chore(清理): 删除死代码与过期文件——7个无引用DTO+图片工作台整棵子树(16文件)+冗余副本+一次性脚本+可再生审计快照
每项删除前均做过全仓库引用核对(含 tests/、两个前端、Python 侧):
- Java 7 个 DTO:BrandTaskRunRequest、LegacyBrandTaskCreateVo、Ziniao{SwitchShopRequest,CurrentShopVo,LoginUrlVo,SwitchShopVo}、
AppearancePatentParsedGroupManifestDto —— 全仓库仅命中声明行本身
- frontend-vue 整棵 src/pages/image/(16 文件):9a487ae9 起首页一级入口已删("图片已并入视频页"),
该子树完全自闭环;同步移除 /image 路由与 ImageVideoPage 的图片入口
- frontend-vue 两个孤儿组件 AiModuleSwitch/AiWorkflowShell:无引用,且位于 src/shared/components
而非 unplugin-vue-components 默认扫描的 src/components,不存在自动注册
- backend/tool/devices.py + .device_id:app_client/tool/devices.py 的冗余副本(活的是 app_client 那份),
app.py 只注册 version_bp,无任何引用
- admin-frontend-vue 的一次性 Playwright 调试脚本 rescan.tmp.mjs / diag-role-price.mjs
- backend-java/scripts/*_report.json ×4:可由 n1_scan.py 等重跑的审计快照
- frontend-vue/DESIGN.md:Last refreshed 2026-06-09,仍是 MPA 时代内容
- backend/requirement.txt:pip freeze 63 条裁剪为实际 import 的 5 条;run.sh 硬依赖该文件存在,故保留文件本身
验证:mvn compile 通过、vue-tsc --noEmit 通过、残留引用 grep 零命中。
注:仓库既有的 4 个失败测试类(FlywayMigration*DocTest、MigrationInventoryTest、HttpClientTimeoutEffectiveTest)
经 stash 对照实验确认与本次改动无关(相同 Failures/Errors 计数)。
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 200)) })
|
||||
const userReqs = []
|
||||
page.on('request', (r) => { if (r.url().includes('/api/admin/users')) userReqs.push(r.url()) })
|
||||
await page.goto('http://localhost:5174/admin-vue/', { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(800)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForSelector('.admin-shell', { timeout: 25000 }).catch(() => {})
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
await page.goto('http://localhost:5174/admin-vue/account/users', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2200)
|
||||
const selects = page.locator('.users-filter-row .el-select')
|
||||
console.log('筛选行 el-select 数量:', await selects.count())
|
||||
await selects.nth(1).click()
|
||||
await page.waitForTimeout(700)
|
||||
const items = page.locator('.el-select-dropdown__item:visible')
|
||||
console.log('选项:', JSON.stringify(await items.allTextContents()))
|
||||
await items.filter({ hasText: /^管理员$/ }).first().click()
|
||||
await page.waitForTimeout(1200)
|
||||
console.log('change 后请求:', JSON.stringify(userReqs.slice(-1)))
|
||||
await page.locator('.users-filter-row button.btn').first().click()
|
||||
await page.waitForTimeout(2500)
|
||||
console.log('点查询后请求:', JSON.stringify(userReqs.slice(-1)))
|
||||
console.log('选中显示:', (await selects.nth(1).textContent())?.trim())
|
||||
console.log('最近 users 请求:', JSON.stringify(userReqs.slice(-2)))
|
||||
const roles = await page.locator('.table-scroll tbody tr td').nth(2).textContent().catch(() => '?')
|
||||
console.log('第一行角色:', roles?.trim())
|
||||
await page.goto('http://localhost:5174/admin-vue/asin-center/skip-price', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2000)
|
||||
await page.locator('.price-range-row input').first().fill('1.15')
|
||||
await page.locator('.skip-filter-row button.btn').first().click()
|
||||
await page.waitForTimeout(2500)
|
||||
console.log('errors:', JSON.stringify(errors))
|
||||
await browser.close()
|
||||
@@ -1,21 +0,0 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
const BASE = 'http://127.0.0.1:5174'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ locale: 'zh-CN' })
|
||||
await page.goto(`${BASE}/admin-vue/login`)
|
||||
await page.waitForSelector('#loginUsername', { timeout: 20000 })
|
||||
await page.fill('#loginUsername', 'admin')
|
||||
await page.fill('#loginPassword', 'admin123')
|
||||
await page.click('button.btn-login')
|
||||
await page.waitForTimeout(2500)
|
||||
const ctx = page.context()
|
||||
console.log('FORCE_SCAN_START')
|
||||
const resp = await ctx.request.get(`${BASE}/api/admin/shop-data-crawl/duplicate-check-overview?force=1`, { timeout: 300000 })
|
||||
console.log('HTTP', resp.status())
|
||||
const body = await resp.json()
|
||||
const data = body?.data || {}
|
||||
console.log('pending=', data.pending)
|
||||
console.log('scanned_at=', data.scanned_at)
|
||||
console.log('shops=', (data.shops || []).map(s => s.shop_name + '(' + s.group_name + ',' + s.asin_count + ')').join(' | '))
|
||||
console.log('summary=', JSON.stringify(data.summary))
|
||||
await browser.close()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
||||
{
|
||||
"scanned_files": 640,
|
||||
"modules": [
|
||||
"admin",
|
||||
"appearancepatent",
|
||||
"auth",
|
||||
"brand",
|
||||
"collectdata",
|
||||
"convert",
|
||||
"debug",
|
||||
"dedupe",
|
||||
"deletebrand",
|
||||
"digitalhuman",
|
||||
"file",
|
||||
"filetemplate",
|
||||
"imagehistory",
|
||||
"imagevideo",
|
||||
"invalidasin",
|
||||
"patroldelete",
|
||||
"permission",
|
||||
"pricetrack",
|
||||
"productcategory",
|
||||
"productrisk",
|
||||
"publish",
|
||||
"queryasin",
|
||||
"shopdatacrawl",
|
||||
"shopkey",
|
||||
"shopmatch",
|
||||
"similarasin",
|
||||
"split",
|
||||
"task",
|
||||
"withdraw",
|
||||
"ziniao"
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"file": "deletebrand\\service\\DeleteBrandStaleTaskService.java",
|
||||
"line": 209,
|
||||
"statement": "FileTaskEntity refreshed = fileTaskMapper.selectById(task.getId());",
|
||||
"loop_type": "for",
|
||||
"impact": "HIGH",
|
||||
"module": "deletebrand"
|
||||
},
|
||||
{
|
||||
"file": "pricetrack\\service\\PriceTrackTaskService.java",
|
||||
"line": 354,
|
||||
"statement": "FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId());",
|
||||
"loop_type": "for",
|
||||
"impact": "HIGH",
|
||||
"module": "pricetrack"
|
||||
},
|
||||
{
|
||||
"file": "productrisk\\service\\ProductRiskTaskService.java",
|
||||
"line": 339,
|
||||
"statement": "FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId());",
|
||||
"loop_type": "for",
|
||||
"impact": "HIGH",
|
||||
"module": "productrisk"
|
||||
},
|
||||
{
|
||||
"file": "task\\service\\TaskFileJobService.java",
|
||||
"line": 282,
|
||||
"statement": "publishDispatchEvent(taskFileJobMapper.selectById(job.getId()));",
|
||||
"loop_type": "for",
|
||||
"impact": "HIGH",
|
||||
"module": "task"
|
||||
},
|
||||
{
|
||||
"file": "task\\service\\TaskFileJobService.java",
|
||||
"line": 303,
|
||||
"statement": "TaskFileJobEntity exhausted = taskFileJobMapper.selectById(job.getId());",
|
||||
"loop_type": "for",
|
||||
"impact": "HIGH",
|
||||
"module": "task"
|
||||
},
|
||||
{
|
||||
"file": "task\\service\\TaskFileJobService.java",
|
||||
"line": 315,
|
||||
"statement": "publishDispatchEvent(taskFileJobMapper.selectById(job.getId()));",
|
||||
"loop_type": "for",
|
||||
"impact": "HIGH",
|
||||
"module": "task"
|
||||
},
|
||||
{
|
||||
"file": "appearancepatent\\service\\AppearancePatentTaskService.java",
|
||||
"line": 1094,
|
||||
"statement": "TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "appearancepatent"
|
||||
},
|
||||
{
|
||||
"file": "appearancepatent\\service\\AppearancePatentTaskService.java",
|
||||
"line": 2112,
|
||||
"statement": "List<TaskChunkEntity> page = taskChunkMapper.selectList(query);",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "appearancepatent"
|
||||
},
|
||||
{
|
||||
"file": "dedupe\\service\\DedupeTotalDataService.java",
|
||||
"line": 279,
|
||||
"statement": "List<DedupeTotalDataEntity> rows = dedupeTotalDataMapper.selectList(",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "dedupe"
|
||||
},
|
||||
{
|
||||
"file": "deletebrand\\service\\DeleteBrandRunService.java",
|
||||
"line": 1234,
|
||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "deletebrand"
|
||||
},
|
||||
{
|
||||
"file": "deletebrand\\service\\DeleteBrandRunService.java",
|
||||
"line": 1252,
|
||||
"statement": "rows.addAll(fileResultMapper.selectList(historyResultQuery()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "deletebrand"
|
||||
},
|
||||
{
|
||||
"file": "deletebrand\\service\\DeleteBrandRunService.java",
|
||||
"line": 1268,
|
||||
"statement": "List<FileResultEntity> rows = fileResultMapper.selectList(historyResultQuery()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "deletebrand"
|
||||
},
|
||||
{
|
||||
"file": "patroldelete\\service\\PatrolDeleteTaskService.java",
|
||||
"line": 638,
|
||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "patroldelete"
|
||||
},
|
||||
{
|
||||
"file": "permission\\service\\PermissionMenuService.java",
|
||||
"line": 676,
|
||||
"statement": "cursor = permissionMenuMapper.selectById(ancestorId);",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "permission"
|
||||
},
|
||||
{
|
||||
"file": "pricetrack\\service\\PriceTrackTaskService.java",
|
||||
"line": 153,
|
||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "pricetrack"
|
||||
},
|
||||
{
|
||||
"file": "pricetrack\\service\\PriceTrackTaskService.java",
|
||||
"line": 418,
|
||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "pricetrack"
|
||||
},
|
||||
{
|
||||
"file": "productcategory\\service\\ProductCategoryService.java",
|
||||
"line": 465,
|
||||
"statement": "ProductCategoryEntity parent = productCategoryMapper.selectById(cursor.getParentId());",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "productcategory"
|
||||
},
|
||||
{
|
||||
"file": "productcategory\\service\\ProductCategoryService.java",
|
||||
"line": 481,
|
||||
"statement": "cursor = cursor.getParentId() == null ? null : productCategoryMapper.selectById(cursor.getParentId());",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "productcategory"
|
||||
},
|
||||
{
|
||||
"file": "productcategory\\service\\ProductCategoryService.java",
|
||||
"line": 518,
|
||||
"statement": "ProductCategoryEntity row = productCategoryMapper.selectById(cursor);",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "productcategory"
|
||||
},
|
||||
{
|
||||
"file": "productrisk\\service\\ProductRiskTaskService.java",
|
||||
"line": 144,
|
||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "productrisk"
|
||||
},
|
||||
{
|
||||
"file": "queryasin\\service\\QueryAsinTaskService.java",
|
||||
"line": 587,
|
||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "queryasin"
|
||||
},
|
||||
{
|
||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
||||
"line": 1085,
|
||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "shopdatacrawl"
|
||||
},
|
||||
{
|
||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
||||
"line": 1940,
|
||||
"statement": "FileResultEntity result = fileResultMapper.selectById(member.getResultId());",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "shopdatacrawl"
|
||||
},
|
||||
{
|
||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
||||
"line": 2497,
|
||||
"statement": "FileResultEntity result = fileResultMapper.selectById(member.getResultId());",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "shopdatacrawl"
|
||||
},
|
||||
{
|
||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
||||
"line": 2575,
|
||||
"statement": "FileTaskEntity task = fileTaskMapper.selectById(member.getTaskId());",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "shopdatacrawl"
|
||||
},
|
||||
{
|
||||
"file": "shopmatch\\service\\ShopMatchTaskService.java",
|
||||
"line": 179,
|
||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "shopmatch"
|
||||
},
|
||||
{
|
||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
||||
"line": 1499,
|
||||
"statement": "page = mapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TaskChunkEntity>()",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "similarasin"
|
||||
},
|
||||
{
|
||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
||||
"line": 1646,
|
||||
"statement": "TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "similarasin"
|
||||
},
|
||||
{
|
||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
||||
"line": 2346,
|
||||
"statement": "FileTaskEntity task = fileTaskMapper.selectById(taskId);",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "similarasin"
|
||||
},
|
||||
{
|
||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
||||
"line": 2356,
|
||||
"statement": "List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "similarasin"
|
||||
},
|
||||
{
|
||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
||||
"line": 2379,
|
||||
"statement": "TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "similarasin"
|
||||
},
|
||||
{
|
||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
||||
"line": 4364,
|
||||
"statement": "List<TaskChunkEntity> page = taskChunkMapper.selectList(query);",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "similarasin"
|
||||
},
|
||||
{
|
||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
||||
"line": 4437,
|
||||
"statement": "List<TaskScopeStateEntity> page = taskScopeStateMapper.selectList(query);",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "similarasin"
|
||||
},
|
||||
{
|
||||
"file": "task\\service\\ModuleHistoryCleanupService.java",
|
||||
"line": 131,
|
||||
"statement": "List<FileTaskEntity> page = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
||||
"loop_type": "while",
|
||||
"impact": "LOW",
|
||||
"module": "task"
|
||||
},
|
||||
{
|
||||
"file": "task\\service\\TaskFileJobService.java",
|
||||
"line": 153,
|
||||
"statement": "TaskFileJobEntity claim = taskFileJobMapper.selectById(candidate.getId());",
|
||||
"loop_type": "for",
|
||||
"impact": "LOW",
|
||||
"module": "task"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-24
@@ -1,24 +0,0 @@
|
||||
package com.nanri.aiimage.modules.appearancepatent.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class AppearancePatentParsedGroupManifestDto {
|
||||
private String aiPrompt;
|
||||
private String apiKey;
|
||||
private String patentToken;
|
||||
private Integer pageSize;
|
||||
private Integer totalGroups;
|
||||
private Integer totalRows;
|
||||
private List<PageRef> pages = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class PageRef {
|
||||
private Integer page;
|
||||
private Integer groupCount;
|
||||
private String ref;
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package com.nanri.aiimage.modules.brand.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "品牌任务兼容立即执行请求")
|
||||
public class BrandTaskRunRequest {
|
||||
|
||||
@Schema(description = "旧页面传入的本地路径列表,仅兼容旧接口")
|
||||
private List<String> paths;
|
||||
|
||||
@Schema(description = "品牌匹配方式: Terms/Simple")
|
||||
private String strategy = "Terms";
|
||||
|
||||
@Schema(description = "任务类型: 1=立即执行")
|
||||
private Integer taskType = 1;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package com.nanri.aiimage.modules.brand.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LegacyBrandTaskCreateVo {
|
||||
|
||||
private Long task_id;
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package com.nanri.aiimage.modules.ziniao.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ZiniaoSwitchShopRequest {
|
||||
@NotBlank(message = "sessionId 不能为空")
|
||||
private String sessionId;
|
||||
|
||||
@NotNull(message = "userId 不能为空")
|
||||
private Long userId;
|
||||
|
||||
@NotBlank(message = "shopId 不能为空")
|
||||
private String shopId;
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package com.nanri.aiimage.modules.ziniao.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "当前紫鸟店铺")
|
||||
public class ZiniaoCurrentShopVo {
|
||||
@Schema(description = "店铺 ID")
|
||||
private String shopId;
|
||||
|
||||
@Schema(description = "店铺名称")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "平台")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "选中时间戳(毫秒)")
|
||||
private Long selectedAt;
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package com.nanri.aiimage.modules.ziniao.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "紫鸟登录地址")
|
||||
public class ZiniaoLoginUrlVo {
|
||||
@Schema(description = "登录链接")
|
||||
private String loginUrl;
|
||||
|
||||
@Schema(description = "登录 state")
|
||||
private String state;
|
||||
|
||||
@Schema(description = "state 过期时间戳(毫秒)")
|
||||
private Long expireAt;
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package com.nanri.aiimage.modules.ziniao.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "切换紫鸟店铺结果")
|
||||
public class ZiniaoSwitchShopVo {
|
||||
@Schema(description = "是否切换成功")
|
||||
private Boolean success;
|
||||
|
||||
@Schema(description = "当前店铺 ID")
|
||||
private String currentShopId;
|
||||
|
||||
@Schema(description = "当前店铺名称")
|
||||
private String currentShopName;
|
||||
|
||||
@Schema(description = "紫鸟启动链接")
|
||||
private String openStoreUrl;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
8cecee3bc02a178bf372ca1c3d02fc5cae3c0a51c8346f6e6a710988599c08be
|
||||
+11
-59
@@ -1,63 +1,15 @@
|
||||
alibabacloud-oss-v2==1.2.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.12.1
|
||||
Authlib==1.6.8
|
||||
blinker==1.9.0
|
||||
boto3==1.43.65
|
||||
botocore==1.43.65
|
||||
bottle==0.13.4
|
||||
certifi==2026.1.4
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.4
|
||||
click==8.1.8
|
||||
clr_loader==0.2.10
|
||||
colorama==0.4.6
|
||||
crcmod-plus==2.3.1
|
||||
cryptography==41.0.0
|
||||
distro==1.9.0
|
||||
et_xmlfile==2.0.0
|
||||
exceptiongroup==1.3.1
|
||||
# Flask 版本公开 API 服务依赖(2026-09-11 由 pip freeze 全量清单裁剪)
|
||||
#
|
||||
# 原文件是 63 条的 pip freeze 快照,含 Nuitka/PyQt5/pywebview/pandas/numpy/bottle/websockets
|
||||
# 等桌面端与旧后台打包遗留,本服务实际一个都不用。此处只保留真实 import 到的包:
|
||||
# app.py / blueprints/version.py -> flask, flask-cors
|
||||
# utils/db.py -> pymysql, werkzeug(security.generate_password_hash)
|
||||
# config.py -> python-dotenv(可选,import 失败时走环境变量)
|
||||
#
|
||||
# 注意:run.sh 会检查本文件是否存在(缺失直接报错退出),并在关键依赖缺失时
|
||||
# 执行 `pip install -r requirement.txt`,因此不要删除本文件。
|
||||
Flask==3.1.3
|
||||
flask-cors==6.0.2
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
importlib_metadata==8.7.1
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.3
|
||||
Nuitka==2.8.6
|
||||
numpy==1.25.2
|
||||
openpyxl==3.1.5
|
||||
ordered-set==4.1.0
|
||||
pandas==2.3.3
|
||||
pillow==11.3.0
|
||||
pip==26.0.1
|
||||
proxy_tools==0.1.0
|
||||
psutil==7.2.2
|
||||
pycparser==2.23
|
||||
pycryptodome==3.23.0
|
||||
pydantic==2.12.5
|
||||
pydantic_core==2.41.5
|
||||
PyMySQL==1.1.2
|
||||
PyQt5==5.15.11
|
||||
PyQt5-Qt5==5.15.2
|
||||
PyQt5_sip==12.17.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.0.1
|
||||
pythonnet==3.0.5
|
||||
pytz==2026.1.post1
|
||||
pywebview==6.1
|
||||
requests==2.32.5
|
||||
setuptools==80.9.0
|
||||
six==1.17.0
|
||||
typing_extensions==4.15.0
|
||||
typing-inspection==0.4.2
|
||||
tzdata==2025.3
|
||||
urllib3==2.6.3
|
||||
websockets==14.2
|
||||
Werkzeug==3.1.6
|
||||
wheel==0.45.1
|
||||
zipp==3.23.0
|
||||
zstandard==0.25.0
|
||||
python-dotenv==1.0.1
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import hashlib
|
||||
import platform
|
||||
import subprocess
|
||||
import uuid
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DeviceIDGenerator:
|
||||
"""
|
||||
Windows设备唯一ID生成器
|
||||
通过收集多个硬件特征来生成稳定的设备唯一标识符
|
||||
"""
|
||||
|
||||
def __init__(self, use_cache: bool = True, cache_file: str = ".device_id"):
|
||||
"""
|
||||
初始化设备ID生成器
|
||||
|
||||
Args:
|
||||
use_cache: 是否使用本地缓存
|
||||
cache_file: 缓存文件名
|
||||
"""
|
||||
self.use_cache = use_cache
|
||||
self.cache_file = cache_file
|
||||
|
||||
def _run_wmic_command(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
执行WMIC命令并返回结果
|
||||
|
||||
Args:
|
||||
command: WMIC命令
|
||||
|
||||
Returns:
|
||||
命令执行结果,失败则返回None
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_motherboard_serial(self) -> Optional[str]:
|
||||
"""获取主板序列号"""
|
||||
return self._run_wmic_command("wmic baseboard get serialnumber /value")
|
||||
|
||||
def _get_cpu_id(self) -> Optional[str]:
|
||||
"""获取CPU ID"""
|
||||
return self._run_wmic_command("wmic cpu get processorid /value")
|
||||
|
||||
def _get_bios_serial(self) -> Optional[str]:
|
||||
"""获取BIOS序列号"""
|
||||
return self._run_wmic_command("wmic bios get serialnumber /value")
|
||||
|
||||
def _get_disk_serial(self) -> Optional[str]:
|
||||
"""获取系统盘序列号"""
|
||||
return self._run_wmic_command("wmic diskdrive get serialnumber /value")
|
||||
|
||||
def _get_machine_guid(self) -> Optional[str]:
|
||||
"""获取Windows机器GUID"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'MachineGuid' in line:
|
||||
return line.split()[-1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _extract_value(self, wmic_output: str) -> str:
|
||||
"""从WMIC输出中提取实际值"""
|
||||
if not wmic_output:
|
||||
return ""
|
||||
|
||||
lines = wmic_output.split('\n')
|
||||
for line in lines:
|
||||
if '=' in line and not line.strip().endswith('='):
|
||||
return line.split('=', 1)[1].strip()
|
||||
return ""
|
||||
|
||||
def _collect_hardware_info(self) -> dict:
|
||||
"""
|
||||
收集硬件信息
|
||||
|
||||
Returns:
|
||||
包含各种硬件信息的字典
|
||||
"""
|
||||
hardware_info = {}
|
||||
|
||||
# 主板序列号
|
||||
motherboard = self._get_motherboard_serial()
|
||||
hardware_info['motherboard'] = self._extract_value(motherboard) if motherboard else ""
|
||||
|
||||
# CPU ID
|
||||
cpu_id = self._get_cpu_id()
|
||||
hardware_info['cpu'] = self._extract_value(cpu_id) if cpu_id else ""
|
||||
|
||||
# BIOS序列号
|
||||
bios = self._get_bios_serial()
|
||||
hardware_info['bios'] = self._extract_value(bios) if bios else ""
|
||||
|
||||
# 硬盘序列号
|
||||
disk = self._get_disk_serial()
|
||||
hardware_info['disk'] = self._extract_value(disk) if disk else ""
|
||||
|
||||
# Windows机器GUID
|
||||
machine_guid = self._get_machine_guid()
|
||||
hardware_info['machine_guid'] = machine_guid if machine_guid else ""
|
||||
|
||||
# 计算机名称
|
||||
hardware_info['computer_name'] = platform.node()
|
||||
|
||||
# MAC地址(作为备用)
|
||||
hardware_info['mac_address'] = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff)
|
||||
for elements in range(0, 2*6, 2)][::-1])
|
||||
|
||||
return hardware_info
|
||||
|
||||
def _generate_device_id(self, hardware_info: dict) -> str:
|
||||
"""
|
||||
基于硬件信息生成设备ID
|
||||
|
||||
Args:
|
||||
hardware_info: 硬件信息字典
|
||||
|
||||
Returns:
|
||||
32位十六进制设备ID
|
||||
"""
|
||||
# 过滤掉空值,并按键排序确保一致性
|
||||
filtered_info = {k: v for k, v in hardware_info.items() if v and v.strip()}
|
||||
|
||||
# 如果没有任何硬件信息,使用MAC地址作为后备方案
|
||||
if not filtered_info:
|
||||
filtered_info = {'mac_address': hardware_info.get('mac_address', str(uuid.getnode()))}
|
||||
|
||||
# 将所有信息连接成字符串
|
||||
info_string = '|'.join(f"{k}:{v}" for k, v in sorted(filtered_info.items()))
|
||||
|
||||
# 使用SHA256生成哈希值
|
||||
hash_object = hashlib.sha256(info_string.encode('utf-8'))
|
||||
device_id = hash_object.hexdigest()
|
||||
|
||||
return device_id
|
||||
|
||||
def _load_cached_device_id(self) -> Optional[str]:
|
||||
"""从缓存文件加载设备ID"""
|
||||
try:
|
||||
if os.path.exists(self.cache_file):
|
||||
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
||||
cached_id = f.read().strip()
|
||||
if len(cached_id) == 64: # SHA256哈希长度
|
||||
return cached_id
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _save_device_id_to_cache(self, device_id: str) -> None:
|
||||
"""将设备ID保存到缓存文件"""
|
||||
try:
|
||||
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
||||
f.write(device_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_device_id(self) -> str:
|
||||
"""
|
||||
获取设备唯一ID
|
||||
|
||||
Returns:
|
||||
64字符的十六进制设备ID
|
||||
"""
|
||||
# 如果启用缓存,先尝试从缓存加载
|
||||
if self.use_cache:
|
||||
cached_id = self._load_cached_device_id()
|
||||
if cached_id:
|
||||
return cached_id
|
||||
|
||||
# 收集硬件信息
|
||||
hardware_info = self._collect_hardware_info()
|
||||
|
||||
# 生成设备ID
|
||||
device_id = self._generate_device_id(hardware_info)
|
||||
|
||||
# 保存到缓存
|
||||
if self.use_cache:
|
||||
self._save_device_id_to_cache(device_id)
|
||||
|
||||
return device_id
|
||||
|
||||
def get_device_id_short(self, length: int = 16) -> str:
|
||||
"""
|
||||
获取短版本的设备ID
|
||||
|
||||
Args:
|
||||
length: 返回ID的长度
|
||||
|
||||
Returns:
|
||||
指定长度的设备ID
|
||||
"""
|
||||
full_id = self.get_device_id()
|
||||
return full_id[:length]
|
||||
|
||||
def get_hardware_info(self) -> dict:
|
||||
"""
|
||||
获取硬件信息(用于调试)
|
||||
|
||||
Returns:
|
||||
硬件信息字典
|
||||
"""
|
||||
return self._collect_hardware_info()
|
||||
|
||||
|
||||
# 使用示例
|
||||
def main():
|
||||
"""使用示例"""
|
||||
# 创建设备ID生成器实例
|
||||
device_generator = DeviceIDGenerator()
|
||||
|
||||
# 获取完整设备ID(64字符)
|
||||
device_id = device_generator.get_device_id()
|
||||
print(f"完整设备ID: {device_id}")
|
||||
|
||||
# 获取短版本设备ID(16字符)
|
||||
short_id = device_generator.get_device_id_short(16)
|
||||
print(f"短设备ID: {short_id}")
|
||||
|
||||
# 查看硬件信息(调试用)
|
||||
hardware_info = device_generator.get_hardware_info()
|
||||
print("\n硬件信息:")
|
||||
for key, value in hardware_info.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,177 +0,0 @@
|
||||
# Design
|
||||
|
||||
## Source of truth
|
||||
- Status: Active
|
||||
- Last refreshed: 2026-06-09
|
||||
- Primary product surfaces:
|
||||
- `image-video.html`
|
||||
- `src/pages/image-video/ImageVideoPage.vue`
|
||||
- Evidence reviewed:
|
||||
- `src/pages/image-video/ImageVideoPage.vue`
|
||||
- `src/styles/main.css`
|
||||
- `src/styles/tokens.css`
|
||||
- `src/components/layout/PageShell.vue`
|
||||
- `../yaoyaoAI/aiclientpureui/src/styles/main.css`
|
||||
- `../yaoyaoAI/aiclientpureui/src/components/dashboard/DashboardCard.vue`
|
||||
- `../yaoyaoAI/aiclientpureui/src/components/workflow/Step02AudioVideo.vue`
|
||||
- `../yaoyaoAI/aiclientpureui/src/components/workflow/Step03VideoEdit.vue`
|
||||
|
||||
## Brand
|
||||
- Personality:
|
||||
- Quiet, tool-oriented, high-density AI workflow workspace
|
||||
- Dark professional console rather than marketing landing page
|
||||
- Trust signals:
|
||||
- Structured step cards
|
||||
- Stable layout grid
|
||||
- Clear state summaries and output preview areas
|
||||
- Avoid:
|
||||
- Light hero-page styling
|
||||
- Decorative floating cards wrapped inside larger cards
|
||||
- Tailwind or PrimeVue-specific visual copies that do not fit Element Plus
|
||||
|
||||
## Product goals
|
||||
- Goals:
|
||||
- Add a real `带货视频` module inside `frontend-vue`
|
||||
- Put `视频复刻` and `图生视频` on one page with tab switching
|
||||
- Match the visual tone of `aiclientpureui`
|
||||
- Extract reusable workflow components for future media modules
|
||||
- Non-goals:
|
||||
- Backend API integration in this phase
|
||||
- Rebuilding the entire `aiclientpureui` navigation shell
|
||||
- Success signals:
|
||||
- The page is screenshot-ready
|
||||
- Form sections are organized and legible at desktop widths
|
||||
- Shared components are reusable beyond this one page
|
||||
|
||||
## Personas and jobs
|
||||
- Primary personas:
|
||||
- Internal operators creating or editing commerce videos
|
||||
- Content operations staff iterating scripts, scenes, and avatars quickly
|
||||
- User jobs:
|
||||
- Upload source material
|
||||
- Configure video generation parameters
|
||||
- Adjust scene, face, script, and product settings
|
||||
- Review an output summary before starting generation
|
||||
- Key contexts of use:
|
||||
- Desktop-heavy internal tool usage
|
||||
- Repeated daily workflows with dense settings
|
||||
|
||||
## Information architecture
|
||||
- Primary navigation:
|
||||
- Keep a lightweight page header with module switch and back link
|
||||
- Core routes/screens:
|
||||
- `image-video.html` as the image/video workspace entry
|
||||
- Content hierarchy:
|
||||
- Page header and module switch
|
||||
- Delivery-video tabs
|
||||
- Step-oriented configuration grid
|
||||
- Preview and submission area
|
||||
|
||||
## Design principles
|
||||
- Principle 1:
|
||||
- Reuse structure, not framework-specific implementation. Port the dashboard/card language from `aiclientpureui` into Element Plus-native components.
|
||||
- Principle 2:
|
||||
- Keep the screen directly operable. The first viewport should show the active workflow, not a placeholder entrance.
|
||||
- Tradeoffs:
|
||||
- Prefer a compact dark console layout over a literal flowchart reconstruction from the requirement images.
|
||||
|
||||
## Visual language
|
||||
- Color:
|
||||
- Deep neutral background with restrained blue-indigo highlights
|
||||
- High-contrast text, low-contrast secondary copy
|
||||
- Typography:
|
||||
- Existing sans-serif stack from `tokens.css`
|
||||
- Compact section headings and dense form labels
|
||||
- Spacing/layout rhythm:
|
||||
- 20px to 24px outer spacing
|
||||
- 16px card padding
|
||||
- Multi-column grid with stable min widths
|
||||
- Shape/radius/elevation:
|
||||
- 14px to 18px radii
|
||||
- Soft border glow, minimal shadow depth
|
||||
- Motion:
|
||||
- Short hover and focus transitions only
|
||||
- Imagery/iconography:
|
||||
- Use media preview boxes and upload placeholders
|
||||
- Avoid decorative illustrations
|
||||
|
||||
## Components
|
||||
- Existing components to reuse:
|
||||
- `src/components/layout/PageShell.vue`
|
||||
- New/changed components:
|
||||
- Shared dark workflow shell
|
||||
- Shared module switch
|
||||
- Shared section card
|
||||
- Shared choice-pill selector
|
||||
- Shared asset dropzone / preview block
|
||||
- Variants and states:
|
||||
- Active/inactive tabs
|
||||
- Active/inactive pills
|
||||
- Empty vs selected asset states
|
||||
- Hover/focus for cards and action buttons
|
||||
- Token/component ownership:
|
||||
- Theme tokens in `src/styles/tokens.css`
|
||||
- Global workflow styles in `src/styles/main.css`
|
||||
- Shared components under `src/shared/components/ai-workflow/`
|
||||
|
||||
## Accessibility
|
||||
- Target standard:
|
||||
- Practical keyboard and screen-readable form semantics for internal desktop usage
|
||||
- Keyboard/focus behavior:
|
||||
- Visible focus ring on pills, buttons, upload areas, and tabs
|
||||
- Contrast/readability:
|
||||
- Maintain strong foreground/background contrast in dark mode
|
||||
- Screen-reader semantics:
|
||||
- Preserve labels for upload regions and tab panels
|
||||
- Reduced motion and sensory considerations:
|
||||
- No large-scale animation
|
||||
|
||||
## Responsive behavior
|
||||
- Supported breakpoints/devices:
|
||||
- Primary target desktop >= 1280px
|
||||
- Secondary support tablet widths >= 768px
|
||||
- Layout adaptations:
|
||||
- Collapse multi-column workspace into two columns, then one column
|
||||
- Preview area stops being sticky on narrow screens
|
||||
- Touch/hover differences:
|
||||
- Controls remain usable without hover-only affordances
|
||||
|
||||
## Interaction states
|
||||
- Loading:
|
||||
- Reserved for future backend integration
|
||||
- Empty:
|
||||
- Show upload placeholders and muted guidance text
|
||||
- Error:
|
||||
- Use Element Plus messaging if upload type or action constraints fail
|
||||
- Success:
|
||||
- Local draft actions can confirm save/export interactions
|
||||
- Disabled:
|
||||
- Non-active modules appear disabled but visible
|
||||
- Offline/slow network, if applicable:
|
||||
- Not applicable in the current local-only draft
|
||||
|
||||
## Content voice
|
||||
- Tone:
|
||||
- Direct and operational
|
||||
- Terminology:
|
||||
- Prefer commerce-video workflow language: 素材, 场景, 人脸, 话术, 产品, 输出
|
||||
- Microcopy rules:
|
||||
- Short labels
|
||||
- Guidance belongs in helper text, not long paragraphs
|
||||
|
||||
## Implementation constraints
|
||||
- Framework/styling system:
|
||||
- Vue 3 + TypeScript + Element Plus
|
||||
- Design-token constraints:
|
||||
- Extend current CSS variables instead of introducing Tailwind
|
||||
- Performance constraints:
|
||||
- Keep the page static and local-first until API wiring is required
|
||||
- Compatibility constraints:
|
||||
- Must work within the existing Vite multi-page build
|
||||
- Test/screenshot expectations:
|
||||
- Build must pass
|
||||
- Local dev server should render a screenshot-ready page at `image-video.html`
|
||||
|
||||
## Open questions
|
||||
- [ ] 图二和图三的字段是否需要完全一致,目前按“共用主流程 + 源素材差异化”实现
|
||||
- [ ] 后续是否需要直接接入生成任务接口,目前仅实现本地工作台和交互草稿
|
||||
@@ -21,9 +21,6 @@
|
||||
<button v-if="hasMenuPermission('mix-video')" type="button" class="entrance-btn" @click="showSoon('混剪')">
|
||||
混剪
|
||||
</button>
|
||||
<router-link v-if="hasMenuPermission('image')" class="entrance-btn" to="/image">
|
||||
图片
|
||||
</router-link>
|
||||
</main>
|
||||
|
||||
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
pasteClipboardImages,
|
||||
readFilesAsDataUrls,
|
||||
} from '../image-utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
max: number
|
||||
title?: string
|
||||
hint?: string
|
||||
zoneHint?: string
|
||||
zonePrefix?: string
|
||||
/** none=无粘贴 | row=标题行右侧粘贴按钮 | below=标题下独立粘贴按钮 */
|
||||
pastePos?: 'none' | 'row' | 'below'
|
||||
}>(),
|
||||
{
|
||||
hint: '',
|
||||
zoneHint: '支持多选/拖拽上传',
|
||||
zonePrefix: '点击上传图片',
|
||||
pastePos: 'none',
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string[]>({ required: true })
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
function choose() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files?.length) void addFiles(input.files)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function addFiles(files: Blob[] | FileList) {
|
||||
const allowed = Array.from(files).filter((f) => ALLOWED_IMAGE_TYPES.includes(f.type))
|
||||
if (!allowed.length) return
|
||||
const remaining = Math.max(0, props.max - model.value.length)
|
||||
if (!remaining) return
|
||||
const urls = await readFilesAsDataUrls(allowed.slice(0, remaining))
|
||||
model.value = model.value.concat(urls).slice(0, props.max)
|
||||
}
|
||||
|
||||
function removeAt(i: number) {
|
||||
const next = model.value.slice()
|
||||
next.splice(i, 1)
|
||||
model.value = next
|
||||
}
|
||||
|
||||
async function pasteImages() {
|
||||
const images = await pasteClipboardImages()
|
||||
if (images.length) void addFiles(images)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
if (e.dataTransfer?.files?.length) void addFiles(e.dataTransfer.files)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="option-group">
|
||||
<div v-if="title" class="section-title-row">
|
||||
<span class="section-title">{{ title }}</span>
|
||||
<button
|
||||
v-if="pastePos === 'row'"
|
||||
type="button"
|
||||
class="btn-paste-alone btn-paste-right"
|
||||
@click="pasteImages"
|
||||
>
|
||||
<span>📋</span>
|
||||
粘贴图片
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="pastePos === 'below'"
|
||||
type="button"
|
||||
class="btn-paste-alone"
|
||||
@click="pasteImages"
|
||||
>
|
||||
<span>📋</span>
|
||||
粘贴图片
|
||||
</button>
|
||||
<div v-if="hint" class="hint-text">{{ hint }}</div>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.bmp"
|
||||
multiple
|
||||
hidden
|
||||
@change="onChange"
|
||||
/>
|
||||
<div
|
||||
v-if="model.length < max"
|
||||
class="upload-zone"
|
||||
:class="{ 'drag-over': dragging }"
|
||||
@click="choose"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="upload-zone-icon">↑</div>
|
||||
<div class="upload-zone-text">{{ zonePrefix }} ({{ model.length }}/{{ max }})</div>
|
||||
<div v-if="zoneHint" class="upload-zone-hint">{{ zoneHint }}</div>
|
||||
</div>
|
||||
<div v-if="model.length > 0" class="upload-preview product-detail-preview">
|
||||
<div v-for="(u, i) in model" :key="i" class="upload-preview-item">
|
||||
<img :src="u" :alt="'预览' + (i + 1)" />
|
||||
<button class="remove-btn" type="button" @click.stop="removeAt(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import ImageListField from './ImageListField.vue'
|
||||
import { modelImages } from '../workbench-shared'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ImageListField
|
||||
v-model="modelImages"
|
||||
:max="5"
|
||||
title="多模特图上传(最多5张)"
|
||||
hint="支持JPG PNG BMP JPEG"
|
||||
:paste-pos="'none'"
|
||||
:zone-hint="''"
|
||||
zone-prefix="点击上传"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ALLOWED_IMAGE_TYPES, readFilesAsDataUrls } from '../image-utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
hint?: string
|
||||
zoneText?: string
|
||||
accept?: string
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
hint: '',
|
||||
zoneText: '点击上传',
|
||||
accept: '.jpg,.jpeg,.png,.bmp',
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string | null>({ default: null })
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
function choose() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) void setFile(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function setFile(file: File) {
|
||||
if (props.accept.startsWith('video')) {
|
||||
if (!file.type.startsWith('video/')) return
|
||||
} else if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
return
|
||||
}
|
||||
const urls = await readFilesAsDataUrls([file])
|
||||
model.value = urls[0] ?? null
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
model.value = null
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) void setFile(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="option-group">
|
||||
<div v-if="title" class="section-title">{{ title }}</div>
|
||||
<div v-if="hint" class="hint-text">{{ hint }}</div>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
hidden
|
||||
@change="onChange"
|
||||
/>
|
||||
<div
|
||||
v-if="!model"
|
||||
class="upload-zone"
|
||||
:class="{ 'drag-over': dragging }"
|
||||
@click="choose"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="upload-zone-icon">↑</div>
|
||||
<div class="upload-zone-text">{{ zoneText }}</div>
|
||||
</div>
|
||||
<div v-else class="upload-preview product-detail-preview">
|
||||
<div v-if="accept.startsWith('video')" class="upload-preview-item" style="grid-column: 1/-1;">
|
||||
<span style="color: #888;">已选视频</span>
|
||||
<button class="remove-btn" type="button" @click="clearFile">×</button>
|
||||
</div>
|
||||
<div v-else class="upload-preview-item">
|
||||
<img :src="model" alt="预览" />
|
||||
<button class="remove-btn" type="button" @click="clearFile">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ count: number }>()
|
||||
|
||||
const model = defineModel<string[]>({ default: [] })
|
||||
|
||||
function resize(n: number) {
|
||||
const safeN = Math.max(0, Math.min(50, Math.max(1, Number(n) || 1)))
|
||||
const vals = Array.isArray(model.value) ? model.value : []
|
||||
const next: string[] = []
|
||||
for (let i = 0; i < safeN; i++) next.push(i < vals.length ? String(vals[i] ?? '') : '')
|
||||
model.value = next
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.count,
|
||||
(n) => resize(n),
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="specify-screen-list">
|
||||
<div v-for="(_, i) in model" :key="i" class="specify-screen-item">
|
||||
<label>第{{ i + 1 }}屏文案 (可选)</label>
|
||||
<input
|
||||
v-model="model[i]"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:data-screen-index="i + 1"
|
||||
placeholder="如:高颜值外观,一眼心动"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ImageListField from '../ImageListField.vue'
|
||||
import SpecifyTextList from '../SpecifyTextList.vue'
|
||||
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||
import { RATIO_OPTIONS_AUTO, RES_OPTIONS, collectSpecify } from '../../panel-options'
|
||||
import { modelImages } from '../../workbench-shared'
|
||||
|
||||
defineOptions({ name: 'CloneDetailPanel' })
|
||||
|
||||
const productName = ref('')
|
||||
const features = ref('')
|
||||
const cloneMode = ref<'domestic' | 'amazon' | 'specify'>('domestic')
|
||||
const ratio = ref('auto')
|
||||
const res = ref('2k')
|
||||
const language = ref('')
|
||||
const procImages = ref<string[]>([])
|
||||
const refImages = ref<string[]>([])
|
||||
const specify = ref<string[]>([])
|
||||
|
||||
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||
if (procImages.value.length === 0) return { error: '请上传产品实拍图' }
|
||||
if (refImages.value.length === 0) return { error: '请上传克隆参考图' }
|
||||
const modeMap = { domestic: '1', amazon: '2', specify: '3' } as const
|
||||
const params: Record<string, unknown> = {
|
||||
menu: 10,
|
||||
name: productName.value.trim(),
|
||||
desc: features.value.trim(),
|
||||
ratio: ratio.value,
|
||||
resolution: res.value,
|
||||
language: language.value.trim() || '中文',
|
||||
mode: modeMap[cloneMode.value],
|
||||
proc_images: procImages.value.slice(),
|
||||
ref_images: refImages.value.slice(),
|
||||
}
|
||||
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||
if (cloneMode.value === 'specify') params.text = collectSpecify(specify.value)
|
||||
return { params }
|
||||
}
|
||||
|
||||
defineExpose({ buildParams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<div class="option-group">
|
||||
<label class="section-title">产品名称 (必填)</label>
|
||||
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||
<textarea
|
||||
v-model="features"
|
||||
class="form-textarea"
|
||||
rows="4"
|
||||
placeholder="如: 1:保湿效果好 2:适合敏感肌 3:价格实惠"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<div class="section-title">克隆模式</div>
|
||||
<div class="btn-group">
|
||||
<button class="opt-btn" :class="{ active: cloneMode === 'domestic' }" @click="cloneMode = 'domestic'">国内模式</button>
|
||||
<button class="opt-btn" :class="{ active: cloneMode === 'amazon' }" @click="cloneMode = 'amazon'">亚马逊模式</button>
|
||||
<button class="opt-btn" :class="{ active: cloneMode === 'specify' }" @click="cloneMode = 'specify'">指定文案</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cloneMode === 'specify'" class="option-group">
|
||||
<label class="section-title">每张参考图对应文案 (可选)</label>
|
||||
<SpecifyTextList v-model="specify" :count="refImages.length" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<div class="section-title">画幅比例</div>
|
||||
<div class="btn-group btn-group-ratio-grid">
|
||||
<button
|
||||
v-for="r in RATIO_OPTIONS_AUTO"
|
||||
:key="r"
|
||||
class="opt-btn"
|
||||
:class="{ active: ratio === r }"
|
||||
@click="ratio = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint-text">💡 克隆模式强烈建议使用auto自动比例</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<div class="section-title">分辨率 (RESOLUTION)</div>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="opt in RES_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="opt-btn"
|
||||
:class="{ active: res === opt.value }"
|
||||
@click="res = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||
<input v-model="language" type="text" class="form-input" placeholder="例如: 中文输出、英文输出、中英混合" />
|
||||
</div>
|
||||
<ImageListField v-model="procImages" :max="6" title="产品实拍图" :paste-pos="'row'" />
|
||||
<ImageListField v-model="refImages" :max="14" title="克隆参考图" :paste-pos="'row'" />
|
||||
<ModelImagesUploader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ImageListField from '../ImageListField.vue'
|
||||
import SingleImageField from '../SingleImageField.vue'
|
||||
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
|
||||
import { modelImages } from '../../workbench-shared'
|
||||
|
||||
defineOptions({ name: 'ClonePosterPanel' })
|
||||
|
||||
const mainTitle = ref('')
|
||||
const subtitle = ref('')
|
||||
const ratio = ref('16:9')
|
||||
const res = ref('2k')
|
||||
const layoutImage = ref<string | null>(null)
|
||||
const images = ref<string[]>([])
|
||||
|
||||
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||
if (!layoutImage.value) return { error: '请上传版式图片(1张)' }
|
||||
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
|
||||
const params: Record<string, unknown> = {
|
||||
menu: 4,
|
||||
name: mainTitle.value.trim() || '产品',
|
||||
desc: subtitle.value.trim(),
|
||||
ratio: ratio.value,
|
||||
resolution: res.value,
|
||||
count: 1,
|
||||
layout_image: layoutImage.value,
|
||||
ref_images: images.value.slice(),
|
||||
}
|
||||
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||
return { params }
|
||||
}
|
||||
|
||||
defineExpose({ buildParams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<div class="option-group">
|
||||
<label class="section-title">主标题</label>
|
||||
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">副标题</label>
|
||||
<textarea
|
||||
v-model="subtitle"
|
||||
class="form-textarea"
|
||||
rows="4"
|
||||
placeholder="如: 1: 保湿效果好 2: 适合敏感肌 3: 价格实惠"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">画幅比例</label>
|
||||
<div class="btn-group btn-group-ratio-grid btn-group-ratio-4col">
|
||||
<button
|
||||
v-for="r in RATIO_OPTIONS"
|
||||
:key="r"
|
||||
class="opt-btn"
|
||||
:class="{ active: ratio === r }"
|
||||
@click="ratio = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="opt in RES_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="opt-btn"
|
||||
:class="{ active: res === opt.value }"
|
||||
@click="res = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SingleImageField v-model="layoutImage" title="上传版式 (1张)" zone-text="上传版式" />
|
||||
<ImageListField v-model="images" :max="6" title="上传图片 (最多6张)" />
|
||||
<ModelImagesUploader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ImageListField from '../ImageListField.vue'
|
||||
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
|
||||
import { modelImages } from '../../workbench-shared'
|
||||
|
||||
defineOptions({ name: 'ClothingDetailPanel' })
|
||||
|
||||
const productName = ref('')
|
||||
const features = ref('')
|
||||
const ratio = ref('16:9')
|
||||
const res = ref('2k')
|
||||
const style = ref('')
|
||||
const language = ref('')
|
||||
const count = ref(9)
|
||||
const images = ref<string[]>([])
|
||||
|
||||
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||
if (images.value.length === 0) return { error: '请上传服装细节图片' }
|
||||
const params: Record<string, unknown> = {
|
||||
menu: 5,
|
||||
name: productName.value.trim(),
|
||||
desc: features.value.trim(),
|
||||
ratio: ratio.value,
|
||||
resolution: res.value,
|
||||
count: count.value,
|
||||
style: style.value || '极简高级',
|
||||
language: language.value.trim() || '中文',
|
||||
ref_images: images.value.slice(),
|
||||
}
|
||||
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||
return { params }
|
||||
}
|
||||
|
||||
defineExpose({ buildParams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<div class="option-group">
|
||||
<label class="section-title">产品名称(必填)</label>
|
||||
<input v-model="productName" type="text" class="form-input" placeholder="如:口红吹风机 美容仪 美妆包" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||
<textarea
|
||||
v-model="features"
|
||||
class="form-textarea"
|
||||
rows="4"
|
||||
placeholder="如:1:保湿效果好 2:适合敏感肌 3:价格实惠"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">画幅比例</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="r in RATIO_OPTIONS"
|
||||
:key="r"
|
||||
class="opt-btn"
|
||||
:class="{ active: ratio === r }"
|
||||
@click="ratio = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="opt in RES_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="opt-btn"
|
||||
:class="{ active: res === opt.value }"
|
||||
@click="res = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">风格选择 <span class="title-icon">✦</span></label>
|
||||
<input v-model="style" class="form-select" list="styleList_clothes" placeholder="-- 选择或输入风格 --" />
|
||||
<datalist id="styleList_clothes">
|
||||
<option value="极简高级"></option>
|
||||
<option value="时尚潮流"></option>
|
||||
<option value="复古颗粒"></option>
|
||||
<option value="网红外拍"></option>
|
||||
<option value="手机自拍"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">生成张数 <span class="title-icon">🖼</span></label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="c in COUNT_DETAIL"
|
||||
:key="c"
|
||||
class="opt-btn"
|
||||
:class="{ active: count === c }"
|
||||
@click="count = c"
|
||||
>
|
||||
{{ c }}张
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ImageListField
|
||||
v-model="images"
|
||||
:max="8"
|
||||
title="服装细节(至少1张)"
|
||||
:paste-pos="'below'"
|
||||
/>
|
||||
<ModelImagesUploader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,143 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ImageListField from '../ImageListField.vue'
|
||||
import SpecifyTextList from '../SpecifyTextList.vue'
|
||||
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS, collectSpecify } from '../../panel-options'
|
||||
import { modelImages } from '../../workbench-shared'
|
||||
|
||||
defineOptions({ name: 'ExtremeDetailPanel' })
|
||||
|
||||
const DEFAULT_MANUAL = `1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌
|
||||
2. 加宽腰头无痕边: 不勒肚不卡档, 自在无束缚
|
||||
3. 0荧光0甲醛: 严守安全标准, 贴身穿着才安心
|
||||
4. 萌趣卡通印花: 色彩清新柔和, 孩子一眼就爱
|
||||
5. 立体透气抑菌档: 吸湿排汗强, 干爽舒适不闷
|
||||
6. 高弹面料不松垮: 耐洗不易变形, 久穿不起球
|
||||
7. 2-12岁全尺码: 剪裁贴合身形, 舒适贴合不紧
|
||||
8. 弹力平角不卡腿: 穿脱顺畅方便, 孩子自己穿
|
||||
9. 多种花色随心选: 满足日常替换, 天天不重样`
|
||||
|
||||
const productName = ref('')
|
||||
const featureMode = ref<'auto' | 'manual' | 'specify'>('manual')
|
||||
const manualText = ref(DEFAULT_MANUAL)
|
||||
const ratio = ref('3:4')
|
||||
const res = ref('2k')
|
||||
const styleDesc = ref('')
|
||||
const language = ref('')
|
||||
const count = ref(9)
|
||||
const images = ref<string[]>([])
|
||||
const specify = ref<string[]>([])
|
||||
|
||||
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||
if (images.value.length === 0) return { error: '请上传参考图片' }
|
||||
const modeMap = { auto: '1', manual: '1', specify: '3' } as const
|
||||
let desc = ''
|
||||
if (featureMode.value === 'manual') desc = manualText.value.trim()
|
||||
const params: Record<string, unknown> = {
|
||||
menu: 8,
|
||||
name: productName.value.trim(),
|
||||
desc,
|
||||
style: styleDesc.value.trim() || '极简高级',
|
||||
language: language.value.trim() || '中文',
|
||||
ratio: ratio.value,
|
||||
resolution: res.value,
|
||||
count: count.value,
|
||||
mode: modeMap[featureMode.value],
|
||||
ref_images: images.value.slice(),
|
||||
}
|
||||
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
|
||||
return { params }
|
||||
}
|
||||
|
||||
defineExpose({ buildParams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<div class="option-group">
|
||||
<label class="section-title">产品名称(必填)</label>
|
||||
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||
<div class="feature-mode-tabs btn-group">
|
||||
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
|
||||
<button class="opt-btn" :class="{ active: featureMode === 'manual' }" @click="featureMode = 'manual'">手动模式</button>
|
||||
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
|
||||
</div>
|
||||
<div v-if="featureMode === 'manual'" class="feature-mode-content">
|
||||
<div class="feature-example-label">示例2:</div>
|
||||
<textarea
|
||||
v-model="manualText"
|
||||
class="form-textarea"
|
||||
rows="10"
|
||||
placeholder="每行一个特点,格式如:1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-if="featureMode === 'specify'" class="feature-mode-content">
|
||||
<SpecifyTextList v-model="specify" :count="count" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">画幅比例 (ASPECT RATIO)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="r in RATIO_OPTIONS"
|
||||
:key="r"
|
||||
class="opt-btn"
|
||||
:class="{ active: ratio === r }"
|
||||
@click="ratio = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">风格和功能卖点描述</label>
|
||||
<input v-model="styleDesc" type="text" class="form-input" placeholder="例如:高级质感、极简主义、奢华轻奢" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||
<input v-model="language" type="text" class="form-input" placeholder="例如:使用中文输出、使用英文输出" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="opt in RES_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="opt-btn"
|
||||
:class="{ active: res === opt.value }"
|
||||
@click="res = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">生成张数</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="c in COUNT_DETAIL"
|
||||
:key="c"
|
||||
class="opt-btn"
|
||||
:class="{ active: count === c }"
|
||||
@click="count = c"
|
||||
>
|
||||
{{ c }}张
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ImageListField
|
||||
v-model="images"
|
||||
:max="8"
|
||||
title="参考图片 (最多8张)"
|
||||
hint="建议包含正面及多角度图"
|
||||
:paste-pos="'row'"
|
||||
/>
|
||||
<ModelImagesUploader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,128 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import ImageListField from '../ImageListField.vue'
|
||||
import SpecifyTextList from '../SpecifyTextList.vue'
|
||||
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||
import { COUNT_MAIN, RATIO_OPTIONS_MAIN, RES_OPTIONS, collectSpecify } from '../../panel-options'
|
||||
import { modelImages } from '../../workbench-shared'
|
||||
|
||||
defineOptions({ name: 'MainImagePanel' })
|
||||
|
||||
const props = defineProps<{ panelId: 'productMainImage' | 'buyerShow' }>()
|
||||
|
||||
const productName = ref('')
|
||||
const featureMode = ref<'auto' | 'specify'>('auto')
|
||||
const ratio = ref('3:4')
|
||||
const res = ref('2k')
|
||||
const count = ref(1)
|
||||
const styleDesc = ref('')
|
||||
const language = ref('')
|
||||
const images = ref<string[]>([])
|
||||
const specify = ref<string[]>([])
|
||||
|
||||
const menu = computed(() => (props.panelId === 'buyerShow' ? 12 : 9))
|
||||
|
||||
function collectModelImages(): string[] | null {
|
||||
return modelImages.value.length > 0 ? modelImages.value.slice() : null
|
||||
}
|
||||
|
||||
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||
if (!productName.value.trim()) return { error: '请输入产品名称' }
|
||||
if (images.value.length === 0) return { error: '请上传产品图片' }
|
||||
const params: Record<string, unknown> = {
|
||||
menu: menu.value,
|
||||
name: productName.value.trim(),
|
||||
desc: '',
|
||||
style: styleDesc.value.trim() || '白底图',
|
||||
language: language.value.trim() || '中文',
|
||||
ratio: ratio.value,
|
||||
resolution: res.value,
|
||||
count: count.value,
|
||||
mode: featureMode.value === 'specify' ? '3' : '1',
|
||||
ref_images: images.value.slice(),
|
||||
}
|
||||
const modelImages = collectModelImages()
|
||||
if (modelImages) params.model_images = modelImages
|
||||
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
|
||||
return { params }
|
||||
}
|
||||
|
||||
defineExpose({ buildParams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<div class="option-group">
|
||||
<label class="section-title">产品名称(必填)</label>
|
||||
<input v-model="productName" type="text" class="form-input" placeholder="如:口红 吹风机 美容仪 美妆包" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
|
||||
<div class="feature-mode-tabs btn-group">
|
||||
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
|
||||
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
|
||||
</div>
|
||||
<div v-if="featureMode === 'specify'" class="feature-mode-content">
|
||||
<SpecifyTextList v-model="specify" :count="count" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">画幅比例</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="r in RATIO_OPTIONS_MAIN"
|
||||
:key="r"
|
||||
class="opt-btn"
|
||||
:class="{ active: ratio === r }"
|
||||
@click="ratio = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="opt in RES_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="opt-btn"
|
||||
:class="{ active: res === opt.value }"
|
||||
@click="res = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">风格描述 (可选)</label>
|
||||
<input v-model="styleDesc" type="text" class="form-input" placeholder="如:白底图 浅色调 墨绿色调 户外 居家" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
|
||||
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">生成张数</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="c in COUNT_MAIN"
|
||||
:key="c"
|
||||
class="opt-btn"
|
||||
:class="{ active: count === c }"
|
||||
@click="count = c"
|
||||
>
|
||||
{{ c }}张
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ImageListField
|
||||
v-model="images"
|
||||
:max="8"
|
||||
title="产品图片 (最多8张)"
|
||||
hint="建议包含正面及多角度图"
|
||||
:paste-pos="'row'"
|
||||
/>
|
||||
<ModelImagesUploader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ImageListField from '../ImageListField.vue'
|
||||
import ModelImagesUploader from '../ModelImagesUploader.vue'
|
||||
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
|
||||
import { modelImages } from '../../workbench-shared'
|
||||
|
||||
defineOptions({ name: 'ProductPosterPanel' })
|
||||
|
||||
const mainTitle = ref('')
|
||||
const subtitle = ref('')
|
||||
const brandName = ref('')
|
||||
const ingredients = ref('')
|
||||
const activity = ref('')
|
||||
const ratio = ref('16:9')
|
||||
const res = ref('2k')
|
||||
const images = ref<string[]>([])
|
||||
|
||||
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
|
||||
const params: Record<string, unknown> = {
|
||||
menu: 6,
|
||||
name: mainTitle.value.trim() || '产品',
|
||||
desc: subtitle.value.trim(),
|
||||
brand_name: brandName.value.trim(),
|
||||
Ingredients: ingredients.value.trim(),
|
||||
activity: activity.value.trim(),
|
||||
ratio: ratio.value,
|
||||
resolution: res.value,
|
||||
count: 1,
|
||||
ref_images: images.value.slice(),
|
||||
}
|
||||
if (modelImages.value.length) params.model_images = modelImages.value.slice()
|
||||
return { params }
|
||||
}
|
||||
|
||||
defineExpose({ buildParams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<div class="option-group">
|
||||
<label class="section-title">主标题</label>
|
||||
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红吹风机 美容仪 美妆包" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">副标题</label>
|
||||
<textarea
|
||||
v-model="subtitle"
|
||||
class="form-textarea"
|
||||
rows="4"
|
||||
placeholder="如: 1:保湿效果好 2: 适合敏感肌 3:价格实惠"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">品牌名 (可选)</label>
|
||||
<input v-model="brandName" type="text" class="form-input" placeholder="(可选)" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">成分 (INGREDIENTS)</label>
|
||||
<input v-model="ingredients" type="text" class="form-input" placeholder="例如: 纯棉、透明质酸...(可选)" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">活动 (ACTIVITY)</label>
|
||||
<input v-model="activity" type="text" class="form-input" placeholder="例如: 买一送一、限时折扣...(可选)" />
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">画幅比例</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="r in RATIO_OPTIONS"
|
||||
:key="r"
|
||||
class="opt-btn"
|
||||
:class="{ active: ratio === r }"
|
||||
@click="ratio = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label class="section-title">分辨率 (RESOLUTION)</label>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="opt in RES_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="opt-btn"
|
||||
:class="{ active: res === opt.value }"
|
||||
@click="res = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ImageListField
|
||||
v-model="images"
|
||||
:max="6"
|
||||
title="上传图片 (最多6张)"
|
||||
hint="支持多选与拖拽排序"
|
||||
:paste-pos="'row'"
|
||||
/>
|
||||
<ModelImagesUploader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ImageListField from '../ImageListField.vue'
|
||||
import SingleImageField from '../SingleImageField.vue'
|
||||
|
||||
defineOptions({ name: 'TextToImagePanel' })
|
||||
|
||||
const prompt = ref('')
|
||||
const refImages = ref<string[]>([])
|
||||
const video = ref<string | null>(null)
|
||||
|
||||
function buildParams(): { params?: Record<string, unknown>; error?: string } {
|
||||
const params: Record<string, unknown> = {
|
||||
menu: 1,
|
||||
prompt: prompt.value.trim() || '',
|
||||
ref_images: refImages.value.slice(),
|
||||
}
|
||||
if (video.value) params.video = video.value
|
||||
return { params }
|
||||
}
|
||||
|
||||
defineExpose({ buildParams })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<div class="option-group">
|
||||
<div class="section-title">提示词</div>
|
||||
<div class="textarea-wrap">
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
class="prompt-textarea prompt-textarea-large"
|
||||
placeholder="输入自定义指令,用于反推提示词..."
|
||||
></textarea>
|
||||
<span class="char-count char-count-top-right">{{ prompt.length }} 字符</span>
|
||||
</div>
|
||||
</div>
|
||||
<ImageListField
|
||||
v-model="refImages"
|
||||
:max="8"
|
||||
title="上传图片(最多8张)"
|
||||
hint="支持JPG PNG BMP JPEG"
|
||||
:zone-hint="'支持多选/拖拽上传'"
|
||||
/>
|
||||
<SingleImageField
|
||||
v-model="video"
|
||||
title="上传视频(只支持1个)"
|
||||
hint="支持 MP4 等视频格式"
|
||||
zone-text="点击上传视频"
|
||||
accept="video/*,.mp4,.webm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
|
||||
|
||||
/** 读取多个文件为 data URL,返回解析完的数组。 */
|
||||
export function readFilesAsDataUrls(files: Blob[] | FileList): Promise<string[]> {
|
||||
const list = Array.from(files)
|
||||
return Promise.all(
|
||||
list.map(
|
||||
(file) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('读取文件失败'))
|
||||
reader.readAsDataURL(file)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** 从剪贴板读取图片文件。 */
|
||||
export async function pasteClipboardImages(): Promise<Blob[]> {
|
||||
try {
|
||||
const items = await navigator.clipboard.read()
|
||||
const images: Blob[] = []
|
||||
for (const item of items) {
|
||||
if (item.types.includes('image/png')) {
|
||||
images.push(await item.getType('image/png'))
|
||||
} else if (item.types.includes('image/jpeg')) {
|
||||
images.push(await item.getType('image/jpeg'))
|
||||
}
|
||||
}
|
||||
return images
|
||||
} catch (err) {
|
||||
console.warn('粘贴图片失败:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
export const RES_OPTIONS = [
|
||||
{ label: '2K', value: '2k' },
|
||||
{ label: '4K', value: '4k' },
|
||||
]
|
||||
|
||||
/** 不含 auto 的常规画幅比例 */
|
||||
export const RATIO_OPTIONS = ['3:4', '1:1', '16:9', '9:16', '4:3', '2:3', '3:2', '21:9']
|
||||
|
||||
/** 含 auto 的画幅比例 */
|
||||
export const RATIO_OPTIONS_AUTO = ['auto', ...RATIO_OPTIONS]
|
||||
|
||||
/** 产品主图/买家秀仅三种比例,默认 3:4 */
|
||||
export const RATIO_OPTIONS_MAIN = ['1:1', '3:4', '4:3']
|
||||
|
||||
export const COUNT_MAIN = [1, 3, 5, 7, 9]
|
||||
|
||||
export const COUNT_DETAIL = [3, 5, 9, 14]
|
||||
|
||||
export function collectSpecify(list: string[]): string[] {
|
||||
return (Array.isArray(list) ? list : []).map((s) => String(s || '').trim()).filter(Boolean)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 多模特图:除反推词外所有栏目共享同一组上传 */
|
||||
export const modelImages = ref<string[]>([])
|
||||
|
||||
export const STORAGE_API_KEY = 'maixiang_api_key'
|
||||
export const STORAGE_AUTO_SAVE_PATH = 'maixiang_auto_save_path'
|
||||
@@ -24,11 +24,6 @@ const routes = [
|
||||
name: 'amazon-console',
|
||||
component: () => import('@/pages/amazon/AmazonConsolePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/image',
|
||||
name: 'image',
|
||||
component: () => import('@/pages/image/ImageWorkbenchPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/image-video',
|
||||
name: 'image-video',
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<div class="ai-module-switch" role="tablist" aria-label="功能模块切换">
|
||||
<button
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="ai-module-switch__item"
|
||||
:class="{
|
||||
'ai-module-switch__item--active': modelValue === item.value,
|
||||
'ai-module-switch__item--disabled': item.disabled,
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="$emit('update:modelValue', item.value)"
|
||||
>
|
||||
<span class="ai-module-switch__label">{{ item.label }}</span>
|
||||
<span v-if="item.description" class="ai-module-switch__description">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export type AiModuleSwitchOption = {
|
||||
label: string
|
||||
value: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
options: AiModuleSwitchOption[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-module-switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-module-switch__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-height: 84px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.65);
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #cbd5e1;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-module-switch__item:hover:not(:disabled) {
|
||||
border-color: rgba(96, 165, 250, 0.7);
|
||||
box-shadow: 0 14px 24px rgba(8, 15, 31, 0.34);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ai-module-switch__item--active {
|
||||
border-color: rgba(96, 165, 250, 0.82);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.96));
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(59, 130, 246, 0.18),
|
||||
0 18px 28px rgba(8, 15, 31, 0.36);
|
||||
}
|
||||
|
||||
.ai-module-switch__item--disabled {
|
||||
opacity: 0.62;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-module-switch__label {
|
||||
color: #f8fafc;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-module-switch__description {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ai-module-switch {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<div class="ai-workflow-shell">
|
||||
<div class="ai-workflow-shell__grid" aria-hidden="true"></div>
|
||||
<div class="ai-workflow-shell__inner">
|
||||
<header class="ai-workflow-shell__header">
|
||||
<div class="ai-workflow-shell__meta">
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
<div class="ai-workflow-shell__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="ai-workflow-shell__toolbar">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
<main class="ai-workflow-shell__content">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-workflow-shell {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top center, rgba(59, 130, 246, 0.14), transparent 32%),
|
||||
radial-gradient(circle at right bottom, rgba(99, 102, 241, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #060913 0%, #09111d 40%, #070b14 100%);
|
||||
}
|
||||
|
||||
.ai-workflow-shell__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(77, 104, 145, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(77, 104, 145, 0.08) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(circle at 50% 18%, rgba(0, 0, 0, 0.9), transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__meta,
|
||||
.ai-workflow-shell__actions {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ai-workflow-shell__inner {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ai-workflow-shell__actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user