前端beta1.0 #31

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

@ -1,18 +1,47 @@
// src/api/image.js
import request from '@/utils/request'
import { parseMultipartMixed } from '@/utils/multipartParser'
/**
* 获取任务的图片预览数据
* API: GET /api/image/preview/task/<task_id>
* 设置 2 分钟超时并保留重试机制
* 获取任务的图片预览数据 (二进制流版本)
* API: GET /api/image/binary/task/<task_id>
*/
export function getTaskImagePreview(taskId) {
return request({
url: `/image/preview/task/${taskId}`,
method: 'get',
// 【核心修改】设置 2 分钟超时
timeout: 120000,
retry: 2,
retryDelay: 10000 // 保持原有的重试逻辑
})
export async function getTaskImagePreview(taskId) {
// 1. 发起请求,获取原始 Response 对象以读取 Header
// 注意:这里需要 axios 返回完整的 response不仅仅是 data
// 或者是利用 axios 的 transformResponse但为了简单我们在调用层处理
try {
const response = await request({
url: `/image/binary/task/${taskId}`,
method: 'get',
responseType: 'arraybuffer', // 关键:必须接收二进制
timeout: 120000,
// 告诉拦截器需要完整响应对象(如果 request.js 拦截器只返回 response.data这里可能需要调整)
// 假设目前的 request.js 拦截器只返回 response.data我们需要针对这个接口做特殊处理
// 或者可以直接使用 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']
if (!contentType || !contentType.includes('multipart/mixed')) {
throw new Error('后端未返回 Multipart 数据')
}
const boundaryMatch = contentType.match(/boundary=(.+)/)
if (!boundaryMatch) throw new Error('无法获取 Boundary')
const boundary = boundaryMatch[1]
// 调用解析器
const parsedData = parseMultipartMixed(response.data, boundary)
return parsedData
} catch (error) {
console.error('图片流解析失败:', error)
throw error
}
}

@ -42,6 +42,17 @@ export function getTaskStatus(taskId) {
return request({ url: `/task/${taskId}/status`, method: 'get' })
}
/**
* 获取风格迁移防护的预设风格列表
* API: GET /api/task/perturbation/style-presets
*/
export function getStylePresets() {
return request({
url: '/task/perturbation/style-presets',
method: 'get'
})
}
/**
* 获取任务结果图片
* 用于 Page4 下载结果功能数据量可能很大 (Base64)

@ -251,24 +251,52 @@ watch(() => props.isOpen, (val) => {
}
})
const clearBlobs = () => {
if (previewData.value && previewData.value.images) {
Object.values(previewData.value.images).forEach(list => {
list.forEach(img => {
if (img.data && img.data.startsWith('blob:')) {
URL.revokeObjectURL(img.data)
}
})
})
}
}
const fetchData = async () => {
loading.value = true
error.value = null
// Blob
clearBlobs()
previewData.value = null
try {
const res = await getTaskImagePreview(props.taskId)
if (!res || !res.images) throw new Error("后端返回数据为空")
// res parser { images: { original: [...], ... } }
// img.data blob:http://...
if (!res || !res.images) throw new Error("无图片数据")
previewData.value = res
} catch (err) {
console.error(err)
if (err.message.includes('timeout')) error.value = "图片加载超时,请检查网络或点击重试"
else error.value = "无法加载预览数据: " + (err.message || "未知错误")
error.value = "加载失败: " + (err.message || "未知错误")
} finally {
loading.value = false
}
}
//
watch(() => props.isOpen, (val) => {
if (val && props.taskId) {
fetchData()
} else {
setTimeout(() => {
clearBlobs() //
previewData.value = null
// ...
}, 300)
}
})
// === 1. Total Pairs ===
const totalPairs = computed(() => {
if (isFinetuneTask.value) return 0

@ -0,0 +1,147 @@
<template>
<div class="intensity-slider-container">
<div class="slider-header">
<label class="label">
<i class="fas fa-sliders-h"></i>
自定义强度
<span class="beta-tag">BETA</span>
</label>
<div class="value-display">{{ modelValue }}</div>
</div>
<div class="slider-wrapper">
<input
type="range"
:min="min"
:max="max"
:step="step"
:value="modelValue"
@input="updateValue"
class="custom-range"
:style="backgroundStyle"
/>
<div class="range-labels">
<span>{{ min }}</span>
<span>{{ max }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: Number, default: 0 },
min: { type: Number, default: 0 },
max: { type: Number, default: 20 },
step: { type: Number, default: 1 }
})
const emit = defineEmits(['update:modelValue'])
const updateValue = (e) => {
emit('update:modelValue', parseFloat(e.target.value))
}
//
const backgroundStyle = computed(() => {
const percentage = ((props.modelValue - props.min) / (props.max - props.min)) * 100
return {
background: `linear-gradient(90deg, var(--color-accent-secondary) ${percentage}%, #e2e8f0 ${percentage}%)`
}
})
</script>
<style scoped>
.intensity-slider-container {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 15px 20px;
transition: all 0.3s;
}
.intensity-slider-container:hover {
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
border-color: var(--color-accent-secondary);
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.label {
font-weight: 600;
color: #334155;
display: flex;
align-items: center;
gap: 8px;
}
.beta-tag {
font-size: 0.6rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-weight: 800;
letter-spacing: 0.5px;
}
.value-display {
font-family: monospace;
font-size: 1.1rem;
font-weight: 700;
color: var(--color-accent-secondary);
background: rgba(255, 159, 28, 0.1);
padding: 2px 10px;
border-radius: 6px;
}
.slider-wrapper {
position: relative;
width: 100%;
}
/* === 自定义 Range Input 样式 === */
.custom-range {
-webkit-appearance: none;
width: 100%;
height: 6px;
border-radius: 5px;
outline: none;
cursor: pointer;
margin: 10px 0;
}
/* 滑块头 (Thumb) - Webkit */
.custom-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--color-accent-secondary);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
cursor: pointer;
transition: transform 0.1s;
}
.custom-range::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.range-labels {
display: flex;
justify-content: space-between;
color: #94a3b8;
font-size: 0.8rem;
margin-top: 5px;
}
</style>

@ -11,25 +11,35 @@ export const DATA_TYPE_MAP = {
}
// 2. 算法配置 ID (对应 perturbation_configs_id)
// init_db 插入顺序: 1. aspl, 2. simac, 3. caat, 4. pid
// 根据最新后端 API 文档更新
export const ALGO_MAP = {
ASPL: 1, // ASPL算法
SIMAC: 2, // SimAC算法
CAAT: 3, // CAAT算法
PID: 4, // PID算法
GLAZE: 5 // GLAZE算法
ASPL: 1,
SIMAC: 2,
CAAT: 3,
CAAT_PRO: 4,
PID: 5,
GLAZE: 6,
ANTI_CUSTOMIZE: 7, // 防定制生成 (Face)
ANTI_FACE_EDIT: 8, // 防人脸编辑 (Face)
STYLE_PROTECTION: 9 // 风格迁移防护 (Art)
}
// 算法选项配置数据源,用于前端动态筛选
// Face: ASPL, SimAC
// Art: PID, Glaze, CAAT
// 算法选项配置数据源,用于 Page 1 通用模式前端动态筛选
// Face: ASPL, SimAC, CAAT Pro, Anti-Customize, Anti-Face-Edit
// Art: PID, Glaze, CAAT, Style Protection
export const ALGO_OPTIONS_Data = [
{ id: ALGO_MAP.ASPL, method_name: 'ASPL', type: 'face' },
{ id: ALGO_MAP.SIMAC, method_name: 'SimAC', type: 'face' },
{ id: ALGO_MAP.CAAT_PRO, method_name: 'CAAT Pro', type: 'face' },
{ id: ALGO_MAP.ANTI_CUSTOMIZE, method_name: 'Anti-Customize (专用)', type: 'face' },
{ id: ALGO_MAP.ANTI_FACE_EDIT, method_name: 'Anti-Face-Edit (专用)', type: 'face' },
{ id: ALGO_MAP.PID, method_name: 'PID', type: 'art' },
{ id: ALGO_MAP.GLAZE, method_name: 'Glaze', type: 'art' },
{ id: ALGO_MAP.CAAT, method_name: 'CAAT', type: 'art' }
{ id: ALGO_MAP.CAAT, method_name: 'CAAT', type: 'art' },
//{ id: ALGO_MAP.STYLE_PROTECTION, method_name: 'Style Guard (专用)', type: 'art' }
]
// 3. 微调配置 ID (对应 finetune_configs_id)
// init_db 插入顺序: 1. dreambooth, 2. lora, 3. textual_inversion
export const FINETUNE_MAP = {
@ -39,27 +49,27 @@ export const FINETUNE_MAP = {
}
// 4. 专题防护固定配置 (Page 2 业务逻辑映射)
// 根据 init_db 的算法描述进行推荐搭配
// 更新为后端推荐的专用算法 ID 及强度
export const TOPIC_CONFIG = {
// 防风格迁移 -> 推荐使用 Art 数据集(2) + ASPL(1)
// 防风格迁移 -> ID 9 (需要额外选择 target_style)
STYLE_TRANSFER: {
ALGO_ID: 1,
INTENSITY: 16.0,
DATA_TYPE_ID: 2,
ALGO_NAME: 'ASPL (Style Shield)'
ALGO_ID: ALGO_MAP.STYLE_PROTECTION,
INTENSITY: 0.05,
DATA_TYPE_ID: DATA_TYPE_MAP.ART,
ALGO_NAME: 'Style Protection'
},
// 防人脸编辑 -> 推荐使用 Face 数据集(1) + SimAC(2)
// 防人脸编辑 -> ID 8
FACE_EDIT: {
ALGO_ID: 2,
INTENSITY: 8.0,
DATA_TYPE_ID: 1,
ALGO_NAME: 'SimAC (Face Guard)'
ALGO_ID: ALGO_MAP.ANTI_FACE_EDIT,
INTENSITY: 0.05,
DATA_TYPE_ID: DATA_TYPE_MAP.FACE,
ALGO_NAME: 'Anti-Face-Editing'
},
// 防定制生成 -> 推荐使用 Face 数据集(1) + PID(4) (针对扩散模型)
// 防定制生成 -> ID 7
CUSTOM_GEN: {
ALGO_ID: 4,
INTENSITY: 12.0,
DATA_TYPE_ID: 1,
ALGO_NAME: 'PID (Anti-Diffusion)'
ALGO_ID: ALGO_MAP.ANTI_CUSTOMIZE,
INTENSITY: 0.05,
DATA_TYPE_ID: DATA_TYPE_MAP.FACE,
ALGO_NAME: 'Anti-Customization'
}
}

@ -0,0 +1,107 @@
// src/utils/multipartParser.js
/**
* 解析 Multipart/Mixed 响应流
* @param {ArrayBuffer} buffer - 原始二进制数据
* @param {string} boundary - 分隔符
* @returns {Object} - 解析后的图片对象结构适配前端组件
*/
export function parseMultipartMixed(buffer, boundary) {
const decoder = new TextDecoder('utf-8')
const boundaryBytes = new TextEncoder().encode('--' + boundary)
const result = {
images: {
original: [],
perturbed: [],
original_generate: [],
perturbed_generate: [],
uploaded_generate: [],
heatmap: [],
report: []
}
}
// 简单的二进制搜索实现 (也可以使用更高效的算法,但对于图片预览够用了)
const uint8Array = new Uint8Array(buffer)
const parts = []
let lastIndex = 0
// 1. 根据 Boundary 切分 Buffer
while (true) {
const index = findSequence(uint8Array, boundaryBytes, lastIndex)
if (index === -1) break
// 如果不是第一次找到,说明 lastIndex 到 index 之间是一个完整的 part
if (lastIndex > 0) {
// 去掉末尾的 CRLF (\r\n)
parts.push(uint8Array.slice(lastIndex, index - 2))
}
// 跳过 Boundary + CRLF
lastIndex = index + boundaryBytes.length + 2
}
// 2. 解析每个 Part
parts.forEach(part => {
// 查找 Header 和 Body 的分隔符 (\r\n\r\n)
const splitSequence = new TextEncoder().encode('\r\n\r\n')
const splitIndex = findSequence(part, splitSequence, 0)
if (splitIndex !== -1) {
const headerBytes = part.slice(0, splitIndex)
const bodyBytes = part.slice(splitIndex + 4) // +4 跳过 \r\n\r\n
const headerText = decoder.decode(headerBytes)
const headers = parseHeaders(headerText)
// 获取关键元数据
const imageType = headers['x-image-type'] || 'unknown'
const imageId = headers['x-image-id']
const contentType = headers['content-type'] || 'image/png'
// 生成 Blob URL
const blob = new Blob([bodyBytes], { type: contentType })
const url = URL.createObjectURL(blob)
// 构造组件需要的数据结构
const imgObj = {
image_id: imageId,
data: url, // 组件直接使用 src="img.data"
blob: blob // 保留 blob 对象用于后续下载或销毁
}
// 归类
if (result.images[imageType]) {
result.images[imageType].push(imgObj)
}
}
})
return result
}
// 辅助:在 Uint8Array 中查找序列
function findSequence(data, sequence, fromIndex) {
for (let i = fromIndex; i < data.length - sequence.length + 1; i++) {
let found = true
for (let j = 0; j < sequence.length; j++) {
if (data[i + j] !== sequence[j]) {
found = false
break
}
}
if (found) return i
}
return -1
}
// 辅助:解析 Header 字符串
function parseHeaders(headerText) {
const headers = {}
headerText.split('\r\n').forEach(line => {
const [key, ...values] = line.split(':')
if (key && values) {
headers[key.trim().toLowerCase()] = values.join(':').trim()
}
})
return headers
}

@ -57,6 +57,10 @@ service.interceptors.response.use(undefined, (err) => {
// === 响应拦截器 ===
service.interceptors.response.use(
response => {
// 如果配置了 returnRawResponse则返回完整对象包含 headers
if (response.config.returnRawResponse) {
return response
}
return response.data
},
error => {

@ -42,7 +42,7 @@
<div class="icon-circle"><i class="fas fa-smile"></i></div>
<div class="option-text">
<span class="opt-title">通用人脸防护</span>
<span class="opt-desc">ASPL / SimAC / CAAT_Pro</span>
<span class="opt-desc">ASPL / SimAC</span>
</div>
<div class="check-mark" v-if="formData.style === 'face'"><i class="fas fa-check"></i></div>
</div>
@ -66,33 +66,18 @@
<div class="row-group">
<div class="form-group half">
<label>第二步加密算法</label>
<div
v-if="isDropdownOpen"
class="click-outside-overlay"
@click="isDropdownOpen = false"
></div>
<div v-if="isDropdownOpen" class="click-outside-overlay" @click="isDropdownOpen = false"></div>
<div class="custom-select-container">
<div
class="select-trigger"
:class="{ 'is-open': isDropdownOpen }"
@click="isDropdownOpen = !isDropdownOpen"
>
<span :class="{ 'placeholder': !formData.algorithm }">
{{ currentAlgoName }}
</span>
<div class="select-trigger" :class="{ 'is-open': isDropdownOpen }" @click="isDropdownOpen = !isDropdownOpen">
<span :class="{ 'placeholder': !formData.algorithm }">{{ currentAlgoName }}</span>
<i class="fas fa-chevron-down arrow-icon"></i>
</div>
<Transition name="dropdown">
<div v-if="isDropdownOpen" class="select-options">
<div
v-for="algo in currentAvailableAlgorithms"
:key="algo.id"
class="option-item"
:class="{ selected: formData.algorithm === algo.id }"
@click="selectAlgo(algo)"
>
<div v-for="algo in currentAvailableAlgorithms" :key="algo.id"
class="option-item" :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 check-icon"></i>
</div>
@ -101,29 +86,41 @@
</div>
</div>
<!-- 强度调节区域 -->
<div class="form-group half">
<label>扰动强度 (Intensity)</label>
<div class="strength-selector">
<div class="str-item" :class="{ active: formData.strength === 10.0 }" @click="formData.strength = 10.0"> (10.0)</div>
<div class="str-item" :class="{ active: formData.strength === 12.0 }" @click="formData.strength = 12.0"> (12.0)</div>
<div class="str-item" :class="{ active: formData.strength === 14.0 }" @click="formData.strength = 14.0"> (14.0)</div>
<div class="strength-header">
<label>扰动强度</label>
<div class="mode-toggle">
<span :class="{ active: !isCustomMode }" @click="toggleStrengthMode(false)"></span>
<span :class="{ active: isCustomMode }" @click="toggleStrengthMode(true)"></span>
</div>
</div>
<!-- 模式 A: 预设按钮 -->
<div v-if="!isCustomMode" class="strength-selector">
<div class="str-item" :class="{ active: formData.strength === presetLow }" @click="formData.strength = presetLow"></div>
<div class="str-item" :class="{ active: formData.strength === presetMid }" @click="formData.strength = presetMid"></div>
<div class="str-item" :class="{ active: formData.strength === presetHigh }" @click="formData.strength = presetHigh"></div>
</div>
<!-- 模式 B: 自定义滑块 -->
<div v-else>
<IntensitySlider
v-model="formData.strength"
:min="sliderConfig.min"
:max="sliderConfig.max"
:step="sliderConfig.step"
/>
</div>
</div>
</div>
<!-- 4. 上传与提交 (支持多选) -->
<!-- 4. 上传与提交 -->
<div class="upload-section">
<!-- 增加 active-file 用于改变样式 -->
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.files.length > 0 }">
<input type="file" ref="fileInput" @change="handleFileChange" style="display: none" accept="image/*" multiple />
<!-- 如果有文件显示清空按钮 (阻止冒泡防止触发上传点击) -->
<div
v-if="formData.files.length > 0"
class="clear-file-btn"
@click.stop="clearFiles"
title="清空已选文件"
>
<div v-if="formData.files.length > 0" class="clear-file-btn" @click.stop="clearFiles" title="清空">
<i class="fas fa-times"></i>
</div>
@ -156,9 +153,10 @@
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { ref, computed, watch } from 'vue'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { DATA_TYPE_MAP, ALGO_OPTIONS_Data } from '@/utils/constants'
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'
@ -168,14 +166,73 @@ const isDropdownOpen = ref(false)
const isSubmitting = ref(false)
let specificPollTimer = null
const isCustomMode = ref(false)
const formData = ref({
taskName: '',
algorithm: '',
strength: 12.0,
strength: 12.0,
style: 'face',
files: []
})
// === () ===
const algorithmSettings = computed(() => {
const algoId = formData.value.algorithm
// 1. SimAC, ASPL, Anti-Face-Edit ()
// 8 - 16
if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.ANTI_FACE_EDIT].includes(algoId)) {
return {
min: 8,
max: 16,
step: 1,
presets: { low: 8, mid: 12, high: 16 },
default: 12
}
}
// 2. Glaze, PID, Style Protection ()
// 使 0.0x 8-16
else if ([ALGO_MAP.GLAZE, ALGO_MAP.PID, ALGO_MAP.STYLE_PROTECTION].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
}
}
// 3. ( 8-16 )
return {
min: 8,
max: 16,
step: 1,
presets: { low: 8, mid: 12, high: 16 },
default: 12
}
})
const sliderConfig = computed(() => ({
min: algorithmSettings.value.min,
max: algorithmSettings.value.max,
step: algorithmSettings.value.step
}))
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
})
const toggleStrengthMode = (isCustom) => {
isCustomMode.value = isCustom
if (!isCustom) {
formData.value.strength = algorithmSettings.value.presets.mid
}
}
// ... ...
const currentAvailableAlgorithms = computed(() => {
return ALGO_OPTIONS_Data.filter(algo => algo.type === formData.value.style)
})
@ -206,10 +263,9 @@ const handleFileChange = (event) => {
}
}
//
const clearFiles = () => {
formData.value.files = []
if (fileInput.value) fileInput.value.value = '' // input value 便
if (fileInput.value) fileInput.value.value = ''
}
const submitTask = async () => {
@ -243,12 +299,12 @@ const submitTask = async () => {
taskStore.fetchQuota()
if (res.task?.task_id) startSpecificPolling(res.task.task_id)
//
formData.value.taskName = ''
clearFiles()
} catch (error) {
console.error(error)
alert(error.message || '提交失败')
} finally {
isSubmitting.value = false
}
@ -266,23 +322,20 @@ const startSpecificPolling = (taskId) => {
} catch (e) { clearInterval(specificPollTimer) }
}, 3000)
}
onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
</script>
<style scoped>
/* === 基础布局 === */
/* 样式保持不变 */
.subpage-layout { width: 100%; height: 100%; padding: 20px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); overflow-y: auto; }
.layout-grid { display: grid; grid-template-columns: 280px 1fr; gap: 20px; width: 100%; max-width: 1400px; height: 95%; max-height: 95vh; min-height: 0; }
.grid-sidebar { height: 100%; overflow: hidden; }
.grid-main { height: 100%; min-width: 0; }
.grid-main { height: 100%; min-width: 0; display: flex; flex-direction: column; }
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; overflow: hidden; }
.card-header { flex: 0 0 auto; padding: 20px 30px; border-bottom: 1px solid rgba(0,0,0,0.05); }
.subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-top: 5px; }
.card-body { padding: 30px; flex: 1; overflow-y: auto; padding-bottom: 60px; }
.desc-text { color: var(--color-text-main); margin-bottom: 30px; padding: 15px; background: rgba(24, 40, 59, 0.03); border-radius: 4px; border-left: 4px solid var(--color-contrast-dark); }
/* === 表单元素 === */
.form-wrapper { display: flex; flex-direction: column; gap: 25px; }
.form-group label { display: block; font-size: 1rem; font-weight: 600; margin-bottom: 10px; color: var(--color-text-main); }
.ui-input { width: 100%; padding: 14px; border: 1px solid #e0e0e0; border-radius: 10px; font-size: 1rem; background: #f8f9fa; outline: none; transition: all 0.2s; min-width: 0; }
@ -290,82 +343,51 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
.row-group { display: flex; gap: 20px; }
.half { flex: 1; min-width: 0; }
/* === 风格选择器 === */
/* 强度调节头部样式 */
.strength-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.mode-toggle { display: flex; gap: 10px; font-size: 0.8rem; background: #e2e8f0; padding: 2px; border-radius: 6px; }
.mode-toggle span { padding: 2px 8px; border-radius: 4px; cursor: pointer; color: #64748b; transition: all 0.2s; }
.mode-toggle span.active { background: #fff; color: var(--color-contrast-dark); font-weight: 700; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
/* 预设选择器 */
.strength-selector { display: flex; background: #f8f9fa; border-radius: 10px; padding: 4px; border: 1px solid #e0e0e0; }
.str-item { flex: 1; text-align: center; padding: 10px 5px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; transition: all 0.2s; white-space: nowrap; }
.str-item:hover { background: #f1f5f9; }
.str-item.active { background: var(--color-contrast-dark); color: #fff; font-weight: 600; }
.style-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.style-option { position: relative; background: #f8f9fa; border-radius: 16px; padding: 15px; cursor: pointer; display: flex; align-items: center; gap: 15px; border: 2px solid transparent; transition: all 0.2s; }
.style-option.active { background: #fff; border-color: var(--color-contrast-dark); box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
.icon-circle { width: 42px; height: 42px; background: #e9ecef; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; flex-shrink: 0; }
.style-option.active .icon-circle { background: var(--color-contrast-dark); color: #fff; }
.option-text { display: flex; flex-direction: column; min-width: 0; }
.opt-title { font-weight: 700; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opt-desc { font-size: 0.75rem; color: #888; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opt-title { font-weight: 700; font-size: 0.95rem; }
.opt-desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
.check-mark { position: absolute; top: 10px; right: 10px; color: var(--color-contrast-dark); }
.badge { position: absolute; top: -8px; right: 10px; background: linear-gradient(135deg, #FFD166, #FF9F1C); color: var(--color-text-main); padding: 2px 8px; border-radius: 10px; font-size: 0.65rem; font-weight: 800; }
/* === 下拉菜单 === */
.custom-select-container { position: relative; width: 100%; }
.select-trigger { width: 100%; padding: 14px; background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 10px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
.select-trigger.is-open { border-color: var(--color-contrast-dark); background: #fff; }
.select-options { position: absolute; top: 110%; left: 0; width: 100%; background: #fff; border: 1px solid #eee; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); z-index: 100; max-height: 250px; overflow-y: auto; padding: 5px; }
.option-item { padding: 10px 15px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
.option-item:hover { background: #f1f3f5; }
.option-item.selected { background: rgba(24, 40, 59, 0.05); color: var(--color-contrast-dark); font-weight: 600; }
.click-outside-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 90; }
/* === 强度选择 === */
.strength-selector { display: flex; background: #f8f9fa; border-radius: 10px; padding: 4px; border: 1px solid #e0e0e0; }
.str-item { flex: 1; text-align: center; padding: 10px 5px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; transition: all 0.2s; white-space: nowrap; }
.str-item.active { background: var(--color-contrast-dark); color: #fff; font-weight: 600; }
/* === 上传区域 === */
.upload-zone {
position: relative; /* 为绝对定位的关闭按钮提供参考 */
border: 2px dashed #dbe2e8;
border-radius: 16px;
padding: 30px;
text-align: center;
cursor: pointer;
background: #fcfcfc;
transition: all 0.2s;
}
.upload-zone { position: relative; border: 2px dashed #dbe2e8; border-radius: 16px; padding: 30px; text-align: center; cursor: pointer; background: #fcfcfc; transition: all 0.2s; }
.upload-zone:hover { border-color: var(--color-contrast-dark); background: #fff; }
/* 选中后的样式 */
.upload-zone.has-file {
border-style: solid;
border-color: #2e7d32; /* 绿色边框 */
background: #f1f8e9; /* 浅绿背景 */
}
.upload-zone.has-file { border-style: solid; border-color: #2e7d32; background: #f1f8e9; }
.upload-icon { font-size: 2rem; color: var(--color-text-muted); margin-bottom: 10px; }
.success-icon { color: #2e7d32; } /* 成功时的图标颜色 */
.success-icon { color: #2e7d32; }
.file-name { font-weight: 700; color: var(--color-contrast-dark); }
.submit-area { margin-top: 20px; }
.big-btn { width: 100%; height: 50px; font-size: 1.1rem; border-radius: 12px; }
.clear-file-btn { position: absolute; top: 10px; right: 10px; width: 30px; height: 30px; background: rgba(220, 53, 69, 0.1); color: #dc3545; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: all 0.2s; z-index: 10; }
.clear-file-btn:hover { background: #dc3545; color: #fff; transform: scale(1.1); }
/* 清空文件按钮 */
.clear-file-btn {
position: absolute;
top: 10px; right: 10px;
width: 30px; height: 30px;
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
z-index: 10;
}
.clear-file-btn:hover {
background: #dc3545;
color: #fff;
transform: scale(1.1);
}
/* === 响应式适配 === */
@media (max-width: 900px) {
.subpage-layout { padding: 10px; height: auto; min-height: 100vh; align-items: flex-start; }
.layout-grid { grid-template-columns: 1fr; display: flex; flex-direction: column; height: auto; max-height: none; }
.subpage-layout { padding: 10px; align-items: flex-start; }
.layout-grid { grid-template-columns: 1fr; display: flex; flex-direction: column; height: auto; }
.grid-sidebar { display: none; }
.content-card { height: auto; overflow: visible; }
.card-body { overflow: visible; padding: 15px; }

@ -1,4 +1,5 @@
<template>
<!-- template 部分保持不变省略以节省空间 -->
<div class="subpage-layout">
<div class="layout-grid">
<aside class="grid-sidebar">
@ -24,6 +25,25 @@
<input type="text" v-model="formData.taskName" class="ui-input" placeholder="输入任务名称..." />
</div>
<!-- === 动态区域 === -->
<div v-if="subpageType === 'style'" class="form-group">
<label>目标风格 (Target Style) <span style="color:red">*</span></label>
<div v-if="loadingPresets" class="loading-text">...</div>
<div v-else class="style-presets-grid">
<div
v-for="preset in stylePresets"
:key="preset.style_code"
class="preset-card"
:class="{ active: formData.targetStyle === preset.style_code }"
@click="formData.targetStyle = preset.style_code"
>
<div class="preset-name">{{ preset.name }}</div>
<div class="preset-desc">{{ preset.description }}</div>
<i v-if="formData.targetStyle === preset.style_code" class="fas fa-check-circle check-icon"></i>
</div>
</div>
</div>
<div class="row-group">
<div class="form-group half">
<label>防护算法 (定制)</label>
@ -53,12 +73,12 @@
<div class="style-option vip" :class="{ active: currentConfig.DATA_TYPE_ID === 2 }">
<div class="icon-circle"><i class="fas fa-paint-brush"></i></div>
<div class="option-text"><span class="opt-title">艺术风格</span></div>
<span class="badge">Auto</span>
<div class="check-mark" v-if="currentConfig.DATA_TYPE_ID === 2"><i class="fas fa-check"></i></div>
</div>
</div>
</div>
<!-- 多文件上传 -->
<!-- 上传 -->
<div class="upload-section">
<div class="upload-zone" @click="triggerFileUpload" :class="{ 'has-file': formData.files.length > 0 }">
<input type="file" ref="fileInput" @change="handleFileChange" style="display: none" accept="image/*" multiple />
@ -75,9 +95,9 @@
</div>
<div class="submit-area">
<button class="ui-btn gradient rect big-btn" @click="submitTask">
<i class="fas fa-magic"></i>
启动专项防御
<button class="ui-btn gradient rect big-btn" @click="submitTask" :disabled="isSubmitting">
<i class="fas" :class="isSubmitting ? 'fa-spinner fa-spin' : 'fa-magic'"></i>
{{ isSubmitting ? '启动中...' : '启动专项防御' }}
</button>
</div>
</div>
@ -91,20 +111,25 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import TaskSideBar from '@/components/TaskSideBar.vue'
import { useTaskStore } from '@/stores/taskStore'
import { submitPerturbationTask } from '@/api/task'
import { submitPerturbationTask, getStylePresets } from '@/api/task'
import { TOPIC_CONFIG } from '@/utils/constants'
const route = useRoute()
const taskStore = useTaskStore()
const fileInput = ref(null)
const isSubmitting = ref(false)
const stylePresets = ref([])
const loadingPresets = ref(false)
const formData = ref({
taskName: '',
files: [] //
targetStyle: '',
files: []
})
const subpageType = computed(() => route.params.subpage)
@ -127,6 +152,49 @@ const currentConfig = computed(() => {
}
})
// === 1 watch ===
const fetchStylePresets = async () => {
loadingPresets.value = true
stylePresets.value = [] //
try {
const res = await getStylePresets()
// 2HTML(404/500)
if (typeof res === 'string' && res.trim().startsWith('<')) {
throw new Error("后端接口返回了 HTML 页面而非 JSON 数据 (可能是接口未部署)")
}
if (res && Array.isArray(res.presets) && res.presets.length > 0) {
stylePresets.value = res.presets
} else {
throw new Error("后端返回的 presets 列表为空")
}
} catch (error) {
console.warn('>>> 进入兜底逻辑,原因:', error.message || error)
// 使 Mock
stylePresets.value = [
{ style_code: "van_gogh", name: "梵高印象派", description: "模仿梵高的印象派绘画风格" },
{ style_code: "kandinsky", name: "康定斯基抽象派", description: "模仿康定斯基的抽象艺术风格" },
{ style_code: "picasso", name: "毕加索立体派", description: "模仿毕加索的立体主义风格" },
{ style_code: "baroque", name: "巴洛克风格", description: "经典巴洛克艺术风格" }
]
} finally {
loadingPresets.value = false
}
}
// === 1watch fetchStylePresets ===
watch(subpageType, (newVal) => {
if (newVal === 'style') {
fetchStylePresets()
} else {
stylePresets.value = []
formData.value.targetStyle = ''
}
}, { immediate: true })
const triggerFileUpload = () => fileInput.value.click()
const handleFileChange = (event) => {
@ -140,8 +208,13 @@ const submitTask = async () => {
if (formData.value.files.length === 0) return alert('请先上传图片')
if (!formData.value.taskName) return alert('请填写任务名称')
if (subpageType.value === 'style' && !formData.value.targetStyle) {
return alert('请选择一种目标风格 (Target Style)')
}
if (taskStore.quota.remaining_tasks <= 0) return alert('剩余任务配额不足')
isSubmitting.value = true
const payload = new FormData()
payload.append('data_type_id', currentConfig.value.DATA_TYPE_ID)
@ -150,7 +223,10 @@ const submitTask = async () => {
payload.append('description', formData.value.taskName)
payload.append('perturbation_name', `Topic-${subpageType.value}-${currentConfig.value.ALGO_NAME}`)
//
if (formData.value.targetStyle) {
payload.append('target_style', formData.value.targetStyle)
}
formData.value.files.forEach(file => {
payload.append('files', file)
})
@ -160,14 +236,23 @@ const submitTask = async () => {
alert(res.message || '专题防护任务已启动')
taskStore.fetchTasks()
taskStore.fetchQuota()
formData.value.taskName = ''
formData.value.files = []
formData.value.targetStyle = ''
if (fileInput.value) fileInput.value.value = ''
} catch (error) {
console.error(error)
alert(error.message || '任务提交失败')
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
/* === 基础布局 === */
/* 样式保持不变 */
.subpage-layout { width: 100%; height: 100%; padding: 40px; display: flex; justify-content: center; align-items: center; background: var(--color-bg-primary); overflow-y: auto; }
.layout-grid { display: grid; grid-template-columns: 300px 1fr; gap: 30px; width: 100%; max-width: 1400px; height: 95%; max-height: 95vh; min-height: 0; }
.grid-sidebar { height: 100%; overflow: hidden; display: block; }
@ -175,23 +260,16 @@ const submitTask = async () => {
.content-card { height: 100%; display: flex; flex-direction: column; padding: 0; background: #ffffff; overflow: hidden; }
.card-header { padding: 30px 40px; border-bottom: 1px solid rgba(0,0,0,0.05); flex: 0 0 auto; }
.subtitle { color: var(--color-text-muted); font-size: 0.9rem; margin-top: 5px; }
.tag { background: var(--color-contrast-dark); color: #fff; padding: 6px 16px; border-radius: 20px; font-weight: 700; font-size: 0.8rem; }
.card-body { padding: 40px; flex: 1; overflow-y: auto; }
.desc-text { color: var(--color-text-main); margin-bottom: 40px; padding: 15px 20px; background: rgba(24, 40, 59, 0.03); border-left: 4px solid var(--color-contrast-dark); border-radius: 4px; }
/* === 表单 === */
.form-wrapper { display: flex; flex-direction: column; gap: 30px; }
.form-group label { display: block; font-size: 1rem; font-weight: 600; margin-bottom: 12px; color: var(--color-text-main); }
.ui-input { width: 100%; padding: 16px; border: 1px solid #e0e0e0; border-radius: 12px; font-size: 1rem; background: #f8f9fa; outline: none; transition: all 0.2s; min-width: 0; }
.ui-input:focus { background: #fff; border-color: var(--color-contrast-dark); }
.row-group { display: flex; gap: 30px; }
.half { flex: 1; min-width: 0; }
/* === 只读字段 === */
.readonly-field { width: 100%; padding: 16px; background: #f1f3f5; border: 1px dashed #ced4da; border-radius: 12px; color: var(--color-text-muted); display: flex; align-items: center; gap: 10px; cursor: not-allowed; user-select: none; min-width: 0; }
.lock-icon { font-size: 0.9rem; color: #adb5bd; flex-shrink: 0; }
/* === 风格选择 === */
.style-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.style-option { position: relative; background: #f8f9fa; border-radius: 16px; padding: 20px; cursor: default; display: flex; align-items: center; gap: 15px; border: 2px solid transparent; transition: all 0.2s; opacity: 0.6; filter: grayscale(1); }
.style-option.active { background: #fff; border-color: var(--color-contrast-dark); opacity: 1; filter: grayscale(0); box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
@ -199,9 +277,6 @@ const submitTask = async () => {
.style-option.active .icon-circle { background: var(--color-contrast-dark); color: #fff; }
.opt-title { font-weight: 700; white-space: nowrap; }
.check-mark { position: absolute; top: 15px; right: 15px; color: var(--color-contrast-dark); }
.badge { position: absolute; top: -10px; right: 15px; background: linear-gradient(135deg, #FFD166, #FF9F1C); color: var(--color-text-main); padding: 4px 10px; border-radius: 10px; font-size: 0.7rem; font-weight: 800; }
/* === 上传 === */
.upload-zone { border: 2px dashed #dbe2e8; border-radius: 16px; padding: 30px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fcfcfc; }
.upload-zone:hover { border-color: var(--color-contrast-dark); background: rgba(0,0,0,0.01); }
.upload-zone.has-file { border-style: solid; border-color: var(--color-contrast-dark); background: #fff; }
@ -211,18 +286,57 @@ const submitTask = async () => {
.submit-area { margin-top: 20px; }
.big-btn { width: 100%; height: 60px; font-size: 1.2rem; border-radius: 16px; }
/* === 响应式适配 (High Zoom / Mobile) === */
/* === 风格预设网格 === */
.style-presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
}
.preset-card {
background: #f8f9fa;
border: 2px solid transparent;
border-radius: 12px;
padding: 15px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.preset-card:hover {
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
.preset-card.active {
background: #fff;
border-color: var(--color-accent-secondary);
box-shadow: 0 4px 12px rgba(255, 159, 28, 0.2);
}
.preset-name {
font-weight: 700;
font-size: 0.95rem;
margin-bottom: 5px;
color: var(--color-contrast-dark);
}
.preset-desc {
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.4;
}
.check-icon {
position: absolute;
top: 10px;
right: 10px;
color: var(--color-accent-secondary);
}
.loading-text { font-size: 0.9rem; color: #999; font-style: italic; }
@media (max-width: 900px) {
.subpage-layout { padding: 15px; height: auto; min-height: 100vh; align-items: flex-start; }
.subpage-layout { padding: 15px; align-items: flex-start; }
.layout-grid { grid-template-columns: 1fr; display: flex; flex-direction: column; height: auto; max-height: none; gap: 15px; }
/* 在手机端隐藏侧边栏,或可以设为 order: 2 放到下面 */
.grid-sidebar { display: none !important; }
.content-card { height: auto; overflow: visible; }
.card-body { overflow: visible; padding: 20px; }
.row-group { flex-direction: column; gap: 15px; }
.style-selector { grid-template-columns: 1fr; gap: 10px; }
.style-presets-grid { grid-template-columns: 1fr; }
}
</style>

@ -8,7 +8,7 @@
<p class="page-desc">Task History Management</p>
</div>
<!-- 状态筛选 -->
<!-- 状态筛选 Tab (保留原有的状态切换) -->
<div class="filter-tabs">
<div v-for="tab in statusTabs" :key="tab.key" class="tab-item"
:class="{ active: currentStatus === tab.key }"
@ -20,85 +20,124 @@
<!-- 主内容区 -->
<div class="main-content">
<div class="ui-card solid table-card">
<!-- 桌面表头 (移动端隐藏) -->
<div class="ui-card modern-table-card">
<!-- 新增工具栏搜索与类型筛选 -->
<div class="toolbar-section">
<div class="search-box">
<i class="fas fa-search search-icon"></i>
<input
type="text"
v-model="searchKeyword"
placeholder="搜索任务ID或名称..."
class="search-input"
/>
</div>
<div class="type-filter">
<i class="fas fa-filter filter-icon"></i>
<select v-model="selectedTaskType" class="filter-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>
</div>
<!-- 桌面表头 (汉化) -->
<div class="list-header-row desktop-only-flex">
<div class="col-id sortable" @click="handleSort('task_id', $event)">ID <i :class="getSortIcon('task_id')"></i></div>
<div class="col-info sortable" @click="handleSort('name', $event)">任务名称 <i :class="getSortIcon('name')"></i></div>
<div class="col-type sortable" @click="handleSort('task_type', $event)">类型 <i :class="getSortIcon('task_type')"></i></div>
<div class="col-time sortable" @click="handleSort('created_at', $event)">时间 <i :class="getSortIcon('created_at')"></i></div>
<div class="col-status sortable" @click="handleSort('status', $event)">状态 <i :class="getSortIcon('status')"></i></div>
<div class="col-id sortable" @click="handleSort('task_id', $event)">
ID <i :class="getSortIcon('task_id')"></i>
</div>
<div class="col-info sortable" @click="handleSort('name', $event)">
任务名称 <i :class="getSortIcon('name')"></i>
</div>
<div class="col-type sortable" @click="handleSort('task_type', $event)">
类型 <i :class="getSortIcon('task_type')"></i>
</div>
<div class="col-time sortable" @click="handleSort('created_at', $event)">
创建时间 <i :class="getSortIcon('created_at')"></i>
</div>
<div class="col-status sortable" @click="handleSort('status', $event)">
状态 <i :class="getSortIcon('status')"></i>
</div>
<div class="col-action">操作</div>
</div>
<!-- 列表内容 -->
<div class="list-body">
<div v-if="paginatedTasks.length === 0" class="empty-state"></div>
<div v-if="paginatedTasks.length === 0" class="empty-state">
<i class="fas fa-inbox"></i>
<p>暂无符合条件的任务</p>
</div>
<div v-for="task in paginatedTasks" :key="task.task_id" class="list-row">
<!-- === 1. 移动端/高缩放 专用结构 (默认隐藏) === -->
<!-- === 移动端/窄屏视图 === -->
<div class="mobile-only-block">
<div class="mobile-row-header">
<span class="id-tag">#{{ task.task_id }}</span>
<span class="status-badge-mobile" :class="normalizeStatus(task.status)">
<span class="id-badge">#{{ task.task_id }}</span>
<span class="status-pill" :class="normalizeStatus(task.status)">
{{ formatStatusLabel(task.status) }}
</span>
</div>
<!-- 移动端名称 -->
<div class="mobile-task-name">{{ getTaskName(task) }}</div>
<div class="mobile-meta-row">
<span class="type-badge">{{ task.task_type }}</span>
<span class="type-tag">{{ formatTypeLabel(task.task_type) }}</span>
<span class="time-text">{{ formatDate(task.created_at) }}</span>
</div>
</div>
<!-- === 2. 桌面端 专用列 (移动端隐藏) === -->
<!-- 必须严格对应 Grid 的列顺序 -->
<!-- Col 1: ID -->
<!-- === 桌面端列 === -->
<div class="col-id desktop-only-cell">
<span class="id-tag">#{{ task.task_id }}</span>
<span class="id-text">#{{ task.task_id }}</span>
</div>
<!-- Col 2: Info (名称) -->
<div class="col-info desktop-only-cell">
<span class="task-name">{{ getTaskName(task) }}</span>
</div>
<!-- Col 3: Type -->
<div class="col-type desktop-only-cell">
<span class="type-badge">{{ task.task_type }}</span>
<span class="type-tag">{{ formatTypeLabel(task.task_type) }}</span>
</div>
<!-- Col 4: Time -->
<div class="col-time desktop-only-cell">
{{ formatDate(task.created_at) }}
<span class="time-text">{{ formatDate(task.created_at) }}</span>
</div>
<!-- Col 5: Status -->
<div class="col-status desktop-only-cell">
<span class="status-dot" :class="normalizeStatus(task.status)"></span>
{{ formatStatusLabel(task.status) }}
<span class="status-pill" :class="normalizeStatus(task.status)">
{{ formatStatusLabel(task.status) }}
</span>
</div>
<!-- Col 6: Action (共用但样式微调) -->
<!-- 操作按钮 -->
<div class="col-action">
<button
v-if="userStore.role === 'admin'"
class="action-btn"
title="查看日志"
@click="handleViewLogs(task)"
>
<i class="fas fa-terminal"></i>
</button>
<button
v-if="['pending','waiting','processing','running'].includes(task.status)"
class="ui-btn glass sm icon-only danger"
class="action-btn danger"
title="取消任务"
@click="handleCancel(task)"
>
<i class="fas fa-stop"></i>
</button>
<button v-if="task.status === 'completed'" class="ui-btn glass sm icon-only" title="预览结果" @click="handlePreview(task)">
<button v-if="task.status === 'completed'" class="action-btn primary" title="预览结果" @click="handlePreview(task)">
<i class="fas fa-eye"></i>
</button>
<button v-if="task.status === 'completed'" class="ui-btn glass sm icon-only" title="下载结果" @click="handleDownload(task)">
<button v-if="task.status === 'completed'" class="action-btn success" title="下载结果" @click="handleDownload(task)">
<i class="fas fa-download"></i>
</button>
</div>
@ -107,9 +146,13 @@
<!-- 分页器 -->
<div class="pagination-footer">
<button class="page-btn" :disabled="currentPage <= 1" @click="currentPage--"><i class="fas fa-chevron-left"></i></button>
<span class="page-info">{{ currentPage }} / {{ totalPages || 1 }} </span>
<button class="page-btn" :disabled="currentPage >= totalPages" @click="currentPage++"><i class="fas fa-chevron-right"></i></button>
<button class="page-btn" :disabled="currentPage <= 1" @click="currentPage--">
<i class="fas fa-chevron-left"></i>
</button>
<span class="page-info"> {{ currentPage }} / {{ totalPages || 1 }} </span>
<button class="page-btn" :disabled="currentPage >= totalPages" @click="currentPage++">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
@ -124,15 +167,23 @@
<!-- 日志弹窗 -->
<Teleport to="body">
<div v-if="showLogModal" class="log-overlay" @click.self="showLogModal = false">
<div v-if="showLogModal" class="log-overlay" @click.self="showLogModal = false" @wheel.stop @touchmove.stop>
<div class="ui-card solid log-card">
<div class="log-header">
<h3>运行日志 Task #{{ currentLogTaskId }}</h3>
<div class="log-title-group">
<i class="fas fa-terminal"></i>
<h3>系统日志 Task #{{ currentLogTaskId }}</h3>
</div>
<button class="close-btn" @click="showLogModal = false"><i class="fas fa-times"></i></button>
</div>
<div class="log-content">
<pre v-if="logContent">{{ logContent }}</pre>
<div v-else class="loading-log"><i class="fas fa-spinner fa-spin"></i> 加载日志中...</div>
<div class="log-body-wrapper">
<div class="log-content allow-scroll">
<pre v-if="logContent">{{ logContent }}</pre>
<div v-else class="loading-log">
<i class="fas fa-circle-notch fa-spin"></i>
<span>正在加载日志...</span>
</div>
</div>
</div>
</div>
</div>
@ -144,12 +195,19 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore'
import { getTaskResultImages, cancelTask, getTaskLogs } from '@/api/task'
import JSZip from 'jszip'
import ImagePreviewModal from '@/components/ImagePreviewModal.vue'
const taskStore = useTaskStore()
const userStore = useUserStore()
//
const currentStatus = ref('all')
const searchKeyword = ref('') //
const selectedTaskType = ref('all') //
const statusTabs = [
{ key: 'all', label: '全部' },
{ key: 'running', label: '进行中' },
@ -161,19 +219,38 @@ const currentPage = ref(1)
const pageSize = ref(10)
const showPreview = ref(false)
const previewTaskId = ref(null)
const previewTaskType = ref('')
//
const showLogModal = ref(false)
const currentLogTaskId = ref(null)
const logContent = ref('')
const previewTaskType = ref('')
const normalizeStatus = (status) => {
if (['pending', 'processing', 'waiting', 'running'].includes(status)) return 'running'
return status
}
//
const formatStatusLabel = (s) => {
const map = { running: '运行中', processing: '处理中', pending: '排队中', waiting: '排队中', completed: '已完成', failed: '失败' }
const map = {
running: '运行中', processing: '处理中', pending: '排队中', waiting: '排队中',
completed: '已完成', failed: '失败'
}
return map[s] || s
}
//
const formatTypeLabel = (t) => {
const map = {
perturbation: '通用防护',
finetune: '微调验证',
evaluate: '数据评估',
heatmap: '热力图'
}
return map[t] || t
}
const getTaskName = (t) => {
if (t.description && t.description.trim()) return t.description
if (t.task_type === 'finetune' && t.finetune?.finetune_name) return t.finetune.finetune_name
@ -186,11 +263,33 @@ const formatDate = (iso) => iso ? new Date(iso).toLocaleString('zh-CN', { hour12
const handleStatusChange = (status) => { currentStatus.value = status; currentPage.value = 1 }
// === ===
const filteredAndSortedTasks = computed(() => {
let result = [...(taskStore.tasks || [])]
// 1.
if (currentStatus.value !== 'all') {
result = result.filter(t => normalizeStatus(t.status) === currentStatus.value)
}
// 2. ()
if (selectedTaskType.value !== 'all') {
result = result.filter(t => t.task_type === selectedTaskType.value)
}
// 3. ()
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase().trim()
result = result.filter(t => {
// ID
const idMatch = String(t.task_id).includes(keyword)
//
const nameMatch = getTaskName(t).toLowerCase().includes(keyword)
return idMatch || nameMatch
})
}
// 4.
if (sortRules.value.length > 0) {
result.sort((a, b) => {
const rule = sortRules.value[0]
@ -239,6 +338,23 @@ const handleCancel = async (task) => {
}
}
const handleViewLogs = async (task) => {
currentLogTaskId.value = task.task_id
logContent.value = ''
showLogModal.value = true
try {
const res = await getTaskLogs(task.task_id)
if (res && res.logs) {
logContent.value = res.logs
} else {
logContent.value = '暂无日志信息'
}
} catch (e) {
logContent.value = '获取日志失败: ' + e.message
}
}
const handleDownload = async (task) => {
if (task.status !== 'completed') return alert('任务未完成')
try {
@ -266,129 +382,316 @@ const handleDownload = async (task) => {
link.href = url
link.download = `task_${task.task_id}.zip`
link.click()
} catch (e) { alert('下载出错') }
} catch (e) { alert('下载出错: ' + e.message) }
}
onMounted(() => taskStore.fetchTasks())
</script>
<style scoped>
/* === 1. 基础布局 === */
/*
------------------------------------------
Layout & Container
------------------------------------------
*/
.view-container {
width: 100%;
/* margin: auto 配合 flex column在高度足够时居中不足时置顶 */
margin: auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 40px 20px 120px 20px;
}
.header-section { flex: 0 0 auto; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 2px solid #eee; padding-bottom: 10px; }
.page-header { font-size: 2.5rem; margin: 0; line-height: 1; color: var(--color-contrast-dark); }
.page-desc { color: #999; margin-top: 5px; }
.filter-tabs { display: flex; background: rgba(255,255,255,0.5); padding: 4px; border-radius: 12px; gap: 5px; flex-wrap: wrap;}
.tab-item { padding: 8px 20px; border-radius: 8px; font-size: 0.9rem; color: #999; cursor: pointer; font-weight: 600; white-space: nowrap; }
.tab-item.active { background: var(--color-contrast-dark); }
width: 100%; margin: auto; display: flex; flex-direction: column;
box-sizing: border-box; padding: 40px 20px 120px 20px;
}
.header-section {
flex: 0 0 auto; margin-bottom: 30px;
display: flex; justify-content: space-between; align-items: flex-end;
}
.page-header {
font-size: 2.2rem; margin: 0; line-height: 1.1;
color: var(--color-contrast-dark); letter-spacing: -0.5px;
}
.page-desc { color: #94a3b8; margin-top: 6px; font-weight: 500; }
.filter-tabs {
display: flex; background: rgba(255,255,255,0.6);
padding: 5px; border-radius: 12px; gap: 5px;
box-shadow: 0 4px 15px rgba(0,0,0,0.03);
backdrop-filter: blur(10px);
}
.tab-item {
padding: 8px 20px; border-radius: 8px; font-size: 0.9rem;
color: #64748b; cursor: pointer; font-weight: 600;
transition: all 0.2s ease;
}
.tab-item:hover { color: var(--color-contrast-dark); background: rgba(0,0,0,0.03); }
.tab-item.active {
background: var(--color-contrast-dark); color: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
}
.main-content { flex: 1; width: 100%; max-width: 1400px; margin: 0 auto; }
.table-card { height: auto; min-height: 400px; display: flex; flex-direction: column; padding: 0; background: #fff; }
/* === 2. 桌面端 Grid 布局 (默认) === */
.list-header-row, .list-row {
.modern-table-card {
background: #ffffff;
border-radius: 20px;
box-shadow: 0 20px 40px -5px rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.02);
display: flex; flex-direction: column;
min-height: 500px;
overflow: hidden;
}
/*
------------------------------------------
Toolbar (New)
------------------------------------------
*/
.toolbar-section {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 15px;
padding: 20px 30px 10px 30px;
border-bottom: 1px dashed #f1f5f9;
}
.search-box {
position: relative;
width: 300px;
}
.search-icon {
position: absolute; top: 50%; left: 12px; transform: translateY(-50%);
color: #94a3b8; font-size: 0.9rem;
}
.search-input {
width: 100%; padding: 10px 10px 10px 35px;
border: 1px solid #e2e8f0; border-radius: 8px;
font-size: 0.9rem; color: #334155;
transition: all 0.2s; background: #f8fafc;
}
.search-input:focus {
background: #fff; border-color: var(--color-accent-secondary);
outline: none; box-shadow: 0 0 0 3px rgba(255, 159, 28, 0.1);
}
.type-filter {
position: relative;
}
.filter-icon {
position: absolute; top: 50%; left: 12px; transform: translateY(-50%);
color: #94a3b8; font-size: 0.9rem; pointer-events: none;
}
.filter-select {
padding: 10px 30px 10px 35px;
border: 1px solid #e2e8f0; border-radius: 8px;
font-size: 0.9rem; color: #334155;
background: #f8fafc; cursor: pointer;
appearance: none; /* remove default arrow */
}
.filter-select:focus {
background: #fff; border-color: var(--color-accent-secondary);
outline: none;
}
/*
------------------------------------------
Table Header & Body
------------------------------------------
*/
.list-header-row {
display: grid;
/* 严格的 6 列定义 */
grid-template-columns: 80px 1.5fr 100px 180px 120px 160px;
padding: 12px 20px;
grid-template-columns: 80px 2fr 120px 180px 140px 160px;
padding: 20px 30px;
align-items: center;
border-bottom: 1px solid #f1f1f1;
background: transparent;
border-bottom: 2px solid #e2e8f0; /* 加深线条 */
font-size: 0.9rem;
font-weight: 700;
color: #64748b;
}
.list-header-row {
background: #f8f9fa; font-weight: 700; color: #666; border-bottom: 1px solid #eee;
padding: 15px 20px;
.list-body { flex: 1; overflow-y: auto; padding: 0; }
.empty-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 300px; color: #cbd5e1; font-size: 1rem;
}
.empty-state i { font-size: 3rem; margin-bottom: 15px; opacity: 0.5; }
/*
------------------------------------------
Table Rows (Clean Style)
------------------------------------------
*/
.list-row {
display: grid;
grid-template-columns: 80px 2fr 120px 180px 140px 160px;
padding: 18px 30px;
align-items: center;
/* 分隔线设计 */
border-bottom: 1px solid #e2e8f0;
background: #fff;
transition: background-color 0.2s;
}
/* 移除悬浮特效,仅保留背景色微调 */
.list-row:hover {
background-color: #f8fafc; /* 极淡的灰色 */
/* transform: none; box-shadow: none; removed */
}
.id-text { font-family: monospace; color: #94a3b8; font-weight: 600; }
.task-name {
font-weight: 600; color: #334155; font-size: 0.95rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
padding-right: 20px;
}
.time-text { color: #64748b; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.type-tag {
display: inline-block; padding: 4px 10px; border-radius: 6px;
font-size: 0.75rem; font-weight: 600;
background: #f1f5f9; color: #475569;
border: 1px solid #e2e8f0;
}
.list-body { overflow: visible; padding: 0 10px; }
.list-row:hover { background: #fcfcfc; }
/* Status Pills */
.status-pill {
display: inline-flex; align-items: center; justify-content: center;
padding: 4px 12px; border-radius: 20px;
font-size: 0.75rem; font-weight: 700;
min-width: 80px;
}
.status-pill.completed { background: #dcfce7; color: #166534; }
.status-pill.running { background: #e0f2fe; color: #075985; }
.status-pill.failed { background: #fee2e2; color: #991b1b; }
/* 通用元素样式 */
/* Actions */
.col-action { display: flex; justify-content: flex-end; gap: 8px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.status-dot.running { background: var(--color-accent-secondary); box-shadow: 0 0 5px var(--color-accent-secondary); }
.status-dot.completed { background: #2e7d32; }
.status-dot.failed { background: #c62828; }
.type-badge { font-size: 0.8rem; background: rgba(255, 159, 28, 0.1); color: var(--color-accent-secondary); padding: 2px 6px; border-radius: 4px; }
.id-tag { background: #eee; padding: 2px 6px; border-radius: 4px; font-weight: bold; color: #666; font-size: 0.8rem; }
.task-name { font-weight: 600; font-size: 0.95rem; }
.ui-btn.sm { padding: 6px 12px; font-size: 0.9rem; }
.ui-btn.icon-only { width: 32px; height: 32px; padding: 0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; }
.ui-btn.icon-only.danger { color: #dc3545; background: rgba(220, 53, 69, 0.1); }
.ui-btn.icon-only.danger:hover { background: #dc3545; color: #fff; }
.pagination-footer { padding: 20px; display: flex; justify-content: center; align-items: center; border-top: 1px solid #eee; margin-top: auto; }
.page-btn { background: none; border: 1px solid #ddd; width: 30px; height: 30px; border-radius: 4px; cursor: pointer; margin: 0 10px; display: flex; align-items: center; justify-content: center; }
.sort-icon { font-size: 0.8rem; margin-left: 4px; }
.action-btn {
width: 34px; height: 34px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
border: none; cursor: pointer; transition: all 0.2s;
background: transparent; color: #94a3b8;
}
.action-btn:hover { background: #f1f5f9; color: #334155; }
.action-btn.primary:hover { background: #e0f2fe; color: #0284c7; }
.action-btn.success:hover { background: #dcfce7; color: #16a34a; }
.action-btn.danger:hover { background: #fee2e2; color: #dc2626; }
/* Sorting */
.sortable { cursor: pointer; user-select: none; transition: color 0.2s; }
.sortable:hover { color: #334155; }
.sort-icon { margin-left: 5px; font-size: 0.7rem; }
.sort-icon.active { color: var(--color-accent-secondary); }
.sort-icon.dim { opacity: 0.2; }
/* === 3. 显隐控制类 === */
.mobile-only-block { display: none; } /* 默认隐藏移动端块 */
.sort-icon.dim { opacity: 0.3; }
/* Pagination */
.pagination-footer {
padding: 20px 30px;
display: flex; justify-content: flex-end; align-items: center;
border-top: 1px solid #e2e8f0; /* 分隔线 */
background: #fff;
}
.page-info { margin: 0 15px; color: #64748b; font-size: 0.9rem; font-weight: 500; }
.page-btn {
width: 36px; height: 36px; border-radius: 8px;
border: 1px solid #e2e8f0; background: #fff; color: #64748b;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) { border-color: #cbd5e1; color: #334155; background: #f8fafc; }
.page-btn:disabled { opacity: 0.5; cursor: not-allowed; border-color: #f1f5f9; }
/*
------------------------------------------
Mobile Adaptation
------------------------------------------
*/
.mobile-only-block { display: none; }
.desktop-only-flex { display: grid; }
.desktop-only-cell { display: block; } /* Grid 单元格默认显示 */
.desktop-only-cell { display: block; }
/* === 4. 高缩放/窄屏适配 (宽度 < 900px) === */
@media (max-width: 900px) {
.view-container {
padding-top: 60px;
}
.view-container { padding: 80px 15px 100px; }
.header-section { flex-direction: column; align-items: flex-start; gap: 15px; }
.filter-tabs { width: 100%; overflow-x: auto; padding-bottom: 5px; }
/* 隐藏桌面端元素 */
.desktop-only-flex, .desktop-only-cell { display: none !important; }
/* 显示移动端元素 */
.mobile-only-block { display: block; }
.filter-tabs { width: 100%; overflow-x: auto; padding-bottom: 5px; -webkit-overflow-scrolling: touch; }
/* 移动端隐藏工具栏的部分样式,或者调整布局 */
.toolbar-section { flex-direction: column; align-items: stretch; padding: 15px; }
.search-box { width: 100%; }
.list-header-row, .desktop-only-cell { display: none !important; }
.mobile-only-block { display: block; width: 100%; }
/* 将每一行改为 Flex Column 卡片式布局 */
.list-body { padding: 0; }
.list-row {
display: flex;
flex-direction: column;
gap: 10px;
background: #f8f9fa;
margin-bottom: 15px;
border-radius: 12px;
padding: 15px;
border: 1px solid #eee;
display: flex; flex-direction: column; gap: 12px;
align-items: stretch;
padding: 20px;
margin-bottom: 0;
border-bottom: 10px solid #f1f5f9; /* 移动端用粗线条分隔 */
}
/* 移动端内部布局 */
.mobile-row-header {
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px dashed #e0e0e0; padding-bottom: 8px; margin-bottom: 5px;
.mobile-row-header { display: flex; justify-content: space-between; align-items: center; }
.id-badge { background: #f1f5f9; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; color: #64748b; font-weight: 700; }
.mobile-task-name { font-size: 1.1rem; font-weight: 700; color: #1e293b; margin: 5px 0; }
.mobile-meta-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; color: #94a3b8; }
.col-action {
margin-top: 10px; padding-top: 15px; border-top: 1px dashed #e2e8f0;
justify-content: space-between;
}
.status-badge-mobile { display: inline-block; font-size: 0.8rem; padding: 2px 8px; border-radius: 99px; font-weight: 600; }
.status-badge-mobile.running { background: rgba(255, 159, 28, 0.1); color: var(--color-accent-secondary); }
.status-badge-mobile.completed { background: #e8f5e9; color: #2e7d32; }
.status-badge-mobile.failed { background: #ffebee; color: #c62828; }
.mobile-task-name { font-size: 1.1rem; display: block; margin-bottom: 5px; font-weight: 600; color: var(--color-contrast-dark); }
.mobile-meta-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; color: #888; }
.col-action { margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }
.ui-btn.icon-only { width: 40px; height: 40px; font-size: 1.1rem; }
.action-btn { width: 44px; height: 44px; font-size: 1.1rem; background: #f8fafc; }
}
/* 弹窗样式 */
.log-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 2000; display: flex; justify-content: center; align-items: center; }
.log-card { width: 600px; height: 500px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; background: #fff; border-radius: 12px; }
.log-header { padding: 15px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; font-weight: bold; }
.log-content { flex: 1; padding: 20px; overflow-y: auto; background: #1e1e1e; color: #0f0; font-family: monospace; font-size: 0.9rem; margin: 0; }
.log-content pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
.loading-log { color: #888; text-align: center; margin-top: 50px; }
.close-btn { background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #999; }
/*
------------------------------------------
Log Modal (Dark Theme - VS Code Style)
------------------------------------------
*/
.log-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
z-index: 2000;
display: flex; justify-content: center; align-items: center;
}
.log-card {
width: 900px; height: 80vh; max-width: 95vw;
display: flex; flex-direction: column;
background: #1e1e2e; border-radius: 12px; overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
border: 1px solid #333;
}
.log-header {
padding: 15px 20px; background: #252535; border-bottom: 1px solid #333;
display: flex; justify-content: space-between; align-items: center;
color: #e0e0e0; flex-shrink: 0;
}
.log-title-group { display: flex; align-items: center; gap: 10px; }
.log-title-group i { color: var(--color-accent-secondary); }
.log-header h3 { margin: 0; font-size: 1rem; font-family: sans-serif; letter-spacing: 0.5px; }
.close-btn {
background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #888;
transition: color 0.2s;
}
.close-btn:hover { color: #fff; }
.log-body-wrapper { flex: 1; position: relative; overflow: hidden; background: #1e1e2e; }
.log-content {
width: 100%; height: 100%; padding: 20px;
overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
.log-content::-webkit-scrollbar { width: 10px; }
.log-content::-webkit-scrollbar-track { background: #1e1e2e; }
.log-content::-webkit-scrollbar-thumb { background-color: #444; border-radius: 5px; border: 2px solid #1e1e2e; }
.log-content pre {
margin: 0; white-space: pre-wrap; word-break: break-all;
font-family: 'Consolas', 'Monaco', 'JetBrains Mono', monospace;
font-size: 0.9rem; line-height: 1.6; color: #d4d4d4;
}
.loading-log {
height: 100%; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 15px; color: #888;
}
.loading-log i { font-size: 2rem; color: var(--color-accent-secondary); }
</style>
Loading…
Cancel
Save