navbar样式debug #52

Merged
hnu202326010204 merged 7 commits from yangyixuan_branch into develop 6 days ago

@ -54,7 +54,7 @@
/* Requirements: 4.6 - Container query units for navbar and spacing */
/* These provide fallback values for browsers that don't support container queries */
--cq-navbar-width-collapsed: 8cqw;
--cq-navbar-width-expanded: 19cqw;
--cq-navbar-width-expanded: 13cqw;
--cq-navbar-spacing: 1.5cqw;
--cq-navbar-button-size: 3.5cqw;
@ -1552,7 +1552,7 @@ a:focus-visible,
:root {
/* Fallback navbar widths using viewport units */
--cq-navbar-width-collapsed: 6vw;
--cq-navbar-width-expanded: 20vw;
--cq-navbar-width-expanded: 13vw;
--cq-navbar-spacing: 1.5vw;
--cq-navbar-button-size: 3vw;

@ -82,10 +82,15 @@ export function authLogout() {
})
}
/**
* 修改密码
* _skipLogout 标志防止旧密码错误时被强制登出
*/
export function authChangePassword(data) {
return request({
url: '/auth/change-password',
method: 'post',
data
data,
_skipLogout: true
})
}

@ -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,73 +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.uploaded_generate || imgs.perturbed_generate) return 'Finetuned (微调生成)'
if (imgs.perturbed && imgs.perturbed.length > 0) return 'Perturbed (加噪图片)'
if ((imgs.uploaded_generate && imgs.uploaded_generate.length > 0) ||
(imgs.perturbed_generate && imgs.perturbed_generate.length > 0)) {
return 'Finetuned (微调生成)'
}
return 'Protected (防护后)'
})
@ -380,272 +362,394 @@ const close = () => { emit('close') }
/* ===== KT Style Image Preview Modal ===== */
.kt-preview-overlay {
position: fixed; inset: 0;
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;
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;
flex-direction: column;
overflow-y: auto;
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: 1rem 0;
}
.kt-preview-divider { transform: rotate(90deg); margin: 1cqw 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;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--kt-bg);
flex-shrink: 0;
}
.kt-preview-header-info h3 {
margin: 0;
.kt-preview-header-info h3 {
margin: 0;
font-family: var(--kt-font);
font-weight: 700;
font-size: var(--cq-font-lg);
color: var(--kt-fg);
font-size: 1.5rem;
color: var(--kt-fg);
text-transform: uppercase;
letter-spacing: -0.02em;
}
.kt-preview-task-tag {
.kt-preview-task-tag {
font-family: var(--kt-font);
font-size: var(--cq-font-xs);
background: var(--kt-muted);
padding: 0.2cqw 0.8cqw;
border-radius: var(--kt-radius);
font-size: 0.8rem;
background: var(--kt-muted);
padding: 0.2rem 0.6rem;
border-radius: var(--kt-radius);
border: 1px solid var(--kt-border);
color: var(--kt-fg);
margin-top: 0.4cqw;
display: inline-block;
margin-right: 0.5cqw;
color: var(--kt-fg);
margin-top: 0.4rem;
display: inline-block;
margin-right: 0.5rem;
}
.kt-preview-mode-tag {
.kt-preview-mode-tag {
font-family: var(--kt-font);
font-size: 0.75cqw;
background: var(--kt-accent);
color: var(--kt-accent-fg);
padding: 0.2cqw 0.8cqw;
border-radius: var(--kt-radius);
font-weight: 600;
font-size: 0.75rem;
background: var(--kt-accent);
color: var(--kt-accent-fg);
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);
.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);
cursor: pointer;
color: var(--kt-fg);
padding: 0.4cqw 0.8cqw;
font-size: 1.25rem;
cursor: pointer;
color: var(--kt-fg);
padding: 0.4rem 0.8rem;
transition: all var(--kt-transition-micro);
}
.kt-preview-close-btn:hover {
background: var(--kt-accent);
.kt-preview-close-btn:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
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;
padding: 0;
overflow: hidden;
/* block 布局让 padding 生效 */
display: block;
padding: 0;
overflow-y: auto;
/* 让 Body 内部滚动 */
}
.kt-preview-loading, .kt-preview-error {
width: 100%;
text-align: center;
color: var(--kt-fg);
.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);
font-family: var(--kt-font);
}
.kt-preview-error { color: var(--kt-accent); }
.kt-preview-loading i { color: var(--kt-accent); margin-bottom: 2cqw; }
.kt-preview-error {
color: var(--kt-accent);
}
.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%;
background: var(--kt-bg);
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;
display: flex;
flex-direction: column;
position: relative;
border: 2px solid var(--kt-border);
padding: 1rem;
}
/* 导航箭头 */
.kt-preview-nav-controls {
position: absolute;
top: 50%; width: 100%; left: 0;
display: flex; justify-content: space-between;
top: 50%;
width: 100%;
left: 0;
display: flex;
justify-content: space-between;
transform: translateY(-50%);
pointer-events: none;
padding: 0 1cqw;
pointer-events: none;
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);
color: var(--kt-fg);
display: flex; align-items: center; justify-content: center;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--kt-transition-micro);
font-family: var(--kt-font);
}
.kt-preview-nav-arrow:hover {
background: var(--kt-accent);
.kt-preview-nav-arrow:hover {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
}
.kt-preview-nav-arrow:disabled { opacity: 0.3; cursor: not-allowed; }
.kt-preview-nav-arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.kt-preview-img-label {
position: absolute; top: 1.5cqw; left: 1.5cqw;
background: var(--kt-fg); color: var(--kt-bg);
padding: 0.4cqw 1.2cqw;
border-radius: var(--kt-radius);
font-size: var(--cq-font-xs);
position: absolute;
top: 1rem;
left: 1rem;
background: var(--kt-fg);
color: var(--kt-bg);
padding: 0.4rem 1rem;
border-radius: var(--kt-radius);
font-size: 0.8rem;
font-family: var(--kt-font);
font-weight: 600;
z-index: 2;
z-index: 2;
border: 2px solid var(--kt-border);
text-transform: uppercase;
}
.kt-preview-result-label { background: var(--kt-accent); color: var(--kt-accent-fg); }
.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;
background: var(--kt-muted); color: var(--kt-fg);
padding: 0.4cqw 1cqw;
border-radius: var(--kt-radius);
font-size: var(--cq-font-xs);
position: absolute;
top: 1rem;
right: 1rem;
background: var(--kt-muted);
color: var(--kt-fg);
padding: 0.4rem 1rem;
border-radius: var(--kt-radius);
font-size: 0.8rem;
font-family: var(--kt-font);
z-index: 2;
z-index: 2;
border: 1px solid var(--kt-border);
}
.kt-preview-img-box img {
width: 100%; height: 100%; object-fit: contain; border-radius: var(--kt-radius);
width: 100%;
height: 100%;
object-fit: contain;
border-radius: var(--kt-radius);
}
.kt-preview-divider {
color: var(--kt-fg);
font-size: 2rem;
opacity: 0.5;
/* 垂直居中图标 */
align-self: center;
}
.kt-preview-divider { color: var(--kt-fg); font-size: var(--cq-font-2xl); opacity: 0.5; }
.kt-preview-spacer { width: 1cqw; }
.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);
.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: 0.9rem;
font-family: var(--kt-font);
line-height: 1.6;
flex-direction: column;
line-height: 1.6;
flex-direction: column;
}
/* === 报告视图 (热力图/评估报告) === */
.kt-preview-report-stage {
width: 100%; height: 100%;
.kt-preview-report-stage {
width: 100%;
min-height: 100%;
background: var(--kt-bg);
display: flex; justify-content: center;
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;
background: var(--kt-bg);
.kt-preview-report-container {
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;
text-align: center;
margin: 1cqw;
padding: 2rem;
text-align: center;
/* margin: 1rem; */
}
.kt-preview-report-header-tip {
color: var(--kt-fg);
font-size: var(--cq-font-sm);
.kt-preview-report-header-tip {
color: var(--kt-fg);
font-size: 1rem;
font-family: var(--kt-font);
font-weight: 600;
margin-bottom: 2cqw; padding: 1cqw 1.5cqw;
background: var(--kt-muted);
border-radius: var(--kt-radius);
display: inline-block;
margin-bottom: 2rem;
padding: 1rem 1.5rem;
background: var(--kt-muted);
border-radius: var(--kt-radius);
display: inline-block;
border: 2px solid var(--kt-border);
text-transform: uppercase;
}
.kt-preview-sub-tip {
display: block;
font-size: var(--cq-font-xs);
font-weight: 400;
color: var(--kt-muted-fg);
margin-top: 0.4cqw;
.kt-preview-sub-tip {
display: block;
font-size: 0.8rem;
font-weight: 400;
color: var(--kt-muted-fg);
margin-top: 0.4rem;
text-transform: none;
}
.kt-preview-report-img { width: 100%; height: auto; display: block; border-radius: var(--kt-radius); }
.kt-preview-report-img {
width: 100%;
height: auto;
display: block;
border-radius: var(--kt-radius);
}
/* 底部 */
.kt-preview-footer {
padding: 1.5cqw;
background: var(--kt-bg);
border-top: 2px solid var(--kt-border);
display: flex; justify-content: center; flex-shrink: 0;
.kt-preview-footer {
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;
background: var(--kt-muted);
.kt-preview-thumb-item {
width: 3rem;
height: 3rem;
background: var(--kt-muted);
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
font-weight: bold;
border-radius: var(--kt-radius);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-weight: bold;
font-family: var(--kt-font);
color: var(--kt-fg);
color: var(--kt-fg);
transition: all var(--kt-transition-micro);
}
@ -654,14 +758,21 @@ const close = () => { emit('close') }
border-color: var(--kt-accent);
}
.kt-preview-thumb-item.active {
background: var(--kt-accent);
color: var(--kt-accent-fg);
.kt-preview-thumb-item.active {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
}
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.3s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
/* KT button style */
.kt-preview-btn {
@ -670,13 +781,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;
}
@ -687,12 +798,7 @@ const close = () => { emit('close') }
border-color: var(--kt-accent);
}
/*
===========================================
深色模式适配 (Dark Mode)
===========================================
*/
/* Dark Mode */
html.dark-mode .kt-preview-card {
background: var(--kt-bg);
border-color: var(--kt-border);
@ -728,7 +834,7 @@ html.dark-mode .kt-preview-body {
background: var(--kt-bg);
}
html.dark-mode .kt-preview-loading,
html.dark-mode .kt-preview-loading,
html.dark-mode .kt-preview-error {
color: var(--kt-muted-fg);
}
@ -813,4 +919,4 @@ html.dark-mode .kt-preview-thumb-item.active {
background: var(--kt-accent);
color: var(--kt-accent-fg);
}
</style>
</style>

@ -1,7 +1,19 @@
<script setup>
/**
* KtMarquee - Kinetic Typography Infinite Scrolling Marquee Component
*
* Implements infinite horizontal scrolling animation using CSS transform.
* Updated to use 4 copies of content to support ultra-wide screens without gaps.
*/
import { computed } from 'vue'
const props = defineProps({
/**
* Animation speed variant
* - fast: 15s duration
* - normal: 20s duration (default)
* - slow: 30s duration
*/
speed: {
type: String,
default: 'normal',
@ -15,12 +27,19 @@ const speedClass = computed(() => `kt-marquee--${props.speed}`)
<template>
<div class="kt-marquee" :class="speedClass">
<div class="kt-marquee__track">
<!-- Original content -->
<div class="kt-marquee__content">
<slot />
</div>
<!-- Duplicated content for seamless infinite scroll - aria-hidden for accessibility -->
<div class="kt-marquee__content" aria-hidden="true">
<!--
Render 4 copies of the content.
Why 4?
If content is short and screen is wide (e.g. 4K), 2 copies might not fill the screen width.
4 copies ensure that even if the animation moves by 1 unit, there are still 3 units visible,
covering any realistic screen width.
-->
<div
v-for="i in 4"
:key="i"
class="kt-marquee__content"
:aria-hidden="i > 1"
>
<slot />
</div>
</div>
@ -29,9 +48,7 @@ const speedClass = computed(() => `kt-marquee--${props.speed}`)
<style scoped>
/* Component styles are defined in Style.css for consistency */
/* This component uses the global .kt-marquee classes */
/* Scoped overrides if needed */
/* Scoped overrides */
.kt-marquee {
overflow: hidden;
width: 100%;
@ -43,13 +60,10 @@ const speedClass = computed(() => `kt-marquee--${props.speed}`)
.kt-marquee__track {
display: flex;
width: max-content;
/* GPU-accelerated animation using transform: translateX only */
/* Requirements: 12.1, 12.3 - No layout-triggering properties */
width: max-content; /* Ensure track takes up space of all children */
/* GPU-accelerated animation */
animation: kt-marquee-scroll 20s linear infinite;
/* Performance optimization: hint browser to use GPU layer */
will-change: transform;
/* Ensure smooth rendering */
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
@ -70,7 +84,7 @@ const speedClass = computed(() => `kt-marquee--${props.speed}`)
display: flex;
align-items: center;
gap: 3rem;
padding-right: 3rem;
padding-right: 3rem; /* Add spacing between duplicated blocks */
font-family: var(--kt-font);
font-size: var(--kt-body);
font-weight: 700;
@ -84,18 +98,25 @@ const speedClass = computed(() => `kt-marquee--${props.speed}`)
flex-shrink: 0;
}
/* GPU-accelerated animation using transform only */
/* Requirements: 12.1, 12.3 - Uses only transform: translateX for 60fps performance */
/*
* Animation Logic:
* We have 4 identical copies: [1][2][3][4]
* We want to move left by the width of exactly ONE copy, then snap back to 0.
* Because all copies are identical, [2][3][4][...] looks exactly like [1][2][3][...] at the start.
*
* 1 copy = 1/4 of the total track width (100%).
* So we translate from 0 to -25%.
*/
@keyframes kt-marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
transform: translateX(-25%);
}
}
/* Respect user's reduced motion preference - Requirements 8.6 */
/* Respect user's reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.kt-marquee__track {
animation: none;
@ -103,7 +124,6 @@ const speedClass = computed(() => `kt-marquee--${props.speed}`)
}
/* === Responsive Marquee Styles === */
/* Requirements: 10.5 - Marquee persists at all breakpoints */
/* Mobile (< 768px) */
@media (max-width: 767px) {
@ -141,4 +161,4 @@ const speedClass = computed(() => `kt-marquee--${props.speed}`)
padding-right: 3rem;
}
}
</style>
</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 {

@ -1,5 +1,10 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
/**
* NavBar.vue - Kinetic Typography Navigation Bar
*
* Update: Reverted to pixel-based JS positioning to fix browser zoom misalignment issues.
*/
import { ref, computed, watch, onMounted, nextTick, onUnmounted } from 'vue'
const props = defineProps({
currentSection: { type: String, default: 'home' }
@ -7,19 +12,28 @@ const props = defineProps({
const emit = defineEmits(['navigate', 'logout', 'toggle'])
// Toggle State
const isDarkMode = ref(true)
const savedState = localStorage.getItem('kt_nav_expanded')
const isExpanded = ref(savedState === 'true' && savedState !== null ? true : false)
const isDarkMode = ref(true) // Default to dark mode for Kinetic Typography
// DOM Refs
const navButtonRefs = ref([]) // DOM
const navContainerRef = ref(null) // DOM
const savedState = localStorage.getItem('kt_nav_expanded')
const isExpanded = ref(savedState === 'true' && savedState !== null ? true : false) // false localStorage MainFlow
// Style State
const highlightStyle = ref({
top: '0px',
height: '0px',
opacity: 0 //
})
watch(isExpanded, (newValue) => {
localStorage.setItem('kt_nav_expanded', newValue)
emit('toggle', newValue)
emit('toggle', newValue)
// /
setTimeout(updateHighlightPosition, 300)
})
// Nav Items - Requirement 3.1: Preserve all existing navigation items
const navItems = [
{ id: 'home', label: '首页', icon: 'fas fa-home' },
{ id: 'page1', label: '通用防护', icon: 'fas fa-shield-alt' },
@ -33,34 +47,44 @@ const activeIndex = computed(() => {
return idx >= 0 ? idx : 0
})
// Highlight Calculation - Requirements: 2.6
// Formula: top = 10 + (activeIndex * (80 / navItems.length))%
// The navigation items occupy 80% of the container height (from 10% to 90%)
// Each item takes up (80 / navItems.length)% of the height
const itemHeightPercent = 80 / navItems.length
const highlightTop = computed(() => `${10 + (activeIndex.value * itemHeightPercent)}%`)
const highlightHeight = `${itemHeightPercent}%`
const handleNavClick = (id) => { emit('navigate', id) }
const handleLogout = () => { emit('logout') }
const handlePage5 = () => { emit('navigate', 'page5') }
const isPage5Active = computed(() => props.currentSection === 'page5')
// === Theme toggle - Requirement 3.2: Preserve theme toggle ===
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value
if (isDarkMode.value) {
document.documentElement.classList.add('dark-mode')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark-mode')
localStorage.setItem('theme', 'light')
// === ===
const updateHighlightPosition = () => {
// Page5 DOM
if (isPage5Active.value || !navContainerRef.value || !navButtonRefs.value[activeIndex.value]) {
highlightStyle.value = { ...highlightStyle.value, opacity: 0 }
return
}
const container = navContainerRef.value
const activeBtn = navButtonRefs.value[activeIndex.value]
// 使 getBoundingClientRect
const containerRect = container.getBoundingClientRect()
const btnRect = activeBtn.getBoundingClientRect()
//
const top = btnRect.top - containerRect.top
const height = btnRect.height
highlightStyle.value = {
top: `${top}px`,
height: `${height}px`,
opacity: 1 //
}
}
//
watch(() => props.currentSection, () => {
nextTick(updateHighlightPosition)
})
// ResizeObserver: window.resize
let resizeObserver = null
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
// Default to dark mode for Kinetic Typography
if (savedTheme === 'light') {
isDarkMode.value = false
document.documentElement.classList.remove('dark-mode')
@ -68,33 +92,78 @@ onMounted(() => {
isDarkMode.value = true
document.documentElement.classList.add('dark-mode')
}
//
nextTick(() => {
updateHighlightPosition()
//
if (navContainerRef.value) {
resizeObserver = new ResizeObserver(() => {
updateHighlightPosition()
})
resizeObserver.observe(navContainerRef.value)
}
// window resize
window.addEventListener('resize', updateHighlightPosition)
})
})
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect()
window.removeEventListener('resize', updateHighlightPosition)
})
const handleNavClick = (id) => { emit('navigate', id) }
const handleLogout = () => { emit('logout') }
const handlePage5 = () => { emit('navigate', 'page5') }
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value
if (isDarkMode.value) {
document.documentElement.classList.add('dark-mode')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark-mode')
localStorage.setItem('theme', 'light')
}
}
</script>
<template>
<div id="navbar-container" class="kt-navbar-container">
<input type="checkbox" id="nav-toggle" v-model="isExpanded">
<!-- 导航岛 - Kinetic Typography Style -->
<!-- 导航岛 -->
<div id="nav-bar" class="kt-navbar">
<!-- 头部 - Requirement 3.7: Space Grotesk font -->
<div id="nav-header" class="kt-navbar__header">
<a id="nav-title" class="kt-navbar__title" href="#">MUSEGUARD</a>
<a id="nav-title" class="kt-navbar__title" href="#">GUARD</a>
<label for="nav-toggle"><span id="nav-toggle-burger" class="kt-navbar__burger"></span></label>
<hr class="kt-navbar__divider">
</div>
<!-- 导航链接 - Requirement 3.1: Preserve all navigation items -->
<!-- Requirements: 11.5 - Keyboard navigable -->
<nav id="nav-content" class="kt-navbar__content" role="navigation" aria-label="">
<!-- Requirement 3.6: Active state with acid yellow -->
<div v-if="!isPage5Active" id="nav-content-highlight" class="kt-navbar__highlight" :style="{ top: highlightTop, height: highlightHeight }" aria-hidden="true">
</div>
<div class="nav-items-container kt-navbar__items" role="menubar">
<!-- Requirement 3.4: Uppercase labels with tight tracking -->
<!-- Requirement 3.5: Hover scale 1.05 -->
<!-- Requirements: 11.5 - Keyboard navigable with tabindex and key handlers -->
<div v-for="item in navItems" :key="item.id" class="nav-button kt-navbar__item"
<!--
nav-items-container 作为定位基准 (position: absolute)
highlight buttons 都在其中共享坐标系
-->
<div class="nav-items-container kt-navbar__items" ref="navContainerRef" role="menubar">
<!-- 高亮条使用 JS 计算的像素样式 -->
<div
v-show="!isPage5Active"
id="nav-content-highlight"
class="kt-navbar__highlight"
:style="highlightStyle"
aria-hidden="true"
></div>
<!-- 导航按钮 -->
<div
v-for="(item, index) in navItems"
:key="item.id"
:ref="el => navButtonRefs[index] = el"
class="nav-button kt-navbar__item"
:class="{ active: currentSection === item.id, 'kt-navbar__item--active': currentSection === item.id }"
@click="handleNavClick(item.id)"
@keydown.enter="handleNavClick(item.id)"
@ -109,16 +178,12 @@ onMounted(() => {
</nav>
</div>
<!-- 底部按钮组 - Requirement 3.2: Preserve all action buttons -->
<!-- Requirements: 11.5, 11.6 - Keyboard navigable, decorative icons hidden -->
<!-- 底部按钮组 -->
<div class="external-actions kt-navbar__actions" role="group" aria-label="">
<!-- 主题切换按钮 -->
<button class="kt-btn kt-btn--circle theme-btn" @click="toggleTheme" :title="isDarkMode ? '切换亮色模式' : '切换深色模式'" :aria-label="isDarkMode ? '' : ''">
<i class="fas" :class="isDarkMode ? 'fa-cloud-sun' : 'fa-moon'" aria-hidden="true"></i>
</button>
<!-- 个人中心按钮 -->
<button class="kt-btn kt-btn--circle kt-btn--accent page-5-btn" :class="{ 'kt-btn--active': isPage5Active }" @click="handlePage5"
title="个人中心" aria-label="个人中心" :aria-current="isPage5Active ? 'page' : undefined">
<i class="fas fa-user-circle" aria-hidden="true"></i>
@ -127,7 +192,6 @@ onMounted(() => {
</div>
</template>
<style scoped>
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css');
@ -146,7 +210,7 @@ onMounted(() => {
transition: width 0.2s linear;
}
/* === 1. 导航条 - Kinetic Typography Style (Requirement 3.3) === */
/* === 1. 导航条 === */
#nav-bar {
pointer-events: auto;
position: relative;
@ -246,27 +310,12 @@ label[for="nav-toggle"] {
will-change: transform, top;
}
#nav-toggle-burger::before {
top: -8px;
}
#nav-toggle-burger::after {
top: 8px;
}
#nav-toggle-burger::before { top: -8px; }
#nav-toggle-burger::after { top: 8px; }
#nav-toggle:checked ~ #nav-bar #nav-toggle-burger {
background: transparent;
}
#nav-toggle:checked ~ #nav-bar #nav-toggle-burger::before {
top: 0;
transform: rotate(45deg);
}
#nav-toggle:checked ~ #nav-bar #nav-toggle-burger::after {
top: 0;
transform: rotate(-45deg);
}
#nav-toggle:checked ~ #nav-bar #nav-toggle-burger { background: transparent; }
#nav-toggle:checked ~ #nav-bar #nav-toggle-burger::before { top: 0; transform: rotate(45deg); }
#nav-toggle:checked ~ #nav-bar #nav-toggle-burger::after { top: 0; transform: rotate(-45deg); }
#nav-content {
flex: 1;
@ -275,32 +324,37 @@ label[for="nav-toggle"] {
position: relative;
}
#nav-content-highlight {
position: absolute;
left: 0;
width: 100%;
background: var(--kt-accent, #DFE104);
border: none;
border-radius: 0;
transition: top 0.2s ease-in-out;
z-index: 0;
will-change: top;
}
/* 导航项容器:定位基准 */
.nav-items-container {
position: absolute;
top: 10%;
top: 10%; /* 保持原有的垂直位置 */
height: 80%;
width: 100%;
left: 0;
display: flex;
flex-direction: column;
/* 确保按钮均匀分布 */
justify-content: space-between;
}
/* 高亮条:绝对定位 */
#nav-content-highlight {
position: absolute;
left: 0;
width: 100%;
background: var(--kt-accent, #DFE104);
border: none;
border-radius: 0;
/* 添加 transition 使 JS 位置变化也有动画效果 */
transition: top 0.2s cubic-bezier(0.4, 0, 0.2, 1), height 0.2s ease, opacity 0.2s ease;
z-index: 0; /* 位于按钮下方 */
pointer-events: none;
}
.nav-button {
position: relative;
margin-left: var(--cq-navbar-spacing, 1.5cqw);
flex: 1;
flex: 1; /* 确保每个按钮占据相同的高度空间 */
display: flex;
align-items: center;
font-family: var(--kt-font, 'Space Grotesk', sans-serif);
@ -309,7 +363,7 @@ label[for="nav-toggle"] {
letter-spacing: -0.02em;
color: var(--kt-fg, #FAFAFA);
cursor: pointer;
z-index: 1;
z-index: 1; /* 位于高亮条上方 */
transition: all var(--kt-transition-micro, 200ms ease-in-out);
padding-left: 0;
overflow: hidden;
@ -326,7 +380,6 @@ label[for="nav-toggle"] {
font-weight: 700;
}
/* Icon styling */
.nav-button i {
min-width: 4cqw;
text-align: center;
@ -341,12 +394,11 @@ label[for="nav-toggle"] {
transition-delay: v-bind("isExpanded ? '0.15s' : '0s'");
white-space: nowrap;
z-index: 2;
font-size: 1.25rem;
font-weight: 700;
font-size: var(--cq-font-base, 1rem);
will-change: opacity;
}
/* === 2. 外部按钮 - Kinetic Typography Style (Requirement 3.2) === */
/* === 2. 外部按钮 === */
.external-actions {
pointer-events: auto;
position: fixed;
@ -388,24 +440,10 @@ label[for="nav-toggle"] {
}
.kt-btn--circle {
width: max(4.8cqw, 48px);
height: max(4.8cqw, 48px);
width: var(--cq-navbar-button-size, 3.5cqw);
height: var(--cq-navbar-button-size, 3.5cqw);
border-radius: 0;
padding: 0;
font-size: clamp(1.25rem, 2.5cqw, 2.5rem);
/* 确保图标能够随鼠标悬停效果一起变化 */
transition: all var(--kt-transition-micro, 200ms ease-in-out);
}
.kt-btn--circle i {
/* 强制图标居中 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.kt-btn--accent {
@ -448,8 +486,7 @@ label[for="nav-toggle"] {
font-size: var(--cq-font-sm, 0.875rem);
}
/* === - 顶部导航栏模式 (宽 < 900px) === */
/* Requirements: 2.5 - Mobile layout with hidden hamburger and horizontal navigation */
/* === 移动端适配 (宽 < 900px) === */
@media (max-width: 900px) {
#navbar-container {
width: 100% !important;
@ -477,21 +514,12 @@ label[for="nav-toggle"] {
box-shadow: none;
}
#nav-header {
display: none;
}
label[for="nav-toggle"] {
display: none !important;
}
#nav-toggle-burger {
display: none !important;
}
#nav-content-highlight {
display: none;
}
#nav-header { display: none; }
label[for="nav-toggle"] { display: none !important; }
#nav-toggle-burger { display: none !important; }
/* 移动端不显示高亮条,使用样式激活 */
#nav-content-highlight { display: none; }
#nav-content {
display: flex;
@ -509,6 +537,7 @@ label[for="nav-toggle"] {
flex-direction: row;
gap: var(--cq-navbar-spacing, 1.5cqw);
align-items: center;
justify-content: center;
}
.nav-button {
@ -523,9 +552,7 @@ label[for="nav-toggle"] {
border: var(--kt-border-width, 2px) solid transparent;
}
.nav-button span {
display: none !important;
}
.nav-button span { display: none !important; }
.nav-button i {
min-width: auto;
@ -566,15 +593,8 @@ label[for="nav-toggle"] {
}
@media (prefers-reduced-motion: reduce) {
.nav-button,
.kt-btn {
transition: none;
}
.nav-button:hover,
.kt-btn:hover {
transform: none;
}
.nav-button, .kt-btn { transition: none; }
.nav-button:hover, .kt-btn:hover { transform: none; }
}
.nav-button:focus-visible {
@ -592,4 +612,4 @@ label[for="nav-toggle"] {
.kt-btn:focus:not(:focus-visible) {
outline: none;
}
</style>
</style>

@ -1,5 +1,6 @@
<template>
<div class="kt-sidebar-card">
<!-- 添加 @click 事件和 title 提示 -->
<div class="kt-sidebar-card" @click="goToHistory" title="点击查看完整任务历史">
<div class="kt-sidebar-header">
<h3 class="kt-sidebar-title">任务队列</h3>
<div class="kt-monitor-dot" :class="{ active: isLoading }"></div>
@ -18,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>
@ -36,10 +42,14 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import { useRouter } from 'vue-router'
const store = useTaskStore()
const router = useRouter()
const navigateToSection = inject('navigateToSection')
const displayTasks = computed(() => store.sidebarTasks)
const remainingSlots = computed(() => store.quota.remaining_tasks)
@ -52,13 +62,37 @@ 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 ===== */
.kt-sidebar-card {
height: 100%;
display: flex;
@ -67,9 +101,15 @@ const formatStatusLabel = (status) => {
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
padding: 2.4cqw;
transition:
background-color 300ms ease-in-out,
border-color 300ms 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);
transform: translateY(-2px);
}
.kt-sidebar-header {
@ -89,6 +129,11 @@ const formatStatusLabel = (status) => {
color: var(--kt-fg);
text-transform: uppercase;
letter-spacing: 0.05em;
transition: color 0.2s;
}
.kt-sidebar-card:hover .kt-sidebar-title {
color: var(--kt-accent);
}
.kt-monitor-dot {
@ -145,7 +190,6 @@ const formatStatusLabel = (status) => {
.kt-task-item:hover {
background: var(--kt-bg);
border-color: var(--kt-accent);
}
.kt-task-header {
@ -172,7 +216,19 @@ const formatStatusLabel = (status) => {
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 {
@ -186,7 +242,7 @@ const formatStatusLabel = (status) => {
font-weight: 600;
}
/* Status colors - KT palette */
/* Status colors */
.kt-status-tag.running,
.kt-status-tag.processing {
color: var(--kt-accent);
@ -213,6 +269,14 @@ const formatStatusLabel = (status) => {
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;
@ -270,7 +334,8 @@ const formatStatusLabel = (status) => {
.kt-task-desc,
.kt-progress-container,
.kt-task-footer,
.kt-monitor-dot {
.kt-monitor-dot,
.kt-task-time { /* 移动端空间有限,可以隐藏时间或调整 */
display: none;
}
@ -308,19 +373,9 @@ const formatStatusLabel = (status) => {
}
}
/* 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>
</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) => {
@ -371,55 +380,68 @@ onUnmounted(() => disposeThree())
<style scoped>
.kt-3d-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
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;
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);
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;
display: flex;
flex-direction: column;
overflow: hidden;
}
.kt-3d-header {
padding: 1.5cqw 2.5cqw;
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);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--kt-bg);
z-index: 10;
}
.kt-3d-header-info h3 {
margin: 0;
.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);
font-size: var(--cq-font-lg);
color: var(--kt-fg);
text-transform: uppercase;
letter-spacing: -0.02em;
}
.kt-3d-subtitle {
.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;
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-header-controls {
display: flex;
align-items: center;
gap: 2cqw;
}
.kt-3d-legend {
display: flex; gap: 1.5cqw;
.kt-3d-legend {
display: flex;
gap: 1.5cqw;
font-family: var(--kt-font);
font-size: var(--cq-font-xs);
color: var(--kt-fg);
font-size: var(--cq-font-xs);
color: var(--kt-fg);
align-items: center;
background: var(--kt-muted);
padding: 0.5cqw 1cqw;
@ -427,76 +449,116 @@ onUnmounted(() => disposeThree())
border: 1px solid var(--kt-border);
}
.kt-3d-dot {
width: 1cqw; height: 1cqw;
border-radius: 50%;
display: inline-block;
.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);
.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);
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);
.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%;
.kt-3d-canvas-container {
flex: 1;
position: relative;
width: 100%;
height: 100%;
background: var(--kt-bg);
overflow: hidden;
overflow: hidden;
}
.kt-3d-state-box {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center;
color: var(--kt-fg);
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-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);
position: absolute;
bottom: 2cqw;
left: 2cqw;
background: var(--kt-bg);
padding: 1.2cqw;
border-radius: var(--kt-radius);
border-radius: var(--kt-radius);
font-family: var(--kt-font);
font-size: var(--cq-font-xs);
font-size: var(--cq-font-xs);
pointer-events: none;
border: 2px solid var(--kt-border);
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; }
.kt-3d-axis-label {
margin-bottom: 0.4cqw;
font-weight: bold;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.kt-3d-axis-label:last-child {
margin-bottom: 0;
}
.z-axis {
color: #3366ff;
}
.y-axis {
color: #00cc00;
}
/*
===========================================
深色模式适配 (Dark Mode)
===========================================
*/
.x-axis {
color: #ff3333;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
html.dark-mode .kt-3d-card {
background: var(--kt-bg);
@ -549,4 +611,4 @@ html.dark-mode .kt-3d-axis-labels {
background: var(--kt-bg);
border-color: var(--kt-border);
}
</style>
</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

@ -60,6 +60,7 @@ export function calculateHighlightHeight(navItemsCount) {
export function isHighlightWithinBounds(highlightTop, highlightHeight) {
const minTop = 10
const maxBottom = 90
const epsilon = 0.0001 // 浮点数精度容差
return highlightTop >= minTop && (highlightTop + highlightHeight) <= maxBottom
return highlightTop >= minTop - epsilon && (highlightTop + highlightHeight) <= maxBottom + epsilon
}

@ -1,173 +0,0 @@
/**
* Property-Based Tests for Navbar Highlight Position Calculation
*
* Feature: ui-consistency-fix
* Tests: Property 7 from design document
*
* Requirements: 2.6
*/
import { describe, it, expect } from 'vitest'
import * as fc from 'fast-check'
import {
calculateItemHeightPercent,
calculateHighlightTop,
calculateHighlightHeight,
isHighlightWithinBounds
} from './navbarHighlight'
describe('Navbar Highlight Position Property Tests', () => {
/**
* Property 7: 导航栏高亮位置计算
* Feature: ui-consistency-fix, Property 7: 导航栏高亮位置计算
*
* *对于任意*活动索引0-4高亮指示器的 top 位置应等于 `10 + (activeIndex * (80 / navItems.length))%`
*
* **Validates: Requirements 2.6**
*/
describe('Property 7: Navbar Highlight Position Calculation', () => {
it('should calculate correct top position for any valid activeIndex', () => {
fc.assert(
fc.property(
// Generate navItemsCount between 1 and 10
fc.integer({ min: 1, max: 10 }),
(navItemsCount) => {
// Test all valid indices for this navItemsCount
for (let activeIndex = 0; activeIndex < navItemsCount; activeIndex++) {
const highlightTop = calculateHighlightTop(activeIndex, navItemsCount)
const expectedTop = 10 + (activeIndex * (80 / navItemsCount))
// Allow for floating point precision
expect(Math.abs(highlightTop - expectedTop)).toBeLessThan(0.0001)
}
}
),
{ numRuns: 100 }
)
})
it('should calculate correct top position for standard 5-item navbar', () => {
const navItemsCount = 5
const itemHeightPercent = 80 / navItemsCount // 16%
// Test each index (0-4)
for (let activeIndex = 0; activeIndex < navItemsCount; activeIndex++) {
const highlightTop = calculateHighlightTop(activeIndex, navItemsCount)
const expectedTop = 10 + (activeIndex * itemHeightPercent)
expect(highlightTop).toBe(expectedTop)
}
// Verify specific values
expect(calculateHighlightTop(0, 5)).toBe(10) // 10 + 0*16 = 10
expect(calculateHighlightTop(1, 5)).toBe(26) // 10 + 1*16 = 26
expect(calculateHighlightTop(2, 5)).toBe(42) // 10 + 2*16 = 42
expect(calculateHighlightTop(3, 5)).toBe(58) // 10 + 3*16 = 58
expect(calculateHighlightTop(4, 5)).toBe(74) // 10 + 4*16 = 74
})
it('should keep highlight within 10%-90% bounds for any valid configuration', () => {
fc.assert(
fc.property(
// Generate navItemsCount between 1 and 10
fc.integer({ min: 1, max: 10 }),
(navItemsCount) => {
const highlightHeight = calculateHighlightHeight(navItemsCount)
// Test all valid indices
for (let activeIndex = 0; activeIndex < navItemsCount; activeIndex++) {
const highlightTop = calculateHighlightTop(activeIndex, navItemsCount)
// Highlight should start at or after 10%
expect(highlightTop).toBeGreaterThanOrEqual(10)
// Highlight should end at or before 90%
expect(highlightTop + highlightHeight).toBeLessThanOrEqual(90.0001) // Allow for floating point
}
}
),
{ numRuns: 100 }
)
})
it('should calculate item height as 80 / navItemsCount', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }),
(navItemsCount) => {
const itemHeight = calculateItemHeightPercent(navItemsCount)
const expectedHeight = 80 / navItemsCount
expect(Math.abs(itemHeight - expectedHeight)).toBeLessThan(0.0001)
}
),
{ numRuns: 100 }
)
})
it('should have highlight height equal to item height', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }),
(navItemsCount) => {
const itemHeight = calculateItemHeightPercent(navItemsCount)
const highlightHeight = calculateHighlightHeight(navItemsCount)
expect(highlightHeight).toBe(itemHeight)
}
),
{ numRuns: 100 }
)
})
it('should have consecutive highlights be exactly itemHeight apart', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 10 }),
(navItemsCount) => {
const itemHeight = calculateItemHeightPercent(navItemsCount)
// Check that consecutive items are exactly itemHeight apart
for (let i = 0; i < navItemsCount - 1; i++) {
const currentTop = calculateHighlightTop(i, navItemsCount)
const nextTop = calculateHighlightTop(i + 1, navItemsCount)
expect(Math.abs((nextTop - currentTop) - itemHeight)).toBeLessThan(0.0001)
}
}
),
{ numRuns: 100 }
)
})
it('should validate bounds correctly', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }),
(navItemsCount) => {
const highlightHeight = calculateHighlightHeight(navItemsCount)
for (let activeIndex = 0; activeIndex < navItemsCount; activeIndex++) {
const highlightTop = calculateHighlightTop(activeIndex, navItemsCount)
expect(isHighlightWithinBounds(highlightTop, highlightHeight)).toBe(true)
}
}
),
{ numRuns: 100 }
)
})
it('should throw error for invalid navItemsCount', () => {
expect(() => calculateHighlightTop(0, 0)).toThrow()
expect(() => calculateHighlightTop(0, -1)).toThrow()
expect(() => calculateItemHeightPercent(0)).toThrow()
expect(() => calculateItemHeightPercent(-1)).toThrow()
})
it('should throw error for invalid activeIndex', () => {
expect(() => calculateHighlightTop(-1, 5)).toThrow()
expect(() => calculateHighlightTop(5, 5)).toThrow()
expect(() => calculateHighlightTop(10, 5)).toThrow()
})
})
})

@ -3,33 +3,25 @@ import router from '@/router'
import { useUserStore } from '@/stores/userStore'
import toast from '@/utils/toast'
// 创建 axios 实例
const service = axios.create({
baseURL: '/api', // 配合 vite 代理转发到后端
timeout: 30000,
baseURL: '/api',
// 超时时间2分钟保证大图片上传
timeout: 120000,
headers: {
'Content-Type': 'application/json;charset=utf-8'
}
})
// === 请求拦截器 ===
service.interceptors.request.use(
config => {
const userStore = useUserStore()
// 检查是否为认证接口(登录/注册)
// 如果是登录或注册,不要携带 Token防止旧 Token 失效导致后端直接返回 401
const isAuthRequest = config.url.includes('/auth/login') || config.url.includes('/auth/register')
if (userStore.token && !isAuthRequest) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
// 如果是二进制流请求,处理特殊配置
if (config.returnRawResponse) {
config.responseType = 'arraybuffer'
}
return config
},
error => {
@ -104,6 +96,9 @@ service.interceptors.response.use(
if (isLoginRequest) {
// 登录接口 401 通常是账号密码错误
message = '用户名或密码错误';
} else if (error.config && error.config._skipLogout) {
// 【核心修改】如果配置了跳过登出,则只更新错误消息,不执行登出和跳转
message = serverMsg || '权限验证失败';
} else {
message = '登录已过期,请重新登录';
// 如果是非登录接口的 401执行登出逻辑
@ -116,6 +111,7 @@ service.interceptors.response.use(
break;
case 403: message = serverMsg || '拒绝访问 (权限不足)'; break;
case 404: message = serverMsg || '资源不存在'; break;
case 413: message = '上传文件过大 (超过服务器限制)'; break;
case 500: message = serverMsg || '服务器内部错误'; break;
default: message = serverMsg || `请求错误 ${status}`;
}
@ -124,8 +120,10 @@ service.interceptors.response.use(
}
// 如果是登录接口报错,不再弹出 Toast (交由 LoginView 页面内显示错误文字)
// 如果是非登录接口的 401 (Token 过期),通常伴随跳转,也不弹窗避免干扰
if (!isLoginRequest && error.response?.status !== 401) {
// 如果是非登录接口的 401 (且没有标记 _skipLogout),通常伴随跳转,也不弹窗避免干扰
const shouldSuppressToast = isLoginRequest || (error.response?.status === 401 && !error.config?._skipLogout);
if (!shouldSuppressToast) {
toast.error(message)
}

@ -1,435 +0,0 @@
/**
* Property-Based Tests for Scroll Navigation
*
* Feature: ui-consistency-fix
* Tests: Properties 1-4 from design document
*
* Requirements: 1.1-1.5
*/
import { describe, it, expect } from 'vitest'
import * as fc from 'fast-check'
import {
SCROLL_CONFIG,
shouldDebounce,
shouldLockInput,
checkScrollState,
shouldTriggerPageSwitch,
isAccumulatorOverThreshold,
getScrollDirection,
getPageSwitchDirection
} from './scrollNavigation'
describe('Scroll Navigation Property Tests', () => {
/**
* Property 1: 滚动导航防抖
* Feature: ui-consistency-fix, Property 1: 滚动导航防抖
*
* *对于任意*当前时间和上次事件时间如果时间差小于冷却期 shouldDebounce 应返回 true
*
* **Validates: Requirements 1.5**
*/
describe('Property 1: Scroll Navigation Debounce', () => {
it('should return true when time difference is less than cooldown', () => {
fc.assert(
fc.property(
// Generate lastEventTime as a positive integer
fc.integer({ min: 0, max: 1000000 }),
// Generate timeDiff less than cooldown
fc.integer({ min: 0, max: SCROLL_CONFIG.WHEEL_COOLDOWN - 1 }),
// Generate cooldown value
fc.integer({ min: 1, max: 10000 }),
(lastEventTime, timeDiff, cooldown) => {
const currentTime = lastEventTime + timeDiff
// When time difference is less than cooldown, should debounce
if (timeDiff < cooldown) {
expect(shouldDebounce(currentTime, lastEventTime, cooldown)).toBe(true)
}
}
),
{ numRuns: 100 }
)
})
it('should return false when time difference is greater than or equal to cooldown', () => {
fc.assert(
fc.property(
// Generate lastEventTime as a positive integer
fc.integer({ min: 0, max: 1000000 }),
// Generate timeDiff greater than or equal to cooldown
fc.integer({ min: 0, max: 10000 }),
(lastEventTime, cooldown) => {
// Ensure cooldown is at least 1
const safeCooldown = Math.max(1, cooldown)
const currentTime = lastEventTime + safeCooldown
// When time difference equals cooldown, should not debounce
expect(shouldDebounce(currentTime, lastEventTime, safeCooldown)).toBe(false)
}
),
{ numRuns: 100 }
)
})
it('should correctly apply 500ms wheel cooldown', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1000000 }),
fc.integer({ min: 0, max: 1000 }),
(lastEventTime, timeDiff) => {
const currentTime = lastEventTime + timeDiff
const result = shouldDebounce(currentTime, lastEventTime, SCROLL_CONFIG.WHEEL_COOLDOWN)
expect(result).toBe(timeDiff < SCROLL_CONFIG.WHEEL_COOLDOWN)
}
),
{ numRuns: 100 }
)
})
it('should correctly apply 600ms touch cooldown', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1000000 }),
fc.integer({ min: 0, max: 1200 }),
(lastEventTime, timeDiff) => {
const currentTime = lastEventTime + timeDiff
const result = shouldDebounce(currentTime, lastEventTime, SCROLL_CONFIG.TOUCH_COOLDOWN)
expect(result).toBe(timeDiff < SCROLL_CONFIG.TOUCH_COOLDOWN)
}
),
{ numRuns: 100 }
)
})
})
/**
* Property 2: 动画期间输入锁定
* Feature: ui-consistency-fix, Property 2: 动画期间输入锁定
*
* *对于任意*动画状态 isAnimating true shouldLockInput 应返回 true阻止新的页面切换
*
* **Validates: Requirements 1.3**
*/
describe('Property 2: Animation Input Locking', () => {
it('should return true when isAnimating is true', () => {
expect(shouldLockInput(true)).toBe(true)
})
it('should return false when isAnimating is false', () => {
expect(shouldLockInput(false)).toBe(false)
})
it('should handle various truthy/falsy values correctly', () => {
fc.assert(
fc.property(
fc.boolean(),
(isAnimating) => {
const result = shouldLockInput(isAnimating)
// When isAnimating is true, should lock input
if (isAnimating === true) {
expect(result).toBe(true)
} else {
expect(result).toBe(false)
}
}
),
{ numRuns: 100 }
)
})
it('should only return true for strict boolean true', () => {
// Test that only strict true returns true
expect(shouldLockInput(true)).toBe(true)
expect(shouldLockInput(false)).toBe(false)
expect(shouldLockInput(1)).toBe(false)
expect(shouldLockInput('true')).toBe(false)
expect(shouldLockInput(null)).toBe(false)
expect(shouldLockInput(undefined)).toBe(false)
})
})
/**
* Property 3: 边界滚动检测
* Feature: ui-consistency-fix, Property 3: 边界滚动检测
*
* *对于任意*滚动信息scrollTopclientHeightscrollHeightcheckScrollState 应正确计算 atTop atBottom 状态容差为 5px
*
* **Validates: Requirements 1.4**
*/
describe('Property 3: Boundary Scroll Detection', () => {
it('should correctly detect atTop when scrollTop <= tolerance', () => {
fc.assert(
fc.property(
// scrollTop within tolerance (0 to 5)
fc.integer({ min: 0, max: 5 }),
fc.integer({ min: 100, max: 1000 }),
fc.integer({ min: 200, max: 2000 }),
(scrollTop, clientHeight, scrollHeight) => {
// Ensure scrollHeight > clientHeight for scrollable content
const safeScrollHeight = Math.max(scrollHeight, clientHeight + 10)
const scrollInfo = { scrollTop, clientHeight, scrollHeight: safeScrollHeight }
const state = checkScrollState(scrollInfo)
expect(state.atTop).toBe(true)
}
),
{ numRuns: 100 }
)
})
it('should correctly detect not atTop when scrollTop > tolerance', () => {
fc.assert(
fc.property(
// scrollTop greater than tolerance
fc.integer({ min: 6, max: 1000 }),
fc.integer({ min: 100, max: 1000 }),
fc.integer({ min: 200, max: 2000 }),
(scrollTop, clientHeight, scrollHeight) => {
// Ensure scrollHeight > clientHeight + scrollTop for valid scroll position
const safeScrollHeight = Math.max(scrollHeight, clientHeight + scrollTop + 10)
const scrollInfo = { scrollTop, clientHeight, scrollHeight: safeScrollHeight }
const state = checkScrollState(scrollInfo)
expect(state.atTop).toBe(false)
}
),
{ numRuns: 100 }
)
})
it('should correctly detect atBottom when at scroll end', () => {
fc.assert(
fc.property(
fc.integer({ min: 100, max: 1000 }),
fc.integer({ min: 200, max: 2000 }),
(clientHeight, scrollHeight) => {
// Ensure scrollHeight > clientHeight for scrollable content
const safeScrollHeight = Math.max(scrollHeight, clientHeight + 100)
// Set scrollTop to be at the bottom (within tolerance)
const maxScroll = safeScrollHeight - clientHeight
const scrollTop = maxScroll
const scrollInfo = { scrollTop, clientHeight, scrollHeight: safeScrollHeight }
const state = checkScrollState(scrollInfo)
expect(state.atBottom).toBe(true)
}
),
{ numRuns: 100 }
)
})
it('should correctly detect isScrollable', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 500 }),
fc.integer({ min: 100, max: 500 }),
fc.integer({ min: 100, max: 1000 }),
(scrollTop, clientHeight, scrollHeight) => {
const scrollInfo = { scrollTop, clientHeight, scrollHeight }
const state = checkScrollState(scrollInfo)
const tolerance = SCROLL_CONFIG.BOUNDARY_TOLERANCE
// isScrollable should be true when scrollHeight > clientHeight + tolerance
const expectedScrollable = scrollHeight > clientHeight + tolerance
expect(state.isScrollable).toBe(expectedScrollable)
}
),
{ numRuns: 100 }
)
})
it('should use 5px tolerance for boundary detection', () => {
// Test exact boundary cases with 5px tolerance
const clientHeight = 500
const scrollHeight = 1000
const maxScroll = scrollHeight - clientHeight // 500
// At top with tolerance
expect(checkScrollState({ scrollTop: 0, clientHeight, scrollHeight }).atTop).toBe(true)
expect(checkScrollState({ scrollTop: 5, clientHeight, scrollHeight }).atTop).toBe(true)
expect(checkScrollState({ scrollTop: 6, clientHeight, scrollHeight }).atTop).toBe(false)
// At bottom with tolerance
expect(checkScrollState({ scrollTop: maxScroll, clientHeight, scrollHeight }).atBottom).toBe(true)
expect(checkScrollState({ scrollTop: maxScroll - 5, clientHeight, scrollHeight }).atBottom).toBe(true)
expect(checkScrollState({ scrollTop: maxScroll - 6, clientHeight, scrollHeight }).atBottom).toBe(false)
})
})
/**
* Property 4: 页面切换触发条件
* Feature: ui-consistency-fix, Property 4: 页面切换触发条件
*
* *对于任意*滚动状态和方向shouldTriggerPageSwitch 应仅在以下条件下返回 true
* - 页面不可滚动
* - 向下滚动且在底部
* - 向上滚动且在顶部
*
* **Validates: Requirements 1.1, 1.2, 1.4**
*/
describe('Property 4: Page Switch Trigger Conditions', () => {
it('should return true when page is not scrollable', () => {
fc.assert(
fc.property(
fc.boolean(),
fc.boolean(),
fc.constantFrom('up', 'down'),
(atTop, atBottom, direction) => {
const scrollState = {
isScrollable: false,
atTop,
atBottom
}
// When not scrollable, should always allow page switch
expect(shouldTriggerPageSwitch(scrollState, direction)).toBe(true)
}
),
{ numRuns: 100 }
)
})
it('should return true when scrolling down and at bottom', () => {
fc.assert(
fc.property(
fc.boolean(),
(atTop) => {
const scrollState = {
isScrollable: true,
atTop,
atBottom: true
}
expect(shouldTriggerPageSwitch(scrollState, 'down')).toBe(true)
}
),
{ numRuns: 100 }
)
})
it('should return true when scrolling up and at top', () => {
fc.assert(
fc.property(
fc.boolean(),
(atBottom) => {
const scrollState = {
isScrollable: true,
atTop: true,
atBottom
}
expect(shouldTriggerPageSwitch(scrollState, 'up')).toBe(true)
}
),
{ numRuns: 100 }
)
})
it('should return false when scrolling down but not at bottom', () => {
const scrollState = {
isScrollable: true,
atTop: true,
atBottom: false
}
expect(shouldTriggerPageSwitch(scrollState, 'down')).toBe(false)
})
it('should return false when scrolling up but not at top', () => {
const scrollState = {
isScrollable: true,
atTop: false,
atBottom: true
}
expect(shouldTriggerPageSwitch(scrollState, 'up')).toBe(false)
})
it('should return false when in middle of scrollable content', () => {
fc.assert(
fc.property(
fc.constantFrom('up', 'down'),
(direction) => {
const scrollState = {
isScrollable: true,
atTop: false,
atBottom: false
}
// When in middle of content, should not trigger page switch
expect(shouldTriggerPageSwitch(scrollState, direction)).toBe(false)
}
),
{ numRuns: 100 }
)
})
it('should correctly combine scroll state and direction', () => {
fc.assert(
fc.property(
fc.boolean(),
fc.boolean(),
fc.boolean(),
fc.constantFrom('up', 'down'),
(isScrollable, atTop, atBottom, direction) => {
const scrollState = { isScrollable, atTop, atBottom }
const result = shouldTriggerPageSwitch(scrollState, direction)
// Verify the logic matches the specification
const expected =
!isScrollable ||
(direction === 'down' && atBottom) ||
(direction === 'up' && atTop)
expect(result).toBe(expected)
}
),
{ numRuns: 100 }
)
})
})
/**
* Additional helper function tests
*/
describe('Helper Functions', () => {
it('getScrollDirection should return correct direction', () => {
fc.assert(
fc.property(
fc.integer({ min: -1000, max: 1000 }),
(deltaY) => {
if (deltaY === 0) return // Skip zero case
const direction = getScrollDirection(deltaY)
expect(direction).toBe(deltaY > 0 ? 'down' : 'up')
}
),
{ numRuns: 100 }
)
})
it('getPageSwitchDirection should return correct direction', () => {
fc.assert(
fc.property(
fc.integer({ min: -1000, max: 1000 }),
(accumulator) => {
if (accumulator === 0) return // Skip zero case
const direction = getPageSwitchDirection(accumulator)
expect(direction).toBe(accumulator > 0 ? 'next' : 'prev')
}
),
{ numRuns: 100 }
)
})
it('isAccumulatorOverThreshold should correctly compare values', () => {
fc.assert(
fc.property(
fc.integer({ min: -1000, max: 1000 }),
fc.integer({ min: 1, max: 500 }),
(accumulator, threshold) => {
const result = isAccumulatorOverThreshold(accumulator, threshold)
expect(result).toBe(Math.abs(accumulator) > threshold)
}
),
{ numRuns: 100 }
)
})
})
})

@ -1,267 +0,0 @@
/**
* Property-Based Tests for Subpage Style Consistency
*
* Feature: ui-consistency-fix
* Requirements: 5.1-5.7
*
* Tests that subpages use Kinetic Typography (--kt-*) variables
* instead of hand-drawn style (--hd-*) variables.
*/
import { describe, it, expect } from 'vitest'
import * as fc from 'fast-check'
import {
KT_VARIABLE_PREFIX,
HD_VARIABLE_PREFIX,
REQUIRED_KT_VARIABLES,
BILINGUAL_TITLE_STRUCTURE,
isKTVariable,
isHDVariable,
extractCSSVariables,
validateCSSVariables,
isValidBilingualTitle,
validateSubpageStyle
} from './subpageStyles'
describe('Subpage Style Consistency Tests', () => {
/**
* Requirements: 5.1 - Subpages should use --kt-* variables
*/
describe('CSS Variable Prefix Detection', () => {
it('isKTVariable should return true for --kt-* prefixed variables', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^[a-zA-Z0-9-]+$/.test(s)),
(suffix) => {
const variable = `${KT_VARIABLE_PREFIX}${suffix}`
expect(isKTVariable(variable)).toBe(true)
}
),
{ numRuns: 50 }
)
})
it('isKTVariable should return false for non-kt variables', () => {
fc.assert(
fc.property(
fc.string().filter(s => !s.startsWith(KT_VARIABLE_PREFIX)),
(variable) => {
expect(isKTVariable(variable)).toBe(false)
}
),
{ numRuns: 50 }
)
})
it('isHDVariable should return true for --hd-* prefixed variables', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^[a-zA-Z0-9-]+$/.test(s)),
(suffix) => {
const variable = `${HD_VARIABLE_PREFIX}${suffix}`
expect(isHDVariable(variable)).toBe(true)
}
),
{ numRuns: 50 }
)
})
it('isHDVariable should return false for non-hd variables', () => {
fc.assert(
fc.property(
fc.string().filter(s => !s.startsWith(HD_VARIABLE_PREFIX)),
(variable) => {
expect(isHDVariable(variable)).toBe(false)
}
),
{ numRuns: 50 }
)
})
it('KT and HD variables should be mutually exclusive', () => {
fc.assert(
fc.property(
fc.string(),
(variable) => {
// A variable cannot be both KT and HD
const isKT = isKTVariable(variable)
const isHD = isHDVariable(variable)
expect(isKT && isHD).toBe(false)
}
),
{ numRuns: 50 }
)
})
})
/**
* Requirements: 5.1 - CSS variable extraction and validation
*/
describe('CSS Variable Extraction', () => {
it('should extract all var() references from CSS', () => {
const css = 'color: var(--kt-fg); background: var(--kt-bg);'
const variables = extractCSSVariables(css)
expect(variables).toContain('--kt-fg')
expect(variables).toContain('--kt-bg')
expect(variables.length).toBe(2)
})
it('should return empty array for CSS without variables', () => {
fc.assert(
fc.property(
fc.string().filter(s => !s.includes('var(--')),
(css) => {
const variables = extractCSSVariables(css)
expect(variables).toEqual([])
}
),
{ numRuns: 50 }
)
})
it('should handle non-string input gracefully', () => {
expect(extractCSSVariables(null)).toEqual([])
expect(extractCSSVariables(undefined)).toEqual([])
expect(extractCSSVariables(123)).toEqual([])
})
})
/**
* Requirements: 5.1 - Validate CSS uses only KT variables
*/
describe('CSS Variable Validation', () => {
it('should mark CSS with only KT variables as valid', () => {
fc.assert(
fc.property(
fc.array(
fc.string({ minLength: 1, maxLength: 10 }).filter(s => /^[a-zA-Z0-9-]+$/.test(s)),
{ minLength: 1, maxLength: 5 }
),
(suffixes) => {
const css = suffixes.map(s => `prop: var(--kt-${s});`).join(' ')
const result = validateCSSVariables(css)
expect(result.valid).toBe(true)
expect(result.hdVariables).toEqual([])
}
),
{ numRuns: 50 }
)
})
it('should mark CSS with HD variables as invalid', () => {
const css = 'color: var(--hd-text); background: var(--kt-bg);'
const result = validateCSSVariables(css)
expect(result.valid).toBe(false)
expect(result.hdVariables).toContain('--hd-text')
})
it('should detect all HD variables in mixed CSS', () => {
fc.assert(
fc.property(
fc.array(
fc.string({ minLength: 1, maxLength: 10 }).filter(s => /^[a-zA-Z0-9-]+$/.test(s)),
{ minLength: 1, maxLength: 3 }
),
(hdSuffixes) => {
const css = hdSuffixes.map(s => `prop: var(--hd-${s});`).join(' ')
const result = validateCSSVariables(css)
expect(result.valid).toBe(false)
expect(result.hdVariables.length).toBe(hdSuffixes.length)
}
),
{ numRuns: 50 }
)
})
})
/**
* Requirements: 5.2 - Bilingual title structure validation
*/
describe('Bilingual Title Structure', () => {
it('should validate complete bilingual title structure', () => {
const validStructure = { hasPrimaryTitle: true, hasSubtitle: true }
expect(isValidBilingualTitle(validStructure)).toBe(true)
})
it('should reject incomplete bilingual title structure', () => {
expect(isValidBilingualTitle({ hasPrimaryTitle: true, hasSubtitle: false })).toBe(false)
expect(isValidBilingualTitle({ hasPrimaryTitle: false, hasSubtitle: true })).toBe(false)
expect(isValidBilingualTitle({ hasPrimaryTitle: false, hasSubtitle: false })).toBe(false)
})
it('should handle invalid input gracefully', () => {
expect(isValidBilingualTitle(null)).toBe(false)
expect(isValidBilingualTitle(undefined)).toBe(false)
expect(isValidBilingualTitle('string')).toBe(false)
expect(isValidBilingualTitle(123)).toBe(false)
})
it('BILINGUAL_TITLE_STRUCTURE should have required classes', () => {
expect(BILINGUAL_TITLE_STRUCTURE.primaryClass).toBe('kt-subpage__title')
expect(BILINGUAL_TITLE_STRUCTURE.subtitleClass).toBe('kt-subpage__title-en')
})
})
/**
* Requirements: 5.1-5.7 - Full subpage style validation
*/
describe('Subpage Style Validation', () => {
it('should validate subpage with KT variables and bilingual title', () => {
const subpage = {
cssContent: 'color: var(--kt-fg); background: var(--kt-bg);',
hasBilingualTitle: true
}
const result = validateSubpageStyle(subpage)
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
})
it('should reject subpage with HD variables', () => {
const subpage = {
cssContent: 'color: var(--hd-text);',
hasBilingualTitle: true
}
const result = validateSubpageStyle(subpage)
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})
it('should reject subpage without bilingual title', () => {
const subpage = {
cssContent: 'color: var(--kt-fg);',
hasBilingualTitle: false
}
const result = validateSubpageStyle(subpage)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Missing bilingual title structure')
})
it('should handle invalid subpage object', () => {
expect(validateSubpageStyle(null).valid).toBe(false)
expect(validateSubpageStyle(undefined).valid).toBe(false)
expect(validateSubpageStyle('string').valid).toBe(false)
})
})
/**
* Requirements: 5.1, 5.3 - Required KT variables
*/
describe('Required KT Variables', () => {
it('REQUIRED_KT_VARIABLES should contain essential styling variables', () => {
expect(REQUIRED_KT_VARIABLES).toContain('--kt-bg')
expect(REQUIRED_KT_VARIABLES).toContain('--kt-fg')
expect(REQUIRED_KT_VARIABLES).toContain('--kt-border')
expect(REQUIRED_KT_VARIABLES).toContain('--kt-accent')
expect(REQUIRED_KT_VARIABLES).toContain('--kt-font')
expect(REQUIRED_KT_VARIABLES).toContain('--kt-radius')
})
it('all required variables should use KT prefix', () => {
for (const variable of REQUIRED_KT_VARIABLES) {
expect(isKTVariable(variable)).toBe(true)
}
})
})
})

