From 9ff6cf0ebefd453561b3ee97ef1cd0c986ed814d Mon Sep 17 00:00:00 2001 From: yyx <20328610@qq.com> Date: Mon, 5 Jan 2026 00:41:54 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E9=80=BB?= =?UTF-8?q?=E8=BE=91bug=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/Style.css | 4 +- src/frontend/src/api/auth.js | 7 +- .../src/components/ImagePreviewModal.vue | 9 +- src/frontend/src/components/KtMarquee.vue | 62 +- src/frontend/src/components/NavBar.vue | 284 ++++---- src/frontend/src/components/TaskSideBar.vue | 54 +- src/frontend/src/utils/navbarHighlight.js | 3 +- .../src/utils/navbarHighlight.test.js | 173 ----- src/frontend/src/utils/request.js | 24 +- .../src/utils/scrollNavigation.test.js | 435 ----------- src/frontend/src/utils/subpageStyles.test.js | 267 ------- src/frontend/src/utils/theme.test.js | 311 -------- src/frontend/src/utils/touchTarget.test.js | 333 --------- src/frontend/src/views/LoginView.vue | 29 + src/frontend/src/views/MainFlow.vue | 9 +- src/frontend/src/views/Page1/Page1.vue | 28 +- .../src/views/Page1/subpages/QuickMode.vue | 10 + .../views/Page1/subpages/UniversalMode.vue | 10 + src/frontend/src/views/Page2/Page2.vue | 105 +-- .../views/Page2/subpages/SubpageContainer.vue | 10 + src/frontend/src/views/Page3/Page3.vue | 36 +- .../views/Page3/subpages/SubpageContainer.vue | 10 + src/frontend/src/views/Page4/Page4.vue | 677 +++++++----------- src/frontend/src/views/Page5/Page5.vue | 8 +- .../views/Page5/subpages/SubpageContainer.vue | 110 +-- src/frontend/src/views/home/HomePage.vue | 24 +- .../src/views/home/subpages/PaperSupport.vue | 19 +- .../views/home/subpages/PrincipleDiagram.vue | 4 +- .../src/views/home/subpages/SamplePreview.vue | 304 +++++--- 29 files changed, 981 insertions(+), 2378 deletions(-) delete mode 100644 src/frontend/src/utils/navbarHighlight.test.js delete mode 100644 src/frontend/src/utils/scrollNavigation.test.js delete mode 100644 src/frontend/src/utils/subpageStyles.test.js delete mode 100644 src/frontend/src/utils/theme.test.js delete mode 100644 src/frontend/src/utils/touchTarget.test.js diff --git a/src/frontend/src/Style.css b/src/frontend/src/Style.css index ae06593..131f9d9 100644 --- a/src/frontend/src/Style.css +++ b/src/frontend/src/Style.css @@ -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; diff --git a/src/frontend/src/api/auth.js b/src/frontend/src/api/auth.js index fd2a2f5..e4ea709 100644 --- a/src/frontend/src/api/auth.js +++ b/src/frontend/src/api/auth.js @@ -82,10 +82,15 @@ export function authLogout() { }) } +/** + * 修改密码 + * _skipLogout 标志,防止旧密码错误时被强制登出 + */ export function authChangePassword(data) { return request({ url: '/auth/change-password', method: 'post', - data + data, + _skipLogout: true }) } \ No newline at end of file diff --git a/src/frontend/src/components/ImagePreviewModal.vue b/src/frontend/src/components/ImagePreviewModal.vue index acb36b2..0291490 100644 --- a/src/frontend/src/components/ImagePreviewModal.vue +++ b/src/frontend/src/components/ImagePreviewModal.vue @@ -368,7 +368,14 @@ const rightImageLabel = computed(() => { return '防护后微调结果 (Protected Gen)' } - if (imgs.uploaded_generate || imgs.perturbed_generate) return 'Finetuned (微调生成)' + // 优先判断是否为加噪任务的加噪图片 + if (imgs.perturbed && imgs.perturbed.length > 0) return 'Perturbed (加噪图片)' + + // 判断是否为微调生成图 (且长度大于0) + if ((imgs.uploaded_generate && imgs.uploaded_generate.length > 0) || + (imgs.perturbed_generate && imgs.perturbed_generate.length > 0)) { + return 'Finetuned (微调生成)' + } return 'Protected (防护后)' }) diff --git a/src/frontend/src/components/KtMarquee.vue b/src/frontend/src/components/KtMarquee.vue index 16223a0..97d36bf 100644 --- a/src/frontend/src/components/KtMarquee.vue +++ b/src/frontend/src/components/KtMarquee.vue @@ -1,7 +1,19 @@ - + \ No newline at end of file diff --git a/src/frontend/src/components/TaskSideBar.vue b/src/frontend/src/components/TaskSideBar.vue index 1d539be..b1de423 100644 --- a/src/frontend/src/components/TaskSideBar.vue +++ b/src/frontend/src/components/TaskSideBar.vue @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/src/frontend/src/utils/navbarHighlight.js b/src/frontend/src/utils/navbarHighlight.js index 8996187..cb8c1b4 100644 --- a/src/frontend/src/utils/navbarHighlight.js +++ b/src/frontend/src/utils/navbarHighlight.js @@ -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 } diff --git a/src/frontend/src/utils/navbarHighlight.test.js b/src/frontend/src/utils/navbarHighlight.test.js deleted file mode 100644 index 42cce84..0000000 --- a/src/frontend/src/utils/navbarHighlight.test.js +++ /dev/null @@ -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() - }) - }) -}) diff --git a/src/frontend/src/utils/request.js b/src/frontend/src/utils/request.js index 469cda8..fefb0eb 100644 --- a/src/frontend/src/utils/request.js +++ b/src/frontend/src/utils/request.js @@ -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) } diff --git a/src/frontend/src/utils/scrollNavigation.test.js b/src/frontend/src/utils/scrollNavigation.test.js deleted file mode 100644 index cc6c8b5..0000000 --- a/src/frontend/src/utils/scrollNavigation.test.js +++ /dev/null @@ -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: 边界滚动检测 - * - * *对于任意*滚动信息(scrollTop、clientHeight、scrollHeight),checkScrollState 应正确计算 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 } - ) - }) - }) -}) diff --git a/src/frontend/src/utils/subpageStyles.test.js b/src/frontend/src/utils/subpageStyles.test.js deleted file mode 100644 index 0da4fde..0000000 --- a/src/frontend/src/utils/subpageStyles.test.js +++ /dev/null @@ -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) - } - }) - }) -}) diff --git a/src/frontend/src/utils/theme.test.js b/src/frontend/src/utils/theme.test.js deleted file mode 100644 index 3070a3b..0000000 --- a/src/frontend/src/utils/theme.test.js +++ /dev/null @@ -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: 主题切换布局稳定性 - * - * *对于任意*主题切换操作,切换前后的布局属性(width、height、padding、margin、grid-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) - }) - }) -}) diff --git a/src/frontend/src/utils/touchTarget.test.js b/src/frontend/src/utils/touchTarget.test.js deleted file mode 100644 index 8370244..0000000 --- a/src/frontend/src/utils/touchTarget.test.js +++ /dev/null @@ -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') - }) - }) -}) diff --git a/src/frontend/src/views/LoginView.vue b/src/frontend/src/views/LoginView.vue index dd60131..5c77781 100644 --- a/src/frontend/src/views/LoginView.vue +++ b/src/frontend/src/views/LoginView.vue @@ -180,6 +180,7 @@ /> +

密码至少8位,且需包含大小写字母、数字和符号

@@ -278,6 +279,7 @@ /> +

密码至少8位,且需包含大小写字母、数字和符号

@@ -411,6 +413,15 @@ const validateEmail = (email) => { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) } +/** + * 校验密码强度 + * 至少8位,包含大小写、数字、特殊符号 + */ +const validatePasswordStrength = (pwd) => { + const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._-])[A-Za-z\d@$!%*?&._-]{8,}$/; + return regex.test(pwd); +} + // 统一的发送验证码处理函数 const handleSendCode = async (purpose) => { let email = '' @@ -450,6 +461,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 +494,10 @@ const handleForgotPassword = async () => { return modal.warning('两次输入的新密码不一致') } + if (!validatePasswordStrength(f.new_password)) { + return modal.warning('新密码强度不足:需至少8位且包含大小写字母、数字和符号') + } + loading.value = true try { // 调用忘记密码接口 @@ -793,6 +813,15 @@ const handleForgotPassword = async () => { z-index: 2; } +.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; diff --git a/src/frontend/src/views/MainFlow.vue b/src/frontend/src/views/MainFlow.vue index c0e8bd4..421c698 100644 --- a/src/frontend/src/views/MainFlow.vue +++ b/src/frontend/src/views/MainFlow.vue @@ -458,8 +458,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 +709,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; } diff --git a/src/frontend/src/views/Page1/Page1.vue b/src/frontend/src/views/Page1/Page1.vue index 6f94699..33e4f1d 100644 --- a/src/frontend/src/views/Page1/Page1.vue +++ b/src/frontend/src/views/Page1/Page1.vue @@ -37,7 +37,7 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode') MODE
-

