Files
pod_trend_agent/ui_app.py
T
3218485270 5ab5cf6586 v110-v112 自定义模式完善 + 模板导出增强 + 多模态兼容优化
- 自定义模式:分析模型输出 delta 唯一改动指令,生图模板 custom_image_prompt.md({delta} 占位符),不再使用负向提示词;generate_design 按 custom_mode 分支,Pinterest 模式保留原创化指令,两模式互不影响
- 多模态分析 response_format 三级回退(json_schema → json_object → none),兼容 DeepSeek
- 模板导出:details 扩展列(细节1/2/3)、target_audience 扩展列(适用人群1)、固定值风格1=休闲/风格2=运动
- 童装特征库更新 + 标题模板外部化 + 图源映射增强
2026-09-03 18:28:39 +08:00

1372 lines
64 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""POD 热点抓取 Agent —— 桌面 UITkinter 轻量版)。
功能(简洁版):
- 国家多选(US/GB/JP/AU
- LLM 后端选择(mock/static/openai_compat/openai/deepseek/qwen/moonshot
- 种子数量上限(style/related
- 运行:后台线程调用 graph.agent.run_countryprint 输出重定向到日志区
- 结果表格:国家/热点词/类别/风险/分数;双击看完整详情
- 打开产物目录
打包(PyInstaller):
pyinstaller -F -w ui_app.py --add-data "config.yaml;." --add-data "configs;configs" --add-data "prompts;prompts"
数据文件(config.yaml/configs/prompts)打进 _MEIPASSoutput/ 与 .cache/ 写在 exe 旁运行目录。
自检:python ui_app.py --self-test(无 GUI,跑通 GB mock 核心链路,验证打包环境)。
"""
import contextlib
import io
import json
import os
import queue
import shutil
import sys
import threading
import time
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, ttk, messagebox
import yaml
from graph.agent import run_country
COUNTRIES = ["US", "GB", "JP", "AU", "MX", "DE", "BR", "SA", "PL", "ES", "IT", "CA", "KR"]
def resource_root() -> Path:
"""数据根:开发=脚本目录;打包=_MEIPASSconfig.yaml/configs/prompts 解压处)。"""
if getattr(sys, "frozen", False):
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
return Path(__file__).resolve().parent
def runtime_root() -> Path:
"""运行根(可写):打包=exe 旁;开发=脚本目录。output/.cache 写这里。"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
OUTPUT_ROOT = runtime_root() / "output"
def config_root() -> Path:
"""配置生效根:exe 旁的可编辑副本优先(config.yaml 存在时),缺失则用内置默认(_MEIPASS)。
开发模式 = 脚本目录(即项目根,行为不变)。
"""
if getattr(sys, "frozen", False):
run = Path(sys.executable).resolve().parent
if (run / "config.yaml").exists():
return run
return Path(getattr(sys, "_MEIPASS", run))
return Path(__file__).resolve().parent
def ensure_defaults() -> None:
"""打包后首次运行:把内置默认 config.yaml/configs/prompts 复制到 exe 旁(已存在不覆盖),
用户可直接编辑这份默认配置;exe 内置的作为兜底。"""
if not getattr(sys, "frozen", False):
return
run = Path(sys.executable).resolve().parent
data = Path(getattr(sys, "_MEIPASS", run))
for rel in ("config.yaml", "configs", "prompts"):
src = data / rel
dst = run / rel
if src.exists() and not dst.exists():
try:
if src.is_dir():
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
print(f"[UI] 已生成默认配置: {dst}")
except Exception as e: # noqa: BLE001
print(f"[UI] 默认配置生成失败 {rel}: {e}")
def load_config() -> dict:
with open(config_root() / "config.yaml", encoding="utf-8") as f:
return yaml.safe_load(f)
def load_spus(country: str = None) -> list:
"""从 db 读可选 SPU 列表(可按国家过滤;UI 选品下拉用)。"""
try:
from graph.product import list_spus
pcfg = load_config().get("product") or {}
dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db"))
return list_spus(dbp, country) or []
except Exception:
return []
def load_colors(spu_code: str) -> list:
"""款号 → 颜色列表(UI 颜色下拉用)。"""
try:
from graph.product import list_colors
pcfg = load_config().get("product") or {}
dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db"))
return list_colors(dbp, spu_code) or []
except Exception:
return []
def resolve_data_path(rel: str) -> Path:
"""数据文件相对路径解析:exe 旁自定义优先 → _MEIPASS 内置兜底。"""
p = Path(rel)
if p.is_absolute():
return p
for root in (runtime_root(), resource_root()):
cand = root / p
if cand.exists():
return cand
return resource_root() / p
# 全局日志文件:UI 启动时创建(logs/<时间戳>.log),所有 print(采集+运行)同时落盘
LOG_FILE = None
class StdoutRedirector(io.TextIOBase):
"""把 print 输出转发到 UI 队列 + 写入全局日志文件(带时间戳,详细记录每步含错误)。"""
def __init__(self, q: queue.Queue):
self._q = q
def write(self, s: str) -> int:
if s:
self._q.put(("log", s))
if LOG_FILE:
try:
LOG_FILE.write(f"[{time.strftime('%H:%M:%S')}] {s}")
LOG_FILE.flush()
except Exception:
pass
return len(s)
def flush(self):
if LOG_FILE:
try:
LOG_FILE.flush()
except Exception:
pass
def apply_openai_cfg(config: dict, oai: dict) -> None:
"""把 UI 的 OpenAI 配置写入 llm_screen + compose。
- API Key 不在 UI 显示:始终从 config.yamlllm_screen.api_key / compose.api_key
或环境变量(LLM_API_KEY / OPENAI_API_KEY)读取。
- LLM 与图像的 Base URL 独立(生图模型可能走不同网关)。
"""
if not oai:
return
ls = config.setdefault("llm_screen", {})
cp = config.setdefault("compose", {})
if oai.get("llm_base_url"):
ls["base_url"] = oai["llm_base_url"]
if oai.get("img_base_url"):
cp["base_url"] = oai["img_base_url"]
if oai.get("llm_model"):
ls["model"] = oai["llm_model"]
if oai.get("image_model"):
cp["model"] = oai["image_model"]
def _apply_provider_mode(config: dict, provider: str) -> None:
"""OpenAI 模式开关:openai=全链路真 LLM/真生图;mock=全 mock 演示。
联动:seed_provider / llm_screen.provider / compose.backend / product.backend。
API Key 已在 config.yaml 或环境变量配置好(用户已配)。
"""
on = (provider or "").strip().lower() == "openai"
config["seed_provider"] = provider or "mock"
ls = config.setdefault("llm_screen", {})
ls["provider"] = "openai" if on else "mock"
cp = config.setdefault("compose", {})
cp["backend"] = "openai" if on else "mock"
pp = config.setdefault("product", {})
pp["backend"] = "openai" if on else "mock"
# Pinterest 参考模式:LLM(搜索词/图片分析)提供商跟随模式开关
pin = config.setdefault("pinterest", {})
pin["provider"] = "openai" if on else "mock"
print(f"[UI] 模式开关: {'OpenAI(真 LLM + 真生图)' if on else 'Mock(演示)'} | "
f"llm_screen={ls['provider']} compose={cp['backend']} product={pp['backend']} "
f"pinterest={pin['provider']}")
def fetch_keywords(country, provider, max_seeds, log_q, oai=None):
"""后台线程:仅跑「采集+去重过滤」链路(seed→fetch→filter),
把过滤后的关键词(数量不定)实时回传 UI 显示。"""
from graph.agent import build_country_config
from graph.nodes.fetch_node import fetch_node
from graph.nodes.filter_node import filter_node
from graph.nodes.seed_node import seed_node
config = load_config()
config["seed_provider"] = provider
cfg = config.setdefault("seed_provider_cfg", {})
cfg["max_seeds"] = max_seeds
apply_openai_cfg(config, oai)
_apply_provider_mode(config, provider) # OpenAI 模式开关(采集只用 seed,其余为后续统一)
old_stdout = sys.stdout
sys.stdout = StdoutRedirector(log_q)
try:
cc = build_country_config(config, country, config_root())
state = {
"country": country, "config": config, "country_config": cc,
"prompts_dir": str(config_root() / "prompts" / country),
"output_dir": str(OUTPUT_ROOT / country),
"raw_rows": [], "filtered_rows": [], "errors": [], "stats": {},
}
log_q.put(("log", f"\n===== 采集热点 {country}provider={provider}=====\n"))
st = seed_node(state)
state["country_config"] = st.get("country_config") or cc
state["stats"] = st.get("stats") or {}
state["errors"] = list(st.get("errors") or [])
ft = fetch_node(state)
state["raw_rows"] = ft.get("raw_rows") or []
state["errors"] += list(ft.get("errors") or [])
fl = filter_node(state)
kept = fl.get("filtered_rows") or []
# 去重(大小写不敏感,保留首次出现顺序)+ 限长
seen, keywords = set(), []
for r in kept:
t = (r.get("topic") or "").strip()
if not t or t.lower() in seen:
continue
seen.add(t.lower())
keywords.append({
"topic": t, "source": r.get("source", ""),
"kind": r.get("kind", ""), "raw_score": r.get("raw_score"),
})
log_q.put(("log", f"[{country}] 采集原始 {len(state['raw_rows'])} 条 → 规则过滤保留 {len(kept)} 条 → 去重后 {len(keywords)} 个关键词\n"))
if keywords:
# 采集成功 → ① 清除该国旧热点简报缓存(design_briefs.json),
# 下次「运行」走完整流水线用新采集热点重建简报(fetch 用 collected 缓存,不重复撞 Google
out_dir = OUTPUT_ROOT / country
cache_f = out_dir / "design_briefs.json"
if cache_f.exists():
try:
cache_f.unlink()
log_q.put(("log", f"[UI] 已清除 {country} 旧热点简报缓存,下次运行将使用新采集热点重建\n"))
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 清除旧热点缓存失败: {e}\n"))
# ② 把"过滤后"的关键词写入 collected_keywords.json(运行流水线 fetch 节点优先读取)
try:
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "collected_keywords.json").write_text(
json.dumps({"country": country,
"collected_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"keywords": keywords}, ensure_ascii=False, indent=2),
encoding="utf-8")
log_q.put(("log", f"[UI] 已缓存过滤后热点 {len(keywords)} 条(collected_keywords.json),运行将直接用\n"))
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 写入热点缓存失败: {e}\n"))
log_q.put(("fetched", {"country": country, "keywords": keywords}))
except Exception as e: # noqa: BLE001
import traceback
log_q.put(("log", f"采集失败: {e}\n{traceback.format_exc()}\n"))
log_q.put(("error", None))
finally:
sys.stdout = old_stdout
try:
_pack_cache(country, log_q) # 采集完成 → 自动打包缓存/去重数据
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))
def run_pipeline(countries, provider, max_seeds, log_q,
spu_code="", sku_code="", spu_count=0,
spu_tasks=None, oai=None, markup_percent=0.0, code_prefix="DG",
template_path=""):
"""后台线程:缓存热点产品流程(选定国家后不再全部重跑种子词/抓取)。
1) 有缓存热点(output/<国家>/design_briefs.json)→ 直接加载,按 SPU 数量取未用热点,
每款号一个热点,生成设计稿 → 三图合成 → 多模态标题 → 模板导出。
2) 无缓存 → 先跑一次完整流水线生成缓存,再继续。
spu_tasks: [{"spu": "DG004", "skus": "..."}] 多款号批量选品
spu_count: 款号数量上限(≥1;热点数按它来)
oai: {"llm_base_url","img_base_url","llm_model","image_model"}Key 从 config/env 读取)
markup_percent: 加价百分比(按 SKU 最低价,后续定价用)
code_prefix: 货号前缀(OSS 上传 key{国家}/{时间戳}/{前缀+3位计数}_{4位随机}.jpg000 起最多 999
template_path: 用户上传的自定义商品上传模板(xlsx 绝对路径),后续模板导出从此模板解析
"""
config = load_config()
config["seed_provider"] = provider
cfg = config.setdefault("seed_provider_cfg", {})
cfg["max_seeds"] = max_seeds
apply_openai_cfg(config, oai)
# OpenAI 模式开关:provider=openai → 全链路真 LLM/真生图;mock → 全 mock 演示
_apply_provider_mode(config, provider)
if spu_code or sku_code or spu_count or spu_tasks or markup_percent or code_prefix or template_path:
p = config.setdefault("product", {})
if spu_code:
p["spu_code"] = spu_code
if template_path:
p["template_path"] = template_path # 自定义模板:template_export 从此解析
if sku_code:
p["sku_code"] = sku_code
if spu_tasks:
p["spu_tasks"] = spu_tasks
p.pop("spu_code", None) # 任务清单优先
if spu_count:
p["spu_count"] = spu_count
if markup_percent:
p["markup_percent"] = markup_percent
if code_prefix:
p["code_prefix"] = code_prefix
# 任务扩展:每个「集合」(款-颜色集 + 独立数量 count)按自己数量复制 N 份(N=该集合设计数);
# 每份 skus 保留颜色集合(多色)→ 每个设计配全部颜色各出一张主图
if spu_tasks or spu_code:
base = [dict(t) for t in (spu_tasks or [{"spu": spu_code, "skus": sku_code}])]
tasks_list = []
for t in base:
n = int(t.get("count") or 0) or spu_count or 1 # 每集合独立数量,无则全局
for _ in range(n):
tt = dict(t)
tt.pop("count", None)
tasks_list.append(tt)
config.setdefault("product", {})["spu_tasks"] = tasks_list
if spu_count:
config.setdefault("product", {})["spu_count"] = spu_count
old_stdout = sys.stdout
sys.stdout = StdoutRedirector(log_q)
results = {}
task_ts = time.strftime("%Y%m%d%H%M%S") # 任务开始时间戳(OSS 路径段)
try:
from graph.product_batch import load_cached_briefs, run_product_batch
for c in countries:
log_q.put(("log", f"\n===== 开始处理 {c}(缓存热点模式)=====\n"))
# 只要有采集缓存(collected_keywords)或简报缓存 → 缓存模式:
# run_product_batch 内部简报不足会自动「从采集缓存生成简报」,绝不触发 seed/fetch/Google
has_cache = bool(load_cached_briefs(runtime_root() / "output" / c)) \
or (runtime_root() / "output" / c / "collected_keywords.json").exists()
if has_cache:
out = run_product_batch(c, config, config_root(), runtime_root(),
list(spu_tasks or []), spu_count, log_q,
task_timestamp=task_ts)
items = out.get("product") or []
errs = out.get("errors") or []
log_q.put(("log", f"[{c}] 缓存热点完成:{len(items)} 个产品(每个热点对应一款)\n"))
else:
log_q.put(("log", f"[{c}] 无任何采集缓存 → 走完整流水线(seed+fetch+screen,首次需 Google)…\n"))
state = run_country(c, config, config_root(), output_root=runtime_root(), task_timestamp=task_ts)
items = state.get("briefs", []) or []
errs = state.get("errors") or []
log_q.put(("log", f"[{c}] 完整流水线完成:简报 {len(items)} 条,兜底错误 {len(errs)}\n"))
results[c] = items
ts_dir = runtime_root() / "output" / countries[0] / task_ts
log_q.put(("log", f"\n✅ 任务完成,产物文件夹:{ts_dir}\n"
f" (缓存/去重记录在 {runtime_root() / 'output' / countries[0]} 根目录,不进任务文件夹)\n"))
log_q.put(("done", results))
except Exception as e: # noqa: BLE001
import traceback
log_q.put(("log", f"运行失败: {e}\n{traceback.format_exc()}\n"))
log_q.put(("error", None))
finally:
sys.stdout = old_stdout
try:
for c in countries:
_pack_cache(c, log_q) # 运行完成 → 自动打包缓存/去重数据
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))
def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count=0,
oai=None, markup_percent=0.0, code_prefix="DG", template_path="",
custom_image_dir=""):
"""Pinterest 参考模式后台线程:独立于 Google Trends 采集链路。
各国独立种子词池 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图
→ LLM 分析图片 → 设计简报 → 产品生成(设计稿/主图/种草图/模板导出)。
custom_image_dir 非空 → 自定义图片模式:不搜索不采集,把该文件夹有效图片直接送多模态分析,
沿用 Pinterest 后续所有步骤;数量硬校验(有效图片数 ≥ 选品清单总数)由 run_pinterest_ref 执行。
"""
if not spu_tasks:
log_q.put(("log", "[UI] Pinterest/自定义模式必须通过「+添加选品」添加任务清单后才能运行\n"))
log_q.put(("error", None))
return
config = load_config()
config["seed_provider"] = provider
apply_openai_cfg(config, oai)
_apply_provider_mode(config, provider) # 联动 pinterest.provider / compose / product backend
p = config.setdefault("product", {})
if template_path:
p["template_path"] = template_path
if spu_tasks:
p["spu_tasks"] = spu_tasks
p.pop("spu_code", None)
if spu_count:
p["spu_count"] = spu_count
if markup_percent:
p["markup_percent"] = markup_percent
if code_prefix:
p["code_prefix"] = code_prefix
# 自定义模式:把上传文件夹写入配置(run_pinterest_ref 据此进入 custom 图并校验)
if custom_image_dir:
config.setdefault("pinterest", {})["custom_image_dir"] = custom_image_dir
# 任务扩展(与 run_pipeline 一致):每个集合按自己数量复制 N 份
if spu_tasks:
tasks_list = []
for t in spu_tasks:
n = int(t.get("count") or 0) or spu_count or 1
for _ in range(n):
tt = dict(t)
tt.pop("count", None)
tasks_list.append(tt)
p["spu_tasks"] = tasks_list
if spu_count:
p["spu_count"] = spu_count
# 简报数上限联动:设计数 ≥ 任务数(否则任务会复用简报)
pcfg = config.setdefault("pinterest", {})
pcfg["max_designs"] = max(int(pcfg.get("max_designs", 10) or 10), int(spu_count or 1))
old_stdout = sys.stdout
sys.stdout = StdoutRedirector(log_q)
results = {}
task_ts = time.strftime("%Y%m%d%H%M%S")
try:
from graph.agent import run_pinterest_ref
for c in countries:
log_q.put(("log", f"\n===== Pinterest 参考模式 {c}SPU 数量 {spu_count}=====\n"))
state = run_pinterest_ref(c, config, config_root(), runtime_root(),
task_timestamp=task_ts, custom_image_dir=custom_image_dir)
items = state.get("product") or []
errs = state.get("errors") or []
log_q.put(("log", f"[{c}] Pinterest 参考完成:{len(items)} 个产品,兜底错误 {len(errs)}\n"))
results[c] = items
ts_dir = runtime_root() / "output" / countries[0] / task_ts
log_q.put(("log", f"\n✅ Pinterest 参考任务完成,产物文件夹:{ts_dir}\n"
f" (缓存/去重记录在 {runtime_root() / 'output' / countries[0]} 根目录,不进任务文件夹)\n"))
log_q.put(("done", results))
except Exception as e: # noqa: BLE001
import traceback
log_q.put(("log", f"运行失败: {e}\n{traceback.format_exc()}\n"))
log_q.put(("error", None))
finally:
sys.stdout = old_stdout
try:
for c in countries:
_pack_cache(c, log_q) # 运行完成 → 自动打包缓存/去重数据
except Exception as e: # noqa: BLE001
log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))
def _pack_cache(country: str, log_q=None) -> str:
"""运行/采集完成后,把该国缓存+去重数据打包成 zipoutput/cache_packs/<国>_<时间戳>.zip),
内含 design_briefs / used_designs / collected_keywords / products.json + .cache 关键缓存,
方便直接查看缓存与去重效果。"""
import zipfile
src = OUTPUT_ROOT / country
pack_dir = OUTPUT_ROOT / "cache_packs"
pack_dir.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
zip_path = pack_dir / f"{country}_{stamp}.zip"
files = [
("design_briefs.json", "简报(热点+风格+提示词)"),
("used_designs.json", "已用记录(去重)"),
("collected_keywords.json", "采集过滤结果"),
("product/products.json", "产品产物清单"),
]
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for rel, _ in files:
p = src / rel
if p.exists():
zf.write(p, f"output/{country}/{rel}")
# .cache 关键缓存(种子/抓取,各取最新)
cache_root = runtime_root() / ".cache"
for sub in ("seeds", "google_trends"):
d = cache_root / sub
if not d.exists():
continue
picks = sorted(d.glob(f"*{country}*")) or []
if sub == "google_trends":
picks = sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:10]
for f in picks:
try:
zf.write(f, f".cache/{sub}/{f.name}")
except Exception:
pass
if log_q:
log_q.put(("log", f"[UI] 缓存打包完成: {zip_path}\n"))
return str(zip_path)
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("POD 智能设计 Agent")
self.geometry("1440x860")
self.minsize(1200, 720)
self.configure(bg="#F2F4F8")
self._q = queue.Queue()
self._busy = False
self._results = {}
# 运行日志落盘:logs/<时间戳>.logexe 旁 logs 目录),记录本次打开软件的所有日志
global LOG_FILE
try:
log_dir = runtime_root() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
LOG_FILE = open(log_dir / f"{time.strftime('%Y%m%d_%H%M%S')}.log",
"a", encoding="utf-8", buffering=1)
except Exception as e: # noqa: BLE001
LOG_FILE = None
print(f"[UI] 日志文件创建失败(不影响运行): {e}")
self._setup_style()
self._build_ui()
self._load_cache()
self.after(100, self._poll)
# ---------- 主题样式 ----------
def _setup_style(self):
style = ttk.Style(self)
try:
style.theme_use("clam")
except Exception: # noqa: BLE001
pass
# 主色板
self._C_PRIMARY = "#2563EB" # 主蓝
self._C_PRIMARY_DK = "#1D4ED8" # 主蓝深
self._C_ACCENT = "#F59E0B" # 强调橙
self._C_BG = "#F2F4F8" # 窗口背景
self._C_CARD = "#FFFFFF" # 卡片背景
self._C_BORDER = "#DCE1EA" # 边框
self._C_TEXT = "#1F2937" # 主文字
self._C_SUB = "#6B7280" # 次要文字
self._C_OK = "#16A34A" # 成功绿
self._C_WARN = "#B45309" # 警示
self._FONT = ("Microsoft YaHei UI", 10)
self._FONT_SM = ("Microsoft YaHei UI", 9)
self._FONT_TITLE = ("Microsoft YaHei UI", 15, "bold")
self._FONT_SUB = ("Microsoft YaHei UI", 9)
style.configure(".", font=self._FONT, background=self._C_BG,
foreground=self._C_TEXT)
style.configure("Card.TLabelframe", background=self._C_CARD,
bordercolor=self._C_BORDER, relief="solid", borderwidth=1)
style.configure("Card.TLabelframe.Label", background=self._C_CARD,
foreground=self._C_PRIMARY_DK, font=("Microsoft YaHei UI", 10, "bold"))
style.configure("Card.TFrame", background=self._C_CARD)
style.configure("TLabel", background=self._C_BG, foreground=self._C_TEXT)
style.configure("Card.TLabel", background=self._C_CARD, foreground=self._C_TEXT)
style.configure("Card.TRadiobutton", background=self._C_CARD, foreground=self._C_TEXT)
style.map("Card.TRadiobutton",
background=[("active", self._C_CARD)],
foreground=[("active", self._C_PRIMARY_DK)])
style.configure("Sub.TLabel", background=self._C_BG, foreground=self._C_SUB,
font=self._FONT_SM)
style.configure("CardSub.TLabel", background=self._C_CARD, foreground=self._C_SUB,
font=self._FONT_SM)
style.configure("Accent.TLabel", background=self._C_BG, foreground=self._C_PRIMARY_DK,
font=self._FONT_SM)
style.configure("TButton", font=self._FONT, padding=(12, 6))
style.configure("Primary.TButton", font=self._FONT, padding=(18, 8),
background=self._C_PRIMARY, foreground="#FFFFFF", borderwidth=0)
style.map("Primary.TButton",
background=[("active", self._C_PRIMARY_DK), ("disabled", "#A5B4FC")],
foreground=[("disabled", "#EEF2FF")])
style.configure("AccentBtn.TButton", font=self._FONT, padding=(14, 7),
background=self._C_ACCENT, foreground="#FFFFFF", borderwidth=0)
style.map("AccentBtn.TButton",
background=[("active", "#D97706"), ("disabled", "#FCD34D")],
foreground=[("disabled", "#FFFBEB")])
style.configure("TCombobox", padding=4)
style.configure("TEntry", padding=4)
style.configure("Treeview", font=self._FONT_SM, rowheight=26,
background="#FFFFFF", fieldbackground="#FFFFFF",
bordercolor=self._C_BORDER)
style.configure("Treeview.Heading", font=("Microsoft YaHei UI", 9, "bold"),
background="#EEF2F7", foreground=self._C_TEXT, padding=6)
style.map("Treeview", background=[("selected", "#DBEAFE")],
foreground=[("selected", "#1E3A8A")])
style.configure("TNotebook", background=self._C_BG, borderwidth=0)
style.configure("TNotebook.Tab", font=self._FONT, padding=(14, 7))
style.map("TNotebook.Tab",
background=[("selected", self._C_CARD)],
foreground=[("selected", self._C_PRIMARY_DK)])
# ---------- UI 构建 ----------
def _build_ui(self):
# —— 顶部标题栏 ——
header = tk.Frame(self, bg=self._C_PRIMARY, height=62)
header.pack(fill="x")
header.pack_propagate(False)
tk.Label(header, text="🎨 智能设计 Agent",
bg=self._C_PRIMARY, fg="#FFFFFF",
font=("Microsoft YaHei UI", 16, "bold")).pack(side="left", padx=16, pady=12)
tk.Label(header, text="一键生成 T 恤印花设计 · 多国多站点",
bg=self._C_PRIMARY, fg="#DBEAFE",
font=self._FONT_SM).pack(side="left", padx=(4, 0), pady=12)
self.hotspot_time_var = tk.StringVar(value="")
tk.Label(header, textvariable=self.hotspot_time_var,
bg=self._C_PRIMARY, fg="#DBEAFE",
font=self._FONT_SM).pack(side="right", padx=(0, 8), pady=12)
# —— 运行设置卡片:国家 / 流程 / 自定义图片 / 种子数量 ——
cfg = ttk.LabelFrame(self, text="运行设置", style="Card.TLabelframe", padding=10)
cfg.pack(fill="x", padx=12, pady=(10, 4))
ttk.Label(cfg, text="目标国家:", style="Card.TLabel").pack(side="left")
self.country_var = tk.StringVar(value="US")
self.country_combo = ttk.Combobox(cfg, textvariable=self.country_var,
values=COUNTRIES, state="readonly", width=6)
self.country_combo.pack(side="left", padx=(4, 14))
self.country_combo.bind("<<ComboboxSelected>>", lambda e: self._on_country_selected())
ttk.Label(cfg, text="流程:", style="Card.TLabel").pack(side="left")
self.flow_var = tk.StringVar(value="pinterest")
ttk.Radiobutton(cfg, text="热点采集", value="trends", style="Card.TRadiobutton",
variable=self.flow_var).pack(side="left", padx=2)
ttk.Radiobutton(cfg, text="Pinterest 参考", value="pinterest", style="Card.TRadiobutton",
variable=self.flow_var).pack(side="left", padx=2)
ttk.Radiobutton(cfg, text="自定义图片", value="custom", style="Card.TRadiobutton",
variable=self.flow_var).pack(side="left", padx=2)
ttk.Button(cfg, text="📁 选图片文件夹…", command=self._choose_custom_dir).pack(side="left", padx=(10, 0))
self.custom_dir_var = tk.StringVar(value="")
self.custom_dir_name_var = tk.StringVar(value="")
ttk.Label(cfg, textvariable=self.custom_dir_name_var, anchor="w",
style="Accent.TLabel").pack(side="left", padx=(6, 14))
ttk.Label(cfg, text="种子数量:", style="Card.TLabel").pack(side="left")
self.seed_var = tk.StringVar(value="24")
ttk.Entry(cfg, textvariable=self.seed_var, width=5).pack(side="left", padx=(4, 0))
# —— 商品上传模板卡片 ——
tpl = ttk.LabelFrame(self, text="商品上传模板(必须上传 .xlsx 后才能运行)",
style="Card.TLabelframe", padding=8)
tpl.pack(fill="x", padx=12, pady=(4, 4))
ttk.Button(tpl, text="📁 选择模板…", command=self._choose_template).pack(side="left")
self.template_path_var = tk.StringVar(value="")
self.template_name_var = tk.StringVar(value="(未选择)")
ttk.Label(tpl, textvariable=self.template_name_var, anchor="w",
style="CardSub.TLabel").pack(side="left", padx=(10, 0))
# —— 选品卡片:款号 → 颜色 → 添加任务 ——
sel = ttk.LabelFrame(self, text="选品(选定国家自动加载款号;款号 → 颜色 → +添加选品)",
style="Card.TLabelframe", padding=10)
sel.pack(fill="x", padx=12, pady=(4, 4))
# 左:款号列表
lf1 = ttk.Frame(sel, style="Card.TFrame")
lf1.pack(side="left", padx=4)
ttk.Label(lf1, text="款号(多选)", style="Card.TLabel").pack(anchor="w")
self.spu_list = tk.Listbox(lf1, selectmode="extended", height=6, width=16,
exportselection=False, bg="#FFFFFF", fg=self._C_TEXT,
selectbackground="#DBEAFE", selectforeground="#1E3A8A",
highlightthickness=1, highlightbackground=self._C_BORDER,
highlightcolor=self._C_PRIMARY, font=self._FONT_SM,
relief="solid", borderwidth=1)
self.spu_list.pack(side="left", pady=(4, 0))
self.spu_list.bind("<<ListboxSelect>>", self._on_spu_selected)
self.min_price_var = tk.StringVar(value="")
ttk.Label(lf1, textvariable=self.min_price_var, anchor="w",
style="CardSub.TLabel").pack(fill="x", pady=(4, 0))
# 中:颜色多选 + 添加按钮
lf2 = ttk.Frame(sel, style="Card.TFrame")
lf2.pack(side="left", padx=(12, 4))
ttk.Label(lf2, text="当前款号颜色(点击多选)", style="Card.TLabel").pack(anchor="w")
self.sku_list = tk.Listbox(lf2, selectmode="multiple", height=6, width=38,
exportselection=False, bg="#FFFFFF", fg=self._C_TEXT,
selectbackground="#DBEAFE", selectforeground="#1E3A8A",
highlightthickness=1, highlightbackground=self._C_BORDER,
highlightcolor=self._C_PRIMARY, font=self._FONT_SM,
relief="solid", borderwidth=1)
self.sku_list.pack(side="left", pady=(4, 0))
b2 = ttk.Frame(lf2, style="Card.TFrame")
b2.pack(side="left", padx=6)
ttk.Button(b2, text="全选", command=self._select_all_colors).pack(fill="x")
ttk.Button(b2, text="清空", command=lambda: self.sku_list.selection_clear(0, "end")).pack(fill="x", pady=2)
ttk.Button(b2, text="+添加选品", style="AccentBtn.TButton",
command=self._add_task).pack(fill="x", pady=(4, 0))
ttk.Label(b2, text="该款设计数:", style="CardSub.TLabel").pack(fill="x", pady=(6, 0))
self.count_var = tk.StringVar(value="10")
ttk.Entry(b2, textvariable=self.count_var, width=6).pack(fill="x")
# 右:任务清单
lf3 = ttk.Frame(sel, style="Card.TFrame")
lf3.pack(side="left", padx=4)
ttk.Label(lf3, text="选品任务清单", style="Card.TLabel").pack(anchor="w")
self.task_stat_var = tk.StringVar(value="0 个任务")
ttk.Label(lf3, textvariable=self.task_stat_var, anchor="w",
style="Accent.TLabel").pack(anchor="w")
self.task_list = tk.Listbox(lf3, height=6, width=44, exportselection=False,
bg="#FFFFFF", fg=self._C_TEXT,
selectbackground="#DBEAFE", selectforeground="#1E3A8A",
highlightthickness=1, highlightbackground=self._C_BORDER,
highlightcolor=self._C_PRIMARY, font=self._FONT_SM,
relief="solid", borderwidth=1)
self.task_list.pack(side="left", pady=(4, 0))
b3 = ttk.Frame(lf3, style="Card.TFrame")
b3.pack(side="left", padx=6)
ttk.Button(b3, text="删选中", command=self._del_task).pack(fill="x")
ttk.Button(b3, text="清空", command=self._clear_tasks).pack(fill="x", pady=2)
# 右侧:加价 / 货号前缀
lf4 = ttk.Frame(sel, style="Card.TFrame")
lf4.pack(side="left", padx=(16, 0))
ttk.Label(lf4, text="加价 %", style="Card.TLabel").pack(anchor="w")
self.markup_var = tk.StringVar(value="0")
ttk.Entry(lf4, textvariable=self.markup_var, width=7).pack(anchor="w", pady=(4, 0))
ttk.Label(lf4, text="按 SKU 最低价加价", style="CardSub.TLabel").pack(anchor="w", pady=(4, 0))
lf5 = ttk.Frame(sel, style="Card.TFrame")
lf5.pack(side="left", padx=(16, 0))
ttk.Label(lf5, text="货号前缀", style="Card.TLabel").pack(anchor="w")
self.code_prefix_var = tk.StringVar(value="DG")
ttk.Entry(lf5, textvariable=self.code_prefix_var, width=8).pack(anchor="w", pady=(4, 0))
ttk.Label(lf5, text="前缀+3位计数(000起)", style="CardSub.TLabel").pack(anchor="w", pady=(4, 0))
self._sku_map = {}
self._tasks: list = []
self._reload_spus()
# —— 操作栏 ——
bar = ttk.Frame(self, padding=(12, 6))
bar.pack(fill="x")
self.run_btn = ttk.Button(bar, text="▶ 运行", style="Primary.TButton", command=self._on_run)
self.run_btn.pack(side="left")
self.fetch_btn = ttk.Button(bar, text="📥 采集热点", command=self._on_fetch)
self.fetch_btn.pack(side="left", padx=(10, 0))
ttk.Button(bar, text="打开产物目录", command=self._open_output).pack(side="left", padx=(10, 0))
ttk.Button(bar, text="打开日志目录", command=self._open_logs).pack(side="left", padx=(10, 0))
ttk.Button(bar, text="清空日志", command=self._clear_log).pack(side="left", padx=(10, 0))
# —— 运行日志(深色终端风格)——
log_card = ttk.LabelFrame(self, text="运行日志", style="Card.TLabelframe", padding=6)
log_card.pack(fill="x", padx=12, pady=(4, 4))
self.log_text = tk.Text(log_card, height=7, state="disabled", wrap="word",
bg="#0F172A", fg="#E2E8F0", font=("Consolas", 9),
relief="flat", padx=8, pady=6, insertbackground="#E2E8F0")
self.log_text.pack(fill="both", expand=True)
# —— 设计简报表格 ——
tree_card = ttk.LabelFrame(self, text="设计简报(双击查看完整提示词)",
style="Card.TLabelframe", padding=6)
tree_card.pack(fill="both", expand=True, padx=12, pady=(4, 10))
cols = ("country", "topic", "category", "risk", "score")
self.tree = ttk.Treeview(tree_card, columns=cols, show="headings", height=12)
for col, title, w, anchor in zip(
cols, ["国家", "热点词", "类别", "风险", "分数"],
[70, 420, 180, 90, 80],
["center", "w", "w", "center", "center"],
):
self.tree.heading(col, text=title)
self.tree.column(col, width=w, anchor=anchor)
self.tree.pack(fill="both", expand=True)
self.tree.bind("<Double-1>", self._show_detail)
def _log(self, s: str):
self.log_text.config(state="normal")
self.log_text.insert("end", s)
self.log_text.see("end")
self.log_text.config(state="disabled")
def _reload_spus(self):
"""按当前所选国家加载该国款号到款号列表,并刷新颜色。"""
country = self.country_var.get()
self._spu_list = load_spus(country)
self.spu_list.delete(0, "end")
for s in self._spu_list:
self.spu_list.insert("end", s["code"])
if self._spu_list:
self.spu_list.selection_set(0)
self._on_spu_selected()
def _on_country_selected(self):
"""切换国家:刷新该国款号 + 重新加载该国热点缓存。"""
self._reload_spus()
self._load_cache()
def _on_spu_selected(self, _evt=None):
sel = self.spu_list.curselection()
if not sel:
self.sku_list.delete(0, "end")
self.min_price_var.set("")
return
spu = self._spu_list[sel[0]]["code"] if sel[0] < len(self._spu_list) else None
if not spu:
return
colors = load_colors(spu)
self._sku_map = {c["sku_code"]: c for c in colors}
self.sku_list.delete(0, "end")
for c in colors:
price = c.get("price")
price_txt = f" ¥{price:g}" if price not in (None, "") else ""
self.sku_list.insert("end", f"{c['sku_code']} {c.get('color', '')}{price_txt}")
# 最低价标注(该款号所有颜色 SKU 中的最低价)
prices = [float(c["price"]) for c in colors if c.get("price") not in (None, "")]
if prices:
self.min_price_var.set(f"最低价: ¥{min(prices):g}")
else:
self.min_price_var.set("")
if colors:
self.sku_list.selection_set(0) # 默认选中第一个颜色
def _add_task(self):
"""把当前选中的【款号】加入任务清单(可多选、可重复新增)。
每个款号一条「集合」:skus=选中颜色集合(多色用逗号分隔),
count=当前数量输入框值(每集合独立数量,= 该集合的 SPU/设计数)。"""
sel_idx = self.spu_list.curselection()
if not sel_idx:
messagebox.showwarning("提示", "请先选中至少一个款号(可 Ctrl/Shift 多选)")
return
sel_spus = []
for i in sel_idx:
if i < len(self._spu_list):
sel_spus.append(self._spu_list[i]["code"])
if not sel_spus:
return
try:
per_count = max(int(self.count_var.get() or 0), 1)
except ValueError:
per_count = 1
raw_colors = [self.sku_list.get(i).split(" ")[0] for i in self.sku_list.curselection()]
if not raw_colors:
raw_colors = [self._sku_map and next(iter(self._sku_map))]
if not raw_colors[0]:
messagebox.showwarning("提示", "当前款号无颜色数据")
return
color_codes = [c.split("-")[-1] for c in raw_colors] # 颜色码(如 BL01
for spu in sel_spus:
colors_of_spu = load_colors(spu) or []
skus = []
for cc in color_codes:
m = next((c["sku_code"] for c in colors_of_spu
if c["sku_code"].split("-")[-1] == cc), None)
skus.append(m or (colors_of_spu[0]["sku_code"] if colors_of_spu else cc))
# 每款一条「集合」(可重复新增),颜色集合逗号分隔 + 独立数量
self._tasks.append({"spu": spu, "skus": ",".join(skus), "count": per_count})
self._refresh_tasks()
def _upsert_task(self, spu: str, skus: str):
for t in self._tasks:
if t["spu"] == spu:
t["skus"] = skus
break
else:
self._tasks.append({"spu": spu, "skus": skus})
self._refresh_tasks()
def _del_task(self):
sel = self.task_list.curselection()
if not sel:
return
for i in reversed(sel):
self._tasks.pop(i)
self._refresh_tasks()
def _clear_tasks(self):
self._tasks = []
self._refresh_tasks()
def _refresh_tasks(self):
self.task_list.delete(0, "end")
for t in self._tasks:
skus = t["skus"] or "(首色)"
cnt = t.get("count") or 0
cnt_txt = f" × 设计数{cnt}" if cnt else ""
self.task_list.insert("end", f"{t['spu']}{skus}{cnt_txt}")
# 显示当前任务统计(集合数 × 各自数量 = 总 SPU/设计数)
total = sum(int(t.get("count") or 0) for t in self._tasks)
self.task_stat_var.set(f"{len(self._tasks)} 个集合 · 总设计数 {total}(每集合独立数量)")
def _select_all_colors(self):
self.sku_list.selection_set(0, "end")
def _load_cache(self):
"""前台热点显示(两个独立配置文件):
① collected_keywords.json = 完整采集池(全量,不随用量减少)
② used_designs.json = 已用记录 → 已用热点从显示列表移除(用完即消失)
③ design_briefs.json 只是运行简报(不决定前台显示),无 collected 时兜底。"""
c = self.country_var.get()
self._results = {}
out_dir = OUTPUT_ROOT / c
ga = ""
# ① 完整采集池 → 剔除已用 → 显示"可用热点"
try:
cp = out_dir / "collected_keywords.json"
if cp.exists():
cdata = json.loads(cp.read_text(encoding="utf-8"))
kws = cdata.get("keywords") or []
# 已用 topic 集合(used_designs.json
used_topics = set()
up = out_dir / "used_designs.json"
if up.exists():
try:
ud = json.loads(up.read_text(encoding="utf-8")).get("used", []) or []
used_topics = {str(u.get("topic", "")).strip().lower() for u in ud}
except Exception:
pass
avail = [k for k in kws
if str(k.get("topic", "")).strip().lower() not in used_topics]
self._results[c] = [
{"topic": k.get("topic", ""),
"source": k.get("source", "collected"),
"raw_score": k.get("raw_score")} for k in avail
]
ga = (f"{str(cdata.get('collected_at', ''))}"
f"(可用 {len(avail)} / 采集 {len(kws)},已用 {len(used_topics)}")
except Exception:
pass
# ② 兜底:无 collected 时读简报
if not self._results.get(c):
try:
p = out_dir / "design_briefs.json"
if p.exists():
data = json.loads(p.read_text(encoding="utf-8"))
self._results[c] = data.get("design_briefs", []) or []
ga = str(data.get("generated_at", ""))
except Exception:
pass
self._refresh_table()
total = sum(len(x) for x in self._results.values())
if total:
self.hotspot_time_var.set(f"可用热点 {total} 条 · {ga or '时间未知'}")
self._log(f"[UI] 已加载 {c} 可用热点 {total} 条({ga or '未知'}\n")
else:
self.hotspot_time_var.set(f"{c} 无热点(点「📥 采集热点」)")
# ---------- 采集(独立步骤)----------
def _oai_cfg(self) -> dict:
"""前端不再配置 OpenAI:所有 key/URL/模型从 config.yaml 读取(返回空,不覆盖配置)。"""
return {}
def _on_fetch(self):
"""仅采集+去重过滤热点关键词,直接显示在结果列表。"""
if self._busy:
return
try:
ms = int(self.seed_var.get())
except ValueError:
messagebox.showerror("参数错误", "种子数量必须是数字")
return
try:
int(self.count_var.get())
except ValueError:
messagebox.showerror("参数错误", "数量上限必须是数字")
return
self._clear_log()
self._results = {}
self._busy = True
self.fetch_btn.config(state="disabled", text="采集中…")
self.run_btn.config(state="disabled")
threading.Thread(
target=fetch_keywords,
args=(self.country_var.get(), "openai", ms, self._q,
self._oai_cfg()),
daemon=True,
).start()
# ---------- 运行 ----------
def _choose_custom_dir(self):
"""选择自定义模式图片文件夹,并立即统计有效图片数量(供数量校验提示)。"""
path = filedialog.askdirectory(title="选择自定义图片文件夹(直接送多模态分析)")
if not path:
return
try:
from graph.pinterest import list_valid_images
imgs = list_valid_images(path)
n = len(imgs)
except Exception: # noqa: BLE001
n = 0
self.custom_dir_var.set(path)
if n == 0:
self.custom_dir_name_var.set(f"已选: {Path(path).name}0 张有效图片!)")
else:
self.custom_dir_name_var.set(f"已选: {Path(path).name}{n} 张有效图片)")
self._log(f"[UI] 自定义图片文件夹已选择: {path}(有效图片 {n} 张,jpg/jpeg/png/webp\n")
def _choose_template(self):
"""选择自定义商品上传模板(.xlsx),后续 template_export 从该模板解析。
导入时校验「经营站点」是否与当前国家一致(如 JP → 日本站),不一致则导入失败。"""
path = filedialog.askopenfilename(
title="选择商品上传模板",
filetypes=[("Excel 模板", "*.xlsx"), ("Excel 文件", "*.xls")],
)
if not path:
return
# 站点校验:模板第 1-2 行含「经营站点」+ 站点名(美国站/日本站/英国站/澳洲站…)
site_ok, site_name = self._check_template_site(path)
if not site_ok:
messagebox.showerror(
"模板导入失败",
f"模板经营站点「{site_name or '未知'}」与当前国家「{self.country_var.get()}」不一致,请重新导入对应站点模板。",
)
self._log(f"[UI] 模板导入失败:站点「{site_name or '未知'}」≠ 国家 {self.country_var.get()}\n")
return
self.template_path_var.set(path)
self.template_name_var.set(f"已选: {Path(path).name}")
self._log(f"[UI] 商品上传模板已选择: {path}{site_name}\n")
def _check_template_site(self, path: str):
"""读取模板经营站点(A1=经营站点 → A2=站点名),返回 (是否匹配当前国家, 站点名)。"""
# 国家 → 允许的站点关键词
country_sites = {
"US": ("美国站", "美国"),
"GB": ("英国站", "英国"),
"JP": ("日本站", "日本"),
"AU": ("澳大利亚站", "澳洲站", "澳洲"),
"MX": ("墨西哥站", "墨西哥"),
"DE": ("德国站", "德国"),
"BR": ("巴西站", "巴西"),
"SA": ("沙特站", "沙特阿拉伯站", "沙特阿拉伯", "沙特"),
"PL": ("波兰站", "波兰"),
"ES": ("西班牙站", "西班牙"),
"IT": ("意大利站", "意大利"),
"CA": ("加拿大站", "加拿大"),
"KR": ("韩国站", "韩国"),
}
allowed = country_sites.get(str(self.country_var.get()).upper(), ())
site_name = ""
try:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = None
for _sn in wb.sheetnames:
if "模版" in _sn or "模板" in _sn:
ws = wb[_sn]
break
if ws is None:
ws = wb.worksheets[0]
# 定位「经营站点」表头所在列(第 1 行),站点名从第 2 行起逐行读取(模板可含多个站点,
# 如德国站/法国站/意大利站…,只要当前国家命中其中任意一个即视为匹配)
site_col = None
for c in range(1, 6):
v = ws.cell(1, c).value
if v and "站点" in str(v):
site_col = c
break
if site_col is not None:
sites = []
r = 2
while True:
v = ws.cell(r, site_col).value
if not v or not str(v).strip():
break
s = str(v).strip()
# 已离开元信息区(遇到分组行/列名行/说明文字/数据区层级)→ 停止读取站点
if any(k in s for k in ("基础信息", "商品层级", "必填", "spu", "sku")):
break
sites.append(s)
r += 1
if sites:
site_name = "、".join(sites)
wb.close()
except Exception: # noqa: BLE001
return True, "" # 读不到站点信息 → 放行(不阻塞)
if not site_name:
return True, "" # 模板无站点信息 → 放行
if any(any(k in s for k in allowed) for s in site_name.split("、")):
return True, site_name
return False, site_name
def _missing_basemaps(self, tasks):
"""任务开始前校验 basemap/<款号>/<颜色>/ 是否存在底图;返回缺失的 (款号/颜色) 列表。"""
cfg = {}
try:
import yaml as _yaml
cfg = _yaml.safe_load((config_root() / "config.yaml").read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
pass
bm_rel = (cfg.get("product") or {}).get("basemap_dir", "basemap")
bm_root = None
for r in (runtime_root(), config_root()):
cand = r / bm_rel
if cand.exists():
bm_root = cand
break
if bm_root is None:
bm_root = config_root() / bm_rel
def _norm(s: str) -> str:
return str(s or "").replace(" ", "").replace(" ", "").strip().lower()
missing = []
for t in tasks:
spu = str(t.get("spu", ""))
for sc in str(t.get("skus") or "").split(","):
sc = sc.strip()
if not sc:
continue
# SKU code 与文件夹名两边都去空格后再比较(兼容 db code 或文件夹名带空格)
spu_dir = bm_root / spu
if not spu_dir.is_dir():
missing.append(f"{spu}/{sc}")
continue
target = _norm(sc)
found = False
for cand in spu_dir.iterdir():
if (cand.is_dir() and _norm(cand.name) == target
and any(p.is_file() for p in cand.iterdir())):
found = True
break
if not found:
missing.append(f"{spu}/{sc}")
return missing
def _on_run(self):
if self._busy:
return
if not self.template_path_var.get().strip():
messagebox.showwarning("提示", "请先上传商品上传模板(📁 选择模板…)后才能开始任务")
return
countries = [self.country_var.get()]
try:
ms = int(self.seed_var.get())
spu_count = int(self.count_var.get())
markup = float(self.markup_var.get())
except ValueError:
messagebox.showerror("参数错误", "种子数量 / 款号数量 / 加价% 必须是数字")
return
if spu_count < 1:
messagebox.showerror("参数错误", "款号数量必须 ≥ 1(不支持无上限)")
return
if markup < 0:
messagebox.showerror("参数错误", "加价%不能为负数")
return
# 任务清单必须通过「+添加选品」显式添加后才允许运行;
# 不允许用当前选中款号+颜色兜底(否则未添加选品也能启动,会按数量扩展生成大量简报)。
tasks = list(self._tasks)
if not tasks:
messagebox.showwarning("提示", "请先添加选品(款号 → 颜色 → +添加选品)")
return
# ① 兜底校验:任务开始前检查所有款号颜色的 basemap 底图是否存在;
# 存在缺失 → 直接报错提醒,不开始任何任务
missing = self._missing_basemaps(tasks)
if missing:
messagebox.showerror(
"底图缺失,任务未开始",
"以下款号颜色缺少底图(basemap/<款号>/<颜色>/ 无图片):\n\n "
+ "\n ".join(missing[:10])
+ ("\n …" if len(missing) > 10 else "")
+ "\n\n请补齐底图后重新运行(本次未执行任何任务)。",
)
self._log("[UI] 底图缺失校验未通过,任务未开始: " + ", ".join(missing[:10]) + "\n")
return
self._clear_log()
self._results = {}
self._busy = True
self.run_btn.config(state="disabled", text="运行中…")
self.fetch_btn.config(state="disabled")
flow = self.flow_var.get()
if flow in ("pinterest", "custom"):
# Pinterest 参考 / 自定义图片:独立于 Google Trends 采集链路
custom_dir = self.custom_dir_var.get().strip()
if flow == "custom":
# —— 自定义图片模式硬校验:必须先选文件夹;有效图片数 ≥ 选品清单总数 ——
if not custom_dir:
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal")
messagebox.showwarning("自定义图片模式", "请先点击「📁 选图片文件夹…」选择上传的图片文件夹")
return
try:
from graph.pinterest import list_valid_images, validate_custom_images
# 选品清单总数 = 每个任务按 count 扩展后的求和(与 run_pinterest_pipeline 一致)
expanded = sum(int(t.get("count") or 0) or spu_count or 1 for t in tasks)
ok, valid_n, msg = validate_custom_images(custom_dir, expanded)
except Exception as e: # noqa: BLE001
ok, valid_n, msg = False, 0, f"校验失败: {e}"
if not ok:
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal")
messagebox.showerror("自定义图片数量校验未通过", msg)
self._log("[UI] 自定义图片数量校验未通过,任务未运行: " + msg + "\n")
return
self._log(f"[UI] 自定义图片数量校验通过:有效图片 {valid_n} 张 ≥ 选品清单 {expanded}\n")
threading.Thread(
target=run_pinterest_pipeline,
args=(countries, "openai", self._q,
tasks, spu_count, self._oai_cfg(), markup,
self.code_prefix_var.get().strip() or "DG",
self.template_path_var.get().strip(),
custom_dir if flow == "custom" else ""),
daemon=True,
).start()
else:
threading.Thread(
target=run_pipeline,
args=(countries, "openai", ms, self._q,
"", "", spu_count, tasks, self._oai_cfg(), markup,
self.code_prefix_var.get().strip() or "DG",
self.template_path_var.get().strip()),
daemon=True,
).start()
def _poll(self):
try:
n = 0
while True:
kind, payload = self._q.get_nowait()
if kind == "log":
self.log_text.config(state="normal")
self.log_text.insert("end", payload)
self.log_text.see("end")
self.log_text.config(state="disabled")
n += 1
if n >= 200: # 大批量日志时分批刷新,避免 UI 卡顿导致显示延迟
self.log_text.update_idletasks()
n = 0
elif kind == "error":
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal", text="📥 采集热点")
elif kind == "fetched":
self._results = {payload.get("country"): payload.get("keywords") or []}
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal", text="📥 采集热点")
self._refresh_table()
now = time.strftime("%Y-%m-%d %H:%M:%S")
self.hotspot_time_var.set(f"采集成功 {now} · {len(payload.get('keywords') or [])} 条")
self._log(f"[UI] 采集完成:{len(payload.get('keywords') or [])} 个关键词,时间 {now}\n")
messagebox.showinfo("采集完成",
f"热点采集成功:{len(payload.get('keywords') or [])} 个关键词\n时间:{now}")
elif kind == "done":
self._results = payload or {}
self._busy = False
self.run_btn.config(state="normal", text="▶ 运行")
self.fetch_btn.config(state="normal", text="📥 采集热点")
self._refresh_table()
self._load_cache() # 运行完成 → 刷新前台:用掉的热点从列表移除
except queue.Empty:
pass
self.after(100, self._poll)
# ---------- 结果 ----------
def _refresh_table(self):
self.tree.delete(*self.tree.get_children())
for c, briefs in self._results.items():
for b in briefs:
score = b.get("score")
self.tree.insert("", "end", values=(
c, b.get("topic", ""),
b.get("design_category") or b.get("kind") or b.get("spu_code") or "(采集)",
b.get("risk_level") or "-",
round(float(score), 3) if score not in (None, "") else "-"))
def _show_detail(self, _evt):
sel = self.tree.selection()
if not sel:
return
country, topic = self.tree.item(sel[0], "values")[0], self.tree.item(sel[0], "values")[1]
for c, briefs in self._results.items():
if c != country:
continue
for b in briefs:
if b.get("topic") == topic:
self._detail_window(b)
return
def _detail_window(self, b):
win = tk.Toplevel(self)
win.title(f"{b.get('topic', '')} 详情")
win.geometry("780x540")
txt = tk.Text(win, wrap="word", padx=10, pady=10)
txt.pack(fill="both", expand=True)
for zh, k in [
("国家", "country"), ("热点词", "topic"), ("类别", "design_category"),
("风险", "risk_level"), ("风险原因", "risk_reasons"), ("概念", "concept"),
("主体 motif", "motif"), ("艺术风格", "art_style"), ("配色", "color_palette"),
("构图", "composition"),
("image_prompt", "image_prompt"), ("composite_prompt", "composite_prompt"),
]:
v = b.get(k)
if v in (None, "", []):
continue
txt.insert("end", f"【{zh}\n{json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v}\n\n")
txt.config(state="disabled")
# ---------- 工具 ----------
def _clear_log(self):
self.log_text.config(state="normal")
self.log_text.delete("1.0", "end")
self.log_text.config(state="disabled")
def _open_output(self):
os.makedirs(OUTPUT_ROOT, exist_ok=True)
try:
os.startfile(OUTPUT_ROOT) # Windows
except AttributeError:
import subprocess
subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(OUTPUT_ROOT)])
def _open_logs(self):
log_dir = runtime_root() / "logs"
os.makedirs(log_dir, exist_ok=True)
try:
os.startfile(log_dir)
except AttributeError:
import subprocess
subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(log_dir)])
def self_test():
"""无 GUI 自检:验证打包环境(依赖/数据文件/核心链路),结果写入运行根 self_test_result.txt。"""
import traceback
lines: list = []
def log(s):
print(s)
lines.append(str(s))
try:
log("=== POD Agent 自检 ===")
log(f"数据根: {resource_root()}")
log(f"运行根: {runtime_root()}")
log(f"配置根: {config_root()} (exe 旁配置优先)")
cfg = load_config()
log(f"config 加载 OK | seed_provider: {cfg.get('seed_provider')} | 国家: {cfg.get('countries')}")
# 自检不上传图床(避免 mock 占位图污染用户 OSS bucket
if "oss" in cfg:
cfg["oss"]["enabled"] = False
for c in COUNTRIES:
p = config_root() / "prompts" / c / "aesthetics.yaml"
log(f" prompts/{c}: {'OK' if p.exists() else 'MISSING!'}")
state = run_country("GB", cfg, config_root(), output_root=runtime_root())
briefs = state.get("briefs", []) or []
errs = state.get("errors") or []
log(f"GB 流水线完成:简报 {len(briefs)} 条,兜底错误 {len(errs)}")
if briefs:
log(f"样例: {briefs[0].get('topic')} | {briefs[0].get('risk_level')} | {briefs[0].get('design_category')}")
out = runtime_root() / "output" / "GB"
log(f"产物目录: {out} 存在={'是' if out.exists() else '否'}")
log("自检完成 OK")
except Exception as e: # noqa: BLE001
log(f"自检失败: {e}")
log(traceback.format_exc())
finally:
try:
(runtime_root() / "self_test_result.txt").write_text(
"\n".join(lines), encoding="utf-8")
except Exception:
pass
def main():
# windowed 打包(console=False)下 sys.stdout/stderr 为 Noneprint 会崩;兜底为丢弃流
if sys.stdout is None:
sys.stdout = io.StringIO()
if sys.stderr is None:
sys.stderr = io.StringIO()
ensure_defaults()
if "--self-test" in sys.argv:
self_test()
return
App().mainloop()
if __name__ == "__main__":
main()