@ -1,311 +0,0 @@
/**
* Property-Based Tests for Theme Switching
*
* Feature: ui-consistency-fix
* Tests: Properties 5-6 from design document
*
* Requirements: 3.1, 3.2, 3.5
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import * as fc from 'fast-check'
import {
VALID_THEMES,
DEFAULT_THEME,
LAYOUT_PROPERTIES,
isValidTheme,
normalizeTheme,
saveThemePreference,
loadThemePreference,
themeRoundTrip,
layoutPropertiesEqual
} from './theme'
// Mock localStorage for testing
const localStorageMock = (() => {
let store = {}
return {
getItem: (key) => store[key] || null,
setItem: (key, value) => { store[key] = value.toString() },
removeItem: (key) => { delete store[key] },
clear: () => { store = {} }
}
})()
// Replace global localStorage with mock
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
writable: true
})
describe('Theme Switching Property Tests', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
/**
* Property 5: 主题切换布局稳定性
* Feature: ui-consistency-fix, Property 5: 主题切换布局稳定性
*
* *对于任意*主题切换操作切换前后的布局属性widthheightpaddingmargingrid-template-columns应保持不变
*
* **Validates: Requirements 3.1, 3.2**
*/
describe('Property 5: Theme Switch Layout Stability', () => {
it('should have layout properties defined for stability checking', () => {
// Verify that LAYOUT_PROPERTIES contains the expected properties
expect(LAYOUT_PROPERTIES).toContain('width')
expect(LAYOUT_PROPERTIES).toContain('height')
expect(LAYOUT_PROPERTIES).toContain('padding')
expect(LAYOUT_PROPERTIES).toContain('margin')
expect(LAYOUT_PROPERTIES).toContain('gridTemplateColumns')
})
it('layoutPropertiesEqual should return true for identical objects', () => {
fc.assert(
fc.property(
// Generate random layout property values
fc.record({
width: fc.string(),
height: fc.string(),
padding: fc.string(),
paddingTop: fc.string(),
paddingRight: fc.string(),
paddingBottom: fc.string(),
paddingLeft: fc.string(),
margin: fc.string(),
marginTop: fc.string(),
marginRight: fc.string(),
marginBottom: fc.string(),
marginLeft: fc.string(),
gridTemplateColumns: fc.string(),
gridTemplateRows: fc.string(),
gap: fc.string(),
fontSize: fc.string(),
lineHeight: fc.string(),
letterSpacing: fc.string()
}),
(layoutProps) => {
// Same object should be equal to itself
expect(layoutPropertiesEqual(layoutProps, layoutProps)).toBe(true)
// Copy should be equal
const copy = { ...layoutProps }
expect(layoutPropertiesEqual(layoutProps, copy)).toBe(true)
}
),
{ numRuns: 100 }
)
})
it('layoutPropertiesEqual should return false when any layout property differs', () => {
fc.assert(
fc.property(
// Generate two different values for a property
fc.string({ minLength: 1 }),
fc.string({ minLength: 1 }),
// Pick a random layout property to change
fc.constantFrom(...LAYOUT_PROPERTIES),
(value1, value2, propToChange) => {
// Skip if values happen to be the same
if (value1 === value2) return
// Create base layout object
const baseLayout = {}
for (const prop of LAYOUT_PROPERTIES) {
baseLayout[prop] = 'initial'
}
// Create modified layout with one property changed
const modifiedLayout = { ...baseLayout }
baseLayout[propToChange] = value1
modifiedLayout[propToChange] = value2
// Should detect the difference
expect(layoutPropertiesEqual(baseLayout, modifiedLayout)).toBe(false)
}
),
{ numRuns: 100 }
)
})
it('theme-independent layout variables should be defined in CSS', () => {
// This test verifies the design principle that layout variables
// are defined separately from theme-specific color variables
// The actual CSS verification would be done in integration tests
// Verify the expected layout properties list
const expectedLayoutProps = [
'width', 'height', 'padding', 'margin', 'gridTemplateColumns'
]
for (const prop of expectedLayoutProps) {
expect(LAYOUT_PROPERTIES).toContain(prop)
}
})
})
/**
* Property 6: 主题偏好持久化往返
* Feature: ui-consistency-fix, Property 6: 主题偏好持久化往返
*
* *对于任意*主题值'dark' 'light'保存到 localStorage 后再读取应返回相同的值
*
* **Validates: Requirements 3.5**
*/
describe('Property 6: Theme Preference Persistence Round-Trip', () => {
it('should return same theme after save and load for valid themes', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_THEMES),
(theme) => {
// Save theme
saveThemePreference(theme)
// Load theme
const loadedTheme = loadThemePreference()
// Should be the same
expect(loadedTheme).toBe(theme)
}
),
{ numRuns: 100 }
)
})
it('themeRoundTrip should preserve valid theme values', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_THEMES),
(theme) => {
const result = themeRoundTrip(theme)
expect(result).toBe(theme)
}
),
{ numRuns: 100 }
)
})
it('should normalize invalid themes to default before saving', () => {
fc.assert(
fc.property(
// Generate strings that are NOT valid themes
fc.string().filter(s => !VALID_THEMES.includes(s)),
(invalidTheme) => {
// Save invalid theme
saveThemePreference(invalidTheme)
// Load should return default theme
const loadedTheme = loadThemePreference()
expect(loadedTheme).toBe(DEFAULT_THEME)
}
),
{ numRuns: 100 }
)
})
it('isValidTheme should correctly identify valid themes', () => {
fc.assert(
fc.property(
fc.string(),
(theme) => {
const isValid = isValidTheme(theme)
expect(isValid).toBe(VALID_THEMES.includes(theme))
}
),
{ numRuns: 100 }
)
})
it('normalizeTheme should return valid theme for any input', () => {
fc.assert(
fc.property(
fc.string(),
(theme) => {
const normalized = normalizeTheme(theme)
expect(VALID_THEMES).toContain(normalized)
}
),
{ numRuns: 100 }
)
})
it('normalizeTheme should preserve valid themes', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_THEMES),
(validTheme) => {
const normalized = normalizeTheme(validTheme)
expect(normalized).toBe(validTheme)
}
),
{ numRuns: 100 }
)
})
it('normalizeTheme should return default for invalid themes', () => {
fc.assert(
fc.property(
fc.string().filter(s => !VALID_THEMES.includes(s)),
(invalidTheme) => {
const normalized = normalizeTheme(invalidTheme)
expect(normalized).toBe(DEFAULT_THEME)
}
),
{ numRuns: 100 }
)
})
it('multiple round-trips should be idempotent', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_THEMES),
fc.integer({ min: 1, max: 10 }),
(theme, iterations) => {
let currentTheme = theme
// Perform multiple round-trips
for (let i = 0; i < iterations; i++) {
currentTheme = themeRoundTrip(currentTheme)
}
// Should still be the original theme
expect(currentTheme).toBe(theme)
}
),
{ numRuns: 100 }
)
})
})
/**
* Additional helper function tests
*/
describe('Helper Functions', () => {
it('VALID_THEMES should contain dark and light', () => {
expect(VALID_THEMES).toContain('dark')
expect(VALID_THEMES).toContain('light')
expect(VALID_THEMES.length).toBe(2)
})
it('DEFAULT_THEME should be a valid theme', () => {
expect(VALID_THEMES).toContain(DEFAULT_THEME)
})
it('saveThemePreference should return true on success', () => {
const result = saveThemePreference('dark')
expect(result).toBe(true)
})
it('loadThemePreference should return default when localStorage is empty', () => {
localStorage.clear()
const theme = loadThemePreference()
expect(theme).toBe(DEFAULT_THEME)
})
})
})