通用模式,适用于大多数场景的防护模式,提供多种可调节参数,满足不同需求。

+

通用防护,适用于人脸和艺术品防护,经典模式

@@ -64,11 +64,11 @@ const OpenQuick = () => openSubpage('page1', 'QuickMode') @keydown.space.prevent="OpenQuick" >
- 快速 - 模式 + QUICK + 快速模式
-

快速防护,系统自动推荐配置,一键上传。

+

加速防护,效果不差且耗时更低。

@@ -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; diff --git a/src/frontend/src/views/Page1/subpages/QuickMode.vue b/src/frontend/src/views/Page1/subpages/QuickMode.vue index 9e2159c..f961bf9 100644 --- a/src/frontend/src/views/Page1/subpages/QuickMode.vue +++ b/src/frontend/src/views/Page1/subpages/QuickMode.vue @@ -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: '', @@ -151,6 +152,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 = '' } diff --git a/src/frontend/src/views/Page1/subpages/UniversalMode.vue b/src/frontend/src/views/Page1/subpages/UniversalMode.vue index e6471c5..400c5df 100644 --- a/src/frontend/src/views/Page1/subpages/UniversalMode.vue +++ b/src/frontend/src/views/Page1/subpages/UniversalMode.vue @@ -151,6 +151,7 @@ const isSubmitting = ref(false) const isCustomMode = ref(false) let specificPollTimer = null const MAX_UPLOAD_COUNT = 5 +const MAX_TOTAL_SIZE_MB = 15//限制前端上传最大 15MB const formData = ref({ taskName: '', algorithm: '', strength: 12.0, style: 'face', files: [] }) @@ -210,6 +211,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 = '' } diff --git a/src/frontend/src/views/Page2/Page2.vue b/src/frontend/src/views/Page2/Page2.vue index 0b8ed1f..239e756 100644 --- a/src/frontend/src/views/Page2/Page2.vue +++ b/src/frontend/src/views/Page2/Page2.vue @@ -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') \ No newline at end of file + diff --git a/src/frontend/src/views/Page2/subpages/SubpageContainer.vue b/src/frontend/src/views/Page2/subpages/SubpageContainer.vue index 1633126..973ab96 100644 --- a/src/frontend/src/views/Page2/subpages/SubpageContainer.vue +++ b/src/frontend/src/views/Page2/subpages/SubpageContainer.vue @@ -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 = '' } diff --git a/src/frontend/src/views/Page3/Page3.vue b/src/frontend/src/views/Page3/Page3.vue index 0e1d53b..15a1567 100644 --- a/src/frontend/src/views/Page3/Page3.vue +++ b/src/frontend/src/views/Page3/Page3.vue @@ -29,7 +29,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap')
- 微调生图验证 + 微调生图
验证差异

FINE-TUNING VERIFICATION

@@ -87,7 +87,7 @@ const handleOpenHeatmap = () => openSubpage('page3', 'heatmap') >
-

热力图差异分析

+

热力图
差异分析

HEATMAP

Attention Map 差异分析

