Compare commits
2 Commits
0199c7b12f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fb37bb45a | |||
| 0e8fc4ebe0 |
@@ -33,6 +33,32 @@ const STYLES = `
|
|||||||
.fab:hover { transform: scale(1.08); }
|
.fab:hover { transform: scale(1.08); }
|
||||||
.fab.hidden { display: none; }
|
.fab.hidden { display: none; }
|
||||||
|
|
||||||
|
/* 收起按钮:骑在面板左上边缘(一半在面板外)。必须在 iframe 外渲染——
|
||||||
|
iframe 裁剪内容,iframe 内的元素永远溢不出面板边界 */
|
||||||
|
.panel-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: min(880px, 100vw); /* 面板左边缘 = 视口右沿 - 面板宽度 */
|
||||||
|
transform: translateX(50%);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
background: #fff;
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 4;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.panel-close.visible { opacity: 1; pointer-events: auto; }
|
||||||
|
.panel-close:hover { color: #6d28d9; border-color: #6d28d9; transform: translateX(50%) scale(1.08); }
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -92,23 +118,34 @@ export default defineContentScript({
|
|||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.title = '电商套图工作台';
|
iframe.title = '电商套图工作台';
|
||||||
panel.append(iframe);
|
panel.append(iframe);
|
||||||
container.append(fab, panel);
|
|
||||||
|
// 外置收起按钮(骑在面板左上边缘,一半在面板外)
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'panel-close';
|
||||||
|
closeBtn.title = '收起面板(Esc)';
|
||||||
|
closeBtn.innerHTML =
|
||||||
|
'<svg width="12" height="12" viewBox="0 0 12 12" fill="none">' +
|
||||||
|
'<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
||||||
|
container.append(fab, panel, closeBtn);
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (!iframe.src) iframe.src = chrome.runtime.getURL('/sidepanel.html');
|
if (!iframe.src) iframe.src = chrome.runtime.getURL('/sidepanel.html');
|
||||||
panel.classList.add('open');
|
panel.classList.add('open');
|
||||||
|
closeBtn.classList.add('visible');
|
||||||
fab.classList.add('hidden');
|
fab.classList.add('hidden');
|
||||||
// 聚焦进面板,键盘操作(Esc 关闭 / 预览翻页)直接可用
|
// 聚焦进面板,键盘操作(Esc 关闭 / 预览翻页)直接可用
|
||||||
iframe.focus();
|
iframe.focus();
|
||||||
};
|
};
|
||||||
const close = () => {
|
const close = () => {
|
||||||
panel.classList.remove('open');
|
panel.classList.remove('open');
|
||||||
|
closeBtn.classList.remove('visible');
|
||||||
if (isProductPage()) fab.classList.remove('hidden');
|
if (isProductPage()) fab.classList.remove('hidden');
|
||||||
};
|
};
|
||||||
|
|
||||||
fab.addEventListener('click', open);
|
fab.addEventListener('click', open);
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
|
||||||
// 面板内 App(✕ / Esc)→ 收起
|
// 面板内 App(Esc)→ 收起;✕ 按钮已外置到宿主层(closeBtn)
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (e.source === iframe.contentWindow && (e.data as any)?.type === PANEL_CLOSE_MSG) close();
|
if (e.source === iframe.contentWindow && (e.data as any)?.type === PANEL_CLOSE_MSG) close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd';
|
import { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd';
|
||||||
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined, CloseOutlined } from '@ant-design/icons';
|
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
|
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
|
||||||
import { cleanFilename } from '../../src/collector/url';
|
import { cleanFilename } from '../../src/collector/url';
|
||||||
import {
|
import {
|
||||||
@@ -671,11 +671,9 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
{/* ── 顶栏 ── */}
|
{/* ── 顶栏 ──(页内模式的收起 ✕ 按钮在宿主层渲染:要骑在面板边缘、一半在面板外,
|
||||||
|
iframe 会裁剪内容,按钮必须在 iframe 外;Esc 关闭仍走 closePanel) */}
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
{IN_PAGE && (
|
|
||||||
<button className="icon-btn" title="收起面板(Esc)" onClick={closePanel}><CloseOutlined /></button>
|
|
||||||
)}
|
|
||||||
<div className="logo">套</div>
|
<div className="logo">套</div>
|
||||||
<div>
|
<div>
|
||||||
<h1>电商套图工作台</h1>
|
<h1>电商套图工作台</h1>
|
||||||
|
|||||||
+19
-12
@@ -22,23 +22,27 @@ ALLOWED_KINDS = [
|
|||||||
"size_chart", "sku_collection", "custom",
|
"size_chart", "sku_collection", "custom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 方案总张数硬上限(提示词预算 + 代码兜底裁减都用它)
|
||||||
|
MAX_PLAN_TOTAL = 8
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商套图的出图方案。
|
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商套图的出图方案。
|
||||||
|
|
||||||
## 输出硬性约束(违反即失败)
|
## 输出硬性约束(违反即失败)
|
||||||
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
||||||
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
||||||
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
|
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
|
||||||
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数 8-15。
|
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数(所有 count 之和)不得超过 8,这是硬性上限。
|
||||||
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
||||||
|
|
||||||
## 规划规则
|
## 规划规则(总预算 8 张,按优先级分配)
|
||||||
1. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
|
1. 总预算:所有项 count 之和 ≤ 8。空间不足时砍低优先级项,优先级从低到高:尺寸标注图 / SKU 合集图 / 细节图 → 场景图;SKU 主图不砍。
|
||||||
2. 场景图(kind=lifestyle):按核心使用场景出 2-4 张,每张聚焦一个场景。
|
2. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
|
||||||
3. 细节图(kind=material 或 custom):按关键细节/材质/结构出 2-3 张,每张聚焦一个卖点。
|
3. 场景图(kind=lifestyle):预算内出 1-3 张,每张聚焦一个场景。
|
||||||
4. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
|
4. 细节图(kind=material 或 custom):预算内出 1-2 张,每张聚焦一个卖点。
|
||||||
5. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
|
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸且预算富余时出 1 张。
|
||||||
6. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
|
6. SKU 合集图(kind=sku_collection):SKU >1 且预算富余时出 1 张。
|
||||||
7. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
|
||||||
|
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
||||||
|
|
||||||
## 输出示例(紧凑单行)
|
## 输出示例(紧凑单行)
|
||||||
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
|
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
|
||||||
@@ -214,14 +218,17 @@ async def generate_plan(
|
|||||||
items = _normalize_items(data.get("items") or [], sku_variants)
|
items = _normalize_items(data.get("items") or [], sku_variants)
|
||||||
if not items:
|
if not items:
|
||||||
raise RuntimeError("规划器未返回有效方案项")
|
raise RuntimeError("规划器未返回有效方案项")
|
||||||
# 总量保护:超过 18 张时按比例截断
|
# 总量保护:超过上限时从尾部(低优先级项)裁减,SKU 主图排在前面不受影响
|
||||||
total = sum(i["count"] for i in items)
|
before_trim = sum(i["count"] for i in items)
|
||||||
while total > 18 and items:
|
total = before_trim
|
||||||
|
while total > MAX_PLAN_TOTAL and items:
|
||||||
last = items[-1]
|
last = items[-1]
|
||||||
if last["count"] > 1:
|
if last["count"] > 1:
|
||||||
last["count"] -= 1
|
last["count"] -= 1
|
||||||
else:
|
else:
|
||||||
items.pop()
|
items.pop()
|
||||||
total = sum(i["count"] for i in items)
|
total = sum(i["count"] for i in items)
|
||||||
|
if total < before_trim:
|
||||||
|
log.info("规划方案 %d 张超出上限 %d,已裁减至 %d 张(从尾部低优先级项开始)", before_trim, MAX_PLAN_TOTAL, total)
|
||||||
|
|
||||||
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
|
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ from config import get_settings
|
|||||||
|
|
||||||
log = logging.getLogger("suite.watermark")
|
log = logging.getLogger("suite.watermark")
|
||||||
|
|
||||||
# 尺寸比例(与 ozonSeller app.js 常量一致)
|
# 尺寸比例(BADGE_SCALE 在 ozonSeller 的 0.15 基础上缩小 30%:用户反馈偏大)
|
||||||
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
|
BADGE_SCALE = 0.105 # 图片水印直径 / 图宽
|
||||||
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
|
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
|
||||||
TEXT_SCALE = 0.06 # 文字字号 / 图宽
|
TEXT_SCALE = 0.06 # 文字字号 / 图宽
|
||||||
TEXT_MIN_SIZE = 12
|
TEXT_MIN_SIZE = 12
|
||||||
|
|||||||
Reference in New Issue
Block a user