@ -1,333 +0,0 @@
/**
* Property-Based Tests for Touch Target Validation
*
* Feature: ui-consistency-fix
* Tests: Property 8 from design document
*
* Requirements: 4.4 - Touch targets minimum 44x44px
*/
import { describe, it, expect } from 'vitest'
import * as fc from 'fast-check'
import {
MIN_TOUCH_TARGET_SIZE,
INTERACTIVE_ELEMENT_SELECTORS,
validateTouchTargetSize,
cssValueMeetsMinSize,
validateBatchTouchTargets,
generateTouchTargetCSS
} from './touchTarget'
describe('Touch Target Property Tests', () => {
/**
* Property 8: 触摸目标最小尺寸
* Feature: ui-consistency-fix, Property 8: 触摸目标最小尺寸
*
* *对于任意*交互元素按钮链接导航项其计算尺寸应至少为 44x44px
*
* **Validates: Requirements 4.4**
*/
describe('Property 8: Touch Target Minimum Size', () => {
it('should validate dimensions >= 44px as valid', () => {
fc.assert(
fc.property(
// Generate dimensions that are >= MIN_TOUCH_TARGET_SIZE
fc.integer({ min: MIN_TOUCH_TARGET_SIZE, max: 1000 }),
fc.integer({ min: MIN_TOUCH_TARGET_SIZE, max: 1000 }),
(width, height) => {
const result = validateTouchTargetSize({ width, height })
expect(result.isValid).toBe(true)
expect(result.width).toBe(width)
expect(result.height).toBe(height)
expect(result.minRequired).toBe(MIN_TOUCH_TARGET_SIZE)
expect(result.failureReason).toBeNull()
}
),
{ numRuns: 100 }
)
})
it('should validate dimensions < 44px as invalid', () => {
fc.assert(
fc.property(
// Generate at least one dimension that is < MIN_TOUCH_TARGET_SIZE
fc.integer({ min: 0, max: MIN_TOUCH_TARGET_SIZE - 1 }),
fc.integer({ min: 0, max: 1000 }),
fc.boolean(),
(smallDim, otherDim, widthIsSmall) => {
const width = widthIsSmall ? smallDim : otherDim
const height = widthIsSmall ? otherDim : smallDim
// At least one dimension is small
const result = validateTouchTargetSize({ width, height })
// If both dimensions are >= MIN_TOUCH_TARGET_SIZE, it should be valid
// Otherwise, it should be invalid
const expectedValid = width >= MIN_TOUCH_TARGET_SIZE && height >= MIN_TOUCH_TARGET_SIZE
expect(result.isValid).toBe(expectedValid)
if (!expectedValid) {
expect(result.failureReason).not.toBeNull()
}
}
),
{ numRuns: 100 }
)
})
it('should correctly identify when width is too small', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: MIN_TOUCH_TARGET_SIZE - 1 }),
fc.integer({ min: MIN_TOUCH_TARGET_SIZE, max: 1000 }),
(width, height) => {
const result = validateTouchTargetSize({ width, height })
expect(result.isValid).toBe(false)
expect(result.failureReason).toContain('width')
expect(result.failureReason).toContain(`${width}px`)
}
),
{ numRuns: 100 }
)
})
it('should correctly identify when height is too small', () => {
fc.assert(
fc.property(
fc.integer({ min: MIN_TOUCH_TARGET_SIZE, max: 1000 }),
fc.integer({ min: 0, max: MIN_TOUCH_TARGET_SIZE - 1 }),
(width, height) => {
const result = validateTouchTargetSize({ width, height })
expect(result.isValid).toBe(false)
expect(result.failureReason).toContain('height')
expect(result.failureReason).toContain(`${height}px`)
}
),
{ numRuns: 100 }
)
})
it('should correctly identify when both dimensions are too small', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: MIN_TOUCH_TARGET_SIZE - 1 }),
fc.integer({ min: 0, max: MIN_TOUCH_TARGET_SIZE - 1 }),
(width, height) => {
const result = validateTouchTargetSize({ width, height })
expect(result.isValid).toBe(false)
expect(result.failureReason).toContain('width')
expect(result.failureReason).toContain('height')
}
),
{ numRuns: 100 }
)
})
it('should handle edge case of exactly 44px', () => {
const result = validateTouchTargetSize({
width: MIN_TOUCH_TARGET_SIZE,
height: MIN_TOUCH_TARGET_SIZE
})
expect(result.isValid).toBe(true)
expect(result.failureReason).toBeNull()
})
it('should reject negative dimensions', () => {
fc.assert(
fc.property(
fc.integer({ min: -1000, max: -1 }),
fc.integer({ min: -1000, max: 1000 }),
(negDim, otherDim) => {
const result1 = validateTouchTargetSize({ width: negDim, height: otherDim })
const result2 = validateTouchTargetSize({ width: otherDim, height: negDim })
expect(result1.isValid).toBe(false)
expect(result2.isValid).toBe(false)
}
),
{ numRuns: 100 }
)
})
})
describe('CSS Value Validation', () => {
it('should validate pixel values correctly', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 200 }),
(pixels) => {
const cssValue = `${pixels}px`
const result = cssValueMeetsMinSize(cssValue)
expect(result).toBe(pixels >= MIN_TOUCH_TARGET_SIZE)
}
),
{ numRuns: 100 }
)
})
it('should validate rem values correctly (assuming 16px base)', () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 10, noNaN: true }),
(rems) => {
const cssValue = `${rems.toFixed(2)}rem`
const pixelEquivalent = rems * 16
const result = cssValueMeetsMinSize(cssValue)
expect(result).toBe(pixelEquivalent >= MIN_TOUCH_TARGET_SIZE)
}
),
{ numRuns: 100 }
)
})
it('should validate em values correctly (assuming 16px base)', () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 10, noNaN: true }),
(ems) => {
const cssValue = `${ems.toFixed(2)}em`
const pixelEquivalent = ems * 16
const result = cssValueMeetsMinSize(cssValue)
expect(result).toBe(pixelEquivalent >= MIN_TOUCH_TARGET_SIZE)
}
),
{ numRuns: 100 }
)
})
it('should handle max() function with pixel fallback', () => {
// max(3cqw, 44px) should be valid because 44px >= 44px
expect(cssValueMeetsMinSize('max(3cqw, 44px)')).toBe(true)
expect(cssValueMeetsMinSize('max(3cqw, 48px)')).toBe(true)
expect(cssValueMeetsMinSize('max(3cqw, 40px)')).toBe(false)
})
it('should handle clamp() function', () => {
// clamp(44px, 3cqw, 100px) - minimum is 44px
expect(cssValueMeetsMinSize('clamp(44px, 3cqw, 100px)')).toBe(true)
expect(cssValueMeetsMinSize('clamp(48px, 3cqw, 100px)')).toBe(true)
expect(cssValueMeetsMinSize('clamp(40px, 3cqw, 100px)')).toBe(false)
})
it('should return false for invalid inputs', () => {
expect(cssValueMeetsMinSize(null)).toBe(false)
expect(cssValueMeetsMinSize(undefined)).toBe(false)
expect(cssValueMeetsMinSize('')).toBe(false)
expect(cssValueMeetsMinSize(123)).toBe(false)
})
})
describe('Batch Validation', () => {
it('should validate all elements in a batch', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
width: fc.integer({ min: MIN_TOUCH_TARGET_SIZE, max: 500 }),
height: fc.integer({ min: MIN_TOUCH_TARGET_SIZE, max: 500 })
}),
{ minLength: 1, maxLength: 20 }
),
(dimensionsList) => {
const result = validateBatchTouchTargets(dimensionsList)
expect(result.allValid).toBe(true)
expect(result.failedCount).toBe(0)
expect(result.results.length).toBe(dimensionsList.length)
}
),
{ numRuns: 100 }
)
})
it('should count failures correctly in batch', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
width: fc.integer({ min: 0, max: MIN_TOUCH_TARGET_SIZE - 1 }),
height: fc.integer({ min: 0, max: MIN_TOUCH_TARGET_SIZE - 1 })
}),
{ minLength: 1, maxLength: 20 }
),
(dimensionsList) => {
const result = validateBatchTouchTargets(dimensionsList)
expect(result.allValid).toBe(false)
expect(result.failedCount).toBe(dimensionsList.length)
}
),
{ numRuns: 100 }
)
})
it('should handle empty array', () => {
const result = validateBatchTouchTargets([])
expect(result.allValid).toBe(true)
expect(result.failedCount).toBe(0)
expect(result.results.length).toBe(0)
})
it('should handle invalid input', () => {
const result = validateBatchTouchTargets(null)
expect(result.allValid).toBe(false)
expect(result.results.length).toBe(0)
})
})
describe('CSS Generation', () => {
it('should generate valid CSS rules', () => {
fc.assert(
fc.property(
fc.constantFrom(...INTERACTIVE_ELEMENT_SELECTORS),
(selector) => {
const css = generateTouchTargetCSS(selector)
expect(css).toContain(selector)
expect(css).toContain(`min-width: ${MIN_TOUCH_TARGET_SIZE}px`)
expect(css).toContain(`min-height: ${MIN_TOUCH_TARGET_SIZE}px`)
}
),
{ numRuns: 100 }
)
})
it('should use custom minimum size when provided', () => {
fc.assert(
fc.property(
fc.integer({ min: 20, max: 100 }),
(customSize) => {
const css = generateTouchTargetCSS('.test', customSize)
expect(css).toContain(`min-width: ${customSize}px`)
expect(css).toContain(`min-height: ${customSize}px`)
}
),
{ numRuns: 100 }
)
})
})
describe('Constants', () => {
it('MIN_TOUCH_TARGET_SIZE should be 44', () => {
expect(MIN_TOUCH_TARGET_SIZE).toBe(44)
})
it('INTERACTIVE_ELEMENT_SELECTORS should contain common interactive elements', () => {
expect(INTERACTIVE_ELEMENT_SELECTORS).toContain('button')
expect(INTERACTIVE_ELEMENT_SELECTORS).toContain('a')
expect(INTERACTIVE_ELEMENT_SELECTORS).toContain('[role="button"]')
expect(INTERACTIVE_ELEMENT_SELECTORS).toContain('.kt-btn')
expect(INTERACTIVE_ELEMENT_SELECTORS).toContain('.kt-card')
})
})
})

