- 新增男童/女童检测(gender_from_category 优先判童装)与童装场景图生成
(configs/kids_features.yaml:模特/场景/服装风格,同一商品固定同一组)
- 童装 SPU 字段映射:kids_type→SPU商品属性-类型、kids_age→适用年龄段、
target_audience 按性别映射、kids_type_map 女童「上衣」→「针织上衣」
- 标题生成提示词外部化:prompts/title_prompt_{1,2,3}.md + config.yaml 路由表
- 模板多站点匹配:经营站点可配多个,命中任意一个即匹配
- 种草图生成失败自动重试(seed_shot.retries,换场景/模特/风格)
- 模板导出新增 Preview 文件夹(成功产品 _composite.oss.jpg + result.xlsx)
- 修复 SKU 尺码未按从小到大排序(_size_rank 支持单一年龄码 6Y/10Y)
1371 lines
64 KiB
Python
1371 lines
64 KiB
Python
"""POD 热点抓取 Agent —— 桌面 UI(Tkinter 轻量版)。
|
||
|
||
功能(简洁版):
|
||
- 国家多选(US/GB/JP/AU)
|
||
- LLM 后端选择(mock/static/openai_compat/openai/deepseek/qwen/moonshot)
|
||
- 种子数量上限(style/related)
|
||
- 运行:后台线程调用 graph.agent.run_country,print 输出重定向到日志区
|
||
- 结果表格:国家/热点词/类别/风险/分数;双击看完整详情
|
||
- 打开产物目录
|
||
|
||
打包(PyInstaller):
|
||
pyinstaller -F -w ui_app.py --add-data "config.yaml;." --add-data "configs;configs" --add-data "prompts;prompts"
|
||
数据文件(config.yaml/configs/prompts)打进 _MEIPASS;output/ 与 .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:
|
||
"""数据根:开发=脚本目录;打包=_MEIPASS(config.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.yaml(llm_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位随机}.jpg,000 起最多 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:
|
||
"""运行/采集完成后,把该国缓存+去重数据打包成 zip(output/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/<时间戳>.log(exe 旁 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": ("加拿大站", "加拿大"),
|
||
}
|
||
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 为 None,print 会崩;兜底为丢弃流
|
||
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()
|