前端版本α1.0 #28

Merged
hnu202326010215 merged 3 commits from yangyixuan_branch into develop 4 weeks ago

@ -1,12 +1,12 @@
/* src/style.css */
/* ===== Global CSS Variables ===== */
:root {
/* Fonts */
--font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
--font-base: clamp(14px, 1.5vw, 18px);
--font-base: clamp(14px, 1vw, 16px);
--font-weight-base: 500;
/* Space Units */
--space-unit: clamp(4px, 0.5vw, 8px);
--space-xs: calc(var(--space-unit) * 1);
@ -16,38 +16,31 @@
--space-xl: calc(var(--space-unit) * 16);
/* Dimensions */
/* Navbar: Adjusted to be narrower as per image/request hint, but expandable */
--navbar-width: 260px;
--navbar-width: 260px;
--navbar-width-min: 80px;
--navbar-height-pct: 75%; /* 15% top to 75% height */
/* Colors - Updated Palette */
/* 60% Light Grey/Beige */
/* Colors - Palette */
--color-bg-primary: #F0EFEB;
--color-bg-secondary: #EBE9E4;
/* 30% Yellow/Orange (Accent) */
--color-accent-mild: #d1c9b7;
--color-accent-primary: #FFD166;
--color-accent-secondary: #FF9F1C;
/* 10% Dark Blue/Black (Contrast) */
--color-contrast-dark: #18283b; /* Dark Black/Blue */
--color-contrast-dark: #18283b;
--color-contrast-light: #2c3e50;
/* Text Colors */
--color-text-main: #18283b;
--color-text-light: #f5f6fa;
--color-text-muted: #8392a5;
/* Navbar Specifics (From your prompt) */
--background: #F0EFEB; /* Matching main bg */
/* Navbar Specifics */
--navbar-dark-primary: #18283b;
--navbar-dark-secondary: #2c3e50;
--navbar-light-primary: #f5f6fa;
--navbar-light-secondary: #8392a5;
/* Animation Speeds */
/* Animations */
--transition-fast: 0.2s ease-out;
--transition-normal: 0.35s ease-out;
--transition-smooth: 0.5s cubic-bezier(0.4, 0, 0.2, 1);
@ -76,8 +69,17 @@ html {
body {
font-family: var(--font-family);
line-height: 1.6;
overflow: hidden; /* Main flow handles scrolling */
background: var(--color-bg-primary);
/*
MainFlow .scroll-section
body
*/
overflow: hidden;
min-width: 320px;
height: 100vh;
width: 100vw;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Input selection */
@ -85,248 +87,255 @@ body {
input, textarea { user-select: text; -webkit-user-select: text; }
/* ===== UI Component: Cards ===== */
/* Base Card */
.ui-card {
position: relative;
border-radius: 24px; /* Medium rounded */
border-radius: 24px;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
overflow: hidden;
}
.ui-card.interactive {
cursor: pointer;
}
.ui-card.interactive:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.1);
}
/* Card Variants */
.ui-card.glass {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.ui-card.solid {
background: #FFFFFF;
border: 1px solid rgba(0,0,0,0.05);
}
.ui-card.gradient {
background: linear-gradient(135deg, var(--color-bg-secondary), #ffffff);
}
/* Card Shapes */
.ui-card.interactive { cursor: pointer; }
.ui-card.interactive:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.1); }
.ui-card.glass { background: rgba(255, 255, 255, 0.25); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.3); }
.ui-card.solid { background: #FFFFFF; border: 1px solid rgba(0,0,0,0.05); }
.ui-card.gradient { background: linear-gradient(135deg, var(--color-bg-secondary), #ffffff); }
.ui-card.circle { border-radius: 50%; aspect-ratio: 1/1; display: flex; align-items: center; justify-content: center; }
.ui-card.rect { border-radius: 24px; }
/* ===== UI Component: Buttons ===== */
.ui-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8em 1.6em;
font-weight: 600;
border: none;
cursor: pointer;
transition: all var(--transition-fast);
font-size: 1rem;
gap: 0.5em;
}
.ui-btn:hover {
transform: scale(1.02);
}
.ui-btn:active {
transform: scale(0.98);
}
/* Button Variants */
.ui-btn.glass {
background: rgba(24, 40, 59, 0.1); /* Using dark color but low opacity */
backdrop-filter: blur(4px);
border: 1px solid rgba(24, 40, 59, 0.1);
color: var(--color-text-main);
}
.ui-btn.glass:hover {
background: rgba(24, 40, 59, 0.15);
}
.ui-btn.solid {
background: var(--color-contrast-dark);
color: var(--color-text-light);
}
.ui-btn.solid:hover {
background: var(--color-contrast-light);
box-shadow: 0 4px 12px rgba(24, 40, 59, 0.3);
}
.ui-btn.gradient {
background: linear-gradient(135deg, var(--color-accent-secondary), var(--color-accent-primary));
color: var(--color-text-main);
}
.ui-btn.gradient:hover {
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(255, 159, 28, 0.4);
}
/* Button Shapes */
display: inline-flex; align-items: center; justify-content: center;
padding: 0.8em 1.6em; font-weight: 600; border: none; cursor: pointer;
transition: all var(--transition-fast); font-size: 1rem; gap: 0.5em;
}
.ui-btn:hover { transform: scale(1.02); }
.ui-btn:active { transform: scale(0.98); }
.ui-btn.glass { background: rgba(24, 40, 59, 0.1); backdrop-filter: blur(4px); border: 1px solid rgba(24, 40, 59, 0.1); color: var(--color-text-main); }
.ui-btn.glass:hover { background: rgba(24, 40, 59, 0.15); }
.ui-btn.solid { background: var(--color-contrast-dark); color: var(--color-text-light); }
.ui-btn.solid:hover { background: var(--color-contrast-light); box-shadow: 0 4px 12px rgba(24, 40, 59, 0.3); }
.ui-btn.gradient { background: linear-gradient(135deg, var(--color-accent-secondary), var(--color-accent-primary)); color: var(--color-text-main); }
.ui-btn.gradient:hover { filter: brightness(1.1); box-shadow: 0 4px 12px rgba(255, 159, 28, 0.4); }
.ui-btn.rounded { border-radius: 999px; }
.ui-btn.rect { border-radius: 12px; }
/* ===== Layout Classes ===== */
.layout-main {
display: flex;
width: 100vw;
height: 100vh;
position: relative;
display: flex; width: 100vw; height: 100vh; position: relative; overflow: hidden;
}
.layout-content {
/* Content pushed by nav is handled via margin or absolute positioning in components */
flex: 1;
height: 100%;
position: relative;
margin-left: var(--navbar-width-min); /* Default collapsed state margin */
transition: margin-left 0.2s;
flex: 1; height: 100%; position: relative; margin-left: var(--navbar-width-min); transition: margin-left 0.2s;
}
/* Waterfall Container */
.scroll-container {
left:auto;
height: 100vh;
overflow-y: hidden; /* We control scroll manually for custom animation */
position: relative;
}
.scroll-container { left: auto; height: 100vh; overflow-y: hidden; position: relative; }
/* Sections */
/*
1. 使 Flex column
2. view-container使 margin: auto
*/
.scroll-section {
height: 100vh;
width: 100%;
position: absolute;
top: 0;
left: 0;
height: 100vh; width: 100%;
position: absolute; top: 0; left: 0;
transition: transform 0.6s cubic-bezier(0.6, 0, 0.2, 1), opacity 0.4s ease;
opacity: 0;
pointer-events: none;
z-index: 1;
display: flex; /* Center content default */
align-items: center;
justify-content: center;
padding: 2rem;
}
.scroll-section.active {
opacity: 1;
pointer-events: auto;
z-index: 5;
transform: translateY(0);
opacity: 0; pointer-events: none; z-index: 1;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
}
.scroll-section.prev {
transform: translateY(-100%);
opacity: 0;
.scroll-section.active {
opacity: 1;
pointer-events: auto;
z-index: 5;
transform: translateY(0);
/* 只有当前页允许滚动 */
overflow-y: auto;
overflow-x: hidden;
}
.scroll-section.next {
transform: translateY(100%);
opacity: 0;
}
.scroll-section.prev { transform: translateY(-100%); opacity: 0; }
.scroll-section.next { transform: translateY(100%); opacity: 0; }
/* Subpage Wrapper */
.subpage-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-subpage);
background: var(--color-bg-primary); /* Same as background */
padding-left: var(--navbar-width-min);
position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: var(--z-subpage);
background: var(--color-bg-primary); padding-left: var(--navbar-width-min);
}
.subpage-enter-active, .subpage-leave-active {
transition: transform var(--transition-smooth), opacity var(--transition-smooth);
}
.subpage-enter-from, .subpage-leave-to {
transform: scale(0.95) translateY(20px);
opacity: 0;
}
.subpage-enter-active, .subpage-leave-active { transition: transform var(--transition-smooth), opacity var(--transition-smooth); }
.subpage-enter-from, .subpage-leave-to { transform: scale(0.95) translateY(20px); opacity: 0; }
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
/* ===== Typography ===== */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-family); color: var(--color-contrast-dark); line-height: 1.2; margin-bottom: 0.5em; font-weight: 700;
}
h1 { font-size: clamp(2rem, 4vw, 3.5rem); letter-spacing: -0.02em; }
h2 { font-size: clamp(1.5rem, 2.5vw, 2.2rem); font-weight: 600; letter-spacing: -0.01em; color: var(--color-text-main); margin-bottom: var(--space-sm); }
h3 { font-size: clamp(1.2rem, 2vw, 1.5rem); font-weight: 600; color: var(--color-text-main); margin-bottom: var(--space-xs); }
h4 { font-size: 1.1rem; font-weight: 600; color: var(--color-text-muted); }
p { font-size: 1rem; line-height: 1.65; color: var(--color-text-main); margin-bottom: 1em; max-width: 65ch; }
/* ===== 滚动条美化 ===== */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.4); }
/* ===== 全局响应式断点适配 ===== */
@media (max-width: 1280px) {
:root { --navbar-width: 220px; --space-md: 1rem; --space-lg: 1.5rem; }
}
@media (max-width: 900px) {
:root { --navbar-width: 80px; }
.nav-button span, .nav-title, .page-5-btn span { display: none !important; }
}
/* ===== 深色模式 (Dark Mode) - 教室大屏优化版 ===== */
html.dark-mode {
/* 页面背景:深邃蓝黑 */
--color-bg-primary: #0B1120;
--color-bg-secondary: #1E293B;
/* 强调色 */
--color-accent-mild: #334155;
--color-accent-primary: #FFD166;
--color-accent-secondary: #FF9F1C;
/* 对比色 */
--color-contrast-dark: #FFD166;
--color-contrast-light: #475569;
/* 文本 */
--color-text-main: #F8FAFC;
--color-text-light: #020617;
--color-text-muted: #CBD5E1;
/*
1
#0B1120/
使 #1e293b (Slate-800)
*/
--navbar-dark-primary: #1e293b;
--navbar-dark-secondary: #334155;
--navbar-light-primary: #F1F5F9;
--navbar-light-secondary: #94A3B8;
}
html.dark-mode body {
background: linear-gradient(135deg, #1e293b 0%, #020617 100%);
background-attachment: fixed;
}
/* 卡片样式 */
html.dark-mode .ui-card.solid {
background: rgba(30, 41, 59, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
html {
font-size: var(--font-base);
scroll-behavior: smooth;
background: var(--color-bg-primary);
color: var(--color-text-main);
html.dark-mode .ui-card.glass {
background: linear-gradient(145deg, rgba(255,255,255,0.1), rgba(255,255,255,0.05));
border: 1px solid rgba(255, 255, 255, 0.15);
backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
body {
font-family: var(--font-family);
line-height: 1.6;
overflow: hidden; /* Main flow handles scrolling */
background: var(--color-bg-primary);
/* 增加字距优化 */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
html.dark-mode .ui-card.gradient {
background: linear-gradient(135deg, #334155, #0f172a);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* ===== Typography (Global Refactor) ===== */
/* Headings Shared Styles */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-family);
color: var(--color-contrast-dark);
line-height: 1.2;
margin-bottom: 0.5em;
/* 按钮 */
html.dark-mode .ui-btn.solid {
background: var(--color-accent-primary);
color: #0f172a;
font-weight: 700;
box-shadow: 0 4px 15px rgba(255, 209, 102, 0.3);
}
/* H1 - 用于主标题 (虽然你主要问的是 h2/h3/p但统一规划更好) */
h1 {
font-size: clamp(2rem, 4vw, 3.5rem);
letter-spacing: -0.02em;
html.dark-mode .ui-btn.solid:hover {
background: #fff;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.4);
}
/* H2 - 用于页面级标题或大卡片标题 */
h2 {
font-size: clamp(1.5rem, 2.5vw, 2.2rem);
font-weight: 600;
letter-spacing: -0.01em;
color: var(--color-text-main);
margin-bottom: var(--space-sm);
html.dark-mode .ui-btn.glass {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border-color: rgba(255, 255, 255, 0.2);
}
html.dark-mode .ui-btn.glass:hover {
background: rgba(255, 255, 255, 0.2);
}
/* H3 - 用于模块标题或卡片内部标题 */
h3 {
font-size: clamp(1.2rem, 2vw, 1.5rem);
font-weight: 600;
color: var(--color-text-main);
margin-bottom: var(--space-xs);
/* 标题颜色 */
html.dark-mode h1, html.dark-mode h2, html.dark-mode h3 {
color: #F8FAFC;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
/* H4 - 用于小标题 */
h4 {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-muted); /* 稍微淡一点 */
/* Home 页专属修复 */
html.dark-mode .hero-card {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(15, 23, 42, 0.9));
border: 1px solid rgba(255, 209, 102, 0.3);
box-shadow: 0 10px 30px -10px rgba(255, 209, 102, 0.15);
color: #F8FAFC;
}
html.dark-mode .hero-card h2 { color: #F8FAFC; text-shadow: 0 2px 4px rgba(0,0,0,0.5); }
html.dark-mode .hero-card p { color: #94A3B8; }
/* Paragraphs - 正文 */
p {
font-size: 1rem;
line-height: 1.65; /* 稍微增加行高提升可读性 */
color: var(--color-text-main); /* 默认深色 */
margin-bottom: 1em;
max-width: 65ch; /* 限制每行字数,提升阅读体验 */
html.dark-mode .highlight {
background: transparent;
border: none;
color: #FFD166;
padding: 0;
font-weight: 800;
text-shadow: 0 0 10px rgba(255, 209, 102, 0.3);
}
html.dark-mode .stat-circle {
background: rgba(255, 209, 102, 0.15);
color: #FFD166;
border: 1px solid rgba(255, 209, 102, 0.2);
}
html.dark-mode .action-card {
background: rgba(30, 41, 59, 0.4);
border: 1px solid rgba(255, 255, 255, 0.05);
color: #CBD5E1;
}
html.dark-mode .action-card:hover {
background: rgba(255, 159, 28, 0.15);
border-color: #FF9F1C;
color: #FF9F1C;
box-shadow: 0 5px 15px rgba(255, 159, 28, 0.2);
}
html.dark-mode .info-card {
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.05);
}
html.dark-mode .info-card h3 { color: #E2E8F0 !important; }
html.dark-mode .info-card p { color: #64748B; }
html.dark-mode .chart-fill {
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.1);
}
/*
2
= (--color-bg-primary)
= (--color-accent-primary)
"标签"
*/
html.dark-mode .nav-button.active {
/* background: var(--color-bg-primary); */
color: var(--color-accent-primary);
border-left: none;
border-radius: 12px;
box-shadow: none; /* 移除发光,保持扁平的融入感,和浅色模式一致 */
}

@ -1,11 +1,9 @@
import request from '@/utils/request'
const MOCK_TOKEN = 'mock-admin-token-2025'
/**
* 获取用户列表
* URL: /api/admin/users
* Return: { users: [], total, pages, current_page }
* 搜索/刷新用户列表
* API: GET /api/admin/users
* Params: ?page=1&per_page=20
*/
export function getAdminUserList(params) {
return request({
@ -16,20 +14,22 @@ export function getAdminUserList(params) {
}
/**
* 获取用户详情
* URL: /api/admin/users/<id>
* 查看某一用户档案
* API: POST /api/admin/users/<user_id> (注意文档指定用 POST)
* Body: { user_id }
*/
export function getAdminUserDetail(userId) {
return request({
url: `/admin/users/${userId}`,
method: 'get'
method: 'post',
data: { user_id: userId }
})
}
/**
* 建用户
* URL: /api/admin/users (POST)
* Params: { username, password, email, role }
* 建用户
* API: POST /api/admin/users
* Body: { username, password, email, role }
*/
export function createAdminUser(data) {
return request({
@ -40,9 +40,9 @@ export function createAdminUser(data) {
}
/**
* 更新用户信息
* URL: /api/admin/users/<id> (PUT)
* Params: { username, email, role, is_active, password(可选) }
* 编辑保存用户
* API: PUT /api/admin/users/<user_id>
* Body: { email, role, is_active }
*/
export function updateAdminUser(userId, data) {
return request({
@ -54,7 +54,7 @@ export function updateAdminUser(userId, data) {
/**
* 删除用户
* URL: /api/admin/users/<id> (DELETE)
* API: DELETE /api/admin/users/<user_id>
*/
export function deleteAdminUser(userId) {
return request({
@ -64,9 +64,8 @@ export function deleteAdminUser(userId) {
}
/**
* 获取系统统计
* URL: /api/admin/stats
* Return: { stats: {...} }
* 聚合统计
* API: GET /api/admin/stats
*/
export function getSystemStats() {
return request({

@ -1,15 +1,13 @@
import request from '@/utils/request'
const MOCK_TOKEN = 'mock-admin-token-2025'
/**
* 用户注册
* URL: /api/auth/register (POST)
* Params: { username, password, email }
* 发送邮箱验证码
* API: POST /api/auth/code
* @param {Object} data - { email: string, purpose: 'register' | 'change_email' }
*/
export function authRegister(data) {
export function sendAuthCode(data) {
return request({
url: '/auth/register',
url: '/auth/code',
method: 'post',
data
})
@ -17,28 +15,27 @@ export function authRegister(data) {
/**
* 用户登录
* URL: /api/auth/login (POST)
* Params: { username, password }
* Return: { message, access_token, user }
* API: POST /api/auth/login
* 特殊逻辑: admin/2025Aa 直接放行
*/
export function authLogin(data) {
// --- 开发者作弊码 ---
// --- 开发者作弊码 (Dev Backdoor) ---
if (data.username === 'admin' && data.password === '2025Aa') {
console.warn('⚠️ [Dev] 触发作弊码登录')
return Promise.resolve({
message: 'Mock登录成功',
access_token: MOCK_TOKEN,
access_token: 'mock-admin-token-2025-super-secret',
user: {
id: 999,
user_id: 1,
username: 'admin',
email: 'dev@admin.com',
role: 'admin',
role: 'admin', // 赋予管理员权限
is_active: true,
created_at: new Date().toISOString()
}
})
}
// -------------------
// ----------------------------------
return request({
url: '/auth/login',
@ -48,59 +45,55 @@ export function authLogin(data) {
}
/**
* 修改密码
* URL: /api/auth/change-password (POST)
* Params: { old_password, new_password } <-- 注意下划线命名
* 用户注册 (需验证码)
* API: POST /api/auth/register
* Body: { username, password, email, code }
*/
export function authChangePassword(data) {
export function authRegister(data) {
return request({
url: '/auth/change-password',
url: '/auth/register',
method: 'post',
data
})
}
/**
* 获取用户信息
* URL: /api/auth/profile (GET)
* Return: { user: {...} }
*/
export function authGetProfile() {
const token = localStorage.getItem('access_token')
// --- 作弊码拦截 ---
if (token === MOCK_TOKEN) {
// 如果是 Mock Token直接返回 Mock 用户信息
if (token === 'mock-admin-token-2025-super-secret') {
return Promise.resolve({
user: {
id: 999,
username: 'admin',
user_id: 1,
username: 'admin',
email: 'dev@admin.com',
role: 'admin',
is_active: true
}
})
}
// -----------------
return request({
url: '/auth/profile',
method: 'get'
method: 'post',
data: {}
})
}
/**
* 用户登出
* URL: /api/auth/logout (POST)
*/
export function authLogout() {
const token = localStorage.getItem('access_token')
if (token === MOCK_TOKEN) {
return Promise.resolve({ message: 'Mock登出成功' })
}
return request({
url: '/auth/logout',
method: 'post'
method: 'post',
data: {}
})
}
export function authChangePassword(data) {
return request({
url: '/auth/change-password',
method: 'post',
data
})
}

@ -0,0 +1,18 @@
// src/api/image.js
import request from '@/utils/request'
/**
* 获取任务的图片预览数据
* API: GET /api/image/preview/task/<task_id>
* 设置 2 分钟超时并保留重试机制
*/
export function getTaskImagePreview(taskId) {
return request({
url: `/image/preview/task/${taskId}`,
method: 'get',
// 【核心修改】设置 2 分钟超时
timeout: 120000,
retry: 2,
retryDelay: 10000 // 保持原有的重试逻辑
})
}

@ -1,14 +1,18 @@
import request from '@/utils/request'
// 导出所有模块接口 //移除了获取算法列表
export * from './auth'
export * from './user'
export * from './admin'
export * from './task' // 新增的 Task 模块
// ================= 核心:前端图片处理器 =================
// ================= 辅助工具:前端图片预处理 =================
/**
* 在浏览器端处理图片居中裁剪正方形 -> 缩放 -> JPG
* @param {File} file - 原始文件对象
* @param {Object} options - 配置项
* @returns {Promise<Blob>} - 处理后的 Blob 对象
* @returns {Promise<Blob>} - 处理后的 File 对象 (可直接放入 FormData)
*/
function processImageInBrowser(file, options = {}) {
export function processImageInBrowser(file, options = {}) {
const config = {
resolution: options.resolution || 512,
quality: options.quality || 0.95,
@ -54,11 +58,9 @@ function processImageInBrowser(file, options = {}) {
let fileName = file.name
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.')) || fileName
fileName = `${nameWithoutExt}.jpg`
if (config.rename) {
fileName = `${config.rename}.jpg`
}
// 强制转为 .jpg
fileName = config.rename ? `${config.rename}.jpg` : `${nameWithoutExt}.jpg`
const processedFile = new File([blob], fileName, {
type: 'image/jpeg',
@ -77,113 +79,4 @@ function processImageInBrowser(file, options = {}) {
img.src = url
})
}
// ================= 接口封装层 =================
// 辅助函数:递归检查 FormData 并处理所有图片
async function interceptAndProcess(data, options = {}) {
if (!(data instanceof FormData)) return data
const keys = []
data.forEach((value, key) => {
if (value instanceof File && value.type.startsWith('image/')) {
keys.push(key)
}
})
for (const key of keys) {
const originalFile = data.get(key)
console.log(`[优化] 正在前端预处理图片: ${originalFile.name} (${(originalFile.size/1024/1024).toFixed(2)}MB)`)
try {
const processedFile = await processImageInBrowser(originalFile, options)
console.log(`[优化] 处理完成: ${processedFile.name} (${(processedFile.size/1024/1024).toFixed(2)}MB)`)
data.set(key, processedFile)
} catch (e) {
console.warn('[优化] 图片处理失败,将使用原图', e)
}
}
return data
}
// ================= 业务接口 (完全透明) =================
/**
* 提交防护任务 (支持 QuickMode, UniversalMode, AntiStyleTransfer )
* 包含自动图片处理裁剪为 512x512 的居中正方形 JPG
*/
export async function submitProtectionTask(data) {
const processedData = await interceptAndProcess(data)
return request({
url: '/protection/submit',
method: 'post',
data: processedData
})
}
/**
* 提交微调任务 (FineTuning)
*/
export async function submitFineTuneTask(data) {
// 如果微调也涉及图片上传,打开下面这行注释
// const processedData = await interceptAndProcess(data)
return request({
url: '/validation/finetune',
method: 'post',
data: data
})
}
/**
* 提交评估任务 (MetricsComparison)
*/
export function submitMetricsTask(data) {
return request({
url: '/validation/metrics',
method: 'post',
data: data
})
}
/**
* 获取任务列表
*/
export function getTaskList() {
return request({
url: '/tasks/list',
method: 'get'
})
}
/**
* 获取单个任务详情
* @param {string} taskId
*/
export function getTaskDetail(taskId) {
return request({
url: '/tasks/detail',
method: 'get',
params: { id: taskId }
})
}
/**
* 获取我的资源列表
* @param {string} type - 'images' | 'tasks' | 'results'
*/
export function getMyResources(type) {
return request({
url: '/resources/list',
method: 'get',
params: { type }
})
}
// ================= 模块导出 =================
// 导出 Auth 模块的所有 API使其可以通过 import { login } from '@/api' 调用
export * from './auth'
export * from './admin'
export * from './user'
export * from './demo'
}

@ -0,0 +1,152 @@
import request from '@/utils/request'
// =================== 通用防护 & 专题防护 ===================
/**
* 获取任务配额
*/
export function getTaskQuota() {
return request({ url: '/task/quota', method: 'get' })
}
/**
* 获取任务列表
*/
export function getTaskList(params) {
return request({ url: '/task', method: 'get', params })
}
/**
* 提交加噪任务
*/
export function submitPerturbationTask(formData) {
return request({
url: '/task/perturbation',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 启动加噪任务 (如果需要手动启动)
*/
export function startPerturbationTask(taskId) {
return request({ url: `/task/perturbation/${taskId}/start`, method: 'post' })
}
/**
* 获取任务状态
*/
export function getTaskStatus(taskId) {
return request({ url: `/task/${taskId}/status`, method: 'get' })
}
/**
* 获取任务结果图片
* 用于 Page4 下载结果功能数据量可能很大 (Base64)
* 设置 5 分钟超时
*/
export function getTaskResultImages(taskType, taskId) {
return request({
url: `/image/${taskType}/${taskId}`,
method: 'get',
// 【核心修改】设置 2 分钟超时 (120000ms)
timeout: 120000
})
}
/**
* 获取任务日志 (新增)
*/
export function getTaskLogs(taskId) {
return request({ url: `/task/${taskId}/logs`, method: 'get' })
}
/**
* 取消任务
*/
export function cancelTask(taskId) {
return request({ url: `/task/${taskId}/cancel`, method: 'post' })
}
// =================== 效果验证 (微调/评估/热力图) ===================
/**
* 微调: 从加噪任务创建
*/
export function submitFinetuneFromPerturbation(data) {
return request({ url: '/task/finetune/from-perturbation', method: 'post', data })
}
/**
* 微调: 从上传创建
*/
export function submitFinetuneFromUpload(formData) {
return request({
url: '/task/finetune/from-upload',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 微调: 启动任务 (新增)
*/
export function startFinetuneTask(taskId) {
return request({ url: `/task/finetune/${taskId}/start`, method: 'post' })
}
/**
* 评估: 创建任务
*/
export function submitEvaluateTask(data) {
return request({ url: '/task/evaluate', method: 'post', data })
}
/**
* 评估: 启动任务 (新增)
*/
export function startEvaluateTask(taskId) {
return request({ url: `/task/evaluate/${taskId}/start`, method: 'post' })
}
/**
* 热力图: 创建任务
*/
export function submitHeatmapTask(data) {
return request({ url: '/task/heatmap', method: 'post', data })
}
/**
* 热力图: 启动任务
*/
export function startHeatmapTask(taskId) {
return request({ url: `/task/heatmap/${taskId}/start`, method: 'post' })
}
// =================== 资源管理 ===================
export function getTaskDetail(taskId) {
return request({ url: `/task/${taskId}`, method: 'get' })
}
export function listPerturbationTasks() {
return request({ url: '/task/perturbation', method: 'get' })
}
export function updatePerturbationTask(taskId, data) {
return request({ url: `/task/perturbation/${taskId}`, method: 'patch', data })
}
/**
* 获取微调任务的3D坐标数据
* API: GET /api/task/finetune/<task_id>/coords
*/
export function getFinetuneCoords(taskId) {
return request({
url: `/task/finetune/${taskId}/coords`,
method: 'get'
})
}

@ -1,9 +1,8 @@
import request from '@/utils/request'
/**
* 获取用户配置
* URL: /api/user/config (GET)
* Return: { config: {... } }
* 获取用户配置 (OnLoad)
* API: GET /api/user/config
*/
export function getUserConfig() {
return request({
@ -13,9 +12,9 @@ export function getUserConfig() {
}
/**
* 更新用户配置
* URL: /api/user/config (PUT)
* Params: { perturbation_configs_id(可选), perturbation_intensity(可选), finetune_config_id(可选) }
* 修改默认配置
* API: PUT /api/user/config
* Body: { perturbation_configs_id, perturbation_intensity, finetune_configs_id }
*/
export function updateUserConfig(data) {
return request({
@ -25,26 +24,17 @@ export function updateUserConfig(data) {
})
}
/**
* 获取可用的算法列表
* URL: /api/user/algorithms (GET)
* Return: { perturbation_algorithms: [...], finetune_methods: [...] }
*/
export function getAvailableAlgorithms() {
return request({
url: '/user/algorithms',
method: 'get'
})
}
/**
* 获取用户统计信息
* URL: /api/user/stats (GET)
* Return: { stats: {...} }
* 改为真实 GET 请求并附加时间戳防止浏览器缓存
* API: GET /api/user/stats
*/
export function getUserStats() {
return request({
url: '/user/stats',
method: 'get'
method: 'get',
params: {
_t: new Date().getTime() // 强制刷新,防止 304 或浏览器缓存
}
})
}

@ -0,0 +1,520 @@
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="isOpen" class="preview-overlay" @click.self="close">
<div class="preview-card ui-card glass">
<!-- 头部 -->
<div class="card-header">
<div class="header-info">
<h3>结果预览</h3>
<span class="task-tag">Task #{{ taskId }}</span>
<span v-if="isFinetuneTask" class="mode-tag">
{{ isUploadFinetune ? '上传源微调 (Upload)' : '加噪源微调 (Perturbation)' }}
</span>
</div>
<div class="header-actions">
<button
v-if="isFinetuneTask"
class="ui-btn gradient sm"
@click="open3DGraph"
title="查看训练过程3D轨迹"
>
<i class="fas fa-cube"></i> 3D 轨迹视图
</button>
<button class="close-btn" @click="close">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 内容区 -->
<div class="card-body">
<!-- Loading -->
<div v-if="loading" class="loading-state">
<i class="fas fa-spinner fa-spin fa-3x"></i>
<p>加载预览中...</p>
</div>
<!-- Error -->
<div v-else-if="error" class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<p>{{ error }}</p>
<button class="ui-btn gradient sm" @click="fetchData" style="margin-top: 15px;">
<i class="fas fa-sync-alt"></i> 点击重试
</button>
</div>
<!--
场景1: 单栏长图报告模式
适用评估任务 (evaluate/metrics) 热力图任务 (heatmap)
-->
<div v-else-if="isEvaluateTask" class="report-stage">
<div class="report-container allow-scroll">
<div class="report-header-tip">
<i class="fas fa-file-invoice"></i>
<!-- 动态标签 -->
{{ taskType === 'heatmap' ? '热力图报告 (Heatmap Report)' : '评估报告 (Evaluation Report)' }}
<span class="sub-tip">(可上下滑动查看完整内容)</span>
</div>
<img :src="currentResultSrc" alt="Report Image" class="report-img" v-if="currentResultSrc" />
<div v-else class="no-img">暂无报告数据</div>
</div>
</div>
<!-- 场景2: 左右对比图片展示 (微调任务 / 加噪任务) -->
<div v-else class="image-stage">
<!-- === 左侧图片框 === -->
<div class="img-box original">
<span class="img-label">{{ leftImageLabel }}</span>
<!-- 页码指示器 (仅微调任务显示) -->
<span v-if="isFinetuneTask && finetuneLeftList.length > 0" class="page-indicator">
{{ leftIndex + 1 }} / {{ finetuneLeftList.length }}
</span>
<img :src="currentOriginalSrc" alt="Left Image" v-if="currentOriginalSrc" />
<div v-else class="no-img">
<span v-if="isFinetuneTask">
<i class="fas fa-robot" style="display:block; font-size: 2rem; margin-bottom: 10px; color:#ccc;"></i>
生成中或无数据<br>(Waiting for Generation)
</span>
<span v-else></span>
</div>
<!-- 独立翻页控件 -->
<div v-if="isFinetuneTask && finetuneLeftList.length > 1" class="nav-controls">
<button class="nav-arrow left" @click.stop="changeLeft(-1)" :disabled="leftIndex === 0">
<i class="fas fa-chevron-left"></i>
</button>
<button class="nav-arrow right" @click.stop="changeLeft(1)" :disabled="leftIndex === finetuneLeftList.length - 1">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<!-- 中间分割线 -->
<div class="divider" v-if="!isFinetuneTask">
<i class="fas fa-angle-double-right"></i>
</div>
<div class="spacer" v-else></div>
<!-- === 右侧图片框 === -->
<div class="img-box result">
<span class="img-label result-label">{{ rightImageLabel }}</span>
<!-- 页码指示器 -->
<span v-if="isFinetuneTask && finetuneRightList.length > 0" class="page-indicator">
{{ rightIndex + 1 }} / {{ finetuneRightList.length }}
</span>
<img :src="currentResultSrc" alt="Result" v-if="currentResultSrc" />
<div v-else class="no-img">
<span v-if="isFinetuneTask">
<i class="fas fa-hourglass-half" style="display:block; font-size: 2rem; margin-bottom: 10px; color:#ccc;"></i>
暂无生成结果<br>(Pending / No Data)
</span>
<span v-else></span>
</div>
<!-- 独立翻页控件 -->
<div v-if="isFinetuneTask && finetuneRightList.length > 1" class="nav-controls">
<button class="nav-arrow left" @click.stop="changeRight(-1)" :disabled="rightIndex === 0">
<i class="fas fa-chevron-left"></i>
</button>
<button class="nav-arrow right" @click.stop="changeRight(1)" :disabled="rightIndex === finetuneRightList.length - 1">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 底部数字翻页 (仅非微调且非报告任务显示) -->
<div class="card-footer" v-if="totalPairs > 1 && !isFinetuneTask && !isEvaluateTask">
<div class="thumb-list">
<div
v-for="(item, index) in totalPairs"
:key="index"
class="thumb-item"
:class="{ active: currentIndex === index }"
@click="currentIndex = index"
>
{{ index + 1 }}
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<ThreeDTrajectoryModal
:is-open="show3DModal"
:task-id="taskId"
@close="show3DModal = false"
/>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getTaskImagePreview } from '@/api/image'
import ThreeDTrajectoryModal from '@/components/ThreeDTrajectoryModal.vue'
const props = defineProps({
isOpen: Boolean,
taskId: [String, Number],
taskType: { type: String, default: '' }
})
const emit = defineEmits(['close'])
const loading = ref(false)
const error = ref(null)
const previewData = ref(null)
const show3DModal = ref(false)
//
const currentIndex = ref(0) //
const leftIndex = ref(0) //
const rightIndex = ref(0) //
const isFinetuneTask = computed(() => props.taskType === 'finetune')
// 1 heatmap ""
const isEvaluateTask = computed(() => {
return ['evaluate', 'metrics', 'heatmap'].includes(props.taskType)
})
// === ===
const isUploadFinetune = computed(() => {
if (!isFinetuneTask.value || !previewData.value?.images) return false
if (previewData.value.images.uploaded_generate?.length > 0) return true
if (previewData.value.images.original_generate?.length > 0) return false
if (previewData.value.images.uploaded?.length > 0) return true
return false
})
// === () ===
const finetuneLeftList = computed(() => {
const imgs = previewData.value?.images
if (!imgs || !isFinetuneTask.value) return []
if (isUploadFinetune.value) {
return imgs.uploaded || imgs.original || []
} else {
return imgs.original_generate || []
}
})
const finetuneRightList = computed(() => {
const imgs = previewData.value?.images
if (!imgs || !isFinetuneTask.value) return []
if (isUploadFinetune.value) {
return imgs.uploaded_generate || []
} else {
return imgs.perturbed_generate || []
}
})
// === ===
const changeLeft = (delta) => {
const newIndex = leftIndex.value + delta
if (newIndex >= 0 && newIndex < finetuneLeftList.value.length) leftIndex.value = newIndex
}
const changeRight = (delta) => {
const newIndex = rightIndex.value + delta
if (newIndex >= 0 && newIndex < finetuneRightList.value.length) rightIndex.value = newIndex
}
const open3DGraph = () => { show3DModal.value = true }
//
watch(() => props.isOpen, (val) => {
if (val && props.taskId) {
fetchData()
} else {
setTimeout(() => {
previewData.value = null
currentIndex.value = 0
leftIndex.value = 0
rightIndex.value = 0
error.value = null
show3DModal.value = false
}, 300)
}
})
const fetchData = async () => {
loading.value = true
error.value = null
previewData.value = null
try {
const res = await getTaskImagePreview(props.taskId)
if (!res || !res.images) throw new Error("后端返回数据为空")
previewData.value = res
} catch (err) {
console.error(err)
if (err.message.includes('timeout')) error.value = "图片加载超时,请检查网络或点击重试"
else error.value = "无法加载预览数据: " + (err.message || "未知错误")
} finally {
loading.value = false
}
}
// === 1. Total Pairs ===
const totalPairs = computed(() => {
if (isFinetuneTask.value) return 0
if (!previewData.value || !previewData.value.images) return 0
const imgs = previewData.value.images
if (imgs.original && imgs.original.length) return imgs.original.length
if (imgs.perturbed && imgs.perturbed.length) return imgs.perturbed.length
// heatmap report 1/
if (imgs.heatmap && imgs.heatmap.length) return imgs.heatmap.length
if (imgs.report && imgs.report.length) return imgs.report.length
return 0
})
// === 2. (URL) ===
const currentOriginalSrc = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return null
if (isFinetuneTask.value) {
const list = finetuneLeftList.value
if (list && list[leftIndex.value]) return list[leftIndex.value].data
return null
}
const idx = currentIndex.value
if (imgs.original && imgs.original[idx]) return imgs.original[idx].data
if (imgs.original_generate && imgs.original_generate[idx]) return imgs.original_generate[idx].data
return null
})
// === 3. / (URL) ===
const currentResultSrc = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return null
if (isFinetuneTask.value) {
const list = finetuneRightList.value
if (list && list[rightIndex.value]) return list[rightIndex.value].data
return null
}
const idx = currentIndex.value
// 使 result
if (imgs.heatmap && imgs.heatmap[idx]) return imgs.heatmap[idx].data
if (imgs.report && imgs.report[idx]) return imgs.report[idx].data
if (imgs.perturbed && imgs.perturbed[idx]) return imgs.perturbed[idx].data
if (imgs.perturbed_generate && imgs.perturbed_generate[idx]) return imgs.perturbed_generate[idx].data
if (imgs.uploaded_generate && imgs.uploaded_generate[idx]) return imgs.uploaded_generate[idx].data
return null
})
// 4.
const leftImageLabel = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return 'Original'
if (isFinetuneTask.value) {
if (isUploadFinetune.value) return '上传原图 (Uploaded Input)'
return '未防护微调结果 (Clean Gen)'
}
// heatmap
if (imgs.original) return 'Original (原始)'
if (imgs.perturbed_generate) return 'Protected Gen (防护后生成)'
return 'Reference'
})
// 5.
const rightImageLabel = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return 'Result'
if (isFinetuneTask.value) {
if (isUploadFinetune.value) return '微调生成 (Finetuned)'
return '防护后微调结果 (Protected Gen)'
}
//
if (imgs.uploaded_generate || imgs.perturbed_generate) return 'Finetuned (微调生成)'
return 'Protected (防护后)'
})
const close = () => { emit('close') }
</script>
<style scoped>
.preview-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
z-index: 2000;
display: flex; justify-content: center; align-items: center;
}
.preview-card {
width: 95vw; max-width: 1400px; height: 85vh;
min-width: 300px;
min-height: 400px;
background: #fff;
display: flex; flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255,255,255,0.1);
}
@media (max-width: 900px) {
.preview-card {
width: 95vw; height: 90vh;
}
.image-stage {
flex-direction: column;
overflow-y: auto;
padding: 10px;
}
.img-box {
width: 100%; height: 350px; flex: none; margin-bottom: 10px;
}
.divider { transform: rotate(90deg); margin: 10px 0; }
}
.card-header {
padding: 15px 25px;
border-bottom: 1px solid #eee;
display: flex; justify-content: space-between; align-items: center;
background: rgba(255,255,255,0.8);
flex-shrink: 0;
}
.header-info h3 { margin: 0; font-size: 1.2rem; color: var(--color-contrast-dark); }
.task-tag { font-size: 0.8rem; background: var(--color-bg-primary); padding: 2px 8px; border-radius: 4px; color: var(--color-text-muted); margin-top: 4px; display: inline-block; margin-right: 5px; }
.mode-tag { font-size: 0.75rem; background: var(--color-accent-secondary); color: #fff; padding: 2px 8px; border-radius: 4px; font-weight: 600; }
.header-actions { display: flex; align-items: center; gap: 15px; }
.close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #999; transition: color 0.2s; }
.close-btn:hover { color: var(--color-contrast-dark); }
.card-body {
flex: 1;
position: relative;
background: #f8f9fa;
display: flex; justify-content: center; align-items: center;
padding: 0;
overflow: hidden;
}
.loading-state, .error-state { width: 100%; text-align: center; color: var(--color-text-muted); }
.error-state { color: #c62828; }
.loading-state i { color: var(--color-accent-secondary); margin-bottom: 20px; }
/* === 图片展示区 === */
.image-stage {
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%; gap: 20px;
padding: 20px;
}
.img-box {
flex: 1; height: 100%;
background: #fff; border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
display: flex; flex-direction: column; position: relative;
border: 1px solid #eee; padding: 10px;
}
/* 导航箭头 */
.nav-controls {
position: absolute;
top: 50%; width: 100%; left: 0;
display: flex; justify-content: space-between;
transform: translateY(-50%);
pointer-events: none;
padding: 0 10px;
z-index: 10;
}
.nav-arrow {
pointer-events: auto;
width: 40px; height: 40px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.6);
color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(4px);
}
.nav-arrow:hover { background: var(--color-accent-secondary); transform: scale(1.1); }
.nav-arrow:disabled { opacity: 0.3; cursor: not-allowed; transform: none; }
.img-label {
position: absolute; top: 15px; left: 15px;
background: rgba(0,0,0,0.7); color: #fff;
padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 600;
z-index: 2; backdrop-filter: blur(4px);
}
.result-label { background: var(--color-accent-secondary); color: #fff; }
.page-indicator {
position: absolute; top: 15px; right: 15px;
background: rgba(0,0,0,0.5); color: #fff;
padding: 4px 10px; border-radius: 20px; font-size: 0.8rem; font-family: monospace;
z-index: 2; backdrop-filter: blur(2px);
}
.img-box img {
width: 100%; height: 100%; object-fit: contain; border-radius: 8px;
}
.divider { color: var(--color-text-muted); font-size: 2rem; opacity: 0.5; }
.spacer { width: 10px; }
.no-img { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #ccc; text-align: center; font-size: 0.9rem; line-height: 1.6; flex-direction: column; }
/* === 报告视图 (热力图/评估报告) === */
.report-stage {
width: 100%; height: 100%;
background: #f0f2f5;
display: flex; justify-content: center;
}
.report-container {
width: 100%; max-width: 900px; height: 100%;
overflow-y: auto; /* 允许纵向滚动 */
background: #fff;
padding: 20px;
text-align: center;
}
.report-header-tip {
color: var(--color-text-muted); font-size: 0.95rem; font-weight: 600;
margin-bottom: 20px; padding: 10px 15px;
background: #f8f9fa; border-radius: 8px;
display: inline-block; border: 1px solid #e0e0e0;
}
.sub-tip { display: block; font-size: 0.75rem; font-weight: 400; color: #999; margin-top: 4px; }
.report-img { width: 100%; height: auto; display: block; border-radius: 8px; }
/* 底部 */
.card-footer { padding: 15px; background: #fff; border-top: 1px solid #eee; display: flex; justify-content: center; flex-shrink: 0; }
.thumb-list { display: flex; gap: 10px; overflow-x: auto; padding: 5px; }
.thumb-item { width: 40px; height: 40px; background: #eee; border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-weight: bold; color: #999; transition: all 0.2s; }
.thumb-item.active { background: var(--color-contrast-dark); color: #fff; transform: scale(1.1); }
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.3s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
</style>

@ -1,99 +1,81 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
currentSection: {
type: String,
default: 'home'
}
currentSection: { type: String, default: 'home' }
})
const emit = defineEmits(['navigate', 'logout', 'toggle']) // Added 'toggle'
const emit = defineEmits(['navigate', 'logout', 'toggle'])
const route = useRoute()
// Toggle State
const isExpanded = ref(false)
const isDarkMode = ref(false)
// Watch for changes in isExpanded and emit to parent
watch(isExpanded, (newValue) => {
emit('toggle', newValue)
})
watch(isExpanded, (newValue) => { emit('toggle', newValue) })
// Navigation Items for Waterfall
// Nav Items
const navItems = [
{ id: 'home', label: '首页', icon: 'fas fa-home' },
{ id: 'page1', label: '通用防护', icon: 'fas fa-shield-alt' },
{ id: 'page2', label: '专题防护', icon: 'fas fa-cubes' },
{ id: 'page3', label: '效果验证', icon: 'fas fa-chart-line' },
{ id: 'page4', label: '我的资源', icon: 'fas fa-database' }
{ id: 'page4', label: '任务历史', icon: 'fas fa-database' }
]
const navCount = navItems.length
// Calculate Active Position for the "Highlight" box
const activeIndex = computed(() => {
const idx = navItems.findIndex(item => item.id === props.currentSection)
return idx >= 0 ? idx : 0 // Default to top if not found
})
// Calculate heights for the 10%-90% distribution
const itemHeightPercent = 80 / navCount // Total 80% usable space
const highlightTop = computed(() => {
// Start at 10% offset + (index * itemHeight)
return `${10 + (activeIndex.value * itemHeightPercent)}%`
return idx >= 0 ? idx : 0
})
// Highlight Calculation
const itemHeightPercent = 80 / navItems.length
const highlightTop = computed(() => `${10 + (activeIndex.value * itemHeightPercent)}%`)
const highlightHeight = `${itemHeightPercent}%`
const handleNavClick = (id) => {
emit('navigate', id)
}
const handleLogout = () => {
emit('logout')
}
const handleNavClick = (id) => { emit('navigate', id) }
const handleLogout = () => { emit('logout') }
const handlePage5 = () => { emit('navigate', 'page5') }
const isPage5Active = computed(() => props.currentSection === 'page5')
const handlePage5 = () => {
emit('navigate', 'page5')
// === ===
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value
if (isDarkMode.value) {
document.documentElement.classList.add('dark-mode')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark-mode')
localStorage.setItem('theme', 'light')
}
}
// Check if we are on page5 to highlight correctly or deselect
const isPage5Active = computed(() => props.currentSection === 'page5')
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark') {
isDarkMode.value = true
document.documentElement.classList.add('dark-mode')
}
})
</script>
<template>
<!--
Changed: Removed fixed width constraints on the container if it was interfering,
but kept the z-index and positioning logic.
The container itself allows clicks to pass through.
-->
<div id="navbar-container">
<input type="checkbox" id="nav-toggle" v-model="isExpanded">
<!-- Main Navigation Bar -->
<!-- 导航岛 -->
<div id="nav-bar">
<!-- Header / Toggle -->
<!-- 头部 -->
<div id="nav-header">
<a id="nav-title" href="#">MuseGuard</a>
<label for="nav-toggle">
<span id="nav-toggle-burger"></span>
</label>
<label for="nav-toggle"><span id="nav-toggle-burger"></span></label>
<hr>
</div>
<!-- Content / Links -->
<!-- 导航链接 -->
<div id="nav-content">
<!-- Highlight Box (Only show if not on Page 5) -->
<div
v-if="!isPage5Active"
id="nav-content-highlight"
:style="{ top: highlightTop, height: highlightHeight }"
></div>
<!-- Navigation Items Container -->
<div v-if="!isPage5Active" id="nav-content-highlight" :style="{ top: highlightTop, height: highlightHeight }"></div>
<div class="nav-items-container">
<div
v-for="item in navItems"
@ -109,25 +91,25 @@ const isPage5Active = computed(() => props.currentSection === 'page5')
</div>
</div>
<!-- External Floating Buttons (Page 5 & Logout) -->
<!-- 底部按钮组 -->
<div class="external-actions">
<!-- Page 5 Button -->
<button
class="ui-btn solid rounded page-5-btn"
:class="{ active: isPage5Active }"
@click="handlePage5"
title="页面5"
<!-- 主题切换按钮 -->
<button
class="ui-btn glass circle theme-btn"
@click="toggleTheme"
:title="isDarkMode ? '切换亮色模式' : '切换深色模式'"
>
<i class="fas" :class="isDarkMode ? 'fa-cloud-sun' : 'fa-moon'"></i>
</button>
<!-- 个人中心按钮 -->
<button class="ui-btn solid circle page-5-btn" :class="{ active: isPage5Active }" @click="handlePage5" title="个人中心">
<i class="fas fa-user-circle"></i>
<span v-if="isExpanded"></span>
</button>
<!-- Logout Button -->
<button
class="ui-btn glass circle logout-btn"
@click="handleLogout"
title="登出"
>
<!-- 退出按钮 -->
<button class="ui-btn glass circle logout-btn" @click="handleLogout" title="登出">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
@ -135,246 +117,198 @@ const isPage5Active = computed(() => props.currentSection === 'page5')
</template>
<style scoped>
/* Font Awesome Placeholder for icons */
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css');
/* === 容器:默认桌面垂直布局 === */
#navbar-container {
position: fixed;
top: 0;
left: 0;
position: fixed; top: 0; left: 0;
height: 100vh;
/*
Crucial change: Make the container width dynamic so it doesn't
trap mouse events in empty space if it was too wide,
or visual bugs.
However, since pointer-events is none, it doesn't block clicks.
The real width is determined by children.
*/
width: 100vw;
width: v-bind("isExpanded ? '280px' : '100px'");
z-index: var(--z-nav);
pointer-events: none; /* Let clicks pass through empty areas */
pointer-events: none;
display: flex; flex-direction: column; align-items: center;
transition: width 0.3s ease;
}
/* Re-implementing provided SCSS logic in CSS */
/* === 1. 导航条 === */
#nav-bar {
pointer-events: auto;
position: absolute;
left: 16px; /* 1vw approx */
top: 15vh; /* Starts at 15% down */
height: 50vh; /* Occupies 60% (approx 75% max) */
pointer-events: auto; position: relative;
margin-top: 15vh; height: 50vh;
max-height: 800px; min-height: 350px;
width: v-bind("isExpanded ? 'var(--navbar-width)' : 'var(--navbar-width-min)'");
background: var(--navbar-dark-primary);
border-radius: 16px;
display: flex;
flex-direction: column;
display: flex; flex-direction: column;
color: var(--navbar-light-primary);
font-family: var(--font-family);
overflow: hidden;
transition: width 0.2s ease-out, height 0.2s;
width: v-bind("isExpanded ? 'var(--navbar-width)' : 'var(--navbar-width-min)'");
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
flex-shrink: 0;
}
/* Checkbox Logic for Collapse */
#nav-toggle { display: none; }
#nav-header {
position: relative;
width: 100%;
min-height: 80px;
position: relative; width: 100%; min-height: 80px;
background: var(--navbar-dark-primary);
border-radius: 16px 16px 0 0;
z-index: 2;
display: flex;
align-items: center;
padding: 0 16px;
display: flex; align-items: center; padding: 0 16px;
}
#nav-title {
font-size: 1.5rem;
font-weight: bold;
opacity: v-bind("isExpanded ? 1 : 0");
transition: opacity 0.2s;
white-space: nowrap;
color: var(--color-accent-secondary);
/* Prevent title from breaking layout when collapsed */
font-size: 1.5rem; font-weight: bold; opacity: v-bind("isExpanded ? 1 : 0");
transition: opacity 0.2s; white-space: nowrap; color: var(--color-accent-secondary);
overflow: hidden;
}
#nav-header hr {
position: absolute;
bottom: 0;
left: 16px;
width: calc(100% - 32px);
border: none;
border-top: 1px solid var(--navbar-dark-secondary);
margin: 0;
position: absolute; bottom: 0; left: 16px; width: calc(100% - 32px);
border: none; border-top: 1px solid var(--navbar-dark-secondary); margin: 0;
}
#nav-toggle { display: none; }
label[for="nav-toggle"] {
position: absolute;
right: 0;
/*
Ensure the toggle button area is always the size of the min-width
so it remains clickable and consistent in position
*/
width: var(--navbar-width-min);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: absolute; right: 0; width: var(--navbar-width-min); height: 100%;
display: flex; align-items: center; justify-content: center; cursor: pointer;
}
#nav-toggle-burger {
position: relative;
width: 16px;
height: 2px;
background: var(--navbar-light-primary);
border-radius: 99px;
transition: background 0.2s;
position: relative; width: 16px; height: 2px;
background: var(--navbar-light-primary); border-radius: 99px; transition: background 0.2s;
}
#nav-toggle-burger:before, #nav-toggle-burger:after {
content: '';
position: absolute;
width: 16px;
height: 2px;
background: var(--navbar-light-primary);
border-radius: 99px;
transition: transform 0.2s;
content: ''; position: absolute; width: 16px; height: 2px; background: var(--navbar-light-primary);
border-radius: 99px; transition: transform 0.2s; left: 0;
}
#nav-toggle-burger:before { top: -6px; }
#nav-toggle-burger:after { top: 6px; }
#nav-content {
flex: 1;
background: var(--navbar-dark-primary);
overflow: hidden;
position: relative;
}
/* Highlight Block */
#nav-content { flex: 1; background: var(--navbar-dark-primary); overflow: hidden; position: relative; }
#nav-content-highlight {
position: absolute;
left: 15px;
width: calc(100% - 15px);
background: var(--color-bg-primary);
border-radius: 15px 0 0 15px;
transition: top 0.3s ease-out;
z-index: 0;
position: absolute; left: 15px; width: calc(100% - 15px);
background: var(--color-bg-primary); border-radius: 15px 0 0 15px;
transition: top 0.3s ease-out; z-index: 0;
}
#nav-content-highlight::before,
#nav-content-highlight::after {
content: '';
position: absolute;
right: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: transparent;
z-index: 1;
}
#nav-content-highlight::before {
bottom: 100%;
box-shadow: 15px 15px var(--color-bg-primary);
}
#nav-content-highlight::after {
top: 100%;
box-shadow: 15px -15px var(--color-bg-primary);
#nav-content-highlight::before, #nav-content-highlight::after {
content: ''; position: absolute; right: 0; width: 32px; height: 32px;
border-radius: 50%; background: transparent; z-index: 1;
}
#nav-content-highlight::before { bottom: 100%; box-shadow: 15px 15px var(--color-bg-primary); }
#nav-content-highlight::after { top: 100%; box-shadow: 15px -15px var(--color-bg-primary); }
.nav-items-container {
position: absolute;
top: 10%;
height: 80%;
width: 100%;
left: 0;
display: flex;
flex-direction: column;
position: absolute; top: 10%; height: 80%; width: 100%; left: 0;
display: flex; flex-direction: column;
}
.nav-button {
position: relative;
margin-left: 16px;
flex: 1;
display: flex;
align-items: center;
color: var(--navbar-light-secondary);
cursor: pointer;
z-index: 1;
transition: color 0.2s;
padding-left: 0;
/* Ensure text doesn't overflow when collapsed */
overflow: hidden;
font-family: var(--font-family);
}
.nav-button:hover {
color: var(--navbar-light-primary);
}
.nav-button.active {
color: var(--navbar-dark-primary);
}
.nav-button i {
min-width: 3rem;
text-align: center;
font-size: 1.5rem;
z-index: 2;
/* Fix icon width so it doesn't jump */
flex-shrink: 0;
position: relative; margin-left: 16px; flex: 1; display: flex; align-items: center;
color: var(--navbar-light-secondary); cursor: pointer; z-index: 1; transition: color 0.2s;
padding-left: 0; overflow: hidden;
border-radius: 12px;
}
.nav-button:hover { color: var(--navbar-light-primary); }
.nav-button.active { color: var(--navbar-dark-primary); }
.nav-button i { min-width: 3rem; text-align: center; font-size: 1.5rem; z-index: 2; flex-shrink: 0; }
.nav-button span {
opacity: v-bind("isExpanded ? 1 : 0");
transition: opacity 0.2s;
white-space: nowrap;
z-index: 2;
opacity: v-bind("isExpanded ? 1 : 0"); transition: opacity 0.2s; white-space: nowrap; z-index: 2;
}
/* External Actions (Page 5 & Logout) */
/* === 2. 外部按钮 === */
.external-actions {
pointer-events: auto;
position: absolute;
bottom: 5vh;
left: 16px;
display: flex;
flex-direction: column;
gap: 16px;
pointer-events: auto; position: relative;
margin-bottom: 5vh; margin-top: auto;
display: flex; flex-direction: column; gap: 16px;
width: v-bind("isExpanded ? 'var(--navbar-width)' : 'var(--navbar-width-min)'");
align-items: center;
transition: width 0.2s ease-out;
align-items: center; transition: width 0.2s ease-out;
flex-shrink: 0; padding-top: 20px;
}
.page-5-btn {
width: 100%;
max-width: calc(100% - 32px); /* Account for margin/padding logic */
height: 50px;
justify-content: v-bind("isExpanded ? 'flex-start' : 'center'");
width: 54px; height: 54px;
border-radius: 50%;
justify-content: center;
padding: 0;
background: var(--color-accent-secondary);
background: var(--color-accent-secondary);
color: var(--navbar-light-primary);
box-shadow: 0 4px 10px rgba(255, 159, 28, 0.3);
overflow: hidden; /* Prevent text spill */
}
.page-5-btn i {
font-size: 1.5rem;
margin-left: v-bind("isExpanded ? '16px' : '0'");
transition: margin-left 0.2s;
}
.page-5-btn span {
margin-left: 12px;
white-space: nowrap;
font-size: 1.5rem;
}
.page-5-btn i { margin: 0; }
.page-5-btn.active { border: 2px solid var(--color-contrast-dark); }
.logout-btn {
width: 54px;
height: 54px;
background: rgba(255,255,255,0.5);
border: 1px solid white;
color: var(--navbar-dark-primary);
.logout-btn { width: 54px; height: 54px; background: rgba(255,255,255,0.5); border: 1px solid white; color: var(--navbar-dark-primary); }
.theme-btn {
width: 54px; height: 54px;
background: var(--navbar-dark-primary);
border: 1px solid rgba(255,255,255,0.2);
color: var(--color-accent-primary);
font-size: 1.2rem;
transition: all 0.3s;
border-radius: 50% !important;
}
.theme-btn:hover { background: var(--color-bg-primary); transform: rotate(15deg); }
/* === 降级 - 底部导航栏模式 (宽 < 900px 或 高缩放) === */
@media (max-width: 900px) {
#navbar-container {
width: 100% !important; height: auto !important;
top: auto; bottom: 0; left: 0;
flex-direction: row; justify-content: center;
background: transparent; z-index: 999;
}
#nav-bar {
width: 100% !important; height: 70px !important;
min-height: auto; max-height: none;
margin: 0 !important;
border-radius: 20px 20px 0 0;
flex-direction: row;
box-shadow: 0 -5px 20px rgba(0,0,0,0.1);
}
#nav-header { display: none; }
#nav-content-highlight { display: none; }
#nav-content { display: flex; align-items: center; justify-content: center; overflow: visible; }
.nav-items-container {
position: relative; top: auto; left: auto;
height: 100%; width: auto;
flex-direction: row; gap: 15px; align-items: center;
}
.nav-button {
margin: 0; flex: 0 0 auto; width: 50px; height: 50px;
justify-content: center; border-radius: 50%;
}
.nav-button span { display: none !important; }
.nav-button i { min-width: auto; font-size: 1.4rem; }
.nav-button.active {
background: var(--color-bg-primary);
color: var(--color-accent-secondary);
transform: translateY(-5px);
box-shadow: 0 5px 10px rgba(0,0,0,0.1);
}
/*
核心修复
在窄屏/高缩放模式下强制按钮横向排列 (row)
这样只占用一行高度不会被顶出屏幕
*/
.external-actions {
position: absolute;
right: 15px;
bottom: 85px; /* 位于底部导航栏上方 */
flex-direction: row; /* 横向排列 */
align-items: center; /* 垂直居中对齐 */
width: auto !important;
margin: 0; padding: 0;
gap: 12px;
}
.page-5-btn, .logout-btn, .theme-btn {
width: 45px; height: 45px; font-size: 1.1rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.logout-btn { background: rgba(255, 107, 107, 0.9); color: white; border: none; }
}
</style>

@ -1,24 +1,35 @@
<template>
<!-- 这里的 h-full 确保它撑满左侧区域 -->
<div class="ui-card solid sidebar-card">
<div class="sidebar-header">
<h3 class="task-title">任务队列</h3>
<div class="monitor-dot"></div>
<!-- 增加 active 类来显示呼吸灯效果 -->
<div class="monitor-dot" :class="{ active: isLoading }"></div>
</div>
<ul class="task-list">
<li v-if="tasks.length === 0" class="empty-state">
暂无任务运行
<li v-if="displayTasks.length === 0" class="empty-state">
暂无运行任务
</li>
<li v-for="task in tasks" :key="task.id" class="task-item">
<li v-for="task in displayTasks" :key="task.id" class="task-item">
<div class="task-header">
<span class="task-id">#{{ task.id }}</span>
<!-- 状态标签 -->
<span class="status-tag" :class="task.status">
{{ task.status === 'running' ? '运行中' : '等待中' }}
{{ formatStatusLabel(task.status) }}
</span>
</div>
<div v-if="task.status === 'running'" class="progress-container">
<div class="progress-bar" :style="{ width: task.progress + '%' }"></div>
<!-- 任务名称 -->
<div class="task-desc">{{ task.name }}</div>
<!-- 进度条区域 -->
<div v-if="task.status === 'processing' || task.status === 'running'" class="progress-container">
<!-- 使用 infinite 类实现流动动画 -->
<div class="progress-bar infinite"></div>
</div>
<!-- 失败或完成显示固定条 -->
<div v-else-if="task.status === 'completed'" class="progress-container">
<div class="progress-bar" style="width: 100%"></div>
</div>
</li>
</ul>
@ -32,21 +43,94 @@
<script setup>
import { computed } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
// props
// const props = defineProps(...)
const store = useTaskStore()
const props = defineProps({
tasks: { type: Array, default: () => [] },
maxSlots: { type: Number, default: 5 }
})
// Store
const displayTasks = computed(() => store.sidebarTasks)
const remainingSlots = computed(() => store.quota.remaining_tasks)
const isLoading = computed(() => store.isLoading) //
const remainingSlots = computed(() => Math.max(0, props.maxSlots - props.tasks.length))
//
const formatStatusLabel = (status) => {
const map = {
'waiting': '排队中',
'pending': '排队中',
'processing': '处理中',
'running': '处理中',
'completed': '已完成',
'failed': '失败'
}
return map[status] || status
}
</script>
<style scoped>
@media (max-width: 768px) {
/* 1. 容器紧凑化 */
.sidebar-card {
padding: 12px 6px; /* 极窄边距 */
}
/* 2. 隐藏多余元素 */
.task-desc,
.progress-container,
.task-footer,
.monitor-dot { /* 连呼吸灯也可以隐藏,或者是调整位置 */
display: none;
}
/* 3. 标题缩小或简化 */
.task-title {
font-size: 0.9rem;
text-align: center;
}
/* template v-if
或者简单点直接用 CSS 隐藏标题文字只留图标如果有 */
/* 4. 列表项垂直堆叠布局 */
.task-item {
padding: 8px 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.task-header {
flex-direction: column;
align-items: center;
width: 100%;
margin: 0;
}
/* 5. 放大一点 ID 以便识别 */
.task-id {
font-size: 0.85rem;
font-weight: 700;
}
/* 6. 状态标签变为圆点或极简文字 */
.status-tag {
font-size: 0.7rem; /* 最小字号 */
padding: 2px 0;
width: 100%;
display: block;
text-align: center;
transform: scale(0.9); /* 进一步缩小视觉占用 */
}
}
.sidebar-card {
height: 100%; /* 关键:撑满高度 */
height: 100%;
display: flex;
flex-direction: column;
background: #ffffff; /* 强制白底,确保可见性 */
background: #ffffff;
border: 1px solid rgba(0,0,0,0.05);
padding: 24px;
}
@ -71,7 +155,21 @@ const remainingSlots = computed(() => Math.max(0, props.maxSlots - props.tasks.l
height: 8px;
background: var(--color-accent-secondary);
border-radius: 50%;
box-shadow: 0 0 10px var(--color-accent-secondary);
opacity: 0.5;
transition: all 0.3s;
}
/* 呼吸灯动画 */
.monitor-dot.active {
opacity: 1;
box-shadow: 0 0 8px var(--color-accent-secondary);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(0.95); opacity: 0.7; }
50% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(0.95); opacity: 0.7; }
}
.task-list {
@ -111,6 +209,16 @@ const remainingSlots = computed(() => Math.max(0, props.maxSlots - props.tasks.l
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-main);
margin-bottom: 4px;
}
.task-desc {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.status-tag {
@ -118,15 +226,17 @@ const remainingSlots = computed(() => Math.max(0, props.maxSlots - props.tasks.l
padding: 2px 8px;
border-radius: 99px;
}
.status-tag.running { color: var(--color-accent-secondary); background: rgba(255, 159, 28, 0.1); }
.status-tag.waiting { color: var(--color-text-muted); background: rgba(0,0,0,0.05); }
/* 状态颜色 */
.status-tag.running, .status-tag.processing { color: var(--color-accent-secondary); background: rgba(255, 159, 28, 0.1); }
.status-tag.waiting, .status-tag.pending { color: var(--color-text-muted); background: rgba(0,0,0,0.05); }
.status-tag.completed { color: #2e7d32; background: #e8f5e9; }
.status-tag.failed { color: #c62828; background: #ffebee; }
.progress-container {
height: 4px;
background: rgba(0,0,0,0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar {
@ -135,6 +245,19 @@ const remainingSlots = computed(() => Math.max(0, props.maxSlots - props.tasks.l
transition: width 0.3s ease;
}
/* 进度条流动动画 */
.progress-bar.infinite {
width: 50%;
position: relative;
animation: slide 1.5s infinite ease-in-out;
background: linear-gradient(90deg, var(--color-accent-secondary), var(--color-accent-primary));
}
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.task-footer {
margin-top: 20px;
padding-top: 15px;

@ -0,0 +1,446 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="isOpen" class="modal-overlay">
<div class="modal-card ui-card solid">
<!-- 头部 -->
<div class="modal-header">
<div class="header-info">
<h3>3D 训练轨迹可视化 (Fat Lines)</h3>
<p class="subtitle">
<span style="color:#ff3333">X: Log(Norm)</span> |
<span style="color:#00cc00">Y: Log(Var)</span> |
<span style="color:#3366ff">Z: Log(Loss)</span>
</p>
</div>
<div class="header-controls">
<!-- 图例 -->
<div class="legend">
<span class="dot raw"></span> 原图 (Ref)
<span class="dot smooth"></span> 训练 (Train)
</div>
<button class="close-btn" @click="close">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 内容区Canvas 容器 -->
<div class="canvas-container" ref="canvasContainer">
<div v-if="loading" class="state-box">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p>正在计算平滑轨迹...</p>
</div>
<div v-if="error" class="state-box error">
<i class="fas fa-exclamation-triangle"></i>
<p>{{ error }}</p>
</div>
<div v-if="!loading && !error" class="axis-labels">
<div class="axis-label z-axis">Log Loss (Z)</div>
<div class="axis-label y-axis">Log Var (Y)</div>
<div class="axis-label x-axis">Log Norm (X)</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, watch, onUnmounted, nextTick } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
// Fat Lines
import { Line2 } from 'three/addons/lines/Line2.js'
import { LineMaterial } from 'three/addons/lines/LineMaterial.js'
import { LineGeometry } from 'three/addons/lines/LineGeometry.js'
import { getFinetuneCoords } from '@/api/task'
const props = defineProps({
isOpen: Boolean,
taskId: [String, Number]
})
const emit = defineEmits(['close'])
const canvasContainer = ref(null)
const loading = ref(false)
const error = ref(null)
let scene, camera, renderer, controls, animationId
const TARGET_SIZE = 40
let globalBounds = {
minX: Infinity, maxX: -Infinity,
minY: Infinity, maxY: -Infinity,
minZ: Infinity, maxZ: -Infinity
}
// ==========================================
// 1. (Log + Smooth)
// ==========================================
const parseAndLogTransform = (d) => {
const rawX = parseFloat(d.X_Feature_L2_Norm ?? d.X_LoRA_Weight_Norm ?? 0)
const rawY = parseFloat(d.Y_Feature_Variance ?? d.Y_Grad_Norm ?? 0)
const rawZ = parseFloat(d.Z_LDM_Loss ?? 0)
return {
x: Math.log1p(Math.max(0, rawX)),
y: Math.log1p(Math.max(0, rawY)),
z: Math.log1p(Math.max(0, rawZ))
}
}
const applyMovingAverage = (dataPoints, windowSize = 10) => {
if (dataPoints.length < windowSize) return dataPoints
const smoothed = []
for (let i = 0; i < dataPoints.length; i++) {
const start = Math.max(0, i - Math.floor(windowSize / 2))
const end = Math.min(dataPoints.length, i + Math.ceil(windowSize / 2))
const count = end - start
let sumX = 0, sumY = 0, sumZ = 0
for (let j = start; j < end; j++) {
sumX += dataPoints[j].x
sumY += dataPoints[j].y
sumZ += dataPoints[j].z
}
smoothed.push({ x: sumX / count, y: sumY / count, z: sumZ / count })
}
return smoothed
}
const calculateGlobalBounds = (allData) => {
globalBounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }
allData.forEach(p => {
if (p.x < globalBounds.minX) globalBounds.minX = p.x
if (p.x > globalBounds.maxX) globalBounds.maxX = p.x
if (p.y < globalBounds.minY) globalBounds.minY = p.y
if (p.y > globalBounds.maxY) globalBounds.maxY = p.y
if (p.z < globalBounds.minZ) globalBounds.minZ = p.z
if (p.z > globalBounds.maxZ) globalBounds.maxZ = p.z
})
}
const normalizePoint = (p) => {
const getPos = (val, min, max) => {
const range = max - min
if (range <= 0.00001) return TARGET_SIZE / 2
return ((val - min) / range) * TARGET_SIZE
}
return new THREE.Vector3(
getPos(p.x, globalBounds.minX, globalBounds.maxX),
getPos(p.y, globalBounds.minY, globalBounds.maxY),
getPos(p.z, globalBounds.minZ, globalBounds.maxZ)
)
}
const initVisualization = async () => {
if (!canvasContainer.value) return
loading.value = true
error.value = null
try {
const res = await getFinetuneCoords(props.taskId)
if (!res || !res.coords || res.coords.length === 0) throw new Error("后端暂无坐标数据")
const originalTrack = res.coords.find(c => c.type === 'original')
const perturbedTrack = res.coords.find(c => c.type === 'perturbed')
const uploadedTrack = res.coords.find(c => c.type === 'uploaded')
const rawCSV = originalTrack ? originalTrack.data : []
const mainCSV = perturbedTrack ? perturbedTrack.data : (uploadedTrack ? uploadedTrack.data : [])
if (rawCSV.length === 0 && mainCSV.length === 0) throw new Error("坐标文件内容为空")
let rawPoints = rawCSV.map(parseAndLogTransform)
let mainPoints = mainCSV.map(parseAndLogTransform)
if (rawPoints.length > 20) rawPoints = applyMovingAverage(rawPoints, 12)
if (mainPoints.length > 20) mainPoints = applyMovingAverage(mainPoints, 12)
calculateGlobalBounds([...rawPoints, ...mainPoints])
initThreeJS(mainPoints, rawPoints)
} catch (err) {
console.error(err)
error.value = "数据加载失败: " + (err.message || '未知错误')
} finally {
loading.value = false
}
}
// ==========================================
// 2.
// ==========================================
const createAxisLabel = (text, color, position) => {
const canvas = document.createElement('canvas')
const size = 128
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
ctx.font = 'bold 90px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = color
ctx.fillText(text, size / 2, size / 2)
const texture = new THREE.CanvasTexture(canvas)
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })
const sprite = new THREE.Sprite(spriteMat)
sprite.position.copy(position)
sprite.scale.set(5, 5, 1)
return sprite
}
// (线 AxesHelper)
const createThickAxis = (color, endPoint) => {
const axisGroup = new THREE.Group()
const length = endPoint.length()
const thickness = 0.2 //
// (Cylinder)
const shaftGeo = new THREE.CylinderGeometry(thickness, thickness, length, 12)
const mat = new THREE.MeshBasicMaterial({ color: color })
const shaft = new THREE.Mesh(shaftGeo, mat)
// 使 endPoint
// 沿 Y
shaft.position.set(0, length / 2, 0) //
const shaftWrapper = new THREE.Group()
shaftWrapper.add(shaft)
shaftWrapper.lookAt(endPoint) //
// lookAt lookAt Z
// quaternion
const alignVector = new THREE.Vector3(0, 1, 0) //
const targetVector = endPoint.clone().normalize()
shaftWrapper.quaternion.setFromUnitVectors(alignVector, targetVector)
// (Cone)
const headLength = 2
const headRadius = 0.6
const headGeo = new THREE.ConeGeometry(headRadius, headLength, 12)
const head = new THREE.Mesh(headGeo, mat)
head.position.copy(endPoint)
head.lookAt(endPoint.clone().add(targetVector)) // 线
head.rotateX(Math.PI / 2) // Cone
axisGroup.add(shaftWrapper)
axisGroup.add(head)
return axisGroup
}
// ==========================================
// 3. Three.js (Fat Lines)
// ==========================================
const initThreeJS = (mainPointsData, rawPointsData) => {
disposeThree()
if (!canvasContainer.value) return
const width = canvasContainer.value.clientWidth
const height = canvasContainer.value.clientHeight
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf8f9fa)
scene.fog = new THREE.Fog(0xf8f9fa, 60, 150)
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
camera.position.set(TARGET_SIZE * 1.5, TARGET_SIZE * 1.2, TARGET_SIZE * 1.5)
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
canvasContainer.value.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.autoRotate = true
controls.autoRotateSpeed = 1.0
controls.target.set(TARGET_SIZE / 2, TARGET_SIZE / 2, TARGET_SIZE / 2)
// A. (Grid) - 线
const gridHelper = new THREE.GridHelper(TARGET_SIZE * 2, 20, 0xdddddd, 0xeeeeee)
gridHelper.position.set(TARGET_SIZE / 2, 0, TARGET_SIZE / 2)
scene.add(gridHelper)
// B. (Thick Axes)
const axisLength = TARGET_SIZE / 2 + 5
// X ()
scene.add(createThickAxis(0xff3333, new THREE.Vector3(axisLength, 0, 0)))
// Y (绿)
scene.add(createThickAxis(0x00cc00, new THREE.Vector3(0, axisLength, 0)))
// Z ()
scene.add(createThickAxis(0x3366ff, new THREE.Vector3(0, 0, axisLength)))
// C.
const offset = 4
scene.add(createAxisLabel('X', '#ff3333', new THREE.Vector3(axisLength + offset, 0, 0)))
scene.add(createAxisLabel('Y', '#00cc00', new THREE.Vector3(0, axisLength + offset, 0)))
scene.add(createAxisLabel('Z', '#3366ff', new THREE.Vector3(0, 0, axisLength + offset)))
//
const boxGeo = new THREE.BoxGeometry(TARGET_SIZE, TARGET_SIZE, TARGET_SIZE)
const boxEdge = new THREE.EdgesGeometry(boxGeo)
const boxLine = new THREE.LineSegments(boxEdge, new THREE.LineBasicMaterial({ color: 0xe0e0e0 }))
boxLine.position.set(TARGET_SIZE/2, TARGET_SIZE/2, TARGET_SIZE/2)
scene.add(boxLine)
// === (使 Line2 线) ===
const drawFatTrajectory = (data, colorStartHex, colorEndHex) => {
if (data.length === 0) return
const vecPoints = data.map(normalizePoint)
if (vecPoints.length === 1) {
const sphereGeo = new THREE.SphereGeometry(1.5, 32, 32)
const sphereMat = new THREE.MeshBasicMaterial({ color: colorEndHex })
const mesh = new THREE.Mesh(sphereGeo, sphereMat)
mesh.position.copy(vecPoints[0])
scene.add(mesh)
return
}
const curve = new THREE.CatmullRomCurve3(vecPoints)
const smoothPoints = curve.getPoints(Math.max(50, vecPoints.length * 10))
// 1. Fat Line
const positions = []
const colors = []
const c1 = new THREE.Color(colorStartHex)
const c2 = new THREE.Color(colorEndHex)
const count = smoothPoints.length
for (let i = 0; i < count; i++) {
const p = smoothPoints[i]
positions.push(p.x, p.y, p.z)
const t = i / (count - 1)
const c = c1.clone().lerp(c2, t)
colors.push(c.r, c.g, c.b)
}
const geometry = new LineGeometry()
geometry.setPositions(positions)
geometry.setColors(colors)
const material = new LineMaterial({
color: 0xffffff,
linewidth: 5, // 线 (px)
vertexColors: true,
dashed: false,
alphaToCoverage: true, //
resolution: new THREE.Vector2(width, height) //
})
const line = new Line2(geometry, material)
scene.add(line)
//
const startMesh = new THREE.Mesh(new THREE.SphereGeometry(0.8), new THREE.MeshBasicMaterial({ color: colorStartHex }))
startMesh.position.copy(vecPoints[0])
scene.add(startMesh)
//
const endMesh = new THREE.Mesh(new THREE.SphereGeometry(1.0), new THREE.MeshBasicMaterial({ color: colorEndHex }))
endMesh.position.copy(vecPoints[vecPoints.length - 1])
scene.add(endMesh)
}
// 1. (线)
drawFatTrajectory(rawPointsData, '#666666', '#aaaaaa')
// 2. (线)
drawFatTrajectory(mainPointsData, '#18283b', '#FF9F1C')
animate()
}
const animate = () => {
animationId = requestAnimationFrame(animate)
if (controls) controls.update()
// LineMaterial resolution线
// Modal resize
// renderer ()
// resizeObserver Modal
if (renderer && scene && camera) renderer.render(scene, camera)
}
const disposeThree = () => {
if (animationId) cancelAnimationFrame(animationId)
if (scene) {
scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose()
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose())
else obj.material.dispose()
}
})
scene = null
}
if (renderer) {
renderer.dispose()
if (renderer.domElement && renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement)
renderer = null
}
}
const close = () => {
disposeThree()
emit('close')
}
watch(() => props.isOpen, (val) => {
if (val && props.taskId) nextTick(() => initVisualization())
else disposeThree()
})
onUnmounted(() => disposeThree())
</script>
<style scoped>
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(5px);
z-index: 2200; display: flex; justify-content: center; align-items: center;
}
.modal-card {
width: 80vw; height: 80vh; max-width: 1000px;
background: #fff; display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 25px 50px rgba(0,0,0,0.5); border-radius: 16px;
}
.modal-header {
padding: 15px 25px; border-bottom: 1px solid #eee;
display: flex; justify-content: space-between; align-items: center;
background: rgba(255,255,255,0.9); z-index: 10;
}
.header-info h3 { margin: 0; font-size: 1.2rem; color: var(--color-contrast-dark); }
.subtitle { font-size: 0.85rem; color: #555; margin: 4px 0 0; font-family: monospace; font-weight: 600; }
.header-controls { display: flex; align-items: center; gap: 20px; }
.legend { display: flex; gap: 15px; font-size: 0.85rem; color: #666; align-items: center; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.dot.raw { background: #aaaaaa; border: 1px solid #999; }
.dot.smooth { background: linear-gradient(90deg, #18283b, #FF9F1C); }
.close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #999; transition: color 0.2s; }
.close-btn:hover { color: #333; }
.canvas-container { flex: 1; position: relative; width: 100%; height: 100%; background: #f8f9fa; overflow: hidden; }
.state-box {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center; color: var(--color-text-muted); pointer-events: none;
}
.state-box i { margin-bottom: 10px; color: var(--color-accent-secondary); }
.state-box.error { color: #c62828; }
.state-box.error i { color: #c62828; }
.axis-labels {
position: absolute; bottom: 20px; left: 20px;
background: rgba(255,255,255,0.85); padding: 12px;
border-radius: 8px; font-size: 0.8rem; pointer-events: none;
border: 1px solid #eee; box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
.axis-label { margin-bottom: 4px; font-weight: bold; }
.axis-label:last-child { margin-bottom: 0; }
.z-axis { color: #3366ff; }
.y-axis { color: #00cc00; }
.x-axis { color: #ff3333; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

@ -1,8 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia()) // 启用 Pinia
app.use(router)
app.mount('#app')
app.mount('#app')

@ -6,72 +6,89 @@ const router = createRouter({
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue')
component: () => import('../views/LoginView.vue'),
meta: { requiresAuth: false } // 标记不需要登录
},
// ===注册页路由 ===
{
path: '/register',
name: 'Register',
component: () => import('../views/RegisterView.vue'),
meta: { requiresAuth: false } // 标记不需要登录
},
// ========================
{
path: '/',
name: 'Main',
component: () => import('../views/MainFlow.vue'),
meta: { requiresAuth: true }, // 标记需要登录
children: [
// 子页面路由 - 按主页面分组
{
path: 'home/PrincipleDiagram',
name: 'PrincipleDiagram',
component: () => import('../views/home/subpages/PrincipleDiagram.vue'),
meta: { parent: 'home' }
},
{
path: 'home/SamplePreview',
name: 'SamplePreview',
component: () => import('../views/home/subpages/SamplePreview.vue'),
meta: { parent: 'home' }
},
{
path: 'home/PaperSupport',
name: 'PaperSupport',
component: () => import('../views/home/subpages/PaperSupport.vue'),
meta: { parent: 'home' }
},
{
path: 'page1/UniversalMode',
name: 'UniversalMode',
component: () => import('../views/Page1/subpages/UniversalMode.vue'),
meta: { parent: 'page1' }
},
{
path: 'page1/QuickMode',
name: 'QuickMode',
component: () => import('../views/Page1/subpages/QuickMode.vue'),
meta: { parent: 'page1' }
},
{
path: 'page2/:subpage',
name: 'Page2Sub',
component: () => import('../views/Page2/subpages/SubpageContainer.vue'),
meta: { parent: 'page2' }
},
{
path: 'page3/:subpage',
name: 'Page3Sub',
component: () => import('../views/Page3/subpages/SubpageContainer.vue'),
meta: { parent: 'page3' }
},
{
path: 'page4/:subpage',
name: 'Page4Sub',
component: () => import('../views/Page4/subpages/SubpageContainer.vue'),
meta: { parent: 'page4' }
},
{
path: 'page5/:subpage',
name: 'Page5Sub',
component: () => import('../views/Page5/subpages/SubpageContainer.vue'),
meta: { parent: 'page5' }
}
]
}
]
})
export default router
// === 全局路由守卫 (门卫) ===
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('access_token')
// 1. 如果去的是 Login 或 Register 页面,且已经有 Token直接踢到首页
if ((to.name === 'Login' || to.name === 'Register') && token) {
return next({ path: '/' })
}
// 2. 如果去的是需要验证的页面(默认所有非Login/Register都需要),且没有 Token
// (to.matched.some... 检查父级路由是否有 requiresAuth)
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)
if (requiresAuth && !token) {
// 没登录,滚去登录页
return next({ name: 'Login' })
}
// 3. 放行
next()
})
export default router

@ -0,0 +1,115 @@
//负责调用 API存储任务列表并执行 5 秒一次的轮询。
import { defineStore } from 'pinia'
import { getTaskList, getTaskQuota } from '@/api/task'
export const useTaskStore = defineStore('task', {
state: () => ({
tasks: [], // 存储后端返回的完整任务列表
quota: {
max_tasks: 5,
current_tasks: 0,
remaining_tasks: 5
},
timer: null, // 轮询定时器
isLoading: false
}),
actions: {
// === 核心:启动轮询 ===
async startPolling() {
if (this.timer) return
this.isLoading = true
// 立即执行一次
await Promise.all([this.fetchTasks(), this.fetchQuota()])
this.isLoading = false
// 每5秒执行一次
this.timer = setInterval(async () => {
await this.fetchTasks()
await this.fetchQuota()
}, 5000)
},
// === 停止轮询 ===
stopPolling() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
// === API: 获取任务列表 ===
async fetchTasks() {
try {
// 获取所有任务以便 Sidebar 显示历史记录
const res = await getTaskList({ task_status: 'all' })
if (res.tasks) {
// 按创建时间倒序排列 (最新的在前面)
this.tasks = res.tasks.sort((a, b) =>
new Date(b.created_at) - new Date(a.created_at)
)
}
} catch (error) {
console.error('Fetch tasks failed:', error)
}
},
// === API: 获取配额 ===
async fetchQuota() {
try {
const res = await getTaskQuota()
if (res) {
this.quota = res
}
} catch (error) {
console.error('Fetch quota failed:', error)
}
}
},
getters: {
// === 适配 Sidebar 的数据格式 ===
sidebarTasks: (state) => {
// 只取前 10 条展示在侧边栏
return state.tasks.slice(0, 10).map(t => {
// 1. [核心修改] 智能获取任务名称
let name = `Task #${t.task_id}` // 默认值
// 优先级 A: 用户填写的 description (通用/快速/专题防护的用户输入都存在这里)
if (t.description && t.description.trim() !== '') {
name = t.description
}
// 优先级 B: 微调/评估/热力图的特定名称字段 (如果 description 为空)
else if (t.task_type === 'finetune' && t.finetune?.finetune_name) {
name = t.finetune.finetune_name
} else if (t.task_type === 'evaluate' && t.evaluate?.evaluate_name) {
name = t.evaluate.evaluate_name
} else if (t.task_type === 'heatmap' && t.heatmap?.heatmap_name) {
name = t.heatmap.heatmap_name
}
// 优先级 C: 系统自动生成的算法名 (作为最后兜底)
else if (t.task_type === 'perturbation' && t.perturbation?.perturbation_name) {
name = t.perturbation.perturbation_name
}
// 2. 状态映射 (数据库状态 -> 前端样式类名)
let status = t.status
if (status === 'pending') status = 'waiting'
// 3. 进度条逻辑 (后端暂无百分比进行中由CSS动画处理)
const progress = status === 'completed' ? 100 : (status === 'processing' ? 50 : 0)
return {
id: t.task_id,
status: status,
progress: progress,
name: name, // 这里现在是用户友好的名字了
createdAt: t.created_at
}
})
}
}
})

@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
// 1. 初始化时,尝试从本地存储读取,如果没有则为空
// 这样刷新页面后,数据依然存在
userInfo: JSON.parse(localStorage.getItem('user_info') || 'null'),
token: localStorage.getItem('access_token') || ''
}),
getters: {
// 判断是否登录
isLoggedIn: (state) => !!state.token,
// 获取用户名,防止 userInfo 为空报错
username: (state) => state.userInfo?.username || 'Guest',
// 获取角色
role: (state) => state.userInfo?.role || 'normal',
// 获取首字母(用于头像)
initials: (state) => (state.userInfo?.username?.[0] || 'U').toUpperCase()
},
actions: {
// === 登录动作:存 Pinia + 存 LocalStorage ===
setLoginData(payload) {
// payload 就是后端返回的完整 json: { message, access_token, user }
this.token = payload.access_token
this.userInfo = payload.user
// 持久化到浏览器
localStorage.setItem('access_token', this.token)
localStorage.setItem('user_info', JSON.stringify(this.userInfo))
},
// === 更新用户信息(比如修改了配置后) ===
updateUserInfo(newInfo) {
// 合并旧数据和新数据
this.userInfo = { ...this.userInfo, ...newInfo }
localStorage.setItem('user_info', JSON.stringify(this.userInfo))
},
// === 登出动作:清空 ===
logout() {
this.token = ''
this.userInfo = null
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
}
}
})

@ -0,0 +1,65 @@
/**
* src/utils/constants.js
* 前端常量定义 (严格对应后端 init_db.py 初始化数据)
*/
// 1. 数据集类型 ID (对应 data_type_id)
// init_db 插入顺序: 1. facial, 2. art
export const DATA_TYPE_MAP = {
FACE: 1, // 人脸
ART: 2 // 艺术风格
}
// 2. 算法配置 ID (对应 perturbation_configs_id)
// init_db 插入顺序: 1. aspl, 2. simac, 3. caat, 4. pid
export const ALGO_MAP = {
ASPL: 1, // ASPL算法
SIMAC: 2, // SimAC算法
CAAT: 3, // CAAT算法
PID: 4, // PID算法
GLAZE: 5 // GLAZE算法
}
// 算法选项配置数据源,用于前端动态筛选
// Face: ASPL, SimAC
// Art: PID, Glaze, CAAT
export const ALGO_OPTIONS_Data = [
{ id: ALGO_MAP.ASPL, method_name: 'ASPL', type: 'face' },
{ id: ALGO_MAP.SIMAC, method_name: 'SimAC', type: 'face' },
{ id: ALGO_MAP.PID, method_name: 'PID', type: 'art' },
{ id: ALGO_MAP.GLAZE, method_name: 'Glaze', type: 'art' },
{ id: ALGO_MAP.CAAT, method_name: 'CAAT', type: 'art' }
]
// 3. 微调配置 ID (对应 finetune_configs_id)
// init_db 插入顺序: 1. dreambooth, 2. lora, 3. textual_inversion
export const FINETUNE_MAP = {
DREAMBOOTH: 1,
LORA: 2,
TEXTUAL_INVERSION: 3
}
// 4. 专题防护固定配置 (Page 2 业务逻辑映射)
// 根据 init_db 的算法描述进行推荐搭配
export const TOPIC_CONFIG = {
// 防风格迁移 -> 推荐使用 Art 数据集(2) + ASPL(1)
STYLE_TRANSFER: {
ALGO_ID: 1,
INTENSITY: 16.0,
DATA_TYPE_ID: 2,
ALGO_NAME: 'ASPL (Style Shield)'
},
// 防人脸编辑 -> 推荐使用 Face 数据集(1) + SimAC(2)
FACE_EDIT: {
ALGO_ID: 2,
INTENSITY: 8.0,
DATA_TYPE_ID: 1,
ALGO_NAME: 'SimAC (Face Guard)'
},
// 防定制生成 -> 推荐使用 Face 数据集(1) + PID(4) (针对扩散模型)
CUSTOM_GEN: {
ALGO_ID: 4,
INTENSITY: 12.0,
DATA_TYPE_ID: 1,
ALGO_NAME: 'PID (Anti-Diffusion)'
}
}

@ -1,22 +1,21 @@
import axios from 'axios'
import router from '@/router'
import { useUserStore } from '@/stores/userStore'
// 1. 创建 axios 实例
// 创建 axios 实例
const service = axios.create({
baseURL: '/api',
timeout: 15000, // 适当延长超时时间
headers: {
baseURL: '/api', // 配合 vite 代理转发到后端
timeout: 30000,
'Content-Type': 'application/json;charset=utf-8'
}
})
// 2. 请求拦截器
// === 请求拦截器 ===
service.interceptors.request.use(
config => {
const token = localStorage.getItem('access_token')
if (token) {
// 规范化 Bearer 头
config.headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`
const userStore = useUserStore()
if (userStore.token) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
return config
},
@ -26,11 +25,39 @@ service.interceptors.request.use(
}
)
// 3. 响应拦截器
// 响应拦截器:增加重试逻辑
service.interceptors.response.use(undefined, (err) => {
const config = err.config;
// 如果配置不存在或未设置重试选项,直接报错
if (!config || !config.retry) return Promise.reject(err);
// 设置重试次数计数器
config.__retryCount = config.__retryCount || 0;
// 检查是否超过最大重试次数
if (config.__retryCount >= config.retry) {
return Promise.reject(err);
}
// 增加计数
config.__retryCount += 1;
// 创建一个新的 Promise 来处理延时重试
const backoff = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, config.retryDelay || 1000); // 默认等待 1秒后重试
});
// 返回重新发起的请求
return backoff.then(() => service(config));
});
// === 响应拦截器 ===
service.interceptors.response.use(
response => {
// 兼容后端返回格式:直接返回 data 或 { data: ... }
return response.data
return response.data
},
error => {
let message = '网络连接异常'
@ -38,18 +65,25 @@ service.interceptors.response.use(
if (error.response) {
const status = error.response.status
const data = error.response.data || {}
// 优先读取后端返回的错误描述 (detail 是 FastAPI/Flask 常用字段error/message 是通用字段)
const serverMsg = data.error || data.message || data.detail
// 优先使用后端返回的 error 描述
const serverMsg = data.error || data.message
switch (status) {
case 400: message = serverMsg || '请求参数错误'; break;
case 401:
message = '登录已过期,请重新登录';
// 统一清理并跳转
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
// === [核心修改点] ===
// 如果请求的是登录接口401 代表账号密码错误
if (error.config.url.includes('/auth/login')) {
message = '用户名或密码错误';
} else {
// 如果是其他接口401 代表 Token 过期
message = '登录已过期,请重新登录';
const userStore = useUserStore()
userStore.logout()
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
}
break;
case 403: message = serverMsg || '拒绝访问 (权限不足)'; break;
@ -61,9 +95,10 @@ service.interceptors.response.use(
message = '请求超时,请检查网络';
}
// 401 通常会自动跳转,可选择不弹窗
if (error.response?.status !== 401) {
alert(message)
// 修改弹窗逻辑:
// 如果不是 Token 过期的 401是密码错误的 401或者其他错误都弹窗提示
if (error.response?.status !== 401 || error.config.url.includes('/auth/login')) {
alert(message)
}
return Promise.reject(new Error(message))

@ -1,113 +1,302 @@
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const handleLogin = () => {
router.push('/')
}
</script>
<template>
<div class="login-container">
<div class="ui-card glass login-card">
<div class="brand-logo">MUSE</div>
<h1>Welcome Back</h1>
<p>Please login to your dashboard.</p>
<div class="form-group">
<input type="text" placeholder="Username" class="input-field" />
<input type="password" placeholder="Password" class="input-field" />
<!-- 左侧品牌视觉区 -->
<div class="brand-side">
<div class="brand-content">
<div class="logo-text">MUSE</div>
<h2 class="slogan">Guard Your Art<br>Protect Your Rights</h2>
<p class="desc">下一代图像隐私保护系统抵御 AI 风格迁移与恶意编辑</p>
</div>
<div class="circle-deco"></div>
</div>
<!-- 右侧表单区 -->
<div class="form-side">
<div class="form-header">
<h1>Welcome Back</h1>
<p>Please login to your dashboard.</p>
</div>
<div class="form-group">
<!-- Username Input -->
<div class="user-box">
<input
type="text"
name="username"
required=""
v-model="form.username"
@keyup.enter="handleLogin"
>
<label>Username</label>
<i class="fas fa-user input-icon"></i>
</div>
<!-- Password Input -->
<div class="user-box">
<!-- 动态 type: 根据 showPassword 切换 text/password -->
<input
:type="showPassword ? 'text' : 'password'"
name="password"
required=""
v-model="form.password"
@keyup.enter="handleLogin"
>
<label>Password</label>
<i class="fas fa-lock input-icon"></i>
<!-- 眼睛图标按钮 -->
<i
class="fas toggle-password"
:class="showPassword ? 'fa-eye-slash' : 'fa-eye'"
@click="showPassword = !showPassword"
title="显示/隐藏密码"
></i>
</div>
</div>
<button
class="ui-btn gradient rect full-width"
@click="handleLogin"
:disabled="loading"
>
{{ loading ? 'Logging in...' : 'Login' }}
</button>
<div class="footer-link">
<span>No account yet? </span>
<a @click.prevent="goToRegister" href="#">Sign up now</a>
</div>
</div>
<button class="ui-btn gradient rect full-width" @click="handleLogin">
Login
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authLogin } from '@/api/auth'
import { useUserStore } from '@/stores/userStore'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const showPassword = ref(false) //
const form = ref({
username: '',
password: ''
})
const goToRegister = () => {
router.push('/register')
}
const handleLogin = async () => {
if (!form.value.username || !form.value.password) {
alert('请输入用户名和密码')
return
}
loading.value = true
try {
const res = await authLogin(form.value)
if (res.access_token) {
userStore.setLoginData(res)
router.push('/')
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
--color-text-main: #18283b; /* 强制深色文字,适配白色卡片 */
--color-accent-secondary: #FF9F1C;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-contrast-dark);
background: #18283b;
position: relative;
overflow: hidden;
}
/* Decorative background blobs */
.login-container::before {
content: '';
position: absolute;
width: 600px;
height: 600px;
background: var(--color-accent-primary);
border-radius: 50%;
top: -100px;
left: -100px;
filter: blur(80px);
opacity: 0.5;
content: ''; position: absolute; width: 800px; height: 800px; background: var(--color-accent-primary);
border-radius: 50%; top: -200px; left: -200px; filter: blur(100px); opacity: 0.4;
}
.login-container::after {
content: '';
position: absolute;
width: 500px;
height: 500px;
background: var(--color-accent-secondary);
border-radius: 50%;
bottom: -50px;
right: -50px;
filter: blur(100px);
opacity: 0.4;
content: ''; position: absolute; width: 600px; height: 600px; background: var(--color-accent-secondary);
border-radius: 50%; bottom: -100px; right: -100px; filter: blur(120px); opacity: 0.3;
}
.login-card {
width: 400px;
padding: 40px;
width: 900px;
min-height: 550px;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
flex-direction: row;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
background: rgba(255, 255, 255, 0.95);
z-index: 10;
background: rgba(255,255,255,0.9); /* Lighter glass for login */
}
.brand-logo {
font-weight: 900;
font-size: 1.5rem;
letter-spacing: 2px;
margin-bottom: 20px;
color: var(--color-contrast-dark);
.brand-side {
flex: 4;
background: linear-gradient(135deg, var(--color-contrast-dark), #2c3e50);
color: #fff;
padding: 60px 40px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
}
.brand-content { position: relative; z-index: 2; }
.logo-text { font-size: 2rem; font-weight: 900; letter-spacing: 4px; margin-bottom: 40px; color: var(--color-accent-primary); }
.slogan { font-size: 2.2rem; line-height: 1.2; margin-bottom: 20px; font-weight: 700; color: #ffffff; text-shadow: 0 2px 4px rgba(0,0,0,0.3); }
.desc { font-size: 1.1rem; color: rgba(255, 255, 255, 0.9); line-height: 1.6; font-weight: 500; }
.circle-deco { position: absolute; width: 300px; height: 300px; border: 40px solid rgba(255, 255, 255, 0.05); border-radius: 50%; bottom: -100px; right: -100px; z-index: 1; }
.form-side {
flex: 5;
padding: 60px 50px;
display: flex;
flex-direction: column;
justify-content: center;
background: #fff;
}
.form-header { margin-bottom: 40px; }
.form-header h1 { font-size: 2rem; color: var(--color-contrast-dark); margin-bottom: 10px; }
.form-header p { color: #666; }
.form-group {
width: 100%;
margin: 30px 0;
margin-bottom: 30px;
display: flex;
flex-direction: column;
gap: 15px;
gap: 25px;
}
/* === 输入框基础样式 === */
.user-box {
position: relative;
margin-bottom: 10px;
}
.input-field {
.user-box input {
width: 100%;
padding: 15px;
border-radius: 12px;
border: 1px solid #ddd;
background: #f9f9f9;
/* 左侧 30px 留给锁图标,右侧 25px 留给眼睛图标 */
padding: 10px 25px 10px 30px;
font-size: 1rem;
color: var(--color-text-main);
border: none;
border-bottom: 1px solid #ddd;
outline: none;
transition: border-color 0.2s;
background: transparent;
transition: all 0.3s;
}
/* Label 浮动逻辑 */
.user-box label {
position: absolute;
top: 10px;
left: 30px;
padding: 0;
font-size: 1rem;
color: #999;
pointer-events: none;
transition: .5s;
}
/* 左侧图标 */
.input-icon {
position: absolute;
top: 12px;
left: 0;
font-size: 1rem;
color: #999;
transition: .5s;
}
.input-field:focus {
border-color: var(--color-accent-secondary);
/* === 右侧眼睛图标 (Toggle Button) === */
.toggle-password {
position: absolute;
top: 12px;
right: 0; /* 靠右 */
font-size: 1rem;
color: #ccc;
cursor: pointer;
transition: color 0.3s;
z-index: 2; /* 确保在 input 之上可点击 */
}
.toggle-password:hover {
color: var(--color-accent-secondary);
}
/* === 核心动画逻辑 === */
.user-box input:focus ~ label,
.user-box input:valid ~ label {
top: -12px;
left: 0;
color: var(--color-accent-secondary);
font-size: 0.8rem;
font-weight: 600;
}
.user-box input:focus {
border-bottom: 1px solid var(--color-accent-secondary);
}
.user-box input:focus ~ .input-icon {
color: var(--color-accent-secondary);
}
/* === 按钮与底部 === */
.full-width {
width: 100%;
height: 50px;
font-size: 1.1rem;
border-radius: 12px;
margin-top: 10px;
}
.footer-link {
margin-top: 30px;
text-align: center;
font-size: 0.95rem;
color: #666;
}
.footer-link a {
color: var(--color-accent-secondary);
font-weight: 700;
text-decoration: none;
margin-left: 5px;
}
.footer-link a:hover { text-decoration: underline; }
@media (max-width: 900px) {
.login-card { width: 90%; max-width: 450px; flex-direction: column; min-height: auto; }
.brand-side { padding: 30px; flex: 0 0 auto; text-align: center; }
.logo-text { margin-bottom: 10px; font-size: 1.5rem; }
.slogan { font-size: 1.5rem; margin-bottom: 10px; }
.desc, .circle-deco { display: none; }
.form-side { padding: 40px 30px; }
}
</style>

@ -1,5 +1,5 @@
<script setup>
import { ref, provide, onMounted, onUnmounted, computed, watch } from 'vue'
import { ref, provide, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import NavBar from '../components/NavBar.vue'
import Button from '../components/Button.vue'
@ -9,181 +9,209 @@ import Page2 from './Page2/Page2.vue'
import Page3 from './Page3/Page3.vue'
import Page4 from './Page4/Page4.vue'
import Page5 from './Page5/Page5.vue'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore'
import { authLogout } from '@/api/auth'
const router = useRouter()
const route = useRoute()
const taskStore = useTaskStore()
const userStore = useUserStore()
/* 导航状态 */
//
const currentSection = ref('home')
const showSubpage = ref(false)
const isNavExpanded = ref(false)
// UI
const isManualClosing = ref(false)
/* 页面配置 */
const sections = ['home', 'page1', 'page2', 'page3', 'page4']
const sectionMap = {
home: 0,
page1: 1,
page2: 2,
page3: 3,
page4: 4
}
const sectionMap = { home: 0, page1: 1, page2: 2, page3: 3, page4: 4 }
const currentIndex = computed(() => sectionMap[currentSection.value])
/* ---------------------------------------------------------
路由监听核心修复
--------------------------------------------------------- */
// /退
watch(
() => route.path,
(newPath) => {
// 1.
if (newPath === '/' && showSubpage.value && !isManualClosing.value) {
// 退
// URL UI
showSubpage.value = false
}
// 2.
else if (newPath !== '/' && !showSubpage.value) {
showSubpage.value = true
}
//
if (newPath === '/') {
isManualClosing.value = false
}
}
)
/* ---------------------------------------------------------
导航与关闭逻辑
--------------------------------------------------------- */
//
watch(() => route.path, (newPath) => {
if (newPath === '/' && showSubpage.value && !isManualClosing.value) showSubpage.value = false
else if (newPath !== '/' && !showSubpage.value) showSubpage.value = true
if (newPath === '/') isManualClosing.value = false
})
//
const closeSubpage = () => {
// watch
isManualClosing.value = true
// 1.
showSubpage.value = false
// 2.
setTimeout(() => {
router.push('/')
// isManualClosing watch watch
}, 300)
setTimeout(() => router.push('/'), 300)
}
//
const openSubpage = (parentPage, subpageName) => {
showSubpage.value = true
router.push(`/${parentPage}/${subpageName}`)
}
//
//
const handleNavigate = (id) => {
if (id === currentSection.value) {
if (showSubpage.value) closeSubpage()
return
}
const performSwitch = () => {
if (id === 'page5') {
currentSection.value = 'page5'
return
}
if (currentSection.value === 'page5') {
currentSection.value = id
return
}
currentSection.value = id
if (id === 'page5') currentSection.value = 'page5'
else if (currentSection.value === 'page5') currentSection.value = id
else currentSection.value = id
nextTick(() => {
const activeEl = document.querySelector('.scroll-section.is-active')
if (activeEl) activeEl.scrollTop = 0
})
}
if (showSubpage.value) {
closeSubpage()
setTimeout(() => {
performSwitch()
}, 300)
setTimeout(performSwitch, 300)
} else {
performSwitch()
}
}
const handleNavToggle = (expanded) => {
isNavExpanded.value = expanded
}
const handleNavToggle = (expanded) => { isNavExpanded.value = expanded }
const handleLogout = () => {
router.push('/login')
const handleLogout = async () => {
if(confirm('确定要退出登录吗?')) {
try { await authLogout() } catch (e) {}
userStore.logout()
taskStore.stopPolling()
router.push('/login')
}
}
provide('openSubpage', openSubpage)
/* ---------------------------------------------------------
滚动逻辑
--------------------------------------------------------- */
const SCROLL_COOLDOWN = 250
const SCROLL_THRESHOLD = 50
let lastScrollTime = 0
/* === 智能滚动逻辑 === */
const SCROLL_COOLDOWN = 600
const SCROLL_THRESHOLD = 40
let lastSwitchTime = 0
let scrollAccumulator = 0
let resetTimer = null
let touchStartY = 0
const checkScrollState = (el) => {
const TOLERANCE = 2
const isScrollable = el.scrollHeight > el.clientHeight + TOLERANCE
return {
isScrollable,
atTop: el.scrollTop <= TOLERANCE,
atBottom: el.scrollTop + el.clientHeight >= el.scrollHeight - TOLERANCE
}
}
const handleWheel = (e) => {
//
if (currentSection.value === 'page5' || showSubpage.value) return
const now = Date.now()
if (now - lastScrollTime < SCROLL_COOLDOWN) {
e.preventDefault()
if (currentSection.value === 'page4' && e.target.closest('.allow-scroll')) return
const activeEl = document.querySelector('.scroll-section.is-active')
if (!activeEl) return
const { isScrollable, atTop, atBottom } = checkScrollState(activeEl)
const isGoingDown = e.deltaY > 0
const isGoingUp = e.deltaY < 0
if (isScrollable && isGoingDown && !atBottom) {
scrollAccumulator = 0
return
}
if (isScrollable && isGoingUp && !atTop) {
scrollAccumulator = 0
return
}
e.preventDefault()
const now = Date.now()
if (now - lastSwitchTime < SCROLL_COOLDOWN) return
scrollAccumulator += e.deltaY
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
scrollAccumulator = 0
}, 150)
resetTimer = setTimeout(() => { scrollAccumulator = 0 }, 150)
if (Math.abs(scrollAccumulator) > SCROLL_THRESHOLD) {
let nextIndex = currentIndex.value
if (scrollAccumulator > 0) {
if (nextIndex < sections.length - 1) nextIndex++
} else {
if (nextIndex > 0) nextIndex--
}
if (nextIndex !== currentIndex.value) {
handleNavigate(sections[nextIndex])
lastScrollTime = now
}
if (scrollAccumulator > 0) performPageSwitch('next')
else performPageSwitch('prev')
scrollAccumulator = 0
lastSwitchTime = now
}
}
//
const checkRoute = () => {
if (route.params.subpage) {
showSubpage.value = true
} else {
showSubpage.value = false
const handleTouchStart = (e) => {
touchStartY = e.touches[0].clientY
scrollAccumulator = 0
}
const handleTouchMove = (e) => {
if (currentSection.value === 'page5' || showSubpage.value) return
const activeEl = document.querySelector('.scroll-section.is-active')
if (!activeEl) return
const touchY = e.touches[0].clientY
const deltaY = touchStartY - touchY
const { isScrollable, atTop, atBottom } = checkScrollState(activeEl)
if (isScrollable && deltaY > 0 && !atBottom) return
if (isScrollable && deltaY < 0 && !atTop) return
if (e.cancelable) e.preventDefault()
}
const handleTouchEnd = (e) => {
if (currentSection.value === 'page5' || showSubpage.value) return
const activeEl = document.querySelector('.scroll-section.is-active')
if (!activeEl) return
const touchEndY = e.changedTouches[0].clientY
const distance = touchStartY - touchEndY
const { isScrollable, atTop, atBottom } = checkScrollState(activeEl)
if (isScrollable && distance > 0 && !atBottom) return
if (isScrollable && distance < 0 && !atTop) return
const now = Date.now()
if (now - lastSwitchTime < SCROLL_COOLDOWN) return
if (Math.abs(distance) > 60) {
if (distance > 0) performPageSwitch('next')
else performPageSwitch('prev')
lastSwitchTime = now
}
}
const performPageSwitch = (direction) => {
let nextIndex = currentIndex.value
if (direction === 'next') { if (nextIndex < sections.length - 1) nextIndex++ }
else { if (nextIndex > 0) nextIndex-- }
if (nextIndex !== currentIndex.value) handleNavigate(sections[nextIndex])
}
const checkRoute = () => { showSubpage.value = !!route.params.subpage }
onMounted(() => {
checkRoute()
window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('touchstart', handleTouchStart, { passive: true })
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('touchend', handleTouchEnd, { passive: true })
taskStore.startPolling()
})
onUnmounted(() => {
window.removeEventListener('wheel', handleWheel)
window.removeEventListener('touchstart', handleTouchStart)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
taskStore.stopPolling()
})
</script>
<template>
<div class="layout-main">
<!-- 导航栏组件 -->
<NavBar
:current-section="currentSection"
@navigate="handleNavigate"
@ -192,7 +220,6 @@ onUnmounted(() => {
/>
<div class="layout-content" :class="{ 'nav-expanded': isNavExpanded }">
<!-- 瀑布流容器 -->
<div class="scroll-container" v-if="currentSection !== 'page5'">
<div
v-for="(pageId, index) in sections"
@ -214,20 +241,17 @@ onUnmounted(() => {
</div>
</div>
<!-- Page 5 -->
<div v-else class="page-standalone">
<Page5 />
</div>
</div>
<!-- 子页面遮罩 (Fade 动画) -->
<Transition name="fade">
<div v-if="showSubpage" class="subpage-wrapper" :class="{ 'nav-expanded': isNavExpanded }">
<Button @close="closeSubpage" />
<router-view />
</div>
</Transition>
</div>
</template>
@ -237,7 +261,7 @@ onUnmounted(() => {
width: 100vw;
height: 100vh;
position: relative;
overflow: hidden;
overflow: hidden;
}
.layout-content {
@ -246,11 +270,9 @@ onUnmounted(() => {
position: relative;
margin-left: 100px;
transition: margin-left 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
width: 100%;
}
.layout-content.nav-expanded {
margin-left: 280px;
}
.layout-content.nav-expanded { margin-left: 280px; }
.scroll-container {
width: 100%;
@ -259,17 +281,17 @@ onUnmounted(() => {
overflow: hidden;
}
/* 主页面布局 */
.scroll-section {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
padding: 2vh 2vw;
transition: transform 0.7s cubic-bezier(0.19, 1, 0.22, 1), opacity 0.5s ease;
transition: transform 0.6s cubic-bezier(0.19, 1, 0.22, 1), opacity 0.5s ease;
will-change: transform;
z-index: 1;
opacity: 0;
pointer-events: none;
visibility: hidden;
overflow: hidden;
}
.scroll-section.is-active {
@ -277,63 +299,56 @@ onUnmounted(() => {
opacity: 1;
z-index: 10;
pointer-events: auto;
}
.scroll-section.is-prev {
transform: translateY(-100%);
opacity: 0.5;
pointer-events: none;
z-index: 0;
}
.scroll-section.is-next {
transform: translateY(100%);
opacity: 0.5;
pointer-events: none;
z-index: 0;
visibility: visible;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.scroll-section.is-prev { transform: translateY(-100%); opacity: 0; z-index: 0; }
.scroll-section.is-next { transform: translateY(100%); opacity: 0; z-index: 0; }
.page-standalone {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
width: 100%; height: 100%;
position: absolute; top: 0; left: 0;
background: var(--color-bg-secondary);
z-index: 20;
animation: fadeIn 0.3s ease-out;
overflow-y: auto;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* 子页面样式 */
.subpage-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
z-index: 50;
background: var(--color-bg-primary);
padding-left: 100px;
transition: padding-left 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
transition: padding-left 0.3s;
box-shadow: -20px 0 40px rgba(0,0,0,0.1);
overflow-y: auto;
}
.subpage-wrapper.nav-expanded {
padding-left: 280px;
}
.subpage-wrapper.nav-expanded { padding-left: 280px; }
/* Fade 动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* === 核心修改:高缩放/窄屏模式 === */
/* 当屏幕宽度小于 900px约等于放大 200%-300%)时 */
@media (max-width: 900px) {
/* 1. 内容取消左边距(因为导航栏跑下面去了) */
.layout-content, .layout-content.nav-expanded {
margin-left: 0 !important;
}
/* 2. 子页面同样取消左边距 */
.subpage-wrapper, .subpage-wrapper.nav-expanded {
padding-left: 0 !important;
z-index: 200;
}
/* 3. 增加底部 Padding防止内容被底部导航遮挡 */
.scroll-section.is-active, .page-standalone, .subpage-wrapper {
padding-bottom: 90px !important;
}
}
</style>

@ -6,56 +6,42 @@
</div>
<div class="card-grid">
<!-- 主卡片通用模式 (Universal Mode) -->
<!-- 主卡片 -->
<div class="ui-card solid interactive main-card" @click="OpenUniversal">
<div class="card-bg-icon"><i class="fas fa-sliders-h"></i></div>
<div class="content">
<div class="badge-pill">Advanced</div>
<h2>通用模式</h2>
<p>支持自定义选择加密算法AdvNoise, Mist等与扰动强度适合需要精细化控制防护效果的场景</p>
<p>适用于大多数场景的防护模式提供多种可调节参数满足不同需求</p>
<div class="feature-list">
<span><i class="fas fa-check"></i> 多算法支持</span>
<span><i class="fas fa-check"></i> 强度可调</span>
<span><i class="fas fa-check"></i> 参数配置</span>
</div>
<button class="ui-btn gradient rect mt-auto">
进入专家配置
<i class="fas fa-arrow-right"></i>
</button>
<button class="ui-btn gradient rect mt-auto">进入专家配置 <i class="fas fa-arrow-right"></i></button>
</div>
</div>
<!-- 右侧列 -->
<div class="right-col">
<!-- 快速模式 (Quick Mode) -->
<div class="ui-card gradient interactive sub-card" @click="OpenQuick">
<div class="icon-circle">
<i class="fas fa-bolt"></i>
</div>
<div class="icon-circle"><i class="fas fa-bolt"></i></div>
<div class="text-group">
<h3>快速模式</h3>
<p>系统自动推荐最佳配置一键上传即可防护</p>
</div>
</div>
<!-- 系统状态 (算法库) -->
<div class="ui-card glass sub-card info-only">
<div class="icon-circle dark">
<i class="fas fa-microchip"></i>
</div>
<div class="icon-circle dark"><i class="fas fa-microchip"></i></div>
<div class="text-group">
<h3>算法库状态</h3>
<div class="stat-row">
<span class="big-num">4</span>
<span class="big-num">5</span>
<span class="stat-desc">Active Algorithms</span>
</div>
</div>
</div>
</div>
</div>
</div>
@ -63,201 +49,88 @@
<script setup>
import { inject } from 'vue'
const openSubpage = inject('openSubpage')
const OpenUniversal = () => {
openSubpage('page1', 'UniversalMode')
}
const OpenQuick = () => {
openSubpage('page1', 'QuickMode')
}
const OpenUniversal = () => openSubpage('page1', 'UniversalMode')
const OpenQuick = () => openSubpage('page1', 'QuickMode')
</script>
<style scoped>
.view-container {
top: 5%;
/* 使用 min-height 而非 height允许撑开 */
min-height: 100%;
width: 100%;
height: 90%;
display: flex;
flex-direction: column;
padding-bottom: 20px;
}
.header-row {
flex: 0 0 auto;
margin-bottom: var(--space-md);
}
.page-header {
font-size: 2.5rem;
color: var(--color-contrast-dark);
margin-bottom: 5px;
}
.header-desc {
color: var(--color-text-muted);
font-size: 1.1rem;
}
.header-row { flex: 0 0 auto; margin-bottom: var(--space-md); }
.page-header { font-size: 2.5rem; color: var(--color-contrast-dark); margin-bottom: 5px; }
.header-desc { color: var(--color-text-muted); font-size: 1.1rem; }
/* === 核心布局修改 === */
.card-grid {
flex: 1;
display: grid;
grid-template-columns: 1.6fr 1fr; /* 左侧稍宽 */
/* 默认:主卡片占大头,右侧占小头 */
grid-template-columns: 1.6fr 1fr;
gap: var(--space-md);
min-height: 0;
}
/* === 主卡片样式 === */
.main-card {
padding: var(--space-xl);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.3s;
}
.main-card:hover {
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
transform: translateY(-5px);
}
.card-bg-icon {
position: absolute;
right: -20px;
bottom: -20px;
font-size: 15rem;
color: var(--color-text-main);
opacity: 0.03;
pointer-events: none;
transition: transform 0.5s;
}
.main-card:hover .card-bg-icon {
transform: rotate(-10deg) scale(1.1);
}
.content {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.badge-pill {
background: var(--color-contrast-dark);
color: #fff;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 700;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
}
.main-card h2 {
font-size: 2.2rem;
margin-bottom: 15px;
color: var(--color-text-main);
/* 移除固定高度限制 */
height: auto;
}
.main-card p {
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 90%;
margin-bottom: 30px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 30px;
}
.feature-list span {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
color: var(--color-text-main);
}
.feature-list i {
color: var(--color-accent-secondary);
}
.mt-auto {
margin-top: auto;
}
/* === 右侧列样式 === */
/* 右侧列布局 */
.right-col {
display: grid;
grid-template-rows: 1fr 1fr;
gap: var(--space-md);
}
.sub-card {
padding: var(--space-lg);
.main-card {
padding: var(--space-xl);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
gap: 15px;
border-radius: 24px;
}
.icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: var(--color-text-main);
}
.icon-circle.dark {
background: var(--color-contrast-dark);
color: #fff;
}
.text-group h3 {
font-size: 1.4rem;
margin-bottom: 5px;
}
.text-group p {
font-size: 0.95rem;
opacity: 0.9;
margin: 0;
}
.stat-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 5px;
}
.big-num {
font-size: 2.5rem;
font-weight: 800;
color: var(--color-contrast-dark);
line-height: 1;
}
.stat-desc {
font-weight: 600;
color: var(--color-text-muted);
}
.info-only {
cursor: default;
}
/* 最小高度限制 */
min-height: 400px;
}
/* === 响应式适配:当宽度小于 1000px (或者放大时) === */
@media (max-width: 1000px) {
.card-grid {
/* 强制改为单列布局 */
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
.right-col {
/* 右侧的两个小卡片在手机上横向排列 (如果空间够) 或者也垂直排列 */
grid-template-rows: none;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
/* === 卡片内部样式 === */
.card-bg-icon {
position: absolute; right: -20px; bottom: -20px; font-size: 15rem;
color: var(--color-text-main); opacity: 0.03; pointer-events: none;
}
.content { position: relative; z-index: 2; height: 100%; display: flex; flex-direction: column; align-items: flex-start; }
.badge-pill { background: var(--color-contrast-dark); color: #fff; padding: 6px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700; margin-bottom: 20px; text-transform: uppercase; }
.main-card h2 { font-size: 2.2rem; margin-bottom: 15px; color: var(--color-text-main); }
.main-card p { font-size: 1.1rem; color: var(--color-text-muted); max-width: 90%; margin-bottom: 30px; }
.feature-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 30px; }
.feature-list span { display: flex; align-items: center; gap: 10px; font-weight: 600; color: var(--color-text-main); }
.feature-list i { color: var(--color-accent-secondary); }
.mt-auto { margin-top: auto; }
.sub-card { padding: var(--space-lg); display: flex; flex-direction: column; justify-content: center; gap: 15px; min-height: 200px; }
.icon-circle { width: 50px; height: 50px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; color: var(--color-text-main); }
.icon-circle.dark { background: var(--color-contrast-dark); color: #fff; }
.text-group h3 { font-size: 1.4rem; margin-bottom: 5px; }
.text-group p { font-size: 0.95rem; opacity: 0.9; margin: 0; }
.stat-row { display: flex; align-items: baseline; gap: 10px; margin-top: 5px; }
.big-num { font-size: 2.5rem; font-weight: 800; color: var(--color-contrast-dark); line-height: 1; }
.stat-desc { font-weight: 600; color: var(--color-text-muted); }
</style>

@ -1,18 +1,10 @@
<template>
<div class="subpage-layout">
<!--
修复点
1. 删除了可能引起冲突的 class
2. 强制指定 grid-layout 样式
-->
<div class="layout-grid">
<!-- 左侧任务栏 (固定宽度) -->
<aside class="grid-sidebar">
<TaskSideBar :tasks="tasks" />
<TaskSideBar />
</aside>
<!-- 右侧主操作卡片 (自适应) -->
<main class="grid-main">
<div class="ui-card solid content-card">
<div class="card-header">
@ -20,22 +12,23 @@
<h2>快速模式</h2>
<p class="subtitle">Quick Mode Protection</p>
</div>
<span class="tag">AI Guard</span>
</div>
<div class="card-body">
<p class="desc-text">使用系统推荐的默认参数配置上传图片后即可一键完成隐私防护处理</p>
<p class="desc-text">使用系统推荐的默认参数配置 (SimAC算法 + 智能强度 8.0)支持多张图片批量一键防护</p>
<!-- 表单区域 -->
<div class="form-wrapper">
<!-- 1. 任务名 -->
<div class="form-group">
<label>任务名称</label>
<input type="text" v-model="formData.taskName" class="ui-input" placeholder="例如:我的自拍照防护..." />
<input
type="text"
v-model="formData.taskName"
class="ui-input"
placeholder="例如:我的自拍照防护..."
/>
</div>
<!-- 2. 风格选择 -->
<div class="form-group">
<label>防护风格目标</label>
<div class="style-selector">
@ -67,30 +60,26 @@
</div>
</div>
<!-- 3. 上传与提交 -->
<!-- 多文件上传 -->
<div class="upload-section">
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.fileName }">
<input
type="file"
ref="fileInput"
@change="handleFileChange"
style="display: none"
accept="image/*"
/>
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.files.length > 0 }">
<input type="file" ref="fileInput" @change="handleFileChange" style="display: none" accept="image/*" multiple />
<div class="upload-content">
<i class="fas fa-cloud-upload-alt upload-icon"></i>
<div class="upload-text">
<span class="main-tip" v-if="!formData.fileName"></span>
<span class="file-name" v-else>{{ formData.fileName }}</span>
<span class="sub-tip" v-if="!formData.fileName"> JPG, PNG </span>
<span class="main-tip" v-if="formData.files.length === 0"> ()</span>
<span class="file-name" v-else> {{ formData.files.length }} </span>
<p class="sub-list" v-if="formData.files.length > 0">
{{ formData.files[0].name }} <span v-if="formData.files.length > 1">...</span>
</p>
</div>
</div>
</div>
<div class="submit-area">
<button class="ui-btn gradient rect big-btn" @click="submitTask">
<i class="fas fa-shield-alt"></i>
开始防护
<button class="ui-btn gradient rect big-btn" @click="submitTask" :disabled="isSubmitting">
<i class="fas" :class="isSubmitting ? 'fa-spinner fa-spin' : 'fa-shield-alt'"></i>
{{ isSubmitting ? '正在提交...' : '一键防护' }}
</button>
</div>
</div>
@ -104,287 +93,135 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onUnmounted } from 'vue'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { submitPerturbationTask, getTaskStatus } from '@/api/task'
import { useTaskStore } from '@/stores/taskStore'
import { ALGO_MAP, DATA_TYPE_MAP } from '@/utils/constants'
/* --- 逻辑保持不变 --- */
const taskStore = useTaskStore()
const fileInput = ref(null)
const tasks = ref([
{ id: '1024', status: 'running', progress: 65 },
{ id: '1025', status: 'waiting', progress: 0 }
])
const isSubmitting = ref(false)
let specificPollTimer = null
const formData = ref({
taskName: '',
style: 'face',
fileName: '',
file: null
files: [] //
})
const triggerFileUpload = () => {
fileInput.value.click()
}
const triggerFileUpload = () => fileInput.value.click()
const handleFileChange = (event) => {
const file = event.target.files[0]
if (file) {
formData.value.fileName = file.name
formData.value.file = file
const files = event.target.files
if (files && files.length > 0) {
formData.value.files = Array.from(files)
}
}
const submitTask = () => {
if (!formData.value.fileName) {
alert('请先上传图片')
return
}
if (!formData.value.taskName) {
alert('请填写任务名称')
return
}
const submitTask = async () => {
if (formData.value.files.length === 0) return alert('请先上传图片')
if (!formData.value.taskName) return alert('请填写任务名称')
console.log('Submitting Quick Task:', formData.value)
alert('快速任务已提交 (模拟)')
}
</script>
<style scoped>
/* 外层容器:确保居中且有内边距 */
.subpage-layout {
width: 100%;
height: 100%;
padding: 40px;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-bg-primary);
}
.layout-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
width: 100%;
max-width: 1400px;
height: 85vh; /* 恢复为 85vh */
margin-bottom: 0; /* 清除刚才添加的边距 */
}
/* 左侧栏 */
.grid-sidebar {
height: 100%;
overflow: hidden;
}
/* 右侧主内容 */
.grid-main {
height: 100%;
min-width: 0; /* 防止 grid 溢出 */
}
.content-card {
height: 100%;
display: flex;
flex-direction: column;
padding: 0; /* 重置 padding由内部控制 */
background: #ffffff;
}
/* 卡片头部 */
.card-header {
padding: 30px 40px;
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.subtitle {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 5px;
}
.tag {
background: var(--color-accent-primary);
color: var(--color-contrast-dark);
padding: 6px 16px;
border-radius: 20px;
font-weight: 700;
font-size: 0.8rem;
}
/* 卡片主体 */
.card-body {
padding: 40px;
flex: 1;
overflow-y: auto; /* 内容过多可滚动 */
}
.desc-text {
color: var(--color-text-main);
margin-bottom: 40px;
padding: 15px 20px;
background: rgba(24, 40, 59, 0.03);
border-left: 4px solid var(--color-accent-secondary);
border-radius: 4px;
}
/* 表单组件 */
.form-wrapper {
display: flex;
flex-direction: column;
gap: 30px;
}
.form-group label {
display: block;
font-size: 1rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text-main);
}
.ui-input {
width: 100%;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 12px;
font-size: 1rem;
transition: all 0.2s;
background: #f8f9fa;
}
.ui-input:focus {
background: #fff;
border-color: var(--color-accent-secondary);
box-shadow: 0 0 0 4px rgba(255, 159, 28, 0.1);
}
/* 风格选择器 */
.style-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.style-option {
position: relative;
border: 2px solid transparent;
background: #f8f9fa;
border-radius: 16px;
padding: 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 15px;
transition: all 0.2s;
}
.style-option:hover {
background: #fff;
box-shadow: 0 10px 20px rgba(0,0,0,0.05);
}
.style-option.active {
background: #fff;
border-color: var(--color-contrast-dark);
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.icon-circle {
width: 48px;
height: 48px;
background: #e9ecef;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: var(--color-text-muted);
transition: all 0.2s;
}
.style-option.active .icon-circle {
background: var(--color-contrast-dark);
color: #fff;
}
.option-text {
display: flex;
flex-direction: column;
}
.opt-title { font-weight: 700; font-size: 1rem; }
.opt-desc { font-size: 0.8rem; color: var(--color-text-muted); }
.check-mark {
position: absolute;
top: 15px;
right: 15px;
color: var(--color-contrast-dark);
}
.badge {
position: absolute;
top: -10px;
right: 15px;
background: linear-gradient(135deg, #FFD166, #FF9F1C);
color: var(--color-text-main);
padding: 4px 10px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 800;
box-shadow: 0 4px 10px rgba(255, 159, 28, 0.3);
}
/* 上传区 */
.upload-zone {
border: 2px dashed #dbe2e8;
border-radius: 16px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #fcfcfc;
}
.upload-zone:hover {
border-color: var(--color-accent-secondary);
background: rgba(255, 159, 28, 0.02);
}
.upload-zone.has-file {
border-style: solid;
border-color: var(--color-contrast-dark);
background: #fff;
if (taskStore.quota.remaining_tasks <= 0) return alert('剩余任务配额不足')
isSubmitting.value = true
const payload = new FormData()
const dataTypeId = formData.value.style === 'art' ? DATA_TYPE_MAP.ART : DATA_TYPE_MAP.FACE
const algoId = ALGO_MAP.DEFAULT_QUICK || 2 // SimAC
const intensity = 8.0
payload.append('data_type_id', dataTypeId)
payload.append('perturbation_configs_id', algoId)
payload.append('perturbation_intensity', intensity)
payload.append('description', `[快速] ${formData.value.taskName}`)
payload.append('perturbation_name', 'Quick-SimAC-8.0')
//
formData.value.files.forEach(file => {
payload.append('files', file)
})
try {
const res = await submitPerturbationTask(payload)
alert(res.message || '快速防护任务已启动!')
taskStore.fetchTasks()
taskStore.fetchQuota()
if (res.task?.task_id) startSpecificPolling(res.task.task_id)
} catch (error) {
console.error(error)
} finally {
isSubmitting.value = false
}
}
.upload-icon {
font-size: 3rem;
color: var(--color-text-muted);
margin-bottom: 15px;
const startSpecificPolling = (taskId) => {
if (specificPollTimer) clearInterval(specificPollTimer)
specificPollTimer = setInterval(async () => {
try {
const statusRes = await getTaskStatus(taskId)
if (statusRes.status === 'completed' || statusRes.status === 'failed') {
clearInterval(specificPollTimer)
taskStore.fetchTasks()
}
} catch (e) { clearInterval(specificPollTimer) }
}, 3000)
}
.upload-text {
display: flex;
flex-direction: column;
gap: 5px;
}
onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
</script>
<style scoped>
/* === 基础布局 === */
.subpage-layout { width: 100%; height: 100%; padding: 40px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); overflow-y: auto; }
.layout-grid { display: grid; grid-template-columns: 300px 1fr; gap: 30px; width: 100%; max-width: 1400px; height: 85vh; margin-bottom: 0; min-height: 0; }
.grid-sidebar { height: 100%; overflow: hidden; }
.grid-main { height: 100%; min-width: 0; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; overflow: hidden; }
.card-header { padding: 30px 40px; border-bottom: 1px solid rgba(0,0,0,0.05); flex: 0 0 auto; }
.subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-top: 5px; }
.card-body { padding: 40px; flex: 1; overflow-y: auto; }
.desc-text { color: var(--color-text-main); margin-bottom: 40px; padding: 15px 20px; background: rgba(24, 40, 59, 0.03); border-left: 4px solid var(--color-accent-secondary); border-radius: 4px; }
/* === 表单 === */
.form-wrapper { display: flex; flex-direction: column; gap: 30px; }
.form-group label { display: block; font-size: 1rem; font-weight: 600; margin-bottom: 12px; color: var(--color-text-main); }
.ui-input { width: 100%; padding: 16px; border: 1px solid #e0e0e0; border-radius: 12px; font-size: 1rem; background: #f8f9fa; min-width: 0; }
.ui-input:focus { background: #fff; border-color: var(--color-accent-secondary); }
/* === 风格选择 === */
.style-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.style-option { position: relative; border: 2px solid transparent; background: #f8f9fa; border-radius: 16px; padding: 20px; cursor: pointer; display: flex; align-items: center; gap: 15px; transition: all 0.2s; }
.style-option:hover { background: #fff; box-shadow: 0 10px 20px rgba(0,0,0,0.05); }
.style-option.active { background: #fff; border-color: var(--color-contrast-dark); box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
.icon-circle { width: 48px; height: 48px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; color: var(--color-text-muted); transition: all 0.2s; flex-shrink: 0; }
.style-option.active .icon-circle { background: var(--color-contrast-dark); color: #fff; }
.option-text { display: flex; flex-direction: column; min-width: 0; }
.opt-title { font-weight: 700; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opt-desc { font-size: 0.8rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.check-mark { position: absolute; top: 15px; right: 15px; color: var(--color-contrast-dark); }
.badge { position: absolute; top: -10px; right: 15px; background: linear-gradient(135deg, #FFD166, #FF9F1C); color: var(--color-text-main); padding: 4px 10px; border-radius: 10px; font-size: 0.7rem; font-weight: 800; box-shadow: 0 4px 10px rgba(255, 159, 28, 0.3); }
/* === 上传 === */
.upload-zone { border: 2px dashed #dbe2e8; border-radius: 16px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fcfcfc; }
.upload-zone:hover { border-color: var(--color-accent-secondary); background: rgba(255, 159, 28, 0.02); }
.upload-zone.has-file { border-style: solid; border-color: var(--color-contrast-dark); background: #fff; }
.upload-icon { font-size: 3rem; color: var(--color-text-muted); margin-bottom: 15px; }
.upload-text { display: flex; flex-direction: column; gap: 5px; }
.main-tip { font-weight: 600; font-size: 1.1rem; }
.file-name { font-weight: 700; font-size: 1.2rem; color: var(--color-contrast-dark); }
.sub-tip { font-size: 0.9rem; color: var(--color-text-muted); }
.submit-area {
margin-top: 20px;
}
.big-btn {
width: 100%;
height: 60px;
font-size: 1.2rem;
border-radius: 16px;
letter-spacing: 1px;
.sub-list { font-size: 0.9rem; color: #888; margin-top: 5px; }
.submit-area { margin-top: 20px; }
.big-btn { width: 100%; height: 60px; font-size: 1.2rem; border-radius: 16px; letter-spacing: 1px; }
/* === 响应式适配 (High Zoom / Mobile) === */
@media (max-width: 900px) {
.subpage-layout { padding: 15px; height: auto; min-height: 100vh; align-items: flex-start; }
.layout-grid { grid-template-columns: 1fr; display: flex; flex-direction: column; height: auto; max-height: none; gap: 15px; }
.grid-sidebar { display: none; }
.content-card { height: auto; overflow: visible; }
.card-body { overflow: visible; padding: 20px; }
.style-selector { grid-template-columns: 1fr; gap: 10px; }
}
</style>

@ -1,13 +1,10 @@
<template>
<div class="subpage-layout">
<div class="layout-grid">
<!-- 左侧任务栏 -->
<aside class="grid-sidebar">
<TaskSideBar :tasks="tasks" />
<TaskSideBar />
</aside>
<!-- 右侧主操作卡片 -->
<main class="grid-main">
<div class="ui-card solid content-card">
<div class="card-header">
@ -15,28 +12,60 @@
<h2>通用模式</h2>
<p class="subtitle">Universal Protection Mode</p>
</div>
<span class="tag">Advanced</span>
</div>
<div class="card-body">
<p class="desc-text">支持自定义选择加密算法与扰动强度针对特定场景进行更精细化的隐私保护</p>
<p class="desc-text">自定义防护流程支持批量上传图片多选系统将自动为您匹配对应的专用算法</p>
<div class="form-wrapper">
<!-- 1. 任务名 -->
<div class="form-group">
<label>任务名称</label>
<input type="text" v-model="formData.taskName" class="ui-input" placeholder="输入任务名称..." />
<input
type="text"
v-model="formData.taskName"
class="ui-input"
placeholder="输入任务名称..."
/>
</div>
<!-- 2. 算法与强度 (一行两列布局) -->
<!-- 2. 第一步选择防护风格 -->
<div class="form-group">
<label>第一步选择防护对象</label>
<div class="style-selector">
<div
class="style-option"
:class="{ active: formData.style === 'face' }"
@click="setDataType('face')"
>
<div class="icon-circle"><i class="fas fa-smile"></i></div>
<div class="option-text">
<span class="opt-title">通用人脸防护</span>
<span class="opt-desc">ASPL / SimAC / CAAT_Pro</span>
</div>
<div class="check-mark" v-if="formData.style === 'face'"><i class="fas fa-check"></i></div>
</div>
<div
class="style-option vip"
:class="{ active: formData.style === 'art' }"
@click="setDataType('art')"
>
<div class="icon-circle"><i class="fas fa-paint-brush"></i></div>
<div class="option-text">
<span class="opt-title">通用艺术品防护</span>
<span class="opt-desc">PID / Glaze / CAAT</span>
</div>
<span class="badge">VIP</span>
</div>
</div>
</div>
<!-- 3. 第二步选择算法与强度 -->
<div class="row-group">
<!-- [修改点] 自定义下拉菜单 -->
<div class="form-group half">
<label>加密算法</label>
<!-- 点击外部关闭遮罩 (仅在打开时显示) -->
<label>第二步加密算法</label>
<div
v-if="isDropdownOpen"
class="click-outside-overlay"
@ -44,7 +73,6 @@
></div>
<div class="custom-select-container">
<!-- 触发器 (显示的框) -->
<div
class="select-trigger"
:class="{ 'is-open': isDropdownOpen }"
@ -56,11 +84,10 @@
<i class="fas fa-chevron-down arrow-icon"></i>
</div>
<!-- 下拉列表 (弹窗) -->
<Transition name="dropdown">
<div v-if="isDropdownOpen" class="select-options">
<div
v-for="algo in perturbationAlgorithms"
v-for="algo in currentAvailableAlgorithms"
:key="algo.id"
class="option-item"
:class="{ selected: formData.algorithm === algo.id }"
@ -74,82 +101,48 @@
</div>
</div>
<!-- 强度选择 -->
<div class="form-group half">
<label>扰动强度</label>
<label>扰动强度 (Intensity)</label>
<div class="strength-selector">
<div
class="str-item"
:class="{ active: formData.strength === 64 }"
@click="formData.strength = 64"
></div>
<div
class="str-item"
:class="{ active: formData.strength === 128 }"
@click="formData.strength = 128"
></div>
<div
class="str-item"
:class="{ active: formData.strength === 192 }"
@click="formData.strength = 192"
></div>
<div class="str-item" :class="{ active: formData.strength === 10.0 }" @click="formData.strength = 10.0"> (10.0)</div>
<div class="str-item" :class="{ active: formData.strength === 12.0 }" @click="formData.strength = 12.0"> (12.0)</div>
<div class="str-item" :class="{ active: formData.strength === 14.0 }" @click="formData.strength = 14.0"> (14.0)</div>
</div>
</div>
</div>
<!-- 3. 风格选择 -->
<div class="form-group">
<label>防护风格目标</label>
<div class="style-selector">
<div
class="style-option"
:class="{ active: formData.style === 'face' }"
@click="formData.style = 'face'"
>
<div class="icon-circle"><i class="fas fa-smile"></i></div>
<div class="option-text">
<span class="opt-title">人脸保护</span>
</div>
<div class="check-mark" v-if="formData.style === 'face'"><i class="fas fa-check"></i></div>
</div>
<!-- 4. 上传与提交 (支持多选) -->
<div class="upload-section">
<!-- 增加 active-file 用于改变样式 -->
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.files.length > 0 }">
<input type="file" ref="fileInput" @change="handleFileChange" style="display: none" accept="image/*" multiple />
<!-- 如果有文件显示清空按钮 (阻止冒泡防止触发上传点击) -->
<div
class="style-option vip"
:class="{ active: formData.style === 'art' }"
@click="formData.style = 'art'"
v-if="formData.files.length > 0"
class="clear-file-btn"
@click.stop="clearFiles"
title="清空已选文件"
>
<div class="icon-circle"><i class="fas fa-paint-brush"></i></div>
<div class="option-text">
<span class="opt-title">艺术风格</span>
</div>
<span class="badge">VIP</span>
<i class="fas fa-times"></i>
</div>
</div>
</div>
<!-- 4. 上传与提交 -->
<div class="upload-section">
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.fileName }">
<input
type="file"
ref="fileInput"
@change="handleFileChange"
style="display: none"
accept="image/*"
/>
<div class="upload-content">
<i class="fas fa-cloud-upload-alt upload-icon"></i>
<i class="fas fa-cloud-upload-alt upload-icon" :class="{ 'success-icon': formData.files.length > 0 }"></i>
<div class="upload-text">
<span class="main-tip" v-if="!formData.fileName"></span>
<span class="file-name" v-else>{{ formData.fileName }}</span>
<span class="main-tip" v-if="formData.files.length === 0"> ()</span>
<span class="file-name" v-else> {{ formData.files.length }} </span>
<p class="sub-list" v-if="formData.files.length > 0">
{{ formData.files[0].name }} <span v-if="formData.files.length > 1">...</span>
</p>
</div>
</div>
</div>
<div class="submit-area">
<button class="ui-btn solid rect big-btn" @click="submitTask">
<i class="fas fa-cogs"></i>
开始计算
<button class="ui-btn solid rect big-btn" @click="submitTask" :disabled="isSubmitting">
<i class="fas" :class="isSubmitting ? 'fa-spinner fa-spin' : 'fa-cogs'"></i>
{{ isSubmitting ? '提交中...' : '开始计算' }}
</button>
</div>
</div>
@ -163,247 +156,220 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, computed, onUnmounted } from 'vue'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { DATA_TYPE_MAP, ALGO_OPTIONS_Data } from '@/utils/constants'
import { submitPerturbationTask, getTaskStatus } from '@/api/task'
import { useTaskStore } from '@/stores/taskStore'
const taskStore = useTaskStore()
const fileInput = ref(null)
const perturbationAlgorithms = ref([])
//
const isDropdownOpen = ref(false)
const tasks = ref([
{ id: '1026', status: 'running', progress: 20 },
{ id: '1027', status: 'waiting', progress: 0 }
])
const isSubmitting = ref(false)
let specificPollTimer = null
const formData = ref({
taskName: '',
algorithm: '', // ID
strength: 128,
style: 'face',
fileName: '',
file: null
algorithm: '',
strength: 12.0,
style: 'face',
files: []
})
const currentAvailableAlgorithms = computed(() => {
return ALGO_OPTIONS_Data.filter(algo => algo.type === formData.value.style)
})
//
const currentAlgoName = computed(() => {
if (!formData.value.algorithm) return '请选择加密算法'
const algo = perturbationAlgorithms.value.find(a => a.id === formData.value.algorithm)
const algo = ALGO_OPTIONS_Data.find(a => a.id === formData.value.algorithm)
return algo ? algo.method_name : '未知算法'
})
const fetchAlgorithms = async () => {
// Mock data
setTimeout(() => {
perturbationAlgorithms.value = [
{ id: 'adv_noise', method_name: 'AdvNoise v1.0 (推荐)' },
{ id: 'mist', method_name: 'Mist (High-Res)' },
{ id: 'fawkes', method_name: 'Fawkes Privacy' },
{ id: 'low_key', method_name: 'LowKey Filter' }
]
}, 500)
const setDataType = (type) => {
if (formData.value.style === type) return
formData.value.style = type
formData.value.algorithm = ''
}
const selectAlgo = (algo) => {
formData.value.algorithm = algo.id
isDropdownOpen.value = false //
isDropdownOpen.value = false
}
const triggerFileUpload = () => {
fileInput.value.click()
}
const triggerFileUpload = () => fileInput.value.click()
const handleFileChange = (event) => {
const file = event.target.files[0]
if (file) {
formData.value.fileName = file.name
formData.value.file = file
const files = event.target.files
if (files && files.length > 0) {
formData.value.files = Array.from(files)
}
}
const submitTask = () => {
if (!formData.value.fileName) return alert('请先上传图片')
//
const clearFiles = () => {
formData.value.files = []
if (fileInput.value) fileInput.value.value = '' // input value 便
}
const submitTask = async () => {
if (formData.value.files.length === 0) return alert('请至少上传一张图片')
if (!formData.value.taskName) return alert('请填写任务名称')
if (!formData.value.algorithm) return alert('请选择加密算法')
console.log('Submitting Universal Task:', formData.value)
alert('通用防护任务已提交 (模拟)')
if (taskStore.quota.remaining_tasks <= 0) return alert('剩余任务配额不足')
isSubmitting.value = true
const payload = new FormData()
const dataTypeId = formData.value.style === 'art' ? DATA_TYPE_MAP.ART : DATA_TYPE_MAP.FACE
payload.append('data_type_id', dataTypeId)
payload.append('perturbation_configs_id', formData.value.algorithm)
payload.append('perturbation_intensity', formData.value.strength)
payload.append('description', formData.value.taskName.trim())
const rawAlgoName = currentAlgoName.value || 'Unknown'
const internalName = `Universal-${rawAlgoName}-${formData.value.strength}`
payload.append('perturbation_name', internalName)
formData.value.files.forEach(file => {
payload.append('files', file)
})
try {
const res = await submitPerturbationTask(payload)
alert(res.message || '通用防护任务已启动')
taskStore.fetchTasks()
taskStore.fetchQuota()
if (res.task?.task_id) startSpecificPolling(res.task.task_id)
//
formData.value.taskName = ''
clearFiles()
} catch (error) {
console.error(error)
} finally {
isSubmitting.value = false
}
}
onMounted(() => {
fetchAlgorithms()
})
const startSpecificPolling = (taskId) => {
if (specificPollTimer) clearInterval(specificPollTimer)
specificPollTimer = setInterval(async () => {
try {
const statusRes = await getTaskStatus(taskId)
if (statusRes.status === 'completed' || statusRes.status === 'failed') {
clearInterval(specificPollTimer)
taskStore.fetchTasks()
}
} catch (e) { clearInterval(specificPollTimer) }
}, 3000)
}
onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
</script>
<style scoped>
/* 基础布局保持不变 */
.subpage-layout {
width: 100%;
height: 100%;
padding: 40px;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-bg-primary);
}
.layout-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
width: 100%;
max-width: 1400px;
/* 修改 1稍微减小高度 给底部腾出物理空间 */
height: 88vh;
/* 修改 2这就是“隐形的砖头” */
/* 在卡片下面垫 60pxFlex 居中计算时,卡片就会视觉上移 30px */
margin-bottom: 60px;
}
/* === 基础布局 === */
.subpage-layout { width: 100%; height: 100%; padding: 20px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); overflow-y: auto; }
.layout-grid { display: grid; grid-template-columns: 280px 1fr; gap: 20px; width: 100%; max-width: 1400px; height: 95%; max-height: 95vh; min-height: 0; }
.grid-sidebar { height: 100%; overflow: hidden; }
.grid-main { height: 100%; min-width: 0; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; }
.card-header { padding: 30px 40px; border-bottom: 1px solid rgba(0,0,0,0.05); display: flex; justify-content: space-between; align-items: flex-start; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; overflow: hidden; }
.card-header { flex: 0 0 auto; padding: 20px 30px; border-bottom: 1px solid rgba(0,0,0,0.05); }
.subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-top: 5px; }
.tag { background: var(--color-contrast-dark); color: #fff; padding: 6px 16px; border-radius: 20px; font-weight: 700; font-size: 0.8rem; }
.card-body { padding: 40px; flex: 1; overflow-y: auto; }
.desc-text { color: var(--color-text-main); margin-bottom: 40px; padding: 15px 20px; background: rgba(24, 40, 59, 0.03); border-left: 4px solid var(--color-contrast-dark); border-radius: 4px; }
.form-wrapper { display: flex; flex-direction: column; gap: 30px; }
.form-group label { display: block; font-size: 1rem; font-weight: 600; margin-bottom: 12px; color: var(--color-text-main); }
.ui-input { width: 100%; padding: 16px; border: 1px solid #e0e0e0; border-radius: 12px; font-size: 1rem; background: #f8f9fa; outline: none; transition: all 0.2s; }
.ui-input:focus { background: #fff; border-color: var(--color-contrast-dark); }
.row-group { display: flex; gap: 30px; }
.half { flex: 1; }
/* === [核心] 自定义下拉菜单样式 === */
.custom-select-container {
position: relative;
width: 100%;
}
/* 透明遮罩,用于点击外部关闭 */
.click-outside-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 90;
cursor: default;
}
/* 触发框 */
.select-trigger {
width: 100%;
padding: 16px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 12px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
z-index: 91; /* 保证在遮罩之上 */
}
.card-body { padding: 30px; flex: 1; overflow-y: auto; padding-bottom: 60px; }
.desc-text { color: var(--color-text-main); margin-bottom: 30px; padding: 15px; background: rgba(24, 40, 59, 0.03); border-radius: 4px; border-left: 4px solid var(--color-contrast-dark); }
.select-trigger:hover {
background: #fff;
border-color: #d0d0d0;
}
/* === 表单元素 === */
.form-wrapper { display: flex; flex-direction: column; gap: 25px; }
.form-group label { display: block; font-size: 1rem; font-weight: 600; margin-bottom: 10px; color: var(--color-text-main); }
.ui-input { width: 100%; padding: 14px; border: 1px solid #e0e0e0; border-radius: 10px; font-size: 1rem; background: #f8f9fa; outline: none; transition: all 0.2s; min-width: 0; }
.ui-input:focus { background: #fff; border-color: var(--color-contrast-dark); }
.row-group { display: flex; gap: 20px; }
.half { flex: 1; min-width: 0; }
.select-trigger.is-open {
background: #fff;
border-color: var(--color-contrast-dark);
box-shadow: 0 0 0 4px rgba(24, 40, 59, 0.05);
/* === 风格选择器 === */
.style-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.style-option { position: relative; background: #f8f9fa; border-radius: 16px; padding: 15px; cursor: pointer; display: flex; align-items: center; gap: 15px; border: 2px solid transparent; transition: all 0.2s; }
.style-option.active { background: #fff; border-color: var(--color-contrast-dark); box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
.icon-circle { width: 42px; height: 42px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; flex-shrink: 0; }
.style-option.active .icon-circle { background: var(--color-contrast-dark); color: #fff; }
.option-text { display: flex; flex-direction: column; min-width: 0; }
.opt-title { font-weight: 700; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opt-desc { font-size: 0.75rem; color: #888; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.check-mark { position: absolute; top: 10px; right: 10px; color: var(--color-contrast-dark); }
.badge { position: absolute; top: -8px; right: 10px; background: linear-gradient(135deg, #FFD166, #FF9F1C); color: var(--color-text-main); padding: 2px 8px; border-radius: 10px; font-size: 0.65rem; font-weight: 800; }
/* === 下拉菜单 === */
.custom-select-container { position: relative; width: 100%; }
.select-trigger { width: 100%; padding: 14px; background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 10px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
.select-trigger.is-open { border-color: var(--color-contrast-dark); background: #fff; }
.select-options { position: absolute; top: 110%; left: 0; width: 100%; background: #fff; border: 1px solid #eee; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); z-index: 100; max-height: 250px; overflow-y: auto; padding: 5px; }
.option-item { padding: 10px 15px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
.option-item:hover { background: #f1f3f5; }
.option-item.selected { background: rgba(24, 40, 59, 0.05); color: var(--color-contrast-dark); font-weight: 600; }
.click-outside-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 90; }
/* === 强度选择 === */
.strength-selector { display: flex; background: #f8f9fa; border-radius: 10px; padding: 4px; border: 1px solid #e0e0e0; }
.str-item { flex: 1; text-align: center; padding: 10px 5px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; transition: all 0.2s; white-space: nowrap; }
.str-item.active { background: var(--color-contrast-dark); color: #fff; font-weight: 600; }
/* === 上传区域 === */
.upload-zone {
position: relative; /* 为绝对定位的关闭按钮提供参考 */
border: 2px dashed #dbe2e8;
border-radius: 16px;
padding: 30px;
text-align: center;
cursor: pointer;
background: #fcfcfc;
transition: all 0.2s;
}
.upload-zone:hover { border-color: var(--color-contrast-dark); background: #fff; }
.placeholder {
color: var(--color-text-muted);
/* 选中后的样式 */
.upload-zone.has-file {
border-style: solid;
border-color: #2e7d32; /* 绿色边框 */
background: #f1f8e9; /* 浅绿背景 */
}
.arrow-icon {
color: var(--color-text-muted);
transition: transform 0.3s ease;
}
.upload-icon { font-size: 2rem; color: var(--color-text-muted); margin-bottom: 10px; }
.success-icon { color: #2e7d32; } /* 成功时的图标颜色 */
.select-trigger.is-open .arrow-icon {
transform: rotate(180deg);
}
.file-name { font-weight: 700; color: var(--color-contrast-dark); }
.submit-area { margin-top: 20px; }
.big-btn { width: 100%; height: 50px; font-size: 1.1rem; border-radius: 12px; }
/* 下拉列表容器 */
.select-options {
/* 清空文件按钮 */
.clear-file-btn {
position: absolute;
top: 110%; /* 稍微留点空隙 */
left: 0;
width: 100%;
background: #fff;
border: 1px solid rgba(0,0,0,0.05);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
z-index: 100;
padding: 8px;
max-height: 250px;
overflow-y: auto;
}
/* 选项项 */
.option-item {
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--color-text-main);
transition: all 0.1s;
}
.option-item:hover {
background: #f1f3f5;
}
.option-item.selected {
background: rgba(24, 40, 59, 0.05);
color: var(--color-contrast-dark);
font-weight: 600;
}
.check-icon {
color: var(--color-accent-secondary);
top: 10px; right: 10px;
width: 30px; height: 30px;
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
z-index: 10;
}
/* 动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease-out;
.clear-file-btn:hover {
background: #dc3545;
color: #fff;
transform: scale(1.1);
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
/* === 响应式适配 === */
@media (max-width: 900px) {
.subpage-layout { padding: 10px; height: auto; min-height: 100vh; align-items: flex-start; }
.layout-grid { grid-template-columns: 1fr; display: flex; flex-direction: column; height: auto; max-height: none; }
.grid-sidebar { display: none; }
.content-card { height: auto; overflow: visible; }
.card-body { overflow: visible; padding: 15px; }
.row-group { flex-direction: column; gap: 15px; }
.style-selector { grid-template-columns: 1fr; }
}
/* === 其他样式 (强度选择器等) === */
.strength-selector { display: flex; background: #f8f9fa; border-radius: 12px; padding: 5px; border: 1px solid #e0e0e0; }
.str-item { flex: 1; text-align: center; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 600; color: var(--color-text-muted); transition: all 0.2s; }
.str-item.active { background: var(--color-contrast-dark); color: #fff; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
.style-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.style-option { position: relative; background: #f8f9fa; border-radius: 16px; padding: 20px; cursor: pointer; display: flex; align-items: center; gap: 15px; border: 2px solid transparent; transition: all 0.2s; }
.style-option.active { background: #fff; border-color: var(--color-contrast-dark); }
.icon-circle { width: 48px; height: 48px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; }
.style-option.active .icon-circle { background: var(--color-contrast-dark); color: #fff; }
.opt-title { font-weight: 700; }
.check-mark { position: absolute; top: 15px; right: 15px; color: var(--color-contrast-dark); }
.badge { position: absolute; top: -10px; right: 15px; background: linear-gradient(135deg, #FFD166, #FF9F1C); color: var(--color-text-main); padding: 4px 10px; border-radius: 10px; font-size: 0.7rem; font-weight: 800; }
.upload-zone { border: 2px dashed #dbe2e8; border-radius: 16px; padding: 30px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fcfcfc; }
.upload-zone:hover { border-color: var(--color-contrast-dark); background: rgba(0,0,0,0.01); }
.upload-zone.has-file { border-style: solid; border-color: var(--color-contrast-dark); background: #fff; }
.upload-icon { font-size: 2rem; color: var(--color-text-muted); margin-bottom: 10px; }
.file-name { font-weight: 700; color: var(--color-contrast-dark); }
.submit-area { margin-top: 20px; }
.big-btn { width: 100%; height: 60px; font-size: 1.2rem; border-radius: 16px; }
</style>

@ -27,7 +27,7 @@
<i class="fas fa-user-shield"></i>
</div>
<h4>防人脸编辑</h4>
<p class="desc">DeepFake 防御</p>
<p class="desc"></p>
</div>
<!-- 卡片2防定制生成 -->

@ -1,13 +1,10 @@
<template>
<div class="subpage-layout">
<div class="layout-grid">
<!-- 左侧任务栏 -->
<aside class="grid-sidebar">
<TaskSideBar :tasks="tasks" />
<TaskSideBar />
</aside>
<!-- 右侧主操作卡片 -->
<main class="grid-main">
<div class="ui-card solid content-card">
<div class="card-header">
@ -15,83 +12,64 @@
<h2>{{ pageTitle }}</h2>
<p class="subtitle">Topic Protection</p>
</div>
<span class="tag">Specialized</span>
</div>
<div class="card-body">
<p class="desc-text">本模块针对特定攻击场景提供定制化防御部分参数由算法固定以确保最佳防护效果</p>
<p class="desc-text">本模块针对特定攻击场景提供定制化防御参数由系统固定以确保最佳效果支持批量上传</p>
<div class="form-wrapper">
<!-- 1. 任务名 -->
<div class="form-group">
<label>任务名称</label>
<input type="text" v-model="formData.taskName" class="ui-input" placeholder="输入任务名称..." />
</div>
<!-- 2. 只读参数区域 (专题防护特色) -->
<div class="row-group">
<div class="form-group half">
<label>防护算法 (定制)</label>
<div class="readonly-field">
<i class="fas fa-lock lock-icon"></i>
<span>{{ fixedConfig.algorithm }}</span>
<span>{{ currentConfig.ALGO_NAME }}</span>
</div>
</div>
<div class="form-group half">
<label>扰动强度 (定制)</label>
<div class="readonly-field">
<i class="fas fa-lock lock-icon"></i>
<span>{{ fixedConfig.strength }}</span>
<span>{{ currentConfig.INTENSITY }}</span>
</div>
</div>
</div>
<!-- 3. 风格选择 -->
<div class="form-group">
<label>防护风格目标</label>
<label>数据集类型 (自动匹配)</label>
<div class="style-selector">
<div
class="style-option"
:class="{ active: formData.style === 'face' }"
@click="formData.style = 'face'"
>
<div class="style-option" :class="{ active: currentConfig.DATA_TYPE_ID === 1 }">
<div class="icon-circle"><i class="fas fa-smile"></i></div>
<div class="option-text">
<span class="opt-title">人脸保护</span>
</div>
<div class="check-mark" v-if="formData.style === 'face'"><i class="fas fa-check"></i></div>
<div class="option-text"><span class="opt-title">人脸保护</span></div>
<div class="check-mark" v-if="currentConfig.DATA_TYPE_ID === 1"><i class="fas fa-check"></i></div>
</div>
<div
class="style-option vip"
:class="{ active: formData.style === 'art' }"
@click="formData.style = 'art'"
>
<div class="style-option vip" :class="{ active: currentConfig.DATA_TYPE_ID === 2 }">
<div class="icon-circle"><i class="fas fa-paint-brush"></i></div>
<div class="option-text">
<span class="opt-title">艺术风格</span>
</div>
<span class="badge">VIP</span>
<div class="option-text"><span class="opt-title">艺术风格</span></div>
<span class="badge">Auto</span>
</div>
</div>
</div>
<!-- 4. 上传与提交 -->
<!-- 多文件上传 -->
<div class="upload-section">
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.fileName }">
<input
type="file"
ref="fileInput"
@change="handleFileChange"
style="display: none"
accept="image/*"
/>
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.files.length > 0 }">
<input type="file" ref="fileInput" @change="handleFileChange" style="display: none" accept="image/*" multiple />
<div class="upload-content">
<i class="fas fa-cloud-upload-alt upload-icon"></i>
<div class="upload-text">
<span class="main-tip" v-if="!formData.fileName"></span>
<span class="file-name" v-else>{{ formData.fileName }}</span>
<span class="main-tip" v-if="formData.files.length === 0"> ()</span>
<span class="file-name" v-else> {{ formData.files.length }} </span>
<p class="sub-list" v-if="formData.files.length > 0">
{{ formData.files[0].name }} <span v-if="formData.files.length > 1">...</span>
</p>
</div>
</div>
</div>
@ -116,24 +94,19 @@
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { useTaskStore } from '@/stores/taskStore'
import { submitPerturbationTask } from '@/api/task'
import { TOPIC_CONFIG } from '@/utils/constants'
const route = useRoute()
const taskStore = useTaskStore()
const fileInput = ref(null)
//
const tasks = ref([
{ id: '2001', status: 'running', progress: 85 },
{ id: '2002', status: 'waiting', progress: 0 }
])
const formData = ref({
taskName: '',
style: 'face',
fileName: '',
file: null
files: [] //
})
// (subpage)
const subpageType = computed(() => route.params.subpage)
const pageTitle = computed(() => {
@ -145,117 +118,111 @@ const pageTitle = computed(() => {
return map[subpageType.value] || '专题防护'
})
//
const fixedConfig = computed(() => {
if (subpageType.value === 'style') {
return { algorithm: 'Anti-Mist v2.0', strength: 'High / 高 (Fixed)' }
} else if (subpageType.value === 'face') {
return { algorithm: 'Fawkes-Pro', strength: 'Mid / 中 (Fixed)' }
} else {
return { algorithm: 'Glaze-Shield', strength: 'Adaptive / 自适应' }
const currentConfig = computed(() => {
switch (subpageType.value) {
case 'style': return TOPIC_CONFIG.STYLE_TRANSFER
case 'face': return TOPIC_CONFIG.FACE_EDIT
case 'custom': return TOPIC_CONFIG.CUSTOM_GEN
default: return TOPIC_CONFIG.FACE_EDIT
}
})
const triggerFileUpload = () => {
fileInput.value.click()
}
const triggerFileUpload = () => fileInput.value.click()
const handleFileChange = (event) => {
const file = event.target.files[0]
if (file) {
formData.value.fileName = file.name
formData.value.file = file
const files = event.target.files
if (files && files.length > 0) {
formData.value.files = Array.from(files)
}
}
const submitTask = () => {
if (!formData.value.fileName) return alert('请先上传图片')
const submitTask = async () => {
if (formData.value.files.length === 0) return alert('请先上传图片')
if (!formData.value.taskName) return alert('请填写任务名称')
console.log(`Submitting Topic Task [${subpageType.value}]:`, formData.value)
alert('专题防护任务已提交 (模拟)')
}
</script>
if (taskStore.quota.remaining_tasks <= 0) return alert('剩余任务配额不足')
<style scoped>
/* === 布局复用 (Page1) === */
.subpage-layout {
width: 100%;
height: 100%;
padding: 40px;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-bg-primary);
}
.layout-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
width: 100%;
max-width: 1400px;
const payload = new FormData()
/* 修改 1稍微减小高度 (85vh -> 82vh),给底部腾出物理空间 */
height: 90vh;
payload.append('data_type_id', currentConfig.value.DATA_TYPE_ID)
payload.append('perturbation_configs_id', currentConfig.value.ALGO_ID)
payload.append('perturbation_intensity', currentConfig.value.INTENSITY)
payload.append('description', formData.value.taskName)
payload.append('perturbation_name', `Topic-${subpageType.value}-${currentConfig.value.ALGO_NAME}`)
/* 修改 2这就是“隐形的砖头” */
/* 在卡片下面垫 60pxFlex 居中计算时,卡片就会视觉上移 30px */
margin-bottom: 60px;
//
formData.value.files.forEach(file => {
payload.append('files', file)
})
try {
const res = await submitPerturbationTask(payload)
alert(res.message || '专题防护任务已启动')
taskStore.fetchTasks()
taskStore.fetchQuota()
} catch (error) {
console.error(error)
}
}
</script>
.grid-sidebar { height: 100%; overflow: hidden; }
<style scoped>
/* === 基础布局 === */
.subpage-layout { width: 100%; height: 100%; padding: 40px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); overflow-y: auto; }
.layout-grid { display: grid; grid-template-columns: 300px 1fr; gap: 30px; width: 100%; max-width: 1400px; height: 95%; max-height: 95vh; min-height: 0; }
.grid-sidebar { height: 100%; overflow: hidden; display: block; }
.grid-main { height: 100%; min-width: 0; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; }
.card-header { padding: 30px 40px; border-bottom: 1px solid rgba(0,0,0,0.05); display: flex; justify-content: space-between; align-items: flex-start; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; overflow: hidden; }
.card-header { padding: 30px 40px; border-bottom: 1px solid rgba(0,0,0,0.05); flex: 0 0 auto; }
.subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-top: 5px; }
.tag { background: var(--color-contrast-dark); color: #fff; padding: 6px 16px; border-radius: 20px; font-weight: 700; font-size: 0.8rem; }
.card-body { padding: 40px; flex: 1; overflow-y: auto; }
.desc-text { color: var(--color-text-main); margin-bottom: 40px; padding: 15px 20px; background: rgba(24, 40, 59, 0.03); border-left: 4px solid var(--color-contrast-dark); border-radius: 4px; }
/* === 表单 === */
.form-wrapper { display: flex; flex-direction: column; gap: 30px; }
.form-group label { display: block; font-size: 1rem; font-weight: 600; margin-bottom: 12px; color: var(--color-text-main); }
.ui-input { width: 100%; padding: 16px; border: 1px solid #e0e0e0; border-radius: 12px; font-size: 1rem; background: #f8f9fa; outline: none; transition: all 0.2s; }
.ui-input { width: 100%; padding: 16px; border: 1px solid #e0e0e0; border-radius: 12px; font-size: 1rem; background: #f8f9fa; outline: none; transition: all 0.2s; min-width: 0; }
.ui-input:focus { background: #fff; border-color: var(--color-contrast-dark); }
.row-group { display: flex; gap: 30px; }
.half { flex: 1; }
/* === 专题防护特有样式 === */
.half { flex: 1; min-width: 0; }
/* 只读字段样式 */
.readonly-field {
width: 100%;
padding: 16px;
background: #f1f3f5; /* 灰色背景 */
border: 1px dashed #ced4da; /* 虚线框 */
border-radius: 12px;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 10px;
cursor: not-allowed;
user-select: none;
}
.lock-icon {
font-size: 0.9rem;
color: #adb5bd;
}
/* === 只读字段 === */
.readonly-field { width: 100%; padding: 16px; background: #f1f3f5; border: 1px dashed #ced4da; border-radius: 12px; color: var(--color-text-muted); display: flex; align-items: center; gap: 10px; cursor: not-allowed; user-select: none; min-width: 0; }
.lock-icon { font-size: 0.9rem; color: #adb5bd; flex-shrink: 0; }
/* 风格选择器 & 上传区 (复用) */
/* === 风格选择 === */
.style-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.style-option { position: relative; background: #f8f9fa; border-radius: 16px; padding: 20px; cursor: pointer; display: flex; align-items: center; gap: 15px; border: 2px solid transparent; transition: all 0.2s; }
.style-option.active { background: #fff; border-color: var(--color-contrast-dark); }
.icon-circle { width: 48px; height: 48px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; }
.style-option { position: relative; background: #f8f9fa; border-radius: 16px; padding: 20px; cursor: default; display: flex; align-items: center; gap: 15px; border: 2px solid transparent; transition: all 0.2s; opacity: 0.6; filter: grayscale(1); }
.style-option.active { background: #fff; border-color: var(--color-contrast-dark); opacity: 1; filter: grayscale(0); box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
.icon-circle { width: 48px; height: 48px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; flex-shrink: 0; }
.style-option.active .icon-circle { background: var(--color-contrast-dark); color: #fff; }
.opt-title { font-weight: 700; }
.opt-title { font-weight: 700; white-space: nowrap; }
.check-mark { position: absolute; top: 15px; right: 15px; color: var(--color-contrast-dark); }
.badge { position: absolute; top: -10px; right: 15px; background: linear-gradient(135deg, #FFD166, #FF9F1C); color: var(--color-text-main); padding: 4px 10px; border-radius: 10px; font-size: 0.7rem; font-weight: 800; }
/* === 上传 === */
.upload-zone { border: 2px dashed #dbe2e8; border-radius: 16px; padding: 30px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fcfcfc; }
.upload-zone:hover { border-color: var(--color-contrast-dark); background: rgba(0,0,0,0.01); }
.upload-zone.has-file { border-style: solid; border-color: var(--color-contrast-dark); background: #fff; }
.upload-icon { font-size: 2rem; color: var(--color-text-muted); margin-bottom: 10px; }
.file-name { font-weight: 700; color: var(--color-contrast-dark); }
.sub-list { font-size: 0.9rem; color: #888; margin-top: 5px; }
.submit-area { margin-top: 20px; }
.big-btn { width: 100%; height: 60px; font-size: 1.2rem; border-radius: 16px; }
/* === 响应式适配 (High Zoom / Mobile) === */
@media (max-width: 900px) {
.subpage-layout { padding: 15px; height: auto; min-height: 100vh; align-items: flex-start; }
.layout-grid { grid-template-columns: 1fr; display: flex; flex-direction: column; height: auto; max-height: none; gap: 15px; }
/* 在手机端隐藏侧边栏,或可以设为 order: 2 放到下面 */
.grid-sidebar { display: none !important; }
.content-card { height: auto; overflow: visible; }
.card-body { overflow: visible; padding: 20px; }
.row-group { flex-direction: column; gap: 15px; }
.style-selector { grid-template-columns: 1fr; gap: 10px; }
}
</style>

@ -1,10 +1,9 @@
<template>
<div class="subpage-layout">
<div class="layout-grid">
<!-- 左侧任务栏 -->
<aside class="grid-sidebar">
<TaskSideBar :tasks="tasks" />
<TaskSideBar />
</aside>
<!-- 右侧主操作卡片 -->
@ -15,87 +14,172 @@
<h2>{{ pageTitle }}</h2>
<p class="subtitle">Validation Task Setup</p>
</div>
<span class="tag">Analysis</span>
</div>
<div class="card-body">
<p class="desc-text">选择已完成的防护任务作为数据源对其进行攻击模拟或指标计算以验证防护效果</p>
<!-- === 场景 1: 微调验证 === -->
<div v-if="subpageType === 'fine-tuning'" class="form-wrapper">
<!-- Tab 切换 -->
<div class="mode-tabs">
<button class="tab-btn" :class="{ active: finetuneMode === 'task' }" @click="finetuneMode = 'task'">
<i class="fas fa-history"></i> 从现有加噪任务
</button>
<button class="tab-btn vip" :class="{ active: finetuneMode === 'upload' }" @click="finetuneMode = 'upload'">
<i class="fas fa-upload"></i> 自定义上传 (VIP)
</button>
</div>
<div class="form-wrapper">
<!-- 1. 任务名 -->
<div class="form-group">
<label>验证任务名称</label>
<input type="text" v-model="formData.taskName" class="ui-input" placeholder="例如第1批次效果验证..." />
<!-- 模式 A: 从现有任务 -->
<div v-if="finetuneMode === 'task'" class="mode-content">
<div class="form-group">
<label>1. 选取已有加噪数据源 (Completed Only)</label>
<div class="source-selector">
<div class="source-trigger" @click="isSourceListOpen = !isSourceListOpen">
<span>{{ currentSourceName }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div v-if="isSourceListOpen" class="source-list">
<div v-for="item in candidateTasks" :key="item.id" class="source-item" @click="selectSource(item)">
<span>#{{ item.id }} - {{ item.name }}</span>
<span class="s-tag">Done</span>
</div>
</div>
</div>
<!-- 预览区域 -->
<div v-if="formData.sourceId" class="single-preview-area fade-in">
<div v-if="isLoadingImages" class="loading-text"><i class="fas fa-spinner fa-spin"></i> ...</div>
<div v-else-if="previewImage" class="preview-content">
<span class="preview-label">任务首图预览:</span>
<img :src="previewImage" class="preview-img-sm" />
</div>
</div>
</div>
</div>
<!-- 2. 数据源选择 (核心交互) -->
<div class="form-group">
<label>选择数据源 (原始防护任务)</label>
<!-- 模式 B: 自定义上传 -->
<div v-else class="mode-content">
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.files.length > 0 }">
<input type="file" ref="fileInput" @change="handleFileChange" multiple style="display:none" accept="image/*" />
<!-- 清空按钮 -->
<div v-if="formData.files.length > 0" class="clear-file-btn" @click.stop="clearFiles">
<i class="fas fa-times"></i>
</div>
<i class="fas fa-cloud-upload-alt" :class="{ 'success-icon': formData.files.length > 0 }"></i>
<p v-if="!formData.files.length"> ()</p>
<p v-else class="file-name">已选择 {{ formData.files.length }} 张图片</p>
</div>
<div class="source-selector">
<!-- 触发器 -->
<div class="form-group" style="margin-top: 20px;">
<label>数据集类型</label>
<select v-model="formData.dataType" class="ui-input">
<option :value="1">人脸 (Facial)</option>
<option :value="2">艺术 (Art)</option>
</select>
</div>
</div>
<!-- 核心修改2. 微调配置选择 (3选1) -->
<div class="form-group">
<label>2. 选择微调算法</label>
<div class="style-selector finetune-selector">
<div
class="source-trigger"
:class="{ active: isSourceListOpen, selected: formData.sourceId }"
@click="isSourceListOpen = !isSourceListOpen"
v-for="opt in finetuneOptions"
:key="opt.id"
class="style-option"
:class="{ active: formData.finetuneConfig === opt.id }"
@click="formData.finetuneConfig = opt.id"
>
<div class="trigger-left">
<div class="icon-box"><i class="fas fa-database"></i></div>
<div class="trigger-info">
<span class="t-title">{{ currentSourceName }}</span>
<span class="t-desc" v-if="formData.sourceId">ID: {{ formData.sourceId }}</span>
<span class="t-desc" v-else>...</span>
</div>
<!-- 选中标记 -->
<div class="check-mark" v-if="formData.finetuneConfig === opt.id">
<i class="fas fa-check"></i>
</div>
<!-- 内容 -->
<div class="option-text">
<span class="opt-title">{{ opt.name }}</span>
<span class="opt-desc">{{ opt.desc }}</span>
</div>
<i class="fas fa-chevron-down arrow"></i>
</div>
</div>
</div>
<!-- 公共部分 -->
<div class="form-group">
<label>微调任务名称</label>
<input type="text" v-model="formData.taskName" class="ui-input" placeholder="输入任务名称..." />
</div>
<!-- 自定义 Prompt -->
<div class="form-group">
<label>自定义提示词 (Optional Prompt)</label>
<input type="text" v-model="formData.customPrompt" class="ui-input" placeholder="例如: a photo of sks person..." />
</div>
<!-- 展开的任务列表 -->
<Transition name="expand">
<button class="ui-btn gradient rect big-btn" @click="submitTask" :disabled="isSubmitting">
<i class="fas" :class="isSubmitting ? 'fa-spinner fa-spin' : 'fa-rocket'"></i>
{{ isSubmitting ? '提交中...' : '提交微调任务' }}
</button>
</div>
<!-- === 场景 2: 评估与热力图 === -->
<div v-else class="form-wrapper">
<!-- 1. 选择数据源 -->
<div class="form-group">
<label>选择数据源 ({{ subpageType === 'heatmap' ? '已完成的加噪任务' : '微调任务' }})</label>
<div class="source-selector">
<div class="source-trigger" @click="isSourceListOpen = !isSourceListOpen">
<span>{{ currentSourceName }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div v-if="isSourceListOpen" class="source-list">
<div class="list-header">可选历史任务</div>
<div class="list-body">
<div
v-for="item in historyTasks"
:key="item.id"
class="source-item"
@click="selectSource(item)"
>
<div class="s-info">
<span class="s-name">{{ item.name }}</span>
<span class="s-date">{{ item.date }}</span>
</div>
<span class="s-tag">{{ item.imageCount }}张图</span>
</div>
<div v-for="item in candidateTasks" :key="item.id" class="source-item" @click="selectSource(item)">
<span>#{{ item.id }} - {{ item.name }}</span>
<span class="s-tag">Done</span>
</div>
</div>
</Transition>
</div>
<!-- 评估模式单图预览 -->
<div v-if="subpageType === 'metrics' && formData.sourceId" class="single-preview-area fade-in">
<div v-if="isLoadingImages" class="loading-text"><i class="fas fa-spinner fa-spin"></i> ...</div>
<div v-else-if="previewImage" class="preview-content">
<span class="preview-label">任务首图预览:</span>
<img :src="previewImage" class="preview-img-sm" />
</div>
</div>
</div>
<!-- 3. 可视化类型 (仅热力图显示) -->
<div class="form-group" v-if="subpageType === 'heatmap'">
<label>可视化类型</label>
<div class="style-selector">
<div class="style-option" :class="{ active: formData.visType === 'attention' }" @click="formData.visType = 'attention'">
<i class="fas fa-eye"></i> <span>Attention Map</span>
</div>
<div class="style-option" :class="{ active: formData.visType === 'frequency' }" @click="formData.visType = 'frequency'">
<i class="fas fa-wave-square"></i> <span>频域分析</span>
<!-- 热力图专用图片选择区域 -->
<div v-if="subpageType === 'heatmap'" class="form-group fade-in" :class="{ 'disabled': !formData.sourceId }">
<label>选择目标图片 <span v-if="taskImages.length">({{ taskImages.length }})</span></label>
<div class="image-select-container">
<div v-if="isLoadingImages" class="loading-box"><i class="fas fa-spinner fa-spin"></i> ...</div>
<div v-else-if="!formData.sourceId" class="empty-box">请先选择上方的加噪任务</div>
<div v-else class="image-grid">
<div v-for="img in taskImages" :key="img.image_id" class="img-item"
:class="{ active: formData.selectedImageId === img.image_id }"
@click="selectImage(img)">
<img :src="img.base64" loading="lazy" />
<div class="check-overlay"><i class="fas fa-check"></i></div>
</div>
</div>
</div>
</div>
<!-- 4. 提交 -->
<div class="submit-area">
<button class="ui-btn solid rect big-btn" @click="submitTask">
<i class="fas fa-play"></i>
开始验证分析
</button>
<!-- 任务名称 -->
<div class="form-group">
<label>任务名称</label>
<input type="text" v-model="formData.taskName" class="ui-input" placeholder="输入任务名称..." />
</div>
<button class="ui-btn solid rect big-btn" @click="submitTask" :disabled="isSubmitting">
<i class="fas" :class="isSubmitting ? 'fa-spinner fa-spin' : 'fa-play'"></i>
{{ isSubmitting ? '提交中...' : '提交任务' }}
</button>
</div>
</div>
</div>
</main>
@ -104,158 +188,311 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { useTaskStore } from '@/stores/taskStore'
import {
submitFinetuneFromPerturbation,
submitFinetuneFromUpload,
submitEvaluateTask,
submitHeatmapTask,
startFinetuneTask,
startEvaluateTask,
getTaskResultImages // image/preview
} from '@/api/task'
import { getTaskImagePreview } from '@/api/image'
import { FINETUNE_MAP } from '@/utils/constants'
const route = useRoute()
const taskStore = useTaskStore()
const isSourceListOpen = ref(false)
const fileInput = ref(null)
//
const tasks = ref([
{ id: '3001', status: 'running', progress: 45 },
{ id: '3002', status: 'waiting', progress: 0 }
])
//
const historyTasks = [
{ id: '1024', name: '风景图通用防护任务', date: '2023-11-20', imageCount: 12 },
{ id: '1025', name: '人脸隐私加噪测试', date: '2023-11-21', imageCount: 5 },
{ id: '1026', name: '艺术风格迁移防御', date: '2023-11-22', imageCount: 8 }
]
const subpageType = computed(() => route.params.subpage)
const pageTitle = computed(() => subpageType.value === 'fine-tuning' ? '微调生图验证' : (subpageType.value === 'heatmap' ? '热力图分析' : '数据指标对比'))
const finetuneMode = ref('task')
const isLoadingImages = ref(false)
const isSubmitting = ref(false)
const taskImages = ref([])
const previewImage = ref(null)
const formData = ref({
taskName: '',
sourceId: '',
sourceName: '',
visType: 'attention'
finetuneConfig: FINETUNE_MAP.LORA, // LoRA
dataType: 1,
files: [],
selectedImageId: null,
customPrompt: ''
})
const subpageType = computed(() => route.params.subpage)
//
const finetuneOptions = [
{ id: FINETUNE_MAP.DREAMBOOTH, name: 'DreamBooth', desc: '高质量,耗时较长' },
{ id: FINETUNE_MAP.LORA, name: 'LoRA', desc: '平衡推荐,速度快' },
{ id: FINETUNE_MAP.TEXTUAL_INVERSION, name: 'Textual Inv', desc: '轻量级风格' }
]
const pageTitle = computed(() => {
const map = {
'tuning': '微调生图验证',
'metrics': '数据指标对比',
'heatmap': '热力图分析'
}
return map[subpageType.value] || '效果验证'
const candidateTasks = computed(() => {
const targetType = (subpageType.value === 'metrics') ? 'finetune' : 'perturbation'
return taskStore.tasks.filter(t =>
t.status === 'completed' && t.task_type === targetType
).map(t => {
let displayName = `Task #${t.task_id}`
if (t.description) displayName = t.description
else if (t.task_type === 'finetune' && t.finetune?.finetune_name) displayName = t.finetune.finetune_name
else if (t.perturbation?.perturbation_name) displayName = t.perturbation.perturbation_name
return { id: t.task_id, name: displayName, status: t.status }
})
})
const currentSourceName = computed(() => {
return formData.value.sourceName || '请选择数据源'
})
const currentSourceName = computed(() => formData.value.sourceName || '点击选择...')
const selectSource = (item) => {
const selectSource = async (item) => {
formData.value.sourceId = item.id
formData.value.sourceName = item.name
isSourceListOpen.value = false
formData.value.selectedImageId = null
taskImages.value = []
previewImage.value = null
isLoadingImages.value = true
try {
if (subpageType.value === 'heatmap') {
const res = await getTaskImagePreview(item.id)
if (res && res.images) {
const list = res.images.perturbed || res.images.original || []
taskImages.value = list.map(img => {
let rawData = img.data || img.base64 || ''
if (rawData && !rawData.startsWith('data:image')) {
rawData = `data:image/jpeg;base64,${rawData}`
}
return { ...img, image_id: img.image_id || img.id, base64: rawData }
})
}
}
else {
//
const res = await getTaskImagePreview(item.id) // 使
if (res && res.images) {
//
let targetImg = null
if (subpageType.value === 'fine-tuning') {
//
targetImg = res.images.perturbed?.[0] || res.images.original?.[0]
} else {
//
targetImg = res.images.original_generate?.[0] || res.images.perturbed_generate?.[0] || res.images.uploaded_generate?.[0]
}
if (targetImg) {
let raw = targetImg.data || targetImg.base64 || ''
if (raw && !raw.startsWith('data:image')) raw = `data:image/jpeg;base64,${raw}`
previewImage.value = raw
}
}
}
} catch (error) {
console.error('获取图片失败:', error)
} finally {
isLoadingImages.value = false
}
}
const selectImage = (img) => formData.value.selectedImageId = img.image_id
const triggerFileUpload = () => fileInput.value.click()
const handleFileChange = (e) => formData.value.files = Array.from(e.target.files)
const clearFiles = () => {
formData.value.files = []
if (fileInput.value) fileInput.value.value = ''
}
const submitTask = () => {
if (!formData.value.taskName) return alert('请填写任务名称')
if (!formData.value.sourceId) return alert('请选择数据源')
console.log(`Submitting Validation [${subpageType.value}]:`, formData.value)
alert('验证任务已提交 (模拟)')
const submitTask = async () => {
if (!formData.value.taskName) return alert('请输入任务名称')
isSubmitting.value = true
try {
let taskId = null
// 1.
if (subpageType.value === 'fine-tuning') {
if (finetuneMode.value === 'task') {
if (!formData.value.sourceId) throw new Error('请选择数据源')
const payload = {
perturbation_task_id: formData.value.sourceId,
finetune_configs_id: formData.value.finetuneConfig, // 使
finetune_name: formData.value.taskName,
custom_prompt: formData.value.customPrompt || undefined
}
const res = await submitFinetuneFromPerturbation(payload)
taskId = res.task?.task_id || res.job_id
} else {
if (!formData.value.files.length) throw new Error('请上传图片')
const form = new FormData()
form.append('finetune_configs_id', formData.value.finetuneConfig) // 使
form.append('data_type_id', formData.value.dataType)
form.append('finetune_name', formData.value.taskName)
form.append('description', '[Upload] ' + formData.value.taskName)
if(formData.value.customPrompt) form.append('custom_prompt', formData.value.customPrompt)
formData.value.files.forEach(f => form.append('files', f))
const res = await submitFinetuneFromUpload(form)
taskId = res.task?.task_id || res.job_id
}
if (taskId) {
await startFinetuneTask(taskId)
alert('微调任务已创建并启动!')
}
}
// 2.
else if (subpageType.value === 'metrics') {
if (!formData.value.sourceId) throw new Error('请选择微调任务')
const res = await submitEvaluateTask({
finetune_task_id: formData.value.sourceId,
evaluate_name: formData.value.taskName
})
taskId = res.task?.task_id || res.job_id
if (taskId) await startEvaluateTask(taskId)
alert('评估任务已创建并启动!')
}
// 3.
else if (subpageType.value === 'heatmap') {
if (!formData.value.sourceId) throw new Error('请选择加噪任务')
if (!formData.value.selectedImageId) throw new Error('请选择一张目标图片')
const res = await submitHeatmapTask({
perturbation_task_id: formData.value.sourceId,
perturbed_image_id: formData.value.selectedImageId,
heatmap_name: formData.value.taskName,
description: formData.value.taskName
})
alert(res.message || '热力图任务已创建并启动!')
}
//
formData.value.taskName = ''
formData.value.customPrompt = ''
clearFiles()
taskStore.fetchTasks()
} catch (error) {
console.error(error)
alert(error.message || '提交失败')
} finally {
isSubmitting.value = false
}
}
onMounted(() => taskStore.fetchTasks())
</script>
<style scoped>
/* === 基础布局复用 (Page1) === */
.subpage-layout { width: 100%; height: 100%; padding: 40px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); }
.layout-grid { display: grid; grid-template-columns: 300px 1fr; gap: 30px; width: 100%; max-width: 1400px; height: 85vh; }
/* === 基础布局 === */
.subpage-layout { width: 100%; height: 100%; padding: 20px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); overflow-y: auto; }
.layout-grid { display: grid; grid-template-columns: 280px 1fr; gap: 20px; width: 100%; max-width: 1400px; height: 95%; max-height: 95vh; min-height: 0; }
.grid-sidebar { height: 100%; overflow: hidden; }
.grid-main { height: 100%; min-width: 0; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; }
.card-header { padding: 30px 40px; border-bottom: 1px solid rgba(0,0,0,0.05); display: flex; justify-content: space-between; align-items: flex-start; }
.grid-main { height: 100%; min-width: 0; display: flex; flex-direction: column; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; overflow: hidden; }
.card-header { flex: 0 0 auto; padding: 20px 30px; border-bottom: 1px solid rgba(0,0,0,0.05); display: flex; justify-content: space-between; align-items: flex-start; }
.subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-top: 5px; }
.tag { background: var(--color-contrast-dark); color: #fff; padding: 6px 16px; border-radius: 20px; font-weight: 700; font-size: 0.8rem; }
.card-body { padding: 40px; flex: 1; overflow-y: auto; }
.desc-text { color: var(--color-text-main); margin-bottom: 40px; padding: 15px 20px; background: rgba(24, 40, 59, 0.03); border-left: 4px solid var(--color-contrast-dark); border-radius: 4px; }
.form-wrapper { display: flex; flex-direction: column; gap: 30px; }
.form-group label { display: block; font-size: 1rem; font-weight: 600; margin-bottom: 12px; color: var(--color-text-main); }
.ui-input { width: 100%; padding: 16px; border: 1px solid #e0e0e0; border-radius: 12px; font-size: 1rem; background: #f8f9fa; outline: none; transition: all 0.2s; }
.ui-input:focus { background: #fff; border-color: var(--color-contrast-dark); }
.submit-area { margin-top: 20px; }
.big-btn { width: 100%; height: 60px; font-size: 1.2rem; border-radius: 16px; }
/* === 验证页面特有样式 === */
/* 数据源选择器 */
.source-selector {
position: relative;
width: 100%;
.card-body { padding: 30px; flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 60px; }
/* === 表单 === */
.form-wrapper { display: flex; flex-direction: column; gap: 25px; }
.form-group label { display: block; font-weight: 600; margin-bottom: 10px; }
.ui-input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; min-width: 0; }
.big-btn { width: 100%; height: 50px; font-size: 1.1rem; border-radius: 10px; margin-top: 10px; }
.fade-in { animation: fadeIn 0.3s ease-in-out; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* === 模式切换 Tab === */
.mode-tabs { display: flex; gap: 15px; margin-bottom: 25px; border-bottom: 2px solid #eee; padding-bottom: 10px; flex-wrap: wrap; }
.tab-btn { padding: 10px 20px; border: none; background: transparent; cursor: pointer; font-size: 1rem; color: #999; font-weight: 600; border-radius: 8px; transition: all 0.2s; white-space: nowrap; }
.tab-btn.active { background: var(--color-contrast-dark); color: #fff; }
.tab-btn.vip { color: var(--color-accent-secondary); }
.tab-btn.vip.active { background: linear-gradient(135deg, #FFD166, #FF9F1C); color: #fff; }
/* === 风格/算法选择器 === */
.style-selector {
display: grid;
gap: 15px;
}
/* 微调配置专用 (3列自适应) */
.finetune-selector {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.source-trigger {
border: 1px solid #e0e0e0;
border-radius: 16px;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
background: #f8f9fa;
transition: all 0.2s;
.style-option {
position: relative; background: #f8f9fa; border-radius: 12px; padding: 15px;
cursor: pointer; display: flex; flex-direction: column; gap: 5px;
border: 2px solid transparent; transition: all 0.2s;
}
.style-option:hover { background: #f1f3f5; }
.style-option.active {
background: #fff; border-color: var(--color-contrast-dark);
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
}
.check-mark { position: absolute; top: 8px; right: 8px; color: var(--color-contrast-dark); font-size: 0.9rem; }
.option-text { display: flex; flex-direction: column; }
.opt-title { font-weight: 700; font-size: 0.95rem; }
.opt-desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
.source-trigger:hover { background: #fff; border-color: #ccc; }
.source-trigger.active { background: #fff; border-color: var(--color-contrast-dark); box-shadow: 0 0 0 4px rgba(24, 40, 59, 0.05); }
.source-trigger.selected .icon-box { background: var(--color-contrast-dark); color: #fff; }
.trigger-left { display: flex; align-items: center; gap: 15px; }
.icon-box { width: 40px; height: 40px; background: #e9ecef; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: var(--color-text-muted); transition: all 0.2s; }
.trigger-info { display: flex; flex-direction: column; }
.t-title { font-weight: 600; font-size: 1rem; }
.t-desc { font-size: 0.8rem; color: var(--color-text-muted); }
.arrow { transition: transform 0.3s; color: var(--color-text-muted); }
.source-trigger.active .arrow { transform: rotate(180deg); }
/* 下拉列表 */
.source-list {
margin-top: 10px;
background: #fff;
border: 1px solid rgba(0,0,0,0.05);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
overflow: hidden;
/* === 上传与选择器 === */
.upload-zone { position: relative; border: 2px dashed #ddd; border-radius: 12px; padding: 30px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fcfcfc; }
.upload-zone:hover { border-color: var(--color-contrast-dark); background: #fff; }
.upload-zone.has-file { border-style: solid; border-color: #2e7d32; background: #f1f8e9; }
.upload-icon { font-size: 2rem; color: var(--color-text-muted); margin-bottom: 10px; }
.success-icon { color: #2e7d32 !important; }
.file-name { font-weight: 700; color: #2e7d32; }
.clear-file-btn {
position: absolute; top: 10px; right: 10px; width: 30px; height: 30px;
background: rgba(220, 53, 69, 0.1); color: #dc3545;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
transition: all 0.2s; z-index: 10;
}
.clear-file-btn:hover { background: #dc3545; color: #fff; transform: scale(1.1); }
.clear-file-btn i { font-size: 1rem; margin: 0; color: inherit; }
.list-header {
padding: 10px 20px;
font-size: 0.8rem;
font-weight: 700;
color: var(--color-text-muted);
background: #f8f9fa;
border-bottom: 1px solid #eee;
.source-selector { position: relative; }
.source-trigger { border: 1px solid #eee; padding: 12px; border-radius: 8px; display: flex; justify-content: space-between; cursor: pointer; background: #f9f9f9; }
.source-list { position: absolute; top: 100%; width: 100%; background: #fff; border: 1px solid #eee; box-shadow: 0 5px 20px rgba(0,0,0,0.1); z-index: 10; max-height: 200px; overflow-y: auto; }
.source-item { padding: 10px; cursor: pointer; display: flex; justify-content: space-between; }
.source-item:hover { background: #f0f0f0; }
.s-tag { font-size: 0.8rem; background: #e8f5e9; color: #2e7d32; padding: 2px 6px; border-radius: 4px; }
/* === 图片选择网格 === */
.image-select-container {
border: 2px dashed #e0e0e0; border-radius: 12px; padding: 15px; background: #fafafa;
min-height: 180px; max-height: 40vh; overflow-y: auto; display: flex; flex-direction: column;
}
.disabled .image-select-container { opacity: 0.6; pointer-events: none; }
.loading-box, .empty-box { flex: 1; display: flex; align-items: center; justify-content: center; color: #999; font-size: 0.9rem; min-height: 120px; }
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 12px; width: 100%; }
.img-item { aspect-ratio: 1; border-radius: 8px; overflow: hidden; position: relative; cursor: pointer; border: 3px solid transparent; transition: all 0.2s; background: #eee; }
.img-item img { width: 100%; height: 100%; object-fit: cover; }
.img-item:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.img-item.active { border-color: var(--color-accent-secondary); transform: scale(0.95); }
.check-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 159, 28, 0.4); display: none; align-items: center; justify-content: center; color: white; font-size: 1.5rem; backdrop-filter: blur(1px); }
.img-item.active .check-overlay { display: flex; }
.single-preview-area { margin-top: 10px; background: #f8f9fa; border-radius: 8px; padding: 10px; border: 1px dashed #ddd; display: flex; align-items: center; justify-content: center; min-height: 80px; }
.preview-content { display: flex; align-items: center; gap: 15px; width: 100%; }
.preview-label { font-weight: 600; font-size: 0.9rem; color: #666; white-space: nowrap; }
.preview-img-sm { height: 60px; width: 60px; object-fit: cover; border-radius: 6px; border: 1px solid #eee; }
.source-item {
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid #f8f9fa;
/* === 响应式适配 (High Zoom / Mobile) === */
@media (max-width: 900px) {
.subpage-layout { padding: 15px; height: auto; min-height: 100vh; align-items: flex-start; }
.layout-grid { grid-template-columns: 1fr; display: flex; flex-direction: column; height: auto; max-height: none; gap: 15px; }
.grid-sidebar { display: none; }
.content-card { height: auto; overflow: visible; }
.card-body { overflow: visible; padding: 20px; }
.mode-tabs { flex-direction: column; gap: 10px; }
.tab-btn { width: 100%; text-align: center; }
.style-selector { grid-template-columns: 1fr; }
}
.source-item:last-child { border-bottom: none; }
.source-item:hover { background: #f0f7ff; }
.s-info { display: flex; flex-direction: column; }
.s-name { font-weight: 600; color: var(--color-text-main); }
.s-date { font-size: 0.8rem; color: var(--color-text-muted); }
.s-tag { font-size: 0.8rem; padding: 2px 8px; background: #e9ecef; border-radius: 4px; color: var(--color-text-muted); }
/* 动画 */
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; max-height: 300px; opacity: 1; }
.expand-enter-from, .expand-leave-to { max-height: 0; opacity: 0; }
/* 简单选项 */
.style-selector { display: flex; gap: 20px; }
.style-option { flex: 1; padding: 15px; border-radius: 12px; background: #f8f9fa; border: 1px solid #e0e0e0; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 10px; font-weight: 600; transition: all 0.2s; }
.style-option:hover { background: #fff; box-shadow: 0 4px 10px rgba(0,0,0,0.05); }
.style-option.active { background: var(--color-contrast-dark); color: #fff; border-color: var(--color-contrast-dark); }
</style>

@ -1,165 +1,394 @@
<template>
<div class="view-container">
<!-- 头部区域 -->
<div class="header-section">
<h1 class="page-header">资源管理</h1>
<p class="page-desc">My Assets & History</p>
<div class="header-content">
<h1 class="page-header">任务历史</h1>
<p class="page-desc">Task History Management</p>
</div>
<!-- 状态筛选 -->
<div class="filter-tabs">
<div v-for="tab in statusTabs" :key="tab.key" class="tab-item"
:class="{ active: currentStatus === tab.key }"
@click="handleStatusChange(tab.key)">
{{ tab.label }}
</div>
</div>
</div>
<div class="split-layout">
<!-- 左侧主面板任务历史 -->
<div class="ui-card solid interactive left-panel" @click="handleOpen('tasks')">
<div class="panel-header">
<div class="icon-box main">
<i class="fas fa-tasks"></i>
</div>
<div class="text-group">
<h2>任务历史</h2>
<p>查看所有防护任务的状态与详情</p>
</div>
<!-- 主内容区 -->
<div class="main-content">
<div class="ui-card solid table-card">
<!-- 桌面表头 (移动端隐藏) -->
<div class="list-header-row desktop-only-flex">
<div class="col-id sortable" @click="handleSort('task_id', $event)">ID <i :class="getSortIcon('task_id')"></i></div>
<div class="col-info sortable" @click="handleSort('name', $event)">任务名称 <i :class="getSortIcon('name')"></i></div>
<div class="col-type sortable" @click="handleSort('task_type', $event)">类型 <i :class="getSortIcon('task_type')"></i></div>
<div class="col-time sortable" @click="handleSort('created_at', $event)">时间 <i :class="getSortIcon('created_at')"></i></div>
<div class="col-status sortable" @click="handleSort('status', $event)">状态 <i :class="getSortIcon('status')"></i></div>
<div class="col-action">操作</div>
</div>
<!-- 装饰性的列表预览 -->
<div class="list-preview">
<div class="preview-item" v-for="n in 3" :key="n">
<div class="line sm"></div>
<div class="line lg"></div>
<div class="badge-dot"></div>
<!-- 列表内容 -->
<div class="list-body">
<div v-if="paginatedTasks.length === 0" class="empty-state"></div>
<div v-for="task in paginatedTasks" :key="task.task_id" class="list-row">
<!-- === 1. 移动端/高缩放 专用结构 (默认隐藏) === -->
<div class="mobile-only-block">
<div class="mobile-row-header">
<span class="id-tag">#{{ task.task_id }}</span>
<span class="status-badge-mobile" :class="normalizeStatus(task.status)">
{{ formatStatusLabel(task.status) }}
</span>
</div>
<!-- 移动端名称 -->
<div class="mobile-task-name">{{ getTaskName(task) }}</div>
<div class="mobile-meta-row">
<span class="type-badge">{{ task.task_type }}</span>
<span class="time-text">{{ formatDate(task.created_at) }}</span>
</div>
</div>
<!-- === 2. 桌面端 专用列 (移动端隐藏) === -->
<!-- 必须严格对应 Grid 的列顺序 -->
<!-- Col 1: ID -->
<div class="col-id desktop-only-cell">
<span class="id-tag">#{{ task.task_id }}</span>
</div>
<!-- Col 2: Info (名称) -->
<div class="col-info desktop-only-cell">
<span class="task-name">{{ getTaskName(task) }}</span>
</div>
<!-- Col 3: Type -->
<div class="col-type desktop-only-cell">
<span class="type-badge">{{ task.task_type }}</span>
</div>
<!-- Col 4: Time -->
<div class="col-time desktop-only-cell">
{{ formatDate(task.created_at) }}
</div>
<!-- Col 5: Status -->
<div class="col-status desktop-only-cell">
<span class="status-dot" :class="normalizeStatus(task.status)"></span>
{{ formatStatusLabel(task.status) }}
</div>
<!-- Col 6: Action (共用但样式微调) -->
<div class="col-action">
<button
v-if="['pending','waiting','processing','running'].includes(task.status)"
class="ui-btn glass sm icon-only danger"
title="取消任务"
@click="handleCancel(task)"
>
<i class="fas fa-stop"></i>
</button>
<button v-if="task.status === 'completed'" class="ui-btn glass sm icon-only" title="预览结果" @click="handlePreview(task)">
<i class="fas fa-eye"></i>
</button>
<button v-if="task.status === 'completed'" class="ui-btn glass sm icon-only" title="下载结果" @click="handleDownload(task)">
<i class="fas fa-download"></i>
</button>
</div>
</div>
</div>
<div class="ui-btn gradient rect mt-auto">管理任务</div>
</div>
<!-- 右侧次级面板 -->
<div class="right-col">
<!-- 已防护图片 -->
<div class="ui-card glass interactive sub-panel" @click="handleOpen('images')">
<div class="icon-box sub">
<i class="fas fa-images"></i>
</div>
<div class="text-group">
<h3>图片库</h3>
<p>已处理的加密图像资源</p>
</div>
<!-- 分页器 -->
<div class="pagination-footer">
<button class="page-btn" :disabled="currentPage <= 1" @click="currentPage--"><i class="fas fa-chevron-left"></i></button>
<span class="page-info">{{ currentPage }} / {{ totalPages || 1 }} </span>
<button class="page-btn" :disabled="currentPage >= totalPages" @click="currentPage++"><i class="fas fa-chevron-right"></i></button>
</div>
</div>
</div>
<!-- 预览组件 -->
<ImagePreviewModal
:is-open="showPreview"
:task-id="previewTaskId"
:task-type="previewTaskType"
@close="showPreview = false"
/>
<!-- 验证结果 -->
<div class="ui-card glass interactive sub-panel" @click="handleOpen('results')">
<div class="icon-box sub">
<i class="fas fa-file-alt"></i>
<!-- 日志弹窗 -->
<Teleport to="body">
<div v-if="showLogModal" class="log-overlay" @click.self="showLogModal = false">
<div class="ui-card solid log-card">
<div class="log-header">
<h3>运行日志 Task #{{ currentLogTaskId }}</h3>
<button class="close-btn" @click="showLogModal = false"><i class="fas fa-times"></i></button>
</div>
<div class="text-group">
<h3>分析报告</h3>
<p>效果验证生成的评估文档</p>
<div class="log-content">
<pre v-if="logContent">{{ logContent }}</pre>
<div v-else class="loading-log"><i class="fas fa-spinner fa-spin"></i> 加载日志中...</div>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { inject } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import { getTaskResultImages, cancelTask, getTaskLogs } from '@/api/task'
import JSZip from 'jszip'
import ImagePreviewModal from '@/components/ImagePreviewModal.vue'
const openSubpage = inject('openSubpage')
const taskStore = useTaskStore()
const currentStatus = ref('all')
const statusTabs = [
{ key: 'all', label: '全部' },
{ key: 'running', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'failed', label: '失败' }
]
const sortRules = ref([{ field: 'created_at', direction: 'desc' }])
const currentPage = ref(1)
const pageSize = ref(10)
const showPreview = ref(false)
const previewTaskId = ref(null)
const showLogModal = ref(false)
const currentLogTaskId = ref(null)
const logContent = ref('')
const previewTaskType = ref('')
const handleOpen = (type) => {
openSubpage('page4', type)
const normalizeStatus = (status) => {
if (['pending', 'processing', 'waiting', 'running'].includes(status)) return 'running'
return status
}
</script>
<style scoped>
.view-container {
top: 5%;
width: 100%;
height: 90%;
display: flex;
flex-direction: column;
const formatStatusLabel = (s) => {
const map = { running: '运行中', processing: '处理中', pending: '排队中', waiting: '排队中', completed: '已完成', failed: '失败' }
return map[s] || s
}
const getTaskName = (t) => {
if (t.description && t.description.trim()) return t.description
if (t.task_type === 'finetune' && t.finetune?.finetune_name) return t.finetune.finetune_name
if (t.task_type === 'evaluate' && t.evaluate?.evaluate_name) return t.evaluate.evaluate_name
if (t.heatmap?.heatmap_name) return t.heatmap.heatmap_name
if (t.perturbation?.perturbation_name) return t.perturbation.perturbation_name
return `Task #${t.task_id}`
}
const formatDate = (iso) => iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-'
.header-section { margin-bottom: var(--space-md); flex: 0 0 auto; }
.page-header { font-size: 2.5rem; color: var(--color-contrast-dark); margin-bottom: 5px; }
.page-desc { color: var(--color-text-muted); font-size: 1.1rem; }
const handleStatusChange = (status) => { currentStatus.value = status; currentPage.value = 1 }
.split-layout {
flex: 1;
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--space-md);
min-height: 0;
}
const filteredAndSortedTasks = computed(() => {
let result = [...(taskStore.tasks || [])]
if (currentStatus.value !== 'all') {
result = result.filter(t => normalizeStatus(t.status) === currentStatus.value)
}
if (sortRules.value.length > 0) {
result.sort((a, b) => {
const rule = sortRules.value[0]
const dir = rule.direction === 'asc' ? 1 : -1
let valA = rule.field === 'created_at' ? new Date(a.created_at).getTime() : a[rule.field]
let valB = rule.field === 'created_at' ? new Date(b.created_at).getTime() : b[rule.field]
return (valA > valB ? 1 : -1) * dir
})
}
return result
})
const totalPages = computed(() => Math.ceil(filteredAndSortedTasks.value.length / pageSize.value))
const paginatedTasks = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredAndSortedTasks.value.slice(start, start + pageSize.value)
})
/* 左侧面板 */
.left-panel {
padding: var(--space-xl);
display: flex;
flex-direction: column;
const handleSort = (field) => {
if (sortRules.value[0].field === field) {
sortRules.value[0].direction = sortRules.value[0].direction === 'asc' ? 'desc' : 'asc'
} else {
sortRules.value = [{ field, direction: 'asc' }]
}
}
const getSortIcon = (field) => {
const rule = sortRules.value.find(r => r.field === field)
return rule ? (rule.direction === 'asc' ? 'fas fa-sort-up active' : 'fas fa-sort-down active') : 'fas fa-sort dim'
}
.panel-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 30px;
const handlePreview = (task) => {
previewTaskId.value = task.task_id
previewTaskType.value = task.task_type
showPreview.value = true
}
.icon-box {
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
const handleCancel = async (task) => {
if (!confirm(`确定要终止任务 #${task.task_id} 吗?`)) return
try {
await cancelTask(task.task_id)
alert('已发送取消请求')
taskStore.fetchTasks()
} catch (e) {
console.error(e)
alert('取消失败: ' + e.message)
}
}
.icon-box.main { width: 64px; height: 64px; background: var(--color-contrast-dark); color: #fff; font-size: 1.8rem; }
.icon-box.sub { width: 50px; height: 50px; background: rgba(255,255,255,0.6); color: var(--color-contrast-dark); font-size: 1.4rem; }
.text-group h2 { font-size: 1.8rem; margin: 0; }
.text-group h3 { font-size: 1.4rem; margin-bottom: 5px; }
.text-group p { margin: 0; color: var(--color-text-muted); }
.list-preview {
flex: 1;
display: flex;
flex-direction: column;
gap: 15px;
opacity: 0.6;
margin-bottom: 20px;
const handleDownload = async (task) => {
if (task.status !== 'completed') return alert('任务未完成')
try {
const type = task.task_type || 'perturbation'
let res = await getTaskResultImages(type, task.task_id)
if (res instanceof Blob) res = JSON.parse(await res.text())
const zip = new JSZip()
const folder = zip.folder(`task_${task.task_id}`)
let hasImg = false
const processImg = (img, prefix='') => {
const d = img.data || img.base64
let n = img.filename || img.stored_filename || `${prefix}_${img.image_id}.png`
if (d) { folder.file(n, d.includes(',') ? d.split(',')[1] : d, { base64: true }); hasImg = true }
}
if (res.images) res.images.forEach(i => processImg(i))
else {
['original_generate', 'perturbed_generate', 'uploaded_generate'].forEach(k => {
if (res[k]) res[k].forEach(i => processImg(i, k))
})
}
if (!hasImg) return alert('无图片数据')
const content = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(content)
const link = document.createElement('a')
link.href = url
link.download = `task_${task.task_id}.zip`
link.click()
} catch (e) { alert('下载出错') }
}
.preview-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
background: var(--color-bg-secondary);
border-radius: 8px;
onMounted(() => taskStore.fetchTasks())
</script>
<style scoped>
/* === 1. 基础布局 === */
.view-container {
width: 100%;
/* margin: auto 配合 flex column在高度足够时居中不足时置顶 */
margin: auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 40px 20px 120px 20px;
}
.line { height: 8px; background: #ddd; border-radius: 4px; }
.line.sm { width: 40px; }
.line.lg { flex: 1; }
.badge-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-accent-secondary); }
.header-section { flex: 0 0 auto; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 2px solid #eee; padding-bottom: 10px; }
.page-header { font-size: 2.5rem; margin: 0; line-height: 1; color: var(--color-contrast-dark); }
.page-desc { color: #999; margin-top: 5px; }
.filter-tabs { display: flex; background: rgba(255,255,255,0.5); padding: 4px; border-radius: 12px; gap: 5px; flex-wrap: wrap;}
.tab-item { padding: 8px 20px; border-radius: 8px; font-size: 0.9rem; color: #999; cursor: pointer; font-weight: 600; white-space: nowrap; }
.tab-item.active { background: var(--color-contrast-dark); }
.mt-auto { margin-top: auto; align-self: flex-start; }
.main-content { flex: 1; width: 100%; max-width: 1400px; margin: 0 auto; }
.table-card { height: auto; min-height: 400px; display: flex; flex-direction: column; padding: 0; background: #fff; }
/* 右侧列 */
.right-col {
display: grid;
grid-template-rows: 1fr 1fr;
gap: var(--space-md);
/* === 2. 桌面端 Grid 布局 (默认) === */
.list-header-row, .list-row {
display: grid;
/* 严格的 6 列定义 */
grid-template-columns: 80px 1.5fr 100px 180px 120px 160px;
padding: 12px 20px;
align-items: center;
border-bottom: 1px solid #f1f1f1;
}
.sub-panel {
padding: var(--space-lg);
display: flex;
align-items: center;
gap: 20px;
transition: all 0.2s;
.list-header-row {
background: #f8f9fa; font-weight: 700; color: #666; border-bottom: 1px solid #eee;
padding: 15px 20px;
}
.sub-panel:hover {
transform: translateX(-5px);
background: #fff;
.list-body { overflow: visible; padding: 0 10px; }
.list-row:hover { background: #fcfcfc; }
/* 通用元素样式 */
.col-action { display: flex; justify-content: flex-end; gap: 8px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.status-dot.running { background: var(--color-accent-secondary); box-shadow: 0 0 5px var(--color-accent-secondary); }
.status-dot.completed { background: #2e7d32; }
.status-dot.failed { background: #c62828; }
.type-badge { font-size: 0.8rem; background: rgba(255, 159, 28, 0.1); color: var(--color-accent-secondary); padding: 2px 6px; border-radius: 4px; }
.id-tag { background: #eee; padding: 2px 6px; border-radius: 4px; font-weight: bold; color: #666; font-size: 0.8rem; }
.task-name { font-weight: 600; font-size: 0.95rem; }
.ui-btn.sm { padding: 6px 12px; font-size: 0.9rem; }
.ui-btn.icon-only { width: 32px; height: 32px; padding: 0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; }
.ui-btn.icon-only.danger { color: #dc3545; background: rgba(220, 53, 69, 0.1); }
.ui-btn.icon-only.danger:hover { background: #dc3545; color: #fff; }
.pagination-footer { padding: 20px; display: flex; justify-content: center; align-items: center; border-top: 1px solid #eee; margin-top: auto; }
.page-btn { background: none; border: 1px solid #ddd; width: 30px; height: 30px; border-radius: 4px; cursor: pointer; margin: 0 10px; display: flex; align-items: center; justify-content: center; }
.sort-icon { font-size: 0.8rem; margin-left: 4px; }
.sort-icon.active { color: var(--color-accent-secondary); }
.sort-icon.dim { opacity: 0.2; }
/* === 3. 显隐控制类 === */
.mobile-only-block { display: none; } /* 默认隐藏移动端块 */
.desktop-only-flex { display: grid; }
.desktop-only-cell { display: block; } /* Grid 单元格默认显示 */
/* === 4. 高缩放/窄屏适配 (宽度 < 900px) === */
@media (max-width: 900px) {
.view-container {
padding-top: 60px;
}
.header-section { flex-direction: column; align-items: flex-start; gap: 15px; }
.filter-tabs { width: 100%; overflow-x: auto; padding-bottom: 5px; }
/* 隐藏桌面端元素 */
.desktop-only-flex, .desktop-only-cell { display: none !important; }
/* 显示移动端元素 */
.mobile-only-block { display: block; }
/* 将每一行改为 Flex Column 卡片式布局 */
.list-row {
display: flex;
flex-direction: column;
gap: 10px;
background: #f8f9fa;
margin-bottom: 15px;
border-radius: 12px;
padding: 15px;
border: 1px solid #eee;
align-items: stretch;
}
/* 移动端内部布局 */
.mobile-row-header {
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px dashed #e0e0e0; padding-bottom: 8px; margin-bottom: 5px;
}
.status-badge-mobile { display: inline-block; font-size: 0.8rem; padding: 2px 8px; border-radius: 99px; font-weight: 600; }
.status-badge-mobile.running { background: rgba(255, 159, 28, 0.1); color: var(--color-accent-secondary); }
.status-badge-mobile.completed { background: #e8f5e9; color: #2e7d32; }
.status-badge-mobile.failed { background: #ffebee; color: #c62828; }
.mobile-task-name { font-size: 1.1rem; display: block; margin-bottom: 5px; font-weight: 600; color: var(--color-contrast-dark); }
.mobile-meta-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; color: #888; }
.col-action { margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }
.ui-btn.icon-only { width: 40px; height: 40px; font-size: 1.1rem; }
}
/* 弹窗样式 */
.log-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 2000; display: flex; justify-content: center; align-items: center; }
.log-card { width: 600px; height: 500px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; background: #fff; border-radius: 12px; }
.log-header { padding: 15px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; font-weight: bold; }
.log-content { flex: 1; padding: 20px; overflow-y: auto; background: #1e1e1e; color: #0f0; font-family: monospace; font-size: 0.9rem; margin: 0; }
.log-content pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
.loading-log { color: #888; text-align: center; margin-top: 50px; }
.close-btn { background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #999; }
</style>

@ -1,243 +0,0 @@
<template>
<div class="subpage-layout">
<div class="layout-grid">
<!-- 左侧侧边栏 (显示当前队列保持上下文) -->
<aside class="grid-sidebar">
<TaskSideBar :tasks="sidebarTasks" />
</aside>
<!-- 右侧数据列表区域 -->
<main class="grid-main">
<div class="ui-card solid content-card">
<!-- 头部动态标题 -->
<div class="card-header">
<div>
<h2>{{ pageInfo.title }}</h2>
<p class="subtitle">{{ pageInfo.subtitle }}</p>
</div>
<span class="tag">{{ pageInfo.tag }}</span>
</div>
<div class="card-body">
<!-- === 场景 1: 任务列表 === -->
<div v-if="subpageType === 'tasks'" class="view-section">
<!-- 状态筛选器 -->
<div class="filter-tabs">
<div
v-for="tab in statusTabs"
:key="tab.key"
class="tab-item"
:class="{ active: currentStatus === tab.key }"
@click="currentStatus = tab.key"
>
{{ tab.label }}
</div>
</div>
<!-- 任务列表 (Div 模拟表格) -->
<div class="data-list">
<div v-for="task in filteredTasks" :key="task.id" class="list-card">
<div class="lc-left">
<span class="lc-id">#{{ task.id }}</span>
<div class="lc-info">
<span class="lc-title">{{ task.name }}</span>
<span class="lc-date">{{ task.createdAt }}</span>
</div>
</div>
<div class="lc-right">
<span class="status-badge" :class="task.status">{{ statusLabel(task.status) }}</span>
<button class="ui-btn glass rounded sm">详情</button>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredTasks.length === 0" class="empty-hint"></div>
</div>
</div>
<!-- === 场景 2: 图片库 === -->
<div v-else-if="subpageType === 'images'" class="view-section">
<div class="image-grid">
<div v-for="img in protectedImages" :key="img.id" class="img-card">
<div class="img-placeholder">
<i class="fas fa-image"></i>
</div>
<div class="img-meta">
<span class="img-title">{{ img.taskName }}</span>
<span class="img-tag">{{ img.type }}</span>
</div>
</div>
</div>
</div>
<!-- === 场景 3: 验证结果 === -->
<div v-else-if="subpageType === 'results'" class="view-section">
<div class="data-list">
<div v-for="res in validationResults" :key="res.id" class="list-card result-style">
<div class="lc-icon">
<i class="fas fa-file-contract"></i>
</div>
<div class="lc-info">
<span class="lc-title">{{ res.taskName }}</span>
<span class="lc-desc">{{ res.resultType }} - {{ res.compareType }}</span>
</div>
<div class="lc-actions">
<i class="fas fa-download action-icon"></i>
<i class="fas fa-eye action-icon"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import TaskSideBar from '@/components/TaskSideBar.vue'
const route = useRoute()
const subpageType = computed(() => route.params.subpage)
// 使 (Mock)
const sidebarTasks = ref([
{ id: '1028', status: 'running', progress: 55 },
{ id: '1029', status: 'waiting', progress: 0 }
])
// === ===
const pageInfo = computed(() => {
const map = {
'tasks': { title: '任务历史', subtitle: 'Task History Management', tag: 'Tasks' },
'images': { title: '图片资源库', subtitle: 'Protected Image Assets', tag: 'Gallery' },
'results': { title: '验证报告', subtitle: 'Validation & Analysis Reports', tag: 'Reports' }
}
return map[subpageType.value] || { title: '资源详情', subtitle: '', tag: 'Data' }
})
// === 1: ===
const currentStatus = ref('all')
const statusTabs = [
{ key: 'all', label: '全部' },
{ key: 'running', label: '运行中' },
{ key: 'completed', label: '已完成' },
{ key: 'failed', label: '失败' }
]
const allTasks = [
{ id: '1024', name: '风景图通用防护', status: 'completed', createdAt: '2023-11-20' },
{ id: '1025', name: '人脸隐私保护', status: 'running', createdAt: '2023-11-21' },
{ id: '1023', name: '测试任务失败案例', status: 'failed', createdAt: '2023-11-19' },
{ id: '1022', name: '批量艺术风格', status: 'completed', createdAt: '2023-11-18' }
]
const filteredTasks = computed(() => {
if (currentStatus.value === 'all') return allTasks
return allTasks.filter(t => t.status === currentStatus.value)
})
const statusLabel = (s) => {
const map = { running: '运行中', completed: '已完成', failed: '失败' }
return map[s] || s
}
// === 2: ===
const protectedImages = [
{ id: 'P01', taskName: '任务 #1024', type: '通用防护' },
{ id: 'P02', taskName: '任务 #1025', type: '人脸保护' },
{ id: 'P03', taskName: '任务 #1022', type: '风格迁移' },
{ id: 'P04', taskName: '任务 #1022', type: '风格迁移' }
]
// === 3: ===
const validationResults = [
{ id: 'R01', taskName: '微调效果对比-A组', compareType: '样例图', resultType: '图像报告' },
{ id: 'R02', taskName: 'PSNR指标分析', compareType: '数据表', resultType: 'CSV表格' }
]
</script>
<style scoped>
/* 基础布局复用 */
.subpage-layout { width: 100%; height: 100%; padding: 40px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); }
.layout-grid { display: grid; grid-template-columns: 300px 1fr; gap: 30px; width: 100%; max-width: 1400px; height: 85vh; }
.grid-sidebar { height: 100%; overflow: hidden; }
.grid-main { height: 100%; min-width: 0; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; }
.card-header { padding: 30px 40px; border-bottom: 1px solid rgba(0,0,0,0.05); display: flex; justify-content: space-between; align-items: flex-start; }
.subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-top: 5px; }
.tag { background: var(--color-contrast-dark); color: #fff; padding: 6px 16px; border-radius: 20px; font-weight: 700; font-size: 0.8rem; }
.card-body { padding: 40px; flex: 1; overflow-y: auto; }
/* === 筛选 Tab === */
.filter-tabs {
display: flex;
gap: 10px;
margin-bottom: 30px;
border-bottom: 1px solid #eee;
padding-bottom: 15px;
}
.tab-item {
padding: 8px 16px;
border-radius: 8px;
font-size: 0.9rem;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.2s;
}
.tab-item:hover { background: #f8f9fa; }
.tab-item.active { background: var(--color-contrast-dark); color: #fff; font-weight: 600; }
/* === 列表卡片样式 (用于任务和结果) === */
.data-list { display: flex; flex-direction: column; gap: 15px; }
.list-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border: 1px solid #eee;
border-radius: 12px;
transition: all 0.2s;
background: #fcfcfc;
}
.list-card:hover { border-color: var(--color-contrast-dark); background: #fff; box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
.lc-left { display: flex; gap: 20px; align-items: center; }
.lc-id { font-weight: 700; color: var(--color-accent-secondary); background: rgba(255, 159, 28, 0.1); padding: 4px 8px; border-radius: 6px; font-size: 0.9rem; }
.lc-info { display: flex; flex-direction: column; }
.lc-title { font-weight: 600; font-size: 1rem; color: var(--color-text-main); }
.lc-date { font-size: 0.8rem; color: var(--color-text-muted); }
.lc-right { display: flex; align-items: center; gap: 15px; }
.status-badge { font-size: 0.85rem; font-weight: 600; padding: 4px 10px; border-radius: 99px; }
.status-badge.completed { color: #2e7d32; background: #e8f5e9; }
.status-badge.running { color: #1976d2; background: #e3f2fd; }
.status-badge.failed { color: #c62828; background: #ffebee; }
.sm { font-size: 0.8rem; padding: 0.4em 1em; }
/* === 结果列表特有样式 === */
.result-style .lc-icon { width: 40px; height: 40px; background: #f1f3f5; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: var(--color-text-muted); }
.result-style .lc-desc { font-size: 0.85rem; color: var(--color-text-muted); }
.lc-actions { display: flex; gap: 15px; font-size: 1.1rem; color: var(--color-text-muted); }
.action-icon { cursor: pointer; transition: color 0.2s; }
.action-icon:hover { color: var(--color-contrast-dark); }
/* === 图片网格 === */
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
.img-card { border: 1px solid #eee; border-radius: 12px; overflow: hidden; transition: all 0.2s; }
.img-card:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,0.05); }
.img-placeholder { height: 150px; background: #f1f3f5; display: flex; align-items: center; justify-content: center; color: #adb5bd; font-size: 2rem; }
.img-meta { padding: 15px; display: flex; flex-direction: column; }
.img-title { font-weight: 600; font-size: 0.95rem; }
.img-tag { font-size: 0.8rem; color: var(--color-text-muted); margin-top: 5px; }
.empty-hint { text-align: center; color: var(--color-text-muted); padding: 40px; }
</style>

@ -1,212 +1,199 @@
<template>
<div class="page5-container">
<!-- 1. 顶部用户信息卡片 -->
<div class="header-section">
<div class="user-profile-card ui-card glass">
<div class="avatar-circle">
{{ userInitials }}
</div>
<div class="user-info">
<div class="name-row">
<h1>{{ userData.username }}</h1>
<span class="role-badge" :class="userData.role">
{{ userData.role === 'admin' ? '管理员' : '普通用户' }}
</span>
<div class="main-view">
<!-- 用户卡片 -->
<div class="header-section">
<div class="user-profile-card ui-card glass">
<div class="avatar-circle">{{ userStore.initials }}</div>
<div class="user-info">
<div class="name-row">
<h1>{{ userStore.userInfo?.username || 'Guest' }}</h1>
<!-- [修正] 使用 role 字符串 -->
<span class="role-badge" :class="getRoleClass(userStore.role)">
{{ formatRole(userStore.role) }}
</span>
</div>
<p class="email">{{ userStore.userInfo?.email || 'No Email' }}</p>
</div>
<div class="logout-wrapper">
<button class="ui-btn glass circle" @click="handleLogout" title="退出登录">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
<p class="email">{{ userData.email || 'loading...' }}</p>
</div>
<div class="logout-wrapper">
<button class="ui-btn glass circle" @click="handleLogout" title="退出登录">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
<!-- 2. 统计数据看板 (改为3列布局) -->
<div class="stats-grid">
<div class="stat-item ui-card solid">
<div class="stat-label">总任务</div>
<div class="stat-val">{{ userStats?.total_tasks || 0 }}</div>
</div>
<div class="stat-item ui-card solid">
<div class="stat-label">已完成</div>
<div class="stat-val success">{{ userStats?.completed_tasks || 0 }}</div>
</div>
<div class="stat-item ui-card solid">
<div class="stat-label">处理中</div>
<div class="stat-val warning">{{ userStats?.processing_tasks || 0 }}</div>
<!-- 统计看板 -->
<div class="stats-grid">
<div class="stat-item ui-card solid">
<div class="stat-label">总任务</div>
<div class="stat-val">{{ userStats?.total_tasks || 0 }}</div>
</div>
<div class="stat-item ui-card solid">
<div class="stat-label">已完成</div>
<div class="stat-val success">{{ userStats?.completed_tasks || 0 }}</div>
</div>
<div class="stat-item ui-card solid">
<div class="stat-label">处理中</div>
<div class="stat-val warning">{{ userStats?.processing_tasks || 0 }}</div>
</div>
</div>
</div>
<!-- 3. 功能菜单 Grid -->
<h3 class="section-title">账户设置</h3>
<div class="settings-grid">
<!-- 编辑资料 -->
<div class="ui-card solid setting-item interactive" @click="openSub('profile')">
<div class="icon-box"><i class="fas fa-user-edit"></i></div>
<div class="text">
<h4>编辑资料</h4>
<p>查看与修改个人基本信息</p>
<!-- 菜单 -->
<h3 class="section-title">账户设置</h3>
<div class="settings-grid">
<div class="ui-card solid setting-item interactive" @click="openModal('password')">
<div class="icon-box"><i class="fas fa-key"></i></div>
<div class="text"><h4>修改密码</h4><p>Update Security Credentials</p></div>
<i class="fas fa-chevron-right arrow"></i>
</div>
<i class="fas fa-chevron-right arrow"></i>
</div>
<!-- 修改密码 -->
<div class="ui-card solid setting-item interactive" @click="openSub('password')">
<div class="icon-box"><i class="fas fa-key"></i></div>
<div class="text">
<h4>修改密码</h4>
<p>更新账户登录密码</p>
<div class="ui-card solid setting-item interactive" @click="openModal('config')">
<div class="icon-box"><i class="fas fa-sliders-h"></i></div>
<div class="text"><h4>默认配置</h4><p>System Preferences</p></div>
<i class="fas fa-chevron-right arrow"></i>
</div>
<i class="fas fa-chevron-right arrow"></i>
</div>
<!-- 用户配置 -->
<div class="ui-card solid setting-item interactive" @click="openSub('config')">
<div class="icon-box"><i class="fas fa-sliders-h"></i></div>
<div class="text">
<h4>系统配置</h4>
<p>设置默认算法与参数偏好</p>
<div v-if="userStore.role === 'admin'" class="ui-card gradient setting-item interactive" @click="openModal('admin')">
<div class="icon-box dark"><i class="fas fa-users-cog"></i></div>
<div class="text"><h4>用户管理后台</h4><p>Administrator Console</p></div>
<i class="fas fa-chevron-right arrow"></i>
</div>
<i class="fas fa-chevron-right arrow"></i>
</div>
</div>
<!-- 管理员入口 (仅管理员可见) -->
<div v-if="userData.role === 'admin'" class="ui-card gradient setting-item interactive" @click="openSub('admin')">
<div class="icon-box dark"><i class="fas fa-users-cog"></i></div>
<div class="text">
<h4>用户管理</h4>
<p>Admin Console</p>
<!-- 弹窗挂载点 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="activeModal" class="overlay-container">
<div class="overlay-backdrop" @click="closeModal"></div>
<SubpageContainer
:subpageType="activeModal"
@close="closeModal"
/>
</div>
<i class="fas fa-chevron-right arrow"></i>
</div>
</Transition>
</Teleport>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, inject } from 'vue'
import { ref, onMounted, onActivated, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import { authGetProfile, getUserStats, authLogout } from '@/api/index'
const router = useRouter()
const openSubpage = inject('openSubpage')
import { authLogout } from '@/api/index'
// 1. API
import { getTaskList } from '@/api/task'
import { useUserStore } from '@/stores/userStore'
const userData = ref({ username: 'User', email: '', role: 'user' })
const userStats = ref(null)
const SubpageContainer = defineAsyncComponent(() =>
import('./subpages/SubpageContainer.vue')
)
const userInitials = computed(() => {
return (userData.value.username?.[0] || 'U').toUpperCase()
const router = useRouter()
const userStore = useUserStore()
const userStats = ref({
total_tasks: 0,
completed_tasks: 0,
processing_tasks: 0
})
const activeModal = ref(null)
//
const formatRole = (role) => {
const map = { 'admin': '管理员', 'vip': 'VIP', 'normal': '普通用户', 'user': '普通用户' }
return map[role] || '普通用户'
}
const openSub = (type) => {
openSubpage('page5', type)
const getRoleClass = (role) => {
const map = { 'admin': 'admin', 'vip': 'vip', 'normal': 'user', 'user': 'user' }
return map[role] || 'user'
}
const handleLogout = async () => {
if(confirm('确定要退出登录吗?')) {
try { await authLogout() } catch(e){}
localStorage.removeItem('access_token')
userStore.logout()
router.push('/login')
}
}
const openModal = (type) => { activeModal.value = type }
const closeModal = () => { activeModal.value = null }
// === 2. ===
const fetchData = async () => {
try {
const [profileRes, statsRes] = await Promise.all([
authGetProfile(),
getUserStats()
])
if (profileRes?.user) userData.value = profileRes.user
if (statsRes?.stats) userStats.value = statsRes.stats
} catch (error) {
console.error(error)
//
const res = await getTaskList({ task_status: 'all' })
if (res && res.tasks) {
const tasks = res.tasks
//
const total = tasks.length
//
const completed = tasks.filter(t => t.status === 'completed').length
// ()
// pending, processing, running, waiting
const processing = tasks.filter(t =>
['processing', 'pending', 'running', 'waiting'].includes(t.status)
).length
//
userStats.value = {
total_tasks: total,
completed_tasks: completed,
processing_tasks: processing
}
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
onMounted(() => {
//
onMounted(() => {
fetchData()
})
// ()
onActivated(() => {
fetchData()
})
</script>
<style scoped>
.page5-container {
padding: 100px 10% 50px;
height: 100vh;
overflow-y: auto;
background: var(--color-bg-secondary);
}
/* 顶部用户卡片 */
/* 样式保持不变 */
.page5-container { padding: 100px 10% 50px; height: 100vh; overflow-y: auto; background: var(--color-bg-secondary); }
.header-section { margin-bottom: 30px; }
.user-profile-card {
display: flex;
align-items: center;
padding: 30px 40px;
gap: 30px;
}
.avatar-circle {
width: 80px; height: 80px;
border-radius: 50%;
background: var(--color-contrast-dark);
color: #fff;
font-size: 2.5rem;
font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.user-profile-card { display: flex; align-items: center; padding: 30px 40px; gap: 30px; }
.avatar-circle { width: 80px; height: 80px; border-radius: 50%; background: var(--color-contrast-dark); color: #fff; font-size: 2.5rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.user-info { flex: 1; }
.name-row { display: flex; align-items: center; gap: 15px; margin-bottom: 5px; }
.name-row h1 { margin: 0; font-size: 2rem; }
.role-badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
.role-badge.user { background: #e0e0e0; color: #555; }
.role-badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; background: #e0e0e0; color: #555; }
.role-badge.admin { background: var(--color-accent-secondary); color: #fff; }
.role-badge.vip { background: linear-gradient(135deg, #FFD166, #FF9F1C); color: #fff; }
.email { color: var(--color-text-muted); margin: 0; }
/* 统计 Grid - 改为3列 */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 修改这里3列均分 */
gap: 20px;
margin-bottom: 40px;
}
.stat-item {
padding: 20px;
text-align: center;
}
.stat-label { font-size: 0.9rem; color: var(--color-text-muted); margin-bottom: 5px; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 40px; }
.stat-item { padding: 20px; text-align: center; }
.stat-val { font-size: 1.8rem; font-weight: 800; color: var(--color-text-main); }
.stat-val.success { color: #2e7d32; }
.stat-val.warning { color: var(--color-accent-secondary); }
/* 设置菜单 */
.section-title { font-size: 1.2rem; color: var(--color-text-muted); margin-bottom: 20px; }
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
padding-bottom: 50px;
}
.setting-item {
padding: 20px 25px;
display: flex;
align-items: center;
gap: 20px;
}
.settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; padding-bottom: 50px; }
.setting-item { padding: 20px 25px; display: flex; align-items: center; gap: 20px; }
.setting-item:hover { transform: translateY(-3px); box-shadow: 0 10px 20px rgba(0,0,0,0.05); }
.icon-box { width: 50px; height: 50px; background: var(--color-bg-primary); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; color: var(--color-contrast-dark); }
.icon-box.dark { background: rgba(0,0,0,0.2); color: #fff; }
.text h4 { margin: 0 0 5px 0; font-size: 1.1rem; }
.text p { margin: 0; font-size: 0.9rem; color: var(--color-text-muted); }
.arrow { margin-left: auto; color: var(--color-text-muted); opacity: 0.5; }
.overlay-container { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 2000; display: flex; justify-content: center; align-items: center; }
.overlay-backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(5px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

@ -1,19 +1,79 @@
<template>
<div class="subpage-layout">
<div class="center-wrapper">
<!-- C. 管理员用户管理 (宽屏模式) -->
<div v-if="subpageType === 'admin'" class="center-wrapper wide-mode">
<div class="ui-card solid content-card full-screen">
<div class="overlay-header">
<h3>用户管理后台</h3>
<button class="close-btn" @click="$emit('close')"><i class="fas fa-times"></i></button>
</div>
<div class="admin-view overlay-body">
<div class="list-tools">
<div class="search-group">
<input type="text" placeholder="搜索用户..." class="ui-input sm" v-model="searchKeyword" @keyup.enter="fetchAdminData" />
<span class="tip-text">💡按住 Shift 点击表头可多选排序</span>
</div>
<div class="btn-group">
<button class="ui-btn glass sm" @click="fetchAdminData"></button>
<button class="ui-btn gradient sm" @click="openUserForm('create')">
<i class="fas fa-plus"></i> 新建
</button>
</div>
</div>
<div class="table-wrapper">
<div class="user-list">
<div class="list-head-row">
<span class="sortable" @click="handleSort('user_id', $event)">ID <i :class="getSortIcon('user_id')"></i></span>
<span class="sortable" @click="handleSort('username', $event)">用户名 <i :class="getSortIcon('username')"></i></span>
<span class="sortable" @click="handleSort('email', $event)">邮箱 <i :class="getSortIcon('email')"></i></span>
<span class="sortable" @click="handleSort('role', $event)">角色 <i :class="getSortIcon('role')"></i></span>
<span>操作</span>
</div>
<div v-if="adminUsers.length === 0" class="empty-row"></div>
<div v-for="u in adminUsers" :key="u.user_id" class="list-row">
<span>#{{ u.user_id }}</span>
<span class="u-name">{{ u.username }}</span>
<span class="u-email">{{ u.email }}</span>
<span>
<span class="role-tag" :class="getRoleClass(u.role)">
{{ formatRole(u.role) }}
</span>
</span>
<div class="actions">
<button class="text-btn" @click="openUserForm('edit', u)">编辑</button>
<button class="text-btn danger" @click="handleDeleteUser(u)"></button>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<button class="page-btn" :disabled="currentPage <= 1" @click="changePage(-1)"></button>
<span> {{ currentPage }} </span>
<button class="page-btn" @click="changePage(1)"></button>
</div>
</div>
</div>
</div>
<!-- A/B. 普通弹窗 (修改密码/系统配置) -->
<div v-else class="center-wrapper">
<div class="ui-card solid content-card">
<div class="card-header">
<div>
<h2>{{ pageInfo.title }}</h2>
<p class="subtitle">{{ pageInfo.subtitle }}</p>
<h3>{{ modalTitle }}</h3>
</div>
<span class="tag">Setting</span>
<button class="close-btn" @click="$emit('close')"><i class="fas fa-times"></i></button>
</div>
<div class="card-body">
<!-- === 1. 修改密码表单 === -->
<!-- 1. 修改密码表单 -->
<div v-if="subpageType === 'password'" class="form-container">
<div class="form-group">
<label>当前密码</label>
@ -32,166 +92,361 @@
</div>
</div>
<!-- === 2. 用户配置表单 === -->
<!-- 2. 用户配置表单 (已修改联动逻辑) -->
<div v-else-if="subpageType === 'config'" class="form-container">
<!-- 2.1 默认防护对象 -->
<div class="form-group">
<label>默认防护对象</label>
<select v-model="configForm.data_type_id" class="ui-input" @change="onConfigDataTypeChange">
<option :value="1">通用人脸防护</option>
<option :value="2">通用艺术品防护</option>
</select>
</div>
<!-- 2.2 默认扰动算法 (根据对象联动) -->
<div class="form-group">
<label>默认扰动算法</label>
<select v-model="configForm.perturbation_configs_id" class="ui-input">
<option :value="null">不设置默认值</option>
<option value="adv_noise">AdvNoise v1.0</option>
<option value="mist">Mist</option>
<option
v-for="algo in filteredConfigAlgorithms"
:key="algo.id"
:value="algo.id"
>
{{ algo.method_name }}
</option>
</select>
</div>
<!-- 2.3 默认强度 -->
<div class="form-group">
<label>默认强度 (0-255)</label>
<input type="number" v-model.number="configForm.perturbation_intensity" class="ui-input" />
<label>默认强度 (浮点数)</label>
<input type="number" step="0.1" v-model.number="configForm.perturbation_intensity" class="ui-input" />
</div>
<div class="action-row">
<button class="ui-btn gradient rect" @click="submitConfig"></button>
</div>
</div>
<!-- === 3. 编辑资料 (只读提示) === -->
<div v-else-if="subpageType === 'profile'" class="info-view">
<div class="notice-box">
<i class="fas fa-info-circle"></i>
<p>当前系统暂不支持用户自行修改用户名邮箱如需变更请联系管理员</p>
</div>
</div>
</div>
</div>
</div>
<!-- === 4. 管理员用户列表 === -->
<div v-else-if="subpageType === 'admin'" class="admin-view">
<div class="list-header">
<input type="text" placeholder="搜索用户..." class="ui-input sm" v-model="searchKeyword" />
<button class="ui-btn solid sm" @click="fetchAdminData"></button>
<!-- 管理员子弹窗 (新建/编辑用户) -->
<Teleport to="body">
<div v-if="showUserModal" class="sub-modal-overlay">
<div class="ui-card solid sub-modal-card">
<h3>{{ userModalMode === 'create' ? '新建用户' : '编辑用户' }}</h3>
<div class="form-container">
<div class="form-group">
<label>用户名</label>
<input type="text" v-model="userForm.username" class="ui-input" :disabled="userModalMode === 'edit'" />
</div>
<div class="user-list">
<div class="list-head-row">
<span>ID</span><span>用户名</span><span>角色</span><span>操作</span>
</div>
<div v-for="u in adminUsers" :key="u.id" class="list-row">
<span>#{{ u.id }}</span>
<span class="u-name">{{ u.username }}</span>
<span>
<span class="role-tag" :class="u.role">{{ u.role }}</span>
</span>
<div class="actions">
<button class="text-btn">编辑</button>
<button class="text-btn danger">删除</button>
</div>
</div>
<div class="form-group" v-if="userModalMode === 'create'">
<label>密码</label>
<input type="password" v-model="userForm.password" class="ui-input" />
</div>
<div class="form-group">
<label>邮箱</label>
<input type="email" v-model="userForm.email" class="ui-input" />
</div>
<div class="form-group">
<label>角色</label>
<select v-model="userForm.role" class="ui-input">
<option value="normal">普通用户 (Normal)</option>
<option value="vip">VIP用户</option>
<option value="admin">管理员 (Admin)</option>
</select>
</div>
<div class="action-row">
<button class="ui-btn glass sm" @click="showUserModal = false">取消</button>
<button class="ui-btn gradient sm" @click="submitUserForm"></button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { authChangePassword, getUserConfig, updateUserConfig, getAdminUserList } from '@/api/index'
const route = useRoute()
const router = useRouter()
const subpageType = computed(() => route.params.subpage)
//
const pageInfo = computed(() => {
const map = {
password: { title: '修改密码', subtitle: 'Update Security Credentials' },
config: { title: '用户配置', subtitle: 'System Preferences' },
profile: { title: '编辑资料', subtitle: 'Basic Information' },
admin: { title: '用户管理', subtitle: 'Administrator Console' }
}
return map[subpageType.value] || { title: '设置', subtitle: '' }
import { ref, computed, onMounted, reactive } from 'vue'
import {
authChangePassword, getUserConfig, updateUserConfig,
getAdminUserList, createAdminUser, updateAdminUser, deleteAdminUser
} from '@/api/index'
//
import { ALGO_OPTIONS_Data, DATA_TYPE_MAP } from '@/utils/constants'
const props = defineProps(['subpageType'])
const emit = defineEmits(['close'])
// === ===
const modalTitle = computed(() => {
const map = { password: '修改密码', config: '系统配置', admin: '用户管理后台' }
return map[props.subpageType] || ''
})
// --- ---
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' })
const configForm = ref({ perturbation_configs_id: null, perturbation_intensity: null })
const adminUsers = ref([])
const searchKeyword = ref('')
// === 1. (Config) ===
const configForm = ref({
data_type_id: 1, // (1)
perturbation_configs_id: null,
perturbation_intensity: null
})
// data_type_id
const filteredConfigAlgorithms = computed(() => {
const typeStr = configForm.value.data_type_id === DATA_TYPE_MAP.ART ? 'art' : 'face'
return ALGO_OPTIONS_Data.filter(a => a.type === typeStr)
})
// --- ---
//
const onConfigDataTypeChange = () => {
const currentAlgoId = configForm.value.perturbation_configs_id
if (currentAlgoId) {
const algo = ALGO_OPTIONS_Data.find(a => a.id === currentAlgoId)
const newTypeStr = configForm.value.data_type_id === DATA_TYPE_MAP.ART ? 'art' : 'face'
//
if (algo && algo.type !== newTypeStr) {
configForm.value.perturbation_configs_id = null
}
}
}
// 1.
const submitPassword = async () => {
if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) return alert('两次密码不一致')
const fetchConfig = async () => {
const res = await getUserConfig();
if (res?.config) {
configForm.value = {
// 使 data_type_id 1
data_type_id: res.config.data_type_id || 1,
perturbation_configs_id: res.config.perturbation_configs_id,
perturbation_intensity: res.config.perturbation_intensity
}
}
}
const submitConfig = async () => {
await updateUserConfig(configForm.value);
alert('配置已保存');
emit('close')
}
// === 2. ===
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' })
const submitPassword = async () => {
if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) return alert('两次输入密码不一致')
try {
await authChangePassword({
old_password: pwdForm.value.oldPassword,
new_password: pwdForm.value.newPassword
})
alert('修改成功,请重新登录')
router.push('/login')
} catch (e) { console.error(e) }
alert('密码修改成功')
emit('close')
} catch(e) { console.error(e) }
}
// 2.
const fetchConfig = async () => {
const res = await getUserConfig()
if (res?.config) configForm.value = res.config
// === 3. ===
const adminUsers = ref([])
const searchKeyword = ref('')
const currentPage = ref(1)
const sortRules = ref([])
const showUserModal = ref(false)
const userModalMode = ref('create')
const userForm = reactive({ user_id: null, username: '', password: '', email: '', role: 'normal' })
const formatRole = (role) => {
const map = { 'admin': '管理员', 'vip': 'VIP', 'normal': '普通' }
return map[role] || '普通'
}
const submitConfig = async () => {
await updateUserConfig(configForm.value)
alert('配置已保存')
const getRoleClass = (role) => {
const map = { 'admin': 'admin', 'vip': 'vip', 'normal': 'user' }
return map[role] || 'user'
}
// 3. (Mock)
const fetchAdminData = async () => {
// const res = await getAdminUserList()
// if (res?.users) adminUsers.value = res.users
// Mock
adminUsers.value = [
{ id: 1, username: 'admin', role: 'admin' },
{ id: 2, username: 'test_user', role: 'user' },
{ id: 3, username: 'guest', role: 'user' }
]
try {
const sortParam = sortRules.value.map(r => `${r.field}:${r.direction}`).join(',')
const res = await getAdminUserList({
page: currentPage.value, per_page: 20,
q: searchKeyword.value, sort: sortParam
})
if (res?.users) adminUsers.value = res.users
} catch (e) { console.error(e) }
}
//
const changePage = (delta) => { currentPage.value += delta; fetchAdminData() }
const handleSort = (field, event) => {
const isMulti = event.shiftKey
const existingIndex = sortRules.value.findIndex(rule => rule.field === field)
if (existingIndex !== -1) {
const currentRule = sortRules.value[existingIndex]
if (currentRule.direction === 'asc') currentRule.direction = 'desc'
else sortRules.value.splice(existingIndex, 1)
} else {
const newRule = { field, direction: 'asc' }
if (isMulti) sortRules.value.push(newRule)
else sortRules.value = [newRule]
}
fetchAdminData()
}
const getSortIcon = (field) => {
const rule = sortRules.value.find(r => r.field === field)
if (!rule) return 'fas fa-sort sort-icon dim'
return rule.direction === 'asc' ? 'fas fa-sort-up sort-icon active' : 'fas fa-sort-down sort-icon active'
}
//
const openUserForm = (mode, user = null) => {
userModalMode.value = mode
if (mode === 'edit' && user) {
userForm.user_id = user.user_id
userForm.username = user.username
userForm.email = user.email
userForm.role = user.role
userForm.password = ''
} else {
userForm.user_id = null
userForm.username = ''
userForm.password = ''
userForm.email = ''
userForm.role = 'normal'
}
showUserModal.value = true
}
const submitUserForm = async () => {
try {
const payload = {
username: userForm.username,
email: userForm.email,
role: userForm.role
}
if (userModalMode.value === 'create') {
payload.password = userForm.password
await createAdminUser(payload)
alert('创建成功')
} else {
await updateAdminUser(userForm.user_id, {
...payload,
is_active: true
})
alert('更新成功')
}
showUserModal.value = false
fetchAdminData()
} catch (e) { console.error(e) }
}
const handleDeleteUser = async (u) => {
if(!confirm(`确定要删除用户 "${u.username}" 吗?此操作无法撤销。`)) return
try {
await deleteAdminUser(u.user_id)
alert('删除成功')
if (adminUsers.value.length === 1 && currentPage.value > 1) currentPage.value--
fetchAdminData()
} catch (e) { console.error(e) }
}
// === ===
onMounted(() => {
if (subpageType.value === 'config') fetchConfig()
if (subpageType.value === 'admin') fetchAdminData()
if (props.subpageType === 'config') fetchConfig()
if (props.subpageType === 'admin') fetchAdminData()
})
</script>
<style scoped>
.subpage-layout { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; background: rgba(0,0,0,0.4); backdrop-filter: blur(5px); }
.center-wrapper { width: 100%; max-width: 600px; padding: 20px; }
.content-card { background: #fff; display: flex; flex-direction: column; max-height: 80vh; }
.subpage-layout { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; padding: 20px; box-sizing: border-box; }
.card-header { padding: 25px 30px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
.subtitle { font-size: 0.9rem; color: #999; margin-top: 5px; }
.tag { background: var(--color-contrast-dark); color: #fff; padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; height: fit-content; }
/* 容器适配:普通弹窗 & 宽屏管理后台 */
.center-wrapper { width: 100%; max-width: 600px; padding: 0; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); display: flex; justify-content: center; }
.center-wrapper.wide-mode { max-width: 1100px; height: 85vh; padding: 0; }
.card-body { padding: 30px; overflow-y: auto; }
.content-card { background: #fff; display: flex; flex-direction: column; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); width: 100%; }
/* 限制普通卡片高度,防止溢出 */
.center-wrapper:not(.wide-mode) .content-card { max-height: 90vh; }
.content-card.full-screen { height: 100%; max-height: 100%; }
/* 表单样式 */
.form-container { display: flex; flex-direction: column; gap: 20px; }
.form-group label { display: block; font-weight: 600; margin-bottom: 8px; font-size: 0.95rem; }
.ui-input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; }
.ui-input:focus { border-color: var(--color-accent-secondary); outline: none; }
.action-row { margin-top: 10px; display: flex; justify-content: flex-end; }
/* 提示框 */
.notice-box { background: #f8f9fa; padding: 20px; border-radius: 8px; display: flex; gap: 15px; align-items: center; color: #666; }
.notice-box i { font-size: 1.5rem; color: var(--color-accent-secondary); }
/* 管理员列表 */
.list-header { display: flex; gap: 10px; margin-bottom: 15px; }
.sm { padding: 8px 12px; font-size: 0.9rem; }
.user-list { border: 1px solid #eee; border-radius: 8px; }
.list-head-row { display: grid; grid-template-columns: 1fr 2fr 1fr 1fr; padding: 10px 15px; background: #f8f9fa; font-weight: 600; font-size: 0.9rem; color: #666; }
.list-row { display: grid; grid-template-columns: 1fr 2fr 1fr 1fr; padding: 12px 15px; border-top: 1px solid #eee; align-items: center; font-size: 0.95rem; }
.u-name { font-weight: 600; }
.role-tag { font-size: 0.8rem; padding: 2px 6px; border-radius: 4px; background: #eee; }
.overlay-header { padding: 20px 30px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; flex: 0 0 auto; }
.card-header { padding: 20px 30px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; flex: 0 0 auto; }
.overlay-body, .card-body { padding: 30px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; }
.close-btn { background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #999; }
/* 角色标签 */
.role-tag { font-size: 0.8rem; padding: 2px 8px; border-radius: 4px; background: #eee; text-transform: capitalize; }
.role-tag.admin { background: var(--color-contrast-dark); color: #fff; }
.text-btn { background: none; border: none; color: var(--color-accent-secondary); cursor: pointer; margin-right: 10px; font-size: 0.9rem; }
.role-tag.vip { background: #fff3cd; color: #856404; }
.role-tag.user { background: #f5f5f5; color: #757575; }
/* === 管理员视图 === */
.admin-view { flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; }
.list-tools { display: flex; justify-content: space-between; margin-bottom: 15px; flex-shrink: 0; flex-wrap: wrap; gap: 10px; }
.search-group { display: flex; align-items: center; gap: 10px; flex: 1; max-width: 600px; min-width: 200px; }
.btn-group { display: flex; gap: 10px; }
.tip-text { font-size: 0.8rem; color: #999; white-space: nowrap; }
/* 核心:表格横向滚动 */
.table-wrapper { flex: 1; overflow: auto; border: 1px solid #eee; border-radius: 8px; min-height: 200px; }
.user-list { min-width: 800px; /* 强制最小宽度,触发滚动条 */ width: 100%; display: table; font-size: 0.95rem; }
.list-head-row, .list-row {
display: grid;
grid-template-columns: 80px 1.5fr 2fr 120px 140px;
padding: 15px 20px;
align-items: center;
gap: 10px;
}
.list-head-row { background: #f8f9fa; font-weight: 700; color: #666; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 10; }
.list-row { border-bottom: 1px solid #f1f1f1; }
.list-row:hover { background: #fcfcfc; }
.empty-row { padding: 20px; text-align: center; color: #999; }
.u-name { font-weight: 600; }
.u-email { color: #666; }
.actions { display: flex; gap: 10px; }
.text-btn { background: none; border: none; color: var(--color-accent-secondary); cursor: pointer; padding: 5px 10px; font-size: 0.9rem; }
.text-btn.danger { color: #dc3545; }
/* 子弹窗 */
.sub-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 3000; display: flex; justify-content: center; align-items: center; background: rgba(0,0,0,0.2); }
.sub-modal-card { width: 400px; max-width: 90vw; background: #fff; padding: 30px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
.sub-modal-card h3 { margin-top: 0; margin-bottom: 20px; }
/* 表单通用 */
.form-container { display: flex; flex-direction: column; gap: 20px; }
.form-group label { display: block; font-weight: 600; margin-bottom: 8px; font-size: 0.95rem; }
.ui-input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; min-width: 0; }
.action-row { margin-top: 10px; display: flex; justify-content: flex-end; gap: 10px; }
.sortable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 5px; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 20px; margin-top: 20px; padding: 10px 0; }
.page-btn { background: none; border: 1px solid #ddd; padding: 5px 15px; border-radius: 4px; cursor: pointer; }
.page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* === 响应式适配 === */
@media (max-width: 900px) {
.center-wrapper.wide-mode {
height: 100%;
max-height: 100%;
border-radius: 0;
}
.content-card.full-screen {
border-radius: 0;
}
.list-tools {
flex-direction: column;
align-items: stretch;
}
.btn-group {
justify-content: flex-end;
}
}
</style>

@ -0,0 +1,265 @@
<template>
<div class="login-container">
<div class="ui-card glass login-card">
<!-- 左侧品牌视觉区 -->
<div class="brand-side">
<div class="brand-content">
<div class="logo-text">MUSE</div>
<h2 class="slogan">Join the<br>Revolution</h2>
<p class="desc">注册成为 MuseGuard 会员开启您的 AI 隐私防护之旅</p>
</div>
<div class="circle-deco"></div>
</div>
<!-- 右侧表单区 -->
<div class="form-side">
<div class="form-header">
<h1>Create Account</h1>
<p>Join MuseGuard protection system.</p>
</div>
<div class="form-group">
<!-- Username -->
<div class="input-wrapper">
<i class="fas fa-user input-icon"></i>
<input
type="text"
v-model="form.username"
placeholder="Username"
class="input-field"
/>
</div>
<!-- Email -->
<div class="input-wrapper">
<i class="fas fa-envelope input-icon"></i>
<input
type="email"
v-model="form.email"
placeholder="Email"
class="input-field"
/>
</div>
<!-- Code -->
<div class="code-group">
<div class="input-wrapper flex-1">
<i class="fas fa-shield-alt input-icon"></i>
<input
type="text"
v-model="form.code"
placeholder="Code"
class="input-field code-input"
maxlength="6"
/>
</div>
<button
class="ui-btn solid sm code-btn"
:disabled="isSending || countdown > 0"
@click="handleSendCode"
>
{{ codeBtnText }}
</button>
</div>
<!-- Password -->
<div class="input-wrapper">
<i class="fas fa-lock input-icon"></i>
<input
type="password"
v-model="form.password"
placeholder="Password"
class="input-field"
/>
</div>
</div>
<button
class="ui-btn gradient rect full-width"
@click="handleRegister"
:disabled="loading"
>
{{ loading ? 'Creating...' : 'Register' }}
</button>
<div class="footer-link">
<span>Already have an account? </span>
<a @click.prevent="router.push('/login')" href="#">Login</a>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { authRegister, sendAuthCode } from '@/api/auth'
const router = useRouter()
const loading = ref(false)
const isSending = ref(false)
const countdown = ref(0)
let timer = null
const form = ref({
username: '',
password: '',
email: '',
code: ''
})
const codeBtnText = computed(() => {
if (isSending.value) return 'Sending...'
if (countdown.value > 0) return `${countdown.value}s`
return 'Get Code'
})
const validateEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
const handleSendCode = async () => {
if (!form.value.email) return alert('请先填写邮箱地址')
if (!validateEmail(form.value.email)) return alert('邮箱格式不正确')
isSending.value = true
try {
await sendAuthCode({ email: form.value.email, purpose: 'register' })
alert(`验证码已发送至 ${form.value.email}`)
countdown.value = 60
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
} catch (error) {
console.error(error)
} finally {
isSending.value = false
}
}
const handleRegister = async () => {
if (!form.value.username || !form.value.password || !form.value.email || !form.value.code) {
return alert('请填写完整注册信息(含验证码)')
}
loading.value = true
try {
const res = await authRegister(form.value)
if (res.user || res.message) {
alert('注册成功,请登录')
router.push('/login')
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
/* 样式与 LoginView 保持一致 */
.login-container {
--color-text-main: #18283b; /* 强制深色文字,适配白色卡片 */
--color-accent-secondary: #FF9F1C;
background: #18283b;
width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--color-contrast-dark); position: relative; overflow: hidden;
}
.login-container::before {
content: ''; position: absolute; width: 800px; height: 800px; background: var(--color-accent-primary);
border-radius: 50%; top: -200px; left: -200px; filter: blur(100px); opacity: 0.4;
}
.login-container::after {
content: ''; position: absolute; width: 600px; height: 600px; background: var(--color-accent-secondary);
border-radius: 50%; bottom: -100px; right: -100px; filter: blur(120px); opacity: 0.3;
}
.login-card {
width: 900px;
min-height: 550px;
padding: 0;
display: flex;
flex-direction: row;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
background: rgba(255, 255, 255, 0.95);
z-index: 10;
}
.brand-side {
flex: 4;
background: linear-gradient(135deg, var(--color-contrast-dark), #2c3e50);
color: #fff;
padding: 60px 40px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
}
.brand-content { position: relative; z-index: 2; }
.logo-text {
font-size: 2rem;
font-weight: 900;
letter-spacing: 4px;
margin-bottom: 40px;
color: var(--color-accent-primary);
}
.slogan {
font-size: 2.2rem;
line-height: 1.2;
margin-bottom: 20px;
font-weight: 700;
color: #ffffff; /* [修改] 强制纯白 */
}
.desc {
font-size: 1.1rem; /* [修改] 调大 */
color: rgba(255, 255, 255, 0.9); /* [修改] 强制高亮白 */
line-height: 1.6;
/* 移除了 display: none (之前移动端隐藏逻辑保持不变,这里只改桌面端样式) */
}
.circle-deco { position: absolute; width: 300px; height: 300px; border: 40px solid rgba(255, 255, 255, 0.05); border-radius: 50%; bottom: -100px; right: -100px; z-index: 1; }
.form-side { flex: 5; padding: 50px; display: flex; flex-direction: column; justify-content: center; }
.form-header { margin-bottom: 30px; }
.form-header h1 { font-size: 2rem; color: var(--color-contrast-dark); margin-bottom: 10px; }
.form-header p { color: #666; }
.form-group { margin-bottom: 30px; display: flex; flex-direction: column; gap: 15px; }
.input-wrapper { position: relative; display: flex; align-items: center; width: 100%; }
.input-icon { position: absolute; left: 15px; color: #999; font-size: 1rem; z-index: 2; }
.input-field { width: 100%; padding: 15px 15px 15px 45px; border-radius: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; font-size: 1rem; transition: all 0.3s; }
.input-field:focus { background: #fff; border-color: var(--color-accent-secondary); box-shadow: 0 0 0 4px rgba(255, 159, 28, 0.1); }
/* 验证码特殊布局 */
.code-group { display: flex; gap: 10px; align-items: center; }
.flex-1 { flex: 1; }
.code-btn { width: 110px; height: 50px; border-radius: 12px; font-size: 0.9rem; background: var(--color-contrast-dark); color: #fff; flex-shrink: 0; }
.code-btn:disabled { background: #ccc; cursor: not-allowed; }
.full-width { width: 100%; height: 50px; font-size: 1.1rem; border-radius: 12px; }
.footer-link { margin-top: 20px; text-align: center; font-size: 0.95rem; color: #666; }
.footer-link a { color: var(--color-accent-secondary); font-weight: 700; text-decoration: none; margin-left: 5px; }
.footer-link a:hover { text-decoration: underline; }
@media (max-width: 900px) {
.login-card { width: 90%; max-width: 450px; flex-direction: column; min-height: auto; }
.brand-side { padding: 30px; flex: 0 0 auto; text-align: center; }
.logo-text { margin-bottom: 10px; font-size: 1.5rem; }
.slogan { font-size: 1.5rem; margin-bottom: 10px; }
.desc, .circle-deco { display: none; }
.form-side { padding: 40px 30px; }
}
</style>

@ -2,40 +2,30 @@
import { inject } from 'vue'
const openSubpage = inject('openSubpage')
const openPrincipleDiagram = () => {
openSubpage('home', 'PrincipleDiagram')
}
const openSamplePreview = () => {
openSubpage('home', 'SamplePreview')
}
const openPaperSupport = () => {
openSubpage('home', 'PaperSupport')
}
const openPrincipleDiagram = () => openSubpage('home', 'PrincipleDiagram')
const openSamplePreview = () => openSubpage('home', 'SamplePreview')
const openPaperSupport = () => openSubpage('home', 'PaperSupport')
</script>
<template>
<div class="view-container">
<div class="grid-layout">
<!-- Hero Card (撑满高度的 60%) -->
<div class="ui-card solid interactive hero-card" @click="openPaperSupport">
<div class="hero-content">
<h2>论文支持<span class="highlight">Protective_Perturbation</span></h2>
<p>ASPL+SimAC+PID+CAAT+Glaze</p>
</div>
<div class="stat-circle">
<span>85%</span>
<span><i class="fas fa-file-alt"></i></span>
</div>
</div>
<!-- Secondary Info (右侧全高) -->
<div class="ui-card solid interactive info-card" @click="openPrincipleDiagram">
<h3 style="color: var(--color-text-light);">原理详解</h3>
<p>Diffusion is all you need</p>
<div class="chart-fill"></div>
</div>
<!-- Small Actions (左下角) -->
<div class="ui-card gradient interactive action-card" @click="openSamplePreview">
<i class="fas fa-play"></i>
<span>样例效果尝鲜</span>
@ -46,75 +36,82 @@ const openPaperSupport = () => {
<style scoped>
.view-container {
top: 5%;
width: 100%;
height: 90%;
/* 【关键】移除 min-height: 100%,允许内容自然高度 */
/* min-height: 100%; */
display: flex;
flex-direction: column;
box-sizing: border-box;
/*
核心魔法 margin: auto
1. 如果高度不够 (放大)它会变成 margin-top: 0 -> 顶部对齐 -> 可滚动
2. 如果高度充足 (100%)它会变成 margin-top: auto -> 垂直居中
*/
margin: auto;
/* 增加内边距,确保放大时不贴边 */
padding: 40px 20px 120px 20px;
}
.grid-layout {
display: grid;
grid-template-columns: 2fr 1fr; /* 左 2 : 右 1 */
grid-template-rows: 60% 1fr; /* 上 60% : 下 剩余 */
grid-template-columns: 2fr 1fr;
grid-template-rows: minmax(300px, 55vh) 1fr;
gap: var(--space-md);
width: 100%;
height: 100%;
}
.hero-card {
grid-column: 1 / 2;
grid-row: 1 / 2;
grid-column: 1 / 2; grid-row: 1 / 2;
padding: var(--space-lg);
display: flex;
justify-content: space-between;
align-items: flex-start;
display: flex; justify-content: space-between; align-items: flex-start;
background: var(--color-accent-primary);
}
.info-card {
grid-column: 2 / 3;
grid-row: 1 / 3; /* 占据整列 */
background: var(--color-contrast-dark);
color: var(--color-text-light);
padding: var(--space-lg);
display: flex;
flex-direction: column;
grid-column: 2 / 3; grid-row: 1 / 3;
background: var(--color-contrast-dark); color: var(--color-text-light);
padding: var(--space-lg); display: flex; flex-direction: column;
}
.action-card {
grid-column: 1 / 2;
grid-row: 2 / 3;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
font-size: 1.5rem;
background: var(--color-accent-mild);
font-weight: bold;
grid-column: 1 / 2; grid-row: 2 / 3;
display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 10px; font-size: 1.5rem;
background: var(--color-accent-mild); font-weight: bold;
min-height: 150px;
}
.highlight { color: var(--color-accent-secondary); }
.stat-circle {
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--color-accent-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: bold;
color: var(--color-contrast-dark);
width: 80px; height: 80px; border-radius: 50%;
background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; color: var(--color-contrast-dark);
}
.chart-fill {
margin-top: 20px;
flex: 1; /* 撑满剩余空间 */
background: rgba(255,255,255,0.1);
border-radius: 16px;
width: 100%;
margin-top: 20px; flex: 1;
background: rgba(255,255,255,0.1); border-radius: 16px;
width: 100%; min-height: 100px;
}
@media (max-width: 1100px) {
.view-container {
padding-top: 60px; /* 窄屏下额外加一点顶部空间 */
}
.grid-layout {
display: flex;
flex-direction: column;
}
.hero-card { order: 1; min-height: 250px; }
.action-card { order: 2; min-height: 120px; }
.info-card { order: 3; min-height: 300px; }
}
</style>
Loading…
Cancel
Save