@ -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,13 @@
/>
<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>
<div class="kt-user-box">
@ -270,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"
@ -278,12 +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"
@ -291,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>
@ -328,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('')
@ -411,6 +434,25 @@ const validateEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
/**
* 校验密码强度
* 至少8位包含大小写数字特殊符号
*/
const validatePasswordStrength = (pwd) => {
//
// ^
// (?=.*[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);
}
//
const handleSendCode = async (purpose) => {
let email = ''
@ -450,6 +492,11 @@ const handleRegister = async () => {
if (!form.value.username || !form.value.password || !form.value.email || !form.value.code) {
return modal.warning('请填写完整注册信息(含验证码)')
}
if (!validatePasswordStrength(form.value.password)) {
return modal.warning('密码强度不足需至少8位且包含大小写字母、数字和符号')
}
loading.value = true
try {
const payload = { ...form.value }
@ -478,6 +525,10 @@ const handleForgotPassword = async () => {
return modal.warning('两次输入的新密码不一致')
}
if (!validatePasswordStrength(f.new_password)) {
return modal.warning('新密码强度不足需至少8位且包含大小写字母、数字和符号')
}
loading.value = true
try {
//
@ -730,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;
@ -740,6 +792,8 @@ const handleForgotPassword = async () => {
}
.kt-register-card .kt-input-icon {
/* 修复位置:注册卡片输入框高 48px */
top: 24px;
left: 12px;
}
@ -749,7 +803,8 @@ const handleForgotPassword = async () => {
.kt-label {
position: absolute;
top: 50%;
/* 修复位置:改为固定像素值 */
top: 26px;
left: 45px;
transform: translateY(-50%);
font-family: var(--kt-font);
@ -762,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;
@ -783,7 +841,8 @@ const handleForgotPassword = async () => {
.kt-toggle-password {
position: absolute;
top: 50%;
/* 修复位置:改为固定像素值 */
top: 26px;
right: 15px;
transform: translateY(-50%);
font-size: 1.1rem;
@ -793,6 +852,20 @@ 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;
color: var(--kt-muted-fg);
margin-top: 0.4rem;
margin-left: 0.2rem;
line-height: 1.4;
}
/* ===== Code Group Layout ===== */
.kt-code-group {
display: flex;

@ -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"
@ -458,8 +473,7 @@ onUnmounted(() => {
grid-row: 1;
width: 100%;
height: 100%;
padding: var(--kt-container-px);
padding-right: calc(var(--kt-container-px) + 8px);
padding: 0;
--waterfall-duration: 350ms;
--waterfall-easing: ease-out;
@ -710,12 +724,14 @@ onUnmounted(() => {
}
.kt-corner-btn i {
font-size: clamp(1.2rem, 2cqw, 1.8rem);
font-size: 2.2cqw;
min-font-size: 1.2rem;
margin-bottom: 0;
}
.kt-corner-btn__text {
font-size: clamp(0.65rem, 0.7cqw, 0.85rem);
font-size: 0.8cqw;
min-font-size: 0.65rem;
white-space: nowrap;
font-weight: 700;
}

@ -37,7 +37,7 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
<span class="kt-card__hero-line">MODE</span>
</div>
<div class="kt-card__content">
<p class="kt-card__desc">通用模式适用于大多数场景的防护模式提供多种可调节参数满足不同需求</p>
<p class="kt-card__desc">通用防护适用于人脸和艺术品防护经典模式</p>
<!-- Feature Tags -->
<div class="kt-tags">
@ -64,11 +64,11 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
@keydown.space.prevent="OpenQuick"
>
<div class="kt-card__hero-text kt-card__hero-text--small">
<span class="kt-card__hero-line">快速</span>
<span class="kt-card__hero-line">模式</span>
<span class="kt-card__hero-line">QUICK</span>
<span class="kt-card__hero-line">快速模式</span>
</div>
<div class="kt-card__content">
<p class="kt-card__desc">快速防护系统自动推荐配置一键上传</p>
<p class="kt-card__desc">加速防护效果不差且耗时更低</p>
</div>
<span class="kt-card__arrow" aria-hidden="true"></span>
</article>
@ -138,7 +138,7 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
.kt-card--universal {
flex-direction: column;
align-items: flex-start;
padding: 3rem;
padding: 2.5rem;
min-height: 300px;
}
@ -226,11 +226,11 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
display: flex;
align-items: center;
gap: 2rem;
padding: 2rem 3rem;
padding: 2.5rem;
}
.kt-card__hero-text--small .kt-card__hero-line {
font-size: clamp(1.5rem, 5vw, 3rem);
font-size: clamp(2rem, 6vw, 4rem);
line-height: 1.25;
}
@ -322,6 +322,13 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
min-height: auto;
}
.kt-card--quick {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
padding: 1.5rem;
}
.kt-card__hero-line {
font-size: clamp(2rem, 12vw, 4rem);
}
@ -344,13 +351,6 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode')
justify-content: center;
}
.kt-card--quick {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
padding: 1.5rem;
}
.kt-card__hero-text--small .kt-card__hero-line {
font-size: clamp(1.25rem, 8vw, 2rem);
line-height: 1.25;

@ -126,6 +126,7 @@ const fileInput = ref(null)
const isSubmitting = ref(false)
let specificPollTimer = null
const MAX_UPLOAD_COUNT = 5
const MAX_TOTAL_SIZE_MB = 15
const formData = ref({
taskName: '',
@ -143,14 +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('所选文件已存在')
}
// 2.
const currentCount = formData.value.files.length
const availableSlots = MAX_UPLOAD_COUNT - currentCount
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 = ''
}
@ -173,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)
@ -233,19 +270,74 @@ 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-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-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;
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;

@ -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,14 +154,15 @@ const isSubmitting = ref(false)
const isCustomMode = ref(false)
let specificPollTimer = null
const MAX_UPLOAD_COUNT = 5
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 }
@ -168,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
@ -202,15 +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('所选文件已存在')
}
// 2.
const currentCount = formData.value.files.length
const availableSlots = MAX_UPLOAD_COUNT - currentCount
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 = () => {
@ -268,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
}
}
}
@ -320,56 +345,205 @@ 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; }
.kt-select-container { position: relative; width: 100%; }
.kt-select-trigger { width: 100%; padding: 0.75rem 1rem; font-family: var(--kt-font); background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); display: flex; justify-content: space-between; align-items: center; cursor: pointer; transition: all var(--kt-transition-micro); color: var(--kt-fg); }
.kt-select-trigger:hover, .kt-select-trigger.is-open { border-color: var(--kt-accent); }
.kt-select-trigger .placeholder { color: var(--kt-muted-fg); }
.kt-select-options { position: absolute; top: 110%; left: 0; width: 100%; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); z-index: 100; max-height: 25cqh; overflow-y: auto; padding: 0.5rem; }
.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-mode-toggle { display: flex; gap: 0.5rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-muted); padding: 0.25rem; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); }
.kt-mode-toggle span { padding: 0.25rem 0.75rem; border-radius: var(--kt-radius); cursor: pointer; color: var(--kt-muted-fg); transition: all var(--kt-transition-micro); }
.kt-mode-toggle span.active { background: var(--kt-bg); color: var(--kt-fg); font-weight: 700; }
.kt-strength-selector { display: flex; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); padding: 0.25rem; }
.kt-strength-item { flex: 1; text-align: center; padding: 0.5rem 0.5rem; border-radius: var(--kt-radius); cursor: pointer; font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-fg); transition: all var(--kt-transition-micro); }
.kt-strength-item:hover { background: var(--kt-muted); }
.kt-strength-item.active { background: var(--kt-accent); color: var(--kt-accent-fg); font-weight: 600; }
.kt-subpage__upload { position: relative; border: 3px dashed var(--kt-border); border-radius: var(--kt-radius); padding: var(--kt-card-padding); text-align: center; cursor: pointer; transition: all var(--kt-transition-micro); background: var(--kt-bg); }
.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-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;
}
.kt-select-container {
position: relative;
width: 100%;
}
.kt-select-trigger {
width: 100%;
padding: 0.75rem 1rem;
font-family: var(--kt-font);
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all var(--kt-transition-micro);
color: var(--kt-fg);
}
.kt-select-trigger:hover,
.kt-select-trigger.is-open {
border-color: var(--kt-accent);
}
.kt-select-trigger .placeholder {
color: var(--kt-muted-fg);
}
.kt-select-options {
position: absolute;
top: 110%;
left: 0;
width: 100%;
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
z-index: 100;
max-height: 25cqh;
overflow-y: auto;
padding: 0.5rem;
}
.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;
height: 44px;
}
.kt-mode-toggle {
display: flex;
gap: 0.5rem;
font-family: var(--kt-font);
font-size: var(--kt-small);
background: var(--kt-muted);
padding: 0.25rem;
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
}
.kt-mode-toggle span {
padding: 0.25rem 0.75rem;
border-radius: var(--kt-radius);
cursor: pointer;
color: var(--kt-muted-fg);
transition: all var(--kt-transition-micro);
}
.kt-mode-toggle span.active {
background: var(--kt-bg);
color: var(--kt-fg);
font-weight: 700;
}
.kt-strength-selector {
display: flex;
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
padding: 0.25rem;
}
.kt-strength-item {
flex: 1;
text-align: center;
padding: 0.5rem 0.5rem;
border-radius: var(--kt-radius);
cursor: pointer;
font-family: var(--kt-font);
font-size: var(--kt-small);
color: var(--kt-fg);
transition: all var(--kt-transition-micro);
}
.kt-strength-item:hover {
background: var(--kt-muted);
}
.kt-strength-item.active {
background: var(--kt-accent);
color: var(--kt-accent-fg);
font-weight: 600;
}
.kt-subpage__upload {
position: relative;
border: 3px dashed var(--kt-border);
border-radius: var(--kt-radius);
padding: var(--kt-card-padding);
text-align: center;
cursor: pointer;
transition: all var(--kt-transition-micro);
background: var(--kt-bg);
}
.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);
}
@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;
@ -378,4 +552,15 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) })
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;
}
}
</style>