@@ -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 { diff --git a/src/frontend/src/views/Page3/subpages/SubpageContainer.vue b/src/frontend/src/views/Page3/subpages/SubpageContainer.vue index 82558bf..711b0fc 100644 --- a/src/frontend/src/views/Page3/subpages/SubpageContainer.vue +++ b/src/frontend/src/views/Page3/subpages/SubpageContainer.vue @@ -195,6 +195,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' ? '热力图分析' : '数据指标对比')) @@ -301,6 +302,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 = '' } diff --git a/src/frontend/src/views/Page4/Page4.vue b/src/frontend/src/views/Page4/Page4.vue index ecf79b3..8e554cd 100644 --- a/src/frontend/src/views/Page4/Page4.vue +++ b/src/frontend/src/views/Page4/Page4.vue @@ -1,15 +1,13 @@ @@ -285,7 +278,7 @@ const statusTabs = [ { key: 'all', label: '全部' }, { key: 'running', label: '进行中' }, { key: 'completed', label: '已完成' }, - { key: 'failed', label: '失败' } + { key: 'failed', label: '失败' } // 这里其实也包含了 Cancelled ] const sortRules = ref([{ field: 'created_at', direction: 'desc' }]) const currentPage = ref(1) @@ -298,19 +291,25 @@ const showLogModal = ref(false) const currentLogTaskId = ref(null) const logContent = ref('') - - const normalizeStatus = (status) => { - if (['pending', 'processing', 'waiting', 'running'].includes(status)) return 'running' - return status + const s = status ? status.toLowerCase() : '' + if (['pending', 'processing', 'waiting', 'running'].includes(s)) return 'running' + if (s === 'cancelled') return 'cancelled' + return s } const formatStatusLabel = (s) => { + const key = s ? s.toLowerCase() : '' const map = { - running: '运行中', processing: '处理中', pending: '排队中', waiting: '排队中', - completed: '已完成', failed: '失败' + running: '运行中', + processing: '处理中', + pending: '排队中', + waiting: '排队中', + completed: '已完成', + failed: '失败', + cancelled: '已取消' } - return map[s] || s + return map[key] || s } const formatTypeLabel = (t) => { @@ -339,7 +338,14 @@ const filteredAndSortedTasks = computed(() => { let result = [...(taskStore.tasks || [])] if (currentStatus.value !== 'all') { - result = result.filter(t => normalizeStatus(t.status) === currentStatus.value) + // 简单过滤器:如果是 failed,可能包含 cancelled,这取决于后端。 + // 如果要让 '失败' Tab 同时显示 Cancelled,可以在这里做包含逻辑。 + // 目前保持严格匹配,或者让 'cancelled' 归类到 'failed' Tab + if (currentStatus.value === 'failed') { + result = result.filter(t => ['failed', 'cancelled'].includes(t.status?.toLowerCase())) + } else { + result = result.filter(t => normalizeStatus(t.status) === currentStatus.value) + } } if (selectedTaskType.value !== 'all') { @@ -391,7 +397,6 @@ const handlePreview = (task) => { showPreview.value = true } -// handleCancel (仅取消运行中/排队中的任务) const handleCancel = async (task) => { const confirmed = await modal.confirm(`确定要终止任务 #${task.task_id} 吗?`) if (!confirmed) return @@ -405,7 +410,6 @@ const handleCancel = async (task) => { } } -// 处理任务重启 const handleRestart = async (task) => { const confirmed = await modal.confirm(`确定要重启任务 #${task.task_id} 吗?`) if (!confirmed) return @@ -419,7 +423,6 @@ const handleRestart = async (task) => { } } -// 处理任务删除 const handleDelete = async (task) => { const confirmed = await modal.confirm(`确定要彻底删除任务 #${task.task_id} 吗?此操作无法撤销。`) if (!confirmed) return @@ -451,87 +454,45 @@ const handleViewLogs = async (task) => { } const handleDownload = async (task) => { - if (task.status !== 'completed') { - return modal.warning('任务未完成,无法下载') - } - - modal.info('正在准备下载文件,请稍候...') // 给出反馈 - + if (task.status !== 'completed') return modal.warning('任务未完成') try { - // 1. 调用接口获取任务结果图片数据 - const res = await getTaskImagePreview(task.task_id) - - if (!res || !res.images) { - return modal.warning('任务结果中未找到图片数据') - } - - // 2. 整合所有图片到一个列表中 - const allImages = [] - // 合并所有结果图片列表(finetune, perturbation, heatmap, report) - Object.values(res.images).forEach(list => { - if (Array.isArray(list)) { - allImages.push(...list) - } - }) - - if (allImages.length === 0) { - return modal.warning('任务结果中未找到图片数据') - } - + const type = task.task_type || 'perturbation' + let res = await getTaskResultImages(type, task.task_id) + if (res instanceof Blob) res = JSON.parse(await res.text()) const zip = new JSZip() - const folder = zip.folder(`task_${task.task_id}_${task.task_type}`) - - // 3. 将 Blob 或 Base64 数据添加到 ZIP 文件 - for (const img of allImages) { - const filename = img.filename || `${img.image_type || 'unknown'}_${img.image_id}.png` - const dataUrl = img.data // 这里的 data 是 Blob URL 或 Base64 - - if (dataUrl.startsWith('blob:')) { - // 如果是 Blob URL,需要先 fetch 转换成 ArrayBuffer - const blob = await fetch(dataUrl).then(r => r.blob()) - // ArrayBuffer 比 Base64 性能更好 - folder.file(filename, blob, { binary: true }) - } else if (dataUrl.startsWith('data:')) { - // 如果是 Base64 数据(包含前缀),需要提取出 Base64 字符串 - const base64String = dataUrl.split(',')[1] - folder.file(filename, base64String, { base64: true }) - } + const folder = zip.folder(`task_${task.task_id}`) + let hasImg = false + const processImg = (img, prefix='') => { + const d = img.data || img.base64 + let n = img.filename || img.stored_filename || `${prefix}_${img.image_id}.png` + if (d) { folder.file(n, d.includes(',') ? d.split(',')[1] : d, { base64: true }); hasImg = true } } - - // 4. 生成 ZIP 文件并触发下载 + if (res.images) res.images.forEach(i => processImg(i)) + else { + ['original_generate', 'perturbed_generate', 'uploaded_generate'].forEach(k => { + if (res[k]) res[k].forEach(i => processImg(i, k)) + }) + } + if (!hasImg) return modal.warning('无图片数据') const content = await zip.generateAsync({ type: 'blob' }) const url = URL.createObjectURL(content) const link = document.createElement('a') link.href = url - link.download = `museguard_task_${task.task_id}_${task.task_type}.zip` - document.body.appendChild(link) + link.download = `task_${task.task_id}.zip` link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) // 释放 Blob URL - - modal.success('文件打包完成,下载已开始') - - } catch (e) { - console.error('下载打包失败:', e) - modal.error('下载打包失败: ' + (e.message || '未知错误')) - } + } catch (e) { modal.error('下载出错: ' + e.message) } } onMounted(() => { - // 1. 检查 Session Storage 是否有过滤状态 const savedStatus = sessionStorage.getItem('kt_task_filter_status') - if (savedStatus) { currentStatus.value = savedStatus - sessionStorage.removeItem('kt_task_filter_status') // 使用后清除,避免下次刷新保留状态 + sessionStorage.removeItem('kt_task_filter_status') } - - // 2. 加载任务列表 taskStore.fetchTasks() }) - \ No newline at end of file diff --git a/src/frontend/src/views/Page5/Page5.vue b/src/frontend/src/views/Page5/Page5.vue index 26422f3..6c75825 100644 --- a/src/frontend/src/views/Page5/Page5.vue +++ b/src/frontend/src/views/Page5/Page5.vue @@ -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 ===== */ diff --git a/src/frontend/src/views/Page5/subpages/SubpageContainer.vue b/src/frontend/src/views/Page5/subpages/SubpageContainer.vue index 7f68db6..050b898 100644 --- a/src/frontend/src/views/Page5/subpages/SubpageContainer.vue +++ b/src/frontend/src/views/Page5/subpages/SubpageContainer.vue @@ -148,7 +148,7 @@
-
+

{{ modalTitle }} {{ modalTitleEn }}

@@ -164,6 +164,7 @@
+

密码至少8位,且需包含大小写字母、数字和符号

@@ -320,7 +321,49 @@ const onConfigDataTypeChange = () => { if (configForm.value.perturbation_configs const fetchConfig = async () => { const res = await getUserConfig(); if (res?.config) { configForm.value = { data_type_id: res.config.data_type_id || 1, perturbation_configs_id: res.config.perturbation_configs_id, perturbation_intensity: res.config.perturbation_intensity }; nextTick(() => { const p = algorithmSettings.value.presets; isCustomMode.value = !(res.config.perturbation_intensity === p.low || res.config.perturbation_intensity === p.mid || res.config.perturbation_intensity === p.high) }) } } const submitConfig = async () => { await updateUserConfig(configForm.value); modal.success('配置已保存'); handleClose() } -const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' }); const submitPassword = async () => { if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) return modal.warning('两次输入不一致'); try { await authChangePassword({ old_password: pwdForm.value.oldPassword, new_password: pwdForm.value.newPassword }); modal.success('密码修改成功'); handleClose() } catch(e) {} } +// === Password Logic (成功后再跳转,失败不跳转) === +const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' }) + +/** + * 校验密码强度 + */ +const validatePasswordStrength = (pwd) => { + const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._-])[A-Za-z\d@$!%*?&._-]{8,}$/; + return regex.test(pwd); +} + +const submitPassword = async () => { + if (!pwdForm.value.oldPassword || !pwdForm.value.newPassword || !pwdForm.value.confirmPassword) { + return modal.warning('请填写完整的密码信息') + } + if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) { + return modal.warning('两次输入的新密码不一致') + } + + if (!validatePasswordStrength(pwdForm.value.newPassword)) { + return modal.warning('新密码强度不足:需至少8位且包含大小写字母、数字和符号') + } + + try { + // 拦截器配置了 _skipLogout,所以如果旧密码错(401),这里会进 catch 而不是直接跳转 + await authChangePassword({ + old_password: pwdForm.value.oldPassword, + new_password: pwdForm.value.newPassword + }) + + // modal.success 返回一个 Promise,用户点击确认后才会 resolve + await modal.success('密码修改成功,请使用新密码重新登录') + + // 确认后清空状态并跳转 + userStore.logout() + router.push('/login') + + } catch(e) { + console.error('修改密码失败:', e) + const errorMsg = e.message || '修改失败,请检查旧密码是否正确' + await modal.error(errorMsg) + } +} const allAdminUsers = ref([]); const searchKeyword = ref(''); const currentPage = ref(1); const sortRules = ref([]); const isRefreshing = ref(false); const pageSize = 20 const totalPages = computed(() => Math.ceil(filteredUsers.value.length / pageSize) || 1) @@ -367,7 +410,7 @@ onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpage text-transform: uppercase; letter-spacing: 0.05em; border-bottom: var(--kt-border-width) solid var(--kt-border); - position: sticky; top: 0; z-index: 10; + position: sticky; top: 0; z-index: 1; } .kt-list-row { @@ -405,68 +448,39 @@ onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpage .kt-subpage-layout { width: 100%; height: 100%; padding: 2rem; display: flex; justify-content: center; align-items: center; background: var(--kt-bg); } .kt-modal-wrapper { width: 100%; max-width: 500px; } .kt-modal-wrapper--wide { max-width: 90vw; } -.kt-modal-card { width: 100%; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); } +.kt-modal-card { width: 100%; display: flex; flex-direction: column; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); } .kt-modal-card--fullscreen { height: 80vh; display: flex; flex-direction: column; } .kt-modal-header { padding: 1.5rem 2rem; border-bottom: var(--kt-border-width) solid var(--kt-border); background: var(--kt-muted); display: flex; justify-content: space-between; align-items: center; } .kt-modal-title { font-family: var(--kt-font); font-weight: 700; color: var(--kt-fg); font-size: var(--kt-body); text-transform: uppercase; letter-spacing: 0.02em; margin: 0; display: flex; align-items: baseline; gap: 1rem; } .kt-modal-title-en { font-size: var(--kt-small); color: var(--kt-muted-fg); font-weight: 400; letter-spacing: 0.1em; } - -/* 5. 按钮与交互 */ .kt-close-btn { width: 40px; height: 40px; background: transparent; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-muted-fg); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--kt-transition-micro); } .kt-close-btn:hover { background: var(--kt-fg); border-color: var(--kt-fg); color: var(--kt-bg); } -.kt-text-btn { font-weight: 700; color: var(--kt-muted-fg); background: none; border: none; cursor: pointer; transition: 0.2s; white-space: nowrap; } -.kt-text-btn--edit:hover { color: var(--kt-accent); } -.kt-text-btn--danger { color: #ef4444; } -.kt-actions { display: flex; gap: 0.75rem; justify-content: flex-end; } -.kt-btn { padding: 0.8rem 1.5rem; cursor: pointer; font-weight: 700; text-transform: uppercase; border: 2px solid var(--kt-border); background: none; color: var(--kt-fg); font-family: var(--kt-font); transition: 0.2s; } -.kt-btn--primary { background: var(--kt-accent); border-color: var(--kt-accent); color: #000; } -.kt-btn--primary:hover { background: var(--kt-fg); color: var(--kt-bg); border-color: var(--kt-fg); } - -/* 确保块级堆叠 */ .kt-modal-body { padding: 2.5rem; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2rem; } .kt-form { display: flex; flex-direction: column; gap: 1.5rem; width: 100%; } -.kt-form-group { display: block; width: 100%; margin-bottom: 0.5rem; } /* 强制块级 */ +.kt-form-group { display: block; width: 100%; margin-bottom: 0.5rem; } .kt-form-label { display: block; margin-bottom: 0.75rem; font-size: var(--kt-small); font-weight: 700; text-transform: uppercase; color: var(--kt-fg); letter-spacing: 0.05em; } -.kt-form-input { - display: block; - width: 100%; - padding: 1rem; - border: var(--kt-border-width) solid var(--kt-border); - background: var(--kt-bg); - color: var(--kt-fg); - font-family: var(--kt-font); - transition: border-color 0.2s; - box-sizing: border-box; /* 必须包含 padding */ -} +.kt-form-input { display: block; width: 100%; padding: 1rem; border: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); color: var(--kt-fg); font-family: var(--kt-font); transition: border-color 0.2s; box-sizing: border-box; } .kt-form-input:focus { border-color: var(--kt-accent); outline: none; } .kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1rem; } +.kt-btn { padding: 0.8rem 1.5rem; cursor: pointer; font-weight: 700; text-transform: uppercase; border: 2px solid var(--kt-border); background: none; color: var(--kt-fg); font-family: var(--kt-font); transition: 0.2s; } +.kt-btn--primary { background: var(--kt-accent); border-color: var(--kt-accent); color: #000; } +.kt-btn--primary:hover { background: var(--kt-fg); color: var(--kt-bg); border-color: var(--kt-fg); } -/* 7. 其他组件 */ -.kt-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 0.5rem; } -.kt-search-group { position: relative; flex: 1; display: flex; align-items: center; gap: 1rem; } -.kt-search-input { width: 100%; max-width: 300px; padding: 0.75rem 1rem 0.75rem 2.5rem; border: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); color: var(--kt-fg); } -.kt-search-icon { position: absolute; left: 1rem; color: var(--kt-muted-fg); } -.kt-table-wrapper { flex: 1; overflow-y: auto; border: var(--kt-border-width) solid var(--kt-border); } -.kt-role-tag { width: 80px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--kt-border); font-weight: 700; font-size: 0.7rem; text-transform: uppercase; } -.kt-role--admin { background: var(--kt-accent); color: #000; border-color: var(--kt-accent); } -.kt-role--vip { color: var(--kt-accent); border-color: var(--kt-accent); } -.kt-pagination { display: flex; justify-content: center; align-items: center; gap: 1.5rem; padding-top: 1rem; } -.kt-page-btn { width: 36px; height: 36px; border: 1px solid var(--kt-border); background: none; color: var(--kt-fg); cursor: pointer; } -.kt-form-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; min-height: 44px; } -.kt-mode-toggle { display: flex; gap: 0.5rem; background: var(--kt-muted); padding: 0.3rem; } -.kt-mode-toggle span { padding: 0.3rem 0.8rem; cursor: pointer; font-size: 0.7rem; font-weight: 700; } -.kt-mode-toggle span.active { background: var(--kt-bg); color: var(--kt-fg); } -.kt-strength-selector { display: flex; border: 1px solid var(--kt-border); } -.kt-strength-item { flex: 1; text-align: center; padding: 0.6rem; cursor: pointer; font-weight: 700; } -.kt-strength-item.active { background: var(--kt-accent); color: #000; } -.kt-sub-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 3000; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(4px); } -.kt-sub-modal-card { width: 95%; max-width: 500px; height: auto !important; } +.kt-input-hint { + font-family: var(--kt-font); + font-size: 0.75rem; + color: var(--kt-muted-fg); + margin-top: 0.5rem; + line-height: 1.4; +} @media (max-width: 900px) { .kt-modal-card--fullscreen { height: auto; } .kt-list-header { display: none; } .kt-list-row { grid-template-columns: 1fr; padding: 1.5rem; gap: 0.5rem; } - .kt-col--right { justify-content: flex-start; } .kt-actions { justify-content: flex-start; margin-top: 1rem; } } + +.kt-close-btn:focus-visible, .kt-btn:focus-visible, .kt-form-input:focus-visible, .kt-form-select:focus-visible, .kt-text-btn:focus-visible, .kt-page-btn:focus-visible { outline: 2px solid var(--kt-accent); outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { .kt-close-btn, .kt-btn, .kt-list-row, .kt-text-btn, .kt-page-btn, .kt-form-input, .kt-form-select { transition: none; } .kt-btn:hover, .kt-page-btn:hover:not(:disabled) { transform: none; } } \ No newline at end of file diff --git a/src/frontend/src/views/home/HomePage.vue b/src/frontend/src/views/home/HomePage.vue index 9e1fb9c..a17120e 100644 --- a/src/frontend/src/views/home/HomePage.vue +++ b/src/frontend/src/views/home/HomePage.vue @@ -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; } diff --git a/src/frontend/src/views/home/subpages/PaperSupport.vue b/src/frontend/src/views/home/subpages/PaperSupport.vue index 88ee260..325c35e 100644 --- a/src/frontend/src/views/home/subpages/PaperSupport.vue +++ b/src/frontend/src/views/home/subpages/PaperSupport.vue @@ -6,7 +6,6 @@

论文支持 - ACADEMIC RESEARCH

@@ -56,7 +55,7 @@
AUTHORS: -

{{ currentPaper.authors }}

+ {{ currentPaper.authors }}
@@ -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 */ diff --git a/src/frontend/src/views/home/subpages/PrincipleDiagram.vue b/src/frontend/src/views/home/subpages/PrincipleDiagram.vue index ebb7df8..148b97c 100644 --- a/src/frontend/src/views/home/subpages/PrincipleDiagram.vue +++ b/src/frontend/src/views/home/subpages/PrincipleDiagram.vue @@ -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; } diff --git a/src/frontend/src/views/home/subpages/SamplePreview.vue b/src/frontend/src/views/home/subpages/SamplePreview.vue index 2349853..d06cf46 100644 --- a/src/frontend/src/views/home/subpages/SamplePreview.vue +++ b/src/frontend/src/views/home/subpages/SamplePreview.vue @@ -1,43 +1,29 @@ @@ -217,6 +211,7 @@ const nextBadGen = () => { indices[currentTab.value].bad++ } \ No newline at end of file -- 2.34.1 From dab0f638f9c47fb98fb194ef4147d01d090b69a0 Mon Sep 17 00:00:00 2001 From: yyx <20328610@qq.com> Date: Mon, 5 Jan 2026 01:11:15 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=A1=A8=E6=A0=BC=E5=B0=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/views/Page4/Page4.vue | 20 +++-- .../views/Page5/subpages/SubpageContainer.vue | 76 ++++++++++++------- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/src/frontend/src/views/Page4/Page4.vue b/src/frontend/src/views/Page4/Page4.vue index 8e554cd..204cbd8 100644 --- a/src/frontend/src/views/Page4/Page4.vue +++ b/src/frontend/src/views/Page4/Page4.vue @@ -88,7 +88,7 @@
- #{{ task.task_id }} + #{{ task.virtual_id }} {{ formatStatusLabel(task.status) }} @@ -102,7 +102,7 @@
- #{{ task.task_id }} + #{{ task.virtual_id }}
@@ -335,7 +335,12 @@ const formatDate = (iso) => iso ? new Date(iso).toLocaleString('zh-CN', { hour12 const handleStatusChange = (status) => { currentStatus.value = status; currentPage.value = 1 } const filteredAndSortedTasks = computed(() => { - let result = [...(taskStore.tasks || [])] + // 1. 先按主键 ID 升序排列,生成从 1 开始的 virtual_id + const sortedByPk = [...(taskStore.tasks || [])].sort((a, b) => a.task_id - b.task_id); + let result = sortedByPk.map((t, index) => ({ + ...t, + virtual_id: index + 1 + })); if (currentStatus.value !== 'all') { // 简单过滤器:如果是 failed,可能包含 cancelled,这取决于后端。 @@ -359,6 +364,7 @@ const filteredAndSortedTasks = computed(() => { const nameMatch = getTaskName(t).toLowerCase().includes(keyword) return idMatch || nameMatch }) + } if (sortRules.value.length > 0) { @@ -667,8 +673,12 @@ onMounted(() => { .kt-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 300px; color: var(--kt-muted-fg); font-family: var(--kt-font); font-size: var(--kt-body); } .kt-empty-state i { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; } -.kt-mobile-view { display: none; } -.kt-desktop-only { display: flex; } +.kt-mobile-view { + display: none !important; /* 强制隐藏 */ +} +.kt-desktop-only { + display: flex !important; /* 确保显示 */ +} /* Log Modal (Keep existing styles) */ .kt-modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(4px); z-index: 2000; display: flex; justify-content: center; align-items: center; } diff --git a/src/frontend/src/views/Page5/subpages/SubpageContainer.vue b/src/frontend/src/views/Page5/subpages/SubpageContainer.vue index 050b898..c6b2887 100644 --- a/src/frontend/src/views/Page5/subpages/SubpageContainer.vue +++ b/src/frontend/src/views/Page5/subpages/SubpageContainer.vue @@ -148,7 +148,7 @@
-
+

{{ modalTitle }} {{ modalTitleEn }}

@@ -164,7 +164,6 @@
-

密码至少8位,且需包含大小写字母、数字和符号

@@ -324,33 +323,26 @@ const submitConfig = async () => { await updateUserConfig(configForm.value); mod // === Password Logic (成功后再跳转,失败不跳转) === const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' }) -/** - * 校验密码强度 - */ -const validatePasswordStrength = (pwd) => { - const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._-])[A-Za-z\d@$!%*?&._-]{8,}$/; - return regex.test(pwd); -} - const submitPassword = async () => { + // 1. 基础校验 if (!pwdForm.value.oldPassword || !pwdForm.value.newPassword || !pwdForm.value.confirmPassword) { return modal.warning('请填写完整的密码信息') } + + // 2. 一致性校验 if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) { return modal.warning('两次输入的新密码不一致') } - - if (!validatePasswordStrength(pwdForm.value.newPassword)) { - return modal.warning('新密码强度不足:需至少8位且包含大小写字母、数字和符号') - } try { + // 3. 调用后端 API // 拦截器配置了 _skipLogout,所以如果旧密码错(401),这里会进 catch 而不是直接跳转 await authChangePassword({ old_password: pwdForm.value.oldPassword, new_password: pwdForm.value.newPassword }) + // 4. 成功处理:等待用户确认 -> 登出 -> 跳转登录 // modal.success 返回一个 Promise,用户点击确认后才会 resolve await modal.success('密码修改成功,请使用新密码重新登录') @@ -359,7 +351,9 @@ const submitPassword = async () => { router.push('/login') } catch(e) { + // 5. 失败处理:弹出错误 -> 停留在当前页 console.error('修改密码失败:', e) + // 显示拦截器处理后的友好错误消息 const errorMsg = e.message || '修改失败,请检查旧密码是否正确' await modal.error(errorMsg) } @@ -455,29 +449,59 @@ onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpage .kt-modal-title-en { font-size: var(--kt-small); color: var(--kt-muted-fg); font-weight: 400; letter-spacing: 0.1em; } .kt-close-btn { width: 40px; height: 40px; background: transparent; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-muted-fg); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--kt-transition-micro); } .kt-close-btn:hover { background: var(--kt-fg); border-color: var(--kt-fg); color: var(--kt-bg); } -.kt-modal-body { padding: 2.5rem; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2rem; } -.kt-form { display: flex; flex-direction: column; gap: 1.5rem; width: 100%; } -.kt-form-group { display: block; width: 100%; margin-bottom: 0.5rem; } -.kt-form-label { display: block; margin-bottom: 0.75rem; font-size: var(--kt-small); font-weight: 700; text-transform: uppercase; color: var(--kt-fg); letter-spacing: 0.05em; } -.kt-form-input { display: block; width: 100%; padding: 1rem; border: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); color: var(--kt-fg); font-family: var(--kt-font); transition: border-color 0.2s; box-sizing: border-box; } -.kt-form-input:focus { border-color: var(--kt-accent); outline: none; } -.kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1rem; } +.kt-text-btn { font-weight: 700; color: var(--kt-muted-fg); background: none; border: none; cursor: pointer; transition: 0.2s; white-space: nowrap; } +.kt-text-btn--edit:hover { color: var(--kt-accent); } +.kt-text-btn--danger { color: #ef4444; } +.kt-actions { display: flex; gap: 0.75rem; justify-content: flex-end; } .kt-btn { padding: 0.8rem 1.5rem; cursor: pointer; font-weight: 700; text-transform: uppercase; border: 2px solid var(--kt-border); background: none; color: var(--kt-fg); font-family: var(--kt-font); transition: 0.2s; } .kt-btn--primary { background: var(--kt-accent); border-color: var(--kt-accent); color: #000; } .kt-btn--primary:hover { background: var(--kt-fg); color: var(--kt-bg); border-color: var(--kt-fg); } -.kt-input-hint { +/* 确保块级堆叠 */ +.kt-modal-body { padding: 2.5rem; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2rem; } +.kt-form { display: flex; flex-direction: column; gap: 1.5rem; width: 100%; } +.kt-form-group { display: block; width: 100%; margin-bottom: 0.5rem; } /* 强制块级 */ +.kt-form-label { display: block; margin-bottom: 0.75rem; font-size: var(--kt-small); font-weight: 700; text-transform: uppercase; color: var(--kt-fg); letter-spacing: 0.05em; } +.kt-form-input { + display: block; + width: 100%; + padding: 1rem; + border: var(--kt-border-width) solid var(--kt-border); + background: var(--kt-bg); + color: var(--kt-fg); font-family: var(--kt-font); - font-size: 0.75rem; - color: var(--kt-muted-fg); - margin-top: 0.5rem; - line-height: 1.4; + transition: border-color 0.2s; + box-sizing: border-box; /* 必须包含 padding */ } +.kt-form-input:focus { border-color: var(--kt-accent); outline: none; } +.kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1rem; } + +/* 7. 其他组件 */ +.kt-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 0.5rem; } +.kt-search-group { position: relative; flex: 1; display: flex; align-items: center; gap: 1rem; } +.kt-search-input { width: 100%; max-width: 300px; padding: 0.75rem 1rem 0.75rem 2.5rem; border: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); color: var(--kt-fg); } +.kt-search-icon { position: absolute; left: 1rem; color: var(--kt-muted-fg); } +.kt-table-wrapper { flex: 1; overflow-y: auto; border: var(--kt-border-width) solid var(--kt-border); } +.kt-role-tag { width: 80px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--kt-border); font-weight: 700; font-size: 0.7rem; text-transform: uppercase; } +.kt-role--admin { background: var(--kt-accent); color: #000; border-color: var(--kt-accent); } +.kt-role--vip { color: var(--kt-accent); border-color: var(--kt-accent); } +.kt-pagination { display: flex; justify-content: center; align-items: center; gap: 1.5rem; padding-top: 1rem; } +.kt-page-btn { width: 36px; height: 36px; border: 1px solid var(--kt-border); background: none; color: var(--kt-fg); cursor: pointer; } +.kt-form-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; min-height: 44px; } +.kt-mode-toggle { display: flex; gap: 0.5rem; background: var(--kt-muted); padding: 0.3rem; } +.kt-mode-toggle span { padding: 0.3rem 0.8rem; cursor: pointer; font-size: 0.7rem; font-weight: 700; } +.kt-mode-toggle span.active { background: var(--kt-bg); color: var(--kt-fg); } +.kt-strength-selector { display: flex; border: 1px solid var(--kt-border); } +.kt-strength-item { flex: 1; text-align: center; padding: 0.6rem; cursor: pointer; font-weight: 700; } +.kt-strength-item.active { background: var(--kt-accent); color: #000; } +.kt-sub-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 3000; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(4px); } +.kt-sub-modal-card { width: 95%; max-width: 500px; height: auto !important; } @media (max-width: 900px) { .kt-modal-card--fullscreen { height: auto; } .kt-list-header { display: none; } .kt-list-row { grid-template-columns: 1fr; padding: 1.5rem; gap: 0.5rem; } + .kt-col--right { justify-content: flex-start; } .kt-actions { justify-content: flex-start; margin-top: 1rem; } } -- 2.34.1 From c60c66cc13afa62b5216ff01530d3a33dcb9f4ee Mon Sep 17 00:00:00 2001 From: yyx <20328610@qq.com> Date: Wed, 7 Jan 2026 14:19:26 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98=E5=8A=9F?= =?UTF-8?q?=E8=83=BDbug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/api/image.js | 15 +- src/frontend/src/api/task.js | 13 - .../src/components/GridDistortion.vue | 277 ++++++------ .../src/components/ImagePreviewModal.vue | 394 +++++++----------- .../src/components/KtSuccessModal.vue | 12 +- src/frontend/src/components/TaskSideBar.vue | 83 ++-- .../src/components/ThreeDTrajectoryModal.vue | 220 ++-------- src/frontend/src/stores/taskStore.js | 36 +- src/frontend/src/utils/constants.js | 4 +- src/frontend/src/utils/multipartParser.js | 39 +- src/frontend/src/views/LoginView.vue | 60 ++- src/frontend/src/views/MainFlow.vue | 29 +- .../src/views/Page1/subpages/QuickMode.vue | 90 ++-- .../views/Page1/subpages/UniversalMode.vue | 126 +++--- .../views/Page3/subpages/SubpageContainer.vue | 78 ++-- src/frontend/src/views/Page4/Page4.vue | 281 +++++-------- .../views/Page5/subpages/SubpageContainer.vue | 196 +++++---- 17 files changed, 912 insertions(+), 1041 deletions(-) diff --git a/src/frontend/src/api/image.js b/src/frontend/src/api/image.js index d08f7c3..9b1a6af 100644 --- a/src/frontend/src/api/image.js +++ b/src/frontend/src/api/image.js @@ -2,23 +2,18 @@ import request from '@/utils/request' import { parseMultipartMixed } from '@/utils/multipartParser' /** - * 获取任务的图片预览数据 (二进制流版本) + * 获取任务的所有图片数据 (二进制流版本) + * 用于预览和下载 * API: GET /api/image/binary/task/ */ -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 }) diff --git a/src/frontend/src/api/task.js b/src/frontend/src/api/task.js index 0c4e698..3144f98 100644 --- a/src/frontend/src/api/task.js +++ b/src/frontend/src/api/task.js @@ -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 - }) -} /** * 获取任务日志 (新增) diff --git a/src/frontend/src/components/GridDistortion.vue b/src/frontend/src/components/GridDistortion.vue index 4cbc11a..bff5a56 100644 --- a/src/frontend/src/components/GridDistortion.vue +++ b/src/frontend/src/components/GridDistortion.vue @@ -1,5 +1,6 @@ \ No newline at end of file diff --git a/src/frontend/src/components/ThreeDTrajectoryModal.vue b/src/frontend/src/components/ThreeDTrajectoryModal.vue index 8c06e52..a3e1593 100644 --- a/src/frontend/src/components/ThreeDTrajectoryModal.vue +++ b/src/frontend/src/components/ThreeDTrajectoryModal.vue @@ -18,7 +18,7 @@
原图 (Ref) - 训练 (Train) + 防护 (Perturb)
@@ -330,6 +348,9 @@ const userStore = useUserStore() const flowMode = ref('login') const loading = ref(false) const showPassword = ref(false) +const showRegisterPwd = ref(false) +const showResetPwd = ref(false) +const showResetConfirm = ref(false) const isDark = ref(true) const errorMessage = ref('') @@ -418,7 +439,17 @@ const validateEmail = (email) => { * 至少8位,包含大小写、数字、特殊符号 */ const validatePasswordStrength = (pwd) => { - const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&._-])[A-Za-z\d@$!%*?&._-]{8,}$/; + // 解析: + // ^ 匹配开头 + // (?=.*[a-z]) 必须包含至少一个小写字母 + // (?=.*[A-Z]) 必须包含至少一个大写字母 + // (?=.*\d) 必须包含至少一个数字 + // (?=.*[^A-Za-z0-9]) 必须包含至少一个特殊字符 (逻辑:只要不是字母或数字,就算特殊字符) + // \S{8,} 主体部分:由8个或更多的“非空白字符”组成 + // $ 匹配结尾 + + const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{8,}$/; + return regex.test(pwd); } @@ -750,7 +781,8 @@ const handleForgotPassword = async () => { .kt-input-icon { position: absolute; - top: 50%; + /* 修复位置:改为固定像素值,以应对下方 Hint 文字撑开容器 */ + top: 26px; /* 52px 的一半 */ left: 15px; transform: translateY(-50%); font-size: 1.1rem; @@ -760,6 +792,8 @@ const handleForgotPassword = async () => { } .kt-register-card .kt-input-icon { + /* 修复位置:注册卡片输入框高 48px */ + top: 24px; left: 12px; } @@ -769,7 +803,8 @@ const handleForgotPassword = async () => { .kt-label { position: absolute; - top: 50%; + /* 修复位置:改为固定像素值 */ + top: 26px; left: 45px; transform: translateY(-50%); font-family: var(--kt-font); @@ -782,12 +817,15 @@ const handleForgotPassword = async () => { } .kt-register-card .kt-label { + /* 修复位置:注册卡片输入框高 48px */ + top: 24px; left: 40px; } +/* 浮动状态的 label 定位 */ .kt-input:focus ~ .kt-label, .kt-input:not(:placeholder-shown) ~ .kt-label { - top: -10px; + top: -10px; /* 浮动到顶部外侧,不受 Box 高度影响 */ left: 12px; transform: translateY(0); font-size: 0.85rem; @@ -803,7 +841,8 @@ const handleForgotPassword = async () => { .kt-toggle-password { position: absolute; - top: 50%; + /* 修复位置:改为固定像素值 */ + top: 26px; right: 15px; transform: translateY(-50%); font-size: 1.1rem; @@ -813,6 +852,11 @@ const handleForgotPassword = async () => { z-index: 2; } +.kt-register-card .kt-toggle-password { + /* 注册卡片高度略小 */ + top: 24px; +} + .kt-input-hint { font-family: var(--kt-font); font-size: 0.75rem; diff --git a/src/frontend/src/views/MainFlow.vue b/src/frontend/src/views/MainFlow.vue index 421c698..c88db26 100644 --- a/src/frontend/src/views/MainFlow.vue +++ b/src/frontend/src/views/MainFlow.vue @@ -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 默认是 false,NavBar 的 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" /> -
已选择 {{ formData.files.length }} 张图片 +

+ {{ formData.files[0].name }} 等... +

@@ -139,7 +142,7 @@ import { DATA_TYPE_MAP, ALGO_OPTIONS_Data, ALGO_MAP } from '@/utils/constants' import { submitPerturbationTask, getTaskStatus } from '@/api/task' import { useTaskStore } from '@/stores/taskStore' import { useUserStore } from '@/stores/userStore' -import { getUserConfig } from '@/api/user' // 引入获取配置接口 +import { getUserConfig } from '@/api/user' import modal from '@/utils/modal' import KtSelect from '@/components/KtSelect.vue' @@ -151,15 +154,15 @@ const isSubmitting = ref(false) const isCustomMode = ref(false) let specificPollTimer = null const MAX_UPLOAD_COUNT = 5 -const MAX_TOTAL_SIZE_MB = 15//限制前端上传最大 15MB +const MAX_TOTAL_SIZE_MB = 15 const formData = ref({ taskName: '', algorithm: '', strength: 12.0, style: 'face', files: [] }) const algorithmSettings = computed(() => { const algoId = formData.value.algorithm - if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.CAAT_PRO].includes(algoId)) { + if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.PID, ALGO_MAP.QUICK].includes(algoId)) { return { min: 10, max: 16, step: 1, presets: { low: 10, mid: 12, high: 16 }, default: 12 } - } else if ([ALGO_MAP.GLAZE, ALGO_MAP.PID, ALGO_MAP.STYLE_PROTECTION, ALGO_MAP.QUICK].includes(algoId)) { + } else if ([ALGO_MAP.GLAZE, ALGO_MAP.STYLE_PROTECTION, ALGO_MAP.CAAT, ALGO_MAP.CAAT_PRO].includes(algoId)) { return { min: 0.01, max: 0.2, step: 0.01, presets: { low: 0.03, mid: 0.05, high: 0.1 }, default: 0.05 } } return { min: 10, max: 16, step: 1, presets: { low: 10, mid: 12, high: 16 }, default: 12 } @@ -169,10 +172,7 @@ const presetLow = computed(() => algorithmSettings.value.presets.low) const presetMid = computed(() => algorithmSettings.value.presets.mid) const presetHigh = computed(() => algorithmSettings.value.presets.high) -// 监听算法切换,重置强度和模式 watch(() => formData.value.algorithm, (newVal, oldVal) => { - // 只有当用户在页面上手动切换算法时才重置 - // 初始化加载时的变化由 loadUserDefaults 控制 if (oldVal !== undefined && newVal !== oldVal) { formData.value.strength = algorithmSettings.value.default isCustomMode.value = false @@ -203,24 +203,52 @@ const setDataType = (type) => { const selectAlgo = (algo) => { formData.value.algorithm = algo.id; isDropdownOpen.value = false } const triggerFileUpload = () => fileInput.value.click() +// 优化文件处理:追加模式 + 容量检查 const handleFileChange = (event) => { - const files = Array.from(event.target.files) - if (files.length > MAX_UPLOAD_COUNT) { - modal.warning(`单次最多允许上传 ${MAX_UPLOAD_COUNT} 张图片,已自动截取前 ${MAX_UPLOAD_COUNT} 张。`) - formData.value.files = files.slice(0, MAX_UPLOAD_COUNT) - } else if (files.length > 0) { - formData.value.files = files + const newFiles = Array.from(event.target.files) + if (newFiles.length === 0) return + + // 1. 去重(基于文件名和大小) + const uniqueNewFiles = newFiles.filter(nf => + !formData.value.files.some(ef => ef.name === nf.name && ef.size === nf.size) + ) + + if (uniqueNewFiles.length === 0) { + event.target.value = '' // 清空 input 以便下次选择 + return modal.info('所选文件已存在') } - //大小限制 - let totalSize = 0 - formData.value.files.forEach(f => totalSize += f.size) + + // 2. 数量检查 + const currentCount = formData.value.files.length + const availableSlots = MAX_UPLOAD_COUNT - currentCount - if (totalSize > MAX_TOTAL_SIZE_MB * 1024 * 1024) { - modal.warning(`所选图片总大小超过 ${MAX_TOTAL_SIZE_MB}MB,请压缩或减少图片数量。`) - clearFiles() - return + if (availableSlots <= 0) { + event.target.value = '' + return modal.warning(`已达到最大上传数量 (${MAX_UPLOAD_COUNT})`) + } + + let filesToAdd = uniqueNewFiles + if (uniqueNewFiles.length > availableSlots) { + filesToAdd = uniqueNewFiles.slice(0, availableSlots) + modal.warning(`最多只能再添加 ${availableSlots} 张图片,已自动截取。`) + } + + // 3. 大小检查 + let currentTotalSize = formData.value.files.reduce((acc, f) => acc + f.size, 0) + const validFiles = [] + + for (const file of filesToAdd) { + if (currentTotalSize + file.size > MAX_TOTAL_SIZE_MB * 1024 * 1024) { + modal.warning(`文件总大小超过 ${MAX_TOTAL_SIZE_MB}MB,部分文件未添加。`) + break + } + currentTotalSize += file.size + validFiles.push(file) } - event.target.value = '' + + // 4. 追加文件 + formData.value.files = [...formData.value.files, ...validFiles] + event.target.value = '' // 清空 input } const clearFiles = () => { @@ -278,41 +306,28 @@ const startSpecificPolling = (taskId) => { }, 3000) } -// 加载默认配置 const loadUserDefaults = async () => { try { const res = await getUserConfig() if (res?.config) { const cfg = res.config - - // 1. 设置类型 if (cfg.data_type_id) { formData.value.style = (cfg.data_type_id === DATA_TYPE_MAP.ART) ? 'art' : 'face' } - - // 2. 设置算法 if (cfg.perturbation_configs_id) { const algo = ALGO_OPTIONS_Data.find(a => a.id === cfg.perturbation_configs_id) if (algo && algo.type === formData.value.style) { formData.value.algorithm = cfg.perturbation_configs_id } } - - // 3. 设置强度并判断模式 (预设 vs 自定义) if (cfg.perturbation_intensity !== null && cfg.perturbation_intensity !== undefined) { - // 等待 algorithmSettings 根据新算法更新 await nextTick() - formData.value.strength = cfg.perturbation_intensity - - //判断是否为预设值 const p = algorithmSettings.value.presets - if (cfg.perturbation_intensity === p.low || - cfg.perturbation_intensity === p.mid || - cfg.perturbation_intensity === p.high) { + if (cfg.perturbation_intensity === p.low || cfg.perturbation_intensity === p.mid || cfg.perturbation_intensity === p.high) { isCustomMode.value = false } else { - isCustomMode.value = true // 如果不是预设值,切换到自定义滑块 + isCustomMode.value = true } } } @@ -330,7 +345,7 @@ onUnmounted(() => { if (specificPollTimer) clearInterval(specificPollTimer) }) \ No newline at end of file diff --git a/src/frontend/src/views/Page3/subpages/SubpageContainer.vue b/src/frontend/src/views/Page3/subpages/SubpageContainer.vue index 711b0fc..e61fe14 100644 --- a/src/frontend/src/views/Page3/subpages/SubpageContainer.vue +++ b/src/frontend/src/views/Page3/subpages/SubpageContainer.vue @@ -67,10 +67,7 @@
-
- 任务首图预览: - -
+
@@ -124,7 +121,11 @@
- + +

+ 效果极度依赖提示词水平,仅作为扩展内容 +

+