UI-debug若干 #44

Merged
hnu202326010204 merged 4 commits from yangyixuan_branch into develop 1 week ago

@ -1,280 +0,0 @@
# 设计文档
## 概述
本设计文档描述了 MuseGuard 前端 UI 系统的修复和增强方案。主要目标是:
1. 修复瀑布流滚动/触摸导航的逻辑问题
2. 修复导航栏汉堡包按钮的动画和显示错位
3. 统一明暗主题的布局,避免切换时的样式重排
4. 增强响应式布局,特别是大屏幕上的字体和 UI 缩放
5. 统一所有子页面的设计语言与主页面一致
## 架构
### 整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ App.vue (根容器) │
│ - 容器查询上下文 (container-type: size) │
│ - 全局 CSS 变量定义 │
├─────────────────────────────────────────────────────────────┤
│ MainFlow.vue │
│ ┌─────────────┐ ┌─────────────────────────────────────┐ │
│ │ NavBar │ │ Layout Content │ │
│ │ (固定左侧) │ │ ┌─────────────────────────────┐ │ │
│ │ │ │ │ Scroll Container │ │ │
│ │ - 汉堡菜单 │ │ │ ┌─────────────────────┐ │ │ │
│ │ - 导航项 │ │ │ │ Scroll Section │ │ │ │
│ │ - 操作按钮 │ │ │ │ (home/page1-4) │ │ │ │
│ │ │ │ │ └─────────────────────┘ │ │ │
│ └─────────────┘ │ └─────────────────────────────┘ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Subpage Wrapper │ │ │
│ │ │ (覆盖层,统一 KT 风格) │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### CSS 变量架构
```
:root / :root.dark-mode
├── 颜色变量 (--kt-bg, --kt-fg, --kt-accent, --kt-border, --kt-muted, --kt-muted-fg)
├── 排版变量 (--kt-font, --kt-hero-size, --kt-section-size, --kt-body, --kt-small)
├── 间距变量 (--kt-section-py, --kt-container-px, --kt-gap, --kt-card-padding)
├── 边框变量 (--kt-border-width, --kt-radius)
├── 过渡变量 (--kt-transition-micro, --kt-transition-normal)
└── 容器查询变量 (--cq-navbar-width-collapsed, --cq-navbar-width-expanded)
```
## 组件和接口
### 1. 滚动导航模块 (scrollNavigation.js)
```typescript
interface ScrollConfig {
WHEEL_COOLDOWN: number // 500ms - 滚轮冷却时间
WHEEL_THRESHOLD: number // 50 - 滚轮阈值
TOUCH_COOLDOWN: number // 600ms - 触摸冷却时间
TOUCH_THRESHOLD: number // 100px - 触摸滑动阈值
ACCUMULATOR_RESET: number // 300ms - 累加器重置时间
ANIMATION_DURATION: number // 350ms - 动画持续时间
}
interface ScrollState {
isScrollable: boolean // 页面是否可滚动
atTop: boolean // 是否在顶部
atBottom: boolean // 是否在底部
scrollPosition: number // 当前滚动位置
maxScroll: number // 最大滚动距离
}
interface AnimationState {
isAnimating: boolean // 是否正在动画
lastWheelTime: number // 上次滚轮时间
lastTouchTime: number // 上次触摸时间
scrollAccumulator: number // 滚动累加器
}
// 核心函数
function shouldDebounce(currentTime: number, lastEventTime: number, cooldown: number): boolean
function shouldLockInput(isAnimating: boolean): boolean
function checkScrollState(scrollInfo: ScrollInfo, tolerance?: number): ScrollState
function shouldTriggerPageSwitch(scrollState: ScrollState, direction: 'up' | 'down'): boolean
```
### 2. 导航栏组件 (NavBar.vue)
```typescript
interface NavBarProps {
currentSection: string // 当前活动区块
}
interface NavBarEmits {
navigate: (id: string) => void
logout: () => void
toggle: (expanded: boolean) => void
}
interface NavBarState {
isExpanded: boolean // 是否展开
isDarkMode: boolean // 是否深色模式
}
// CSS 变量绑定
const navbarWidth = computed(() =>
isExpanded.value ? 'var(--cq-navbar-width-expanded)' : 'var(--cq-navbar-width-collapsed)'
)
// 高亮位置计算
const highlightTop = computed(() => `${10 + (activeIndex.value * itemHeightPercent)}%`)
```
### 3. 主题系统
```typescript
interface ThemeColors {
background: string
foreground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
border: string
}
// 主题切换函数
function toggleTheme(): void {
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')
}
}
```
## 数据模型
### 导航状态模型
```typescript
interface NavigationModel {
sections: string[] // ['home', 'page1', 'page2', 'page3', 'page4']
currentSection: string // 当前区块
currentIndex: number // 当前索引
showSubpage: boolean // 是否显示子页面
isNavExpanded: boolean // 导航栏是否展开
}
```
### 动画状态模型
```typescript
interface AnimationModel {
isAnimating: boolean // 动画锁定标志
animationQueue: string[] // 动画队列
animationTimer: number | null // 动画定时器
}
```
## 正确性属性
*正确性属性是应该在系统所有有效执行中保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。*
### 属性 1滚动导航防抖
*对于任意*当前时间和上次事件时间,如果时间差小于冷却期,则 shouldDebounce 应返回 true。
**验证:需求 1.5**
### 属性 2动画期间输入锁定
*对于任意*动画状态,当 isAnimating 为 true 时shouldLockInput 应返回 true阻止新的页面切换。
**验证:需求 1.3**
### 属性 3边界滚动检测
*对于任意*滚动信息scrollTop、clientHeight、scrollHeightcheckScrollState 应正确计算 atTop 和 atBottom 状态,容差为 5px。
**验证:需求 1.4**
### 属性 4页面切换触发条件
*对于任意*滚动状态和方向shouldTriggerPageSwitch 应仅在以下条件下返回 true
- 页面不可滚动,或
- 向下滚动且在底部,或
- 向上滚动且在顶部
**验证:需求 1.1, 1.2, 1.4**
### 属性 5主题切换布局稳定性
*对于任意*主题切换操作切换前后的布局属性width、height、padding、margin、grid-template-columns应保持不变。
**验证:需求 3.1, 3.2**
### 属性 6主题偏好持久化往返
*对于任意*主题值('dark' 或 'light'),保存到 localStorage 后再读取应返回相同的值。
**验证:需求 3.5**
### 属性 7导航栏高亮位置计算
*对于任意*活动索引0-4高亮指示器的 top 位置应等于 `10 + (activeIndex * (80 / navItems.length))%`
**验证:需求 2.6**
### 属性 8触摸目标最小尺寸
*对于任意*交互元素(按钮、链接、导航项),其计算尺寸应至少为 44x44px。
**验证:需求 4.4**
## 错误处理
### 滚动导航错误处理
1. **无效的滚动容器**:如果找不到活动的滚动区块,静默忽略滚动事件
2. **动画冲突**:如果动画正在进行,忽略新的切换请求并记录日志
3. **边界越界**:如果计算的索引超出范围,保持当前索引不变
### 主题切换错误处理
1. **localStorage 不可用**:捕获异常,使用内存存储作为后备
2. **无效的主题值**:默认使用深色模式
### 响应式布局错误处理
1. **容器查询不支持**:提供媒体查询作为后备方案
2. **字体加载失败**:使用 font-display: swap 确保文本可见
## 测试策略
### 双重测试方法
本项目采用单元测试和属性测试相结合的方式:
- **单元测试**:验证特定示例、边界情况和错误条件
- **属性测试**:验证跨所有输入的通用属性
### 属性测试配置
- 测试框架Vitest + fast-check
- 每个属性测试最少运行 100 次迭代
- 每个测试必须引用设计文档中的属性编号
- 标签格式:**Feature: ui-consistency-fix, Property {number}: {property_text}**
### 测试覆盖范围
| 需求 | 测试类型 | 覆盖属性 |
|------|----------|----------|
| 1.1-1.5 | 属性测试 | 属性 1-4 |
| 2.6 | 属性测试 | 属性 7 |
| 3.1-3.2 | 属性测试 | 属性 5 |
| 3.5 | 属性测试 | 属性 6 |
| 4.4 | 属性测试 | 属性 8 |
| 2.1-2.5 | 单元测试 | 示例验证 |
| 4.2-4.7 | 单元测试 | 响应式断点验证 |
| 5.1-5.7 | 单元测试 | 样式一致性验证 |
### 关键测试场景
1. **滚动导航测试**
- 边界检测准确性
- 防抖逻辑正确性
- 动画锁定有效性
2. **主题切换测试**
- 布局稳定性验证
- localStorage 持久化验证
3. **响应式布局测试**
- 各断点下的布局验证
- 触摸目标尺寸验证

@ -1,82 +0,0 @@
# 需求文档
## 简介
本文档规定了 MuseGuard 前端 UI 系统的修复和增强需求。当前实现存在以下问题:瀑布流滚动/触摸导航损坏、导航栏汉堡包按钮动画故障、主题切换导致布局重排、响应式设计不一致,以及子页面未遵循主页面的 Kinetic Typography 设计语言。
## 术语表
- **瀑布流导航 (Waterfall_Navigation)**: 允许用户通过滚动或滑动在主要区块home, page1-4之间切换的垂直页面切换系统使用平滑的 translateY 动画
- **导航栏 (NavBar)**: 显示导航项和操作按钮的导航组件,具有可折叠的汉堡包菜单功能
- **主题系统 (Theme_System)**: 使用 CSS 自定义属性(--kt-* 变量)的明暗模式切换机制
- **动态排版 (Kinetic_Typography)**: 整个应用使用的设计系统,特点包括 Space Grotesk 字体、直角边框、酸性黄强调色和大写排版
- **子页面 (Subpage)**: 在主区块上方打开的模态覆盖页面(如 PrincipleDiagram、SamplePreview
- **响应式布局 (Responsive_Layout)**: 适应不同视口尺寸和宽高比的自适应布局系统
- **容器查询 (Container_Query)**: 使用 cqw/cqh 单位相对于容器尺寸进行响应式调整的 CSS 特性
## 需求
### 需求 1修复瀑布流滚动/触摸导航
**用户故事:** 作为用户,我希望能够平滑地滚动或滑动切换页面,以便在应用中导航时不会出现故障或意外行为。
#### 验收标准
1. 当用户在页面边界使用鼠标滚轮滚动时,瀑布流导航应以平滑的 350ms 动画过渡到下一页/上一页
2. 当用户在触摸设备上在页面边界滑动时,瀑布流导航应在超过 100px 阈值后过渡到下一页/上一页
3. 当动画正在进行时,瀑布流导航应忽略额外的滚动/触摸输入以防止重复触发
4. 当用户在具有可滚动内容的页面内滚动时,瀑布流导航不应触发页面切换,直到到达内容边界
5. 如果发生快速连续滚动事件,则瀑布流导航应使用 500ms 冷却期对输入进行防抖处理
### 需求 2修复导航栏汉堡包动画和显示
**用户故事:** 作为用户,我希望导航栏能够平滑地展开和折叠,以便在访问导航选项时不会出现视觉故障。
#### 验收标准
1. 当点击汉堡包按钮时导航栏应从折叠状态8cqw平滑展开到展开状态26cqw并同步淡入标签
2. 当导航栏展开时导航标签应在宽度过渡完成后淡入0.15s 延迟)
3. 当导航栏折叠时,导航标签应在宽度过渡开始前淡出(无延迟)
4. 当点击汉堡包按钮时,汉堡包图标应以平滑的旋转动画变换为 X 形状
5. 当导航栏处于移动端模式(< 900px
6. 导航栏高亮指示器应平滑跟踪活动导航项的位置
### 需求 3统一主题切换避免布局重排
**用户故事:** 作为用户,我希望在明暗模式之间切换时不会看到布局偏移,以便主题切换感觉无缝。
#### 验收标准
1. 当切换主题时,主题系统应仅更改与颜色相关的 CSS 属性background、color、border-color
2. 当切换主题时主题系统不应导致任何布局属性的更改width、height、padding、margin、grid
3. 明亮模式和深色模式应共享相同的布局 CSS 规则,仅颜色值不同
4. 当主题过渡发生时,主题系统应对颜色属性应用 300ms ease-in-out 过渡
5. 主题系统应将用户的主题偏好持久化到 localStorage
### 需求 4增强响应式布局和字体缩放
**用户故事:** 作为在各种屏幕尺寸上使用的用户,我希望 UI 能够适当缩放,具有可读的字体和适当大小的元素,以便我可以在任何设备上舒适地使用应用程序。
#### 验收标准
1. 响应式布局应对所有排版尺寸使用 clamp() 函数,以确保在最小值和最大值之间流畅缩放
2. 当视口宽度超过 1440px 时排版尺寸应显著放大hero最大 14remsection最大 6rembody最大 1.5rem
3. 当视口宽度超过 1920px 时,排版尺寸应达到适合投影显示的最大值
4. 响应式布局应在所有交互元素上保持最小 44x44px 的触摸目标尺寸
5. 当宽高比超过 2:1超宽屏响应式布局应限制内容宽度以防止行长过长
6. 响应式布局应使用容器查询单位cqw、cqh用于导航栏和间距以保持比例缩放
7. 当视口为移动端(< 768px
### 需求 5统一子页面设计与 Kinetic Typography 系统
**用户故事:** 作为用户,我希望所有子页面都具有与主页面相同的视觉风格,以便应用程序感觉连贯和专业。
#### 验收标准
1. 子页面组件应使用 Kinetic_Typography CSS 变量(--kt-*)进行所有样式设置,而不是手绘风格(--hd-*)变量
2. 子页面标题应使用与主页面相同的双语标题模式(中文标题 + 英文副标题)
3. 子页面卡片和容器应使用直角边框border-radius: 0和 2px 实线边框,与主设计匹配
4. 子页面排版应使用 Space Grotesk 字体,配合大写 text-transform 和紧凑的 letter-spacing
5. 当悬停在子页面中的交互元素上时,元素应使用酸性黄强调色反转效果
6. 子页面布局应遵循与主页面相同的响应式断点和缩放规则
7. 子页面关闭按钮应匹配 Kinetic_Typography 按钮样式kt-btn 类)

@ -1,159 +0,0 @@
# 实现计划UI 一致性修复
## 概述
本实现计划将设计文档中的方案转化为具体的编码任务。采用增量开发方式,每个任务都建立在前一个任务的基础上,确保代码始终处于可运行状态。
## 任务
- [x] 1. 修复瀑布流滚动/触摸导航
- [x] 1.1 重构 scrollNavigation.js 中的边界检测逻辑
- 修复 checkScrollState 函数,确保正确计算 atTop 和 atBottom
- 增加容差值到 5px 以提高检测准确性
- _需求: 1.4_
- [x] 1.2 修复动画锁定机制
- 确保 animationState.isAnimating 在动画期间正确阻止新输入
- 修复 smartPageSwitch 函数中的锁定检查
- _需求: 1.3_
- [x] 1.3 优化防抖逻辑
- 确保 500ms 冷却期正确应用于滚轮事件
- 确保 600ms 冷却期正确应用于触摸事件
- _需求: 1.5_
- [x] 1.4 修复 MainFlow.vue 中的滚动事件处理
- 修复 handleWheel 函数的边界检测
- 修复 handleTouchMove 和 handleTouchEnd 的阈值判断
- _需求: 1.1, 1.2_
- [x] 1.5 编写滚动导航属性测试
- **属性 1: 滚动导航防抖**
- **属性 2: 动画期间输入锁定**
- **属性 3: 边界滚动检测**
- **属性 4: 页面切换触发条件**
- **验证: 需求 1.1-1.5**
- [x] 2. 修复导航栏汉堡包动画和显示
- [x] 2.1 修复导航栏展开/折叠动画时序
- 修复 NavBar.vue 中的 CSS transition-delay 配置
- 确保标签淡入在宽度过渡后0.15s 延迟)
- 确保标签淡出在宽度过渡前(无延迟)
- _需求: 2.1, 2.2, 2.3_
- [x] 2.2 修复汉堡包图标变换动画
- 修复 #nav-toggle-burger 的 CSS 变换
- 确保 X 形状变换平滑
- _需求: 2.4_
- [x] 2.3 修复移动端导航栏布局
- 确保 < 900px
- 确保导航项水平显示
- _需求: 2.5_
- [x] 2.4 修复高亮指示器位置计算
- 修复 highlightTop 计算逻辑
- 确保高亮平滑跟踪活动项
- _需求: 2.6_
- [x] 2.5 编写导航栏高亮位置属性测试
- **属性 7: 导航栏高亮位置计算**
- **验证: 需求 2.6**
- [x] 3. 检查点 - 确保所有测试通过
- 运行所有测试,确保滚动导航和导航栏修复正常工作
- 如有问题,询问用户
- [x] 4. 统一主题切换,避免布局重排
- [x] 4.1 重构 Style.css 中的主题变量
- 确保明暗模式只定义颜色变量
- 将所有布局变量移到主题无关的 :root 中
- _需求: 3.1, 3.2, 3.3_
- [x] 4.2 添加主题过渡动画
- 为颜色属性添加 300ms ease-in-out 过渡
- 确保不影响布局属性
- _需求: 3.4_
- [x] 4.3 修复子页面的主题适配
- 移除子页面中的 html.dark-mode 特定布局规则
- 确保子页面只使用 --kt-* 颜色变量
- _需求: 3.1, 3.2_
- [x] 4.4 编写主题切换属性测试
- **属性 5: 主题切换布局稳定性**
- **属性 6: 主题偏好持久化往返**
- **验证: 需求 3.1, 3.2, 3.5**
- [x] 5. 增强响应式布局和字体缩放
- [x] 5.1 增强大屏幕字体缩放
- 更新 Style.css 中 1440px+ 断点的 clamp() 值
- 更新 1920px+ 断点的最大字体尺寸
- _需求: 4.1, 4.2, 4.3_
- [x] 5.2 确保触摸目标最小尺寸
- 检查并修复所有交互元素的最小尺寸
- 添加 @media (pointer: coarse) 规则
- _需求: 4.4_
- [x] 5.3 添加超宽屏适配
- 添加 @media (min-aspect-ratio: 2/1) 规则
- 限制内容宽度防止行长过长
- _需求: 4.5_
- [x] 5.4 优化容器查询单位使用
- 确保导航栏和间距使用 cqw/cqh 单位
- 添加不支持容器查询的后备方案
- _需求: 4.6_
- [x] 5.5 修复移动端单列布局
- 确保 < 768px
- 减少移动端间距
- _需求: 4.7_
- [x] 5.6 编写触摸目标尺寸属性测试
- **属性 8: 触摸目标最小尺寸**
- **验证: 需求 4.4**
- [x] 6. 检查点 - 确保所有测试通过
- 运行所有测试,确保主题切换和响应式布局修复正常工作
- 如有问题,询问用户
- [x] 7. 统一子页面设计与 Kinetic Typography 系统
- [x] 7.1 创建统一的子页面基础样式
- 在 Style.css 中添加 .kt-subpage 基础类
- 定义子页面的标准布局和间距
- _需求: 5.1, 5.3_
- [x] 7.2 重构 PrincipleDiagram.vue
- 将 --hd-* 变量替换为 --kt-* 变量
- 应用双语标题模式
- 应用 Kinetic Typography 卡片样式
- _需求: 5.1, 5.2, 5.3, 5.4_
- [x] 7.3 重构 SamplePreview.vue
- 将 --hd-* 变量替换为 --kt-* 变量
- 应用双语标题模式
- 应用 Kinetic Typography 卡片样式
- _需求: 5.1, 5.2, 5.3, 5.4_
- [x] 7.4 重构 PaperSupport.vue
- 将 --hd-* 变量替换为 --kt-* 变量
- 应用双语标题模式
- 应用 Kinetic Typography 卡片样式
- _需求: 5.1, 5.2, 5.3, 5.4_
- [x] 7.5 重构 Page1 子页面 (QuickMode.vue, UniversalMode.vue)
- 应用 Kinetic Typography 设计语言
- 确保响应式布局一致
- _需求: 5.1-5.6_
- [x] 7.6 重构 Page2 子页面 (SubpageContainer.vue)
- 应用 Kinetic Typography 设计语言
- 确保响应式布局一致
- _需求: 5.1-5.6_
- [x] 7.7 重构 Page3-5 子页面
- 应用 Kinetic Typography 设计语言
- 确保响应式布局一致
- _需求: 5.1-5.6_
- [x] 7.8 修复 Button.vue (关闭按钮)
- 应用 kt-btn 样式类
- 确保与 Kinetic Typography 按钮风格一致
- _需求: 5.7_
- [x] 7.9 添加子页面悬停效果
- 确保交互元素使用酸性黄强调色反转效果
- _需求: 5.5_
- [x] 7.10 编写子页面样式一致性单元测试 ✅
- 验证子页面使用正确的 CSS 变量
- 验证双语标题结构
- **验证: 需求 5.1-5.7**
- [x] 8. 最终检查点 - 确保所有测试通过
- 运行所有测试,确保所有修复正常工作
- 如有问题,询问用户
## 注意事项
- 每个任务都引用了具体的需求以确保可追溯性
- 检查点确保增量验证
- 属性测试验证通用正确性属性
- 单元测试验证特定示例和边界情况

Binary file not shown.

@ -1,3 +1,4 @@
<!-- index.html -->
<!doctype html>
<html lang="zh-CN">
<head>
@ -9,9 +10,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
<!-- 新增:图片资源预加载 -->
<link rel="preload" href="/register_bg.png" as="image">
<!-- 如果有其他关键图片也可以在这里添加 -->
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because it is too large Load Diff

@ -2,8 +2,6 @@
/**
* App.vue - Root Component
*
* 修改说明
* 移除了强制宽高比 (Aspect Ratio) 的计算逻辑
* 现在根容器将自然撑满 100vw * 100vh适应任意分辨率包括 4K
* 保留了 container-type: size确保内部组件依然可以使用 cqw/cqh 进行响应式缩放
*/
@ -19,17 +17,12 @@
/* 根容器样式 - 全局样式 */
.app-root-container {
/*
关键配置启用容器查询
启用容器查询
子组件 (MainFlow, LoginView) 依赖 cqw/cqh 单位
这里必须声明 container-type
*/
container-type: size;
container-name: app-root;
/*
布局修复强制填满视口
不再使用 aspect-ratio max-width 限制
*/
width: 100vw;
height: 100vh;

@ -54,7 +54,7 @@
/* Requirements: 4.6 - Container query units for navbar and spacing */
/* These provide fallback values for browsers that don't support container queries */
--cq-navbar-width-collapsed: 8cqw;
--cq-navbar-width-expanded: 26cqw;
--cq-navbar-width-expanded: 19cqw;
--cq-navbar-spacing: 1.5cqw;
--cq-navbar-button-size: 3.5cqw;

@ -16,27 +16,8 @@ export function sendAuthCode(data) {
/**
* 用户登录
* 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-admin-token-2025-super-secret',
user: {
user_id: 1,
username: 'admin',
email: 'dev@admin.com',
role: 'admin', // 赋予管理员权限
is_active: true,
created_at: new Date().toISOString()
}
})
}
// ----------------------------------
return request({
url: '/auth/login',
method: 'post',
@ -57,22 +38,24 @@ export function authRegister(data) {
})
}
export function authGetProfile() {
const token = localStorage.getItem('access_token')
// 如果是 Mock Token直接返回 Mock 用户信息
if (token === 'mock-admin-token-2025-super-secret') {
return Promise.resolve({
user: {
user_id: 1,
username: 'admin',
email: 'dev@admin.com',
role: 'admin',
is_active: true
}
})
}
/**
* 忘记密码/重置密码
* API: POST /api/auth/forgot-password
* Body: { email, code, new_password }
*/
export function authForgotPassword(data) {
return request({
url: '/auth/forgot-password',
method: 'post',
data
})
}
/**
* 获取用户信息
* API: POST /api/auth/profile
*/
export function authGetProfile() {
return request({
url: '/auth/profile',
method: 'post',

@ -21,10 +21,6 @@ export async function getTaskImagePreview(taskId) {
// 或者可以直接使用 axios.get 绕过拦截器,带上 token
returnRawResponse: true
})
// 如果 request.js 封装得比较死,返回的是 arrayBuffer我们无法获取 header 中的 boundary。
// **建议修改 src/utils/request.js** 或者在这里手动获取 headers。
// 假设 request.js 已经修改或支持返回 headers:
// 获取 Boundary
const contentType = response.headers['content-type'] || response.headers['Content-Type']

@ -160,4 +160,20 @@ export function getFinetuneCoords(taskId) {
url: `/task/finetune/${taskId}/coords`,
method: 'get'
})
}
/**
* 重启已取消或失败的任务
* API: POST /api/task/{taskId}/restart
*/
export function restartTask(taskId) {
return request({ url: `/task/${taskId}/restart`, method: 'post' })
}
/**
* 删除任务
* API: DELETE /api/task/{taskId}
*/
export function deleteTask(taskId) {
return request({ url: `/task/${taskId}`, method: 'delete' })
}

@ -48,7 +48,7 @@ const handleClose = () => {
<style scoped>
/* ===== Kinetic Typography Close Button Component ===== */
/* Requirements: 5.7 - Match KT button style (kt-btn class) */
/* 位置为左上角,与登出按钮位置一致 */
/* 位置为左上角,与登出按钮位置一致 */
.kt-close-button {
/* Fixed top LEFT position - 左上角空闲区域 */

@ -0,0 +1,198 @@
<template>
<div ref="containerRef" :class="[className, 'kt-grid-distortion-bg']" />
</template>
<script setup>
import * as THREE from 'three';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'
const router = useRouter();
const props = defineProps({
gridX: { type: Number, default: 8 }, //
gridY: { type: Number, default: 5 }, //
mouse: { type: Number, default: 0.1 }, //
strength: { type: Number, default: 0.15 }, //
relaxation: { type: Number, default: 0.9 }, //
imageSrc: { type: String, required: true }, //
className: { type: String, default: '' }, //
isDark: { type: Boolean, default: true } //
});
//
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
//
const fragmentShader = `
uniform sampler2D uDataTexture;
uniform sampler2D uTexture;
uniform float uBrightness;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
vec4 offset = texture2D(uDataTexture, vUv);
// rg
vec4 texColor = texture2D(uTexture, uv - 0.02 * offset.rg);
//
gl_FragColor = vec4(texColor.rgb * uBrightness, texColor.a);
}
`;
const containerRef = ref(null);
const imageAspectRef = ref(1); //
let uniforms = null; //
let cleanupAnimation = () => {}; //
const setupAnimation = () => {
const container = containerRef.value;
if (!container) return;
// WebGL
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
// 使
const camera = new THREE.OrthographicCamera(0, 0, 0, 0, -1000, 1000);
camera.position.z = 2;
uniforms = {
uTexture: { value: null }, //
uDataTexture: { value: null }, //
uBrightness: { value: props.isDark ? 0.40 : 1.0 } //
};
//
const textureLoader = new THREE.TextureLoader();
textureLoader.load(props.imageSrc, (texture) => {
// 使 LinearFilter mipmaps
texture.minFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
imageAspectRef.value = texture.image.width / texture.image.height;
uniforms.uTexture.value = texture;
handleResize();
});
//
const sizeX = props.gridX;
const sizeY = props.gridY;
const data = new Float32Array(4 * sizeX * sizeY);
const dataTexture = new THREE.DataTexture(data, sizeX, sizeY, THREE.RGBAFormat, THREE.FloatType);
dataTexture.needsUpdate = true;
uniforms.uDataTexture.value = dataTexture;
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader,
fragmentShader,
transparent: true
});
//
const geometry = new THREE.PlaneGeometry(1, 1, sizeX - 1, sizeY - 1);
const plane = new THREE.Mesh(geometry, material);
scene.add(plane);
// Cover
const handleResize = () => {
const width = container.offsetWidth;
const height = container.offsetHeight;
if (width === 0 || height === 0) return;
renderer.setSize(width, height);
const containerAspect = width / height;
const scale = Math.max(containerAspect / imageAspectRef.value, 1);
plane.scale.set(imageAspectRef.value * scale, scale, 1);
camera.left = -containerAspect / 2;
camera.right = containerAspect / 2;
camera.top = 0.5;
camera.bottom = -0.5;
camera.updateProjectionMatrix();
};
//
const mouseState = { x: 0, y: 0, prevX: 0, prevY: 0, vX: 0, vY: 0 };
const handleMouseMove = (e) => {
const rect = container.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1 - (e.clientY - rect.top) / rect.height;
mouseState.vX = x - mouseState.prevX;
mouseState.vY = y - mouseState.prevY;
Object.assign(mouseState, { x, y, prevX: x, prevY: y });
};
window.addEventListener('resize', handleResize);
window.addEventListener('mousemove', handleMouseMove);
let animationId;
const animate = () => {
animationId = requestAnimationFrame(animate);
const currentData = dataTexture.image.data;
//
for (let i = 0; i < sizeX * sizeY; i++) {
currentData[i * 4] *= props.relaxation;
currentData[i * 4 + 1] *= props.relaxation;
}
//
const gridMouseX = sizeX * mouseState.x;
const gridMouseY = sizeY * mouseState.y;
const maxDist = Math.max(sizeX, sizeY) * props.mouse;
for (let i = 0; i < sizeX; i++) {
for (let j = 0; j < sizeY; j++) {
const distSq = Math.pow(gridMouseX - i, 2) + Math.pow(gridMouseY - j, 2);
if (distSq < maxDist * maxDist) {
const index = 4 * (i + sizeX * j);
const power = Math.min(maxDist / Math.sqrt(distSq), 10);
currentData[index] += props.strength * 100 * mouseState.vX * power;
currentData[index + 1] -= props.strength * 100 * mouseState.vY * power;
}
}
}
dataTexture.needsUpdate = true;
renderer.render(scene, camera);
};
animate();
// WebGL
cleanupAnimation = () => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handleMouseMove);
renderer.dispose();
geometry.dispose();
material.dispose();
dataTexture.dispose();
};
};
// Uniform
watch(() => props.isDark, (dark) => {
if (uniforms) uniforms.uBrightness.value = dark ? 0.40 : 1.0;
});
onMounted(() => setupAnimation());
onUnmounted(() => cleanupAnimation());
</script>
<style scoped>
.kt-grid-distortion-bg {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 0;
pointer-events: none; /* 确保背景不拦截页面内按钮等组件的点击 */
}
</style>