@ -4,10 +4,8 @@
*/
import { inject } from 'vue'
// Inject subpage navigation from parent
const openSubpage = inject('openSubpage')
// Preserved original click handlers
const handleOpenStyle = () => openSubpage('page2', 'style')
const handleOpenFace = () => openSubpage('page2', 'face')
const handleOpenCustom = () => openSubpage('page2', 'custom')
@ -15,13 +13,11 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
<template>
<div class="kt-page kt-page2">
<!-- Header Section: Bilingual Title -->
<header class="kt-header">
<h1 class="kt-header__title-cn">专题防护</h1>
<p class="kt-header__title-en">TOPIC PROTECTION</p>
</header>
<!-- Main Feature Card: Style Transfer Defense -->
<section class="kt-main-feature">
<article
class="kt-card kt-card--hero kt-card--style"
@ -30,44 +26,38 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
@keydown.enter="handleOpenStyle"
@keydown.space.prevent="handleOpenStyle"
>
<!-- 左侧文字信息 (40%) -->
<div class="style-text-col">
<div class="kt-card__hero-text">
<span class="kt-card__hero-line kt-card__hero-line--cn">风格防护</span>
<span class="kt-card__hero-line">风格<br>防护</span>
</div>
<div class="kt-card__content">
<p class="kt-card__subtitle">Style Protection</p>
<p class="kt-card__desc">针对艺术品防护将AI视角的风格样式迁移到指定的无效关键词保护原创画风不被模仿</p>
<p class="kt-card__subtitle">STYLE PROTECTION</p>
<p class="kt-card__desc">针对画作等艺术品防护保护原创画风不被学习将作品的风格样误导到指定的无效风格</p>
<button class="kt-btn kt-btn--primary">
开始防护
进入防护配置
<span aria-hidden="true"></span>
</button>
</div>
</div>
<!-- 右侧视觉流 (60%) -->
<div class="style-img-col">
<div class="visual-flow">
<!-- Image 1: Unprotected Gen (左上) -->
<div class="flow-item img-frame pos-top-left">
<img src="/method_examples/style_trans_example/good_gen/validation_image_6.png" alt="Unprotected Gen" />
<span class="flow-badge">GOOD_GEN</span>
</div>
<!-- Image 2: Protected Gen (右下) -->
<div class="flow-item img-frame frame-accent pos-bottom-right">
<img src="/method_examples/style_trans_example/bad_gen/validation_image_7.png" alt="Protected Gen" />
<span class="flow-badge badge-accent">FAILED_GEN</span>
<span class="flow-badge badge-accent">FAILED GEN</span>
</div>
</div>
</div>
</article>
</section>
<!-- Secondary Features: Dual-Column Cards -->
<section class="kt-secondary-features">
<div class="kt-features__grid">
<!-- Card 01: Face Edit Defense -->
<article
class="kt-card kt-card--numbered"
@click="handleOpenFace"
@ -83,7 +73,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
</div>
</article>
<!-- Card 02: Custom Generation Defense -->
<article
class="kt-card kt-card--numbered"
@click="handleOpenCustom"
@ -104,8 +93,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
</template>
<style scoped>
/* ===== Page2 Kinetic Typography Styles ===== */
.kt-page2 {
min-height: 100%;
display: flex;
@ -114,7 +101,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
overflow-x: hidden;
}
/* ===== Header Section ===== */
.kt-header {
padding: 4rem var(--kt-container-px) 2rem;
}
@ -140,27 +126,22 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
margin: 0;
}
/* ===== Main Feature Section ===== */
.kt-main-feature {
padding: 0 var(--kt-container-px) 2rem;
}
/*
* ===== Style Transfer Defense Card (Split Layout) =====
*/
.kt-card--style {
display: flex;
flex-direction: row;
flex-direction: row;
align-items: stretch;
padding: 0;
padding: 0;
overflow: hidden;
min-height: 400px;
}
/* Left Column: Text (40%) */
.style-text-col {
flex: 0 0 40%;
padding: 4rem;
flex: 4;
padding: 2.5rem;
display: flex;
flex-direction: column;
justify-content: center;
@ -169,54 +150,50 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
min-width: 0;
}
/* Right Column: Visuals (60%) */
.style-img-col {
flex: 0 0 60%;
flex: 6;
position: relative;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
}
/* === Visual Flow Styles (Staggered Layout) === */
.visual-flow {
position: relative;
width: 100%;
height: 100%;
min-height: 360px;
padding: 0;
padding: 0;
box-sizing: border-box;
}
/* 图片通用样式 */
.img-frame {
position: absolute;
width: 44%;
max-width: 480px;
min-width: 120px;
position: absolute;
width: 40%;
max-width: 450px;
min-width: 110px;
aspect-ratio: 1;
border: 2px solid var(--kt-border);
border-radius: var(--kt-radius);
background: var(--kt-bg);
padding: 4px;
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
transition: transform 0.3s ease, box-shadow 0.3s ease;
z-index: 1;
}
/* 左上角图片位置 */
.pos-top-left {
top: 8%;
left: 8%;
top: 10%;
left: 10%;
}
/* 右下角图片位置 */
.pos-bottom-right {
bottom: 8%;
right: 8%;
z-index: 2;
bottom: 10%;
right: 10%;
z-index: 2;
}
.img-frame img {
@ -224,14 +201,13 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
height: 100%;
object-fit: cover;
display: block;
border-radius: 2px;
border-radius: 2px;
}
.frame-accent {
border-color: var(--kt-accent);
}
/* 标签样式 */
.flow-badge {
position: absolute;
bottom: -12px;
@ -256,7 +232,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
border-color: var(--kt-accent);
}
/* Hover Effects */
.kt-card--style:hover .img-frame {
transform: translateY(-8px);
box-shadow: 0 30px 60px rgba(0,0,0,0.2);
@ -274,7 +249,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
border-color: var(--kt-border) !important;
}
/* Text Styles */
.kt-card__hero-text {
margin-bottom: 1.5rem;
}
@ -282,18 +256,14 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
.kt-card__hero-line {
display: block;
font-family: var(--kt-font);
font-size: clamp(2.5rem, 8vw, 6rem);
font-size: clamp(3rem, 10vw, 8rem);
font-weight: 700;
line-height: 0.9;
line-height: 1.1;
letter-spacing: -0.02em;
text-transform: uppercase;
color: var(--kt-fg);
transition: color var(--kt-transition-normal);
}
.kt-card__hero-line--cn {
letter-spacing: 0.05em;
line-height: 1.15;
white-space: nowrap;
}
.kt-card__content {
@ -328,7 +298,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
margin-top: 0.5rem;
}
/* Card Hover States */
.kt-card--style:hover .kt-card__hero-line,
.kt-card--style:hover .kt-card__subtitle,
.kt-card--style:hover .kt-card__desc,
@ -346,25 +315,22 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
color: var(--kt-accent) !important;
}
/* ===== Secondary Features Section ===== */
.kt-secondary-features {
padding: 0 var(--kt-container-px) 2rem;
flex: 1;
}
/* ===== Features Grid ===== */
.kt-features__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
/* ===== Numbered Cards ===== */
.kt-card--numbered {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 2rem;
padding: 1.5rem;
min-height: 200px;
}
@ -374,7 +340,7 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
font-weight: 700;
color: var(--kt-muted);
line-height: 1;
margin-bottom: 1rem;
margin-bottom: 0.33rem;
transition: color var(--kt-transition-normal);
}
@ -386,7 +352,7 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
.kt-card__title {
font-family: var(--kt-font);
font-size: clamp(1.25rem, 3vw, 2rem);
font-size: clamp(1.5rem, 3vw, 2.5rem);
font-weight: 700;
text-transform: uppercase;
letter-spacing: -0.02em;
@ -415,7 +381,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
transition: color var(--kt-transition-normal);
}
/* Numbered Card Hover */
.kt-card--numbered:hover .kt-card__number,
.kt-card--numbered:hover .kt-card__title,
.kt-card--numbered:hover .kt-card__subtitle-en,
@ -423,7 +388,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
color: var(--kt-accent-fg) !important;
}
/* ===== Base Card Styles ===== */
.kt-card {
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
@ -447,7 +411,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
outline-offset: 2px;
}
/* ===== Button Styles ===== */
.kt-btn {
display: inline-flex;
align-items: center;
@ -487,7 +450,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
color: var(--kt-bg);
}
/* ===== Responsive Design ===== */
@media (max-width: 900px) {
.kt-header {
padding: 2rem var(--kt-container-px) 1rem;
@ -502,7 +464,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
padding: 0 var(--kt-container-px) 1rem;
}
/* Stack Style Card on Mobile */
.kt-card--style {
flex-direction: column;
padding: 0;
@ -520,7 +481,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
background: transparent;
}
/* 移动端取消交错,恢复垂直排列 */
.visual-flow {
display: flex;
flex-direction: column;
@ -531,7 +491,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
padding: 0;
}
/* 移动端图片复位 */
.img-frame {
position: relative;
width: 60%;
@ -544,9 +503,10 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
.kt-card__hero-line {
font-size: clamp(2rem, 10vw, 3.5rem);
white-space: normal;
}
.kt-card--style .kt-card__content {
.kt-card__content {
gap: 0.75rem;
}
@ -575,7 +535,6 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
}
}
/* ===== Reduced Motion ===== */
@media (prefers-reduced-motion: reduce) {
.kt-card {
transition: none;
@ -606,4 +565,4 @@ const handleOpenCustom = () => openSubpage('page2', 'custom')
transform: none;
}
}
</style>
</style>

@ -151,6 +151,7 @@ const loadingPresets = ref(false)
const showLockedTip = ref(false)
const lockedTipTimer = ref(null)
const MAX_UPLOAD_COUNT = 5
const MAX_TOTAL_SIZE_MB = 15
const formData = ref({ taskName: '', targetStyle: '', files: [] })
@ -211,6 +212,15 @@ const handleFileChange = (event) => {
} else if (files.length > 0) {
formData.value.files = files
}
//
let totalSize = 0
formData.value.files.forEach(f => totalSize += f.size)
if (totalSize > MAX_TOTAL_SIZE_MB * 1024 * 1024) {
modal.warning(`所选图片总大小超过 ${MAX_TOTAL_SIZE_MB}MB请压缩或减少图片数量。`)
clearFiles()
return
}
event.target.value = ''
}
@ -270,34 +280,200 @@ const submitTask = async () => {
</script>
<style scoped>
.kt-readonly-field { width: 100%; padding: 0.75rem 1rem; font-family: var(--kt-font); background: var(--kt-muted); border: var(--kt-border-width) dashed var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-muted-fg); display: flex; align-items: center; gap: 0.75rem; cursor: not-allowed; }
.kt-preset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--kt-gap); }
.kt-preset-card { background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); padding: 1rem; cursor: pointer; transition: all var(--kt-transition-micro); position: relative; }
.kt-preset-card:hover { background: var(--kt-muted); transform: scale(1.02); }
.kt-preset-card.active { background: var(--kt-accent); border-color: var(--kt-accent); }
.kt-preset-card.active * { color: var(--kt-accent-fg) !important; }
.kt-preset-name { font-family: var(--kt-font); font-weight: 700; font-size: var(--kt-small); margin-bottom: 0.25rem; color: var(--kt-fg); text-transform: uppercase; }
.kt-preset-desc { font-family: var(--kt-font); font-size: clamp(0.625rem, 1vw, 0.75rem); color: var(--kt-muted-fg); line-height: 1.4; }
.kt-preset-check { position: absolute; top: 0.5rem; right: 0.5rem; color: var(--kt-accent-fg); }
.kt-label-lock { font-size: 0.75em; color: var(--kt-muted-fg); margin-left: 0.25rem; }
.kt-subpage__style-option.disabled { opacity: 0.5; cursor: not-allowed; background: var(--kt-muted); border-style: dashed; }
.kt-subpage__style-option.disabled:hover { transform: none; background: var(--kt-muted); }
.kt-subpage__lock-mark { position: absolute; top: 0.5rem; right: 0.5rem; color: var(--kt-muted-fg); font-size: 0.875rem; }
.kt-subpage__hint { font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-muted-fg); margin-top: 0.5rem; display: flex; align-items: center; gap: 0.25rem; }
.kt-locked-tip { margin-top: 0.75rem; padding: 0.75rem 1rem; background: var(--kt-accent); color: var(--kt-accent-fg); border-radius: var(--kt-radius); font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
.kt-locked-tip i { font-size: 1rem; }
.kt-tip-fade-enter-active, .kt-tip-fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.kt-tip-fade-enter-from, .kt-tip-fade-leave-to { opacity: 0; transform: translateY(-0.5rem); }
.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-readonly-field {
width: 100%;
padding: 0.75rem 1rem;
font-family: var(--kt-font);
background: var(--kt-muted);
border: var(--kt-border-width) dashed var(--kt-border);
border-radius: var(--kt-radius);
color: var(--kt-muted-fg);
display: flex;
align-items: center;
gap: 0.75rem;
cursor: not-allowed;
}
.kt-preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--kt-gap);
}
.kt-preset-card {
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
padding: 1rem;
cursor: pointer;
transition: all var(--kt-transition-micro);
position: relative;
}
.kt-preset-card:hover {
background: var(--kt-muted);
transform: scale(1.02);
}
.kt-preset-card.active {
background: var(--kt-accent);
border-color: var(--kt-accent);
}
.kt-preset-card.active * {
color: var(--kt-accent-fg) !important;
}
.kt-preset-name {
font-family: var(--kt-font);
font-weight: 700;
font-size: var(--kt-small);
margin-bottom: 0.25rem;
color: var(--kt-fg);
text-transform: uppercase;
}
.kt-preset-desc {
font-family: var(--kt-font);
font-size: clamp(0.625rem, 1vw, 0.75rem);
color: var(--kt-muted-fg);
line-height: 1.4;
}
.kt-preset-check {
position: absolute;
top: 0.5rem;
right: 0.5rem;
color: var(--kt-accent-fg);
}
.kt-label-lock {
font-size: 0.75em;
color: var(--kt-muted-fg);
margin-left: 0.25rem;
}
.kt-subpage__style-option.disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--kt-muted);
border-style: dashed;
}
.kt-subpage__style-option.disabled:hover {
transform: none;
background: var(--kt-muted);
}
.kt-subpage__lock-mark {
position: absolute;
top: 0.5rem;
right: 0.5rem;
color: var(--kt-muted-fg);
font-size: 0.875rem;
}
.kt-subpage__hint {
font-family: var(--kt-font);
font-size: var(--kt-small);
color: var(--kt-muted-fg);
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.kt-locked-tip {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-radius: var(--kt-radius);
font-family: var(--kt-font);
font-size: var(--kt-small);
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.kt-locked-tip i {
font-size: 1rem;
}
.kt-tip-fade-enter-active,
.kt-tip-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.kt-tip-fade-enter-from,
.kt-tip-fade-leave-to {
opacity: 0;
transform: translateY(-0.5rem);
}
.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; color: var(--kt-muted-fg); }
.kt-subpage__style-option.is-locked {
opacity: 0.6;
filter: grayscale(0.8);
cursor: not-allowed;
}
@media (max-width: 900px) { .kt-preset-grid { grid-template-columns: 1fr; } }
.kt-lock-icon {
margin-left: 0.5rem;
color: var(--kt-muted-fg);
}
@media (max-width: 900px) {
.kt-preset-grid {
grid-template-columns: 1fr;
}
}
.kt-upload-hint {
display: block;

@ -29,7 +29,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
<div class="finetune-text-col">
<div class="kt-card__hero-text">
<span class="kt-card__hero-line">微调生图验证</span>
<span class="kt-card__hero-line">微调生图<br>验证差异</span>
</div>
<div class="kt-card__content">
<p class="kt-card__subtitle">FINE-TUNING VERIFICATION</p>
@ -87,7 +87,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
>
<span class="kt-card__number" aria-hidden="true">02</span>
<div class="kt-card__content">
<h2 class="kt-card__title">热力图差异分析</h2>
<h2 class="kt-card__title">热力图<br>差异分析</h2>
<p class="kt-card__subtitle-en">HEATMAP</p>
<p class="kt-card__desc">Attention Map 差异分析</p>
</div>
@ -144,35 +144,33 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
*/
.kt-card--finetune {
display: flex;
flex-direction: row; /* Split Layout */
flex-direction: row;
align-items: stretch;
padding: 0; /* Remove default padding, handled by columns */
padding: 0;
overflow: hidden;
min-height: 400px;
}
/* Left Column: Text (40%) */
.finetune-text-col {
flex: 0 0 40%;
padding: 4rem;
flex: 4;
padding: 2.5rem;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2rem;
gap: 1.5rem;
z-index: 2;
min-width: 0;
}
/* Right Column: Visuals (60%) */
.finetune-img-col {
flex: 0 0 60%;
flex: 6;
position: relative;
/* 透明背景,统一视觉 */
background: transparent;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
}
/* === Visual Flow Container === */
@ -276,7 +274,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
.kt-card__hero-line {
display: block;
font-family: var(--kt-font);
font-size: clamp(2rem, 5vw, 4rem);
font-size: clamp(4rem, 6vw, 6rem);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
@ -353,7 +351,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 2rem;
padding: 1.5rem;
min-height: 200px;
}
@ -363,19 +361,19 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
font-weight: 700;
color: var(--kt-muted);
line-height: 1;
margin-bottom: 1rem;
margin-bottom: 0.25rem;
transition: color var(--kt-transition-normal);
}
.kt-card--numbered .kt-card__content {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.25rem;
}
.kt-card__title {
font-family: var(--kt-font);
font-size: clamp(1.25rem, 3vw, 2rem);
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 700;
text-transform: uppercase;
letter-spacing: -0.02em;
@ -400,7 +398,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
font-family: var(--kt-font);
font-size: var(--kt-small);
color: var(--kt-muted-fg);
margin: 0.5rem 0 0 0;
margin: 0.125rem 0 0 0;
transition: color var(--kt-transition-normal);
}
@ -551,13 +549,13 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
}
.kt-card--numbered {
padding: 1.5rem;
padding: 0.5rem;
min-height: auto;
}
.kt-card__number {
font-size: clamp(2.5rem, 10vw, 4rem);
margin-bottom: 0.75rem;
margin-bottom: 0.25rem;
}
.kt-card__title {

@ -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 ? '提交中...' : '提交微调任务' }}
@ -152,7 +153,13 @@
<div v-if="subpageType === 'heatmap' && formData.sourceId" class="kt-subpage__form-group">
<label class="kt-subpage__label">选择目标图片</label>
<div class="kt-image-grid">
<div v-if="isLoadingImages" class="kt-loading-placeholder">
<i class="fas fa-circle-notch fa-spin"></i>
<span>正在加载图片资源...</span>
</div>
<div v-else class="kt-image-grid">
<div v-if="taskImages.length === 0" class="kt-empty-text"></div>
<div v-for="img in taskImages" :key="img.image_id" class="kt-image-item" :class="{ active: formData.selectedImageId === img.image_id }" @click="selectImage(img)">
<img :src="img.base64" loading="lazy" />
<div class="kt-image-check"><i class="fas fa-check"></i></div>
@ -185,7 +192,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'
@ -195,6 +202,7 @@ const userStore = useUserStore()
const isSourceListOpen = ref(false)
const fileInput = ref(null)
const MAX_UPLOAD_COUNT = 5
const MAX_TOTAL_SIZE_MB = 15
const subpageType = computed(() => route.params.subpage)
const pageTitle = computed(() => subpageType.value === 'fine-tuning' ? '微调生图验证' : (subpageType.value === 'heatmap' ? '热力图分析' : '数据指标对比'))
@ -263,31 +271,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
@ -301,6 +321,15 @@ const handleFileChange = (e) => {
} else if (files.length > 0) {
formData.value.files = files
}
//
let totalSize = 0
formData.value.files.forEach(f => totalSize += f.size)
if (totalSize > MAX_TOTAL_SIZE_MB * 1024 * 1024) {
modal.warning(`所选图片总大小超过 ${MAX_TOTAL_SIZE_MB}MB请压缩或减少图片数量。`)
clearFiles()
return
}
e.target.value = ''
}
@ -312,7 +341,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')
@ -382,48 +410,254 @@ onMounted(() => taskStore.fetchTasks())
</script>
<style scoped>
.kt-mode-tabs { display: flex; gap: 1rem; margin-bottom: 1.5rem; border-bottom: var(--kt-border-width) solid var(--kt-border); padding-bottom: 1rem; flex-wrap: wrap; }
.kt-tab-btn { font-family: var(--kt-font); padding: 0.75rem 1.5rem; border: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); cursor: pointer; font-size: var(--kt-small); color: var(--kt-muted-fg); font-weight: 600; border-radius: var(--kt-radius); transition: all var(--kt-transition-micro); text-transform: uppercase; letter-spacing: 0.05em; }
.kt-tab-btn:hover { color: var(--kt-fg); transform: scale(1.02); }
.kt-tab-btn.active { background: var(--kt-accent); color: var(--kt-accent-fg); border-color: var(--kt-accent); }
.kt-mode-tabs {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
border-bottom: var(--kt-border-width) solid var(--kt-border);
padding-bottom: 1rem;
flex-wrap: wrap;
}
.kt-tab-btn {
font-family: var(--kt-font);
padding: 0.75rem 1.5rem;
border: var(--kt-border-width) solid var(--kt-border);
background: var(--kt-bg);
cursor: pointer;
font-size: var(--kt-small);
color: var(--kt-muted-fg);
font-weight: 600;
border-radius: var(--kt-radius);
transition: all var(--kt-transition-micro);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kt-tab-btn:hover {
color: var(--kt-fg);
transform: scale(1.02);
}
.kt-tab-btn.active {
background: var(--kt-accent);
color: var(--kt-accent-fg);
border-color: var(--kt-accent);
}
/* 锁定样式 */
.kt-tab-btn.is-locked { opacity: 0.6; cursor: not-allowed; }
.kt-source-selector { position: relative; }
.kt-source-trigger { font-family: var(--kt-font); border: var(--kt-border-width) solid var(--kt-border); padding: 0.75rem 1rem; border-radius: var(--kt-radius); display: flex; justify-content: space-between; cursor: pointer; background: var(--kt-bg); transition: all var(--kt-transition-micro); color: var(--kt-fg); }
.kt-source-trigger:hover { border-color: var(--kt-accent); }
.kt-source-list { position: absolute; top: 100%; width: 100%; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); z-index: 10; max-height: 20cqh; overflow-y: auto; }
.kt-source-item { font-family: var(--kt-font); padding: 0.75rem 1rem; cursor: pointer; display: flex; justify-content: space-between; color: var(--kt-fg); transition: all var(--kt-transition-micro); }
.kt-source-item:hover { background: var(--kt-muted); }
.kt-finetune-selector { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; }
.kt-finetune-option { position: relative; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); padding: 1rem; cursor: pointer; display: flex; flex-direction: column; gap: 0.25rem; transition: all var(--kt-transition-micro); }
.kt-finetune-option:hover { background: var(--kt-muted); transform: scale(1.02); }
.kt-finetune-option.active { background: var(--kt-accent); border-color: var(--kt-accent); }
.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; }
.kt-image-item:hover { transform: scale(1.05); }
.kt-image-item.active { border-color: var(--kt-accent); }
.kt-image-check { position: absolute; inset: 0; background: rgba(223, 225, 4, 0.4); display: none; align-items: center; justify-content: center; color: var(--kt-accent-fg); font-size: var(--kt-body); }
.kt-image-item.active .kt-image-check { display: flex; }
.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); }
@media (max-width: 900px) { .kt-mode-tabs { flex-direction: column; gap: 0.5rem; } .kt-tab-btn { width: 100%; text-align: center; } .kt-finetune-selector { grid-template-columns: 1fr; } }
.kt-tab-btn.is-locked {
opacity: 0.6;
cursor: not-allowed;
}
.kt-source-selector {
position: relative;
}
.kt-source-trigger {
font-family: var(--kt-font);
border: var(--kt-border-width) solid var(--kt-border);
padding: 0.75rem 1rem;
border-radius: var(--kt-radius);
display: flex;
justify-content: space-between;
cursor: pointer;
background: var(--kt-bg);
transition: all var(--kt-transition-micro);
color: var(--kt-fg);
}
.kt-source-trigger:hover {
border-color: var(--kt-accent);
}
.kt-source-list {
position: absolute;
top: 100%;
width: 100%;
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
z-index: 10;
max-height: 20cqh;
overflow-y: auto;
}
.kt-source-item {
font-family: var(--kt-font);
padding: 0.75rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
color: var(--kt-fg);
transition: all var(--kt-transition-micro);
}
.kt-source-item:hover {
background: var(--kt-muted);
}
.kt-finetune-selector {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.kt-finetune-option {
position: relative;
background: var(--kt-bg);
border: var(--kt-border-width) solid var(--kt-border);
border-radius: var(--kt-radius);
padding: 1rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: all var(--kt-transition-micro);
}
.kt-finetune-option:hover {
background: var(--kt-muted);
transform: scale(1.02);
}
.kt-finetune-option.active {
background: var(--kt-accent);
border-color: var(--kt-accent);
}
.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-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;
}
.kt-image-item:hover {
transform: scale(1.05);
}
.kt-image-item.active {
border-color: var(--kt-accent);
}
.kt-image-check {
position: absolute;
inset: 0;
background: rgba(223, 225, 4, 0.4);
display: none;
align-items: center;
justify-content: center;
color: var(--kt-accent-fg);
font-size: var(--kt-body);
}
.kt-image-item.active .kt-image-check {
display: flex;
}
.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);
}
@media (max-width: 900px) {
.kt-mode-tabs {
flex-direction: column;
gap: 0.5rem;
}
.kt-tab-btn {
width: 100%;
text-align: center;
}
.kt-finetune-selector {
grid-template-columns: 1fr;
}
}
.kt-upload-hint {
display: block;
@ -433,4 +667,45 @@ 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;
}
.kt-loading-placeholder {
width: 100%;
min-height: 150px;
background: var(--kt-bg);
border: var(--kt-border-width) dashed var(--kt-border);
border-radius: var(--kt-radius);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--kt-muted-fg);
font-family: var(--kt-font);
font-size: var(--kt-small);
}
.kt-loading-placeholder i {
font-size: 2rem;
color: var(--kt-accent);
}
.kt-empty-text {
width: 100%;
text-align: center;
padding: 2rem;
color: var(--kt-muted-fg);
grid-column: 1 / -1;
}
</style>

