feat: 会员功能bug修复

pull/52/head
yyx 4 months ago
parent dab0f638f9
commit c60c66cc13

@ -2,23 +2,18 @@ import request from '@/utils/request'
import { parseMultipartMixed } from '@/utils/multipartParser'
/**
* 获取任务的图片预览数据 (二进制流版本)
* 获取任务的所有图片数据 (二进制流版本)
* 用于预览和下载
* API: GET /api/image/binary/task/<task_id>
*/
export async function getTaskImagePreview(taskId) {
// 1. 发起请求,获取原始 Response 对象以读取 Header
// 注意:这里需要 axios 返回完整的 response不仅仅是 data
// 或者是利用 axios 的 transformResponse但为了简单我们在调用层处理
export async function getTaskImages(taskId) {
try {
const response = await request({
url: `/image/binary/task/${taskId}`,
method: 'get',
responseType: 'arraybuffer', // 关键:必须接收二进制
responseType: 'arraybuffer', // 接收二进制
timeout: 120000,
// 告诉拦截器需要完整响应对象(如果 request.js 拦截器只返回 response.data这里可能需要调整)
// 假设目前的 request.js 拦截器只返回 response.data我们需要针对这个接口做特殊处理
// 或者可以直接使用 axios.get 绕过拦截器,带上 token
// 告诉拦截器需要完整响应对象以读取 Header
returnRawResponse: true
})

@ -53,19 +53,6 @@ export function getStylePresets() {
})
}
/**
* 获取任务结果图片
* 用于 Page4 下载结果功能数据量可能很大 (Base64)
* 设置 5 分钟超时
*/
export function getTaskResultImages(taskType, taskId) {
return request({
url: `/image/${taskType}/${taskId}`,
method: 'get',
// 【核心修改】设置 2 分钟超时 (120000ms)
timeout: 120000
})
}
/**
* 获取任务日志 (新增)

@ -1,5 +1,6 @@
<template>
<div ref="containerRef" :class="[className, 'kt-grid-distortion-bg']" />
<!-- 添加 kt-fallback-bg 类作为 WebGL 失败时的后备方案 -->
<div ref="containerRef" :class="[className, 'kt-grid-distortion-bg', 'kt-fallback-bg']" />
</template>
<script setup>
@ -9,17 +10,16 @@ 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 } //
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() {
@ -28,7 +28,6 @@ void main() {
}
`;
//
const fragmentShader = `
uniform sampler2D uDataTexture;
uniform sampler2D uTexture;
@ -37,148 +36,141 @@ 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 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) => {
// [Bug 10 Fix] WebGL CSS Fallback
try {
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// 使 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;
// renderer WebGL
if (!renderer.domElement) throw new Error('WebGL creation failed');
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) => {
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
});
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;
const geometry = new THREE.PlaneGeometry(1, 1, sizeX - 1, sizeY - 1);
const plane = new THREE.Mesh(geometry, material);
scene.add(plane);
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();
};
dataTexture.needsUpdate = true;
renderer.render(scene, camera);
};
animate();
cleanupAnimation = () => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handleMouseMove);
renderer.dispose();
geometry.dispose();
material.dispose();
dataTexture.dispose();
};
} catch (err) {
console.warn('WebGL Initialization failed, fallback to CSS background.', err);
cleanupAnimation();
}
};
// Uniform
watch(() => props.isDark, (dark) => {
if (uniforms) uniforms.uBrightness.value = dark ? 0.40 : 1.0;
});
@ -193,6 +185,19 @@ onUnmounted(() => cleanupAnimation());
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 0;
pointer-events: none; /* 确保背景不拦截页面内按钮等组件的点击 */
pointer-events: none;
}
.kt-fallback-bg {
background-image: url('/register_bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
/*
Canvas 如果正常加载会覆盖在 background-image 之上
由于 renderer alpha: trueCanvas 背景是透明的所以需要注意叠加效果
这里设置背景色以确保 Canvas 加载前或者失败时有底色
*/
background-color: var(--kt-bg);
}
</style>

@ -8,7 +8,7 @@
<div class="kt-preview-header">
<div class="kt-preview-header-info">
<h3>结果预览</h3>
<span class="kt-preview-task-tag">Task #{{ taskId }}</span>
<span class="kt-preview-task-tag">Task #{{ virtualId || taskId }}</span>
<span v-if="isFinetuneTask" class="kt-preview-mode-tag">
{{ isUploadFinetune ? '上传源微调 (Upload)' : '加噪源微调 (Perturbation)' }}
</span>
@ -49,10 +49,9 @@
<!--
场景1: 单栏长图报告模式
适用评估任务 (evaluate/metrics) 热力图任务 (heatmap)
-->
<div v-else-if="isEvaluateTask" class="kt-preview-report-stage">
<div class="kt-preview-report-container allow-scroll">
<div class="kt-preview-report-container">
<div class="kt-preview-report-header-tip">
<i class="fas fa-file-invoice"></i>
{{ taskType === 'heatmap' ? '热力图报告 (Heatmap Report)' : '评估报告 (Evaluation Report)' }}
@ -70,7 +69,6 @@
<div class="kt-preview-img-box kt-preview-original">
<span class="kt-preview-img-label">{{ leftImageLabel }}</span>
<!-- 页码指示器 (仅微调任务显示) -->
<span v-if="isFinetuneTask && finetuneLeftList.length > 0" class="kt-preview-page-indicator">
{{ leftIndex + 1 }} / {{ finetuneLeftList.length }}
</span>
@ -78,13 +76,12 @@
<img :src="currentOriginalSrc" alt="Left Image" v-if="currentOriginalSrc" />
<div v-else class="kt-preview-no-img">
<span v-if="isFinetuneTask">
<i class="fas fa-robot" style="display:block; font-size: var(--cq-font-2xl); margin-bottom: 1cqh; color: var(--kt-muted-fg);"></i>
<i class="fas fa-robot" style="display:block; font-size: 2rem; margin-bottom: 1rem; color: var(--kt-muted-fg);"></i>
生成中或无数据<br>(Waiting for Generation)
</span>
<span v-else></span>
</div>
<!-- 独立翻页控件 -->
<div v-if="isFinetuneTask && finetuneLeftList.length > 1" class="kt-preview-nav-controls">
<button class="kt-preview-nav-arrow left" @click.stop="changeLeft(-1)" :disabled="leftIndex === 0">
<i class="fas fa-chevron-left"></i>
@ -105,7 +102,6 @@
<div class="kt-preview-img-box kt-preview-result">
<span class="kt-preview-img-label kt-preview-result-label">{{ rightImageLabel }}</span>
<!-- 页码指示器 -->
<span v-if="isFinetuneTask && finetuneRightList.length > 0" class="kt-preview-page-indicator">
{{ rightIndex + 1 }} / {{ finetuneRightList.length }}
</span>
@ -113,13 +109,12 @@
<img :src="currentResultSrc" alt="Result" v-if="currentResultSrc" />
<div v-else class="kt-preview-no-img">
<span v-if="isFinetuneTask">
<i class="fas fa-hourglass-half" style="display:block; font-size: var(--cq-font-2xl); margin-bottom: 1cqh; color: var(--kt-muted-fg);"></i>
<i class="fas fa-hourglass-half" style="display:block; font-size: 2rem; margin-bottom: 1rem; color: var(--kt-muted-fg);"></i>
暂无生成结果<br>(Pending / No Data)
</span>
<span v-else></span>
</div>
<!-- 独立翻页控件 -->
<div v-if="isFinetuneTask && finetuneRightList.length > 1" class="kt-preview-nav-controls">
<button class="kt-preview-nav-arrow left" @click.stop="changeRight(-1)" :disabled="rightIndex === 0">
<i class="fas fa-chevron-left"></i>
@ -133,7 +128,7 @@
</div>
<!-- 底部数字翻页 (仅非微调且非报告任务显示) -->
<!-- 底部数字翻页 -->
<div class="kt-preview-footer" v-if="totalPairs > 1 && !isFinetuneTask && !isEvaluateTask">
<div class="kt-preview-thumb-list">
<div
@ -161,13 +156,14 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getTaskImagePreview } from '@/api/image'
import { ref, computed, watch, onUnmounted } from 'vue'
import { getTaskImages } from '@/api/image'
import ThreeDTrajectoryModal from '@/components/ThreeDTrajectoryModal.vue'
const props = defineProps({
isOpen: Boolean,
taskId: [String, Number],
virtualId: [String, Number],
taskType: { type: String, default: '' }
})
@ -178,10 +174,9 @@ const error = ref(null)
const previewData = ref(null)
const show3DModal = ref(false)
//
const currentIndex = ref(0) //
const leftIndex = ref(0) //
const rightIndex = ref(0) //
const currentIndex = ref(0)
const leftIndex = ref(0)
const rightIndex = ref(0)
const isFinetuneTask = computed(() => props.taskType === 'finetune')
@ -189,7 +184,6 @@ const isEvaluateTask = computed(() => {
return ['evaluate', 'metrics', 'heatmap'].includes(props.taskType)
})
// === ===
const isUploadFinetune = computed(() => {
if (!isFinetuneTask.value || !previewData.value?.images) return false
if (previewData.value.images.uploaded_generate?.length > 0) return true
@ -198,7 +192,6 @@ const isUploadFinetune = computed(() => {
return false
})
// === () ===
const finetuneLeftList = computed(() => {
const imgs = previewData.value?.images
if (!imgs || !isFinetuneTask.value) return []
@ -219,7 +212,6 @@ const finetuneRightList = computed(() => {
}
})
// === ===
const changeLeft = (delta) => {
const newIndex = leftIndex.value + delta
if (newIndex >= 0 && newIndex < finetuneLeftList.value.length) leftIndex.value = newIndex
@ -232,22 +224,6 @@ const changeRight = (delta) => {
const open3DGraph = () => { show3DModal.value = true }
//
watch(() => props.isOpen, (val) => {
if (val && props.taskId) {
fetchData()
} else {
setTimeout(() => {
previewData.value = null
currentIndex.value = 0
leftIndex.value = 0
rightIndex.value = 0
error.value = null
show3DModal.value = false
}, 300)
}
})
const clearBlobs = () => {
if (previewData.value && previewData.value.images) {
Object.values(previewData.value.images).forEach(list => {
@ -260,6 +236,18 @@ const clearBlobs = () => {
}
}
// body overflow
const toggleBodyScroll = (lock) => {
if (typeof document !== 'undefined' && document.body) {
document.body.style.overflow = lock ? 'hidden' : ''
}
}
onUnmounted(() => {
clearBlobs()
toggleBodyScroll(false)
})
const fetchData = async () => {
loading.value = true
error.value = null
@ -267,7 +255,7 @@ const fetchData = async () => {
previewData.value = null
try {
const res = await getTaskImagePreview(props.taskId)
const res = await getTaskImages(props.taskId)
if (!res || !res.images) throw new Error("无图片数据")
previewData.value = res
} catch (err) {
@ -278,19 +266,25 @@ const fetchData = async () => {
}
}
//
watch(() => props.isOpen, (val) => {
//
toggleBodyScroll(val)
if (val && props.taskId) {
fetchData()
} else {
setTimeout(() => {
clearBlobs()
previewData.value = null
currentIndex.value = 0
leftIndex.value = 0
rightIndex.value = 0
error.value = null
show3DModal.value = false
}, 300)
}
})
// === 1. Total Pairs ===
const totalPairs = computed(() => {
if (isFinetuneTask.value) return 0
if (!previewData.value || !previewData.value.images) return 0
@ -303,80 +297,61 @@ const totalPairs = computed(() => {
return 0
})
// === 2. (URL) ===
const currentOriginalSrc = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return null
if (isFinetuneTask.value) {
const list = finetuneLeftList.value
if (list && list[leftIndex.value]) return list[leftIndex.value].data
return null
}
const idx = currentIndex.value
if (imgs.original && imgs.original[idx]) return imgs.original[idx].data
if (imgs.original_generate && imgs.original_generate[idx]) return imgs.original_generate[idx].data
return null
})
// === 3. / (URL) ===
const currentResultSrc = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return null
if (isFinetuneTask.value) {
const list = finetuneRightList.value
if (list && list[rightIndex.value]) return list[rightIndex.value].data
return null
}
const idx = currentIndex.value
if (imgs.heatmap && imgs.heatmap[idx]) return imgs.heatmap[idx].data
if (imgs.report && imgs.report[idx]) return imgs.report[idx].data
if (imgs.perturbed && imgs.perturbed[idx]) return imgs.perturbed[idx].data
if (imgs.perturbed_generate && imgs.perturbed_generate[idx]) return imgs.perturbed_generate[idx].data
if (imgs.uploaded_generate && imgs.uploaded_generate[idx]) return imgs.uploaded_generate[idx].data
return null
})
// 4.
const leftImageLabel = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return 'Original'
if (isFinetuneTask.value) {
if (isUploadFinetune.value) return '上传原图 (Uploaded Input)'
return '未防护微调结果 (Clean Gen)'
}
if (imgs.original) return 'Original (原始)'
if (imgs.perturbed_generate) return 'Protected Gen (防护后生成)'
return 'Reference'
})
// 5.
const rightImageLabel = computed(() => {
const imgs = previewData.value?.images
if (!imgs) return 'Result'
if (isFinetuneTask.value) {
if (isUploadFinetune.value) return '微调生成 (Finetuned)'
return '防护后微调结果 (Protected Gen)'
}
//
if (imgs.perturbed && imgs.perturbed.length > 0) return 'Perturbed (加噪图片)'
// (0)
if ((imgs.uploaded_generate && imgs.uploaded_generate.length > 0) ||
(imgs.perturbed_generate && imgs.perturbed_generate.length > 0)) {
return 'Finetuned (微调生成)'
}
return 'Protected (防护后)'
})
@ -389,39 +364,62 @@ const close = () => { emit('close') }
.kt-preview-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(0.8cqw);
backdrop-filter: blur(8px);
z-index: 2000;
display: flex; justify-content: center; align-items: center;
display: flex;
justify-content: center;
/* align-items: center; */
padding: 4vh 2vw; /* 增加上下边距 */
overflow-y: auto; /* 允许 Overlay 本身滚动,以防 Card 高度超屏幕 */
}
.kt-preview-card {
width: 95cqw; max-width: 140cqw; height: 85cqh;
min-width: 30cqw;
min-height: 40cqh;
/* 使用 margin: auto 实现 Flexbox 下的安全居中 (溢出时自动顶部对齐) */
margin: auto;
width: 90vw;
max-width: 1400px;
/* 使用 vh 限制高度,确保不会被撑爆 */
height: 85vh;
max-height: 900px;
min-width: 320px;
min-height: 400px;
background: var(--kt-bg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
display: flex; flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
}
@media (max-width: 900px) {
.kt-preview-card {
width: 95cqw; height: 90cqh;
width: 95vw;
height: 90vh;
max-height: 90vh;
}
.kt-preview-image-stage {
flex-direction: column;
overflow-y: auto;
padding: 1cqw;
padding: 1rem;
height: auto; /* Allow grow */
}
.kt-preview-img-box {
width: 100%; height: 35cqh; flex: none; margin-bottom: 1cqw;
width: 100%;
/* 移动端限制图片框高度 */
height: 35vh;
min-height: 200px;
flex: none;
margin-bottom: 1rem;
}
.kt-preview-divider { transform: rotate(90deg); margin: 1cqw 0; }
.kt-preview-divider { transform: rotate(90deg); margin: 1rem 0; }
}
.kt-preview-header {
padding: 1.5cqw 2.5cqw;
padding: 1.5rem 2rem;
border-bottom: 2px solid var(--kt-border);
display: flex; justify-content: space-between; align-items: center;
background: var(--kt-bg);
@ -432,7 +430,7 @@ const close = () => { emit('close') }
margin: 0;
font-family: var(--kt-font);
font-weight: 700;
font-size: var(--cq-font-lg);
font-size: 1.5rem;
color: var(--kt-fg);
text-transform: uppercase;
letter-spacing: -0.02em;
@ -440,37 +438,37 @@ const close = () => { emit('close') }
.kt-preview-task-tag {
font-family: var(--kt-font);
font-size: var(--cq-font-xs);
font-size: 0.8rem;
background: var(--kt-muted);
padding: 0.2cqw 0.8cqw;
padding: 0.2rem 0.6rem;
border-radius: var(--kt-radius);
border: 1px solid var(--kt-border);
color: var(--kt-fg);
margin-top: 0.4cqw;
margin-top: 0.4rem;
display: inline-block;
margin-right: 0.5cqw;
margin-right: 0.5rem;
}
.kt-preview-mode-tag {
font-family: var(--kt-font);
font-size: 0.75cqw;
font-size: 0.75rem;
background: var(--kt-accent);
color: var(--kt-accent-fg);
padding: 0.2cqw 0.8cqw;
padding: 0.2rem 0.6rem;
border-radius: var(--kt-radius);
font-weight: 600;
}
.kt-preview-header-actions { display: flex; align-items: center; gap: 1.5cqw; }
.kt-preview-header-actions { display: flex; align-items: center; gap: 1rem; }
.kt-preview-close-btn {
background: var(--kt-bg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
font-size: var(--cq-font-xl);
font-size: 1.25rem;
cursor: pointer;
color: var(--kt-fg);
padding: 0.4cqw 0.8cqw;
padding: 0.4rem 0.8rem;
transition: all var(--kt-transition-micro);
}
@ -480,16 +478,21 @@ const close = () => { emit('close') }
border-color: var(--kt-accent);
}
/* Body 负责内部滚动 */
.kt-preview-body {
flex: 1;
position: relative;
background: var(--kt-bg);
display: flex; justify-content: center; align-items: center;
/* block 布局让 padding 生效 */
display: block;
padding: 0;
overflow: hidden;
overflow-y: auto; /* 让 Body 内部滚动 */
}
.kt-preview-loading, .kt-preview-error {
/* 居中 loading/error */
position: absolute;
top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 100%;
text-align: center;
color: var(--kt-fg);
@ -497,22 +500,31 @@ const close = () => { emit('close') }
}
.kt-preview-error { color: var(--kt-accent); }
.kt-preview-loading i { color: var(--kt-accent); margin-bottom: 2cqw; }
.kt-preview-loading i { color: var(--kt-accent); margin-bottom: 1rem; }
/* === 图片展示区 === */
.kt-preview-image-stage {
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%; gap: 2cqw;
padding: 2cqw;
display: flex;
/* 移除 align-items: center防止高度溢出时被居中裁剪 */
align-items: stretch;
justify-content: center;
width: 100%;
/* 使用 min-height 撑满,允许内容撑开 */
min-height: 100%;
gap: 2vw;
padding: 2rem; /* 给足内边距,防止贴边 */
box-sizing: border-box;
}
.kt-preview-img-box {
flex: 1; height: 100%;
flex: 1;
/* 移除固定高度,让它自适应 */
min-height: 300px;
background: var(--kt-bg);
border-radius: var(--kt-radius);
display: flex; flex-direction: column; position: relative;
border: 2px solid var(--kt-border);
padding: 1cqw;
padding: 1rem;
}
/* 导航箭头 */
@ -522,13 +534,13 @@ const close = () => { emit('close') }
display: flex; justify-content: space-between;
transform: translateY(-50%);
pointer-events: none;
padding: 0 1cqw;
padding: 0 1rem;
z-index: 10;
}
.kt-preview-nav-arrow {
pointer-events: auto;
width: 4cqw; height: 4cqw;
width: 3rem; height: 3rem;
border-radius: var(--kt-radius);
border: 2px solid var(--kt-border);
background: var(--kt-bg);
@ -548,11 +560,11 @@ const close = () => { emit('close') }
.kt-preview-nav-arrow:disabled { opacity: 0.3; cursor: not-allowed; }
.kt-preview-img-label {
position: absolute; top: 1.5cqw; left: 1.5cqw;
position: absolute; top: 1rem; left: 1rem;
background: var(--kt-fg); color: var(--kt-bg);
padding: 0.4cqw 1.2cqw;
padding: 0.4rem 1rem;
border-radius: var(--kt-radius);
font-size: var(--cq-font-xs);
font-size: 0.8rem;
font-family: var(--kt-font);
font-weight: 600;
z-index: 2;
@ -563,11 +575,11 @@ const close = () => { emit('close') }
.kt-preview-result-label { background: var(--kt-accent); color: var(--kt-accent-fg); }
.kt-preview-page-indicator {
position: absolute; top: 1.5cqw; right: 1.5cqw;
position: absolute; top: 1rem; right: 1rem;
background: var(--kt-muted); color: var(--kt-fg);
padding: 0.4cqw 1cqw;
padding: 0.4rem 1rem;
border-radius: var(--kt-radius);
font-size: var(--cq-font-xs);
font-size: 0.8rem;
font-family: var(--kt-font);
z-index: 2;
border: 1px solid var(--kt-border);
@ -577,15 +589,19 @@ const close = () => { emit('close') }
width: 100%; height: 100%; object-fit: contain; border-radius: var(--kt-radius);
}
.kt-preview-divider { color: var(--kt-fg); font-size: var(--cq-font-2xl); opacity: 0.5; }
.kt-preview-spacer { width: 1cqw; }
.kt-preview-divider {
color: var(--kt-fg); font-size: 2rem; opacity: 0.5;
/* 垂直居中图标 */
align-self: center;
}
.kt-preview-spacer { width: 1vw; }
.kt-preview-no-img {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
color: var(--kt-muted-fg);
text-align: center;
font-size: var(--cq-font-sm);
font-size: 0.9rem;
font-family: var(--kt-font);
line-height: 1.6;
flex-direction: column;
@ -593,28 +609,31 @@ const close = () => { emit('close') }
/* === 报告视图 (热力图/评估报告) === */
.kt-preview-report-stage {
width: 100%; height: 100%;
width: 100%; min-height: 100%;
background: var(--kt-bg);
display: flex; justify-content: center;
padding: 2rem;
box-sizing: border-box;
}
.kt-preview-report-container {
width: 100%; max-width: 90cqw; height: 100%;
overflow-y: auto;
width: 100%; max-width: 90vw;
/* 移除固定 height: 100%,让其自适应内容 */
overflow-y: visible;
background: var(--kt-bg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
padding: 2cqw;
padding: 2rem;
text-align: center;
margin: 1cqw;
/* margin: 1rem; */
}
.kt-preview-report-header-tip {
color: var(--kt-fg);
font-size: var(--cq-font-sm);
font-size: 1rem;
font-family: var(--kt-font);
font-weight: 600;
margin-bottom: 2cqw; padding: 1cqw 1.5cqw;
margin-bottom: 2rem; padding: 1rem 1.5rem;
background: var(--kt-muted);
border-radius: var(--kt-radius);
display: inline-block;
@ -624,10 +643,10 @@ const close = () => { emit('close') }
.kt-preview-sub-tip {
display: block;
font-size: var(--cq-font-xs);
font-size: 0.8rem;
font-weight: 400;
color: var(--kt-muted-fg);
margin-top: 0.4cqw;
margin-top: 0.4rem;
text-transform: none;
}
@ -635,16 +654,16 @@ const close = () => { emit('close') }
/* 底部 */
.kt-preview-footer {
padding: 1.5cqw;
padding: 1rem;
background: var(--kt-bg);
border-top: 2px solid var(--kt-border);
display: flex; justify-content: center; flex-shrink: 0;
}
.kt-preview-thumb-list { display: flex; gap: 1cqw; overflow-x: auto; padding: 0.5cqw; }
.kt-preview-thumb-list { display: flex; gap: 0.5rem; overflow-x: auto; padding: 0.5rem; }
.kt-preview-thumb-item {
width: 4cqw; height: 4cqw;
width: 3rem; height: 3rem;
background: var(--kt-muted);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
@ -677,13 +696,13 @@ const close = () => { emit('close') }
color: var(--kt-fg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
padding: 0.8cqh 1.6cqw;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all var(--kt-transition-micro);
display: inline-flex;
align-items: center;
gap: 0.5cqw;
font-size: var(--cq-font-sm);
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
}
@ -694,130 +713,29 @@ const close = () => { emit('close') }
border-color: var(--kt-accent);
}
/*
===========================================
深色模式适配 (Dark Mode)
===========================================
*/
html.dark-mode .kt-preview-card {
background: var(--kt-bg);
border-color: var(--kt-border);
}
html.dark-mode .kt-preview-header {
background: var(--kt-bg);
border-bottom-color: var(--kt-border);
}
html.dark-mode .kt-preview-header-info h3 {
color: var(--kt-fg);
}
html.dark-mode .kt-preview-task-tag {
background: var(--kt-muted);
border-color: var(--kt-border);
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-close-btn {
background: var(--kt-bg);
border-color: var(--kt-border);
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-close-btn:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
}
html.dark-mode .kt-preview-body {
background: var(--kt-bg);
}
html.dark-mode .kt-preview-loading,
html.dark-mode .kt-preview-error {
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-img-box {
background: var(--kt-bg);
border-color: var(--kt-border);
}
html.dark-mode .kt-preview-no-img {
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-divider {
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-nav-arrow {
background: var(--kt-bg);
border-color: var(--kt-border);
color: var(--kt-fg);
}
html.dark-mode .kt-preview-nav-arrow:hover {
background: var(--kt-accent);
border-color: var(--kt-accent);
color: var(--kt-accent-fg);
}
html.dark-mode .kt-preview-img-label {
background: var(--kt-muted);
border-color: var(--kt-border);
color: var(--kt-fg);
}
html.dark-mode .kt-preview-page-indicator {
background: var(--kt-muted);
border-color: var(--kt-border);
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-report-stage {
background: var(--kt-bg);
}
html.dark-mode .kt-preview-report-container {
background: var(--kt-bg);
border-color: var(--kt-border);
scrollbar-color: var(--kt-border) var(--kt-bg);
}
html.dark-mode .kt-preview-report-container::-webkit-scrollbar-track {
background: var(--kt-bg);
}
html.dark-mode .kt-preview-report-container::-webkit-scrollbar-thumb {
background-color: var(--kt-border);
}
html.dark-mode .kt-preview-report-header-tip {
background: var(--kt-muted);
border-color: var(--kt-border);
color: var(--kt-fg);
}
html.dark-mode .kt-preview-sub-tip {
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-footer {
background: var(--kt-bg);
border-top-color: var(--kt-border);
}
html.dark-mode .kt-preview-thumb-item {
background: var(--kt-muted);
border-color: var(--kt-border);
color: var(--kt-muted-fg);
}
html.dark-mode .kt-preview-thumb-item.active {
background: var(--kt-accent);
color: var(--kt-accent-fg);
}
</style>
/* Dark Mode */
html.dark-mode .kt-preview-card { background: var(--kt-bg); border-color: var(--kt-border); }
html.dark-mode .kt-preview-header { background: var(--kt-bg); border-bottom-color: var(--kt-border); }
html.dark-mode .kt-preview-header-info h3 { color: var(--kt-fg); }
html.dark-mode .kt-preview-task-tag { background: var(--kt-muted); border-color: var(--kt-border); color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-close-btn { background: var(--kt-bg); border-color: var(--kt-border); color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-close-btn:hover { background: var(--kt-accent); color: var(--kt-accent-fg); }
html.dark-mode .kt-preview-body { background: var(--kt-bg); }
html.dark-mode .kt-preview-loading, html.dark-mode .kt-preview-error { color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-img-box { background: var(--kt-bg); border-color: var(--kt-border); }
html.dark-mode .kt-preview-no-img { color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-divider { color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-nav-arrow { background: var(--kt-bg); border-color: var(--kt-border); color: var(--kt-fg); }
html.dark-mode .kt-preview-nav-arrow:hover { background: var(--kt-accent); border-color: var(--kt-accent); color: var(--kt-accent-fg); }
html.dark-mode .kt-preview-img-label { background: var(--kt-muted); border-color: var(--kt-border); color: var(--kt-fg); }
html.dark-mode .kt-preview-page-indicator { background: var(--kt-muted); border-color: var(--kt-border); color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-report-stage { background: var(--kt-bg); }
html.dark-mode .kt-preview-report-container { background: var(--kt-bg); border-color: var(--kt-border); scrollbar-color: var(--kt-border) var(--kt-bg); }
html.dark-mode .kt-preview-report-container::-webkit-scrollbar-track { background: var(--kt-bg); }
html.dark-mode .kt-preview-report-container::-webkit-scrollbar-thumb { background-color: var(--kt-border); }
html.dark-mode .kt-preview-report-header-tip { background: var(--kt-muted); border-color: var(--kt-border); color: var(--kt-fg); }
html.dark-mode .kt-preview-sub-tip { color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-footer { background: var(--kt-bg); border-top-color: var(--kt-border); }
html.dark-mode .kt-preview-thumb-item { background: var(--kt-muted); border-color: var(--kt-border); color: var(--kt-muted-fg); }
html.dark-mode .kt-preview-thumb-item.active { background: var(--kt-accent); color: var(--kt-accent-fg); }
</style>

@ -22,7 +22,7 @@
<div v-if="taskInfo" class="kt-success-modal__info">
<div class="kt-success-modal__info-item" v-if="taskInfo.taskName">
<span class="kt-success-modal__info-label">任务名称</span>
<span class="kt-success-modal__info-value">{{ taskInfo.taskName }}</span>
<span class="kt-success-modal__info-value" :title="taskInfo.taskName">{{ taskInfo.taskName }}</span>
</div>
<div class="kt-success-modal__info-item" v-if="taskInfo.taskType">
<span class="kt-success-modal__info-label">任务类型</span>
@ -210,6 +210,9 @@ defineExpose({ close: handleClose })
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
/* 允许 Flex 子项收缩 */
min-width: 0;
}
.kt-success-modal__info-item:not(:last-child) {
@ -229,6 +232,13 @@ defineExpose({ close: handleClose })
font-size: 0.875rem;
font-weight: 600;
color: var(--kt-fg);
/* 文本截断 */
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 隐藏溢出 */
text-overflow: ellipsis; /* 显示省略号 */
max-width: 65%; /* 限制最大宽度 (留给 Label 35% 的空间) */
text-align: right; /* 保持靠右对齐 */
}
.kt-success-modal__hint {

@ -19,6 +19,11 @@
</div>
<div class="kt-task-desc">{{ task.name }}</div>
<!-- 提交时间显示 -->
<div class="kt-task-time">
<i class="far fa-clock"></i> {{ formatTime(task.createdAt) }}
</div>
<div v-if="task.status === 'processing' || task.status === 'running'" class="kt-progress-container">
<div class="kt-progress-bar infinite"></div>
@ -37,15 +42,13 @@
</template>
<script setup>
import { computed, inject } from 'vue' // inject
import { computed, inject } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import { useRouter } from 'vue-router'
const store = useTaskStore()
const router = useRouter()
//
// MainFlow.vue Tab
const navigateToSection = inject('navigateToSection')
const displayTasks = computed(() => store.sidebarTasks)
@ -59,30 +62,36 @@ const formatStatusLabel = (status) => {
'processing': '处理中',
'running': '处理中',
'completed': '已完成',
'failed': '失败'
'failed': '失败',
'cancelled': '已取消'
}
return map[status] || status
}
//
//
const formatTime = (isoString) => {
if (!isoString) return '--'
const date = new Date(isoString)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
const goToHistory = () => {
if (navigateToSection) {
// 使
navigateToSection('page4')
} else {
//
router.push('/page4')
}
}
</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
*/
/* ===== Kinetic Typography Task Sidebar ===== */
.kt-sidebar-card {
height: 100%;
@ -92,13 +101,11 @@ const goToHistory = () => {
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
padding: 2.4cqw;
transition: all 0.2s ease-in-out; /* 过渡动画 */
/* 添加手型光标 */
transition: all 0.2s ease-in-out;
cursor: pointer;
position: relative;
}
/* 添加悬停效果,提示可点击 */
.kt-sidebar-card:hover {
border-color: var(--kt-accent);
box-shadow: 0 0 20px rgba(223, 225, 4, 0.1);
@ -125,7 +132,6 @@ const goToHistory = () => {
transition: color 0.2s;
}
/* 悬停时标题变色 */
.kt-sidebar-card:hover .kt-sidebar-title {
color: var(--kt-accent);
}
@ -182,10 +188,8 @@ const goToHistory = () => {
transition: all var(--kt-transition-micro);
}
/* 内部项目悬停时不改变边框颜色,以免与外层 hover 冲突视觉 */
.kt-task-item:hover {
background: var(--kt-bg);
/* border-color: var(--kt-accent); */
}
.kt-task-header {
@ -212,7 +216,19 @@ const goToHistory = () => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.4cqw;
}
/* 时间样式 */
.kt-task-time {
font-family: var(--kt-font);
font-size: clamp(0.5rem, 0.65cqw, 0.75rem);
color: var(--kt-muted-fg);
opacity: 0.8;
margin-bottom: 0.8cqw;
display: flex;
align-items: center;
gap: 0.4rem;
}
.kt-status-tag {
@ -226,7 +242,7 @@ const goToHistory = () => {
font-weight: 600;
}
/* Status colors - KT palette */
/* Status colors */
.kt-status-tag.running,
.kt-status-tag.processing {
color: var(--kt-accent);
@ -253,6 +269,14 @@ const goToHistory = () => {
border-color: #ef4444;
}
/* Cancelled 状态样式 (灰色/中性色) */
.kt-status-tag.cancelled {
color: var(--kt-muted-fg);
background: transparent;
border-color: var(--kt-muted-fg);
text-decoration: line-through;
}
.kt-progress-container {
height: 0.4cqw;
min-height: 4px;
@ -310,7 +334,8 @@ const goToHistory = () => {
.kt-task-desc,
.kt-progress-container,
.kt-task-footer,
.kt-monitor-dot {
.kt-monitor-dot,
.kt-task-time { /* 移动端空间有限,可以隐藏时间或调整 */
display: none;
}
@ -348,19 +373,9 @@ const goToHistory = () => {
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.kt-monitor-dot.active {
animation: none;
}
.kt-progress-bar.infinite {
animation: none;
width: 100%;
}
.kt-task-item {
transition: none;
}
.kt-monitor-dot.active { animation: none; }
.kt-progress-bar.infinite { animation: none; width: 100%; }
.kt-task-item { transition: none; }
}
</style>

@ -18,7 +18,7 @@
<!-- 图例 -->
<div class="kt-3d-legend">
<span class="kt-3d-dot raw"></span> 原图 (Ref)
<span class="kt-3d-dot smooth"></span> 训练 (Train)
<span class="kt-3d-dot smooth"></span> 防护 (Perturb)
</div>
<button class="kt-3d-close-btn" @click="close">
<i class="fas fa-times"></i>
@ -71,6 +71,7 @@ const loading = ref(false)
const error = ref(null)
let scene, camera, renderer, controls, animationId
let resizeHandler = null // [Bug 7 Fix]
const TARGET_SIZE = 40
let globalBounds = {
@ -79,9 +80,6 @@ let globalBounds = {
minZ: Infinity, maxZ: -Infinity
}
// ==========================================
// 1. (Log + Smooth)
// ==========================================
const parseAndLogTransform = (d) => {
const rawX = parseFloat(d.X_Feature_L2_Norm ?? d.X_LoRA_Weight_Norm ?? 0)
const rawY = parseFloat(d.Y_Feature_Variance ?? d.Y_Grad_Norm ?? 0)
@ -168,9 +166,6 @@ const initVisualization = async () => {
}
}
// ==========================================
// 2.
// ==========================================
const createAxisLabel = (text, color, position) => {
const canvas = document.createElement('canvas')
const size = 128
@ -220,9 +215,6 @@ const createThickAxis = (color, endPoint) => {
return axisGroup
}
// ==========================================
// 3. Three.js (Fat Lines)
// ==========================================
const initThreeJS = (mainPointsData, rawPointsData) => {
disposeThree()
if (!canvasContainer.value) return
@ -328,6 +320,17 @@ const initThreeJS = (mainPointsData, rawPointsData) => {
drawFatTrajectory(rawPointsData, '#666666', '#aaaaaa')
drawFatTrajectory(mainPointsData, '#18283b', '#FF9F1C')
//
resizeHandler = () => {
if (!canvasContainer.value || !camera || !renderer) return
const w = canvasContainer.value.clientWidth
const h = canvasContainer.value.clientHeight
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
}
window.addEventListener('resize', resizeHandler)
animate()
}
@ -338,6 +341,12 @@ const animate = () => {
}
const disposeThree = () => {
// Remove resize listener
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
if (animationId) cancelAnimationFrame(animationId)
if (scene) {
scene.traverse((obj) => {
@ -370,183 +379,40 @@ onUnmounted(() => disposeThree())
</script>
<style scoped>
.kt-3d-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(0.5cqw);
z-index: 2200;
display: flex; justify-content: center; align-items: center;
}
.kt-3d-card {
width: 80cqw; height: 80cqh; max-width: 100cqw;
background: var(--kt-bg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
display: flex; flex-direction: column;
overflow: hidden;
}
.kt-3d-header {
padding: 1.5cqw 2.5cqw;
border-bottom: 2px solid var(--kt-border);
display: flex; justify-content: space-between; align-items: center;
background: var(--kt-bg);
z-index: 10;
}
.kt-3d-header-info h3 {
margin: 0;
font-family: var(--kt-font);
font-weight: 700;
font-size: var(--cq-font-lg);
color: var(--kt-fg);
text-transform: uppercase;
letter-spacing: -0.02em;
}
.kt-3d-subtitle {
font-family: var(--kt-font);
font-size: var(--cq-font-xs);
color: var(--kt-fg);
margin: 0.4cqw 0 0;
font-weight: 600;
}
.kt-3d-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(0.5cqw); z-index: 2200; display: flex; justify-content: center; align-items: center; }
.kt-3d-card { width: 80cqw; height: 80cqh; max-width: 100cqw; background: var(--kt-bg); border: 2px solid var(--kt-border); border-radius: var(--kt-radius); display: flex; flex-direction: column; overflow: hidden; }
.kt-3d-header { padding: 1.5cqw 2.5cqw; border-bottom: 2px solid var(--kt-border); display: flex; justify-content: space-between; align-items: center; background: var(--kt-bg); z-index: 10; }
.kt-3d-header-info h3 { margin: 0; font-family: var(--kt-font); font-weight: 700; font-size: var(--cq-font-lg); color: var(--kt-fg); text-transform: uppercase; letter-spacing: -0.02em; }
.kt-3d-subtitle { font-family: var(--kt-font); font-size: var(--cq-font-xs); color: var(--kt-fg); margin: 0.4cqw 0 0; font-weight: 600; }
.kt-3d-header-controls { display: flex; align-items: center; gap: 2cqw; }
.kt-3d-legend {
display: flex; gap: 1.5cqw;
font-family: var(--kt-font);
font-size: var(--cq-font-xs);
color: var(--kt-fg);
align-items: center;
background: var(--kt-muted);
padding: 0.5cqw 1cqw;
border-radius: var(--kt-radius);
border: 1px solid var(--kt-border);
}
.kt-3d-dot {
width: 1cqw; height: 1cqw;
border-radius: 50%;
display: inline-block;
margin-right: 0.6cqw;
border: 1px solid var(--kt-border);
}
.kt-3d-legend { display: flex; gap: 1.5cqw; font-family: var(--kt-font); font-size: var(--cq-font-xs); color: var(--kt-fg); align-items: center; background: var(--kt-muted); padding: 0.5cqw 1cqw; border-radius: var(--kt-radius); border: 1px solid var(--kt-border); }
.kt-3d-dot { width: 1cqw; height: 1cqw; border-radius: 50%; display: inline-block; margin-right: 0.6cqw; border: 1px solid var(--kt-border); }
.kt-3d-dot.raw { background: #aaaaaa; }
.kt-3d-dot.smooth { background: linear-gradient(90deg, #18283b, #FF9F1C); }
.kt-3d-close-btn {
background: var(--kt-bg);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
font-size: var(--cq-font-xl);
cursor: pointer;
color: var(--kt-fg);
padding: 0.4cqw 0.8cqw;
transition: all var(--kt-transition-micro);
}
.kt-3d-close-btn:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
}
.kt-3d-canvas-container {
flex: 1; position: relative;
width: 100%; height: 100%;
background: var(--kt-bg);
overflow: hidden;
}
.kt-3d-state-box {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center;
color: var(--kt-fg);
font-family: var(--kt-font);
pointer-events: none;
}
.kt-3d-close-btn { background: var(--kt-bg); border: 2px solid var(--kt-border); border-radius: var(--kt-radius); font-size: var(--cq-font-xl); cursor: pointer; color: var(--kt-fg); padding: 0.4cqw 0.8cqw; transition: all var(--kt-transition-micro); }
.kt-3d-close-btn:hover { background: var(--kt-accent); color: var(--kt-accent-fg); border-color: var(--kt-accent); }
.kt-3d-canvas-container { flex: 1; position: relative; width: 100%; height: 100%; background: var(--kt-bg); overflow: hidden; }
.kt-3d-state-box { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: var(--kt-fg); font-family: var(--kt-font); pointer-events: none; }
.kt-3d-state-box i { margin-bottom: 1cqw; color: var(--kt-accent); }
.kt-3d-state-box.error { color: var(--kt-accent); }
.kt-3d-state-box.error i { color: var(--kt-accent); }
.kt-3d-axis-labels {
position: absolute; bottom: 2cqw; left: 2cqw;
background: var(--kt-bg);
padding: 1.2cqw;
border-radius: var(--kt-radius);
font-family: var(--kt-font);
font-size: var(--cq-font-xs);
pointer-events: none;
border: 2px solid var(--kt-border);
}
.kt-3d-axis-labels { position: absolute; bottom: 2cqw; left: 2cqw; background: var(--kt-bg); padding: 1.2cqw; border-radius: var(--kt-radius); font-family: var(--kt-font); font-size: var(--cq-font-xs); pointer-events: none; border: 2px solid var(--kt-border); }
.kt-3d-axis-label { margin-bottom: 0.4cqw; font-weight: bold; }
.kt-3d-axis-label:last-child { margin-bottom: 0; }
.z-axis { color: #3366ff; }
.y-axis { color: #00cc00; }
.x-axis { color: #ff3333; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/*
===========================================
深色模式适配 (Dark Mode)
===========================================
*/
html.dark-mode .kt-3d-card {
background: var(--kt-bg);
border-color: var(--kt-border);
}
html.dark-mode .kt-3d-header {
background: var(--kt-bg);
border-bottom-color: var(--kt-border);
}
html.dark-mode .kt-3d-header-info h3 {
color: var(--kt-fg);
}
html.dark-mode .kt-3d-subtitle {
color: var(--kt-muted-fg);
}
html.dark-mode .kt-3d-legend {
background: var(--kt-muted);
border-color: var(--kt-border);
color: var(--kt-muted-fg);
}
html.dark-mode .kt-3d-dot {
border-color: var(--kt-border);
}
html.dark-mode .kt-3d-close-btn {
background: var(--kt-bg);
border-color: var(--kt-border);
color: var(--kt-muted-fg);
}
html.dark-mode .kt-3d-close-btn:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
}
html.dark-mode .kt-3d-canvas-container {
background: var(--kt-bg);
}
html.dark-mode .kt-3d-state-box {
color: var(--kt-muted-fg);
}
html.dark-mode .kt-3d-axis-labels {
background: var(--kt-bg);
border-color: var(--kt-border);
}
</style>
html.dark-mode .kt-3d-card { background: var(--kt-bg); border-color: var(--kt-border); }
html.dark-mode .kt-3d-header { background: var(--kt-bg); border-bottom-color: var(--kt-border); }
html.dark-mode .kt-3d-header-info h3 { color: var(--kt-fg); }
html.dark-mode .kt-3d-subtitle { color: var(--kt-muted-fg); }
html.dark-mode .kt-3d-legend { background: var(--kt-muted); border-color: var(--kt-border); color: var(--kt-muted-fg); }
html.dark-mode .kt-3d-dot { border-color: var(--kt-border); }
html.dark-mode .kt-3d-close-btn { background: var(--kt-bg); border-color: var(--kt-border); color: var(--kt-muted-fg); }
html.dark-mode .kt-3d-close-btn:hover { background: var(--kt-accent); color: var(--kt-accent-fg); }
html.dark-mode .kt-3d-canvas-container { background: var(--kt-bg); }
html.dark-mode .kt-3d-state-box { color: var(--kt-muted-fg); }
html.dark-mode .kt-3d-axis-labels { background: var(--kt-bg); border-color: var(--kt-border); }
</style>

@ -1,5 +1,3 @@
//负责调用 API存储任务列表并执行 5 秒一次的轮询。
import { defineStore } from 'pinia'
import { getTaskList, getTaskQuota } from '@/api/task'
@ -16,7 +14,7 @@ export const useTaskStore = defineStore('task', {
}),
actions: {
// === 核心:启动轮询 ===
// === 启动轮询 ===
async startPolling() {
if (this.timer) return
@ -72,38 +70,50 @@ export const useTaskStore = defineStore('task', {
getters: {
// === 适配 Sidebar 的数据格式 ===
sidebarTasks: (state) => {
// 只取前 10 条展示在侧边栏
return state.tasks.slice(0, 10).map(t => {
// 1.先按主键 ID 升序排列,生成全局的 virtual_id
const allTasksWithVid = [...state.tasks]
.sort((a, b) => a.task_id - b.task_id)
.map((t, index) => ({
...t,
virtual_id: index + 1 // 从1开始
}));
// 2.重新按创建时间倒序排列,以展示最新任务
const sortedForSidebar = allTasksWithVid.sort((a, b) =>
new Date(b.created_at) - new Date(a.created_at)
);
// 3. 只取前 10 条展示在侧边栏
return sortedForSidebar.slice(0, 10).map(t => {
//获取任务名称
let name = `Task #${t.task_id}` // 默认值
// 优先级 A: 用户填写的 description (通用/快速/专题防护的用户输入都存在这里)
// 优先级 A: 用户填写的 description
if (t.description && t.description.trim() !== '') {
name = t.description
}
// 优先级 B: 微调/评估/热力图的特定名称字段 (如果 description 为空)
// 优先级 B: 算法特定名称
else if (t.task_type === 'finetune' && t.finetune?.finetune_name) {
name = t.finetune.finetune_name
} else if (t.task_type === 'evaluate' && t.evaluate?.evaluate_name) {
name = t.evaluate.evaluate_name
} else if (t.task_type === 'heatmap' && t.heatmap?.heatmap_name) {
name = t.heatmap.heatmap_name
}
// 优先级 C: 系统自动生成的算法名 (作为最后兜底)
else if (t.task_type === 'perturbation' && t.perturbation?.perturbation_name) {
} else if (t.task_type === 'perturbation' && t.perturbation?.perturbation_name) {
name = t.perturbation.perturbation_name
}
// 2. 状态映射 (数据库状态 -> 前端样式类名)
// 状态映射
let status = t.status
if (status === 'pending') status = 'waiting'
// 3. 进度条逻辑
// 进度条逻辑
const progress = status === 'completed' ? 100 : (status === 'processing' ? 50 : 0)
return {
id: t.task_id,
id: t.virtual_id,
original_id: t.task_id, // 保留原始ID
status: status,
progress: progress,
name: name,

@ -60,14 +60,14 @@ export const TOPIC_CONFIG = {
// 防人脸编辑 -> ID 8
FACE_EDIT: {
ALGO_ID: ALGO_MAP.ANTI_FACE_EDIT,
INTENSITY: 0.05,
INTENSITY: 12,
DATA_TYPE_ID: DATA_TYPE_MAP.FACE,
ALGO_NAME: 'Anti-Face-Editing'
},
// 防定制生成 -> ID 7
CUSTOM_GEN: {
ALGO_ID: ALGO_MAP.ANTI_CUSTOMIZE,
INTENSITY: 0.05,
INTENSITY: 12,
DATA_TYPE_ID: DATA_TYPE_MAP.FACE,
ALGO_NAME: 'Anti-Customization'
}

@ -4,7 +4,7 @@
* 解析 Multipart/Mixed 响应流
* @param {ArrayBuffer} buffer - 原始二进制数据
* @param {string} boundary - 分隔符
* @returns {Object} - 解析后的图片对象结构适配前端组件
* @returns {Object} - 解析后的图片对象
*/
export function parseMultipartMixed(buffer, boundary) {
const decoder = new TextDecoder('utf-8')
@ -21,7 +21,6 @@ export function parseMultipartMixed(buffer, boundary) {
}
}
// 简单的二进制搜索实现 (也可以使用更高效的算法,但对于图片预览够用了)
const uint8Array = new Uint8Array(buffer)
const parts = []
let lastIndex = 0
@ -31,12 +30,10 @@ export function parseMultipartMixed(buffer, boundary) {
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
}
@ -57,21 +54,36 @@ export function parseMultipartMixed(buffer, boundary) {
const imageType = headers['x-image-type'] || 'unknown'
const imageId = headers['x-image-id']
const contentType = headers['content-type'] || 'image/png'
// 提取文件名 (Content-Disposition: attachment; filename="example.jpg")
let filename = `image_${imageId || Date.now()}.png`
const disposition = headers['content-disposition']
if (disposition) {
// 简单正则提取 filename兼容带引号和不带引号
const match = disposition.match(/filename="?([^";]+)"?/)
if (match && match[1]) {
filename = match[1]
}
}
// 生成 Blob URL
// 生成 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 对象用于后续下载或销毁
data: url, // Blob URL组件直接使用 src="img.data"
blob: blob, // 原始 Blob 对象,用于下载打包
filename: filename // 文件名
}
// 归类
if (result.images[imageType]) {
result.images[imageType].push(imgObj)
} else {
// 如果是未定义的类型,也可以放入一个通用数组,这里暂且忽略或按需处理
// console.warn('Unknown image type:', imageType)
}
}
})
@ -79,7 +91,7 @@ export function parseMultipartMixed(buffer, boundary) {
return result
}
// 辅助:在 Uint8Array 中查找序列
// 在 Uint8Array 中查找序列
function findSequence(data, sequence, fromIndex) {
for (let i = fromIndex; i < data.length - sequence.length + 1; i++) {
let found = true
@ -94,13 +106,16 @@ function findSequence(data, sequence, fromIndex) {
return -1
}
// 辅助:解析 Header 字符串
// 解析 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()
const parts = line.split(':')
if (parts.length >= 2) {
const key = parts[0].trim().toLowerCase()
// 值可能包含冒号(如时间),需要重新拼接后续部分
const value = parts.slice(1).join(':').trim()
headers[key] = value
}
})
return headers

@ -172,7 +172,7 @@
<div class="kt-user-box">
<input
class="kt-input"
type="password"
:type="showRegisterPwd ? 'text' : 'password'"
name="password"
required
v-model="form.password"
@ -180,6 +180,12 @@
/>
<label class="kt-label">密码</label>
<i class="fas fa-lock kt-input-icon"></i>
<i
class="fas kt-toggle-password"
:class="showRegisterPwd ? 'fa-eye-slash' : 'fa-eye'"
@click="showRegisterPwd = !showRegisterPwd"
title="显示/隐藏密码"
></i>
<p class="kt-input-hint">密码至少8位且需包含大小写字母数字和符号</p>
</div>
@ -271,7 +277,7 @@
<div class="kt-user-box">
<input
class="kt-input"
type="password"
:type="showResetPwd ? 'text' : 'password'"
name="new_password"
required
v-model="forgotForm.new_password"
@ -279,13 +285,19 @@
/>
<label class="kt-label">新密码</label>
<i class="fas fa-lock kt-input-icon"></i>
<i
class="fas kt-toggle-password"
:class="showResetPwd ? 'fa-eye-slash' : 'fa-eye'"
@click="showResetPwd = !showResetPwd"
title="显示/隐藏密码"
></i>
<p class="kt-input-hint">密码至少8位且需包含大小写字母数字和符号</p>
</div>
<div class="kt-user-box">
<input
class="kt-input"
type="password"
:type="showResetConfirm ? 'text' : 'password'"
name="confirm_password"
required
v-model="forgotForm.confirm_password"
@ -293,6 +305,12 @@
/>
<label class="kt-label">确认新密码</label>
<i class="fas fa-lock kt-input-icon"></i>
<i
class="fas kt-toggle-password"
:class="showResetConfirm ? 'fa-eye-slash' : 'fa-eye'"
@click="showResetConfirm = !showResetConfirm"
title="显示/隐藏密码"
></i>
</div>
</div>
@ -330,6 +348,9 @@ const userStore = useUserStore()
const flowMode = ref('login')
const loading = ref(false)
const showPassword = ref(false)
const showRegisterPwd = ref(false)
const showResetPwd = ref(false)
const showResetConfirm = ref(false)
const isDark = ref(true)
const errorMessage = ref('')
@ -418,7 +439,17 @@ const validateEmail = (email) => {
* 至少8位包含大小写数字特殊符号
*/
const validatePasswordStrength = (pwd) => {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._-])[A-Za-z\d@$!%*?&._-]{8,}$/;
//
// ^
// (?=.*[a-z])
// (?=.*[A-Z])
// (?=.*\d)
// (?=.*[^A-Za-z0-9]) ()
// \S{8,} 8
// $
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{8,}$/;
return regex.test(pwd);
}
@ -750,7 +781,8 @@ const handleForgotPassword = async () => {
.kt-input-icon {
position: absolute;
top: 50%;
/* 修复位置:改为固定像素值,以应对下方 Hint 文字撑开容器 */
top: 26px; /* 52px 的一半 */
left: 15px;
transform: translateY(-50%);
font-size: 1.1rem;
@ -760,6 +792,8 @@ const handleForgotPassword = async () => {
}
.kt-register-card .kt-input-icon {
/* 修复位置:注册卡片输入框高 48px */
top: 24px;
left: 12px;
}
@ -769,7 +803,8 @@ const handleForgotPassword = async () => {
.kt-label {
position: absolute;
top: 50%;
/* 修复位置:改为固定像素值 */
top: 26px;
left: 45px;
transform: translateY(-50%);
font-family: var(--kt-font);
@ -782,12 +817,15 @@ const handleForgotPassword = async () => {
}
.kt-register-card .kt-label {
/* 修复位置:注册卡片输入框高 48px */
top: 24px;
left: 40px;
}
/* 浮动状态的 label 定位 */
.kt-input:focus ~ .kt-label,
.kt-input:not(:placeholder-shown) ~ .kt-label {
top: -10px;
top: -10px; /* 浮动到顶部外侧,不受 Box 高度影响 */
left: 12px;
transform: translateY(0);
font-size: 0.85rem;
@ -803,7 +841,8 @@ const handleForgotPassword = async () => {
.kt-toggle-password {
position: absolute;
top: 50%;
/* 修复位置:改为固定像素值 */
top: 26px;
right: 15px;
transform: translateY(-50%);
font-size: 1.1rem;
@ -813,6 +852,11 @@ const handleForgotPassword = async () => {
z-index: 2;
}
.kt-register-card .kt-toggle-password {
/* 注册卡片高度略小 */
top: 24px;
}
.kt-input-hint {
font-family: var(--kt-font);
font-size: 0.75rem;

@ -91,7 +91,6 @@ provide('navigateToSection', handleNavigate)
const handleNavToggle = (expanded) => { isNavExpanded.value = expanded }
// Page5 handleNavigate
const navigateToSection = (id) => { handleNavigate(id) }
provide('navigateToSection', navigateToSection)
@ -285,22 +284,36 @@ const checkRoute = () => {
}
}
//
const handleVisibilityChange = () => {
if (document.hidden) {
//
taskStore.stopPolling()
} else {
//
if (userStore.isLoggedIn) {
taskStore.startPolling()
}
}
}
onMounted(() => {
checkRoute()
window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('touchstart', handleTouchStart, { passive: true })
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('touchend', handleTouchEnd, { passive: true })
taskStore.startPolling()
// === ===
//
document.addEventListener('visibilitychange', handleVisibilityChange)
const savedNavState = localStorage.getItem('kt_nav_expanded')
// localStorage 'true'
// MainFlow.vue isNavExpanded falseNavBar isExpanded false
if (savedNavState === 'true') {
// DOM
setTimeout(() => {
handleNavToggle(true)
}, 200) // 2s00ms
}, 200)
}
})
@ -309,6 +322,9 @@ onUnmounted(() => {
window.removeEventListener('touchstart', handleTouchStart)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
//
document.removeEventListener('visibilitychange', handleVisibilityChange)
taskStore.stopPolling()
cancelCurrentAnimation()
})
@ -327,7 +343,6 @@ onUnmounted(() => {
@toggle="handleNavToggle" />
<!-- 左上角按钮区域 -->
<!-- 增加 :class="{'is-subpage': showSubpage}" 用于 CSS 区分状态 -->
<div class="kt-corner-btn-area" :class="{ 'is-subpage': showSubpage }">
<button
v-if="!showSubpage"

@ -144,23 +144,51 @@ const handleStyleChange = (style) => {
const triggerFileUpload = () => fileInput.value.click()
// +
const handleFileChange = (event) => {
const files = Array.from(event.target.files)
if (files.length > MAX_UPLOAD_COUNT) {
modal.warning(`单次最多允许上传 ${MAX_UPLOAD_COUNT} 张图片,已自动截取前 ${MAX_UPLOAD_COUNT} 张。`)
formData.value.files = files.slice(0, MAX_UPLOAD_COUNT)
} else if (files.length > 0) {
formData.value.files = files
const newFiles = Array.from(event.target.files)
if (newFiles.length === 0) return
// 1.
const uniqueNewFiles = newFiles.filter(nf =>
!formData.value.files.some(ef => ef.name === nf.name && ef.size === nf.size)
)
if (uniqueNewFiles.length === 0) {
event.target.value = ''
return modal.info('所选文件已存在')
}
//
let totalSize = 0
formData.value.files.forEach(f => totalSize += f.size)
// 2.
const currentCount = formData.value.files.length
const availableSlots = MAX_UPLOAD_COUNT - currentCount
if (totalSize > MAX_TOTAL_SIZE_MB * 1024 * 1024) {
modal.warning(`所选图片总大小超过 ${MAX_TOTAL_SIZE_MB}MB请压缩或减少图片数量。`)
clearFiles()
return
if (availableSlots <= 0) {
event.target.value = ''
return modal.warning(`已达到最大上传数量 (${MAX_UPLOAD_COUNT})`)
}
let filesToAdd = uniqueNewFiles
if (uniqueNewFiles.length > availableSlots) {
filesToAdd = uniqueNewFiles.slice(0, availableSlots)
modal.warning(`最多只能再添加 ${availableSlots} 张图片,已自动截取。`)
}
// 3.
let currentTotalSize = formData.value.files.reduce((acc, f) => acc + f.size, 0)
const validFiles = []
for (const file of filesToAdd) {
if (currentTotalSize + file.size > MAX_TOTAL_SIZE_MB * 1024 * 1024) {
modal.warning(`文件总大小超过 ${MAX_TOTAL_SIZE_MB}MB部分文件未添加。`)
break
}
currentTotalSize += file.size
validFiles.push(file)
}
// 4.
formData.value.files = [...formData.value.files, ...validFiles]
event.target.value = ''
}
@ -183,16 +211,15 @@ const submitTask = async () => {
const dataTypeId = formData.value.style === 'art' ? DATA_TYPE_MAP.ART : DATA_TYPE_MAP.FACE
// 使 QUICK (ID 10)
const algoId = ALGO_MAP.QUICK || 10
// QUICK PID使
const intensity = 0.05
const intensity = 12
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-PID-0.05')
//
payload.append('perturbation_name', `Quick-PID-${intensity}`)
formData.value.files.forEach(file => {
payload.append('files', file)
@ -243,31 +270,16 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
</script>
<style scoped>
/* 保持原有样式 */
.kt-subpage__style-content { display: flex; flex-direction: column; min-width: 0; }
.kt-subpage__upload-content { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
.kt-subpage__upload-icon.success { color: var(--kt-accent); }
.kt-subpage__upload { position: relative; }
.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); }
.kt-subpage__upload:hover { border-color: var(--kt-accent); background: var(--kt-muted); }
.kt-subpage__upload.has-file { border-style: solid; border-color: var(--kt-accent); }
.kt-clear-btn { position: absolute; top: 1rem; right: 1rem; width: 2.5rem; height: 2.5rem; background: var(--kt-bg); color: var(--kt-accent); border: var(--kt-border-width) solid var(--kt-accent); border-radius: var(--kt-radius); display: flex; align-items: center; justify-content: center; transition: all var(--kt-transition-micro); z-index: 10; cursor: pointer; }
.kt-clear-btn:hover { background: var(--kt-accent); color: var(--kt-accent-fg); transform: scale(1.1); }
/* 锁定样式 */
.kt-subpage__style-option.is-locked {
opacity: 0.6;
filter: grayscale(0.8);
cursor: not-allowed;
}
.kt-lock-icon {
margin-left: 0.5rem;
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;
}
.kt-subpage__style-option.is-locked { opacity: 0.6; filter: grayscale(0.8); cursor: not-allowed; }
.kt-lock-icon { margin-left: 0.5rem; 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>

@ -113,6 +113,9 @@
</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">
{{ formData.files[0].name }} <span v-if="formData.files.length > 1">...</span>
</p>
</div>
</div>
</div>
@ -139,7 +142,7 @@ 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 { getUserConfig } from '@/api/user'
import modal from '@/utils/modal'
import KtSelect from '@/components/KtSelect.vue'
@ -151,15 +154,15 @@ const isSubmitting = ref(false)
const isCustomMode = ref(false)
let specificPollTimer = null
const MAX_UPLOAD_COUNT = 5
const MAX_TOTAL_SIZE_MB = 15// 15MB
const MAX_TOTAL_SIZE_MB = 15
const formData = ref({ taskName: '', algorithm: '', strength: 12.0, style: 'face', files: [] })
const algorithmSettings = computed(() => {
const algoId = formData.value.algorithm
if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.CAAT_PRO].includes(algoId)) {
if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.PID, ALGO_MAP.QUICK].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, ALGO_MAP.QUICK].includes(algoId)) {
} else if ([ALGO_MAP.GLAZE, ALGO_MAP.STYLE_PROTECTION, ALGO_MAP.CAAT, ALGO_MAP.CAAT_PRO].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 }
@ -169,10 +172,7 @@ 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, (newVal, oldVal) => {
//
// loadUserDefaults
if (oldVal !== undefined && newVal !== oldVal) {
formData.value.strength = algorithmSettings.value.default
isCustomMode.value = false
@ -203,24 +203,52 @@ const setDataType = (type) => {
const selectAlgo = (algo) => { formData.value.algorithm = algo.id; isDropdownOpen.value = false }
const triggerFileUpload = () => fileInput.value.click()
// +
const handleFileChange = (event) => {
const files = Array.from(event.target.files)
if (files.length > MAX_UPLOAD_COUNT) {
modal.warning(`单次最多允许上传 ${MAX_UPLOAD_COUNT} 张图片,已自动截取前 ${MAX_UPLOAD_COUNT} 张。`)
formData.value.files = files.slice(0, MAX_UPLOAD_COUNT)
} else if (files.length > 0) {
formData.value.files = files
const newFiles = Array.from(event.target.files)
if (newFiles.length === 0) return
// 1.
const uniqueNewFiles = newFiles.filter(nf =>
!formData.value.files.some(ef => ef.name === nf.name && ef.size === nf.size)
)
if (uniqueNewFiles.length === 0) {
event.target.value = '' // input 便
return modal.info('所选文件已存在')
}
//
let totalSize = 0
formData.value.files.forEach(f => totalSize += f.size)
// 2.
const currentCount = formData.value.files.length
const availableSlots = MAX_UPLOAD_COUNT - currentCount
if (totalSize > MAX_TOTAL_SIZE_MB * 1024 * 1024) {
modal.warning(`所选图片总大小超过 ${MAX_TOTAL_SIZE_MB}MB请压缩或减少图片数量。`)
clearFiles()
return
if (availableSlots <= 0) {
event.target.value = ''
return modal.warning(`已达到最大上传数量 (${MAX_UPLOAD_COUNT})`)
}
let filesToAdd = uniqueNewFiles
if (uniqueNewFiles.length > availableSlots) {
filesToAdd = uniqueNewFiles.slice(0, availableSlots)
modal.warning(`最多只能再添加 ${availableSlots} 张图片,已自动截取。`)
}
// 3.
let currentTotalSize = formData.value.files.reduce((acc, f) => acc + f.size, 0)
const validFiles = []
for (const file of filesToAdd) {
if (currentTotalSize + file.size > MAX_TOTAL_SIZE_MB * 1024 * 1024) {
modal.warning(`文件总大小超过 ${MAX_TOTAL_SIZE_MB}MB部分文件未添加。`)
break
}
currentTotalSize += file.size
validFiles.push(file)
}
event.target.value = ''
// 4.
formData.value.files = [...formData.value.files, ...validFiles]
event.target.value = '' // input
}
const clearFiles = () => {
@ -278,41 +306,28 @@ 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) {
if (cfg.perturbation_intensity === p.low || cfg.perturbation_intensity === p.mid || cfg.perturbation_intensity === p.high) {
isCustomMode.value = false
} else {
isCustomMode.value = true //
isCustomMode.value = true
}
}
}
@ -330,7 +345,7 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
</script>
<style scoped>
/* 保持原有布局样式 */
/* 保持原有样式 */
.kt-subpage__style-content { display: flex; flex-direction: column; min-width: 0; }
.kt-subpage__upload-icon.success { color: var(--kt-accent); }
.kt-dropdown-overlay { position: fixed; inset: 0; z-index: 90; }
@ -342,17 +357,7 @@ 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-form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
/* 右侧开关约42-44px设置固定高度确保左侧无开关也被撑开同样高度 */
height: 44px;
}
.kt-form-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; 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; }
@ -365,27 +370,8 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
.kt-subpage__upload.has-file { border-style: solid; border-color: var(--kt-accent); }
.kt-clear-btn { position: absolute; top: 1rem; right: 1rem; width: 2.5rem; height: 2.5rem; background: var(--kt-bg); color: var(--kt-accent); border: var(--kt-border-width) solid var(--kt-accent); border-radius: var(--kt-radius); display: flex; align-items: center; justify-content: center; transition: all var(--kt-transition-micro); z-index: 10; cursor: pointer; }
.kt-clear-btn:hover { background: var(--kt-accent); color: var(--kt-accent-fg); transform: scale(1.1); }
/* 锁定状态 */
.kt-subpage__style-option.is-locked {
opacity: 0.6;
filter: grayscale(0.8);
cursor: not-allowed;
}
.kt-lock-icon {
margin-left: 0.5rem;
font-size: 0.9em;
color: var(--kt-muted-fg);
}
.kt-subpage__style-option.is-locked { opacity: 0.6; filter: grayscale(0.8); cursor: not-allowed; }
.kt-lock-icon { margin-left: 0.5rem; 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; }
@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>

@ -67,10 +67,7 @@
</div>
</div>
</div>
<div v-if="formData.sourceId && previewImage" class="kt-preview-area">
<span class="kt-small-text">任务首图预览:</span>
<img :src="previewImage" class="kt-preview-img" />
</div>
<!-- 移除预览区域 -->
</div>
<div v-else class="kt-subpage__form-group">
@ -124,7 +121,11 @@
<label class="kt-subpage__label">自定义提示词 (可选仅限英语描述)</label>
<input type="text" v-model="formData.customPrompt" class="kt-subpage__input" placeholder="补充状语例如standing in front of the Eiffel Tower" />
</div>
<p class="kt-input-hint">
<i class="fas fa-exclamation-circle"></i> 效果极度依赖提示词水平仅作为扩展内容
</p>
<button class="kt-btn kt-btn--primary kt-subpage__submit-btn" @click="submitTask" :disabled="isSubmitting">
<i class="fas" :class="isSubmitting ? 'fa-spinner fa-spin' : 'fa-rocket'"></i>
{{ isSubmitting ? '提交中...' : '提交微调任务' }}
@ -185,7 +186,7 @@ import TaskSideBar from '@/components/TaskSideBar.vue'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore'
import { submitFinetuneFromPerturbation, submitFinetuneFromUpload, submitEvaluateTask, submitHeatmapTask, startFinetuneTask, startEvaluateTask } from '@/api/task'
import { getTaskImagePreview } from '@/api/image'
import { getTaskImages } from '@/api/image'
import { FINETUNE_MAP } from '@/utils/constants'
import modal from '@/utils/modal'
@ -264,31 +265,43 @@ const selectSource = async (item) => {
taskImages.value = []
previewImage.value = null
isLoadingImages.value = true
try {
if (subpageType.value === 'heatmap') {
const res = await getTaskImagePreview(item.id)
if (res && res.images) {
const res = await getTaskImages(item.id)
if (res && res.images) {
if (subpageType.value === 'heatmap') {
//
const list = res.images.perturbed || res.images.original || []
taskImages.value = list.map(img => {
let rawData = img.data || img.base64 || ''
if (rawData && !rawData.startsWith('data:image')) rawData = `data:image/jpeg;base64,${rawData}`
return { ...img, image_id: img.image_id || img.id, base64: rawData }
})
}
} else {
const res = await getTaskImagePreview(item.id)
if (res && res.images) {
taskImages.value = list.map(img => ({
...img,
image_id: img.image_id,
// 使 Blob URL
base64: img.data
}))
} else {
//
let targetImg = null
if (subpageType.value === 'fine-tuning') targetImg = res.images.perturbed?.[0] || res.images.original?.[0]
else targetImg = res.images.original_generate?.[0] || res.images.perturbed_generate?.[0] || res.images.uploaded_generate?.[0]
if (subpageType.value === 'fine-tuning') {
targetImg = (res.images.perturbed && res.images.perturbed[0]) ||
(res.images.original && res.images.original[0])
} else {
targetImg = (res.images.original_generate && res.images.original_generate[0]) ||
(res.images.perturbed_generate && res.images.perturbed_generate[0]) ||
(res.images.uploaded_generate && res.images.uploaded_generate[0])
}
if (targetImg) {
let raw = targetImg.data || targetImg.base64 || ''
if (raw && !raw.startsWith('data:image')) raw = `data:image/jpeg;base64,${raw}`
previewImage.value = raw
// 使 Blob URL
previewImage.value = targetImg.data
}
}
}
} catch (error) { console.error('获取图片失败:', error) } finally { isLoadingImages.value = false }
} catch (error) {
console.error('获取图片失败:', error)
} finally {
isLoadingImages.value = false
}
}
const selectImage = (img) => formData.value.selectedImageId = img.image_id
@ -322,7 +335,6 @@ const clearFiles = () => {
const submitTask = async () => {
if (!formData.value.taskName) return modal.warning('请输入任务名称')
//
if (finetuneMode.value === 'upload' && !userStore.isVip) return modal.warning('权限不足:上传微调仅限 VIP')
if (formData.value.dataType === 2 && !userStore.isVip) return modal.warning('权限不足:艺术品微调仅限 VIP')
@ -414,10 +426,6 @@ onMounted(() => taskStore.fetchTasks())
.kt-finetune-option.active * { color: var(--kt-accent-fg) !important; }
.kt-finetune-title { font-family: var(--kt-font); font-weight: 700; font-size: var(--kt-small); color: var(--kt-fg); text-transform: uppercase; }
.kt-finetune-desc { font-family: var(--kt-font); font-size: clamp(0.625rem, 1vw, 0.75rem); color: var(--kt-muted-fg); }
.kt-preview-area { margin-top: 1rem; background: var(--kt-muted); border-radius: var(--kt-radius); padding: 1rem; border: var(--kt-border-width) dashed var(--kt-border); display: flex; align-items: center; gap: 1rem; }
.kt-preview-img { height: 60px; width: 60px; object-fit: cover; border-radius: var(--kt-radius); border: var(--kt-border-width) solid var(--kt-border); }
.kt-image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 1rem; border: var(--kt-border-width) dashed var(--kt-border); border-radius: var(--kt-radius); padding: 1rem; background: var(--kt-bg); min-height: 150px; max-height: 300px; overflow-y: auto; }
.kt-image-item { aspect-ratio: 1; border-radius: var(--kt-radius); overflow: hidden; position: relative; cursor: pointer; border: var(--kt-border-width) solid var(--kt-border); transition: all var(--kt-transition-micro); background: var(--kt-muted); }
.kt-image-item img { width: 100%; height: 100%; object-fit: cover; }
@ -443,4 +451,16 @@ onMounted(() => taskStore.fetchTasks())
margin-top: 0.25rem;
letter-spacing: 0;
}
.kt-input-hint {
font-family: var(--kt-font);
font-size: 0.85rem;
color: var(--kt-muted-fg);
margin-top: 0.25rem;
margin-bottom: 1.5rem;
line-height: 1.4;
display: flex;
align-items: center;
gap: 0.3rem;
}
</style>

@ -56,8 +56,8 @@
<!-- Desktop Table Header (Sticky) -->
<div class="kt-table-header">
<div class="kt-col kt-col--id kt-sortable" @click="handleSort('task_id', $event)">
ID <i :class="getSortIcon('task_id')" aria-hidden="true"></i>
<div class="kt-col kt-col--id kt-sortable" @click="handleSort('virtual_id', $event)">
ID <i :class="getSortIcon('virtual_id')" aria-hidden="true"></i>
</div>
<div class="kt-col kt-col--name kt-sortable" @click="handleSort('name', $event)">
任务名称 <i :class="getSortIcon('name')" aria-hidden="true"></i>
@ -210,6 +210,7 @@
<ImagePreviewModal
:is-open="showPreview"
:task-id="previewTaskId"
:virtual-id="previewVirtualId"
:task-type="previewTaskType"
@close="showPreview = false"
/>
@ -250,17 +251,18 @@
<script setup>
import KtSelect from '@/components/KtSelect.vue'
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import { useUserStore } from '@/stores/userStore'
import { getTaskResultImages, cancelTask, getTaskLogs, restartTask, deleteTask } from '@/api/task'
import { cancelTask, getTaskLogs, restartTask, deleteTask } from '@/api/task'
import { getTaskImages } from '@/api/image'
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: 'perturbation' },
{ label: '微调验证', value: 'finetune' },
{ label: '数据评估', value: 'evaluate' },
{ label: '热力图', value: 'heatmap' }
@ -268,23 +270,27 @@ const taskTypeOptions = [
const taskStore = useTaskStore()
const userStore = useUserStore()
// Filter state
const currentStatus = ref('all')
const searchKeyword = ref('')
const selectedTaskType = ref('all')
//
watch([searchKeyword, selectedTaskType], () => {
currentPage.value = 1
})
const statusTabs = [
{ key: 'all', label: '全部' },
{ key: 'running', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'failed', label: '失败' } // Cancelled
{ key: 'failed', label: '失败' }
]
const sortRules = ref([{ field: 'created_at', direction: 'desc' }])
const currentPage = ref(1)
const pageSize = ref(10)
const showPreview = ref(false)
const previewTaskId = ref(null)
const previewVirtualId = ref(null)
const previewTaskType = ref('')
const showLogModal = ref(false)
@ -314,7 +320,7 @@ const formatStatusLabel = (s) => {
const formatTypeLabel = (t) => {
const map = {
perturbation: '通用防护',
perturbation: '加噪防护',
finetune: '微调验证',
evaluate: '数据评估',
heatmap: '热力图'
@ -335,7 +341,6 @@ const formatDate = (iso) => iso ? new Date(iso).toLocaleString('zh-CN', { hour12
const handleStatusChange = (status) => { currentStatus.value = status; currentPage.value = 1 }
const filteredAndSortedTasks = computed(() => {
// 1. ID 1 virtual_id
const sortedByPk = [...(taskStore.tasks || [])].sort((a, b) => a.task_id - b.task_id);
let result = sortedByPk.map((t, index) => ({
...t,
@ -343,9 +348,6 @@ const filteredAndSortedTasks = computed(() => {
}));
if (currentStatus.value !== 'all') {
// failed cancelled
// '' Tab Cancelled
// 'cancelled' 'failed' Tab
if (currentStatus.value === 'failed') {
result = result.filter(t => ['failed', 'cancelled'].includes(t.status?.toLowerCase()))
} else {
@ -360,11 +362,11 @@ const filteredAndSortedTasks = computed(() => {
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase().trim()
result = result.filter(t => {
const virtualIdMatch = String(t.virtual_id).includes(keyword)
const idMatch = String(t.task_id).includes(keyword)
const nameMatch = getTaskName(t).toLowerCase().includes(keyword)
return idMatch || nameMatch
return virtualIdMatch || idMatch || nameMatch
})
}
if (sortRules.value.length > 0) {
@ -399,12 +401,13 @@ const getSortIcon = (field) => {
const handlePreview = (task) => {
previewTaskId.value = task.task_id
previewVirtualId.value = task.virtual_id
previewTaskType.value = task.task_type
showPreview.value = true
}
const handleCancel = async (task) => {
const confirmed = await modal.confirm(`确定要终止任务 #${task.task_id} 吗?`)
const confirmed = await modal.confirm(`确定要终止任务 #${task.virtual_id} 吗?`)
if (!confirmed) return
try {
await cancelTask(task.task_id)
@ -417,7 +420,7 @@ const handleCancel = async (task) => {
}
const handleRestart = async (task) => {
const confirmed = await modal.confirm(`确定要重启任务 #${task.task_id} 吗?`)
const confirmed = await modal.confirm(`确定要重启任务 #${task.virtual_id} 吗?`)
if (!confirmed) return
try {
await restartTask(task.task_id)
@ -430,12 +433,20 @@ const handleRestart = async (task) => {
}
const handleDelete = async (task) => {
const confirmed = await modal.confirm(`确定要彻底删除任务 #${task.task_id} 吗?此操作无法撤销。`)
const confirmed = await modal.confirm(`确定要彻底删除任务 #${task.virtual_id} 吗?此操作无法撤销。`)
if (!confirmed) return
try {
await deleteTask(task.task_id)
modal.success('任务已彻底删除')
taskStore.fetchTasks()
await taskStore.fetchTasks() //
//
const totalItems = filteredAndSortedTasks.value.length
const maxPage = Math.ceil(totalItems / pageSize.value) || 1
if (currentPage.value > maxPage) {
currentPage.value = maxPage
}
} catch (e) {
console.error(e)
modal.error('删除失败: ' + e.message)
@ -443,7 +454,7 @@ const handleDelete = async (task) => {
}
const handleViewLogs = async (task) => {
currentLogTaskId.value = task.task_id
currentLogTaskId.value = task.virtual_id
logContent.value = ''
showLogModal.value = true
@ -461,32 +472,50 @@ const handleViewLogs = async (task) => {
const handleDownload = async (task) => {
if (task.status !== 'completed') return modal.warning('任务未完成')
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())
// 1. getTaskImages
const res = await getTaskImages(task.task_id)
const zip = new JSZip()
const folder = zip.folder(`task_${task.task_id}`)
const folder = zip.folder(`task_${task.virtual_id}`)
let hasImg = false
const processImg = (img, prefix='') => {
const d = img.data || img.base64
let n = img.filename || img.stored_filename || `${prefix}_${img.image_id}.png`
if (d) { folder.file(n, d.includes(',') ? d.split(',')[1] : d, { base64: true }); hasImg = true }
}
if (res.images) res.images.forEach(i => processImg(i))
else {
['original_generate', 'perturbed_generate', 'uploaded_generate'].forEach(k => {
if (res[k]) res[k].forEach(i => processImg(i, k))
// 2.
if (res && res.images) {
Object.keys(res.images).forEach(type => {
const list = res.images[type]
if (Array.isArray(list)) {
list.forEach(img => {
if (img.blob) {
// 使
const fileName = img.filename || `${type}_${img.image_id}.png`
// Blob zip
folder.file(fileName, img.blob)
hasImg = true
}
})
}
})
}
if (!hasImg) return modal.warning('无图片数据')
// 3. 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 = `task_${task.virtual_id}.zip`
document.body.appendChild(link)
link.click()
} catch (e) { modal.error('下载出错: ' + e.message) }
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (e) {
console.error(e)
modal.error('下载出错: ' + (e.message || '未知错误'))
}
}
onMounted(() => {
@ -500,24 +529,9 @@ onMounted(() => {
</script>
<style scoped>
.kt-page4 {
min-height: 100%;
display: flex;
flex-direction: column;
background: var(--kt-bg);
overflow-x: hidden;
padding: 0 0 4rem 0;
}
.kt-header {
padding: 4rem var(--kt-container-px) 2rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: 2rem;
}
/* 样式保持不变 */
.kt-page4 { min-height: 100%; display: flex; flex-direction: column; background: var(--kt-bg); overflow-x: hidden; padding: 0 0 4rem 0; }
.kt-header { padding: 4rem var(--kt-container-px) 2rem; display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 2rem; }
.kt-header__content { flex: 1; min-width: 200px; }
.kt-header__title-cn { font-family: var(--kt-font); font-size: clamp(2.5rem, 8vw, 6rem); font-weight: 700; line-height: 0.9; letter-spacing: -0.02em; text-transform: uppercase; color: var(--kt-fg); margin: 0 0 0.5rem 0; }
.kt-header__title-en { font-family: var(--kt-font); font-size: clamp(0.875rem, 1.5vw, 1.25rem); color: var(--kt-muted-fg); text-transform: uppercase; letter-spacing: 0.2em; font-weight: 400; margin: 0; }
@ -539,125 +553,27 @@ onMounted(() => {
.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); }
/* =========================================================================
CORE FIX: Table Layout
========================================================================= */
/* 1. Scroll Area: Wraps Header and Body to sync horizontal scroll */
.kt-table-scroll-area {
flex: 1;
overflow: auto; /* Enable X and Y scrolling here */
position: relative;
display: flex;
flex-direction: column;
}
/* 2. Grid Definitions: STRICT FIXED WIDTHS to prevent collapse */
/* Common grid for Header and Row */
.kt-table-header, .kt-table-row {
display: grid;
/*
ID: 80px
Name: 1fr (Takes remaining space, min 250px)
Type: 120px
Time: 200px
Status: 120px
Action: 240px (Wide enough for 4 buttons)
*/
grid-template-columns:
80px
minmax(250px, 1fr)
120px
200px
120px
240px;
/* FORCE MIN-WIDTH: Triggers horizontal scroll if viewport is too small */
min-width: 1100px;
align-items: center;
gap: 1rem;
}
/* Header Specifics */
.kt-table-header {
padding: 1rem 2rem;
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);
/* Sticky Positioning */
position: sticky;
top: 0;
z-index: 20; /* Ensure above rows */
}
/* Body Specifics */
.kt-table-body {
/* Let the scroll area handle scrolling, body just expands */
display: flex;
flex-direction: column;
min-width: fit-content; /* Ensure it matches the header's forced width */
}
.kt-table-row {
padding: 1.25rem 2rem;
border-bottom: var(--kt-border-width) solid var(--kt-border);
background: var(--kt-bg);
transition: background var(--kt-transition-micro);
}
.kt-table-scroll-area { flex: 1; overflow: auto; position: relative; display: flex; flex-direction: column; }
.kt-table-header, .kt-table-row { display: grid; grid-template-columns: 80px minmax(250px, 1fr) 120px 200px 120px 240px; min-width: 1100px; align-items: center; gap: 1rem; }
.kt-table-header { padding: 1rem 2rem; 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); position: sticky; top: 0; z-index: 20; }
.kt-table-body { display: flex; flex-direction: column; min-width: fit-content; }
.kt-table-row { padding: 1.25rem 2rem; 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); }
/* Column Alignments */
.kt-col { display: flex; align-items: center; }
.kt-col--action { justify-content: flex-end; gap: 0.5rem; }
/* ------------------------------------------------------------------------- */
.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-id-text { font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 600; color: var(--kt-muted-fg); }
.kt-task-name { font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 600; color: var(--kt-fg); white-space: normal; word-break: break-word; line-height: 1.4; }
.kt-time-text { font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-muted-fg); font-variant-numeric: tabular-nums; }
.kt-type-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: 600; text-transform: uppercase; letter-spacing: 0.05em; background: var(--kt-muted); color: var(--kt-fg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); white-space: nowrap; }
.kt-status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
/* 减小内边距 */
padding: 0.15rem 0.5rem;
font-family: var(--kt-font);
/* 固定较小的字体 */
font-size: 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);
/* 移除较大的最小宽度限制 (原为80px) */
min-width: 50px;
/* 让宽度自适应内容,不再强制撑大 */
width: fit-content;
white-space: nowrap;
}
.kt-status-badge { display: inline-flex; align-items: center; justify-content: center; padding: 0.15rem 0.5rem; font-family: var(--kt-font); font-size: 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); min-width: 50px; width: fit-content; white-space: nowrap; }
.kt-status--completed { background: var(--kt-accent); color: var(--kt-accent-fg); border-color: var(--kt-accent); }
.kt-status--running { background: transparent; color: var(--kt-accent); border-color: var(--kt-accent); }
.kt-status--failed { background: #991b1b; color: #fef2f2; border-color: #991b1b; }
/* 已取消状态样式 */
.kt-status--cancelled { background: var(--kt-muted); color: var(--kt-muted-fg); border-color: var(--kt-border); }
.kt-action-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; font-family: var(--kt-font); font-size: var(--kt-small); background: transparent; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-muted-fg); cursor: pointer; transition: all var(--kt-transition-micro); flex-shrink: 0; }
.kt-action-btn:hover { background: var(--kt-muted); color: var(--kt-fg); transform: scale(1.05); }
.kt-action-btn:active { transform: scale(0.95); }
@ -672,15 +588,6 @@ onMounted(() => {
.kt-page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.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-mobile-view {
display: none !important; /* 强制隐藏 */
}
.kt-desktop-only {
display: flex !important; /* 确保显示 */
}
/* Log Modal (Keep existing styles) */
.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; }
.kt-log-modal { width: 90vw; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; background: #ffffff; border: 1px solid #e5e5e5; border-radius: var(--kt-radius); overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); }
.kt-log-header { padding: 1.5rem 2rem; background: #f3f3f3; border-bottom: 1px solid #e5e5e5; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
@ -698,7 +605,6 @@ onMounted(() => {
.kt-log-content pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace; font-size: 0.9rem; line-height: 1.6; color: #333333; }
.kt-log-loading { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: #666666; font-family: var(--kt-font); }
.kt-log-loading i { font-size: 2rem; color: #007acc; }
html.dark-mode .kt-log-modal { background: #1e1e1e; border: 1px solid #333333; }
html.dark-mode .kt-log-header { background: #252526; border-bottom: 1px solid #333333; }
html.dark-mode .kt-log-title { color: #cccccc; }
@ -712,28 +618,45 @@ html.dark-mode .kt-log-content::-webkit-scrollbar-thumb:hover { background: #4f4
html.dark-mode .kt-log-content pre { color: #d4d4d4; }
html.dark-mode .kt-log-loading { color: #cccccc; }
/* Mobile Responsive */
/*
* 大比例缩放/小屏时的显示
* 1. 移除基础样式中对 .kt-mobile-view !important允许媒体查询覆盖
* 2. 移除基础样式中对 .kt-desktop-only !important确保 flex 属性正常工作
* 3. 在移动端媒体查询中明确覆盖 display 属性
*/
/* 默认状态(桌面端) */
.kt-mobile-view {
display: none; /* 默认隐藏移动端视图 */
}
/* 移动端适配 */
@media (max-width: 900px) {
.kt-header { padding: 2rem var(--kt-container-px) 1rem; }
.kt-table-header { display: none; } /* Hide Desktop Header */
/* 隐藏桌面端列 */
.kt-desktop-only { display: none !important; }
.kt-mobile-view { display: block; width: 100%; }
.kt-table-header { display: none !important; }
/* Reset Grid for Mobile Card Layout */
/* 显示移动端视图 */
.kt-mobile-view { display: block !important; width: 100%; }
/* 调整表格行布局为纵向堆叠 */
.kt-table-row {
display: flex;
display: flex !important;
flex-direction: column;
gap: 1rem;
padding: 1.5rem 1rem;
min-width: auto; /* Allow shrink on mobile */
align-items: stretch;
min-width: auto !important; /* 取消最小宽度限制,适应屏幕 */
width: 100% !important; /* 确保占满容器 */
align-items: stretch;
}
/* 调整移动端表头布局 */
.kt-mobile-header { display: flex; justify-content: space-between; align-items: center; }
.kt-id-badge { font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 700; color: var(--kt-muted-fg); background: var(--kt-muted); padding: 0.25rem 0.5rem; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); }
.kt-mobile-name { font-family: var(--kt-font); font-size: var(--kt-body); font-weight: 700; color: var(--kt-fg); line-height: 1.3; }
.kt-mobile-meta { display: flex; justify-content: space-between; align-items: center; font-size: var(--kt-small); color: var(--kt-muted-fg); }
/* 操作栏调整 */
.kt-col--action {
margin-top: 0.5rem;
padding-top: 1rem;
@ -748,7 +671,13 @@ html.dark-mode .kt-log-loading { color: #cccccc; }
.kt-toolbar { flex-direction: column; align-items: stretch; padding: 1rem; gap: 1rem; }
.kt-search-box, .kt-select { width: 100%; }
.kt-type-filter { width: 100%; }
.kt-type-filter div { width: 100% !important; } /* Override inline style */
.kt-type-filter div { width: 100% !important; }
/* 强制表格主体宽度 */
.kt-table-body {
min-width: 100% !important;
width: 100%;
}
}
.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; }

@ -148,7 +148,7 @@
</div>
<!-- A/B. 普通弹窗 (修改密码/默认配置) -->
<div v-else class="kt-modal-wrapper">
<div v-else-if="subpageType === 'password' || subpageType === 'config'" 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>
@ -159,15 +159,40 @@
<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" placeholder="输入当前密码" />
<div class="kt-input-wrapper">
<input
:type="pwdVisibility.old ? 'text' : 'password'"
v-model="pwdForm.oldPassword"
class="kt-form-input"
placeholder="输入当前密码"
/>
<i class="fas kt-input-eye" :class="pwdVisibility.old ? 'fa-eye-slash' : 'fa-eye'" @click="pwdVisibility.old = !pwdVisibility.old"></i>
</div>
</div>
<div class="kt-form-group">
<label class="kt-form-label">新密码</label>
<input type="password" v-model="pwdForm.newPassword" class="kt-form-input" placeholder="输入新密码" />
<div class="kt-input-wrapper">
<input
:type="pwdVisibility.new ? 'text' : 'password'"
v-model="pwdForm.newPassword"
class="kt-form-input"
placeholder="输入新密码"
/>
<i class="fas kt-input-eye" :class="pwdVisibility.new ? 'fa-eye-slash' : 'fa-eye'" @click="pwdVisibility.new = !pwdVisibility.new"></i>
</div>
<p class="kt-input-hint">密码至少8位且需包含大小写字母数字和符号</p>
</div>
<div class="kt-form-group">
<label class="kt-form-label">确认新密码</label>
<input type="password" v-model="pwdForm.confirmPassword" class="kt-form-input" placeholder="再次输入新密码" />
<div class="kt-input-wrapper">
<input
:type="pwdVisibility.confirm ? 'text' : 'password'"
v-model="pwdForm.confirmPassword"
class="kt-form-input"
placeholder="再次输入新密码"
/>
<i class="fas kt-input-eye" :class="pwdVisibility.confirm ? 'fa-eye-slash' : 'fa-eye'" @click="pwdVisibility.confirm = !pwdVisibility.confirm"></i>
</div>
</div>
<div class="kt-form-actions">
<button class="kt-btn kt-btn--primary" @click="submitPassword"></button>
@ -226,7 +251,16 @@
</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" placeholder="输入密码" />
<div class="kt-input-wrapper">
<input
:type="pwdVisibility.createUser ? 'text' : 'password'"
v-model="userForm.password"
class="kt-form-input"
placeholder="输入密码"
/>
<i class="fas kt-input-eye" :class="pwdVisibility.createUser ? 'fa-eye-slash' : 'fa-eye'" @click="pwdVisibility.createUser = !pwdVisibility.createUser"></i>
</div>
<p class="kt-input-hint">密码至少8位且需包含大小写字母数字和符号</p>
</div>
<div class="kt-form-group">
<label class="kt-form-label">邮箱</label>
@ -254,7 +288,7 @@ import { ref, computed, onMounted, reactive, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
authChangePassword, getUserConfig, updateUserConfig,
getAdminUserList, createAdminUser, updateAdminUser, deleteAdminUser
getAdminUserList, createAdminUser, updateAdminUser, deleteAdminUser, authGetProfile
} from '@/api/index'
import { upgradeToVip } from '@/api/user'
import { generateVipCodes } from '@/api/admin'
@ -284,16 +318,51 @@ const modalTitleEn = computed(() => {
const dataTypeOptions = [{ label: '通用人脸防护', value: 1 }, { label: '通用艺术品防护', value: 2 }]
const roleOptions = [{ label: '普通用户 (Normal)', value: 'normal' }, { label: 'VIP用户', value: 'vip' }, { label: '管理员 (Admin)', value: 'admin' }]
//
const pwdVisibility = reactive({
old: false,
new: false,
confirm: false,
createUser: false
})
const vipUpgradeCode = ref('')
//VIP
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); handleClose() } catch (e) { console.error(e) }
if (!vipUpgradeCode.value) {
return modal.warning('请输入邀请码')
}
try {
// 1.
await upgradeToVip({ vip_code: vipUpgradeCode.value });
// 2.
await modal.show({
type: 'success',
title: '升级成功',
message: '恭喜您已成功成为 VIP 用户!请重新登录以激活全部权益。',
confirmText: '重新登录',
showCancel: false,
closeOnOverlay: false //
});
// 3.
userStore.logout();
router.push('/login');
} catch (e) {
console.error(e);
//
modal.error(e.message || '升级失败,请检查邀请码是否有效');
}
}
const vipGenForm = ref({ days: 30, count: 1 }); const generatedCodes = ref([])
const handleGenerateCodes = async () => {
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('生成数量必须为正整数')
if (vipGenForm.value.count > 10) return modal.warning('单次生成数量不能超过 10 个')
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 = async (code) => {
@ -306,8 +375,8 @@ const copyCode = async (code) => {
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 }
if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.PID, ALGO_MAP.QUICK].includes(algoId)) return { min: 10, max: 16, step: 1, presets: { low: 10, mid: 12, high: 16 }, default: 12 }
if ([ALGO_MAP.GLAZE, ALGO_MAP.STYLE_PROTECTION, ALGO_MAP.CAAT, ALGO_MAP.CAAT_PRO].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 }))
@ -323,37 +392,36 @@ const submitConfig = async () => { await updateUserConfig(configForm.value); mod
// === Password Logic () ===
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' })
const validatePasswordStrength = (pwd) => {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._-])[A-Za-z\d@$!%*?&._-]{8,}$/;
return regex.test(pwd);
}
const submitPassword = async () => {
// 1.
if (!pwdForm.value.oldPassword || !pwdForm.value.newPassword || !pwdForm.value.confirmPassword) {
return modal.warning('请填写完整的密码信息')
}
// 2.
if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) {
return modal.warning('两次输入的新密码不一致')
}
if (!validatePasswordStrength(pwdForm.value.newPassword)) {
return modal.warning('新密码强度不足需至少8位且包含大小写字母、数字和符号')
}
try {
// 3. API
// _skipLogout401 catch
await authChangePassword({
old_password: pwdForm.value.oldPassword,
new_password: pwdForm.value.newPassword
})
// 4. -> ->
// modal.success Promise resolve
await modal.success('密码修改成功,请使用新密码重新登录')
//
userStore.logout()
router.push('/login')
} catch(e) {
// 5. ->
console.error('修改密码失败:', e)
//
const errorMsg = e.message || '修改失败,请检查旧密码是否正确'
await modal.error(errorMsg)
}
@ -385,60 +453,7 @@ onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpage
</script>
<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;
}
.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: 1;
}
.kt-list-row {
padding: 1.5rem 1.5rem;
border-bottom: var(--kt-border-width) solid var(--kt-border);
transition: background var(--kt-transition-micro);
}
.kt-list-row:hover { background: var(--kt-muted); }
/* 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; }
@ -460,20 +475,40 @@ onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpage
/* 确保块级堆叠 */
.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-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; }
/* Input Wrapper 和 Icon 定位 */
.kt-input-wrapper { position: relative; width: 100%; }
.kt-form-input {
display: block;
width: 100%;
padding: 1rem;
padding-right: 2.5rem; /* 为眼睛图标留空 */
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 */
box-sizing: border-box;
}
.kt-form-input:focus { border-color: var(--kt-accent); outline: none; }
.kt-input-eye {
position: absolute;
top: 50%;
right: 15px;
transform: translateY(-50%);
color: var(--kt-muted-fg);
cursor: pointer;
transition: color 0.2s;
z-index: 5;
}
.kt-input-eye:hover { color: var(--kt-fg); }
/* 提示文字样式 */
.kt-input-hint { font-family: var(--kt-font); font-size: 0.75rem; color: var(--kt-muted-fg); margin-top: 0.5rem; line-height: 1.4; }
.kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1rem; }
/* 7. 其他组件 */
@ -497,6 +532,15 @@ onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpage
.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; }
/* 管理员列表样式 */
.kt-list-header, .kt-list-row { display: grid; grid-template-columns: 0.8fr 1.5fr 2.5fr 1fr 1.2fr; gap: 1rem; align-items: center; }
.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: 1; }
.kt-list-row { padding: 1.5rem 1.5rem; border-bottom: var(--kt-border-width) solid var(--kt-border); transition: background var(--kt-transition-micro); }
.kt-list-row:hover { background: var(--kt-muted); }
.kt-col--center { justify-content: center !important; text-align: center; }
.kt-col--right { justify-content: flex-end !important; text-align: right; }
.kt-sortable { cursor: pointer; display: inline-flex; align-items: center; gap: 0.5rem; transition: color var(--kt-transition-micro); white-space: nowrap; }
@media (max-width: 900px) {
.kt-modal-card--fullscreen { height: auto; }
.kt-list-header { display: none; }

Loading…
Cancel
Save