@ -1,21 +1,7 @@
<script setup>
/**
* KtMarquee - Kinetic Typography Infinite Scrolling Marquee Component
*
* Implements infinite horizontal scrolling animation using CSS transform
* for GPU acceleration. Content is duplicated to create seamless looping.
*
* Requirements: 4.2, 8.1, 8.4, 11.6
*/
import { computed } from 'vue'
const props = defineProps({
/**
* Animation speed variant
* - fast: 15s duration
* - normal: 20s duration (default)
* - slow: 30s duration
*/
speed: {
type: String,
default: 'normal',

@ -0,0 +1,236 @@
<template>
<div class="kt-select-container" :class="{ 'is-disabled': disabled }" ref="containerRef">
<!-- 触发器 (显示当前选中值) -->
<div
class="kt-select-trigger"
:class="{ 'is-open': isOpen }"
@click="toggleDropdown"
>
<span class="kt-select-value" :class="{ 'is-placeholder': !selectedLabel }">
{{ selectedLabel || placeholder }}
</span>
<i class="fas fa-chevron-down kt-select-arrow"></i>
</div>
<!-- 下拉菜单 -->
<Transition name="kt-slide-down">
<div v-if="isOpen" class="kt-select-dropdown">
<ul class="kt-select-options">
<li
v-for="option in options"
:key="option.value"
class="kt-select-option"
:class="{ 'is-selected': modelValue === option.value }"
@click="handleSelect(option)"
>
<span class="option-label">{{ option.label }}</span>
<i v-if="modelValue === option.value" class="fas fa-check option-check"></i>
</li>
<!-- 空状态 -->
<li v-if="options.length === 0" class="kt-select-empty">
暂无选项
</li>
</ul>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
modelValue: { type: [String, Number, null], default: null },
options: { type: Array, default: () => [] }, // : [{ label: 'A', value: 1 }]
placeholder: { type: String, default: '请选择...' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'change'])
const isOpen = ref(false)
const containerRef = ref(null)
//
const selectedLabel = computed(() => {
const selected = props.options.find(opt => opt.value === props.modelValue)
return selected ? selected.label : ''
})
const toggleDropdown = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
}
const handleSelect = (option) => {
emit('update:modelValue', option.value)
emit('change', option.value)
isOpen.value = false
}
//
const handleClickOutside = (e) => {
if (containerRef.value && !containerRef.value.contains(e.target)) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</script>
<style scoped>
/* 容器 */
.kt-select-container {
position: relative;
width: 100%;
font-family: var(--kt-font);
}
.kt-select-container.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 触发器 (输入框外观) */
.kt-select-trigger {
width: 100%;
min-height: 48px; /* 确保触摸友好 */
padding: 0 1rem;
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: all var(--kt-transition-micro);
user-select: none;
}
.kt-select-trigger:hover:not(.is-disabled) {
border-color: var(--kt-accent);
}
.kt-select-trigger.is-open {
border-color: var(--kt-accent);
background: var(--kt-muted);
}
/* 文字样式 */
.kt-select-value {
font-size: var(--kt-small);
font-weight: 600;
color: var(--kt-fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.kt-select-value.is-placeholder {
color: var(--kt-muted-fg);
font-weight: 400;
}
/* 箭头动画 */
.kt-select-arrow {
color: var(--kt-muted-fg);
font-size: 0.8rem;
transition: transform var(--kt-transition-micro);
}
.kt-select-trigger.is-open .kt-select-arrow {
transform: rotate(180deg);
color: var(--kt-accent);
}
/* 下拉菜单面板 */
.kt-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
max-height: 240px;
overflow-y: auto;
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
z-index: 1000; /* 确保在最上层 */
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
/* 滚动条美化 */
.kt-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.kt-select-dropdown::-webkit-scrollbar-thumb {
background: var(--kt-border);
}
/* 选项列表 */
.kt-select-options {
list-style: none;
padding: 0;
margin: 0;
}
.kt-select-option {
padding: 0.75rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all var(--kt-transition-micro);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.kt-select-option:last-child {
border-bottom: none;
}
.option-label {
font-size: var(--kt-small);
color: var(--kt-fg);
font-weight: 500;
}
.option-check {
color: var(--kt-accent-fg);
font-size: 0.8rem;
}
/* 悬停与选中状态 (核心风格:黑黄反转) */
.kt-select-option:hover {
background: var(--kt-muted);
}
.kt-select-option.is-selected {
background: var(--kt-accent);
}
.kt-select-option.is-selected .option-label {
color: var(--kt-accent-fg); /* 黑色文字 */
font-weight: 700;
}
.kt-select-empty {
padding: 1rem;
text-align: center;
color: var(--kt-muted-fg);
font-size: var(--kt-small);
}
/* 动画 */
.kt-slide-down-enter-active,
.kt-slide-down-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.kt-slide-down-enter-from,
.kt-slide-down-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

@ -1,17 +1,4 @@
<script setup>
/**
* NavBar.vue - Kinetic Typography Navigation Bar
*
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
* - 3.1: Preserve all existing navigation items
* - 3.2: Preserve all existing action buttons
* - 3.3: Rich black background with 2px zinc-700 border
* - 3.4: Navigation labels in uppercase with tight tracking
* - 3.5: Hover scale 1.05 transform
* - 3.6: Active state with acid yellow accent
* - 3.7: Space Grotesk font
* - 3.8: Collapsed state shows only icons
*/
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({
@ -21,10 +8,16 @@ const props = defineProps({
const emit = defineEmits(['navigate', 'logout', 'toggle'])
// Toggle State
const isExpanded = ref(false)
const isDarkMode = ref(true) // Default to dark mode for Kinetic Typography
watch(isExpanded, (newValue) => { emit('toggle', newValue) })
const savedState = localStorage.getItem('kt_nav_expanded')
const isExpanded = ref(savedState === 'true' && savedState !== null ? true : false) // false localStorage MainFlow
watch(isExpanded, (newValue) => {
localStorage.setItem('kt_nav_expanded', newValue)
emit('toggle', newValue)
})
// Nav Items - Requirement 3.1: Preserve all existing navigation items
const navItems = [
@ -138,16 +131,6 @@ onMounted(() => {
<style scoped>
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css');
/* ===== Kinetic Typography NavBar Styles =====
Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
- 3.3: Rich black background with 2px zinc-700 border
- 3.4: Navigation labels in uppercase with tight tracking
- 3.5: Hover scale 1.05 transform
- 3.6: Active state with acid yellow accent
- 3.7: Space Grotesk font
- 3.8: Collapsed state shows only icons
*/
/* === 容器:默认桌面垂直布局 (使用 cqw 单位) === */
#navbar-container {
position: fixed;
@ -172,25 +155,19 @@ onMounted(() => {
max-height: 80vh;
min-height: 35vh;
width: v-bind("isExpanded ? 'var(--cq-navbar-width-expanded)' : 'var(--cq-navbar-width-collapsed)'");
/* Kinetic Typography: Rich black background */
background: var(--kt-bg, #09090B);
/* 2px zinc-700 border */
border: var(--kt-border-width, 2px) solid var(--kt-border, #3F3F46);
border-left: none;
/* Sharp corners - 0px border-radius */
border-radius: 0;
/* No shadow for clean look */
box-shadow: none;
display: flex;
flex-direction: column;
/* Off-white text */
color: var(--kt-fg, #FAFAFA);
overflow: hidden;
transition: all 0.2s linear;
flex-shrink: 0;
}
/* Header section */
#nav-header {
position: relative;
width: 100%;
@ -201,27 +178,19 @@ onMounted(() => {
padding: 0 var(--cq-navbar-spacing, 1.5cqw);
}
/* Title - Requirement 3.7: Space Grotesk font, Requirement 3.4: Uppercase */
/* Requirements: 2.1, 2.2, 2.3 - Expand/collapse animation timing */
#nav-title {
font-family: var(--kt-font, 'Space Grotesk', sans-serif);
font-weight: 700;
font-size: var(--cq-font-lg, 1.25rem);
/* Uppercase with tight tracking */
text-transform: uppercase;
letter-spacing: -0.02em;
opacity: v-bind("isExpanded ? 1 : 0");
/* Synchronized with width transition:
- Expanding: title fades in AFTER width change (0.15s delay)
- Collapsing: title fades out BEFORE width decreases (no delay) */
transition: opacity 0.15s ease-in-out;
transition-delay: v-bind("isExpanded ? '0.15s' : '0s'");
white-space: nowrap;
/* Acid yellow accent */
color: var(--kt-accent, #DFE104);
overflow: hidden;
text-decoration: none;
/* Ensure smooth animation */
will-change: opacity;
}
@ -229,7 +198,6 @@ onMounted(() => {
color: var(--kt-fg, #FAFAFA);
}
/* Divider with Kinetic Typography style - solid line */
#nav-header hr {
position: absolute;
bottom: 0;
@ -255,8 +223,6 @@ label[for="nav-toggle"] {
cursor: pointer;
}
/* Burger menu with Kinetic Typography style */
/* Requirements: 2.4 - Smooth hamburger to X transformation */
#nav-toggle-burger {
position: relative;
width: 1.5cqw;
@ -264,7 +230,6 @@ label[for="nav-toggle"] {
height: 2px;
background: var(--kt-fg, #FAFAFA);
border-radius: 0;
/* Smooth transition for the middle bar disappearing */
transition: background 0.15s ease-in-out;
}
@ -277,13 +242,10 @@ label[for="nav-toggle"] {
background: var(--kt-fg, #FAFAFA);
border-radius: 0;
left: 0;
/* Smooth transition for rotation and position */
transition: transform 0.25s ease-in-out, top 0.25s ease-in-out;
/* Ensure smooth animation */
will-change: transform, top;
}
/* Fixed pixel values for consistent spacing across viewports */
#nav-toggle-burger::before {
top: -8px;
}
@ -292,8 +254,6 @@ label[for="nav-toggle"] {
top: 8px;
}
/* Expanded state - X shape transformation */
/* Requirements: 2.4 - Smooth X shape transformation */
#nav-toggle:checked ~ #nav-bar #nav-toggle-burger {
background: transparent;
}
@ -308,7 +268,6 @@ label[for="nav-toggle"] {
transform: rotate(-45deg);
}
/* Content area */
#nav-content {
flex: 1;
background: var(--kt-bg, #09090B);
@ -316,24 +275,18 @@ label[for="nav-toggle"] {
position: relative;
}
/* Active highlight - Kinetic Typography style (Requirement 3.6) */
/* Requirements: 2.6 - Smooth highlight tracking */
#nav-content-highlight {
position: absolute;
left: 0;
width: 100%;
/* Acid yellow background for active state */
background: var(--kt-accent, #DFE104);
border: none;
border-radius: 0;
/* Smooth transition for highlight position tracking */
transition: top 0.2s ease-in-out;
z-index: 0;
/* Ensure smooth animation */
will-change: top;
}
/* Navigation items container */
.nav-items-container {
position: absolute;
top: 10%;
@ -344,17 +297,14 @@ label[for="nav-toggle"] {
flex-direction: column;
}
/* Navigation button - Requirement 3.4, 3.5, 3.7 */
.nav-button {
position: relative;
margin-left: var(--cq-navbar-spacing, 1.5cqw);
flex: 1;
display: flex;
align-items: center;
/* Space Grotesk font */
font-family: var(--kt-font, 'Space Grotesk', sans-serif);
font-weight: 600;
/* Uppercase with tight tracking */
text-transform: uppercase;
letter-spacing: -0.02em;
color: var(--kt-fg, #FAFAFA);
@ -366,15 +316,12 @@ label[for="nav-toggle"] {
border-radius: 0;
}
/* Hover effect - Requirement 3.5: scale 1.05 */
.nav-button:hover {
color: var(--kt-accent, #DFE104);
transform: scale(1.05);
}
/* Active state - Requirement 3.6: acid yellow */
.nav-button.active {
/* Black text on yellow background */
color: var(--kt-accent-fg, #000000);
font-weight: 700;
}
@ -388,20 +335,14 @@ label[for="nav-toggle"] {
flex-shrink: 0;
}
/* Label styling - Requirement 3.8: collapsed shows only icons */
/* Requirements: 2.1, 2.2, 2.3 - Expand/collapse animation timing
- Expanding: labels fade in AFTER width change (0.15s delay)
- Collapsing: labels fade out BEFORE width decreases (no delay)
*/
.nav-button span {
opacity: v-bind("isExpanded ? 1 : 0");
/* Smooth fade transition with proper timing */
transition: opacity 0.15s ease-in-out;
transition-delay: v-bind("isExpanded ? '0.15s' : '0s'");
white-space: nowrap;
z-index: 2;
font-size: var(--cq-font-base, 1rem);
/* Ensure smooth animation */
font-size: 1.25rem;
font-weight: 700;
will-change: opacity;
}
@ -420,7 +361,6 @@ label[for="nav-toggle"] {
z-index: calc(var(--z-nav, 100) + 1);
}
/* Kinetic Typography button base style */
.kt-btn {
display: inline-flex;
align-items: center;
@ -436,7 +376,6 @@ label[for="nav-toggle"] {
transform: translate(0, 0);
}
/* Hover - Requirement 3.5: scale 1.05 */
.kt-btn:hover {
background: var(--kt-accent, #DFE104);
color: var(--kt-accent-fg, #000000);
@ -448,15 +387,27 @@ label[for="nav-toggle"] {
transform: scale(0.95);
}
/* Circle button variant */
.kt-btn--circle {
width: var(--cq-navbar-button-size, 3.5cqw);
height: var(--cq-navbar-button-size, 3.5cqw);
width: max(4.8cqw, 48px);
height: max(4.8cqw, 48px);
border-radius: 0;
padding: 0;
font-size: clamp(1.25rem, 2.5cqw, 2.5rem);
/* 确保图标能够随鼠标悬停效果一起变化 */
transition: all var(--kt-transition-micro, 200ms ease-in-out);
}
.kt-btn--circle i {
/* 强制图标居中 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* Accent button variant - Requirement 3.6 */
.kt-btn--accent {
background: var(--kt-accent, #DFE104);
color: var(--kt-accent-fg, #000000);
@ -469,7 +420,6 @@ label[for="nav-toggle"] {
border-color: var(--kt-fg, #FAFAFA);
}
/* Ghost button variant */
.kt-btn--ghost {
background: transparent;
border: var(--kt-border-width, 2px) solid var(--kt-border, #3F3F46);
@ -481,12 +431,10 @@ label[for="nav-toggle"] {
color: var(--kt-accent-fg, #000000);
}
/* Active state for buttons */
.kt-btn--active {
border: var(--kt-border-width, 2px) solid var(--kt-fg, #FAFAFA);
}
/* Page 5 button specific */
.page-5-btn {
font-size: var(--cq-font-lg, 1.25rem);
}
@ -495,13 +443,12 @@ label[for="nav-toggle"] {
margin: 0;
}
/* Theme button specific */
.theme-btn {
background: var(--kt-bg, #09090B);
font-size: var(--cq-font-sm, 0.875rem);
}
/* === 降级 - 顶部导航栏模式 (宽 < 900px) === */
/* === - 顶部导航栏模式 (宽 < 900px) === */
/* Requirements: 2.5 - Mobile layout with hidden hamburger and horizontal navigation */
@media (max-width: 900px) {
#navbar-container {
@ -522,7 +469,6 @@ label[for="nav-toggle"] {
min-height: auto;
max-height: none;
margin: 0 !important;
/* Kinetic Typography style for mobile - sharp corners */
border-radius: 0;
border-top: none;
border-left: none;
@ -531,12 +477,10 @@ label[for="nav-toggle"] {
box-shadow: none;
}
/* Hide header section on mobile */
#nav-header {
display: none;
}
/* Hide hamburger button and toggle on mobile - Requirements: 2.5 */
label[for="nav-toggle"] {
display: none !important;
}
@ -545,7 +489,6 @@ label[for="nav-toggle"] {
display: none !important;
}
/* Hide highlight indicator on mobile */
#nav-content-highlight {
display: none;
}
@ -557,7 +500,6 @@ label[for="nav-toggle"] {
overflow: visible;
}
/* Horizontal navigation items - Requirements: 2.5 */
.nav-items-container {
position: relative;
top: auto;
@ -572,7 +514,6 @@ label[for="nav-toggle"] {
.nav-button {
margin: 0;
flex: 0 0 auto;
/* Requirements: 10.6 - Touch target minimum 44x44px */
width: max(5cqw, 44px);
height: max(5cqw, 44px);
min-width: 44px;
@ -582,7 +523,6 @@ label[for="nav-toggle"] {
border: var(--kt-border-width, 2px) solid transparent;
}
/* Hide labels on mobile - show only icons */
.nav-button span {
display: none !important;
}
@ -592,7 +532,6 @@ label[for="nav-toggle"] {
font-size: var(--cq-font-base, 1rem);
}
/* Active state for mobile - Kinetic Typography style */
.nav-button.active {
background: var(--kt-accent, #DFE104);
color: var(--kt-accent-fg, #000000);
@ -602,7 +541,6 @@ label[for="nav-toggle"] {
box-shadow: none;
}
/* External actions - FAB style at bottom right */
.external-actions {
position: fixed;
right: 2cqw;
@ -618,7 +556,6 @@ label[for="nav-toggle"] {
z-index: 1000;
}
/* Mobile buttons - Requirements: 10.6 - Touch target minimum 44x44px */
.kt-btn--circle {
width: max(4.8cqw, 44px);
height: max(4.8cqw, 44px);
@ -628,7 +565,6 @@ label[for="nav-toggle"] {
}
}
/* === Prefers Reduced Motion === */
@media (prefers-reduced-motion: reduce) {
.nav-button,
.kt-btn {
@ -641,8 +577,6 @@ label[for="nav-toggle"] {
}
}
/* === Focus States for Accessibility === */
/* Requirements: 11.4 - Focus indicator with accent color, minimum 2px */
.nav-button:focus-visible {
outline: 2px solid var(--kt-accent, #DFE104);
outline-offset: 2px;
@ -654,7 +588,6 @@ label[for="nav-toggle"] {
outline-offset: 2px;
}
/* Remove default focus outline when using focus-visible */
.nav-button:focus:not(:focus-visible),
.kt-btn:focus:not(:focus-visible) {
outline: none;

@ -1,19 +1,7 @@
<script setup>
/**
* NoiseOverlay - Kinetic Typography Noise Texture Overlay Component
*
* Creates a subtle noise texture overlay using SVG feTurbulence filter
* to add visual depth and texture to the dark theme.
*
* Requirements: 2.2, 11.6
*/
</script>
<template>
<!--
Decorative noise overlay - aria-hidden for accessibility (Requirements 11.6)
This is purely visual and should be ignored by screen readers
-->
<div class="kt-noise" aria-hidden="true">
<svg
width="100%"
@ -46,10 +34,6 @@
</template>
<style scoped>
/*
* Noise overlay styling
* Requirements: 2.2 - opacity 0.03, mix-blend-mode overlay
*/
.kt-noise {
position: fixed;
inset: 0;

@ -1,181 +1,181 @@
<template>
<div class="stack-container" :style="{ width: width, height: height }">
<div class="card-stack">
<div
v-for="(item, index) in items"
:key="index"
class="card-item"
:class="{ 'is-active': getDisplayIndex(index) === 0 }"
:style="getCardStyle(index)"
@click="handleCardClick(index)"
>
<div class="card-inner">
<img :src="item.image" :alt="item.alt || ''" class="card-img" />
<!-- 覆盖层内容 (插槽) -->
<div class="card-overlay" v-if="showOverlay">
<slot name="overlay" :item="item" :index="index" :is-active="getDisplayIndex(index) === 0">
<!-- 默认显示内容 -->
<span class="badge" v-if="item.badge">{{ item.badge }}</span>
<h3 v-if="item.title">{{ item.title }}</h3>
</slot>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
items: { type: Array, required: true, default: () => [] },
width: { type: String, default: '260px' },
height: { type: String, default: '360px' },
showOverlay: { type: Boolean, default: true },
visibleCount: { type: Number, default: 3 },
// v-model
modelValue: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'change'])
const total = computed(() => props.items.length)
//
const getDisplayIndex = (index) => {
return (index - props.modelValue + total.value) % total.value
}
//
const getCardStyle = (index) => {
const displayIndex = getDisplayIndex(index)
if (displayIndex < props.visibleCount) {
//
const scale = 1 - (displayIndex * 0.05)
// ()
const translateY = -(displayIndex * 20)
// ()
const rotate = displayIndex * 2
const zIndex = total.value - displayIndex
const brightness = 1 - (displayIndex * 0.15)
return {
transform: `translate(${translateY}px, ${translateY}px) rotate(${rotate}deg) scale(${scale})`, //
zIndex: zIndex,
filter: `brightness(${brightness})`,
opacity: 1,
visibility: 'visible',
pointerEvents: displayIndex === 0 ? 'auto' : 'none',
cursor: displayIndex === 0 ? 'pointer' : 'default'
}
} else if (displayIndex === total.value - 1) {
// ()
return {
transform: `translate(-40px, 40px) rotate(-10deg) scale(0.9)`,
opacity: 0,
zIndex: total.value + 1,
visibility: 'hidden',
transition: 'all 0.4s ease-out'
}
} else {
//
return {
transform: `translate(0px, 0px) scale(0.8)`,
opacity: 0,
zIndex: 0,
visibility: 'hidden',
transition: 'none'
}
}
}
const handleCardClick = (index) => {
if (getDisplayIndex(index) === 0) {
const nextIndex = (props.modelValue + 1) % total.value
emit('update:modelValue', nextIndex)
emit('change', nextIndex)
}
}
</script>
<style scoped>
.stack-container {
position: relative;
/* 预留右下空间给堆叠位移 */
margin-right: 40px;
margin-bottom: 40px;
box-sizing: border-box;
}
.card-stack {
position: relative;
width: 100%;
height: 100%;
}
.card-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* KT 风格样式 */
background-color: var(--kt-bg);
border: 4px solid #fff;
border-radius: 8px;
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.3),
0 4px 8px rgba(0,0,0,0.1);
transition:
transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.4s ease,
filter 0.4s ease,
z-index 0s;
will-change: transform, opacity;
}
/* 深色模式适配 */
:root.dark-mode .card-item {
border-color: #3f3f46;
background-color: #18181b;
}
/* 顶层高亮 */
.card-item.is-active {
border-color: var(--kt-fg);
}
.card-item.is-active:hover {
transform: translate(-4px, -4px) scale(1.02) !important;
border-color: var(--kt-accent);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.card-inner {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
border-radius: 4px;
}
.card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
<template>
<div class="stack-container" :style="{ width: width, height: height }">
<div class="card-stack">
<div
v-for="(item, index) in items"
:key="index"
class="card-item"
:class="{ 'is-active': getDisplayIndex(index) === 0 }"
:style="getCardStyle(index)"
@click="handleCardClick(index)"
>
<div class="card-inner">
<img :src="item.image" :alt="item.alt || ''" class="card-img" />
<!-- 覆盖层内容 (插槽) -->
<div class="card-overlay" v-if="showOverlay">
<slot name="overlay" :item="item" :index="index" :is-active="getDisplayIndex(index) === 0">
<!-- 默认显示内容 -->
<span class="badge" v-if="item.badge">{{ item.badge }}</span>
<h3 v-if="item.title">{{ item.title }}</h3>
</slot>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
items: { type: Array, required: true, default: () => [] },
width: { type: String, default: '260px' },
height: { type: String, default: '360px' },
showOverlay: { type: Boolean, default: true },
visibleCount: { type: Number, default: 3 },
// v-model
modelValue: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'change'])
const total = computed(() => props.items.length)
//
const getDisplayIndex = (index) => {
return (index - props.modelValue + total.value) % total.value
}
//
const getCardStyle = (index) => {
const displayIndex = getDisplayIndex(index)
if (displayIndex < props.visibleCount) {
//
const scale = 1 - (displayIndex * 0.05)
// ()
const translateY = -(displayIndex * 20)
// ()
const rotate = displayIndex * 2
const zIndex = total.value - displayIndex
const brightness = 1 - (displayIndex * 0.15)
return {
transform: `translate(${translateY}px, ${translateY}px) rotate(${rotate}deg) scale(${scale})`, //
zIndex: zIndex,
filter: `brightness(${brightness})`,
opacity: 1,
visibility: 'visible',
pointerEvents: displayIndex === 0 ? 'auto' : 'none',
cursor: displayIndex === 0 ? 'pointer' : 'default'
}
} else if (displayIndex === total.value - 1) {
// ()
return {
transform: `translate(-40px, 40px) rotate(-10deg) scale(0.9)`,
opacity: 0,
zIndex: total.value + 1,
visibility: 'hidden',
transition: 'all 0.4s ease-out'
}
} else {
//
return {
transform: `translate(0px, 0px) scale(0.8)`,
opacity: 0,
zIndex: 0,
visibility: 'hidden',
transition: 'none'
}
}
}
const handleCardClick = (index) => {
if (getDisplayIndex(index) === 0) {
const nextIndex = (props.modelValue + 1) % total.value
emit('update:modelValue', nextIndex)
emit('change', nextIndex)
}
}
</script>
<style scoped>
.stack-container {
position: relative;
/* 预留右下空间给堆叠位移 */
margin-right: 40px;
margin-bottom: 40px;
box-sizing: border-box;
}
.card-stack {
position: relative;
width: 100%;
height: 100%;
}
.card-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* KT 风格样式 */
background-color: var(--kt-bg);
border: 4px solid #fff;
border-radius: 8px;
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.3),
0 4px 8px rgba(0,0,0,0.1);
transition:
transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.4s ease,
filter 0.4s ease,
z-index 0s;
will-change: transform, opacity;
}
/* 深色模式适配 */
:root.dark-mode .card-item {
border-color: #3f3f46;
background-color: #18181b;
}
/* 顶层高亮 */
.card-item.is-active {
border-color: var(--kt-fg);
}
.card-item.is-active:hover {
transform: translate(-4px, -4px) scale(1.02) !important;
border-color: var(--kt-accent);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.card-inner {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
border-radius: 4px;
}
.card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
</style>

@ -59,13 +59,6 @@ const formatStatusLabel = (status) => {
</script>
<style scoped>
/* ===== Kinetic Typography Task Sidebar =====
Requirements: 5.1 - Use --kt-* variables for all styling
- Sharp corners (border-radius: 0)
- Space Grotesk font
- Acid yellow accent color
*/
.kt-sidebar-card {
height: 100%;
display: flex;

@ -370,8 +370,6 @@ onUnmounted(() => disposeThree())
</script>
<style scoped>
/* ===== KT Style 3D Trajectory Modal ===== */
.kt-3d-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);

@ -65,8 +65,6 @@ defineExpose({ close })
</script>
<style scoped>
/* ===== Kinetic Typography Toast Component ===== */
.kt-toast {
position: fixed;
right: 2cqw;
@ -79,8 +77,7 @@ defineExpose({ close })
min-width: 32cqw;
max-width: 45cqw;
padding: 1.6cqw 2cqw;
/* KT styling */
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
@ -91,7 +88,6 @@ defineExpose({ close })
overflow: visible;
}
/* Left decoration bar */
.kt-toast::before {
content: '';
position: absolute;

@ -9,20 +9,14 @@ const router = createRouter({
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: [
// Home Subpages
{
path: 'home/PrincipleDiagram',
name: 'PrincipleDiagram',
@ -38,6 +32,8 @@ const router = createRouter({
name: 'PaperSupport',
component: () => import('../views/home/subpages/PaperSupport.vue'),
},
// Page 1 Subpages
{
path: 'page1/UniversalMode',
name: 'UniversalMode',
@ -48,17 +44,22 @@ const router = createRouter({
name: 'QuickMode',
component: () => import('../views/Page1/subpages/QuickMode.vue'),
},
// Page 2 Subpages (Dynamic)
{
path: 'page2/:subpage',
name: 'Page2Sub',
component: () => import('../views/Page2/subpages/SubpageContainer.vue'),
},
// Page 3 Subpages (Dynamic)
{
path: 'page3/:subpage',
name: 'Page3Sub',
component: () => import('../views/Page3/subpages/SubpageContainer.vue'),
},
// Page 5 Subpages (Dynamic)
{
path: 'page5/:subpage',
name: 'Page5Sub',
@ -66,19 +67,36 @@ const router = createRouter({
}
]
}
]
],
scrollBehavior(to, from, savedPosition) {
// 1. 如果存在历史记录位置(浏览器前进/后退),则恢复该位置
if (savedPosition) {
return savedPosition
}
// 2. 如果存在 hash 锚点,滚动到锚点
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
}
// 3. 默认情况:滚动到顶部
// 对于 MainFlow 这种特殊的滚动容器结构,这个 top: 0 主要影响 body/window 的滚动
// MainFlow 内部的滚动逻辑由 MainFlow.vue 中的 JS 控制
return { top: 0 }
}
})
// === 全局路由守卫 (门卫) ===
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('access_token')
// 1. 如果去的是 Login 或 Register 页面,且已经有 Token直接踢到首页
if ((to.name === 'Login' || to.name === 'Register') && token) {
// 1. 如果去的是 Login 页面,且已经有 Token直接踢到首页
if (to.name === 'Login' && token) {
return next({ path: '/' })
}
// 2. 如果去的是需要验证的页面(默认所有非Login/Register都需要),且没有 Token
// 2. 如果去的是需要验证的页面(默认所有非Login都需要),且没有 Token
// (to.matched.some... 检查父级路由是否有 requiresAuth)
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)

@ -75,7 +75,7 @@ export const useTaskStore = defineStore('task', {
// 只取前 10 条展示在侧边栏
return state.tasks.slice(0, 10).map(t => {
// 1. [核心修改] 智能获取任务名称
//获取任务名称
let name = `Task #${t.task_id}` // 默认值
// 优先级 A: 用户填写的 description (通用/快速/专题防护的用户输入都存在这里)
@ -99,14 +99,14 @@ export const useTaskStore = defineStore('task', {
let status = t.status
if (status === 'pending') status = 'waiting'
// 3. 进度条逻辑 (后端暂无百分比进行中由CSS动画处理)
// 3. 进度条逻辑
const progress = status === 'completed' ? 100 : (status === 'processing' ? 50 : 0)
return {
id: t.task_id,
status: status,
progress: progress,
name: name, // 这里现在是用户友好的名字了
name: name,
createdAt: t.created_at
}
})

@ -13,7 +13,7 @@ export const useUserStore = defineStore('user', {
role: (state) => state.userInfo?.role || 'normal',
initials: (state) => (state.userInfo?.username?.[0] || 'U').toUpperCase(),
// 【功能说明】判断当前用户是否拥有 VIP 权限 (Admin 或 VIP 角色返回 true)
//判断当前用户是否拥有 VIP 权限 (Admin 或 VIP 角色返回 true)
isVip: (state) => {
const role = state.userInfo?.role
return role === 'admin' || role === 'vip'

@ -31,7 +31,6 @@ export function isUltraWide(ratio) {
if (typeof ratio !== 'number' || !isFinite(ratio) || ratio <= 0) {
return false
}
// Ultra-wide is typically 21:9 (2.33) or wider
return ratio >= 2.0
}
@ -64,7 +63,6 @@ export function getLayoutRecommendations(ratio) {
}
if (isUltraWide(ratio)) {
// Ultra-wide: limit content width, add side margins
return {
maxContentWidth: '75vw',
sideMargins: '12.5vw',
@ -74,7 +72,6 @@ export function getLayoutRecommendations(ratio) {
}
if (isPortrait(ratio)) {
// Portrait: single column, full width
return {
maxContentWidth: '100%',
sideMargins: '3vw',
@ -83,9 +80,7 @@ export function getLayoutRecommendations(ratio) {
}
}
// Standard landscape ratios
if (ratio >= 1.7) {
// Wide (16:9 or wider)
return {
maxContentWidth: '90vw',
sideMargins: '5vw',
@ -94,7 +89,6 @@ export function getLayoutRecommendations(ratio) {
}
}
// Standard (4:3, 3:2, etc.)
return {
maxContentWidth: '95vw',
sideMargins: '2.5vw',

@ -1,6 +1,6 @@
/**
* src/utils/constants.js
* 前端常量定义 (严格对应后端 init_db.py 初始化数据)
* 前端常量定义
*/
// 1. 数据集类型 ID (对应 data_type_id)
@ -11,7 +11,6 @@ export const DATA_TYPE_MAP = {
}
// 2. 算法配置 ID (对应 perturbation_configs_id)
// 根据最新后端 API 文档更新
export const ALGO_MAP = {
ASPL: 1,
SIMAC: 2,
@ -21,12 +20,13 @@ export const ALGO_MAP = {
GLAZE: 6,
ANTI_CUSTOMIZE: 7, // 防定制生成 (Face)
ANTI_FACE_EDIT: 8, // 防人脸编辑 (Face)
STYLE_PROTECTION: 9 // 风格迁移防护 (Art)
STYLE_PROTECTION: 9, // 风格迁移防护 (Art)
QUICK: 10 // 快速防护算法 (Based on PID)
}
// 算法选项配置数据源,用于 Page 1 通用模式前端动态筛选
// Face: ASPL, SimAC, CAAT Pro
// Art: PID, Glaze, CAAT
// Art: PID, Glaze, CAAT, Quick
export const ALGO_OPTIONS_Data = [
{ id: ALGO_MAP.ASPL, method_name: 'ASPL', type: 'face' },
{ id: ALGO_MAP.SIMAC, method_name: 'SimAC', type: 'face' },
@ -35,7 +35,8 @@ export const ALGO_OPTIONS_Data = [
{ 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' },
//{ id: ALGO_MAP.STYLE_PROTECTION, method_name: 'Style Guard (专用)', type: 'art' }
// 新增快速算法选项
{ id: ALGO_MAP.QUICK, method_name: 'Quick Guard (Fast PID)', type: 'art' }
]
// 3. 微调配置 ID (对应 finetune_configs_id)
@ -47,7 +48,7 @@ export const FINETUNE_MAP = {
}
// 4. 专题防护固定配置 (Page 2 业务逻辑映射)
// 更新为后端推荐的专用算法 ID 及强度
// 后端推荐的专用算法 ID 及强度
export const TOPIC_CONFIG = {
// 防风格迁移 -> ID 9 (需要额外选择 target_style)
STYLE_TRANSFER: {

@ -16,10 +16,16 @@ const service = axios.create({
service.interceptors.request.use(
config => {
const userStore = useUserStore()
if (userStore.token) {
// 检查是否为认证接口(登录/注册)
// 如果是登录或注册,不要携带 Token防止旧 Token 失效导致后端直接返回 401
const isAuthRequest = config.url.includes('/auth/login') || config.url.includes('/auth/register')
if (userStore.token && !isAuthRequest) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
//如果是二进制流请求,处理特殊配置
// 如果是二进制流请求,处理特殊配置
if (config.returnRawResponse) {
config.responseType = 'arraybuffer'
}
@ -32,7 +38,7 @@ service.interceptors.request.use(
}
)
// 响应拦截器:增加重试逻辑
// === 响应拦截器:重试逻辑 ===
service.interceptors.response.use(undefined, (err) => {
const config = err.config;
@ -61,10 +67,10 @@ service.interceptors.response.use(undefined, (err) => {
return backoff.then(() => service(config));
});
// === 响应拦截器 ===
// === 响应拦截器:数据处理与错误拦截 ===
service.interceptors.response.use(
response => {
//支持返回完整响应(用于 Multipart 解析获取 Headers
// 支持返回完整响应(用于 Multipart 解析获取 Headers
if (response.config.returnRawResponse) {
return response
}
@ -72,6 +78,7 @@ service.interceptors.response.use(
},
error => {
let message = '网络连接异常'
// 判断是否是登录请求出错
const isLoginRequest = error.config && error.config.url.includes('/auth/login')
if (error.response) {
@ -99,6 +106,7 @@ service.interceptors.response.use(
message = '用户名或密码错误';
} else {
message = '登录已过期,请重新登录';
// 如果是非登录接口的 401执行登出逻辑
const userStore = useUserStore()
userStore.logout()
if (router.currentRoute.value.path !== '/login') {
@ -115,7 +123,7 @@ service.interceptors.response.use(
message = '请求超时,请检查网络';
}
// 【修改点】如果是登录接口报错,不再弹出 Toast交由 LoginView 页面内显示小字
// 如果是登录接口报错,不再弹出 Toast (交由 LoginView 页面内显示错误文字)
// 如果是非登录接口的 401 (Token 过期),通常伴随跳转,也不弹窗避免干扰
if (!isLoginRequest && error.response?.status !== 401) {
toast.error(message)

@ -10,14 +10,16 @@ let seed = 1
* @param {Object} options - { type, message, duration }
*/
const toast = (options) => {
// 创建容器
// 1. 创建一个容器 div
const container = document.createElement('div')
const id = `toast_${seed++}`
// 计算垂直偏移量 (堆叠效果)
let verticalOffset = 20
instances.forEach(({ vm }) => {
verticalOffset += (vm.el?.offsetHeight || 60) + 16
// 获取前一个实例的高度,如果未挂载完成可能取不到,给个默认值 60
const height = vm.el?.offsetHeight || 60
verticalOffset += height + 16
})
// 销毁回调
@ -28,15 +30,13 @@ const toast = (options) => {
// 移除当前实例
const { container } = instances[idx]
render(null, container) // 卸载 Vue 组件
// document.body.removeChild(container) // 移除 DOM (render null 有时会残留空 div手动移除更保险)
// 注意createVNode 渲染的组件在 leave 动画结束后会由组件内部逻辑处理,
// 但这里我们简单处理:组件 close 后触发 props.onClose
// 从 Body 移除容器 div
if (container.parentNode) {
container.parentNode.removeChild(container)
}
instances.splice(idx, 1) // 从数组移除
// 重新计算剩余 Toast 的位置 (向上移动填补空缺)
// 简化版:这里暂不实现动态上移补位动画,仅防止重叠
// 若要完美补位需要响应式 top这里为简单起见不做复杂布局重算
}
// 创建虚拟节点
@ -45,16 +45,25 @@ const toast = (options) => {
id,
offset: verticalOffset,
onClose: (closedId) => {
// 从 DOM 中彻底移除
render(null, container)
const idx = instances.findIndex(ins => ins.id === closedId)
if (idx !== -1) instances.splice(idx, 1)
// 这里的逻辑主要用于处理组件内部自动关闭的情况
// 实际上上面的 onClose 已经包含了清理逻辑,这里主要是触发源不同
// 我们统一调用上面的 onClose 逻辑即可,但需要找到对应的 id
const targetIdx = instances.findIndex(ins => ins.id === closedId)
if (targetIdx !== -1) {
// 手动移除 DOM
const { container } = instances[targetIdx]
render(null, container)
if (container.parentNode) container.parentNode.removeChild(container)
instances.splice(targetIdx, 1)
}
}
})
// 渲染并挂载
// 2. 将组件渲染到容器中
render(vm, container)
document.body.appendChild(container.firstElementChild) // 将组件根元素移入 Body
// 3. 将容器 div 挂载到 body
document.body.appendChild(container)
instances.push({ id, vm, container })
}

@ -1,219 +1,543 @@
<template>
<div class="kt-login-container">
<!-- 主题切换按钮 -->
<GridDistortion
imageSrc="/register_bg.png"
:gridX="8"
:gridY="5"
:strength="0.25"
:relaxation="0.9"
:mouse="0.1"
:isDark="isDark"
className="kt-fixed-bg"
/>
<button class="kt-theme-toggle" @click="handleToggleTheme" :title="isDark ? '切换到日间模式' : '切换到夜间模式'">
<i :class="isDark ? 'fas fa-sun' : 'fas fa-moon'"></i>
</button>
<div class="kt-login-card">
<transition name="kt-fade" mode="out-in">
<!-- 左侧品牌视觉区 -->
<div class="kt-brand-side">
<div class="kt-brand-content">
<div class="kt-logo-text">MUSE</div>
<h2 class="kt-slogan">守护您的艺术<br>保护您的权益</h2>
<p class="kt-desc">下一代图像隐私保护系统抵御 AI 风格迁移与恶意编辑</p>
<!-- 1. 登录模式 -->
<div v-if="flowMode === 'login'" key="login" class="kt-login-card">
<div class="kt-brand-side">
<div class="kt-brand-content">
<div class="kt-logo-text">MUSE GUARD</div>
<h2 class="kt-slogan">守护您的艺术<br>保护您的权益</h2>
<p class="kt-desc">下一代 AI 图像隐私保护系统</p>
</div>
</div>
<div class="kt-form-side">
<div class="kt-form-header">
<h1>欢迎回来</h1>
<p>请登录您的账户</p>
</div>
<div class="kt-form-group">
<div class="kt-user-box">
<input
class="kt-input"
type="text"
name="username"
required=""
v-model="form.username"
@keyup.enter="handleLogin"
@input="clearError"
placeholder=" "
>
<label class="kt-label">用户名</label>
<i class="fas fa-user kt-input-icon"></i>
</div>
<div class="kt-user-box">
<input
class="kt-input"
:type="showPassword ? 'text' : 'password'"
name="password"
required=""
v-model="form.password"
@keyup.enter="handleLogin"
@input="clearError"
placeholder=" "
>
<label class="kt-label">密码</label>
<i class="fas fa-lock kt-input-icon"></i>
<i
class="fas kt-toggle-password"
:class="showPassword ? 'fa-eye-slash' : 'fa-eye'"
@click="showPassword = !showPassword"
title="显示/隐藏密码"
></i>
</div>
</div>
<div v-if="errorMessage" class="kt-error-tip">
<i class="fas fa-exclamation-circle"></i> {{ errorMessage }}
</div>
<!-- 忘记密码链接 -->
<div class="kt-forgot-link">
<a @click.prevent="flowMode = 'forgot'" href="#">忘记密码</a>
</div>
<button
class="kt-btn kt-btn--primary kt-full-width"
@click="handleLogin"
:disabled="loading"
>
{{ loading ? '登录中...' : '登录' }}
</button>
<div class="kt-footer-link">
<span>还没有账户</span>
<a @click.prevent="flowMode = 'register'" href="#">立即注册</a>
</div>
</div>
<div class="kt-circle-deco"></div>
<div class="kt-deco-tape"></div>
</div>
<!-- 右侧表单区 -->
<div class="kt-form-side">
<div class="kt-form-header">
<h1>欢迎回来</h1>
<p>请登录您的账户</p>
</div>
<!-- 2. 注册模式 -->
<div v-else-if="flowMode === 'register'" key="register" class="kt-login-card kt-register-card">
<div class="kt-form-group">
<!-- 用户名输入 -->
<div class="kt-user-box">
<input
class="kt-input"
type="text"
name="username"
required=""
v-model="form.username"
@keyup.enter="handleLogin"
@input="clearError"
placeholder=" "
>
<label class="kt-label">用户名</label>
<i class="fas fa-user kt-input-icon"></i>
<div class="kt-brand-side kt-register-brand">
<div class="kt-brand-content">
<div class="kt-logo-text">MUSE GUARD</div>
<h2 class="kt-slogan">加入我们<br>开启防护之旅</h2>
<p class="kt-desc">注册成为 MuseGuard 会员开启您的 AI 隐私防护之旅</p>
</div>
</div>
<!-- 密码输入 -->
<div class="kt-user-box">
<input
class="kt-input"
:type="showPassword ? 'text' : 'password'"
name="password"
required=""
v-model="form.password"
@keyup.enter="handleLogin"
@input="clearError"
placeholder=" "
>
<label class="kt-label">密码</label>
<i class="fas fa-lock kt-input-icon"></i>
<div class="kt-form-side kt-register-form">
<div class="kt-form-header">
<h1>创建账户</h1>
<p>加入 MuseGuard 隐私保护系统</p>
</div>
<div class="kt-form-group">
<!-- 眼睛图标按钮 -->
<i
class="fas kt-toggle-password"
:class="showPassword ? 'fa-eye-slash' : 'fa-eye'"
@click="showPassword = !showPassword"
title="显示/隐藏密码"
></i>
<div class="kt-user-box">
<input
class="kt-input"
type="text"
name="username"
required
v-model="form.username"
placeholder=" "
/>
<label class="kt-label">用户名</label>
<i class="fas fa-user kt-input-icon"></i>
</div>
<div class="kt-user-box">
<input
class="kt-input"
type="email"
name="email"
required
v-model="form.email"
placeholder=" "
/>
<label class="kt-label">邮箱地址</label>
<i class="fas fa-envelope kt-input-icon"></i>
</div>
<div class="kt-code-group">
<div class="kt-user-box kt-code-input-box">
<input
class="kt-input"
type="text"
name="code"
required
v-model="form.code"
maxlength="6"
placeholder=" "
/>
<label class="kt-label">验证码</label>
<i class="fas fa-shield-alt kt-input-icon"></i>
</div>
<button
class="kt-btn kt-btn--secondary kt-code-btn"
:disabled="isSending || countdown > 0"
@click="handleSendCode('register')"
>
{{ codeBtnText }}
</button>
</div>
<div class="kt-user-box">
<input
class="kt-input"
type="password"
name="password"
required
v-model="form.password"
placeholder=" "
/>
<label class="kt-label">密码</label>
<i class="fas fa-lock kt-input-icon"></i>
</div>
<div class="kt-user-box">
<input
class="kt-input"
type="text"
name="vip_code"
v-model="form.vip_code"
placeholder=" "
/>
<label class="kt-label">VIP 邀请码 (选填)</label>
<i class="fas fa-crown kt-input-icon" style="color: var(--kt-accent);"></i>
</div>
</div>
<button
class="kt-btn kt-btn--primary kt-full-width"
@click="handleRegister"
:disabled="loading"
>
{{ loading ? '注册中...' : '注册' }}
</button>
<div class="kt-footer-link">
<span>已有账户</span>
<a @click.prevent="flowMode = 'login'" href="#">立即登录</a>
</div>
</div>
<!-- 登录失败提示信息 (小字 + 震动动画) -->
<div v-if="errorMessage" class="kt-error-tip">
<i class="fas fa-exclamation-circle"></i> {{ errorMessage }}
</div>
<!-- 3. 忘记密码模式 -->
<div v-else key="forgot" class="kt-login-card kt-register-card">
<div class="kt-brand-side kt-register-brand">
<div class="kt-brand-content">
<div class="kt-logo-text">MUSE</div>
<h2 class="kt-slogan">重置密码<br>找回您的账户</h2>
<p class="kt-desc">通过邮箱验证码您可以安全地重置您的账户密码</p>
</div>
</div>
<button
class="kt-btn kt-btn--primary kt-full-width"
@click="handleLogin"
:disabled="loading"
>
{{ loading ? '登录中...' : '登录' }}
</button>
<div class="kt-footer-link">
<span>还没有账户</span>
<a @click.prevent="goToRegister" href="#">立即注册</a>
<div class="kt-form-side kt-register-form">
<div class="kt-form-header">
<h1>重置密码</h1>
<p>输入邮箱获取验证码</p>
</div>
<div class="kt-form-group">
<div class="kt-user-box">
<input
class="kt-input"
type="email"
name="email"
required
v-model="forgotForm.email"
placeholder=" "
/>
<label class="kt-label">邮箱地址</label>
<i class="fas fa-envelope kt-input-icon"></i>
</div>
<div class="kt-code-group">
<div class="kt-user-box kt-code-input-box">
<input
class="kt-input"
type="text"
name="code"
required
v-model="forgotForm.code"
maxlength="6"
placeholder=" "
/>
<label class="kt-label">验证码</label>
<i class="fas fa-shield-alt kt-input-icon"></i>
</div>
<button
class="kt-btn kt-btn--secondary kt-code-btn"
:disabled="isSending || countdown > 0"
@click="handleSendCode('forgot_password')"
>
{{ codeBtnText }}
</button>
</div>
<div class="kt-user-box">
<input
class="kt-input"
type="password"
name="new_password"
required
v-model="forgotForm.new_password"
placeholder=" "
/>
<label class="kt-label">新密码</label>
<i class="fas fa-lock kt-input-icon"></i>
</div>
<div class="kt-user-box">
<input
class="kt-input"
type="password"
name="confirm_password"
required
v-model="forgotForm.confirm_password"
placeholder=" "
/>
<label class="kt-label">确认新密码</label>
<i class="fas fa-lock kt-input-icon"></i>
</div>
</div>
<button
class="kt-btn kt-btn--primary kt-full-width"
@click="handleForgotPassword"
:disabled="loading"
>
{{ loading ? '重置中...' : '重置密码' }}
</button>
<div class="kt-footer-link">
<span>想起来了</span>
<a @click.prevent="flowMode = 'login'" href="#">返回登录</a>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { authLogin } from '@/api/auth'
import { authLogin, authRegister, sendAuthCode, authForgotPassword } from '@/api/auth'
import { useUserStore } from '@/stores/userStore'
import { toggleTheme, loadThemePreference, applyTheme } from '@/utils/theme'
import modal from '@/utils/modal'
import GridDistortion from '@/components/GridDistortion.vue'
const router = useRouter()
const userStore = useUserStore()
// login, register, forgot
const flowMode = ref('login')
const loading = ref(false)
const showPassword = ref(false)
const isDark = ref(true)
const errorMessage = ref('')
// /
const isSending = ref(false)
const countdown = ref(0)
let timer = null
// /
const form = ref({
username: '',
password: ''
password: '',
email: '',
code: '',
vip_code: ''
})
//
const forgotForm = ref({
email: '',
code: '',
new_password: '',
confirm_password: ''
})
//
onMounted(() => {
const savedTheme = loadThemePreference()
applyTheme(savedTheme)
isDark.value = savedTheme === 'dark'
})
//
onUnmounted(() => {
if (timer) clearInterval(timer)
})
const handleToggleTheme = () => {
const newTheme = toggleTheme()
isDark.value = newTheme === 'dark'
}
const goToRegister = () => {
router.push('/register')
}
//
const clearError = () => {
errorMessage.value = ''
}
// ============================================
// 🔧 DEV BACKDOOR -
// 线
// ============================================
const DEV_BACKDOOR = {
enabled: true,
username: 'admin',
password: '2025Aa'
}
const handleLogin = async () => {
errorMessage.value = '' //
errorMessage.value = ''
if (!form.value.username || !form.value.password) {
errorMessage.value = '请输入用户名和密码'
return
}
// 🔧 DEV BACKDOOR CHECK -
if (DEV_BACKDOOR.enabled &&
form.value.username === DEV_BACKDOOR.username &&
form.value.password === DEV_BACKDOOR.password) {
console.warn('⚠️ DEV BACKDOOR USED - 请上线前移除!')
userStore.setLoginData({
access_token: 'dev_backdoor_token_' + Date.now(),
user: { username: 'admin', role: 'admin' }
})
router.push('/')
return
}
// ============================================
loading.value = true
try {
const res = await authLogin(form.value)
const res = await authLogin({
username: form.value.username,
password: form.value.password
})
if (res.access_token) {
userStore.setLoginData(res)
localStorage.setItem('kt_nav_expanded', 'true') //
router.push('/')
}
} catch (error) {
console.error(error)
// request.js
errorMessage.value = error.message || '登录失败,请检查账号密码'
} finally {
loading.value = false
}
}
// === ===
const codeBtnText = computed(() => {
if (isSending.value) return '发送中...'
if (countdown.value > 0) return `${countdown.value}`
return '获取验证码'
})
const validateEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
//
const handleSendCode = async (purpose) => {
let email = ''
if (purpose === 'register') {
email = form.value.email
} else if (purpose === 'forgot_password') {
email = forgotForm.value.email
} else {
return
}
if (!email) return modal.warning('请先填写邮箱地址')
if (!validateEmail(email)) return modal.warning('邮箱格式不正确')
isSending.value = true
try {
// API
await sendAuthCode({ email, purpose })
modal.success(`验证码已发送至 ${email}`)
//
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
} catch (error) {
console.error(error)
modal.error(error.message || '发送失败')
} finally {
isSending.value = false
}
}
const handleRegister = async () => {
if (!form.value.username || !form.value.password || !form.value.email || !form.value.code) {
return modal.warning('请填写完整注册信息(含验证码)')
}
loading.value = true
try {
const payload = { ...form.value }
if (!payload.vip_code) delete payload.vip_code
const res = await authRegister(payload)
if (res.user || res.message) {
await modal.success('注册成功,请登录')
flowMode.value = 'login'
}
} catch (error) {
console.error(error)
modal.error(error.message || '注册失败')
} finally {
loading.value = false
}
}
// === ===
const handleForgotPassword = async () => {
const f = forgotForm.value
if (!f.email || !f.code || !f.new_password || !f.confirm_password) {
return modal.warning('请填写所有必填项')
}
if (f.new_password !== f.confirm_password) {
return modal.warning('两次输入的新密码不一致')
}
loading.value = true
try {
//
const res = await authForgotPassword({
email: f.email,
code: f.code,
new_password: f.new_password
})
if (res.message) {
await modal.success(res.message || '密码重置成功')
flowMode.value = 'login'
//
forgotForm.value = { email: '', code: '', new_password: '', confirm_password: '' }
}
} catch (error) {
console.error(error)
modal.error(error.message || '重置失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ===== Login Container (修复滚动问题) ===== */
/* ===== Login Container (Scroll Fix) ===== */
.kt-login-container {
position: relative;
/* 关键修改height: 100% 确保它在 App.vue 的容器内占满,从而产生滚动条 */
/* 允许内容撑开高度,产生滚动 */
height: 100%;
width: 100%;
/* Flex 布局配合 margin: auto 实现安全居中 */
display: flex;
flex-direction: column;
background-color: var(--kt-bg);
/* 允许 Y 轴滚动 */
/* 背景透明,透出下方的 GridDistortion */
background-color: transparent;
overflow-y: auto;
/* 添加上下内边距,防止贴边 */
padding: 40px 20px;
/* 启用容器查询 */
container-type: size;
container-name: login-root;
/* 隐藏默认滚动条但保持可滚动 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
/* Hide scrollbar */
scrollbar-width: none;
-ms-overflow-style: none;
}
/* Chrome/Safari 隐藏滚动条 */
.kt-login-container::-webkit-scrollbar {
display: none;
}
/* 强制背景组件固定在最底层 */
.kt-fixed-bg {
position: fixed !important;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
}
/* ===== Theme Toggle Button ===== */
.kt-theme-toggle {
position: fixed;
@ -240,23 +564,30 @@ const handleLogin = async () => {
border-color: var(--kt-accent);
}
/* ===== Login Card (核心布局修复) ===== */
/* ===== Login Card (Strictly preserved) ===== */
.kt-login-card {
/* 关键修改margin: auto 在 Flex 容器中实现垂直水平居中,且溢出时自动顶部对齐 */
/* Margin auto 实现 Flex 容器内的智能居中 */
margin: auto;
width: 100%;
max-width: 900px;
min-height: 550px; /* 合理的最小高度 */
min-height: 550px;
display: flex;
flex-direction: row;
overflow: hidden;
/* 卡片本身必须不透明 */
background: var(--kt-bg);
border: 3px solid var(--kt-border);
border-radius: var(--kt-radius);
box-shadow: 0 20px 50px rgba(0,0,0,0.1);
/* 增加阴影,从背景中浮起 */
box-shadow: 0 40px 80px rgba(0,0,0,0.5);
z-index: 10;
flex-shrink: 0;
transition: 0.2s ease-in-out;
}
.kt-login-card:hover {
transform: translateY(-2px);
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.3);
}
/* ===== Brand Side (Left) ===== */
@ -271,6 +602,14 @@ const handleLogin = async () => {
position: relative;
overflow: hidden;
min-width: 300px;
border-right: 1px solid var(--kt-border);
}
/* 注册卡片特定比例保持 */
.kt-register-brand {
flex: 4;
padding: 3rem 2rem;
min-height: 500px;
}
.kt-brand-content {
@ -304,29 +643,6 @@ const handleLogin = async () => {
line-height: 1.6;
}
.kt-circle-deco {
position: absolute;
width: 200px;
height: 200px;
border: 20px dashed rgba(255, 255, 255, 0.1);
border-radius: 50%;
bottom: -50px;
right: -50px;
z-index: 1;
}
.kt-deco-tape {
position: absolute;
top: 30px;
right: 20px;
width: 80px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
transform: rotate(45deg);
border-radius: 2px;
z-index: 3;
}
/* ===== Form Side (Right) ===== */
.kt-form-side {
flex: 1.2;
@ -337,10 +653,20 @@ const handleLogin = async () => {
background: var(--kt-bg);
}
/* 注册/忘记密码表单特定比例与内边距保持 */
.kt-register-card .kt-form-side {
flex: 5;
padding: 3rem 2.5rem;
}
.kt-form-header {
margin-bottom: 2.5rem;
}
.kt-register-card .kt-form-header {
margin-bottom: 2rem;
}
.kt-form-header h1 {
font-family: var(--kt-font);
font-size: 2rem;
@ -362,6 +688,10 @@ const handleLogin = async () => {
gap: 1.5rem;
}
.kt-register-card .kt-form-group {
gap: 1.25rem;
}
/* ===== Input Box ===== */
.kt-user-box {
position: relative;
@ -381,6 +711,12 @@ const handleLogin = async () => {
outline: none;
transition: all var(--kt-transition-micro);
box-sizing: border-box;
text-transform: none;
}
.kt-register-card .kt-input {
height: 48px;
padding: 0 12px 0 40px;
}
.kt-input::placeholder {
@ -403,6 +739,10 @@ const handleLogin = async () => {
pointer-events: none;
}
.kt-register-card .kt-input-icon {
left: 12px;
}
.kt-input:focus ~ .kt-input-icon {
color: var(--kt-accent);
}
@ -421,6 +761,10 @@ const handleLogin = async () => {
padding: 0 4px;
}
.kt-register-card .kt-label {
left: 40px;
}
.kt-input:focus ~ .kt-label,
.kt-input:not(:placeholder-shown) ~ .kt-label {
top: -10px;
@ -432,6 +776,11 @@ const handleLogin = async () => {
background: var(--kt-bg);
}
.kt-register-card .kt-input:focus ~ .kt-label,
.kt-register-card .kt-input:not(:placeholder-shown) ~ .kt-label {
left: 10px;
}
.kt-toggle-password {
position: absolute;
top: 50%;
@ -444,8 +793,17 @@ const handleLogin = async () => {
z-index: 2;
}
.kt-toggle-password:hover {
color: var(--kt-accent);
/* ===== Code Group Layout ===== */
.kt-code-group {
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
}
.kt-code-input-box {
flex: 1;
min-width: 0;
}
/* ===== Button ===== */
@ -464,6 +822,20 @@ const handleLogin = async () => {
justify-content: center;
}
.kt-register-card .kt-btn {
height: 48px;
font-size: 1rem;
}
.kt-code-btn {
width: auto;
min-width: 110px;
white-space: nowrap;
flex-shrink: 0;
font-size: 0.9rem;
padding: 0 1rem;
}
.kt-btn--primary {
background: var(--kt-bg);
color: var(--kt-fg);
@ -477,18 +849,27 @@ const handleLogin = async () => {
transform: translateY(-2px);
}
.kt-btn--primary:active {
transform: translateY(0);
.kt-btn--secondary {
background: var(--kt-fg);
color: var(--kt-bg);
}
.kt-btn--primary:disabled {
background: var(--kt-muted);
color: var(--kt-muted-fg);
cursor: not-allowed;
transform: none;
.kt-full-width {
width: 100%;
}
/* ===== Error Tip ===== */
/* ===== Transitions ===== */
.kt-fade-enter-active,
.kt-fade-leave-active {
transition: opacity 0.15s ease;
}
.kt-fade-enter-from,
.kt-fade-leave-to {
opacity: 0;
}
/* ===== Error Tip & Footer ===== */
.kt-error-tip {
color: #ef4444;
font-family: var(--kt-font);
@ -507,7 +888,24 @@ const handleLogin = async () => {
75% { transform: translateX(5px); }
}
/* ===== Footer Link ===== */
/* 新增:忘记密码链接样式 */
.kt-forgot-link {
text-align: right;
margin-top: -1rem; /* 调整位置,使其更靠近输入框 */
margin-bottom: 1rem;
}
.kt-forgot-link a {
font-family: var(--kt-font);
font-size: 0.95rem;
color: var(--kt-muted-fg);
text-decoration: none;
}
.kt-forgot-link a:hover {
color: var(--kt-accent);
}
.kt-footer-link {
margin-top: 2rem;
text-align: center;
@ -516,12 +914,16 @@ const handleLogin = async () => {
color: var(--kt-muted-fg);
}
.kt-register-card .kt-footer-link {
margin-top: 1.5rem;
font-size: 0.9rem;
}
.kt-footer-link a {
color: var(--kt-accent);
font-weight: 700;
text-decoration: none;
margin-left: 0.5rem;
transition: color var(--kt-transition-micro);
}
.kt-footer-link a:hover {
@ -542,38 +944,25 @@ const handleLogin = async () => {
padding: 2.5rem 2rem;
flex: 0 0 auto;
text-align: center;
border-right: none;
border-bottom: 3px solid var(--kt-border);
border-radius: var(--kt-radius) var(--kt-radius) 0 0;
}
.kt-logo-text {
margin-bottom: 1rem;
font-size: 2rem;
}
.kt-slogan {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.kt-desc,
.kt-circle-deco,
.kt-deco-tape {
display: none;
}
.kt-logo-text { margin-bottom: 1rem; font-size: 2rem; }
.kt-slogan { font-size: 1.5rem; margin-bottom: 1rem; }
.kt-desc { display: none; }
.kt-form-side {
padding: 2.5rem 2rem;
border-radius: 0 0 var(--kt-radius) var(--kt-radius);
}
.kt-theme-toggle {
width: 44px;
height: 44px;
font-size: 18px;
.kt-forgot-link {
text-align: center;
}
}
/* 针对小屏幕设备的 Media Query 兜底 */
@media (max-width: 900px) {
.kt-login-card {
width: 95%;
@ -586,10 +975,16 @@ const handleLogin = async () => {
padding: 2rem;
min-height: auto;
text-align: center;
border-right: none;
border-bottom: 3px solid var(--kt-border);
}
.kt-form-side {
padding: 2rem 1.5rem;
}
.kt-forgot-link {
text-align: center;
}
}
</style>

@ -1,14 +1,5 @@
<script setup>
/**
* MainFlow.vue - Kinetic Typography Main Layout
*
* Requirements: 2.1, 2.2, 2.3, 2.5
* - 2.1: max-width 95vw layout
* - 2.2: Noise texture overlay with opacity 0.03
* - 2.3: Preserve waterfall page transition animation
* - 2.5: CSS Grid for responsive layouts
*/
import { ref, provide, onMounted, onUnmounted, computed, watch, nextTick, reactive } from 'vue'
import { inject, ref, provide, onMounted, onUnmounted, computed, watch, nextTick, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import NavBar from '../components/NavBar.vue'
import NoiseOverlay from '../components/NoiseOverlay.vue'
@ -96,8 +87,14 @@ const handleNavigate = (id) => {
}
}
provide('navigateToSection', handleNavigate)
const handleNavToggle = (expanded) => { isNavExpanded.value = expanded }
// Page5 handleNavigate
const navigateToSection = (id) => { handleNavigate(id) }
provide('navigateToSection', navigateToSection)
const handleLogout = async () => {
const confirmed = await modal.confirm('确定要退出登录吗?')
if (confirmed) {
@ -269,7 +266,24 @@ const performPageSwitch = (direction) => {
}
}
const checkRoute = () => { showSubpage.value = !!route.params.subpage }
const checkRoute = () => {
showSubpage.value = route.path !== '/'
const path = route.path
if (path.includes('/page1')) {
currentSection.value = 'page1'
} else if (path.includes('/page2')) {
currentSection.value = 'page2'
} else if (path.includes('/page3')) {
currentSection.value = 'page3'
} else if (path.includes('/page4')) {
currentSection.value = 'page4'
} else if (path.includes('/page5')) {
currentSection.value = 'page5'
} else if (path.includes('/home')) {
currentSection.value = 'home'
}
}
onMounted(() => {
checkRoute()
@ -278,6 +292,16 @@ onMounted(() => {
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('touchend', handleTouchEnd, { passive: true })
taskStore.startPolling()
// === ===
const savedNavState = localStorage.getItem('kt_nav_expanded')
// localStorage 'true'
// MainFlow.vue isNavExpanded falseNavBar isExpanded false
if (savedNavState === 'true') {
// DOM
setTimeout(() => {
handleNavToggle(true)
}, 200) // 2s00ms
}
})
onUnmounted(() => {
@ -650,23 +674,23 @@ onUnmounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
gap: 0.25rem;
/* 宽度保持自适应内容 */
width: calc(var(--cq-navbar-width-collapsed, 8cqw) - 1cqw);
height: auto;
padding: 1rem 0;
min-width: 44px;
min-height: 44px;
padding: 0.5rem;
font-family: var(--kt-font);
font-weight: 700;
font-size: var(--cq-font-xs, 0.625rem);
text-transform: uppercase;
letter-spacing: 0.02em;
background: var(--kt-bg);
color: var(--kt-fg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
cursor: pointer;
transition: transform var(--kt-transition-micro), background-color var(--kt-transition-micro), border-color var(--kt-transition-micro), color var(--kt-transition-micro);
will-change: transform;
transition: all var(--kt-transition-micro);
}
.kt-corner-btn:hover {
@ -686,17 +710,17 @@ onUnmounted(() => {
}
.kt-corner-btn i {
font-size: 1.25rem;
font-size: clamp(1.2rem, 2cqw, 1.8rem);
margin-bottom: 0;
}
.kt-corner-btn__text {
font-size: var(--cq-font-xs, 0.625rem);
font-size: clamp(0.65rem, 0.7cqw, 0.85rem);
white-space: nowrap;
font-weight: 700;
}
/*
=== 核心修复移动端按钮显示逻辑 ===
- 默认隐藏整个区域即隐藏登出按钮
- 当具有 .is-subpage 类时显示区域并覆盖在顶部导航栏之上
*/
@media (max-width: 900px) {

@ -8,13 +8,10 @@
* - Feature tags: 多算法支持强度可调参数配置
* - Quick mode card
* - Bottom marquee with algorithm stats
*
* Requirements: 4.7, 4.8, 5.1, 5.2, 5.3, 5.6
*/
import { inject } from 'vue'
import KtMarquee from '@/components/KtMarquee.vue'
// Inject subpage navigation from parent
const openSubpage = inject('openSubpage')
const OpenUniversal = () => openSubpage('page1', 'UniversalMode')
const OpenQuick = () => openSubpage('page1', 'QuickMode')
@ -22,15 +19,11 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
<template>
<div class="kt-page kt-page1">
<!-- Header Section: Bilingual Title -->
<!-- Requirements: 4.7, 4.8 -->
<header class="kt-header">
<h1 class="kt-header__title-cn">通用防护</h1>
<p class="kt-header__title-en">GENERAL PROTECTION</p>
</header>
<!-- Main Feature Card: Universal Mode -->
<!-- Requirements: 5.1, 5.2, 5.3, 5.6 -->
<section class="kt-main-feature">
<article
class="kt-card kt-card--hero kt-card--universal"
@ -71,8 +64,8 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
@keydown.space.prevent="OpenQuick"
>
<div class="kt-card__hero-text kt-card__hero-text--small">
<span class="kt-card__hero-line">QUICK</span>
<span class="kt-card__hero-line">MODE</span>
<span class="kt-card__hero-line">快速</span>
<span class="kt-card__hero-line">模式</span>
</div>
<div class="kt-card__content">
<p class="kt-card__desc">快速防护系统自动推荐配置一键上传</p>
@ -81,7 +74,6 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
</article>
</section>
<!-- Algorithm Stats Marquee -->
<KtMarquee speed="normal">
<span class="kt-stat">
<span class="kt-stat__number">6+</span>
@ -104,8 +96,6 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
</template>
<style scoped>
/* ===== Page1 Kinetic Typography Styles ===== */
.kt-page1 {
min-height: 100%;
display: flex;
@ -114,8 +104,7 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
overflow-x: hidden;
}
/* ===== Header Section ===== */
/* Requirements: 4.7, 4.8 - Bilingual title with scale hierarchy */
.kt-header {
padding: 4rem var(--kt-container-px) 2rem;
}
@ -141,13 +130,11 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
margin: 0;
}
/* ===== Main Feature Section ===== */
.kt-main-feature {
padding: 0 var(--kt-container-px) 2rem;
flex: 1;
}
/* ===== Universal Mode Card ===== */
.kt-card--universal {
flex-direction: column;
align-items: flex-start;
@ -187,7 +174,6 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
transition: color var(--kt-transition-normal);
}
/* ===== Feature Tags ===== */
.kt-tags {
display: flex;
flex-wrap: wrap;
@ -210,13 +196,11 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
transition: all var(--kt-transition-normal);
}
/* ===== Button in Card ===== */
.kt-card--universal .kt-btn {
align-self: flex-start;
margin-top: 1rem;
}
/* ===== Card Hover States ===== */
.kt-card--universal:hover .kt-card__hero-line,
.kt-card--universal:hover .kt-card__desc,
.kt-card--universal:hover .kt-tag,
@ -230,12 +214,14 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
color: var(--kt-accent) !important;
}
/* ===== Secondary Features Section ===== */
.kt-card--universal:hover .kt-btn * {
color: var(--kt-accent) !important;
}
.kt-secondary-features {
padding: 0 var(--kt-container-px) 2rem;
}
/* ===== Quick Mode Card ===== */
.kt-card--quick {
display: flex;
align-items: center;
@ -245,7 +231,7 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
.kt-card__hero-text--small .kt-card__hero-line {
font-size: clamp(1.5rem, 5vw, 3rem);
line-height: 0.9;
line-height: 1.25;
}
.kt-card--quick .kt-card__content {
@ -267,14 +253,12 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
flex-shrink: 0;
}
/* Quick Mode Card Hover */
.kt-card--quick:hover .kt-card__hero-line,
.kt-card--quick:hover .kt-card__desc,
.kt-card--quick:hover .kt-card__arrow {
color: var(--kt-accent-fg) !important;
}
/* ===== Stats in Marquee ===== */
.kt-stat {
display: flex;
align-items: baseline;
@ -296,7 +280,6 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
letter-spacing: 0.1em;
}
/* ===== Base Card Styles (inherited from global) ===== */
.kt-card {
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
@ -320,7 +303,6 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
outline-offset: 2px;
}
/* ===== Responsive Design ===== */
@media (max-width: 768px) {
.kt-header {
padding: 2rem var(--kt-container-px) 1rem;
@ -371,6 +353,7 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
.kt-card__hero-text--small .kt-card__hero-line {
font-size: clamp(1.25rem, 8vw, 2rem);
line-height: 1.25;
}
.kt-card__arrow {
@ -378,7 +361,6 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
}
}
/* ===== Reduced Motion ===== */
@media (prefers-reduced-motion: reduce) {
.kt-card {
transition: none;

@ -16,7 +16,7 @@
<div class="kt-subpage__card-body">
<div class="kt-subpage__desc">
<p class="kt-body-text">使用系统推荐的参数配置速度比通用模式更快兼顾基本防护效果</p>
<p class="kt-body-text">使用系统推荐的参数配置基于快速 PID 算法速度比通用模式更快兼顾基本防护效果</p>
</div>
<div class="kt-subpage__form">
@ -83,7 +83,9 @@
<div class="kt-subpage__upload-content">
<i class="fas fa-cloud-upload-alt kt-subpage__upload-icon" :class="{ 'success': formData.files.length > 0 }"></i>
<div class="kt-subpage__upload-text" v-if="formData.files.length === 0"> ()</div>
<div class="kt-subpage__upload-text" v-if="formData.files.length === 0"> ()
<div class="kt-upload-hint">仅支持 PNG, JPG 格式</div>
</div>
<div class="kt-subpage__upload-text" v-else>
已选择 <span style="color: var(--kt-accent);">{{ formData.files.length }}</span> 张图片
<p class="kt-small-text" v-if="formData.files.length > 0">
@ -114,12 +116,12 @@ import { ref, onUnmounted } from 'vue'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { submitPerturbationTask, getTaskStatus } from '@/api/task'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore' // UserStore
import { useUserStore } from '@/stores/userStore'
import { ALGO_MAP, DATA_TYPE_MAP } from '@/utils/constants'
import modal from '@/utils/modal'
const taskStore = useTaskStore()
const userStore = useUserStore() // 使 UserStore
const userStore = useUserStore()
const fileInput = ref(null)
const isSubmitting = ref(false)
let specificPollTimer = null
@ -131,7 +133,6 @@ const formData = ref({
files: []
})
// VIP Art
const handleStyleChange = (style) => {
if (style === 'art' && !userStore.isVip) {
modal.warning('艺术风格防护仅限 VIP 用户使用,请前往个人中心升级。')
@ -163,7 +164,6 @@ const submitTask = async () => {
if (!formData.value.taskName) return modal.warning('请填写任务名称')
if (taskStore.quota.remaining_tasks <= 0) return modal.warning('剩余任务配额不足')
// VIP
if (formData.value.style === 'art' && !userStore.isVip) {
return modal.warning('权限不足:艺术风格仅限 VIP 使用')
}
@ -172,14 +172,17 @@ const submitTask = async () => {
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
const intensity = 10.0
// 使 QUICK (ID 10)
const algoId = ALGO_MAP.QUICK || 10
// QUICK PID使
const intensity = 0.05
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-10.0')
payload.append('perturbation_name', 'Quick-PID-0.05')
formData.value.files.forEach(file => {
payload.append('files', file)
@ -248,4 +251,13 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
font-size: 0.9em;
color: var(--kt-muted-fg);
}
.kt-upload-hint {
display: block;
font-size: 0.85em;
color: var(--kt-muted-fg);
font-weight: 400;
margin-top: 0.25rem;
letter-spacing: 0;
}
</style>

@ -32,7 +32,7 @@
<div class="kt-subpage__style-option" :class="{ active: formData.style === 'face' }" @click="setDataType('face')">
<div class="kt-subpage__style-icon"><i class="far fa-smile"></i></div>
<div class="kt-subpage__style-content">
<span class="kt-subpage__style-title">通用人脸防护</span>
<span class="kt-subpage__style-title">针对人脸防护</span>
<span class="kt-subpage__style-desc">ASPL / SimAC / CAAT-Pro</span>
</div>
<div class="kt-subpage__check-mark" v-if="formData.style === 'face'"><i class="fas fa-check"></i></div>
@ -53,39 +53,39 @@
通用艺术品防护
<i v-if="!userStore.isVip" class="fas fa-lock kt-lock-icon"></i>
</span>
<span class="kt-subpage__style-desc">PID / Glaze / CAAT</span>
<span class="kt-subpage__style-desc">PID / Glaze / CAAT / Quick</span>
</div>
<span class="kt-subpage__badge">VIP</span>
</div>
</div>
</div>
<!-- 算法选择 (保持不变) -->
<!-- 算法选择 (左右两栏) -->
<div class="kt-subpage__row">
<!-- 左侧防护算法 -->
<div class="kt-subpage__form-group">
<label class="kt-subpage__label">第二步防护算法</label>
<div v-if="isDropdownOpen" class="kt-dropdown-overlay" @click="isDropdownOpen = false"></div>
<div class="kt-select-container">
<div class="kt-select-trigger" :class="{ 'is-open': isDropdownOpen }" @click="isDropdownOpen = !isDropdownOpen">
<span :class="{ 'placeholder': !formData.algorithm }">{{ currentAlgoName }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div v-if="isDropdownOpen" class="kt-select-options">
<div v-for="algo in currentAvailableAlgorithms" :key="algo.id" class="kt-select-option" :class="{ selected: formData.algorithm === algo.id }" @click="selectAlgo(algo)">
<span>{{ algo.method_name }}</span>
<i v-if="formData.algorithm === algo.id" class="fas fa-check"></i>
</div>
</div>
<div class="kt-form-header">
<label class="kt-subpage__label" style="margin-bottom: 0;">第二步防护算法</label>
</div>
<KtSelect
v-model="formData.algorithm"
:options="algoOptions"
placeholder="请选择防护算法"
/>
</div>
<!-- 右侧扰动强度 -->
<div class="kt-subpage__form-group">
<div class="kt-strength-header">
<label class="kt-subpage__label">扰动强度</label>
<div class="kt-form-header">
<label class="kt-subpage__label" style="margin-bottom: 0;">扰动强度</label>
<div class="kt-mode-toggle">
<span :class="{ active: !isCustomMode }" @click="toggleStrengthMode(false)"></span>
<span :class="{ active: isCustomMode }" @click="toggleStrengthMode(true)"></span>
</div>
</div>
<div v-if="!isCustomMode" class="kt-strength-selector">
<div class="kt-strength-item" :class="{ active: formData.strength === presetLow }" @click="formData.strength = presetLow"></div>
<div class="kt-strength-item" :class="{ active: formData.strength === presetMid }" @click="formData.strength = presetMid"></div>
@ -108,7 +108,9 @@
</div>
<i class="fas fa-cloud-upload-alt kt-subpage__upload-icon" :class="{ 'success': formData.files.length > 0 }"></i>
<div class="kt-subpage__upload-text" v-if="formData.files.length === 0"> ()</div>
<div class="kt-subpage__upload-text" v-if="formData.files.length === 0"> ()
<div class="kt-upload-hint">仅支持 PNG, JPG 格式</div>
</div>
<div class="kt-subpage__upload-text" v-else>
已选择 <span style="color: var(--kt-accent);">{{ formData.files.length }}</span> 张图片
</div>
@ -130,14 +132,16 @@
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { ref, computed, watch, onUnmounted, onMounted, nextTick } from 'vue'
import TaskSideBar from '@/components/TaskSideBar.vue'
import IntensitySlider from '@/components/IntensitySlider.vue'
import { DATA_TYPE_MAP, ALGO_OPTIONS_Data, ALGO_MAP } from '@/utils/constants'
import { submitPerturbationTask, getTaskStatus } from '@/api/task'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore'
import { getUserConfig } from '@/api/user' //
import modal from '@/utils/modal'
import KtSelect from '@/components/KtSelect.vue'
const taskStore = useTaskStore()
const userStore = useUserStore()
@ -154,7 +158,7 @@ const algorithmSettings = computed(() => {
const algoId = formData.value.algorithm
if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.CAAT_PRO].includes(algoId)) {
return { min: 10, max: 16, step: 1, presets: { low: 10, mid: 12, high: 16 }, default: 12 }
} else if ([ALGO_MAP.GLAZE, ALGO_MAP.PID, ALGO_MAP.STYLE_PROTECTION].includes(algoId)) {
} else if ([ALGO_MAP.GLAZE, ALGO_MAP.PID, ALGO_MAP.STYLE_PROTECTION, ALGO_MAP.QUICK].includes(algoId)) {
return { min: 0.01, max: 0.2, step: 0.01, presets: { low: 0.03, mid: 0.05, high: 0.1 }, default: 0.05 }
}
return { min: 10, max: 16, step: 1, presets: { low: 10, mid: 12, high: 16 }, default: 12 }
@ -163,12 +167,28 @@ const sliderConfig = computed(() => ({ min: algorithmSettings.value.min, max: al
const presetLow = computed(() => algorithmSettings.value.presets.low)
const presetMid = computed(() => algorithmSettings.value.presets.mid)
const presetHigh = computed(() => algorithmSettings.value.presets.high)
watch(() => formData.value.algorithm, () => { formData.value.strength = algorithmSettings.value.default })
//
watch(() => formData.value.algorithm, (newVal, oldVal) => {
//
// loadUserDefaults
if (oldVal !== undefined && newVal !== oldVal) {
formData.value.strength = algorithmSettings.value.default
isCustomMode.value = false
}
})
const toggleStrengthMode = (isCustom) => { isCustomMode.value = isCustom; if (!isCustom) formData.value.strength = algorithmSettings.value.presets.mid }
const currentAvailableAlgorithms = computed(() => ALGO_OPTIONS_Data.filter(algo => algo.type === formData.value.style))
const currentAlgoName = computed(() => { if (!formData.value.algorithm) return '请选择防护算法'; const algo = ALGO_OPTIONS_Data.find(a => a.id === formData.value.algorithm); return algo ? algo.method_name : '未知算法' })
// VIP
const algoOptions = computed(() => {
return currentAvailableAlgorithms.value.map(algo => ({
label: algo.method_name,
value: algo.id
}))
})
const setDataType = (type) => {
if (type === 'art' && !userStore.isVip) {
modal.warning('艺术风格防护仅限 VIP 用户使用,请前往个人中心升级。')
@ -204,7 +224,6 @@ const submitTask = async () => {
if (!formData.value.algorithm) return modal.warning('请选择防护算法')
if (taskStore.quota.remaining_tasks <= 0) return modal.warning('剩余任务配额不足')
// VIP
if (formData.value.style === 'art' && !userStore.isVip) {
return modal.warning('权限不足:艺术风格仅限 VIP 使用')
}
@ -249,6 +268,54 @@ const startSpecificPolling = (taskId) => {
}, 3000)
}
//
const loadUserDefaults = async () => {
try {
const res = await getUserConfig()
if (res?.config) {
const cfg = res.config
// 1.
if (cfg.data_type_id) {
formData.value.style = (cfg.data_type_id === DATA_TYPE_MAP.ART) ? 'art' : 'face'
}
// 2.
if (cfg.perturbation_configs_id) {
const algo = ALGO_OPTIONS_Data.find(a => a.id === cfg.perturbation_configs_id)
if (algo && algo.type === formData.value.style) {
formData.value.algorithm = cfg.perturbation_configs_id
}
}
// 3. ( vs )
if (cfg.perturbation_intensity !== null && cfg.perturbation_intensity !== undefined) {
// algorithmSettings
await nextTick()
formData.value.strength = cfg.perturbation_intensity
//
const p = algorithmSettings.value.presets
if (cfg.perturbation_intensity === p.low ||
cfg.perturbation_intensity === p.mid ||
cfg.perturbation_intensity === p.high) {
isCustomMode.value = false
} else {
isCustomMode.value = true //
}
}
}
} catch (e) {
console.error('加载默认配置失败', e)
}
}
onMounted(() => {
if (specificPollTimer) clearInterval(specificPollTimer)
loadUserDefaults()
})
onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
</script>
@ -265,12 +332,22 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
.kt-select-option { font-family: var(--kt-font); padding: 0.75rem 1rem; border-radius: var(--kt-radius); cursor: pointer; display: flex; justify-content: space-between; color: var(--kt-fg); transition: all var(--kt-transition-micro); }
.kt-select-option:hover { background: var(--kt-muted); }
.kt-select-option.selected { background: var(--kt-accent); color: var(--kt-accent-fg); }
.kt-strength-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
/* 强制固定高度,确保左右对齐 */
.kt-form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
/* 右侧开关约42-44px设置固定高度确保左侧无开关也被撑开同样高度 */
height: 44px;
}
.kt-mode-toggle { display: flex; gap: 0.5rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-muted); padding: 0.25rem; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); }
.kt-mode-toggle span { padding: 0.25rem 0.75rem; border-radius: var(--kt-radius); cursor: pointer; color: var(--kt-muted-fg); transition: all var(--kt-transition-micro); }
.kt-mode-toggle span.active { background: var(--kt-bg); color: var(--kt-fg); font-weight: 700; }
.kt-strength-selector { display: flex; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); padding: 0.25rem; }
.kt-strength-item { flex: 1; text-align: center; padding: 0.75rem 0.5rem; border-radius: var(--kt-radius); cursor: pointer; font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-fg); transition: all var(--kt-transition-micro); }
.kt-strength-item { flex: 1; text-align: center; padding: 0.5rem 0.5rem; border-radius: var(--kt-radius); cursor: pointer; font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-fg); transition: all var(--kt-transition-micro); }
.kt-strength-item:hover { background: var(--kt-muted); }
.kt-strength-item.active { background: var(--kt-accent); color: var(--kt-accent-fg); font-weight: 600; }
.kt-subpage__upload { position: relative; border: 3px dashed var(--kt-border); border-radius: var(--kt-radius); padding: var(--kt-card-padding); text-align: center; cursor: pointer; transition: all var(--kt-transition-micro); background: var(--kt-bg); }
@ -292,4 +369,13 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
}
@media (max-width: 900px) { .kt-subpage__row { flex-direction: column; gap: 1.5rem; } .kt-subpage__style-selector { grid-template-columns: 1fr; } }
.kt-upload-hint {
display: block;
font-size: 0.85em;
color: var(--kt-muted-fg);
font-weight: 400;
margin-top: 0.25rem;
letter-spacing: 0;
}
</style>

@ -1,14 +1,6 @@
<script setup>
/**
* Page2 - 专题防护 (Topic Protection)
*
* Kinetic Typography redesign with:
* - Bilingual title: "专题防护" + "TOPIC PROTECTION"
* - Hero section with "风格迁移防护" + "STYLE TRANSFER DEFENSE"
* - Dual-column cards: "01 防人脸编辑", "02 防定制生成"
* - Preserved click handlers: handleOpenStyle, handleOpenFace, handleOpenCustom
*
* Requirements: 4.7, 4.8, 5.1, 5.2, 5.3, 5.6
*/
import { inject } from 'vue'
@ -24,14 +16,12 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
<template>
<div class="kt-page kt-page2">
<!-- Header Section: Bilingual Title -->
<!-- Requirements: 4.7, 4.8 -->
<header class="kt-header">
<h1 class="kt-header__title-cn">专题防护</h1>
<p class="kt-header__title-en">TOPIC PROTECTION</p>
</header>
<!-- Main Feature Card: Style Transfer Defense -->
<!-- Requirements: 5.1, 5.2, 5.3, 5.6 -->
<section class="kt-main-feature">
<article
class="kt-card kt-card--hero kt-card--style"
@ -43,11 +33,10 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
<!-- 左侧文字信息 (40%) -->
<div class="style-text-col">
<div class="kt-card__hero-text">
<span class="kt-card__hero-line kt-card__hero-line--cn">风格</span>
<span class="kt-card__hero-line kt-card__hero-line--cn">迁移</span>
<span class="kt-card__hero-line kt-card__hero-line--cn">风格防护</span>
</div>
<div class="kt-card__content">
<p class="kt-card__subtitle">STYLE TRANSFER DEFENSE</p>
<p class="kt-card__subtitle">Style Protection</p>
<p class="kt-card__desc">针对艺术品防护将AI视角的风格样式迁移到指定的无效关键词保护原创画风不被模仿</p>
<button class="kt-btn kt-btn--primary">
开始防护
@ -59,32 +48,23 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
<!-- 右侧视觉流 (60%) -->
<div class="style-img-col">
<div class="visual-flow">
<!-- Image 1: Original Art -->
<div class="flow-item img-frame">
<img src="/method_examples/style_trans_example/original/001.jpg" alt="Original Art" />
<span class="flow-badge">ORIGINAL</span>
<!-- Image 1: Unprotected Gen (左上) -->
<div class="flow-item img-frame pos-top-left">
<img src="/method_examples/style_trans_example/good_gen/validation_image_6.png" alt="Unprotected Gen" />
<span class="flow-badge">GOOD_GEN</span>
</div>
<!-- Arrow -->
<div class="flow-arrow">
<div class="arrow-line"></div>
<i class="fas fa-chevron-right"></i>
</div>
<!-- Image 2: Protected Art -->
<div class="flow-item img-frame frame-accent">
<img src="/method_examples/style_trans_example/perturbed/001_glazed_eps12_steps150.jpg" alt="Protected Art" />
<span class="flow-badge badge-accent">PROTECTED</span>
<!-- Image 2: Protected Gen (右下) -->
<div class="flow-item img-frame frame-accent pos-bottom-right">
<img src="/method_examples/style_trans_example/bad_gen/validation_image_7.png" alt="Protected Gen" />
<span class="flow-badge badge-accent">FAILED_GEN</span>
</div>
</div>
<!-- Decorative overlay -->
<div class="img-overlay"></div>
</div>
</article>
</section>
<!-- Secondary Features: Dual-Column Cards -->
<!-- Requirements: 5.1, 5.2, 5.3, 5.6 -->
<section class="kt-secondary-features">
<div class="kt-features__grid">
<!-- Card 01: Face Edit Defense -->
@ -167,13 +147,12 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
/*
* ===== Style Transfer Defense Card (Split Layout) =====
* Updated to match Page3's split layout structure
*/
.kt-card--style {
display: flex;
flex-direction: row; /* Split Layout */
flex-direction: row;
align-items: stretch;
padding: 0; /* Remove default padding, handled by columns */
padding: 0;
overflow: hidden;
min-height: 400px;
}
@ -194,37 +173,50 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
.style-img-col {
flex: 0 0 60%;
position: relative;
background: var(--kt-bg-secondary);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* === Visual Flow Styles (Ported from Page3) === */
/* === Visual Flow Styles (Staggered Layout) === */
.visual-flow {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
z-index: 2;
padding: 2rem;
height: 100%;
min-height: 360px;
padding: 0;
box-sizing: border-box;
}
/* 图片通用样式 */
.img-frame {
position: relative;
width: 32%;
max-width: 380px;
min-width: 140px;
position: absolute;
width: 44%;
max-width: 480px;
min-width: 120px;
aspect-ratio: 1;
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
background: var(--kt-bg);
padding: 4px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
transition: transform 0.3s ease, box-shadow 0.3s ease;
z-index: 1;
}
/* 左上角图片位置 */
.pos-top-left {
top: 8%;
left: 8%;
}
/* 右下角图片位置 */
.pos-bottom-right {
bottom: 8%;
right: 8%;
z-index: 2;
}
.img-frame img {
@ -239,6 +231,7 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
border-color: var(--kt-accent);
}
/* 标签样式 */
.flow-badge {
position: absolute;
bottom: -12px;
@ -254,7 +247,7 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
border-radius: 4px;
text-transform: uppercase;
white-space: nowrap;
transition: background 0.3s ease, color 0.3s ease, border-color 0.3s ease;
transition: all 0.3s ease;
}
.badge-accent {
@ -265,53 +258,22 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
/* Hover Effects */
.kt-card--style:hover .img-frame {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(0,0,0,0.2);
transform: translateY(-8px);
box-shadow: 0 30px 60px rgba(0,0,0,0.2);
}
/* Ensure accent badge stays bright on hover */
.kt-card--style:hover .badge-accent {
background: var(--kt-accent) !important;
color: var(--kt-accent-fg) !important;
border-color: var(--kt-accent) !important;
}
/* Force standard badge to stay muted on hover */
.kt-card--style:hover .flow-badge:not(.badge-accent) {
background: var(--kt-bg) !important;
color: var(--kt-muted-fg) !important;
border-color: var(--kt-border) !important;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
color: var(--kt-muted-fg);
width: auto;
min-width: 40px;
flex-shrink: 0;
}
.arrow-line {
width: 100%;
height: 2px;
background: var(--kt-muted-fg);
margin-bottom: -8px;
}
.flow-arrow i {
font-size: 1.2rem;
}
.img-overlay {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, transparent 0%, var(--kt-bg) 150%);
opacity: 0.5;
pointer-events: none;
}
/* Text Styles */
.kt-card__hero-text {
margin-bottom: 1.5rem;
@ -331,6 +293,7 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
.kt-card__hero-line--cn {
letter-spacing: 0.05em;
line-height: 1.15;
}
.kt-card__content {
@ -379,6 +342,10 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
color: var(--kt-accent) !important;
}
.kt-card--style:hover .kt-btn * {
color: var(--kt-accent) !important;
}
/* ===== Secondary Features Section ===== */
.kt-secondary-features {
padding: 0 var(--kt-container-px) 2rem;
@ -550,23 +517,29 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
.style-img-col {
padding: 2rem;
background: rgba(0,0,0,0.03);
background: transparent;
}
/* 移动端取消交错,恢复垂直排列 */
.visual-flow {
flex-wrap: nowrap;
gap: 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
min-height: auto;
padding: 0;
}
/* 移动端图片复位 */
.img-frame {
width: 40%;
max-width: none;
min-width: 0;
}
.flow-arrow {
width: 20px;
min-width: 20px;
position: relative;
width: 60%;
max-width: 200px;
top: auto !important;
left: auto !important;
bottom: auto !important;
right: auto !important;
}
.kt-card__hero-line {

@ -108,7 +108,9 @@
</div>
<i class="fas fa-cloud-upload-alt kt-subpage__upload-icon"></i>
<div class="kt-subpage__upload-text" v-if="formData.files.length === 0"> ()</div>
<div class="kt-subpage__upload-text" v-if="formData.files.length === 0"> ()
<div class="kt-upload-hint">仅支持 PNG, JPG 格式</div>
</div>
<div class="kt-subpage__upload-text" v-else>
已选择 <span style="color: var(--kt-accent);">{{ formData.files.length }}</span> 张图片
</div>
@ -154,13 +156,13 @@ const formData = ref({ taskName: '', targetStyle: '', files: [] })
const subpageType = computed(() => route.params.subpage)
const pageTitle = computed(() => {
const map = { 'style': '风格迁移防护', 'face': '防人脸编辑', 'custom': '防定制生成' }
const map = { 'style': '风格防护', 'face': '防人脸编辑', 'custom': '防定制生成' }
return map[subpageType.value] || '专题防护'
})
const pageDescription = computed(() => {
const map = {
'style': '针对艺术品定制防御将图片风格迁移到指定关键词以误导AI。参数由系统固定以确保最佳效果支持批量上传。',
'style': '针对画作定制防御将画作的原画风转换成误导画风以干扰AI学习您的原创风格。',
'face': '防止AI对人脸进行换脸、表情编辑等恶意篡改。通过添加不可见扰动使人脸编辑模型无法正常工作。',
'custom': '防止他人使用您的照片训练AI生成定制化内容。扰动会干扰微调过程使生成模型无法学习面部特征。'
}
@ -296,4 +298,13 @@ const submitTask = async () => {
.kt-lock-icon { margin-left: 0.5rem; color: var(--kt-muted-fg); }
@media (max-width: 900px) { .kt-preset-grid { grid-template-columns: 1fr; } }
.kt-upload-hint {
display: block;
font-size: 0.85em;
color: var(--kt-muted-fg);
font-weight: 400;
margin-top: 0.25rem;
letter-spacing: 0;
}
</style>

@ -1,22 +1,11 @@
<script setup>
/**
* Page3 - 效果验证 (Verification)
*
* Kinetic Typography redesign with:
* - Bilingual title: "效果验证" + "VERIFICATION"
* - Hero section with "微调生图验证" + "FINE-TUNING VERIFICATION"
* - Visual Flow in Hero Card: Perturbed Image -> Arrow -> Bad Gen Image
* - Dual-column cards: "数据指标 METRICS", "热力图与频域 HEATMAP"
* - Preserved click handlers: handleOpenFineTune, handleOpenMetrics, handleOpenHeatmap
*
* Requirements: 4.7, 4.8, 5.1, 5.2, 5.3, 5.6
*/
import { inject } from 'vue'
// Inject subpage navigation from parent
const openSubpage = inject('openSubpage')
// Preserved original click handlers
const handleOpenFineTune = () => openSubpage('page3', 'fine-tuning')
const handleOpenMetrics = () => openSubpage('page3', 'metrics')
const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
@ -24,15 +13,11 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
<template>
<div class="kt-page kt-page3">
<!-- Header Section: Bilingual Title -->
<!-- Requirements: 4.7, 4.8 -->
<header class="kt-header">
<h1 class="kt-header__title-cn">效果验证</h1>
<p class="kt-header__title-en">VERIFICATION</p>
</header>
<!-- Main Feature Card: Fine-Tuning Verification (Split Layout) -->
<!-- Requirements: 5.1, 5.2, 5.3, 5.6 -->
<section class="kt-main-feature">
<article
class="kt-card kt-card--hero kt-card--finetune"
@ -41,10 +26,9 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
@keydown.enter="handleOpenFineTune"
@keydown.space.prevent="handleOpenFineTune"
>
<!-- Left Column: Text & Action (40%) -->
<div class="finetune-text-col">
<div class="kt-card__hero-text">
<!-- 修复不换行处理 -->
<span class="kt-card__hero-line">微调生图验证</span>
</div>
<div class="kt-card__content">
@ -57,35 +41,24 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
</div>
</div>
<!-- Right Column: Visual Flow (60%) -->
<div class="finetune-img-col">
<div class="visual-flow">
<!-- Image 1: Perturbed -->
<div class="flow-item img-frame">
<img src="/method_examples/face_edit_example/perturbed/0.png" alt="Protected Input" />
<span class="flow-badge">PROTECTED</span>
<!-- Image 1: Perturbed (左上) -->
<div class="flow-item img-frame pos-top-left">
<img src="/method_examples/face_edit_example/good_gen/image_0.png" alt="Protected Input" />
<span class="flow-badge">GOOD_GEN</span>
</div>
<!-- Arrow -->
<div class="flow-arrow">
<div class="arrow-line"></div>
<i class="fas fa-chevron-right"></i>
</div>
<!-- Image 2: Bad Gen -->
<div class="flow-item img-frame frame-accent">
<div class="flow-item img-frame frame-accent pos-bottom-right">
<img src="/method_examples/face_edit_example/bad_gen/image_0.png" alt="Failed Generation" />
<span class="flow-badge badge-accent">FAILED GEN</span>
</div>
</div>
<!-- Decorative overlay for better integration -->
<div class="img-overlay"></div>
</div>
</article>
</section>
<!-- Secondary Features: Dual-Column Cards -->
<!-- Requirements: 5.1, 5.2, 5.3, 5.6 -->
<section class="kt-secondary-features">
<div class="kt-features__grid">
<!-- Card: Data Metrics -->
@ -181,53 +154,64 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
/* Left Column: Text (40%) */
.finetune-text-col {
flex: 0 0 40%;
/* 增加内边距,防止贴边 */
padding: 4rem;
padding: 4rem;
display: flex;
flex-direction: column;
/* 垂直居中 */
justify-content: center;
justify-content: center;
gap: 2rem;
z-index: 2;
min-width: 0; /* Prevent flex overflow */
min-width: 0;
}
/* Right Column: Visuals (60%) */
.finetune-img-col {
flex: 0 0 60%;
position: relative;
background: var(--kt-bg-secondary); /* Subtle distinction */
/* 透明背景,统一视觉 */
background: transparent;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Visual Flow Container - 居中且使用百分比 */
/* === Visual Flow Container === */
.visual-flow {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: center; /* 整体居中 */
gap: 2rem; /* 固定间距 */
z-index: 2;
padding: 2rem;
height: 100%;
min-height: 360px;
padding: 0;
box-sizing: border-box;
}
/* 图片通用样式 */
.img-frame {
position: relative;
/* 关键修改:从 40% 减小到 32%,留出 36% 的空间用于间距和居中 */
width: 32%;
max-width: 380px;
min-width: 140px;
aspect-ratio: 1; /* 保持正方形 */
position: absolute;
width: 40%;
max-width: 450px;
min-width: 110px;
aspect-ratio: 1;
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
background: var(--kt-bg);
padding: 4px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
transition: transform 0.3s ease, box-shadow 0.3s ease;
z-index: 1;
}
/* 左上角定位 */
.pos-top-left {
top: 10%;
left: 10%;
}
/* 右下角定位 */
.pos-bottom-right {
bottom: 10%;
right: 10%;
z-index: 2;
}
.img-frame img {
@ -235,7 +219,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
height: 100%;
object-fit: cover;
display: block;
border-radius: 2px; /* Inner radius */
border-radius: 2px;
}
.frame-accent {
@ -257,68 +241,33 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
border-radius: 4px;
text-transform: uppercase;
white-space: nowrap;
/* 确保过渡平滑 */
transition: background 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* 卡片悬停时,强制 flow-badge 保持原样,不反色 */
.kt-card--finetune:hover .flow-badge {
background: var(--kt-bg) !important;
color: var(--kt-muted-fg) !important;
border-color: var(--kt-border) !important;
transition: all 0.3s ease;
}
/* 但 .badge-accent (FAILED GEN) 本身是亮色背景,悬停时保持亮色 */
.badge-accent {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
}
/* 确保 accent badge 悬停时也不变 */
/* Hover Effects */
.kt-card--finetune:hover .img-frame {
transform: translateY(-8px);
box-shadow: 0 30px 60px rgba(0,0,0,0.2);
}
.kt-card--finetune:hover .badge-accent {
background: var(--kt-accent) !important;
color: var(--kt-accent-fg) !important;
border-color: var(--kt-accent) !important;
}
/* Hover Effect for Frame */
.kt-card--finetune:hover .img-frame {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(0,0,0,0.2);
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
color: var(--kt-muted-fg);
width: auto;
min-width: 40px;
flex-shrink: 0;
}
.arrow-line {
width: 100%;
height: 2px;
background: var(--kt-muted-fg);
margin-bottom: -8px; /* Visual alignment with chevron */
}
.flow-arrow i {
font-size: 1.2rem;
}
/* Gradient Overlay for Style */
.img-overlay {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, transparent 0%, var(--kt-bg) 150%);
opacity: 0.5;
pointer-events: none;
.kt-card--finetune:hover .flow-badge:not(.badge-accent) {
background: var(--kt-bg) !important;
color: var(--kt-muted-fg) !important;
border-color: var(--kt-border) !important;
}
/* Text Column Styles */
.kt-card__hero-text {
margin-bottom: 1.5rem;
@ -327,7 +276,6 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
.kt-card__hero-line {
display: block;
font-family: var(--kt-font);
/* 字体大小上限 */
font-size: clamp(2rem, 5vw, 4rem);
font-weight: 700;
line-height: 1.1;
@ -335,14 +283,9 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
text-transform: uppercase;
color: var(--kt-fg);
transition: color var(--kt-transition-normal);
/* 强制不换行 */
white-space: nowrap;
}
.kt-card__hero-line--cn {
letter-spacing: 0.05em;
}
.kt-card__content {
display: flex;
flex-direction: column;
@ -389,6 +332,10 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
color: var(--kt-accent) !important;
}
.kt-card--finetune:hover .kt-btn * {
color: var(--kt-accent) !important;
}
/* ===== Secondary Features Section ===== */
.kt-secondary-features {
padding: 0 var(--kt-container-px) 2rem;
@ -544,6 +491,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
padding: 0 var(--kt-container-px) 1rem;
}
/* Stack Layout on Mobile */
.kt-card--finetune {
flex-direction: column;
padding: 0;
@ -553,34 +501,39 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
.finetune-text-col {
padding: 2rem;
flex: 0 0 auto;
/* 移动端恢复左对齐 */
justify-content: flex-start;
}
.finetune-img-col {
padding: 2rem;
background: rgba(0,0,0,0.03);
background: transparent;
}
/* Mobile: Reset Visual Flow to Stack */
.visual-flow {
flex-wrap: nowrap;
gap: 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
min-height: auto;
padding: 0;
}
/* Mobile: Reset Image Positions */
.img-frame {
width: 40%;
max-width: none;
min-width: 0;
}
.flow-arrow {
width: 20px;
min-width: 20px;
position: relative;
width: 60%;
max-width: 200px;
top: auto !important;
left: auto !important;
bottom: auto !important;
right: auto !important;
}
.kt-card__hero-line {
font-size: clamp(2rem, 10vw, 3.5rem);
white-space: normal; /* 移动端允许换行 */
white-space: normal;
}
.kt-card__content {

@ -15,6 +15,21 @@
</div>
<div class="kt-subpage__card-body">
<!-- 描述区域 -->
<div class="kt-subpage__desc" style="margin-bottom: 2rem;">
<p class="kt-body-text" v-if="subpageType === 'heatmap'">
<i class="fas fa-chart-bar" style="margin-right: 0.5rem;"></i>
原始图片和加噪图片的热力图差异对比
</p>
<p class="kt-body-text" v-else-if="subpageType === 'metrics'">
<i class="fas fa-chart-bar" style="margin-right: 0.5rem;"></i>
未加噪图片生成图与加噪后图片生成图的数据差异对比 (PSNR/FID/BRISQUE)
</p>
<p class="kt-body-text" v-else-if="subpageType === 'fine-tuning'">
<i class="fas fa-chart-bar" style="margin-right: 0.5rem;"></i>
模拟训练用于查看数据源的生成效果
</p>
</div>
<!-- 场景 1: 微调验证 -->
<div v-if="subpageType === 'fine-tuning'" class="kt-subpage__form">
@ -40,7 +55,8 @@
<div v-if="finetuneMode === 'task'" class="kt-subpage__form-group">
<label class="kt-subpage__label">1. 选取已有加噪数据源</label>
<div class="kt-source-selector">
<div class="kt-source-trigger" @click="isSourceListOpen = !isSourceListOpen">
<!-- 绑定点击处理函数 -->
<div class="kt-source-trigger" @click="handleToggleSourceList">
<span>{{ currentSourceName }}</span>
<i class="fas fa-chevron-down"></i>
</div>
@ -67,7 +83,10 @@
</div>
<i class="fas fa-cloud-upload-alt kt-subpage__upload-icon"></i>
<p class="kt-subpage__upload-text" v-if="!formData.files.length"> ()</p>
<div class="kt-subpage__upload-text" v-if="!formData.files.length">
点击上传数据集 (可多张)
<div class="kt-upload-hint">仅支持 PNG, JPG 格式</div>
</div>
<p v-else class="kt-subpage__upload-text">
已选择 <span style="color: var(--kt-accent);">{{ formData.files.length }}</span> 张图片
</p>
@ -117,7 +136,8 @@
<div class="kt-subpage__form-group">
<label class="kt-subpage__label">选择数据源</label>
<div class="kt-source-selector">
<div class="kt-source-trigger" @click="isSourceListOpen = !isSourceListOpen">
<!-- 绑定点击处理函数 -->
<div class="kt-source-trigger" @click="handleToggleSourceList">
<span>{{ currentSourceName }}</span>
<i class="fas fa-chevron-down"></i>
</div>
@ -163,7 +183,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore' // Import
import { useUserStore } from '@/stores/userStore'
import { submitFinetuneFromPerturbation, submitFinetuneFromUpload, submitEvaluateTask, submitHeatmapTask, startFinetuneTask, startEvaluateTask } from '@/api/task'
import { getTaskImagePreview } from '@/api/image'
import { FINETUNE_MAP } from '@/utils/constants'
@ -171,7 +191,7 @@ import modal from '@/utils/modal'
const route = useRoute()
const taskStore = useTaskStore()
const userStore = useUserStore() // Use
const userStore = useUserStore()
const isSourceListOpen = ref(false)
const fileInput = ref(null)
const MAX_UPLOAD_COUNT = 5
@ -206,7 +226,19 @@ const candidateTasks = computed(() => {
const currentSourceName = computed(() => formData.value.sourceName || '点击选择...')
// VIP
//
const handleToggleSourceList = () => {
if (candidateTasks.value.length === 0) {
const tipText = subpageType.value === 'metrics'
? '暂无已完成的微调任务,请先进行微调验证。'
: '暂无已完成的加噪任务,请先进行防护处理。'
modal.warning(tipText)
return
}
isSourceListOpen.value = !isSourceListOpen.value
}
// VIP
const handleModeChange = (mode) => {
if (mode === 'upload' && !userStore.isVip) {
modal.warning('自定义上传微调仅限 VIP 用户使用。')
@ -215,7 +247,7 @@ const handleModeChange = (mode) => {
finetuneMode.value = mode
}
// VIP
// VIP
const handleDataTypeChange = () => {
if (formData.value.dataType === 2 && !userStore.isVip) {
modal.warning('艺术品微调仅限 VIP 用户使用。')
@ -392,4 +424,13 @@ onMounted(() => taskStore.fetchTasks())
.kt-clear-btn:hover { background: var(--kt-accent); color: var(--kt-accent-fg); transform: scale(1.1); }
@media (max-width: 900px) { .kt-mode-tabs { flex-direction: column; gap: 0.5rem; } .kt-tab-btn { width: 100%; text-align: center; } .kt-finetune-selector { grid-template-columns: 1fr; } }
.kt-upload-hint {
display: block;
font-size: 0.85em;
color: var(--kt-muted-fg);
font-weight: 400;
margin-top: 0.25rem;
letter-spacing: 0;
}
</style>

@ -42,15 +42,15 @@
</div>
<div class="kt-type-filter">
<i class="fas fa-filter kt-filter-icon" aria-hidden="true"></i>
<select v-model="selectedTaskType" class="kt-select">
<option value="all">所有类型</option>
<option value="perturbation">通用防护 (Perturbation)</option>
<option value="finetune">微调验证 (Finetune)</option>
<option value="evaluate">数据评估 (Evaluate)</option>
<option value="heatmap">热力图 (Heatmap)</option>
</select>
</div>
<i class="fas fa-filter kt-filter-icon" aria-hidden="true" style="z-index: 2;"></i>
<div style="width: 200px;"> <!-- 给一个宽度 -->
<KtSelect
v-model="selectedTaskType"
:options="taskTypeOptions"
placeholder="筛选类型"
/>
</div>
</div> <!-- 补全闭合标签 -->
</div>
<!-- Desktop Table Header -->
@ -135,32 +135,54 @@
<i class="fas fa-terminal" aria-hidden="true"></i>
</button>
<button
v-if="['pending','waiting','processing','running'].includes(task.status)"
class="kt-action-btn kt-action-btn--danger"
title="取消任务"
@click="handleCancel(task)"
>
<i class="fas fa-stop" aria-hidden="true"></i>
</button>
<button
v-if="task.status === 'completed'"
class="kt-action-btn kt-action-btn--primary"
title="预览结果"
@click="handlePreview(task)"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
<button
v-if="task.status === 'completed'"
class="kt-action-btn kt-action-btn--success"
title="下载结果"
@click="handleDownload(task)"
>
<i class="fas fa-download" aria-hidden="true"></i>
</button>
<!-- 取消按钮仅在运行中/排队中显示 -->
<button
v-if="['pending','waiting','processing','running'].includes(task.status)"
class="kt-action-btn kt-action-btn--danger"
title="取消任务"
@click="handleCancel(task)"
>
<i class="fas fa-stop" aria-hidden="true"></i>
</button>
<!-- 新增重启按钮仅在失败/已取消显示 -->
<button
v-if="['failed', 'cancelled'].includes(task.status)"
class="kt-action-btn kt-action-btn--warning"
title="重启任务"
@click="handleRestart(task)"
>
<i class="fas fa-redo" aria-hidden="true"></i>
</button>
<!-- 预览/下载按钮仅在完成/失败如果后端有报告显示 -->
<button
v-if="task.status === 'completed'"
class="kt-action-btn kt-action-btn--primary"
title="预览结果"
@click="handlePreview(task)"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
<button
v-if="task.status === 'completed'"
class="kt-action-btn kt-action-btn--success"
title="下载结果"
@click="handleDownload(task)"
>
<i class="fas fa-download" aria-hidden="true"></i>
</button>
<!-- 新增删除按钮在已完成/失败/已取消时显示 -->
<button
v-if="['completed', 'failed', 'cancelled'].includes(task.status)"
class="kt-action-btn kt-action-btn--danger"
title="删除任务"
@click="handleDelete(task)"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
@ -234,14 +256,23 @@
</template>
<script setup>
import KtSelect from '@/components/KtSelect.vue'
import { ref, computed, onMounted } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore'
import { getTaskResultImages, cancelTask, getTaskLogs } from '@/api/task'
import { getTaskResultImages, cancelTask, getTaskLogs, restartTask, deleteTask } from '@/api/task'
import JSZip from 'jszip'
import ImagePreviewModal from '@/components/ImagePreviewModal.vue'
import modal from '@/utils/modal'
const taskTypeOptions = [
{ label: '所有类型', value: 'all' },
{ label: '通用防护', value: 'perturbation' },
{ label: '微调验证', value: 'finetune' },
{ label: '数据评估', value: 'evaluate' },
{ label: '热力图', value: 'heatmap' }
]
const taskStore = useTaskStore()
const userStore = useUserStore()
@ -360,6 +391,7 @@ const handlePreview = (task) => {
showPreview.value = true
}
// handleCancel (/)
const handleCancel = async (task) => {
const confirmed = await modal.confirm(`确定要终止任务 #${task.task_id} 吗?`)
if (!confirmed) return
@ -373,6 +405,34 @@ const handleCancel = async (task) => {
}
}
//
const handleRestart = async (task) => {
const confirmed = await modal.confirm(`确定要重启任务 #${task.task_id} 吗?`)
if (!confirmed) return
try {
await restartTask(task.task_id)
modal.success('任务已重新入队')
taskStore.fetchTasks()
} catch (e) {
console.error(e)
modal.error('重启失败: ' + e.message)
}
}
//
const handleDelete = async (task) => {
const confirmed = await modal.confirm(`确定要彻底删除任务 #${task.task_id} 吗?此操作无法撤销。`)
if (!confirmed) return
try {
await deleteTask(task.task_id)
modal.success('任务已彻底删除')
taskStore.fetchTasks()
} catch (e) {
console.error(e)
modal.error('删除失败: ' + e.message)
}
}
const handleViewLogs = async (task) => {
currentLogTaskId.value = task.task_id
logContent.value = ''
@ -391,42 +451,88 @@ const handleViewLogs = async (task) => {
}
const handleDownload = async (task) => {
if (task.status !== 'completed') return modal.warning('任务未完成')
if (task.status !== 'completed') {
return modal.warning('任务未完成,无法下载')
}
modal.info('正在准备下载文件,请稍候...') //
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 }
// 1.
const res = await getTaskImagePreview(task.task_id)
if (!res || !res.images) {
return modal.warning('任务结果中未找到图片数据')
}
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))
})
// 2.
const allImages = []
// finetune, perturbation, heatmap, report
Object.values(res.images).forEach(list => {
if (Array.isArray(list)) {
allImages.push(...list)
}
})
if (allImages.length === 0) {
return modal.warning('任务结果中未找到图片数据')
}
if (!hasImg) return modal.warning('无图片数据')
const zip = new JSZip()
const folder = zip.folder(`task_${task.task_id}_${task.task_type}`)
// 3. Blob Base64 ZIP
for (const img of allImages) {
const filename = img.filename || `${img.image_type || 'unknown'}_${img.image_id}.png`
const dataUrl = img.data // data Blob URL Base64
if (dataUrl.startsWith('blob:')) {
// Blob URL fetch ArrayBuffer
const blob = await fetch(dataUrl).then(r => r.blob())
// ArrayBuffer Base64
folder.file(filename, blob, { binary: true })
} else if (dataUrl.startsWith('data:')) {
// Base64 Base64
const base64String = dataUrl.split(',')[1]
folder.file(filename, base64String, { base64: true })
}
}
// 4. ZIP
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.download = `museguard_task_${task.task_id}_${task.task_type}.zip`
document.body.appendChild(link)
link.click()
} catch (e) { modal.error('下载出错: ' + e.message) }
document.body.removeChild(link)
URL.revokeObjectURL(url) // Blob URL
modal.success('文件打包完成,下载已开始')
} catch (e) {
console.error('下载打包失败:', e)
modal.error('下载打包失败: ' + (e.message || '未知错误'))
}
}
onMounted(() => taskStore.fetchTasks())
onMounted(() => {
// 1. Session Storage
const savedStatus = sessionStorage.getItem('kt_task_filter_status')
if (savedStatus) {
currentStatus.value = savedStatus
sessionStorage.removeItem('kt_task_filter_status') // 使
}
// 2.
taskStore.fetchTasks()
})
</script>
<style scoped>
/* ... (保留上面的页面样式,不重复粘贴,仅粘贴修改后的 Log Modal 部分) ... */
.kt-page4 {
min-height: 100%;
display: flex;
@ -461,19 +567,90 @@ onMounted(() => taskStore.fetchTasks())
.kt-search-input { width: 100%; padding: 0.75rem 1rem 0.75rem 2.5rem; font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 400; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); outline: none; transition: border-color var(--kt-transition-micro); }
.kt-search-input::placeholder { color: var(--kt-muted-fg); }
.kt-search-input:focus { border-color: var(--kt-accent); }
.kt-type-filter { position: relative; }
.kt-filter-icon { position: absolute; top: 50%; left: 1rem; transform: translateY(-50%); color: var(--kt-muted-fg); font-size: var(--kt-small); pointer-events: none; }
.kt-type-filter {
display: flex;
align-items: center;
gap: 1rem;
position: relative;
}
.kt-filter-icon {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
color: var(--kt-muted-fg);
font-size: var(--kt-small);
pointer-events: none;
/* 确保层级高于下拉框 */
z-index: 5;
}
.kt-type-filter :deep(.kt-select-trigger) {
padding-left: 2.5rem;
}
.kt-select { font-family: var(--kt-font); padding: 0.75rem 2rem 0.75rem 2.5rem; font-size: var(--kt-small); font-weight: 400; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); cursor: pointer; appearance: none; outline: none; transition: border-color var(--kt-transition-micro); }
.kt-select:focus { border-color: var(--kt-accent); }
.kt-table-header { display: grid; grid-template-columns: minmax(60px, 0.5fr) minmax(200px, 3fr) minmax(100px, 1fr) minmax(160px, 1.5fr) minmax(100px, 1fr) minmax(120px, 1fr); padding: 1rem 2rem; align-items: center; background: var(--kt-muted); border-bottom: var(--kt-border-width) solid var(--kt-border); font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--kt-muted-fg); }
.kt-table-header {
display: grid;
grid-template-columns:
minmax(60px, 0.5fr) /* ID */
minmax(150px, 2.5fr) /* Name: 减小最小宽度和权重,给右侧留空间 */
minmax(90px, 1fr) /* Type: 微调 */
minmax(160px, 1.5fr) /* Time */
minmax(100px, 1fr) /* Status */
minmax(160px, 1.2fr); /* Action: 最小宽度 160px防止重叠 */
padding: 1rem 2rem;
align-items: center;
background: var(--kt-muted);
border-bottom: var(--kt-border-width) solid var(--kt-border);
font-family: var(--kt-font);
font-size: var(--kt-small);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--kt-muted-fg);
}
.kt-sortable { cursor: pointer; user-select: none; transition: color var(--kt-transition-micro); }
.kt-sortable:hover { color: var(--kt-fg); }
.kt-sort-active { color: var(--kt-accent); }
.kt-sort-dim { opacity: 0.3; }
.kt-table-body { flex: 1; overflow-y: auto; }
.kt-table-body {
flex: 1;
overflow-y: auto;
overflow-x: auto; /* 允许水平滚动 */
}
/* 为了确保表头和内容在极窄屏幕下不乱,给它们加个最小宽度 */
.kt-table-header, .kt-table-row {
min-width: 800px;
}
.kt-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 300px; color: var(--kt-muted-fg); font-family: var(--kt-font); font-size: var(--kt-body); }
.kt-empty-state i { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
.kt-table-row { display: grid; grid-template-columns: minmax(60px, 0.5fr) minmax(200px, 3fr) minmax(100px, 1fr) minmax(160px, 1.5fr) minmax(100px, 1fr) minmax(120px, 1fr); padding: 1.25rem 2rem; align-items: center; border-bottom: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); transition: background var(--kt-transition-micro); }
.kt-table-row {
display: grid;
grid-template-columns:
minmax(60px, 0.5fr)
minmax(150px, 2.5fr)
minmax(90px, 1fr)
minmax(160px, 1.5fr)
minmax(100px, 1fr)
minmax(160px, 1.2fr);
padding: 1.25rem 2rem;
align-items: center;
border-bottom: var(--kt-border-width) solid var(--kt-border);
background: var(--kt-bg);
transition: background var(--kt-transition-micro);
}
.kt-table-row:hover { background: var(--kt-muted); }
.kt-col { display: flex; align-items: center; }
.kt-col--action { justify-content: flex-end; gap: 0.5rem; }
@ -499,6 +676,8 @@ onMounted(() => taskStore.fetchTasks())
.kt-mobile-view { display: none; }
.kt-desktop-only { display: flex; }
/*
===========================================
Log Modal (VS Code Style) - Fixed Colors
@ -701,4 +880,11 @@ html.dark-mode .kt-log-loading {
.kt-filter-tab:focus-visible, .kt-search-input:focus-visible, .kt-select:focus-visible, .kt-action-btn:focus-visible, .kt-page-btn:focus-visible, .kt-close-btn:focus-visible { outline: 2px solid var(--kt-accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .kt-filter-tab, .kt-table-row, .kt-action-btn, .kt-page-btn, .kt-close-btn, .kt-search-input, .kt-select { transition: none; } .kt-action-btn:hover, .kt-page-btn:hover:not(:disabled) { transform: none; } }
.kt-type-filter :deep(.kt-select-dropdown) {
/* 确保所有选项(5个)直接完全展示,不再出现滚动条 */
max-height: 400px;
/* 阻止滚动连锁*/
overscroll-behavior: contain;
}
</style>

@ -1,32 +0,0 @@
<template>
<div class="kt-subpage">
<div class="kt-subpage__card">
<div class="kt-subpage__header">
<h2 class="kt-subpage__title">
结果展示
<span class="kt-subpage__title-en">RESULTS</span>
</h2>
<span class="kt-tag">RESULTS</span>
</div>
<div class="kt-subpage__body">
<p class="kt-body-text">本页面展示任务处理结果和数据分析</p>
<div class="kt-subpage__grid">
<div class="kt-subpage__grid-item"></div>
<div class="kt-subpage__grid-item"></div>
<div class="kt-subpage__grid-item"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<style scoped>
/* === Kinetic Typography Subpage Styles === */
/* Requirements: 5.1-5.6 - Use --kt-* variables, KT design language */
/* Component uses global .kt-subpage classes from Style.css */
</style>

@ -32,27 +32,47 @@
<!-- Stats Grid -->
<section class="kt-stats-section">
<div class="kt-stats-grid">
<div class="kt-stat-card">
<!-- 总任务卡片 -->
<article
class="kt-stat-card"
@click="handleStatClick('all')"
tabindex="0"
@keydown.enter="handleStatClick('all')"
>
<div class="kt-stat">
<span class="kt-stat__number">{{ userStats?.total_tasks || 0 }}</span>
<span class="kt-stat__label">TOTAL TASKS</span>
</div>
<p class="kt-stat-desc">总任务</p>
</div>
<div class="kt-stat-card">
</article>
<!-- 已完成卡片 -->
<article
class="kt-stat-card"
@click="handleStatClick('completed')"
tabindex="0"
@keydown.enter="handleStatClick('completed')"
>
<div class="kt-stat">
<span class="kt-stat__number kt-stat__number--success">{{ userStats?.completed_tasks || 0 }}</span>
<span class="kt-stat__label">COMPLETED</span>
</div>
<p class="kt-stat-desc">已完成</p>
</div>
<div class="kt-stat-card">
</article>
<!-- 处理中卡片 -->
<article
class="kt-stat-card"
@click="handleStatClick('running')"
tabindex="0"
@keydown.enter="handleStatClick('running')"
>
<div class="kt-stat">
<span class="kt-stat__number kt-stat__number--warning">{{ userStats?.processing_tasks || 0 }}</span>
<span class="kt-stat__label">PROCESSING</span>
</div>
<p class="kt-stat-desc">处理中</p>
</div>
</article>
</div>
</section>
@ -67,9 +87,9 @@
<!-- 修改密码 -->
<article
class="kt-card kt-setting-card"
@click="openModal('password')"
@keydown.enter="openModal('password')"
@keydown.space.prevent="openModal('password')"
@click="handleOpenSubpage('password')"
@keydown.enter="handleOpenSubpage('password')"
@keydown.space.prevent="handleOpenSubpage('password')"
tabindex="0"
role="button"
aria-label="修改密码">
@ -86,9 +106,9 @@
<!-- 默认配置 -->
<article
class="kt-card kt-setting-card"
@click="openModal('config')"
@keydown.enter="openModal('config')"
@keydown.space.prevent="openModal('config')"
@click="handleOpenSubpage('config')"
@keydown.enter="handleOpenSubpage('config')"
@keydown.space.prevent="handleOpenSubpage('config')"
tabindex="0"
role="button"
aria-label="默认配置">
@ -107,7 +127,7 @@
v-if="userStore.role !== 'admin'"
class="kt-card kt-setting-card"
:class="{ 'kt-setting-card--vip': userStore.role === 'vip' }"
@click="openModal('vip')"
@click="handleOpenSubpage('vip')"
tabindex="0"
role="button"
:aria-label="userStore.role === 'vip' ? 'VIP 特权' : '升级 VIP'"
@ -126,13 +146,13 @@
<article
v-if="userStore.role === 'admin'"
class="kt-card kt-setting-card kt-setting-card--admin"
@click="openModal('admin')"
@keydown.enter="openModal('admin')"
@keydown.space.prevent="openModal('admin')"
@click="handleOpenSubpage('admin')"
@keydown.enter="handleOpenSubpage('admin')"
@keydown.space.prevent="handleOpenSubpage('admin')"
tabindex="0"
role="button"
aria-label="用户管理后台">
<div class="kt-setting-icon kt-setting-icon--accent" aria-hidden="true">
<div class="kt-setting-icon" aria-hidden="true">
<i class="fas fa-users-cog"></i>
</div>
<div class="kt-card__content">
@ -146,11 +166,11 @@
<article
v-if="userStore.role === 'admin'"
class="kt-card kt-setting-card kt-setting-card--admin"
@click="openModal('admin-vip')"
@click="handleOpenSubpage('admin-vip')"
tabindex="0"
role="button"
>
<div class="kt-setting-icon kt-setting-icon--accent" aria-hidden="true">
<div class="kt-setting-icon" aria-hidden="true">
<i class="fas fa-ticket-alt"></i>
</div>
<div class="kt-card__content">
@ -161,43 +181,30 @@
</article>
</div>
</section>
</div>
<!-- Modal Mount Point -->
<Teleport to="body">
<Transition name="kt-fade">
<div v-if="activeModal" class="kt-modal-overlay" @click.self="closeModal">
<SubpageContainer
:subpageType="activeModal"
@close="closeModal"
/>
</div>
</Transition>
</Teleport>
</div>
</div> <!-- CORRECTED: 闭合 kt-main-content -->
</div> <!-- CORRECTED: 闭合 kt-page kt-page5 -->
</template>
<script setup>
import { ref, onMounted, onActivated, defineAsyncComponent } from 'vue'
import { ref, onMounted, onActivated, inject } from 'vue'
import { useRouter } from 'vue-router'
import { authLogout } from '@/api/index'
import { getTaskList } from '@/api/task'
import { useUserStore } from '@/stores/userStore'
import modal from '@/utils/modal'
const SubpageContainer = defineAsyncComponent(() =>
import('./subpages/SubpageContainer.vue')
)
const router = useRouter()
const userStore = useUserStore()
// MainFlow openSubpage navigateToSection
const openSubpage = inject('openSubpage')
const navigateToSection = inject('navigateToSection')
const userStats = ref({
total_tasks: 0,
completed_tasks: 0,
processing_tasks: 0
})
const activeModal = ref(null)
// Role mapping
const formatRole = (role) => {
@ -219,8 +226,25 @@ const handleLogout = async () => {
}
}
const openModal = (type) => { activeModal.value = type }
const closeModal = () => { activeModal.value = null }
//
const handleOpenSubpage = (type) => {
// 使 MainFlow
openSubpage('page5', type)
}
// Page 4
const handleStatClick = (status) => {
// 1. Session Storage
sessionStorage.setItem('kt_task_filter_status', status)
// 2. MainFlow Page 4
if (navigateToSection) {
navigateToSection('page4')
} else {
// Fallback: 使 router.push('/page4')
router.push('/page4')
}
}
// Fetch task statistics
const fetchData = async () => {
@ -231,6 +255,7 @@ const fetchData = async () => {
const tasks = res.tasks
const total = tasks.length
const completed = tasks.filter(t => t.status === 'completed').length
// running = pending, waiting, processing, running
const processing = tasks.filter(t =>
['processing', 'pending', 'running', 'waiting'].includes(t.status)
).length
@ -437,6 +462,7 @@ onActivated(() => {
gap: 1.5rem;
}
/* 确保 stats 卡片可点击 */
.kt-stat-card {
padding: 2rem;
background: var(--kt-bg);
@ -444,10 +470,12 @@ onActivated(() => {
border-radius: var(--kt-radius);
text-align: center;
transition: all var(--kt-transition-micro);
cursor: pointer;
}
.kt-stat-card:hover {
background: var(--kt-muted);
transform: scale(1.02);
}
.kt-stat-card .kt-stat {
@ -511,12 +539,6 @@ onActivated(() => {
font-weight: 400;
}
/*
关键修复使用 Grid 响应式断点
- 默认移动端1
- 平板2
- 桌面3 (强制一行最多3个)
*/
.kt-settings-grid {
display: grid;
grid-template-columns: 1fr;
@ -605,18 +627,6 @@ onActivated(() => {
background: rgba(223, 225, 4, 0.05);
}
/* ===== Modal Overlay ===== */
.kt-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 2000;
display: flex;
justify-content: center;
align-items: center;
}
/* ===== Transitions ===== */
.kt-fade-enter-active,
.kt-fade-leave-active {

@ -6,18 +6,26 @@
<div class="kt-modal-card kt-modal-card--fullscreen">
<div class="kt-modal-header">
<h3 class="kt-modal-title">用户管理后台 <span class="kt-modal-title-en">USER MANAGEMENT</span></h3>
<button class="kt-close-btn" @click="$emit('close')" aria-label=""><i class="fas fa-times" aria-hidden="true"></i></button>
<button class="kt-close-btn" @click="handleClose" aria-label=""><i class="fas fa-times" aria-hidden="true"></i></button>
</div>
<div class="kt-modal-body">
<div class="kt-toolbar">
<div class="kt-search-group">
<i class="fas fa-search kt-search-icon" aria-hidden="true"></i>
<input type="text" placeholder="搜索用户..." class="kt-search-input" v-model="searchKeyword" @keyup.enter="fetchAdminData" />
<span class="kt-tip-text">💡点击表头排序</span>
<i class="fas fa-search kt-search-icon clickable" @click="handleSearch" aria-hidden="true" title="点击搜索"></i>
<input
type="text"
placeholder="搜索用户..."
class="kt-search-input"
v-model="searchKeyword"
@keyup.enter="handleSearch"
/>
<span class="kt-tip-text">💡支持 ID / 用户名 / 邮箱</span>
</div>
<div class="kt-btn-group">
<button class="kt-btn" @click="fetchAdminData"></button>
<button class="kt-btn" @click="handleRefresh" :disabled="isRefreshing">
<i class="fas fa-sync-alt" :class="{ 'fa-spin': isRefreshing }"></i> 刷新
</button>
<button class="kt-btn kt-btn--primary" @click="openUserForm('create')">
<i class="fas fa-plus" aria-hidden="true"></i> 新建
</button>
@ -26,58 +34,70 @@
<div class="kt-table-wrapper">
<div class="kt-user-list">
<!-- 列表头严格 5 列布局 -->
<div class="kt-list-header">
<span class="kt-sortable" @click="handleSort('user_id', $event)">ID <i :class="getSortIcon('user_id')" aria-hidden="true"></i></span>
<span class="kt-sortable" @click="handleSort('username', $event)">用户名 <i :class="getSortIcon('username')" aria-hidden="true"></i></span>
<span class="kt-sortable" @click="handleSort('email', $event)">邮箱 <i :class="getSortIcon('email')" aria-hidden="true"></i></span>
<span class="kt-sortable" @click="handleSort('role', $event)">角色 <i :class="getSortIcon('role')" aria-hidden="true"></i></span>
<span>操作</span>
<div class="kt-sortable" @click="handleSort('user_id', $event)">
<span>ID</span>
<i :class="getSortIcon('user_id')" aria-hidden="true"></i>
</div>
<div class="kt-sortable" @click="handleSort('username', $event)">
<span>用户名</span>
<i :class="getSortIcon('username')" aria-hidden="true"></i>
</div>
<div class="kt-sortable" @click="handleSort('email', $event)">
<span>邮箱</span>
<i :class="getSortIcon('email')" aria-hidden="true"></i>
</div>
<!-- 角色表头居中 -->
<div class="kt-sortable kt-col--center" @click="handleSort('role', $event)">
<span>角色</span>
<i :class="getSortIcon('role')" aria-hidden="true"></i>
</div>
<!-- 操作表头靠右 -->
<div class="kt-col--right">操作</div>
</div>
<div v-if="sortedAdminUsers.length === 0" class="kt-empty-row"></div>
<!-- 修改遍历 sortedAdminUsers 而非 adminUsers -->
<div v-for="u in sortedAdminUsers" :key="u.user_id" class="kt-list-row">
<span class="kt-id-text">#{{ u.user_id }}</span>
<span class="kt-username">{{ u.username }}</span>
<span class="kt-email">{{ u.email }}</span>
<span>
<div v-if="paginatedUsers.length === 0" class="kt-empty-row"></div>
<!-- 列表行比例与 Header 严格一致 -->
<div v-for="u in paginatedUsers" :key="u.user_id" class="kt-list-row">
<div class="kt-col">#{{ u.user_id }}</div>
<div class="kt-col kt-username">{{ u.username }}</div>
<div class="kt-col kt-email">{{ u.email }}</div>
<div class="kt-col kt-col--center">
<span class="kt-role-tag" :class="'kt-role--' + getRoleClass(u.role)">
{{ formatRole(u.role) }}
</span>
</span>
<div class="kt-actions">
<button class="kt-text-btn" @click="openUserForm('edit', u)">编辑</button>
</div>
<div class="kt-col kt-col--right kt-actions">
<button class="kt-text-btn kt-text-btn--edit" @click="openUserForm('edit', u)">编辑</button>
<button class="kt-text-btn kt-text-btn--danger" @click="handleDeleteUser(u)"></button>
</div>
</div>
</div>
</div>
<div class="kt-pagination">
<button class="kt-page-btn" :disabled="currentPage <= 1" @click="changePage(-1)" aria-label="">
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</button>
<span class="kt-page-info"> {{ currentPage }} </span>
<button class="kt-page-btn" @click="changePage(1)" aria-label="">
<i class="fas fa-chevron-right" aria-hidden="true"></i>
</button>
<button class="kt-page-btn" :disabled="currentPage <= 1" @click="changePage(-1)"><i class="fas fa-chevron-left"></i></button>
<span class="kt-page-info"> {{ currentPage }} / {{ totalPages }} </span>
<button class="kt-page-btn" :disabled="currentPage >= totalPages" @click="changePage(1)"><i class="fas fa-chevron-right"></i></button>
</div>
</div>
</div>
</div>
<!-- D. 管理员 VIP 邀请码生成 (新增) -->
<!-- D. 管理员 VIP 邀请码生成 -->
<div v-else-if="subpageType === 'admin-vip'" class="kt-modal-wrapper">
<div class="kt-modal-card">
<div class="kt-modal-header">
<h3 class="kt-modal-title">邀请码管理 <span class="kt-modal-title-en">VIP CODES</span></h3>
<button class="kt-close-btn" @click="$emit('close')"><i class="fas fa-times"></i></button>
<button class="kt-close-btn" @click="handleClose"><i class="fas fa-times"></i></button>
</div>
<div class="kt-modal-body">
<div class="kt-form">
<div class="kt-form-group">
<label class="kt-form-label">有效期 ()</label>
<input type="number" v-model.number="vipGenForm.days" class="kt-form-input" />
<label class="kt-form-label">有效期 (整数)</label>
<input type="number" v-model.number="vipGenForm.days" class="kt-form-input" placeholder="例如: 30" />
</div>
<div class="kt-form-group">
<label class="kt-form-label">生成数量 (1-10)</label>
@ -86,16 +106,10 @@
<div class="kt-form-actions">
<button class="kt-btn kt-btn--primary" @click="handleGenerateCodes"></button>
</div>
<div v-if="generatedCodes.length > 0" class="kt-codes-result">
<p class="kt-codes-title">生成成功 (点击复制):</p>
<div class="kt-code-list">
<div
v-for="code in generatedCodes"
:key="code"
class="kt-code-item"
@click="copyCode(code)"
>
<div v-for="code in generatedCodes" :key="code" class="kt-code-item" @click="copyCode(code)">
{{ code }} <i class="fas fa-copy"></i>
</div>
</div>
@ -105,125 +119,93 @@
</div>
</div>
<!-- E. VIP 升级/状态查看 (新增) -->
<!-- E. VIP 升级/状态查看 -->
<div v-else-if="subpageType === 'vip'" class="kt-modal-wrapper">
<div class="kt-modal-card">
<div class="kt-modal-header">
<h3 class="kt-modal-title">VIP 会员 <span class="kt-modal-title-en">PRIVILEGES</span></h3>
<button class="kt-close-btn" @click="$emit('close')"><i class="fas fa-times"></i></button>
<button class="kt-close-btn" @click="handleClose"><i class="fas fa-times"></i></button>
</div>
<div class="kt-modal-body">
<div class="kt-vip-benefits">
<div class="kt-benefit-item">
<i class="fas fa-bolt"></i>
<div>
<div class="kt-benefit-title">极速并发</div>
<div class="kt-benefit-desc">最大并发任务数提升至 10 </div>
</div>
</div>
<div class="kt-benefit-item">
<i class="fas fa-database"></i>
<div>
<div class="kt-benefit-title">全库解锁</div>
<div class="kt-benefit-desc">解锁艺术品数据集防护权限</div>
</div>
</div>
<div class="kt-benefit-item">
<i class="fas fa-magic"></i>
<div>
<div class="kt-benefit-title">高级微调</div>
<div class="kt-benefit-desc">允许上传自定义图片进行微调</div>
</div>
</div>
<div class="kt-benefit-item"><i class="fas fa-bolt"></i><div><div class="kt-benefit-title">极速并发</div><div class="kt-benefit-desc">最大并发任务数提升至 10 </div></div></div>
<div class="kt-benefit-item"><i class="fas fa-database"></i><div><div class="kt-benefit-title">全库解锁</div><div class="kt-benefit-desc">解锁艺术品数据集防护权限</div></div></div>
<div class="kt-benefit-item"><i class="fas fa-magic"></i><div><div class="kt-benefit-title">高级微调</div><div class="kt-benefit-desc">允许上传自定义图片进行微调</div></div></div>
</div>
<div class="kt-divider"></div>
<div v-if="userStore.role === 'vip' || userStore.role === 'admin'" class="kt-vip-status active">
<i class="fas fa-crown"></i>
<span>尊贵的 VIP 用户</span>
<i class="fas fa-crown"></i><span>尊贵的 VIP 用户</span>
</div>
<div v-else class="kt-form">
<div class="kt-form-group">
<label class="kt-form-label">输入邀请码</label>
<input
type="text"
v-model="vipUpgradeCode"
class="kt-form-input"
placeholder="VIP-XXXXXXXX"
/>
</div>
<div class="kt-form-actions">
<button class="kt-btn kt-btn--primary" @click="handleUpgradeVip"></button>
<input type="text" v-model="vipUpgradeCode" class="kt-form-input" placeholder="VIP-XXXXXXXX"/>
</div>
<div class="kt-form-actions"><button class="kt-btn kt-btn--primary" @click="handleUpgradeVip"></button></div>
</div>
</div>
</div>
</div>
<!-- A/B. 普通弹窗 (修改密码/系统配置) -->
<!-- A/B. 普通弹窗 (修改密码/默认配置) -->
<div v-else class="kt-modal-wrapper">
<div class="kt-modal-card">
<div class="kt-modal-header">
<h3 class="kt-modal-title">{{ modalTitle }} <span class="kt-modal-title-en">{{ modalTitleEn }}</span></h3>
<button class="kt-close-btn" @click="$emit('close')" aria-label=""><i class="fas fa-times" aria-hidden="true"></i></button>
<button class="kt-close-btn" @click="handleClose" aria-label=""><i class="fas fa-times" aria-hidden="true"></i></button>
</div>
<div class="kt-modal-body">
<!-- A. 修改密码 -->
<div v-if="subpageType === 'password'" class="kt-form">
<div class="kt-form-group">
<label class="kt-form-label">当前密码</label>
<input type="password" v-model="pwdForm.oldPassword" class="kt-form-input" />
<input type="password" v-model="pwdForm.oldPassword" class="kt-form-input" placeholder="输入当前密码" />
</div>
<div class="kt-form-group">
<label class="kt-form-label">新密码</label>
<input type="password" v-model="pwdForm.newPassword" class="kt-form-input" />
<input type="password" v-model="pwdForm.newPassword" class="kt-form-input" placeholder="输入新密码" />
</div>
<div class="kt-form-group">
<label class="kt-form-label">确认新密码</label>
<input type="password" v-model="pwdForm.confirmPassword" class="kt-form-input" />
<input type="password" v-model="pwdForm.confirmPassword" class="kt-form-input" placeholder="再次输入新密码" />
</div>
<div class="kt-form-actions">
<button class="kt-btn kt-btn--primary" @click="submitPassword"></button>
</div>
</div>
<!-- B. 默认配置 -->
<div v-else-if="subpageType === 'config'" class="kt-form">
<div class="kt-form-group">
<label class="kt-form-label">默认防护对象</label>
<select v-model="configForm.data_type_id" class="kt-form-select" @change="onConfigDataTypeChange">
<option :value="1">通用人脸防护</option>
<option :value="2">通用艺术品防护</option>
</select>
<KtSelect v-model="configForm.data_type_id" :options="dataTypeOptions" @change="onConfigDataTypeChange"/>
</div>
<div class="kt-form-group">
<label class="kt-form-label">默认扰动算法</label>
<select v-model="configForm.perturbation_configs_id" class="kt-form-select">
<option :value="null">不设置默认值</option>
<option
v-for="algo in filteredConfigAlgorithms"
:key="algo.id"
:value="algo.id"
>
{{ algo.method_name }}
</option>
</select>
<KtSelect v-model="configForm.perturbation_configs_id" :options="algoOptionsWithNone"/>
</div>
<div class="kt-form-group">
<label class="kt-form-label">默认强度 (浮点数)</label>
<input type="number" step="0.1" v-model.number="configForm.perturbation_intensity" class="kt-form-input" />
<div class="kt-form-header">
<label class="kt-form-label" style="margin-bottom: 0;">默认强度</label>
<div class="kt-mode-toggle" v-if="configForm.perturbation_configs_id">
<span :class="{ active: !isCustomMode }" @click="toggleStrengthMode(false)"></span>
<span :class="{ active: isCustomMode }" @click="toggleStrengthMode(true)"></span>
</div>
</div>
<div v-if="configForm.perturbation_configs_id">
<div v-if="!isCustomMode" class="kt-strength-selector">
<div class="kt-strength-item" :class="{ active: configForm.perturbation_intensity === presetLow }" @click="configForm.perturbation_intensity = presetLow"></div>
<div class="kt-strength-item" :class="{ active: configForm.perturbation_intensity === presetMid }" @click="configForm.perturbation_intensity = presetMid"></div>
<div class="kt-strength-item" :class="{ active: configForm.perturbation_intensity === presetHigh }" @click="configForm.perturbation_intensity = presetHigh"></div>
</div>
<div v-else><IntensitySlider v-model="configForm.perturbation_intensity" :min="sliderConfig.min" :max="sliderConfig.max" :step="sliderConfig.step" /></div>
</div>
<div v-else class="kt-tip-text">请先选择算法以设置强度</div>
</div>
<div class="kt-form-actions">
<button class="kt-btn kt-btn--primary" @click="submitConfig"></button>
</div>
</div>
</div>
</div>
</div>
@ -240,23 +222,19 @@
<div class="kt-form">
<div class="kt-form-group">
<label class="kt-form-label">用户名</label>
<input type="text" v-model="userForm.username" class="kt-form-input" :disabled="userModalMode === 'edit'" />
<input type="text" v-model="userForm.username" class="kt-form-input" :disabled="userModalMode === 'edit'" placeholder="输入用户名" />
</div>
<div class="kt-form-group" v-if="userModalMode === 'create'">
<label class="kt-form-label">密码</label>
<input type="password" v-model="userForm.password" class="kt-form-input" />
<input type="password" v-model="userForm.password" class="kt-form-input" placeholder="输入密码" />
</div>
<div class="kt-form-group">
<label class="kt-form-label">邮箱</label>
<input type="email" v-model="userForm.email" class="kt-form-input" />
<input type="email" v-model="userForm.email" class="kt-form-input" placeholder="输入邮箱" />
</div>
<div class="kt-form-group">
<label class="kt-form-label">角色</label>
<select v-model="userForm.role" class="kt-form-select">
<option value="normal">普通用户 (Normal)</option>
<option value="vip">VIP用户</option>
<option value="admin">管理员 (Admin)</option>
</select>
<label class="kt-form-label">角色权限</label>
<KtSelect v-model="userForm.role" :options="roleOptions" />
</div>
<div class="kt-form-actions">
<button class="kt-btn" @click="showUserModal = false">取消</button>
@ -272,7 +250,8 @@
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { ref, computed, onMounted, reactive, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
authChangePassword, getUserConfig, updateUserConfig,
getAdminUserList, createAdminUser, updateAdminUser, deleteAdminUser
@ -280,312 +259,214 @@ import {
import { upgradeToVip } from '@/api/user'
import { generateVipCodes } from '@/api/admin'
import { useUserStore } from '@/stores/userStore'
import { ALGO_OPTIONS_Data, DATA_TYPE_MAP } from '@/utils/constants'
import { ALGO_OPTIONS_Data, DATA_TYPE_MAP, ALGO_MAP } from '@/utils/constants'
import modal from '@/utils/modal'
import KtSelect from '@/components/KtSelect.vue'
import IntensitySlider from '@/components/IntensitySlider.vue'
const props = defineProps(['subpageType'])
const emit = defineEmits(['close'])
const userStore = useUserStore()
const route = useRoute(); const router = useRouter(); const userStore = useUserStore()
// Props
const subpageType = computed(() => route.params.subpage || props.subpageType)
const handleClose = () => { if (route.params.subpage) router.push('/'); else emit('close') }
const modalTitle = computed(() => {
const map = { password: '修改密码', config: '系统配置', admin: '用户管理后台', 'admin-vip': '邀请码管理', vip: 'VIP会员' }
return map[props.subpageType] || ''
const map = { password: '修改密码', config: '默认配置', admin: '用户管理后台', 'admin-vip': '邀请码管理', vip: 'VIP会员' }
return map[subpageType.value] || ''
})
const modalTitleEn = computed(() => {
const map = { password: 'CHANGE PASSWORD', config: 'SYSTEM CONFIG', admin: 'USER MANAGEMENT', 'admin-vip': 'VIP CODES', vip: 'MEMBERSHIP' }
return map[props.subpageType] || ''
const map = { password: 'CHANGE PASSWORD', config: 'DEFAULT CONFIGURATION', admin: 'USER MANAGEMENT', 'admin-vip': 'VIP CODES', vip: 'MEMBERSHIP' }
return map[subpageType.value] || ''
})
// === VIP ===
const vipUpgradeCode = ref('')
const dataTypeOptions = [{ label: '通用人脸防护', value: 1 }, { label: '通用艺术品防护', value: 2 }]
const roleOptions = [{ label: '普通用户 (Normal)', value: 'normal' }, { label: 'VIP用户', value: 'vip' }, { label: '管理员 (Admin)', value: 'admin' }]
const vipUpgradeCode = ref('')
const handleUpgradeVip = async () => {
if (!vipUpgradeCode.value) return modal.warning('请输入邀请码')
try {
const res = await upgradeToVip({ vip_code: vipUpgradeCode.value })
await modal.success(res.message || '升级成功!')
if (res.user) {
userStore.updateUserInfo(res.user)
}
emit('close')
} catch (e) {
console.error(e)
}
try { const res = await upgradeToVip({ vip_code: vipUpgradeCode.value }); await modal.success(res.message || '升级成功!'); if (res.user) userStore.updateUserInfo(res.user); handleClose() } catch (e) { console.error(e) }
}
// === Admin ===
const vipGenForm = ref({ days: 30, count: 1 })
const generatedCodes = ref([])
const vipGenForm = ref({ days: 30, count: 1 }); const generatedCodes = ref([])
const handleGenerateCodes = async () => {
try {
const res = await generateVipCodes({
expires_days: vipGenForm.value.days,
count: vipGenForm.value.count
})
generatedCodes.value = res.codes || []
modal.success(`成功生成 ${res.codes.length} 个邀请码`)
} catch (e) {
console.error(e)
}
}
const copyCode = (code) => {
navigator.clipboard.writeText(code)
modal.info('已复制到剪贴板')
if (!Number.isInteger(vipGenForm.value.days) || vipGenForm.value.days <= 0) return modal.warning('请输入有效的正整数天数')
if (!Number.isInteger(vipGenForm.value.count) || vipGenForm.value.count <= 0) return modal.warning('生成数量必须为正整数')
try { const res = await generateVipCodes({ expires_days: vipGenForm.value.days, count: vipGenForm.value.count }); generatedCodes.value = res.codes || []; modal.success(`成功生成 ${res.codes.length} 个邀请码`) } catch (e) { console.error(e) }
}
// === Config Logic ===
const configForm = ref({ data_type_id: 1, perturbation_configs_id: null, perturbation_intensity: null })
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
const copyCode = async (code) => {
try { if (navigator.clipboard) { await navigator.clipboard.writeText(code); modal.info('已复制到剪贴板') } else throw new Error() } catch {
const t = document.createElement("textarea"); t.value = code; t.style.position = "fixed"; document.body.appendChild(t); t.select();
if (document.execCommand('copy')) modal.info('已复制到剪贴板'); document.body.removeChild(t)
}
}
const fetchConfig = async () => {
const res = await getUserConfig();
if (res?.config) {
configForm.value = {
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); modal.success('配置已保存'); emit('close') }
// === Password Logic ===
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' })
const submitPassword = async () => {
if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) return modal.warning('两次输入密码不一致')
try {
await authChangePassword({ old_password: pwdForm.value.oldPassword, new_password: pwdForm.value.newPassword })
modal.success('密码修改成功'); emit('close')
} catch(e) { console.error(e) }
}
// === Admin Logic ===
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 getRoleClass = (role) => { const map = { 'admin': 'admin', 'vip': 'vip', 'normal': 'user' }; return map[role] || 'user' }
const fetchAdminData = async () => {
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 sortedAdminUsers = computed(() => {
//
const data = [...adminUsers.value]
const rule = sortRules.value[0] //
if (!rule) return data //
return data.sort((a, b) => {
let valA = a[rule.field]
let valB = b[rule.field]
// ID ()
if (rule.field === 'user_id') {
return rule.direction === 'asc' ? valA - valB : valB - valA
}
// ()
valA = String(valA || '').toLowerCase()
valB = String(valB || '').toLowerCase()
if (valA < valB) return rule.direction === 'asc' ? -1 : 1
if (valA > valB) return rule.direction === 'asc' ? 1 : -1
return 0
const configForm = ref({ data_type_id: 1, perturbation_configs_id: null, perturbation_intensity: null }); const isCustomMode = ref(false)
const algorithmSettings = computed(() => {
const algoId = configForm.value.perturbation_configs_id
if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.CAAT_PRO].includes(algoId)) return { min: 10, max: 16, step: 1, presets: { low: 10, mid: 12, high: 16 }, default: 12 }
if ([ALGO_MAP.GLAZE, ALGO_MAP.PID, ALGO_MAP.STYLE_PROTECTION, ALGO_MAP.QUICK].includes(algoId)) return { min: 0.01, max: 0.2, step: 0.01, presets: { low: 0.03, mid: 0.05, high: 0.1 }, default: 0.05 }
return { min: 0, max: 100, step: 1, presets: { low: 25, mid: 50, high: 75 }, default: 50 }
})
const sliderConfig = computed(() => ({ min: algorithmSettings.value.min, max: algorithmSettings.value.max, step: algorithmSettings.value.step }))
const presetLow = computed(() => algorithmSettings.value.presets.low), presetMid = computed(() => algorithmSettings.value.presets.mid), presetHigh = computed(() => algorithmSettings.value.presets.high)
watch(() => configForm.value.perturbation_configs_id, (n, o) => { if (o !== undefined && n !== o) { configForm.value.perturbation_intensity = algorithmSettings.value.default; isCustomMode.value = false } })
const toggleStrengthMode = (c) => { isCustomMode.value = c; if (!c) configForm.value.perturbation_intensity = algorithmSettings.value.presets.mid }
const filteredConfigAlgorithms = computed(() => ALGO_OPTIONS_Data.filter(a => a.type === (configForm.value.data_type_id === 2 ? 'art' : 'face')))
const algoOptionsWithNone = computed(() => [{ label: '不设置默认值', value: null }, ...filteredConfigAlgorithms.value.map(a => ({ label: a.method_name, value: a.id }))])
const onConfigDataTypeChange = () => { if (configForm.value.perturbation_configs_id) { if (ALGO_OPTIONS_Data.find(a => a.id === configForm.value.perturbation_configs_id)?.type !== (configForm.value.data_type_id === 2 ? 'art' : 'face')) configForm.value.perturbation_configs_id = null } }
const fetchConfig = async () => { const res = await getUserConfig(); if (res?.config) { configForm.value = { data_type_id: res.config.data_type_id || 1, perturbation_configs_id: res.config.perturbation_configs_id, perturbation_intensity: res.config.perturbation_intensity }; nextTick(() => { const p = algorithmSettings.value.presets; isCustomMode.value = !(res.config.perturbation_intensity === p.low || res.config.perturbation_intensity === p.mid || res.config.perturbation_intensity === p.high) }) } }
const submitConfig = async () => { await updateUserConfig(configForm.value); modal.success('配置已保存'); handleClose() }
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' }); const submitPassword = async () => { if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) return modal.warning('两次输入不一致'); try { await authChangePassword({ old_password: pwdForm.value.oldPassword, new_password: pwdForm.value.newPassword }); modal.success('密码修改成功'); handleClose() } catch(e) {} }
const allAdminUsers = ref([]); const searchKeyword = ref(''); const currentPage = ref(1); const sortRules = ref([]); const isRefreshing = ref(false); const pageSize = 20
const totalPages = computed(() => Math.ceil(filteredUsers.value.length / pageSize) || 1)
const formatRole = (r) => ({ admin: '管理员', vip: 'VIP', normal: '普通' }[r] || '普通'), getRoleClass = (r) => ({ admin: 'admin', vip: 'vip', normal: 'user' }[r] || 'user')
const fetchAdminData = async (t = false) => { if (t) isRefreshing.value = true; try { const res = await getAdminUserList({ page: 1, per_page: 10000 }); if (res?.users) allAdminUsers.value = res.users; if (t) modal.success('已刷新') } catch(e) {} finally { if (t) setTimeout(() => isRefreshing.value = false, 600) } }
const filteredUsers = computed(() => { const k = searchKeyword.value.trim().toLowerCase(); return k ? allAdminUsers.value.filter(u => String(u.user_id).includes(k) || u.username?.toLowerCase().includes(k) || u.email?.toLowerCase().includes(k)) : allAdminUsers.value })
const sortedUsers = computed(() => {
const d = [...filteredUsers.value], r = sortRules.value[0]; if (!r) return d
return d.sort((a, b) => {
let vA = a[r.field], vB = b[r.field]; if (r.field === 'user_id') return r.direction === 'asc' ? vA - vB : vB - vA
vA = String(vA||'').toLowerCase(); vB = String(vB||'').toLowerCase(); return (vA < vB ? -1 : 1) * (r.direction === 'asc' ? 1 : -1)
})
})
const paginatedUsers = computed(() => sortedUsers.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize))
const handleSearch = () => currentPage.value = 1, handleRefresh = () => fetchAdminData(true), changePage = (d) => { const n = currentPage.value + d; if (n >= 1 && n <= totalPages.value) currentPage.value = n }
const handleSort = (f, e) => { const i = sortRules.value.findIndex(r => r.field === f); if (i !== -1) { if (sortRules.value[i].direction === 'asc') sortRules.value[i].direction = 'desc'; else sortRules.value.splice(i, 1) } else { const n = { field: f, direction: 'asc' }; if (e.shiftKey) sortRules.value.push(n); else sortRules.value = [n] } }
const getSortIcon = (f) => { const r = sortRules.value.find(x => x.field === f); return r ? (r.direction === 'asc' ? 'fas fa-sort-up active' : 'fas fa-sort-down active') : 'fas fa-sort dim' }
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]
// asc -> desc ->
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]
}
// fetch
fetchAdminData()
}
const showUserModal = ref(false), userModalMode = ref('create'), userForm = reactive({ user_id: null, username: '', password: '', email: '', role: 'normal' })
const openUserForm = (m, u = null) => { userModalMode.value = m; if (m === 'edit' && u) Object.assign(userForm, { user_id: u.user_id, username: u.username, email: u.email, role: u.role, password: '' }); else Object.assign(userForm, { user_id: null, username: '', password: '', email: '', role: 'normal' }); showUserModal.value = true }
const submitUserForm = async () => { try { const p = { username: userForm.username, email: userForm.email, role: userForm.role }; if (userModalMode.value === 'create') { p.password = userForm.password; await createAdminUser(p) } else await updateAdminUser(userForm.user_id, { ...p, is_active: true }); showUserModal.value = false; fetchAdminData() } catch(e) {} }
const handleDeleteUser = async (u) => { if (await modal.confirm(`确定要删除用户 "${u.username}" 吗?`)) { try { await deleteAdminUser(u.user_id); modal.success('已删除'); fetchAdminData() } catch (e) { console.error(e) } } }
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'
}
onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpageType.value === 'admin') fetchAdminData() })
</script>
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
<style scoped>
/* Header 与 Row 比例严格同步 */
.kt-list-header, .kt-list-row {
display: grid;
/* ID(0.8) | 用户名(1.5) | 邮箱(2.5) | 角色(1) | 操作(1.2) */
grid-template-columns: 0.8fr 1.5fr 2.5fr 1fr 1.2fr;
gap: 1rem;
align-items: center;
}
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)
modal.success('创建成功')
} else {
await updateAdminUser(userForm.user_id, { ...payload, is_active: true })
modal.success('更新成功')
}
showUserModal.value = false; fetchAdminData()
} catch (e) { console.error(e) }
.kt-list-header {
padding: 1rem 1.5rem;
background: var(--kt-muted);
font-family: var(--kt-font);
font-weight: 700;
font-size: var(--kt-small);
color: var(--kt-muted-fg);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: var(--kt-border-width) solid var(--kt-border);
position: sticky; top: 0; z-index: 10;
}
const handleDeleteUser = async (u) => {
const confirmed = await modal.confirm(`确定要删除用户 "${u.username}" 吗?此操作无法撤销。`)
if (!confirmed) return
try {
await deleteAdminUser(u.user_id)
modal.success('删除成功')
if (adminUsers.value.length === 1 && currentPage.value > 1) currentPage.value--; fetchAdminData()
} catch (e) { console.error(e) }
.kt-list-row {
padding: 1.5rem 1.5rem;
border-bottom: var(--kt-border-width) solid var(--kt-border);
transition: background var(--kt-transition-micro);
}
onMounted(() => {
if (props.subpageType === 'config') fetchConfig()
if (props.subpageType === 'admin') fetchAdminData()
})
</script>
.kt-list-row:hover { background: var(--kt-muted); }
<style scoped>
.kt-subpage-layout { width: 100%; height: 100%; padding: 2rem; display: flex; justify-content: center; align-items: center; overflow-y: auto; background: var(--kt-bg); }
.kt-modal-wrapper { display: flex; justify-content: center; align-items: center; width: 100%; max-width: 500px; }
/* 2. 排序标题布局:图标紧跟文字 */
.kt-sortable {
cursor: pointer;
display: inline-flex; /* 紧凑布局 */
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
transition: color var(--kt-transition-micro);
white-space: nowrap;
}
.kt-col--center { justify-content: center !important; text-align: center; }
.kt-sortable--center { justify-content: center; }
.kt-col--right { justify-content: flex-end !important; text-align: right; }
.kt-sortable i { font-size: 0.75rem; }
.active { color: var(--kt-accent); opacity: 1; }
.dim { opacity: 0.2; }
/* 3. 单元格文本处理 */
.kt-col { display: flex; align-items: center; min-height: 1.2em; }
.kt-username { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kt-email { color: var(--kt-muted-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* 4. 基础框架 */
.kt-subpage-layout { width: 100%; height: 100%; padding: 2rem; display: flex; justify-content: center; align-items: center; background: var(--kt-bg); }
.kt-modal-wrapper { width: 100%; max-width: 500px; }
.kt-modal-wrapper--wide { max-width: 90vw; }
.kt-modal-card { width: 100%; display: flex; flex-direction: column; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); overflow: hidden; }
.kt-modal-card--fullscreen { height: 80vh; max-height: 80vh; }
.kt-modal-header { flex: 0 0 auto; padding: 1.5rem 2rem; border-bottom: var(--kt-border-width) solid var(--kt-border); background: var(--kt-muted); display: flex; justify-content: space-between; align-items: center; }
.kt-modal-card { width: 100%; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); }
.kt-modal-card--fullscreen { height: 80vh; display: flex; flex-direction: column; }
.kt-modal-header { padding: 1.5rem 2rem; border-bottom: var(--kt-border-width) solid var(--kt-border); background: var(--kt-muted); display: flex; justify-content: space-between; align-items: center; }
.kt-modal-title { font-family: var(--kt-font); font-weight: 700; color: var(--kt-fg); font-size: var(--kt-body); text-transform: uppercase; letter-spacing: 0.02em; margin: 0; display: flex; align-items: baseline; gap: 1rem; }
.kt-modal-title-en { font-size: var(--kt-small); color: var(--kt-muted-fg); font-weight: 400; letter-spacing: 0.1em; }
/* 5. 按钮与交互 */
.kt-close-btn { width: 40px; height: 40px; background: transparent; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-muted-fg); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--kt-transition-micro); }
.kt-close-btn:hover { background: var(--kt-fg); border-color: var(--kt-fg); color: var(--kt-bg); }
.kt-close-btn:active { transform: scale(0.95); }
.kt-modal-body { padding: 2rem; flex: 1; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 1.5rem; }
.kt-form { display: flex; flex-direction: column; gap: 1.5rem; }
.kt-form-group { display: flex; flex-direction: column; gap: 0.5rem; }
.kt-form-label { font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 600; color: var(--kt-fg); text-transform: uppercase; letter-spacing: 0.05em; }
.kt-form-input { width: 100%; padding: 0.75rem 1rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); outline: none; transition: border-color var(--kt-transition-micro); }
.kt-form-input:focus { border-color: var(--kt-accent); }
.kt-form-input:disabled { opacity: 0.5; cursor: not-allowed; }
.kt-form-input::placeholder { color: var(--kt-muted-fg); }
.kt-form-select { width: 100%; padding: 0.75rem 2.5rem 0.75rem 1rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); cursor: pointer; appearance: none; outline: none; transition: border-color var(--kt-transition-micro); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23FAFAFA' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 1rem center; }
.kt-form-select:focus { border-color: var(--kt-accent); }
.kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 0.5rem; }
.kt-btn { font-family: var(--kt-font); display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: transparent; color: var(--kt-fg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); cursor: pointer; transition: all var(--kt-transition-micro); font-size: var(--kt-small); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.kt-btn:hover { background: var(--kt-muted); transform: scale(1.02); }
.kt-btn:active { transform: scale(0.98); }
.kt-btn--primary { background: var(--kt-accent); border-color: var(--kt-accent); color: var(--kt-accent-fg); }
.kt-btn--primary:hover { background: var(--kt-fg); border-color: var(--kt-fg); color: var(--kt-bg); }
.kt-btn-group { display: flex; gap: 0.75rem; }
.kt-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; }
.kt-search-group { display: flex; align-items: center; gap: 1rem; flex: 1; position: relative; }
.kt-search-icon { position: absolute; left: 1rem; color: var(--kt-muted-fg); font-size: var(--kt-small); }
.kt-search-input { width: 100%; max-width: 300px; padding: 0.75rem 1rem 0.75rem 2.5rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); outline: none; transition: border-color var(--kt-transition-micro); }
.kt-search-input:focus { border-color: var(--kt-accent); }
.kt-tip-text { font-family: var(--kt-font); font-size: clamp(0.625rem, 1vw, 0.75rem); color: var(--kt-muted-fg); }
.kt-table-wrapper { flex: 1; overflow-y: auto; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); background: var(--kt-bg); }
.kt-user-list { display: flex; flex-direction: column; }
.kt-list-header { display: grid; grid-template-columns: 0.8fr 1.5fr 2fr 1fr 1.2fr; gap: 1rem; padding: 1rem 1.5rem; background: var(--kt-muted); font-family: var(--kt-font); font-weight: 700; font-size: var(--kt-small); color: var(--kt-muted-fg); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: var(--kt-border-width) solid var(--kt-border); position: sticky; top: 0; z-index: 1; }
.kt-sortable { cursor: pointer; display: flex; align-items: center; gap: 0.5rem; transition: color var(--kt-transition-micro); }
.kt-sortable:hover { color: var(--kt-fg); }
.sort-icon { font-size: clamp(0.625rem, 1vw, 0.75rem); }
.sort-icon.dim { opacity: 0.3; }
.sort-icon.active { color: var(--kt-accent); opacity: 1; }
.kt-list-row { display: grid; grid-template-columns: 0.8fr 1.5fr 2fr 1fr 1.2fr; gap: 1rem; padding: 1rem 1.5rem; font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-fg); border-bottom: var(--kt-border-width) solid var(--kt-border); align-items: center; transition: background var(--kt-transition-micro); }
.kt-list-row:hover { background: var(--kt-muted); }
.kt-list-row:last-child { border-bottom: none; }
.kt-id-text { color: var(--kt-muted-fg); font-weight: 600; }
.kt-username { font-weight: 600; }
.kt-email { color: var(--kt-muted-fg); word-break: break-all; }
.kt-empty-row { padding: 3rem; text-align: center; font-family: var(--kt-font); color: var(--kt-muted-fg); }
.kt-role-tag { display: inline-flex; align-items: center; padding: 0.25rem 0.75rem; font-family: var(--kt-font); font-size: clamp(0.625rem, 1vw, 0.75rem); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); }
.kt-role--admin { background: var(--kt-accent); color: var(--kt-accent-fg); border-color: var(--kt-accent); }
.kt-role--vip { background: transparent; color: var(--kt-accent); border-color: var(--kt-accent); }
.kt-role--user { background: var(--kt-muted); color: var(--kt-fg); border-color: var(--kt-border); }
.kt-actions { display: flex; gap: 0.75rem; }
.kt-text-btn { font-family: var(--kt-font); font-size: var(--kt-small); background: none; border: none; color: var(--kt-accent); cursor: pointer; padding: 0.25rem 0.5rem; border-radius: var(--kt-radius); transition: all var(--kt-transition-micro); font-weight: 600; }
.kt-text-btn:hover { background: var(--kt-muted); }
.kt-text-btn { font-weight: 700; color: var(--kt-muted-fg); background: none; border: none; cursor: pointer; transition: 0.2s; white-space: nowrap; }
.kt-text-btn--edit:hover { color: var(--kt-accent); }
.kt-text-btn--danger { color: #ef4444; }
.kt-text-btn--danger:hover { background: rgba(239, 68, 68, 0.1); }
.kt-pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; padding: 1rem 0 0 0; }
.kt-page-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-family: var(--kt-font); background: transparent; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); cursor: pointer; transition: all var(--kt-transition-micro); }
.kt-page-btn:hover:not(:disabled) { background: var(--kt-muted); transform: scale(1.05); }
.kt-page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.kt-page-info { font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-muted-fg); }
.kt-sub-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(4px); display: flex; justify-content: center; align-items: center; z-index: 3000; }
.kt-sub-modal-card { width: 90%; max-width: 450px; }
/* VIP Styles */
.kt-vip-benefits { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
.kt-benefit-item { display: flex; align-items: flex-start; gap: 1rem; padding: 1rem; background: var(--kt-muted); border-radius: var(--kt-radius); border: var(--kt-border-width) solid var(--kt-border); }
.kt-benefit-item i { color: var(--kt-accent); font-size: 1.25rem; margin-top: 0.2rem; }
.kt-benefit-title { font-family: var(--kt-font); font-weight: 700; color: var(--kt-fg); text-transform: uppercase; font-size: 0.9rem; margin-bottom: 0.25rem; }
.kt-benefit-desc { font-family: var(--kt-font); font-size: 0.8rem; color: var(--kt-muted-fg); }
.kt-divider { height: 1px; background: var(--kt-border); margin: 1rem 0; }
.kt-vip-status.active { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 2rem; background: rgba(223, 225, 4, 0.1); border: 2px solid var(--kt-accent); border-radius: var(--kt-radius); color: var(--kt-accent); font-weight: 700; font-family: var(--kt-font); text-transform: uppercase; }
.kt-vip-status i { font-size: 3rem; }
.kt-codes-result { margin-top: 1.5rem; padding-top: 1.5rem; border-top: var(--kt-border-width) dashed var(--kt-border); }
.kt-codes-title { font-family: var(--kt-font); font-weight: 600; color: var(--kt-fg); margin-bottom: 0.75rem; }
.kt-code-list { display: grid; gap: 0.5rem; }
.kt-code-item { padding: 0.75rem; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); font-family: monospace; color: var(--kt-accent); cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: all 0.2s; }
.kt-code-item:hover { border-color: var(--kt-accent); background: var(--kt-muted); }
.kt-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
.kt-btn { padding: 0.8rem 1.5rem; cursor: pointer; font-weight: 700; text-transform: uppercase; border: 2px solid var(--kt-border); background: none; color: var(--kt-fg); font-family: var(--kt-font); transition: 0.2s; }
.kt-btn--primary { background: var(--kt-accent); border-color: var(--kt-accent); color: #000; }
.kt-btn--primary:hover { background: var(--kt-fg); color: var(--kt-bg); border-color: var(--kt-fg); }
/* 确保块级堆叠 */
.kt-modal-body { padding: 2.5rem; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2rem; }
.kt-form { display: flex; flex-direction: column; gap: 1.5rem; width: 100%; }
.kt-form-group { display: block; width: 100%; margin-bottom: 0.5rem; } /* 强制块级 */
.kt-form-label { display: block; margin-bottom: 0.75rem; font-size: var(--kt-small); font-weight: 700; text-transform: uppercase; color: var(--kt-fg); letter-spacing: 0.05em; }
.kt-form-input {
display: block;
width: 100%;
padding: 1rem;
border: var(--kt-border-width) solid var(--kt-border);
background: var(--kt-bg);
color: var(--kt-fg);
font-family: var(--kt-font);
transition: border-color 0.2s;
box-sizing: border-box; /* 必须包含 padding */
}
.kt-form-input:focus { border-color: var(--kt-accent); outline: none; }
.kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1rem; }
/* 7. 其他组件 */
.kt-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }
.kt-search-group { position: relative; flex: 1; display: flex; align-items: center; gap: 1rem; }
.kt-search-input { width: 100%; max-width: 300px; padding: 0.75rem 1rem 0.75rem 2.5rem; border: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); color: var(--kt-fg); }
.kt-search-icon { position: absolute; left: 1rem; color: var(--kt-muted-fg); }
.kt-table-wrapper { flex: 1; overflow-y: auto; border: var(--kt-border-width) solid var(--kt-border); }
.kt-role-tag { width: 80px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--kt-border); font-weight: 700; font-size: 0.7rem; text-transform: uppercase; }
.kt-role--admin { background: var(--kt-accent); color: #000; border-color: var(--kt-accent); }
.kt-role--vip { color: var(--kt-accent); border-color: var(--kt-accent); }
.kt-pagination { display: flex; justify-content: center; align-items: center; gap: 1.5rem; padding-top: 1rem; }
.kt-page-btn { width: 36px; height: 36px; border: 1px solid var(--kt-border); background: none; color: var(--kt-fg); cursor: pointer; }
.kt-form-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; min-height: 44px; }
.kt-mode-toggle { display: flex; gap: 0.5rem; background: var(--kt-muted); padding: 0.3rem; }
.kt-mode-toggle span { padding: 0.3rem 0.8rem; cursor: pointer; font-size: 0.7rem; font-weight: 700; }
.kt-mode-toggle span.active { background: var(--kt-bg); color: var(--kt-fg); }
.kt-strength-selector { display: flex; border: 1px solid var(--kt-border); }
.kt-strength-item { flex: 1; text-align: center; padding: 0.6rem; cursor: pointer; font-weight: 700; }
.kt-strength-item.active { background: var(--kt-accent); color: #000; }
.kt-sub-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 3000; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(4px); }
.kt-sub-modal-card { width: 95%; max-width: 500px; height: auto !important; }
@media (max-width: 900px) {
.kt-subpage-layout { padding: 1rem; height: auto; min-height: 100%; align-items: flex-start; }
.kt-modal-wrapper { max-width: 100%; }
.kt-modal-wrapper--wide { max-width: 100%; }
.kt-modal-card--fullscreen { height: auto; max-height: none; }
.kt-list-header, .kt-list-row { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.kt-list-header span:nth-child(3), .kt-list-header span:nth-child(4), .kt-list-row span:nth-child(3), .kt-list-row span:nth-child(4) { display: none; }
.kt-toolbar { flex-direction: column; align-items: stretch; }
.kt-search-group { flex-direction: column; align-items: stretch; }
.kt-search-input { max-width: 100%; }
.kt-tip-text { display: none; }
.kt-sub-modal-card { max-width: 95%; }
.kt-btn-group { width: 100%; justify-content: stretch; }
.kt-btn-group .kt-btn { flex: 1; }
.kt-modal-card--fullscreen { height: auto; }
.kt-list-header { display: none; }
.kt-list-row { grid-template-columns: 1fr; padding: 1.5rem; gap: 0.5rem; }
.kt-col--right { justify-content: flex-start; }
.kt-actions { justify-content: flex-start; margin-top: 1rem; }
}
.kt-close-btn:focus-visible, .kt-btn:focus-visible, .kt-form-input:focus-visible, .kt-form-select:focus-visible, .kt-text-btn:focus-visible, .kt-page-btn:focus-visible { outline: 2px solid var(--kt-accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .kt-close-btn, .kt-btn, .kt-list-row, .kt-text-btn, .kt-page-btn, .kt-form-input, .kt-form-select { transition: none; } .kt-btn:hover, .kt-page-btn:hover:not(:disabled) { transform: none; } }
</style>

@ -1,629 +0,0 @@
<template>
<div class="kt-login-container">
<!-- 主题切换按钮 -->
<button class="kt-theme-toggle" @click="handleToggleTheme" :title="isDark ? '切换到日间模式' : '切换到夜间模式'">
<i :class="isDark ? 'fas fa-sun' : 'fas fa-moon'"></i>
</button>
<div class="kt-login-card">
<!-- 左侧品牌视觉区 -->
<div class="kt-brand-side">
<div class="kt-brand-content">
<div class="kt-logo-text">MUSE</div>
<h2 class="kt-slogan">加入我们<br>开启防护之旅</h2>
<p class="kt-desc">注册成为 MuseGuard 会员开启您的 AI 隐私防护之旅</p>
</div>
<div class="kt-circle-deco"></div>
<div class="kt-deco-tape"></div>
</div>
<!-- 右侧表单区 -->
<div class="kt-form-side">
<div class="kt-form-header">
<h1>创建账户</h1>
<p>加入 MuseGuard 隐私保护系统</p>
</div>
<div class="kt-form-group">
<!-- 用户名 -->
<div class="kt-user-box">
<input
class="kt-input"
type="text"
name="username"
required
v-model="form.username"
placeholder=" "
/>
<label class="kt-label">用户名</label>
<i class="fas fa-user kt-input-icon"></i>
</div>
<!-- 邮箱 -->
<div class="kt-user-box">
<input
class="kt-input"
type="email"
name="email"
required
v-model="form.email"
placeholder=" "
/>
<label class="kt-label">邮箱地址</label>
<i class="fas fa-envelope kt-input-icon"></i>
</div>
<!-- 验证码组 -->
<div class="kt-code-group">
<div class="kt-user-box kt-code-input-box">
<input
class="kt-input"
type="text"
name="code"
required
v-model="form.code"
maxlength="6"
placeholder=" "
/>
<label class="kt-label">验证码</label>
<i class="fas fa-shield-alt kt-input-icon"></i>
</div>
<button
class="kt-btn kt-btn--secondary kt-code-btn"
:disabled="isSending || countdown > 0"
@click="handleSendCode"
>
{{ codeBtnText }}
</button>
</div>
<!-- 密码 -->
<div class="kt-user-box">
<input
class="kt-input"
type="password"
name="password"
required
v-model="form.password"
placeholder=" "
/>
<label class="kt-label">密码</label>
<i class="fas fa-lock kt-input-icon"></i>
</div>
<!-- VIP 邀请码 (选填) -->
<div class="kt-user-box">
<input
class="kt-input"
type="text"
name="vip_code"
v-model="form.vip_code"
placeholder=" "
/>
<label class="kt-label">VIP 邀请码 (选填)</label>
<i class="fas fa-crown kt-input-icon" style="color: var(--kt-accent);"></i>
</div>
</div>
<button
class="kt-btn kt-btn--primary kt-full-width"
@click="handleRegister"
:disabled="loading"
>
{{ loading ? '注册中...' : '注册' }}
</button>
<div class="kt-footer-link">
<span>已有账户</span>
<a @click.prevent="router.push('/login')" href="#">立即登录</a>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { authRegister, sendAuthCode } from '@/api/auth'
import { toggleTheme, loadThemePreference, applyTheme } from '@/utils/theme'
import modal from '@/utils/modal'
const router = useRouter()
const loading = ref(false)
const isSending = ref(false)
const countdown = ref(0)
const isDark = ref(true)
let timer = null
const form = ref({
username: '',
password: '',
email: '',
code: '',
vip_code: ''
})
//
onMounted(() => {
const savedTheme = loadThemePreference()
applyTheme(savedTheme)
isDark.value = savedTheme === 'dark'
})
//
const handleToggleTheme = () => {
const newTheme = toggleTheme()
isDark.value = newTheme === 'dark'
}
const codeBtnText = computed(() => {
if (isSending.value) return '发送中...'
if (countdown.value > 0) return `${countdown.value}`
return '获取验证码'
})
const validateEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
const handleSendCode = async () => {
if (!form.value.email) return modal.warning('请先填写邮箱地址')
if (!validateEmail(form.value.email)) return modal.warning('邮箱格式不正确')
isSending.value = true
try {
await sendAuthCode({ email: form.value.email, purpose: 'register' })
modal.success(`验证码已发送至 ${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 modal.warning('请填写完整注册信息(含验证码)')
}
loading.value = true
try {
const payload = { ...form.value }
if (!payload.vip_code) delete payload.vip_code
const res = await authRegister(payload)
if (res.user || res.message) {
await modal.success('注册成功,请登录')
router.push('/login')
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
/* ===== Login Container (修复滚动问题) ===== */
.kt-login-container {
position: relative;
height: 100%;
width: 100%;
/* Flex 布局 */
display: flex;
flex-direction: column;
/* 关键修改:移除 justify-content: center 和 align-items: center */
/* 让 margin: auto 在子元素上发挥作用,这是解决顶部截断的核心 */
background-color: var(--kt-bg);
overflow-y: auto;
padding: 40px 20px;
container-type: size;
container-name: register-root;
scrollbar-width: none;
-ms-overflow-style: none;
}
.kt-login-container::-webkit-scrollbar {
display: none;
}
/* ===== Theme Toggle Button ===== */
.kt-theme-toggle {
position: fixed;
top: 20px;
right: 20px;
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--kt-bg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
color: var(--kt-fg);
font-size: 1.2rem;
cursor: pointer;
transition: all var(--kt-transition-micro);
z-index: 100;
}
.kt-theme-toggle:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
}
/* ===== Register Card ===== */
.kt-login-card {
/* 使 margin: auto
1. 当空间充足时自动居中
2. 当空间不足内容溢出上边距自动归零保证顶部内容可见且可滚动
*/
margin: auto;
width: 100%;
max-width: 900px;
/* 移除 min-height允许自然撑开 */
display: flex;
flex-direction: row;
overflow: hidden;
background: var(--kt-bg);
border: 3px solid var(--kt-border);
border-radius: var(--kt-radius);
box-shadow: 0 20px 50px rgba(0,0,0,0.1);
z-index: 10;
flex-shrink: 0;
}
/* ===== Brand Side (Left) ===== */
.kt-brand-side {
flex: 4;
background: var(--kt-fg);
color: var(--kt-bg);
padding: 3rem 2rem;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
min-height: 550px; /* 给左侧一个基础高度,保证在大屏下的美观 */
}
.kt-brand-content {
position: relative;
z-index: 2;
}
.kt-logo-text {
font-family: var(--kt-font);
font-size: 2.5rem;
font-weight: 700;
letter-spacing: 0.2em;
margin-bottom: 2rem;
color: var(--kt-accent);
}
.kt-slogan {
font-family: var(--kt-font);
font-size: 2rem;
line-height: 1.3;
margin-bottom: 1.5rem;
font-weight: 700;
color: var(--kt-bg);
}
.kt-desc {
font-family: var(--kt-font);
font-size: 1rem;
color: var(--kt-bg);
opacity: 0.85;
line-height: 1.6;
}
.kt-circle-deco {
position: absolute;
width: 200px;
height: 200px;
border: 20px dashed rgba(255, 255, 255, 0.1);
border-radius: 50%;
bottom: -50px;
right: -50px;
z-index: 1;
}
.kt-deco-tape {
position: absolute;
top: 20px;
right: 20px;
width: 60px;
height: 15px;
background: rgba(255, 255, 255, 0.2);
transform: rotate(45deg);
border-radius: 2px;
z-index: 3;
}
/* ===== Form Side (Right) ===== */
.kt-form-side {
flex: 5;
padding: 3rem 2.5rem;
display: flex;
flex-direction: column;
justify-content: center;
background: var(--kt-bg);
}
.kt-form-header {
margin-bottom: 2rem;
}
.kt-form-header h1 {
font-family: var(--kt-font);
font-size: 2rem;
color: var(--kt-fg);
margin-bottom: 0.5rem;
font-weight: 700;
}
.kt-form-header p {
font-family: var(--kt-font);
color: var(--kt-muted-fg);
font-size: 1rem;
}
.kt-form-group {
margin-bottom: 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ===== Input Box ===== */
.kt-user-box {
position: relative;
width: 100%;
}
.kt-user-box .kt-input {
width: 100%;
padding: 12px 12px 12px 40px;
font-family: var(--kt-font);
font-size: 1rem;
color: var(--kt-fg);
background: var(--kt-bg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
outline: none;
transition: all var(--kt-transition-micro);
text-transform: none;
height: 48px;
}
.kt-user-box .kt-input::placeholder {
color: transparent;
}
.kt-user-box .kt-input:focus {
border-color: var(--kt-accent);
box-shadow: 0 0 0 3px rgba(223, 225, 4, 0.2);
}
.kt-label {
position: absolute;
top: 12px;
left: 40px;
font-family: var(--kt-font);
font-size: 1rem;
color: var(--kt-muted-fg);
pointer-events: none;
transition: all 0.3s ease;
background: transparent;
padding: 0 4px;
line-height: 1.5;
white-space: nowrap;
}
.kt-user-box .kt-input:focus ~ .kt-label,
.kt-user-box .kt-input:not(:placeholder-shown) ~ .kt-label {
top: -10px;
left: 10px;
font-size: 0.8rem;
color: var(--kt-accent);
font-weight: 600;
background: var(--kt-bg);
}
.kt-input-icon {
position: absolute;
top: 14px;
left: 12px;
font-size: 1.1rem;
color: var(--kt-muted-fg);
transition: color 0.3s ease;
}
.kt-user-box .kt-input:focus ~ .kt-input-icon {
color: var(--kt-accent);
}
/* ===== Code Group Layout ===== */
.kt-code-group {
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
}
.kt-code-input-box {
flex: 1;
min-width: 0;
}
.kt-code-input-box .kt-input {
padding-right: 12px;
}
/* ===== Buttons ===== */
.kt-btn {
font-family: var(--kt-font);
font-size: 1rem;
font-weight: 600;
padding: 0 1.5rem;
height: 48px;
border: 3px solid var(--kt-border);
border-radius: var(--kt-radius);
cursor: pointer;
transition: all var(--kt-transition-micro);
transform: translate(0, 0);
display: flex;
align-items: center;
justify-content: center;
}
.kt-code-btn {
width: auto;
min-width: 110px;
white-space: nowrap;
flex-shrink: 0;
font-size: 0.9rem;
padding: 0 1rem;
}
.kt-btn--primary {
background: var(--kt-bg);
color: var(--kt-fg);
box-shadow: none;
}
.kt-btn--primary:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
box-shadow: none;
transform: translate(2px, 2px);
}
.kt-btn--primary:active {
box-shadow: none;
transform: translate(4px, 4px);
}
.kt-btn--primary:disabled {
background: var(--kt-muted);
color: var(--kt-muted-fg);
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.kt-btn--secondary {
background: var(--kt-fg);
color: var(--kt-bg);
box-shadow: none;
}
.kt-btn--secondary:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
}
.kt-btn--secondary:disabled {
background: var(--kt-muted);
color: var(--kt-muted-fg);
border-color: var(--kt-border);
cursor: not-allowed;
}
.kt-full-width {
width: 100%;
margin-top: 1rem;
}
/* ===== Footer Link ===== */
.kt-footer-link {
margin-top: 1.5rem;
text-align: center;
font-family: var(--kt-font);
font-size: 0.9rem;
color: var(--kt-muted-fg);
}
.kt-footer-link a {
color: var(--kt-accent);
font-weight: 700;
text-decoration: none;
margin-left: 0.5rem;
transition: color var(--kt-transition-micro);
}
.kt-footer-link a:hover {
color: var(--kt-accent);
text-decoration: underline;
}
/* ===== Responsive Design ===== */
@media (max-width: 900px) {
.kt-login-card {
width: 95%;
max-width: 450px;
flex-direction: column;
height: auto;
}
.kt-brand-side {
padding: 2rem;
flex: 0 0 auto;
/* 移除 min-height移动端允许它变小 */
min-height: auto;
text-align: center;
border-radius: var(--kt-radius) var(--kt-radius) 0 0;
}
.kt-logo-text {
margin-bottom: 1rem;
font-size: 2rem;
}
.kt-slogan {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.kt-desc,
.kt-circle-deco,
.kt-deco-tape {
display: none;
}
.kt-form-side {
padding: 2rem 1.5rem;
border-radius: 0 0 var(--kt-radius) var(--kt-radius);
}
.kt-form-header {
text-align: center;
}
}
</style>

@ -2,13 +2,11 @@
import { inject, ref, onMounted, onUnmounted } from 'vue'
import KtMarquee from '@/components/KtMarquee.vue'
// Inject subpage navigation from parent
const openSubpage = inject('openSubpage')
const openPrincipleDiagram = () => openSubpage('home', 'PrincipleDiagram')
const openSamplePreview = () => openSubpage('home', 'SamplePreview')
const openPaperSupport = () => openSubpage('home', 'PaperSupport')
// Parallax scroll state
const heroScale = ref(1)
const heroOpacity = ref(1)
const heroRef = ref(null)
@ -61,7 +59,7 @@ onUnmounted(() => {
<!-- Marquee -->
<KtMarquee speed="fast">
<span>PROTECTIVE PERTURBATION</span>
<span>算法支持</span>
<span></span>
<span>ASPL</span>
<span></span>
@ -162,7 +160,6 @@ onUnmounted(() => {
<style scoped>
.kt-home {
min-height: 100%;
/* 允许高度自然增长,防止截断 */
height: auto;
display: flex;
flex-direction: column;
@ -194,8 +191,8 @@ onUnmounted(() => {
.kt-hero__subtitle {
font-family: var(--kt-font);
font-size: clamp(1rem, 2vw, 1.5rem);
color: var(--kt-muted-fg);
font-size: clamp(1.25rem, 2.5vw, 2rem);
color: var(--kt-fg);
margin-top: 2rem;
text-transform: uppercase;
letter-spacing: 0.2em;
@ -204,16 +201,9 @@ onUnmounted(() => {
.kt-features {
padding: 4rem var(--kt-container-px);
/* 关键修改:允许 flex 子项收缩和增长,但不要被挤压到 0 */
flex: 1 0 auto;
}
/*
* =================================================================
* CSS ORDER FIX: Standard Card Styles moved BEFORE specific styles
* =================================================================
*/
/*
* === Standard Card Styles (Base) ===
*/
@ -225,11 +215,8 @@ onUnmounted(() => {
cursor: pointer;
transition: all var(--kt-transition-normal);
display: flex;
/* 默认垂直排列,左对齐 */
flex-direction: column;
align-items: flex-start;
/* 关键修改:允许卡片根据内容撑开 */
height: auto;
min-height: 100%;
}
@ -249,14 +236,9 @@ onUnmounted(() => {
.kt-card:active { transform: scale(0.98); }
.kt-card:focus-visible { outline: 2px solid var(--kt-accent); outline-offset: 2px; }
/*
* === Paper Card Styles (Modifier) ===
*/
.kt-card--paper {
display: flex;
/* 覆盖 column 为 row */
flex-direction: row;
/* 关键修复:强制子元素高度拉伸,确保右侧容器占满高度 */
align-items: stretch;
padding: 0;
overflow: hidden;
@ -264,7 +246,8 @@ onUnmounted(() => {
}
.paper-text-col {
flex: 0 0 55%;
/* 文字区域占比 35% */
flex: 0 0 35%;
padding: var(--kt-card-padding);
display: flex;
flex-direction: column;
@ -274,17 +257,12 @@ onUnmounted(() => {
/* 右侧图片容器 */
.paper-img-col {
flex: 0 0 45%;
/* 图片区域占比 65% */
flex: 0 0 65%;
position: relative;
overflow: hidden;
/* 修改 1: 增加边框分割线,区分左右区域 */
border-left: 1px solid var(--kt-border);
/* 修改 2: 背景改为透明(显示卡片底色),适应深浅模式 */
border-left: none;
background-color: transparent;
/* Flex 布局实现完美居中 */
display: flex;
flex-direction: column;
align-items: center;
@ -293,29 +271,18 @@ onUnmounted(() => {
/* 图片样式 */
.paper-cover-img {
max-width: 80%;
max-height: 80%;
max-width: 85%;
max-height: 85%;
width: auto;
height: auto;
object-fit: contain;
transition: transform 0.5s ease;
/* 修改 3: 调整图片滤镜,不再强制黑白,使其更自然融入 */
filter: grayscale(100%) opacity(0.9);
/* 阴影让图片浮起来 */
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
}
.img-overlay {
position: absolute;
inset: 0;
/* 修改 4: 调整遮罩,使其更轻微,仅用于边缘融合 */
background: linear-gradient(to right, var(--kt-bg) 0%, transparent 10%);
opacity: 0.6;
pointer-events: none;
z-index: 10;
display: none;
}
.kt-card--paper:hover .paper-cover-img {
@ -334,7 +301,6 @@ onUnmounted(() => {
line-height: 1;
flex-shrink: 0;
transition: color var(--kt-transition-normal);
/* 调整位置,防止挤压文字 */
margin-bottom: 1rem;
}
@ -342,10 +308,8 @@ onUnmounted(() => {
flex: 1;
display: flex;
flex-direction: column;
/* 关键修改:不再强制居中,而是从底部向上排列 */
justify-content: flex-end;
width: 100%;
/* 增加上边距,防止文字覆盖在巨大的数字上 */
padding-top: 1rem;
}
@ -359,7 +323,6 @@ onUnmounted(() => {
line-height: 1.1;
color: var(--kt-fg);
transition: color var(--kt-transition-normal);
/* 防止长标题被截断 */
word-wrap: break-word;
}
@ -393,7 +356,6 @@ onUnmounted(() => {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--kt-gap);
/* 关键:拉伸子元素高度一致 */
align-items: stretch;
}
@ -405,7 +367,6 @@ onUnmounted(() => {
@media (max-width: 900px) {
.kt-card--paper {
flex-direction: column;
/* 移动端恢复为 flex-start 或 center不再 stretch */
align-items: stretch;
}
@ -418,8 +379,8 @@ onUnmounted(() => {
flex: 0 0 auto;
height: 250px;
width: 100%;
border-top: none;
border-left: none;
border-top: 1px solid var(--kt-border);
}
.paper-cover-img {
@ -427,21 +388,25 @@ onUnmounted(() => {
max-height: 90%;
}
/* 移动端同样隐藏遮罩 */
.img-overlay {
background: linear-gradient(to bottom, var(--kt-bg) 0%, transparent 20%);
display: none;
}
.kt-hero { padding: 4rem var(--kt-container-px) 2rem; }
.kt-hero__subtitle { margin-top: 1rem; letter-spacing: 0.1em; }
.kt-hero__subtitle {
margin-top: 1rem;
letter-spacing: 0.1em;
font-size: clamp(1rem, 3vw, 1.5rem);
}
.kt-features { padding: 2rem var(--kt-container-px); }
.kt-features__grid { grid-template-columns: 1fr; }
/* 移动端卡片样式 */
.kt-card:not(.kt-card--paper) {
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
min-height: 200px; /* 移动端最小高度 */
min-height: 200px;
}
.kt-card__number { font-size: clamp(3rem, 15vw, 5rem); }

@ -282,7 +282,7 @@ const prevPage = () => {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
padding: 0.8rem;
border-right: 1px solid rgba(0,0,0,0.1);
}

@ -76,7 +76,6 @@ const slides = [
'/principle/P3.png',
'/principle/P4.png',
'/principle/P5.png',
'/principle/P6.png'
]
const currentSlide = computed(() => slides[currentIndex.value])
@ -122,11 +121,6 @@ onUnmounted(() => {
</script>
<style scoped>
/*
* Principle Diagram - PPT Layout
* Uses Kinetic Typography Design System (--kt-*)
*/
.ppt-container {
height: 100%;
display: flex;
@ -167,7 +161,7 @@ onUnmounted(() => {
flex: 1;
position: relative;
overflow: hidden;
/* 修改:使用透明背景,使其跟随父容器背景色 (适配 Light/Dark) */
/* 使用透明背景,使其跟随父容器背景色 (适配 Light/Dark) */
background: transparent;
display: flex;
flex-direction: column;
@ -216,7 +210,7 @@ onUnmounted(() => {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
/* 修改:使用 KT 变量,适配明暗模式 */
/* 使用 KT 变量,适配明暗模式 */
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
color: var(--kt-fg);

@ -148,16 +148,16 @@ import { ref, reactive, computed } from 'vue'
const tabs = [
{ key: 'face_edit', label: '防人脸编辑' },
{ key: 'style_trans', label: '风格迁移' },
{ key: 'style_trans', label: '风格迁移' },
{ key: 'target_gen', label: '防定制生成' }
]
const currentTab = ref('face_edit')
//
// ( 1: target_gen )
const promptsConfig = {
face_edit: 'A man wearing sunglasses',
target_gen: 'A person under the Eiffel Tower',
target_gen: 'A person in front of the Eiffel Tower', // Modified here
style_trans: '' // Prompt
}
@ -232,6 +232,7 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
border-bottom: var(--kt-border-width) solid var(--kt-border);
flex-shrink: 0;
background: var(--kt-muted);
gap: 3rem;
}
.sample-tabs {
@ -357,6 +358,8 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
/* 修改点 2: 强制不换行 */
white-space: nowrap;
}
/*
@ -475,6 +478,25 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
/* Mobile */
@media (max-width: 900px) {
/* --- 小屏幕下改为垂直排列,避免横向空间不足 --- */
.kt-subpage__card-header {
flex-direction: column;
align-items: flex-start;
gap: 1.5rem;
padding: 1.5rem;
}
.sample-tabs {
width: 100%;
justify-content: space-between;
}
.tab-btn {
flex: 1;
text-align: center;
}
.demo-row { flex-direction: column; gap: 1.5rem; }
.demo-arrow-h { transform: rotate(90deg); margin: 1rem 0; }

Loading…
Cancel
Save