File diff suppressed because it is too large Load Diff

@ -619,12 +619,10 @@ onActivated(() => {
border-color: var(--kt-accent);
}
/* VIP Card Style */
.kt-setting-card--vip {
.kt-setting-card:hover .kt-setting-icon {
background: var(--kt-accent);
border-color: var(--kt-accent);
}
.kt-setting-card--vip:hover {
background: rgba(223, 225, 4, 0.05);
color: var(--kt-accent-fg);
}
/* ===== Transitions ===== */

File diff suppressed because it is too large Load Diff

@ -191,7 +191,7 @@ onUnmounted(() => {
.kt-hero__subtitle {
font-family: var(--kt-font);
font-size: clamp(1.25rem, 2.5vw, 2rem);
font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--kt-fg);
margin-top: 2rem;
text-transform: uppercase;
@ -246,27 +246,27 @@ onUnmounted(() => {
}
.paper-text-col {
/* 文字区域占比 35% */
flex: 0 0 35%;
padding: var(--kt-card-padding);
flex: 4;
padding: 1.75rem;
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 2;
min-width: 0;
border-right: none;
}
/* 右侧图片容器 */
.paper-img-col {
/* 图片区域占比 65% */
flex: 0 0 65%;
flex: 6;
position: relative;
overflow: hidden;
border-left: none;
background-color: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: center;
min-height: 0;
border-left: none;
}
/* 图片样式 */
@ -301,7 +301,7 @@ onUnmounted(() => {
line-height: 1;
flex-shrink: 0;
transition: color var(--kt-transition-normal);
margin-bottom: 1rem;
margin-bottom: 0.5rem;
}
.kt-card__content {
@ -310,7 +310,7 @@ onUnmounted(() => {
flex-direction: column;
justify-content: flex-end;
width: 100%;
padding-top: 1rem;
padding-top: 0.5rem;
}
.kt-card__title {
@ -344,7 +344,7 @@ onUnmounted(() => {
/* 针对非 Hero 卡片的特殊处理 */
.kt-card:not(.kt-card--paper) {
padding: var(--kt-card-padding);
padding: calc(var(--kt-card-padding) / 2);
justify-content: space-between;
}

@ -6,7 +6,6 @@
<div class="kt-subpage__card-header">
<h2 class="kt-subpage__title">
论文支持
<span class="kt-subpage__title-en">ACADEMIC RESEARCH</span>
</h2>
<!-- 页码指示器 -->
<div class="page-indicator">
@ -56,7 +55,7 @@
<div class="paper-authors">
<span class="label">AUTHORS:</span>
<p>{{ currentPaper.authors }}</p>
<span class="author-names">{{ currentPaper.authors }}</span>
</div>
<div class="paper-abstract-box">
@ -173,7 +172,7 @@ const prevPage = () => {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 3rem;
padding: 0.75rem 0.75rem;
border-bottom: var(--kt-border-width) solid var(--kt-border);
flex-shrink: 0;
}
@ -282,7 +281,7 @@ const prevPage = () => {
display: flex;
align-items: center;
justify-content: center;
padding: 0.8rem;
padding: 0.2rem;
border-right: 1px solid rgba(0,0,0,0.1);
}
@ -306,7 +305,7 @@ const prevPage = () => {
/* Right Page: Info */
.right-page {
padding: 3rem;
padding: 1.75rem;
display: flex;
flex-direction: column;
background: var(--kt-bg);
@ -348,7 +347,7 @@ const prevPage = () => {
line-height: 1.1;
text-transform: uppercase;
color: var(--kt-fg);
margin-bottom: 2rem;
margin-bottom: 0.75rem;
/* Limit lines */
display: -webkit-box;
-webkit-line-clamp: 3;
@ -357,7 +356,7 @@ const prevPage = () => {
}
.paper-authors {
margin-bottom: 2rem;
margin-bottom: 0.75rem;
}
.paper-authors .label,
@ -377,6 +376,12 @@ const prevPage = () => {
font-weight: 500;
}
.author-names {
font-family: var(--kt-font);
font-size: var(--kt-body)/2;
color: var(--kt-fg);
}
.paper-abstract-box {
flex: 1;
overflow-y: auto; /* Allow scrolling text only if strictly necessary, but ideally fits */

@ -161,10 +161,10 @@ onUnmounted(() => {
flex: 1;
position: relative;
overflow: hidden;
/* 使用透明背景,使其跟随父容器背景色 (适配 Light/Dark) */
background: transparent;
display: flex;
flex-direction: column;
padding: 0;
}
.slide-wrapper {
@ -172,7 +172,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
padding: 0.2rem;
overflow: hidden;
}

@ -1,43 +1,29 @@
<template>
<div class="kt-subpage kt-subpage--with-sidebar">
<div class="kt-subpage__content-card sample-container">
<!-- 头部 -->
<div class="kt-subpage__card-header">
<h2 class="kt-subpage__title">
样例效果
<span class="kt-subpage__title-en">VISUAL DEMONSTRATION</span>
</h2>
<div class="sample-tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ active: currentTab === tab.key }"
@click="currentTab = tab.key"
>
<button v-for="tab in tabs" :key="tab.key" class="tab-btn" :class="{ active: currentTab === tab.key }"
@click="currentTab = tab.key">
{{ tab.label }}
</button>
</div>
</div>
<!-- 内容展示区 -->
<div class="kt-subpage__card-body sample-body">
<div class="sample-desc">
<p class="kt-body-text">
<i class="fas fa-layer-group"></i>
点击卡片堆叠可切换下一张
</p>
</div>
<!-- 核心展示区 -->
<div class="demo-stage">
<!-- 上半部分输入对 -->
<div class="demo-row input-row">
<!-- Origin Stack -->
<div class="demo-col">
<div class="stack-box" @click="nextInput">
@ -77,12 +63,12 @@
<!-- 中间连接竖向箭头 + Prompt 提示 -->
<div class="demo-row connect-row">
<div class="demo-col">
<div class="demo-arrow-v">
<div class="line-v"></div>
<i class="fas fa-chevron-down"></i>
</div>
<div class="demo-arrow-v">
<div class="line-v"></div>
<i class="fas fa-chevron-down"></i>
</div>
</div>
<!-- 提示词显示区域 -->
<div class="demo-prompt-container" v-if="currentPrompt">
<div class="kt-prompt-capsule">
@ -93,16 +79,16 @@
<div class="demo-spacer" v-else></div>
<div class="demo-col">
<div class="demo-arrow-v">
<div class="line-v"></div>
<i class="fas fa-chevron-down"></i>
</div>
<div class="demo-arrow-v">
<div class="line-v"></div>
<i class="fas fa-chevron-down"></i>
</div>
</div>
</div>
<!-- 下半部分生成结果 -->
<div class="demo-row result-row">
<!-- Good Gen Stack -->
<div class="demo-col">
<div class="stack-box" @click="nextGoodGen">
@ -139,6 +125,14 @@
</div>
</div>
<div class="sample-desc">
<p class="kt-body-text">
<i class="fas fa-layer-group"></i>
点击卡片堆叠可切换下一张
</p>
</div>
</div>
</div>
</template>
@ -217,6 +211,7 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
<style scoped>
.sample-container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
background: var(--kt-bg);
@ -228,11 +223,11 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 3rem;
padding: 1rem 1rem;
border-bottom: var(--kt-border-width) solid var(--kt-border);
flex-shrink: 0;
background: var(--kt-muted);
gap: 3rem;
gap: 1.5rem;
}
.sample-tabs {
@ -258,8 +253,15 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
transition: all 0.2s ease;
}
.tab-btn:hover { color: var(--kt-fg); background: rgba(255,255,255,0.05); }
.tab-btn.active { background: var(--kt-accent); color: var(--kt-accent-fg); }
.tab-btn:hover {
color: var(--kt-fg);
background: rgba(255, 255, 255, 0.05);
}
.tab-btn.active {
background: var(--kt-accent);
color: var(--kt-accent-fg);
}
.sample-body {
flex: 1;
@ -271,8 +273,15 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
gap: 2rem;
}
.sample-desc { text-align: center; color: var(--kt-muted-fg); }
.sample-desc i { color: var(--kt-accent); margin-right: 0.5rem; }
.sample-desc {
text-align: center;
color: var(--kt-muted-fg);
}
.sample-desc i {
color: var(--kt-accent);
margin-right: 0.5rem;
}
/*
* === 新版布局结构 ===
@ -290,7 +299,7 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
gap: 1rem;
}
.demo-col {
@ -301,7 +310,8 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
}
.demo-spacer {
width: 120px; /* 对应横向箭头的宽度 */
width: 120px;
/* 对应横向箭头的宽度 */
}
/* 动态 Spacer */
@ -316,12 +326,14 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
display: flex;
align-items: center;
justify-content: center;
width: 120px; /* 保持与 spacer 一致,内容溢出可见 */
width: 120px;
/* 保持与 spacer 一致,内容溢出可见 */
position: relative;
}
.kt-prompt-capsule {
position: absolute; /* 绝对定位以允许宽度超出 120px 而不推挤两边 */
position: absolute;
/* 绝对定位以允许宽度超出 120px 而不推挤两边 */
left: 50%;
transform: translateX(-50%);
display: flex;
@ -332,7 +344,7 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
padding: 0.5rem 1rem;
border-radius: 999px;
white-space: nowrap;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
z-index: 10;
}
@ -375,16 +387,20 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
.stack-card {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--kt-bg);
border: 4px solid var(--kt-fg);
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
display: flex; align-items: center; justify-content: center;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
@ -392,8 +408,9 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
:root:not(.dark-mode) .stack-card {
background: #fff;
border-color: #e5e5e5;
box-shadow: 0 5px 15px rgba(0,0,0,0.08);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
}
:root:not(.dark-mode) .stack-card.layer-1 {
border-color: var(--kt-fg);
}
@ -401,7 +418,7 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
.layer-3 {
z-index: 1;
transform: translate(25px, 25px) rotate(6deg);
background: var(--kt-muted) !important;
background: var(--kt-muted) !important;
border-color: var(--kt-border) !important;
}
@ -422,36 +439,77 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
.stack-box:hover .layer-1 {
transform: translate(-5px, -5px) scale(1.02);
border-color: var(--kt-accent) !important;
box-shadow: 0 15px 30px rgba(0,0,0,0.3);
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.3);
}
.stack-box:hover .layer-2 {
transform: translate(15px, 15px) rotate(2deg);
}
.stack-box:hover .layer-3 {
transform: translate(30px, 30px) rotate(5deg);
}
.stack-box:hover .layer-2 { transform: translate(15px, 15px) rotate(2deg); }
.stack-box:hover .layer-3 { transform: translate(30px, 30px) rotate(5deg); }
@keyframes card-enter {
from { opacity: 0; transform: translate(10px, 10px); }
to { opacity: 1; transform: translate(0, 0); }
from {
opacity: 0;
transform: translate(10px, 10px);
}
to {
opacity: 1;
transform: translate(0, 0);
}
}
.stack-img {
width: 100%; height: 100%; object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.stack-badge {
position: absolute; bottom: 0; right: 0;
font-family: var(--kt-font); font-size: 0.65rem; font-weight: 800;
background: var(--kt-fg); color: var(--kt-bg);
padding: 4px 8px; text-transform: uppercase;
border-top-left-radius: 4px; z-index: 2;
position: absolute;
bottom: 0;
right: 0;
font-family: var(--kt-font);
font-size: 0.65rem;
font-weight: 800;
background: var(--kt-fg);
color: var(--kt-bg);
padding: 4px 8px;
text-transform: uppercase;
border-top-left-radius: 4px;
z-index: 2;
}
.badge-accent {
background: var(--kt-accent);
color: var(--kt-accent-fg);
}
.badge-danger {
background: #ef4444;
color: #fff;
}
.badge-success {
background: #22c55e;
color: #000;
}
.badge-accent { background: var(--kt-accent); color: var(--kt-accent-fg); }
.badge-danger { background: #ef4444; color: #fff; }
.badge-success { background: #22c55e; color: #000; }
.noise-hint {
position: absolute; top: 0; left: 0; right: 0;
padding: 4px; background: rgba(0,0,0,0.7);
color: var(--kt-accent); font-size: 0.6rem;
text-align: center; font-weight: 700; text-transform: uppercase;
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 4px;
background: rgba(0, 0, 0, 0.7);
color: var(--kt-accent);
font-size: 0.6rem;
text-align: center;
font-weight: 700;
text-transform: uppercase;
}
/*
@ -459,22 +517,49 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
*/
.demo-arrow-h {
width: 120px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.arrow-graphic {
display: flex; align-items: center; width: 100%; gap: 0.5rem;
display: flex;
align-items: center;
width: 100%;
gap: 0.5rem;
color: var(--kt-muted-fg);
}
.arrow-graphic i { font-size: 1.5rem; }
.line { flex: 1; height: 2px; background: var(--kt-muted-fg); }
.arrow-graphic i {
font-size: 1.5rem;
}
.line {
flex: 1;
height: 2px;
background: var(--kt-muted-fg);
}
.demo-arrow-v {
display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
color: var(--kt-muted-fg); height: 80px; justify-content: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--kt-muted-fg);
height: 80px;
justify-content: center;
}
.demo-arrow-v i {
font-size: 1.5rem;
}
.line-v {
flex: 1;
width: 2px;
background: var(--kt-muted-fg);
}
.demo-arrow-v i { font-size: 1.5rem; }
.line-v { flex: 1; width: 2px; background: var(--kt-muted-fg); }
/* Mobile */
@media (max-width: 900px) {
@ -486,28 +571,69 @@ const nextBadGen = () => { indices[currentTab.value].bad++ }
gap: 1.5rem;
padding: 1.5rem;
}
.sample-tabs {
width: 100%;
justify-content: space-between;
}
.tab-btn {
flex: 1;
text-align: center;
}
.demo-row { flex-direction: column; gap: 1.5rem; }
.demo-arrow-h { transform: rotate(90deg); margin: 1rem 0; }
.demo-row {
flex-direction: column;
gap: 1.5rem;
}
.demo-arrow-h {
transform: rotate(90deg);
margin: 1rem 0;
}
/* 移动端调整:隐藏中间连接线,但显示 Prompt */
.connect-row { display: flex; flex-direction: row; justify-content: center; width: 100%; height: auto; gap: 0; margin: 1rem 0; }
.connect-row .demo-col { display: none; } /* 隐藏竖向箭头 */
.demo-prompt-container { width: auto; position: static; }
.kt-prompt-capsule { position: static; transform: none; width: 100%; justify-content: center; max-width: 90vw; white-space: normal; text-align: center; }
.demo-spacer, .demo-spacer-dynamic { display: none; }
.stack-box { margin-bottom: 25px; margin-right: 25px; }
.col-label { margin-top: 2rem; }
.connect-row {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
gap: 0;
margin: 0;
}
.connect-row .demo-col {
display: none;
}
/* 隐藏竖向箭头 */
.demo-prompt-container {
width: auto;
position: static;
}
.kt-prompt-capsule {
position: static;
transform: none;
width: 100%;
justify-content: center;
max-width: 90vw;
white-space: normal;
text-align: center;
}
.demo-spacer,
.demo-spacer-dynamic {
display: none;
}
.stack-box {
margin-bottom: 25px;
margin-right: 25px;
}
.col-label {
margin-top: 2rem;
}
}
</style>
Loading…
Cancel
Save