From e9939ef9dc9bd911e03fb7ff6daabd170ad311bc Mon Sep 17 00:00:00 2001 From: mosen <181942704@qq.com> Date: Mon, 15 Jun 2026 00:52:31 +0800 Subject: [PATCH] Initial release with production deploy config. Add GitAtlas deploy assets, DATA_FILE support for isolated server data, and port documentation for sourcing.simosen.cn. Co-authored-by: Cursor --- .gitignore | 7 + PORTS.md | 7 + data/.gitkeep | 0 data/seed.json | 11 + deploy/ecosystem.config.cjs | 18 + deploy/nginx-proxy.conf | 17 + docs/implementation-plan.md | 14 + .../2026-06-13-saas-admin-productization.md | 60 + .../2026-06-14-ai-engine-and-token-billing.md | 73 + .../plans/2026-06-14-brand-share-assets.md | 58 + .../2026-06-14-brand-share-assets-design.md | 33 + extension/background.js | 9 + extension/content.js | 189 + extension/icons/icon128.png | Bin 0 -> 9785 bytes extension/icons/icon16.png | Bin 0 -> 834 bytes extension/icons/icon32.png | Bin 0 -> 2083 bytes extension/icons/icon48.png | Bin 0 -> 3604 bytes extension/manifest.json | 52 + extension/popup.css | 190 + extension/popup.html | 55 + extension/popup.js | 272 + package.json | 11 + public/app.js | 2510 +++++++++ public/assets/brand-lockup.svg | 25 + public/assets/brand-mark.png | Bin 0 -> 18015 bytes public/assets/brand-mark.svg | 27 + public/assets/jewelry-buyer-board.png | Bin 0 -> 97629 bytes public/assets/share-card.png | Bin 0 -> 70200 bytes public/assets/share-card.svg | 73 + public/favicon.ico | Bin 0 -> 39657 bytes public/index.html | 497 ++ public/styles.css | 5013 +++++++++++++++++ public/test-capture.html | 20 + scripts/generate_brand_assets.py | 168 + src/ai.js | 239 + src/auth.js | 49 + src/core.js | 624 ++ src/server.js | 884 +++ src/store.js | 724 +++ tests/core.test.js | 303 + tests/server.test.js | 1355 +++++ 使用说明.md | 145 + 直播饰品选品系统_Design_System.md | 129 + 直播饰品选品系统_UIUX_Pro_Max_设计系统.md | 72 + 直播饰品选品系统_UI提示词体系.md | 443 ++ 45 files changed, 14376 insertions(+) create mode 100644 .gitignore create mode 100644 PORTS.md create mode 100644 data/.gitkeep create mode 100644 data/seed.json create mode 100644 deploy/ecosystem.config.cjs create mode 100644 deploy/nginx-proxy.conf create mode 100644 docs/implementation-plan.md create mode 100644 docs/superpowers/plans/2026-06-13-saas-admin-productization.md create mode 100644 docs/superpowers/plans/2026-06-14-ai-engine-and-token-billing.md create mode 100644 docs/superpowers/plans/2026-06-14-brand-share-assets.md create mode 100644 docs/superpowers/specs/2026-06-14-brand-share-assets-design.md create mode 100644 extension/background.js create mode 100644 extension/content.js create mode 100644 extension/icons/icon128.png create mode 100644 extension/icons/icon16.png create mode 100644 extension/icons/icon32.png create mode 100644 extension/icons/icon48.png create mode 100644 extension/manifest.json create mode 100644 extension/popup.css create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/assets/brand-lockup.svg create mode 100644 public/assets/brand-mark.png create mode 100644 public/assets/brand-mark.svg create mode 100644 public/assets/jewelry-buyer-board.png create mode 100644 public/assets/share-card.png create mode 100644 public/assets/share-card.svg create mode 100644 public/favicon.ico create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 public/test-capture.html create mode 100644 scripts/generate_brand_assets.py create mode 100644 src/ai.js create mode 100644 src/auth.js create mode 100644 src/core.js create mode 100644 src/server.js create mode 100644 src/store.js create mode 100644 tests/core.test.js create mode 100644 tests/server.test.js create mode 100644 使用说明.md create mode 100644 直播饰品选品系统_Design_System.md create mode 100644 直播饰品选品系统_UIUX_Pro_Max_设计系统.md create mode 100644 直播饰品选品系统_UI提示词体系.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e18c2e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +.env.* +!.env.example +.DS_Store +*.log +data/items.json diff --git a/PORTS.md b/PORTS.md new file mode 100644 index 0000000..900fb6f --- /dev/null +++ b/PORTS.md @@ -0,0 +1,7 @@ +# 端口 + +| 环境 | 端口 | 说明 | +|------|------|------| +| 本地开发 | 4777 | `npm start`,可通过 `PORT` 覆盖 | +| 生产 Node | 9477 | PM2 `product-sourcing`,仅本机监听 | +| 生产对外 | 443 | Nginx 反代 `https://sourcing.simosen.cn` | diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/seed.json b/data/seed.json new file mode 100644 index 0000000..19ea8c0 --- /dev/null +++ b/data/seed.json @@ -0,0 +1,11 @@ +{ + "items": [], + "users": [], + "sessions": [], + "pluginTokens": [], + "creditCodes": [], + "creditLogs": [], + "aiTokenLogs": [], + "aiSettings": {}, + "rules": {} +} diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..6e3cf99 --- /dev/null +++ b/deploy/ecosystem.config.cjs @@ -0,0 +1,18 @@ +module.exports = { + apps: [ + { + name: "product-sourcing", + cwd: "/www/wwwroot/sourcing.simosen.cn/app", + script: "src/server.js", + interpreter: "node", + env: { + NODE_ENV: "production", + PORT: "9477", + DATA_FILE: "/www/data/product-sourcing/items.json", + }, + max_memory_restart: "512M", + autorestart: true, + watch: false, + }, + ], +}; diff --git a/deploy/nginx-proxy.conf b/deploy/nginx-proxy.conf new file mode 100644 index 0000000..90b33b1 --- /dev/null +++ b/deploy/nginx-proxy.conf @@ -0,0 +1,17 @@ +location ^~ /.well-known/ { + root /www/wwwroot/sourcing.simosen.cn; + allow all; +} + +location / { + proxy_pass http://127.0.0.1:9477; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + client_max_body_size 32m; +} diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..7bd5306 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,14 @@ +# Product Sourcing Capture MVP Implementation Plan + +Goal: build a local browser-capture system for visible Xiaohongshu/Taobao/1688 product data. + +Architecture: Node/Express local API stores captures in a JSON-backed repository, a React/Vite dashboard displays and manages items, and a Manifest V3 browser extension extracts visible page data and posts it to localhost. The extractor is DOM-heuristic based and only reads content already visible in the user's browser. + +Tasks: +1. Create project scaffold with Vite, Express, Vitest, and extension folders. +2. Write tests for platform detection, price parsing, DOM extraction, scoring, and storage. +3. Implement shared extractor and scoring utilities. +4. Implement local API and JSON persistence. +5. Implement React dashboard for item library, filters, detail review, export, and extension install guidance. +6. Implement browser extension popup/content script/background. +7. Run tests, build, start server, and verify the app page. diff --git a/docs/superpowers/plans/2026-06-13-saas-admin-productization.md b/docs/superpowers/plans/2026-06-13-saas-admin-productization.md new file mode 100644 index 0000000..b3d2ebf --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-saas-admin-productization.md @@ -0,0 +1,60 @@ +# SaaS Admin Productization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add productized SaaS administration: roles, user management, user disablement, plugin token lifecycle, token usage tracking, and an admin center UI. + +**Architecture:** Extend the existing JSON store and HTTP API without introducing a database migration framework. First user becomes admin, later users are buyers. Admin-only endpoints expose user summaries and allow status changes; plugin token endpoints gain disable/delete and usage metadata. + +**Tech Stack:** Vanilla Node HTTP server, JSON persistence, vanilla HTML/CSS/JS frontend, Node test runner. + +--- + +### Task 1: Backend Role And Admin APIs + +**Files:** +- Modify: `src/store.js` +- Modify: `src/server.js` +- Test: `tests/server.test.js` + +- [x] Add tests for first user admin, later buyer, admin user list, disable behavior. +- [x] Implement role/status defaults in `JsonStore.createUser` and `publicUser`. +- [x] Add `JsonStore.listUsersWithStats`, `updateUserStatus`. +- [x] Add `requireAdmin` in server and `/api/admin/users`, `/api/admin/users/:id/status`. +- [x] Run focused server tests, then full test suite. + +### Task 2: Plugin Token Lifecycle + +**Files:** +- Modify: `src/store.js` +- Modify: `src/server.js` +- Test: `tests/server.test.js` + +- [x] Add tests for token usage count/last used, disabling token, deleting token. +- [x] Add token fields `status`, `usageCount`, `lastUsedAt`. +- [x] Add store methods `touchPluginToken`, `updatePluginTokenStatus`, `deletePluginToken`. +- [x] Add API endpoints `PATCH /api/plugin-tokens/:id` and `DELETE /api/plugin-tokens/:id`. +- [x] Block disabled token capture. + +### Task 3: Admin Center UI + +**Files:** +- Modify: `public/index.html` +- Modify: `public/app.js` +- Modify: `public/styles.css` + +- [x] Add admin navigation item hidden for non-admin. +- [x] Load admin users when admin view opens. +- [x] Render admin center with user list, stats, status action. +- [x] Enhance plugin token panel with status, usage count, last used, disable/delete actions. +- [x] Keep Buyer Atelier visual system. + +### Task 4: Verification + +**Files:** +- Test: all + +- [ ] Run `node --check public/app.js && npm run check && npm test`. +- [ ] Restart local server if needed. +- [ ] Browser verify admin center and plugin panel desktop/mobile. +- [ ] Confirm no temporary QA files remain. diff --git a/docs/superpowers/plans/2026-06-14-ai-engine-and-token-billing.md b/docs/superpowers/plans/2026-06-14-ai-engine-and-token-billing.md new file mode 100644 index 0000000..ea73ebb --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-ai-engine-and-token-billing.md @@ -0,0 +1,73 @@ +# AI Engine And Token Billing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add administrator-managed large-model configuration, user-facing AI analysis actions, and AI token billing through redemption codes. + +**Architecture:** Extend the existing JSON store with `aiSettings`, `aiTokens`, and `aiTokenLogs`. Add an OpenAI-compatible AI client abstraction so official providers and proxy/base-url providers all use one request path. Admins configure model access; users consume AI actions through server endpoints that estimate/charge AI tokens. + +**Tech Stack:** Node.js HTTP server, JSON store, OpenAI-compatible chat completions over `fetch`, vanilla HTML/CSS/JS frontend, Node test runner. + +--- + +### Task 1: Server Tests + +**Files:** +- Modify: `tests/server.test.js` + +- [ ] Add tests for admin-only AI settings. +- [ ] Add tests for AI token redemption codes. +- [ ] Add tests for AI analysis using a fake AI client and token deduction. +- [ ] Add tests for insufficient AI tokens. + +### Task 2: AI Core Module + +**Files:** +- Create: `src/ai.js` + +- [ ] Add `DEFAULT_AI_SETTINGS`. +- [ ] Add settings normalization and public masking. +- [ ] Add OpenAI-compatible request handling. +- [ ] Add prompt builders for item analysis, procurement reports, and rule generation. +- [ ] Add token estimation helpers. + +### Task 3: Store Extensions + +**Files:** +- Modify: `src/store.js` +- Modify: `src/auth.js` + +- [ ] Add `aiTokens` to users and public users. +- [ ] Extend credit codes to include `aiTokens`. +- [ ] Add `aiSettings` and encrypted API key storage. +- [ ] Add AI token deduction logs. + +### Task 4: API Routes + +**Files:** +- Modify: `src/server.js` + +- [ ] Add admin routes for AI settings and connection testing. +- [ ] Add user route `/api/ai/analyze`. +- [ ] Require AI token balance before calling the model. +- [ ] Deduct AI tokens after successful model use. + +### Task 5: Frontend UI + +**Files:** +- Modify: `public/index.html` +- Modify: `public/app.js` +- Modify: `public/styles.css` + +- [ ] Add admin AI configuration panel in management center. +- [ ] Extend redemption code UI with AI token amount. +- [ ] Show AI token balance on account/admin user rows. +- [ ] Add AI analysis actions in item detail. +- [ ] Keep visual language aligned with jade/paper/brass product UI. + +### Task 6: Verification + +- [ ] Run `npm test`. +- [ ] Run JS syntax checks. +- [ ] Restart local service. +- [ ] Verify AI settings UI and item detail AI action with browser screenshots. diff --git a/docs/superpowers/plans/2026-06-14-brand-share-assets.md b/docs/superpowers/plans/2026-06-14-brand-share-assets.md new file mode 100644 index 0000000..279fba2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-brand-share-assets.md @@ -0,0 +1,58 @@ +# Brand And Share Assets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a category-neutral product logo, favicon, extension icons, and WeChat-friendly social sharing metadata and image. + +**Architecture:** Keep deterministic brand sources as SVG files under `public/assets`, derive raster formats with ImageMagick, and dynamically render absolute Open Graph URLs when serving HTML. Reuse the same logo across the landing page, authenticated shell, startup state, and extension. + +**Tech Stack:** HTML, CSS, SVG, PNG/ICO, Node.js HTTP server, Node test runner, ImageMagick. + +--- + +### Task 1: Add metadata regression coverage + +**Files:** +- Modify: `tests/server.test.js` + +- [ ] Add a server test that requests `/` and verifies title, description, favicon, Open Graph fields, absolute share image URL, and share image availability. +- [ ] Run `npm test -- tests/server.test.js` and verify the new test fails before implementation. + +### Task 2: Create brand assets + +**Files:** +- Create: `public/assets/brand-mark.svg` +- Create: `public/assets/brand-lockup.svg` +- Create: `public/assets/share-card.svg` +- Create: `public/assets/share-card.png` +- Create: `public/favicon.ico` +- Create: `extension/icons/icon16.png` +- Create: `extension/icons/icon32.png` +- Create: `extension/icons/icon48.png` +- Create: `extension/icons/icon128.png` + +- [ ] Build the vector logo and share card from the approved jade, paper, brass, and cinnabar visual system. +- [ ] Rasterize the share image, favicon, and extension icon sizes with ImageMagick. +- [ ] Inspect dimensions and file formats. + +### Task 3: Connect metadata and brand UI + +**Files:** +- Modify: `public/index.html` +- Modify: `public/styles.css` +- Modify: `src/server.js` +- Modify: `extension/manifest.json` +- Modify: `extension/popup.html` +- Modify: `extension/popup.css` + +- [ ] Add SEO, Open Graph, Twitter Card, favicon, and application icon metadata. +- [ ] Replace text-only brand marks with the reusable SVG mark. +- [ ] Render HTML share-origin placeholders to absolute URLs in the server. +- [ ] Add extension icons and generic product naming. + +### Task 4: Verify + +- [ ] Run `npm test`. +- [ ] Run syntax checks for server, frontend, and extension JavaScript. +- [ ] Start the server and verify metadata/assets over HTTP. +- [ ] Inspect the rendered homepage and share image. diff --git a/docs/superpowers/specs/2026-06-14-brand-share-assets-design.md b/docs/superpowers/specs/2026-06-14-brand-share-assets-design.md new file mode 100644 index 0000000..d01b59d --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-brand-share-assets-design.md @@ -0,0 +1,33 @@ +# 全品类选品采购品牌与分享资产设计 + +## 产品定位 + +系统品牌从“直播饰品选品采购”向“全品类机会选品采购台”延展。现阶段饰品直播仍是首个业务模板,但品牌资产不再绑定某个类目,能够覆盖后续服饰、家居、百货等商品选品。 + +## Logo 方向 + +- 保留现有翡翠绿、米白、黄铜金和少量朱砂红,延续高级买手工具气质。 +- 图形使用“选品扫描框 + 商品标签/机会钻石 + 多平台节点”的抽象组合。 +- 不依赖中文单字,确保 favicon、浏览器插件、小尺寸场景中仍能清晰识别。 +- 输出主标识 SVG、浏览器 favicon.ico、多个插件 PNG 尺寸。 + +## 分享体验 + +- 页面标题:`选品采购台|竞品采集、货源匹配与采购决策` +- 页面描述:`采集淘宝、小红书等平台商品线索,匹配 1688 货源,完成筛选、利润测算、拿样与采购决策。` +- 微信及社交分享使用 1200×630 PNG 分享封面。 +- Open Graph 图片地址由服务端根据当前访问域名生成绝对 URL,兼容本地和云端部署。 + +## 页面接入 + +- 登录首页、启动页、侧栏品牌标识统一使用新 logo。 +- 保留现有饰品直播首期业务文案,不在本次改动中重写业务规则。 +- 浏览器插件使用同一套图标,名称升级为通用“选品采购采集器”。 + +## 验收 + +- 浏览器标签页显示 favicon。 +- 首页、启动页、侧栏和插件显示统一 logo。 +- 首页 HTML 包含完整 `description`、Open Graph、Twitter Card 和微信友好分享信息。 +- 分享图、logo、favicon 均能通过本地服务正常访问。 +- 全量自动化测试、JavaScript 语法检查通过。 diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..63735ea --- /dev/null +++ b/extension/background.js @@ -0,0 +1,9 @@ +chrome.runtime.onInstalled.addListener(() => { + console.info("直播饰品选品采集器已安装"); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type === "PING") { + sendResponse({ ok: true, tabId: sender.tab?.id || null }); + } +}); diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..07b73df --- /dev/null +++ b/extension/content.js @@ -0,0 +1,189 @@ +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type !== "EXTRACT_PRODUCTS") return; + try { + sendResponse({ ok: true, items: extractVisibleProducts(message.mode || "page") }); + } catch (error) { + sendResponse({ ok: false, error: error.message || String(error), items: [] }); + } +}); + +function extractVisibleProducts(mode) { + const platform = detectPlatform(location.href); + const sourcePage = location.href; + const title = cleanText(document.title); + const candidates = mode === "main" ? [document.body] : findCandidateNodes(); + const items = []; + const seen = new Set(); + + for (const node of candidates) { + const item = extractFromNode(node, platform, sourcePage, title); + if (!item.title && !item.image) continue; + const key = item.url || `${item.title}|${item.image}`; + if (seen.has(key)) continue; + seen.add(key); + items.push(item); + if (items.length >= 30) break; + } + + if (!items.length) { + const fallback = extractFromNode(document.body, platform, sourcePage, title); + if (fallback.title || fallback.image) items.push(fallback); + } + + return items; +} + +function findCandidateNodes() { + const nodes = new Set(); + const imageNodes = Array.from(document.images) + .filter((img) => { + const rect = img.getBoundingClientRect(); + const src = img.currentSrc || img.src || img.getAttribute("data-src") || ""; + return src && !src.startsWith("data:") && rect.width >= 80 && rect.height >= 80; + }) + .slice(0, 80); + + for (const img of imageNodes) { + nodes.add(findCardRoot(img)); + } + + const linkNodes = Array.from(document.querySelectorAll("a[href]")) + .filter((link) => { + const rect = link.getBoundingClientRect(); + const text = cleanText(link.innerText || link.textContent || ""); + return rect.width >= 80 && rect.height >= 40 && (link.querySelector("img") || /[¥¥]\s*\d|\d+(?:\.\d+)?\s*元/.test(text)); + }) + .slice(0, 60); + + for (const link of linkNodes) nodes.add(findCardRoot(link)); + + return Array.from(nodes).filter(Boolean).slice(0, 90); +} + +function findCardRoot(node) { + let current = node; + let best = node; + for (let i = 0; i < 5 && current?.parentElement; i += 1) { + current = current.parentElement; + const rect = current.getBoundingClientRect(); + const text = cleanText(current.innerText || current.textContent || ""); + if (rect.width >= 100 && rect.height >= 80 && text.length <= 900) best = current; + if (/[¥¥]\s*\d|\d+(?:\.\d+)?\s*元/.test(text) && text.length >= 8) { + best = current; + break; + } + } + return best; +} + +function extractFromNode(node, site, pageUrl, pageTitle) { + const text = cleanText(node.innerText || node.textContent || ""); + const linkEl = node.matches?.("a[href]") ? node : node.querySelector?.("a[href]"); + const url = absolutize(linkEl?.href || pageUrl); + const image = bestImage(node); + const priceText = findPriceText(text); + const titleText = bestTitle(node, text, pageTitle); + const supplier = findSupplier(node, text); + + return { + platform: site, + url, + sourcePage: pageUrl, + title: titleText, + image, + priceText, + supplier, + shop: supplier, + metrics: findMetrics(text), + notes: `插件采集;页面标题:${pageTitle}`, + }; +} + +function bestTitle(node, text, pageTitle) { + const attrs = [ + node.getAttribute?.("title"), + node.getAttribute?.("aria-label"), + node.querySelector?.("[title]")?.getAttribute("title"), + node.querySelector?.("h1,h2,h3")?.innerText, + node.querySelector?.("[class*='title'],[class*='name'],[class*='desc']")?.innerText, + ]; + const fromAttrs = attrs.map(cleanText).find((value) => value && value.length >= 4); + if (fromAttrs) return limit(fromAttrs, 120); + + const lines = text + .split(/[\n\r。]/) + .map(cleanText) + .filter((line) => line.length >= 4 && !/^[¥¥]?\d/.test(line)); + const bestLine = lines.find((line) => /耳|链|饰|手|项|发|戒|藏|民族|绿松石|珍珠|银|夹|挂/.test(line)) || lines[0]; + return limit(bestLine || pageTitle, 120); +} + +function bestImage(node) { + const images = Array.from(node.querySelectorAll?.("img") || (node.tagName === "IMG" ? [node] : [])) + .map((img) => { + const rect = img.getBoundingClientRect(); + return { + src: img.currentSrc || img.src || img.getAttribute("data-src") || img.getAttribute("data-lazy") || "", + score: rect.width * rect.height + (img.naturalWidth || 0) * (img.naturalHeight || 0), + }; + }) + .filter((image) => image.src && !image.src.startsWith("data:")) + .sort((a, b) => b.score - a.score); + return absolutize(images[0]?.src || ""); +} + +function findPriceText(text) { + const match = text.match(/(?:¥|¥)\s*\d+(?:\.\d+)?(?:\s*[-~至]\s*\d+(?:\.\d+)?)?|(?:批发价|价格|券后|到手价)?\s*\d+(?:\.\d+)?\s*元/); + return cleanText(match?.[0] || ""); +} + +function findSupplier(node, text) { + const el = node.querySelector?.("[class*='shop'],[class*='seller'],[class*='supplier'],[class*='company'],[class*='store']"); + const fromEl = cleanText(el?.innerText || el?.textContent || ""); + if (fromEl) return limit(fromEl, 80); + const match = text.match(/[\u4e00-\u9fa5A-Za-z0-9()()]{2,30}(?:工厂|厂家|旗舰店|饰品厂|商行|店|公司)/); + return cleanText(match?.[0] || ""); +} + +function findMetrics(text) { + const metrics = {}; + const patterns = { + sales: /(?:已售|销量|人付款|成交|售出)\s*[::]?\s*[\d.万wW+]+/, + comments: /(?:评论|评价)\s*[::]?\s*[\d.万wW+]+/, + likes: /(?:点赞|赞|喜欢)\s*[::]?\s*[\d.万wW+]+/, + saves: /(?:收藏|加购)\s*[::]?\s*[\d.万wW+]+/, + }; + for (const [key, pattern] of Object.entries(patterns)) { + const match = text.match(pattern); + if (match) metrics[key] = match[0]; + } + metrics.visibleText = limit(text, 500); + return metrics; +} + +function detectPlatform(url) { + const host = new URL(url).hostname; + if (host.includes("1688.com")) return "1688"; + if (host.includes("xiaohongshu.com") || host.includes("xhslink.com")) return "xiaohongshu"; + if (host.includes("tmall.com")) return "tmall"; + if (host.includes("taobao.com")) return "taobao"; + return "other"; +} + +function absolutize(url) { + if (!url) return ""; + try { + return new URL(url, location.href).href; + } catch { + return ""; + } +} + +function cleanText(value) { + return String(value || "").replace(/\s+/g, " ").trim(); +} + +function limit(value, length) { + const text = cleanText(value); + return text.length > length ? `${text.slice(0, length)}…` : text; +} diff --git a/extension/icons/icon128.png b/extension/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..9254a41d46b076dfb04c2fdf00f26066b154437a GIT binary patch literal 9785 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 z z2S?Qleqq4+!<>znb1&96D^t?_zJ9DsKHHex+N(TEco zZ)o}OZENbNKxTU?G<`;cfxd$*>JWGSkHnghN+uzt1*7N`HYS#7VqxoScnlvbGvNl7 z6of|rxxEI2wgXTnyFG7%U}EWLMe$v7-)3PRy167gHcXpt8(&U=m!GVma;uAD$d5o43DC=x5ML7@l zL@*ltG~#2ErXZC}AeYPZoBwH=4F>`^bq*b5VV1p5nn*sUL0V4%Ihq^_?^>n>uP2C) zFa7}X`Fy`Jsl+rI`NXJ}NmF6f;0I60!3$gWmWf~}f^eh>T^()xMnBm^scaf&&OZT1 zO`8R2W&83E80#GELU~GqC$kwczlV(i=L+sLaOsj|h=#(*n^wg}wC&r4rj|(v1VR%e zXrPelCyb`fID%C_57M&HPhJXr?jJsICN#~{7bJ2GK*HRU*{lgMEdhu}0mC*>ooT=_ z)0jJTHZEIo8WQPbzolAYTsD*D225|A&8`TkqzO$h@&xF*A%dCA4LU_=5pbfp zl@cx10x7u`GSdlR6wFC`nbt%&5W+oIUI*RrUCT$eeG;|@-JJ)pch@$=S|+3Q$hq)Q z(u3)2c`nWbn(!DZh`UPYq=wOnW>nSGFfeuI5o~959cahC-8-trT_~HXH{OezKe-Bj zH~TnPW~$FPomN5q`zME2tw1B0|-mgQC{2Qz!`E`YrLRwJJjR@{}`VYAB z&RY=+M=JWU#j2{Qc@m;cF<6#Fyr+{RRWvoYT490%Wx{7lICYZ@13rHc(O5IQK0ngQ zIJ!F8+1jV~Dy{v~sPF9V!att#A>6)dHB2*Ix(||y5a=NTR{R^nh<@9}+$DfZJp)$i zHR!%$VWlNRpT4Ixcf$zdf8YN@-15jnXo)osd<2_ivE>j%t+oY9Os08^j6|9)bV@3jK;IUcyDf{?^X+C zJ>6J#+yXp&{WtAgLs=BOvlsU)=spc*dZqA09(A$iYZPCQmWPE9rn2HULJNNjR+7wl zPtp5aYEjdDxb?B$;)_4Kn->>?WcgPqY2p%rA5OWr2|J*YQ2*Ga^f+tCDO_1?eT2*5 zONDr%7yoPdhj8tJp$Av=BLV$K zn=AAX9(&_2_{=?Pv8{bS+XW;8#<-odFZ(x5=>BqH!08_YXhu#k*EIB|Qt%lDzW<3; z_`*3K=40!mG%JH|r5+VV=-lFwU$|%a4IFy_KyM4A?-6LeQ(z@b2rj){5K1vJj0pC1 zx8ti1+=Ki6yor-Kqy8Z8yx~llsy|~wY84vsG<>M+UY^p+=P$s2Tyix|o_jo`l`Pa6 zDhMt9>tO>j`y#aL`9QRjUZ#1!__`GxbfuIggpf*gH$(c@+axH}1byBCEr}XXgY^{>CmzA8lzslJ_C7?SM-{@(;G1`;PXkNb@V636GIWZ)@nZt#S>$FTGCK$|@|c1<5CnL#F`pOBGmaXDSr zkajnhmWaJ*C!u=`9%wTEI%Mt!;R&xN;N3tE29cU{aUo8iHhY12Uxn1JgXWpaQ-YbI zMFO51crSSW@A8jnROarXD#CGS#s56b(j@3NI7zZP4TzV9UP?u`pNe=MQ?n zS{qpGODYwg2KcXq#3j&-`SvG>yLz52yz70Z%tP4C*Vkj!VTn=hfIz-TmRgO}7L%^i>!IEb{h{b@K17*&H z&|9Ib#?(MRltuA@0d#2LZU|!!AhtoNm!NA;0v&%7Vuro{%iY;UOS*Lc@5=uNv{r1Z Tq^M@A00000NkvXXu0mjfsvMgd literal 0 HcmV?d00001 diff --git a/extension/icons/icon16.png b/extension/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..8d45190f2194427bbce58b4b390d9d174e945f5d GIT binary patch literal 834 zcmV-I1HJr-P)@eE z=gifaz5T;~WFs9qLI`2c;~QW<=KPG~D82rMwWE#ojjswJK32w<$4=zV9qRn(14b`R z*dT3cp$qL=X1Q`%%j%xTcONpwL}F@YVr;`ZuXty2BT-7Z5@T?css-3*oKOmZABKd+ z;Oj7I^H#{8yY>~-@r%RW$V8Dk>B{x1vb?^vgCGoe?$g7Z&KHPtv}e-1{Lpq5xhdZM z`YV1J9+J7qNq!%>;HfBz;(l#!Z*J@0-J^Y+_-mNl)GW7UmY@U-OqMB}JjLQ=&Ajx$ zgKYb7KUHIhbYv_nZ8Ry@r4j`Edf`vb7mBQGQ1m{rk=~t~Sk;syHCyF!ahz%2r`}Ck zSaGbXl@FtcXOA4FI9p+DdI^0`ZK9?1ZVZASc81)0@O#deD%_qMg< zFVNOp$I)H)k@hxl*INg;#{fE zAQ+mu%KZnwvuzKSs$`cp(SF_aG}fi4{70fOO3E0uAk>-{zW9vSwmxhfx{iFp@%$L^ zzgxWw?{z#veX5QZzxgZYb5QYK_vF_}mZMS2cjP0CW@2LXZB z1Uib=WIXv(*S><&)R5lOvtu_mdn=?;5+ejxf&kxWDthknjj?Aso{&=9lx~$hJ9d#? z>g}0ZTVpy)|CIM08#1!i>Q3&275?sfZ{C|3dprg^1DGW?j@b*82B8KOiXp6NLur~wK}y@GloDuJj9N-*LfRxW zYOtb4A~X{GAgWLUDPRGq+J*pyrXs8^N?DBwP_RAN#fe2@dC_5sG&z1&CGrG zo$oB)Irm%ufRs|D@3yv^<+h!yl~P`iQd&|uknTL2=Lny=#T=g*=k-VT8;CKA9yp(0EkHVPy952dAM%WD6zVHCf|*tBBXZQs_k z;+?Tn9Lu(Ca(ABiSTQY2KvD2O#Pi_OeDaC$k9wQGyHt&YO5ixLr+mIX?~7#e_FzK~ z*0&{-;puOz^Uhqktd^EWp!hDqLQ1Ehq2+q{$hAK~QK*PGfpj_vRn>?n=t!lAG2Jft4L@`( z^Xv|AAz-^MQt4Dr$OI4y1o@fkx-bksG)+UaH7Za2sup7h4R6!{b;zM}XN6^39Cq@Y zz#Z4+6#V+h1*qyj0IqFAH4JR}@GtoI@+AZfBa z@yh->*pACaTb2nwz2|x2z^C`hd^j1M)VtigdJw~ZfHChzCSVlQ) zPbTr3Z#~7}8|t>)QdrCK_0kp}dY53^Ed3c_SkUzjV@A90$9XklU z-&8cUw!`&YltsP{yX7!eO{-*Lt=fAUeG3dYQsPgiPO<|O1@C?g=rST?80t~OGZHM41oq}6!F$PcB4yWkFy7$p=>n8Mvroys{v8uWXo90h| z?RpHH5Bzft=oSeS>Ke0MN(DlwTpeVfA{6c$6eVg@5Go|A6q$GKLzKJ^vc&WIR4m@dVf7ffl7`Dw|e#*EEqSQ_Yk-pJ;DaQbMz_}?>T`YzmFBQr&4S{ zfv)E)MeQDNI=ej&6CbYN*7=Wy%N*`-P{-t6=Xp^?mw)%Rm~{K>Z7rf$Vw%5^wI^mmJLOtk?c6u?%IK*Wpf0{ zIfX`j&*QrSVGAkUh6_zs%SZoWIr@Z)1syJCIt@)#;Yx{48cMrzBY}Xf^I?^DDD5I# zc7LJY&)t${Ox-Y``*bu#o8^g@H)6t&k&TL#?x-8quR?tD%a3|3x0?|(0zAEPYvuY6 zaz6fUYy^5fw5D;D1bXccc8~HjmKOwq1dbV_$9WTmj1-R5Q73LEZx8Nk z6x830w}+>`{&Vl*&Ht#8(g;U@2gnB?f{peVd{EyK_wbqxN<~Sez18d-d&kx6Ry>MD zTYu;7t3Rno&l6A|+|`Ums_CP?$K^Dmy!(27rJSzN|F7%0>)v}k|1XB^tH&BfXo>&; N002ovPDHLkV1iCs{J{VK literal 0 HcmV?d00001 diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..bfbd74588af4fa7aaa5b9ef9e5fc9fb546d1fd5e GIT binary patch literal 3604 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 aHvbRp&eL^z8ImUe0000 div { + display: grid; + gap: 4px; + min-width: 0; +} + +header strong { + font-size: 15px; + color: #084738; +} + +header span { + color: #69707d; + font-size: 12px; +} + +.actions { + display: grid; + gap: 8px; +} + +.settings { + margin-top: 12px; + border: 1px solid rgba(13, 107, 88, 0.18); + border-radius: 8px; + background: rgba(255, 255, 255, 0.82); + overflow: hidden; +} + +.settings summary { + position: relative; + min-height: 40px; + padding: 11px 12px; + color: #084738; + cursor: pointer; + font-size: 13px; + font-weight: 700; + list-style: none; +} + +.settings summary::-webkit-details-marker { + display: none; +} + +.settings summary::after { + position: absolute; + right: 12px; + color: #69707d; + content: "展开"; + font-size: 12px; + font-weight: 400; +} + +.settings[open] summary::after { + content: "收起"; +} + +.settings.has-update:not([open]) summary::before { + position: absolute; + right: 48px; + color: #a56f18; + content: "有更新"; + font-size: 12px; + font-weight: 700; +} + +.settings-body { + display: grid; + gap: 8px; + padding: 0 10px 10px; +} + +.update-panel { + display: grid; + gap: 7px; + padding: 10px; + border: 1px solid rgba(165, 111, 24, 0.32); + border-radius: 8px; + background: #fff4cf; + color: #5f4211; + font-size: 12px; + line-height: 1.55; +} + +.update-panel strong { + color: #084738; +} + +.update-panel p { + margin: 0; +} + +label { + display: grid; + gap: 5px; + color: #65766f; + font-size: 12px; +} + +label span { + color: #315f55; + font-weight: 700; +} + +input { + min-height: 34px; + border: 1px solid rgba(13, 107, 88, 0.2); + border-radius: 8px; + padding: 7px 9px; + color: #241a14; +} + +button { + min-height: 38px; + border: 1px solid rgba(13, 107, 88, 0.22); + border-radius: 8px; + background: #ffffff; + color: #241a14; + cursor: pointer; + font: inherit; +} + +button.primary { + border-color: #0d6b58; + background: #0d6b58; + color: #fffaf0; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.result { + margin-top: 12px; + padding: 10px; + border: 1px solid rgba(13, 107, 88, 0.18); + border-radius: 8px; + background: #ffffff; + font-size: 12px; + line-height: 1.6; +} + +.result code { + color: #0d6b58; +} + +.result ul { + margin: 8px 0 0; + padding-left: 18px; +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..4e0ee4a --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,55 @@ + + + + + + 选品采购采集器 + + + +
+
+ +
+ +
+ + + + +
+ +
+ 设置 +
+ + + + +
+
+ +
+

首次使用请打开“设置”配置系统地址和插件 Token。配置保存后会自动收起。

+
+
+ + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..9d8ae89 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,272 @@ +const DEFAULT_API_BASE = "http://127.0.0.1:4777"; + +const statusText = document.querySelector("#statusText"); +const versionText = document.querySelector("#versionText"); +const updatePanel = document.querySelector("#updatePanel"); +const updateText = document.querySelector("#updateText"); +const updateGuideBtn = document.querySelector("#updateGuideBtn"); +const resultBox = document.querySelector("#resultBox"); +const settingsPanel = document.querySelector("#settingsPanel"); +const capturePageBtn = document.querySelector("#capturePageBtn"); +const captureMainBtn = document.querySelector("#captureMainBtn"); +const testSaveBtn = document.querySelector("#testSaveBtn"); +const openDashboardBtn = document.querySelector("#openDashboardBtn"); +const apiBaseInput = document.querySelector("#apiBaseInput"); +const pluginTokenInput = document.querySelector("#pluginTokenInput"); +const saveSettingsBtn = document.querySelector("#saveSettingsBtn"); + +let settings = { + apiBase: DEFAULT_API_BASE, + pluginToken: "", +}; +let latestExtensionInfo = null; + +async function init() { + versionText.textContent = `插件版本 ${currentVersion()}`; + settings = await loadSettings(); + apiBaseInput.value = settings.apiBase; + pluginTokenInput.value = settings.pluginToken; + await checkHealth(); +} + +async function loadSettings() { + const stored = await chromeStorageGet(["apiBase", "pluginToken"]); + return { + apiBase: normalizeApiBase(stored.apiBase || DEFAULT_API_BASE), + pluginToken: stored.pluginToken || "", + }; +} + +async function saveSettings() { + settings = { + apiBase: normalizeApiBase(apiBaseInput.value || DEFAULT_API_BASE), + pluginToken: pluginTokenInput.value.trim(), + }; + await chromeStorageSet(settings); + settings = await loadSettings(); + apiBaseInput.value = settings.apiBase; + pluginTokenInput.value = settings.pluginToken; + resultBox.innerHTML = "

连接配置已保存,下次打开会自动带出。

"; + await checkHealth(); + settingsPanel.open = false; +} + +async function checkHealth() { + try { + const response = await fetch(`${settings.apiBase}/api/health`); + if (!response.ok) throw new Error("服务未响应"); + const payload = await response.json(); + latestExtensionInfo = payload.extension || null; + statusText.textContent = payload.authRequired && !settings.pluginToken ? "系统已连接,待填写插件 Token" : "系统已连接"; + renderUpdatePrompt(payload.extension); + setButtonsDisabled(payload.authRequired && !settings.pluginToken); + openDashboardBtn.disabled = false; + } catch { + statusText.textContent = "系统未连接"; + latestExtensionInfo = null; + renderUpdatePrompt(null); + setButtonsDisabled(true); + openDashboardBtn.disabled = false; + } +} + +function renderUpdatePrompt(extensionInfo) { + if (!extensionInfo?.recommendedVersion || !isVersionGreater(extensionInfo.recommendedVersion, currentVersion())) { + updatePanel.hidden = true; + settingsPanel.classList.remove("has-update"); + return; + } + updatePanel.hidden = false; + settingsPanel.classList.add("has-update"); + updateText.textContent = `当前 ${currentVersion()},系统推荐 ${extensionInfo.recommendedVersion}。${extensionInfo.updateNote || "建议更新插件后再采集。"}`; +} + +function setButtonsDisabled(disabled) { + capturePageBtn.disabled = disabled; + captureMainBtn.disabled = disabled; + testSaveBtn.disabled = disabled; + openDashboardBtn.disabled = disabled; +} + +async function getActiveTab() { + const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + let tab = activeTabs.find(isInjectableTab); + if (!tab) { + const allTabs = await chrome.tabs.query({}); + tab = allTabs.reverse().find(isInjectableTab); + } + if (!tab?.id) throw new Error("没有找到可采集的普通网页标签页,请先打开淘宝、小红书或 1688 页面"); + return tab; +} + +async function runExtractor(mode) { + const tab = await getActiveTab(); + try { + const response = await chrome.tabs.sendMessage(tab.id, { type: "EXTRACT_PRODUCTS", mode }); + return response?.items || []; + } catch (firstError) { + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["content.js"], + }); + const response = await chrome.tabs.sendMessage(tab.id, { type: "EXTRACT_PRODUCTS", mode }); + return response?.items || []; + } catch (secondError) { + throw new Error(`页面采集脚本无法运行:${secondError.message || firstError.message}`); + } + } +} + +async function capture(mode) { + resultBox.innerHTML = "

正在采集当前页面...

"; + setButtonsDisabled(true); + + try { + const items = await runExtractor(mode); + if (!items.length) { + resultBox.innerHTML = "

没有识别到可采集的商品/笔记。可以滚动页面后再试,或打开详情页采集主商品。

"; + return; + } + + const payload = await postCaptures(items); + resultBox.innerHTML = renderSavedResult(payload.items || [], payload.credits); + } catch (error) { + resultBox.innerHTML = ` +

采集失败:${escapeHtml(error.message)}

+

排查:确认系统地址和插件 Token;当前页不是浏览器内部页;淘宝/小红书/1688 页面可刷新后重试。

+ `; + } finally { + setButtonsDisabled(false); + } +} + +async function postCaptures(items) { + const headers = { "Content-Type": "application/json" }; + if (settings.pluginToken) headers.Authorization = `Bearer ${settings.pluginToken}`; + const response = await fetch(`${settings.apiBase}/api/captures`, { + method: "POST", + headers, + body: JSON.stringify({ items }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "保存失败"); + return payload; +} + +async function testSave() { + resultBox.innerHTML = "

正在测试写入系统...

"; + setButtonsDisabled(true); + try { + const payload = await postCaptures([ + { + platform: "1688", + title: "插件测试款 藏式编绳手链 民族风", + url: `https://detail.1688.com/offer/plugin-test-${Date.now()}.html`, + image: "", + priceText: "¥6.80", + supplier: "插件诊断测试", + metrics: { visibleText: "现货 混批 支持拿样 1000+人付款 求链接" }, + notes: "来自浏览器插件测试保存按钮", + }, + ]); + resultBox.innerHTML = ` +

测试保存成功。采集链路正常。

+

已写入:${escapeHtml(payload.items?.[0]?.title || "测试款")}

+ ${payload.credits ? `

已扣 ${escapeHtml(payload.credits.deducted)} 积分,剩余 ${escapeHtml(payload.credits.remaining)}。

` : ""} + `; + } catch (error) { + resultBox.innerHTML = ` +

测试保存失败:${escapeHtml(error.message)}

+

请确认系统地址可访问,SaaS 模式需要填写插件 Token。

+ `; + } finally { + setButtonsDisabled(false); + } +} + +function renderSavedResult(items, credits) { + return ` +

已保存 ${items.length} 条候选。

+ ${credits ? `

已扣 ${escapeHtml(credits.deducted)} 积分,剩余 ${escapeHtml(credits.remaining)}。

` : ""} +
    + ${items + .slice(0, 5) + .map((item) => `
  • ${escapeHtml(item.decision)} · ${escapeHtml(item.title || "未命名")}
  • `) + .join("")} +
+ `; +} + +function chromeStorageGet(keys) { + return new Promise((resolve, reject) => { + chrome.storage.sync.get(keys, (value) => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message || "读取配置失败")); + else resolve(value || {}); + }); + }); +} + +function chromeStorageSet(value) { + return new Promise((resolve, reject) => { + chrome.storage.sync.set(value, () => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message || "保存配置失败,请检查插件 storage 权限")); + else resolve(); + }); + }); +} + +function isInjectableTab(tab) { + return Boolean(tab?.id && /^https?:\/\//.test(tab.url || "")); +} + +function normalizeApiBase(value = "") { + return String(value).trim().replace(/\/+$/, "") || DEFAULT_API_BASE; +} + +function escapeHtml(value = "") { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +capturePageBtn.addEventListener("click", () => capture("page")); +captureMainBtn.addEventListener("click", () => capture("main")); +testSaveBtn.addEventListener("click", testSave); +saveSettingsBtn.addEventListener("click", saveSettings); +openDashboardBtn.addEventListener("click", () => chrome.tabs.create({ url: settings.apiBase })); +updateGuideBtn.addEventListener("click", () => { + const guide = latestExtensionInfo?.reloadRequiredForUnpacked + ? "请下载系统里的新版插件安装包,解压后到 chrome://extensions 重新加载解压后的文件夹。" + : "请按系统提示更新插件。"; + resultBox.innerHTML = `

${escapeHtml(guide)}

安装包:${escapeHtml(latestExtensionInfo?.fileName || "product-sourcing-capture-extension.zip")}

`; + const installUrl = latestExtensionInfo?.installUrl || "/"; + chrome.tabs.create({ url: absoluteSystemUrl(installUrl) }); +}); + +init(); + +function currentVersion() { + return chrome.runtime.getManifest().version || "0.0.0"; +} + +function isVersionGreater(a, b) { + const left = String(a).split(".").map((part) => Number(part) || 0); + const right = String(b).split(".").map((part) => Number(part) || 0); + for (let i = 0; i < Math.max(left.length, right.length); i += 1) { + const diff = (left[i] || 0) - (right[i] || 0); + if (diff > 0) return true; + if (diff < 0) return false; + } + return false; +} + +function absoluteSystemUrl(pathOrUrl = "/") { + if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl; + return `${settings.apiBase}${String(pathOrUrl).startsWith("/") ? "" : "/"}${pathOrUrl}`; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..402b393 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "product-sourcing-capture", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "start": "node src/server.js", + "check": "node --check src/server.js && node --check src/core.js && node --check src/store.js" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..163f26e --- /dev/null +++ b/public/app.js @@ -0,0 +1,2510 @@ +const TOKEN_KEY = "productSourcing.sessionToken"; +const GRID_VIEWS = new Set(["capture", "competitors", "suppliers", "testing", "decisions"]); +const FILTERABLE_VIEWS = new Set(["capture", "competitors", "suppliers", "testing", "decisions"]); +const SELECTABLE_VIEWS = new Set(["capture", "competitors", "suppliers", "testing", "decisions"]); +const STATS_VIEWS = new Set(["overview", "capture", "competitors", "suppliers", "matching", "testing", "decisions"]); +const EXPORTABLE_VIEWS = new Set(["overview", "capture", "competitors", "suppliers", "matching", "testing", "decisions"]); +const VIEW_LABELS = { + overview: "首页总览", + capture: "采集中心", + competitors: "竞品库", + suppliers: "货源库", + matching: "款式匹配", + testing: "拿样测试", + decisions: "采购决策", + admin: "管理中心", + plugin: "采集插件", + rules: "规则配置", + account: "账号设置", +}; +const FILTER_LABELS = { + q: "关键词", + platform: "平台", + track: "赛道", + decision: "建议动作", + status: "状态", + tag: "标签", + priceBand: "价格带", + source: "货源", + risk: "风险", +}; + +const DEFAULT_EXTENSION_PACKAGE = { + name: "选品采购采集器", + recommendedVersion: "0.0.0", + installUrl: "/downloads/product-sourcing-capture-extension.zip", + fileName: "product-sourcing-capture-extension.zip", + reloadRequiredForUnpacked: true, +}; + +const state = { + view: "overview", + authRequired: false, + extensionInfo: DEFAULT_EXTENSION_PACKAGE, + sessionToken: localStorage.getItem(TOKEN_KEY) || "", + user: null, + rules: null, + pluginTokens: [], + adminUsers: [], + creditCodes: [], + aiSettings: null, + aiStatus: null, + aiAnalysisByItem: {}, + adminLoading: false, + adminError: "", + aiAdminMessage: "", + lastPluginToken: "", + lastPluginTokenId: "", + locked: false, + landingOpen: false, + items: [], + dashboard: null, + groups: [], + sampleItems: [], + selectedIds: new Set(), + bulkDeletePending: false, + filters: { + q: "", + platform: "", + track: "", + decision: "", + status: "", + tag: "", + priceBand: "", + source: "", + risk: "", + }, +}; + +const els = { + appShell: document.querySelector(".app-shell"), + authGate: document.querySelector("#authGate"), + authDialog: document.querySelector("#authDialog"), + authDialogTitle: document.querySelector("#authDialogTitle"), + authDialogText: document.querySelector("#authDialogText"), + authSwitchText: document.querySelector("#authSwitchText"), + authSwitchButton: document.querySelector("#authSwitchButton"), + openAuthPage: document.querySelector("#openAuthPageBtn"), + landingBack: document.querySelector("#landingBackBtn"), + registerForm: document.querySelector("#registerForm"), + loginForm: document.querySelector("#loginForm"), + authMessage: document.querySelector("#authMessage"), + sessionBadge: document.querySelector("#sessionBadge"), + logout: document.querySelector("#logoutBtn"), + statsGrid: document.querySelector(".stats-grid"), + grid: document.querySelector("#itemsGrid"), + empty: document.querySelector("#emptyState"), + refresh: document.querySelector("#refreshBtn"), + search: document.querySelector("#searchInput"), + mobileSearch: document.querySelector("#mobileSearchInput"), + platform: document.querySelector("#platformSelect"), + track: document.querySelector("#trackSelect"), + decision: document.querySelector("#decisionSelect"), + status: document.querySelector("#statusSelect"), + tag: document.querySelector("#tagInput"), + priceBand: document.querySelector("#priceBandSelect"), + source: document.querySelector("#sourceSelect"), + risk: document.querySelector("#riskSelect"), + mobileFilter: document.querySelector("#mobileFilterBtn"), + mobileAuth: document.querySelector("#mobileAuthBtn"), + mobileRefresh: document.querySelector("#mobileRefreshBtn"), + mobileViewTitle: document.querySelector("#mobileViewTitle"), + filterDialog: document.querySelector("#filterDialog"), + filterToolbar: document.querySelector("#filterToolbar"), + filterContext: document.querySelector("#filterContextText"), + filterMode: document.querySelector("#filterModeLabel"), + filterResultCount: document.querySelector("#filterResultCount"), + filterActive: document.querySelector("#filterActiveText"), + filterSheetBody: document.querySelector(".filter-sheet-body"), + clearFilters: document.querySelector("#clearFiltersBtn"), + dialog: document.querySelector("#itemDialog"), + dialogContent: document.querySelector("#dialogContent"), + statTotal: document.querySelector("#statTotal"), + statSample: document.querySelector("#statSample"), + stat1688: document.querySelector("#stat1688"), + statTibetan: document.querySelector("#statTibetan"), + workbench: document.querySelector("#workbenchPanel"), + bulkToolbar: document.querySelector("#bulkToolbar"), + bulkStatusText: document.querySelector("#bulkStatusText"), + selectedCount: document.querySelector("#selectedCount"), + selectVisible: document.querySelector("#selectVisibleBtn"), + clearSelection: document.querySelector("#clearSelectionBtn"), + deleteSelected: document.querySelector("#deleteSelectedBtn"), + confirmDelete: document.querySelector("#confirmDeleteBtn"), + cancelDelete: document.querySelector("#cancelDeleteBtn"), + tagSelected: document.querySelector("#tagSelectedBtn"), + mobileStatusMenu: document.querySelector("#mobileStatusMenuBtn"), + statusActionDialog: document.querySelector("#statusActionDialog"), + emptyPrimary: document.querySelector("#emptyPrimaryBtn"), + emptySecondary: document.querySelector("#emptySecondaryBtn"), +}; + +async function init() { + try { + const health = await fetchJson("/api/health", { skipAuth: true }); + state.authRequired = Boolean(health.authRequired); + state.extensionInfo = { ...DEFAULT_EXTENSION_PACKAGE, ...(health.extension || {}) }; + + if (state.sessionToken) { + try { + const me = await fetchJson("/api/me"); + state.user = me.user; + } catch { + clearSession(); + } + } + + if (!state.user) { + setAppLocked(true); + return; + } + + setAppLocked(false); + await loadConfig(); + await loadItems(); + } catch { + state.extensionInfo = DEFAULT_EXTENSION_PACKAGE; + markAuthReady(); + els.empty.hidden = false; + els.empty.querySelector("p").textContent = "服务未启动,请在项目目录运行 npm start。"; + } +} + +async function loadConfig() { + if (!state.user) { + state.rules = null; + state.pluginTokens = []; + state.adminUsers = []; + state.creditCodes = []; + state.aiSettings = null; + state.aiStatus = null; + return; + } + const [mePayload, rulesPayload, tokensPayload, aiStatusPayload] = await Promise.all([ + fetchJson("/api/me"), + fetchJson("/api/rules"), + fetchJson("/api/plugin-tokens"), + fetchJson("/api/ai/status"), + ]); + state.user = mePayload.user || state.user; + state.rules = rulesPayload.rules; + state.pluginTokens = tokensPayload.pluginTokens || []; + state.aiStatus = aiStatusPayload || null; +} + +async function loadAdminUsers() { + if (!isAdmin()) { + state.adminUsers = []; + state.creditCodes = []; + state.aiSettings = null; + state.adminError = ""; + state.adminLoading = false; + return; + } + state.adminLoading = true; + state.adminError = ""; + render(); + try { + const [usersPayload, creditCodesPayload, aiSettingsPayload] = await Promise.all([ + fetchJson("/api/admin/users"), + fetchJson("/api/admin/credit-codes"), + fetchJson("/api/admin/ai-settings"), + ]); + state.adminUsers = usersPayload.users || []; + state.creditCodes = creditCodesPayload.creditCodes || []; + state.aiSettings = aiSettingsPayload.aiSettings || null; + } catch (error) { + state.adminError = error.message || "管理中心加载失败"; + } finally { + state.adminLoading = false; + render(); + } +} + +async function loadItems() { + if (state.locked) return; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(state.filters)) { + if (value) params.set(key, value); + } + + const [itemsPayload, dashboardPayload, groupsPayload, samplePayload] = await Promise.all([ + fetchJson(`/api/items?${params.toString()}`), + fetchJson("/api/dashboard"), + fetchJson("/api/style-groups"), + fetchJson("/api/sample-queue"), + ]); + + state.items = itemsPayload.items || []; + state.dashboard = dashboardPayload.dashboard || null; + state.groups = groupsPayload.groups || []; + state.sampleItems = samplePayload.items || []; + pruneSelection(); + render(); +} + +function render() { + if (state.locked) return; + if (state.view === "admin" && !isAdmin()) state.view = "account"; + document.body.dataset.view = state.view; + renderChrome(); + renderStats(); + renderWorkbench(); + renderBulkToolbar(); + const items = visibleItems(); + const showGrid = GRID_VIEWS.has(state.view); + const emptyCopy = getEmptyStateCopy(); + els.statsGrid.hidden = !STATS_VIEWS.has(state.view); + els.filterToolbar.hidden = !FILTERABLE_VIEWS.has(state.view); + els.mobileFilter.hidden = !FILTERABLE_VIEWS.has(state.view); + renderFilterBench(items); + els.empty.hidden = items.length > 0 || !showGrid; + if (emptyCopy) { + els.empty.querySelector("h3").textContent = emptyCopy.title; + els.empty.querySelector("p").textContent = emptyCopy.body; + els.emptyPrimary.textContent = emptyCopy.primaryLabel; + els.emptyPrimary.dataset.emptyPrimaryView = emptyCopy.primaryView; + els.emptySecondary.hidden = !emptyCopy.secondaryLabel; + els.emptySecondary.textContent = emptyCopy.secondaryLabel || ""; + } + els.grid.hidden = !showGrid; + els.grid.innerHTML = visibleItems().map(renderCard).join(""); + bindCardActions(); +} + +function renderChrome() { + const roleLabel = state.user?.role === "admin" ? "管理员" : "买手"; + const isFilterableView = FILTERABLE_VIEWS.has(state.view); + els.sessionBadge.textContent = state.user ? `${state.user.name || state.user.email} · ${roleLabel}` : "本地模式"; + if (els.mobileViewTitle) els.mobileViewTitle.textContent = VIEW_LABELS[state.view] || "首页总览"; + els.logout.hidden = !state.user; + els.openAuthPage.hidden = false; + els.openAuthPage.textContent = state.user ? "我的账号" : "创建账号/登录"; + els.mobileAuth.hidden = Boolean(state.user); + if (els.mobileSearch) { + els.mobileSearch.closest(".mobile-search-field")?.toggleAttribute("hidden", !isFilterableView); + } + document.querySelectorAll("[data-export-csv]").forEach((button) => { + button.hidden = !EXPORTABLE_VIEWS.has(state.view); + }); + els.landingBack.hidden = !state.user; + document.querySelectorAll(".admin-only").forEach((element) => { + element.hidden = !isAdmin(); + }); + if (state.view === "admin" && !isAdmin()) { + state.view = "account"; + document.body.dataset.view = state.view; + syncActiveNav("account"); + } +} + +function renderStats() { + const totals = state.dashboard?.totals; + els.statTotal.textContent = totals?.items ?? state.items.length; + els.statSample.textContent = totals?.suggestedSample ?? state.items.filter((item) => item.decision === "拿样").length; + els.stat1688.textContent = totals?.suppliers1688 ?? state.items.filter((item) => item.platform === "1688").length; + els.statTibetan.textContent = totals?.styleGroups ?? state.groups.length; +} + +function renderFilterBench(items) { + if (!FILTERABLE_VIEWS.has(state.view)) return; + const activeFilters = Object.entries(state.filters).filter(([, value]) => Boolean(value)); + const context = { + capture: "先看最近采集进来的图片、链接、价格和平台线索,快速清掉误采和不适合直播的款。", + competitors: "筛淘宝、小红书、天猫竞品,优先找热度可见、镜头表现强、直播话术安全的 100 元内饰品。", + suppliers: "筛 1688 货源,重点看同款/近似款、拿样价、供应商信誉、混批和售后保障。", + testing: "筛问供应商、已拿样和直播测试中的款,把验证进度从线索推进到采购判断。", + decisions: "筛高分、可跑量、风险低的采购候选,误选项可以多选后批量清理。", + }; + els.filterContext.textContent = context[state.view] || "按平台、赛道、状态、价格和风险快速缩小候选款。"; + els.filterMode.textContent = VIEW_LABELS[state.view] || "当前视图"; + els.filterResultCount.textContent = String(items.length); + els.filterActive.textContent = + activeFilters.length === 0 + ? "未启用筛选" + : activeFilters + .map(([key, value]) => `${FILTER_LABELS[key] || key}:${filterValueLabel(key, value)}`) + .slice(0, 3) + .join(" / "); +} + +function renderWorkbench() { + if (state.view === "overview") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderOverview(); + bindWorkbenchActions(); + return; + } + if (state.view === "capture") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderCaptureCenter(); + bindWorkbenchActions(); + return; + } + if (state.view === "competitors") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderLibraryHeader({ + eyebrow: "Competitor Desk", + title: "竞品库", + body: "淘宝、小红书、天猫等直播竞品集中在这里清洗。优先找搜索热、直播容易讲、100 元以内能跑量的款。", + count: visibleItems().length, + actionLabel: "清空筛选", + action: "clear-filters", + }); + bindWorkbenchActions(); + return; + } + if (state.view === "suppliers") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderLibraryHeader({ + eyebrow: "1688 Supply", + title: "货源库", + body: "只看 1688 货源,重点比较同款式、低拿样价、供应商信誉、混批和售后保障。", + count: visibleItems().length, + actionLabel: "只看 1688", + action: "source-1688", + }); + bindWorkbenchActions(); + return; + } + if (state.view === "matching") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderStyleGroups(); + bindWorkbenchActions(); + return; + } + if (state.view === "testing") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderSampleQueue(); + bindWorkbenchActions(); + return; + } + if (state.view === "decisions") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderProcurementDecisions(); + bindWorkbenchActions(); + return; + } + if (state.view === "admin") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderAdminCenter(); + bindAdminActions(); + return; + } + if (state.view === "plugin") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderPluginSettings(); + bindSettingsActions(); + return; + } + if (state.view === "rules") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderRuleSettings(); + bindSettingsActions(); + return; + } + if (state.view === "account") { + els.workbench.hidden = false; + els.workbench.innerHTML = renderProfile(); + bindProfileActions(); + return; + } + els.workbench.hidden = true; + els.workbench.innerHTML = ""; +} + +function renderOverview() { + const dashboard = state.dashboard; + if (!dashboard) return ""; + const nextActions = dashboard.nextActions.map((action) => `
  • ${escapeHtml(action)}
  • `).join(""); + const topItems = dashboard.topItems.map(renderCompactItem).join(""); + const lowPriceItems = (dashboard.lowPriceItems || []).slice(0, 6).map(renderCompactItem).join(""); + const riskItems = (dashboard.riskItems || []).slice(0, 6).map(renderCompactItem).join(""); + const profitItems = (dashboard.profitHealthyItems || []).slice(0, 6).map(renderCompactItem).join(""); + const workflow = [ + ["采集线索", dashboard.totals.items], + ["清洗竞品", competitorItems().length], + ["匹配货源", dashboard.totals.suppliers1688], + ["拿样测试", dashboard.totals.samplePipeline], + ["采购决策", procurementCandidates().length], + ] + .map(([label, value], index) => workflowStep(label, value, index + 1)) + .join(""); + const tracks = Object.entries(dashboard.byTrack || {}) + .sort((a, b) => b[1] - a[1]) + .map(([track, count]) => `
    ${escapeHtml(track)}${count}
    `) + .join(""); + + return ` +
    +
    +

    Buyer Command

    +

    今日选品采购指挥台

    +

    先把直播间能跑量的款筛出来,再用 1688 货源做同款/近似款匹配,最后推进拿样和采购。

    +
    +
    + 客单价 ¥100 内 + 直播跑量优先 + + +
    +
    +
    + ${workflow} +
    +
    +
    +

    今日买手动作

    +
      ${nextActions || "
    • 先采集一批候选款,系统会自动给下一步建议。
    • "}
    +
    +
    +

    跑量价格带

    +
    + ${miniStat("≤10", dashboard.priceBands.under10)} + ${miniStat("10-30", dashboard.priceBands.from10To30)} + ${miniStat("30-100", dashboard.priceBands.from30To100)} + ${miniStat(">100", dashboard.priceBands.over100)} +
    +
    +
    +

    内部赛道分布

    +
    ${tracks || "

    暂无赛道数据

    "}
    +
    +
    +
    +
    +

    低价跑量款

    +
    ${lowPriceItems || "

    暂无 10 元以内候选,继续从 1688 补货源。

    "}
    +
    +
    +

    利润健康款

    +
    ${profitItems || "

    暂无利润健康款,优先补进货价和直播价。

    "}
    +
    +
    +

    高风险提醒

    +
    ${riskItems || "

    当前暂无明显高风险话术。

    "}
    +
    +
    +
    +

    高分机会

    +
    ${topItems || "

    暂无候选

    "}
    +
    + `; +} + +function renderSampleQueue() { + const cards = state.sampleItems.map(renderPipelineItem).join(""); + return ` +
    +
    +

    拿样测试

    +

    建议拿样、问供应商、已拿样和直播测试中的款都会集中在这里。这里是直播验证前的推进台。

    +
    +
    ${cards || "

    暂无拿样任务,可以先在候选库批量标记“问供应商”。

    "}
    +
    + `; +} + +function renderStyleGroups() { + const groups = state.groups + .map( + (group) => ` +
    +
    +

    ${escapeHtml(group.track)} · ${group.items.length} 条线索

    +

    ${escapeHtml(group.title)}

    +
    + 平台:${escapeHtml((group.platforms || []).join(" / ") || "待确认")} + 最低货源:${money(group.minCost)} + 竞品高价:${money(group.maxCompetitorPrice)} + 建议直播价:${money(group.suggestedRetailPrice)} + 机会分 ${escapeHtml(String(group.opportunityScore))} +
    +

    最优供应商:${escapeHtml(group.bestSupply?.supplier || group.bestSupply?.shop || group.bestSupply?.platform || "待确认")}

    +
    +
    + ${group.items.slice(0, 4).map(renderCompactItem).join("")} +
    +
    + `, + ) + .join(""); + return ` +
    +
    +

    款式匹配

    +

    系统按赛道和关键词自动合并同款/近似款,用来把淘宝、小红书竞品和 1688 货源放在一起比较。

    +
    +
    ${groups || "

    还没有足够相似款,继续采集竞品和 1688 货源。

    "}
    +
    + `; +} + +function renderCaptureCenter() { + const rules = state.rules || { captureHints: [] }; + const recent = recentCaptureItems() + .slice(0, 6) + .map(renderCompactItem) + .join(""); + const hints = (rules.captureHints || []) + .map((hint) => `
  • ${escapeHtml(hint)}
  • `) + .join(""); + + return ` +
    +
    +

    Capture Hub

    +

    采集中心

    +

    浏览器插件负责抓取当前页面的图片、链接、价格、供应商和可见销量话术,系统负责清洗、打分和归类。

    +
    +
    + 图片 / 链接 / 价格 + + +
    +
    +
    +
    +

    采集顺序

    +
    + ${workflowStep("淘宝直播竞品", competitorItems().filter((item) => item.platform === "taobao" || item.platform === "tmall").length, 1)} + ${workflowStep("小红书种草款", competitorItems().filter((item) => item.platform === "xiaohongshu").length, 2)} + ${workflowStep("1688 同款货源", supplierItems().length, 3)} +
    +
    +
    +

    采集提示

    +
      ${hints || "
    • 优先采集主图清晰、价格明确、评论/销量可见的页面。
    • "}
    +
    +
    +

    最近采集

    +
    ${recent || "

    暂无采集线索。

    "}
    +
    +
    + `; +} + +function renderLibraryHeader({ eyebrow, title, body, count, actionLabel, action }) { + const actionAttr = action === "clear-filters" ? "data-clear-filters" : action === "source-1688" ? "data-source-1688" : ""; + return ` +
    +
    +

    ${escapeHtml(eyebrow)}

    +

    ${escapeHtml(title)}

    +

    ${escapeHtml(body)}

    +
    +
    + ${escapeHtml(String(count))} + 当前结果 + +
    +
    + `; +} + +function renderProcurementDecisions() { + const candidates = procurementCandidates(); + const rows = candidates.slice(0, 10).map(renderDecisionRow).join(""); + const ready = candidates.filter((item) => item.profit?.recommendation === "可跑量").length; + const sample = candidates.filter((item) => item.decision === "拿样").length; + const risky = candidates.filter((item) => item.riskLevel === "high").length; + + return ` +
    +
    +

    Purchase Board

    +

    采购决策

    +

    把高分机会、利润模型、风险提醒和拿样状态放在一张决策表里,适合第一批 100 元以内跑量款快速定款。

    +
    +
    + ${miniStat("可跑量", ready)} + ${miniStat("建议拿样", sample)} + ${miniStat("高风险", risky)} +
    +
    +
    +
    +

    优先采购候选

    +

    先看已匹配货源、利润可跑、风险较低的款;误选的款可以多选后批量清理。

    +
    +
    ${rows || "

    暂无可进入采购决策的款,继续采集竞品和 1688 货源。

    "}
    +
    + `; +} + +function renderDecisionRow(item) { + return ` + + `; +} + +function renderPluginSettings() { + const extensionInfo = state.extensionInfo || DEFAULT_EXTENSION_PACKAGE; + const installUrl = extensionInfo.installUrl || DEFAULT_EXTENSION_PACKAGE.installUrl; + const fileName = extensionInfo.fileName || DEFAULT_EXTENSION_PACKAGE.fileName; + const version = extensionInfo.recommendedVersion || "0.0.0"; + if (!state.user) { + return ` +
    +
    +

    Capture Plugin

    +

    采集插件

    +

    插件会把浏览器页面里的图片、链接、价格和供应商信息提交到 SaaS 工作台。

    +
    +
    +
    +
    +

    Plugin Access

    +

    先下载浏览器插件,再登录配置 Token

    +

    插件安装包已经随系统提供。创建第一个账号后,当前本地候选库会自动归到这个账号,插件也可以用 Token 往云端系统提交采集数据。

    +
    +
    + 下载插件安装包 + +
    +
    + `; + } + + const tokenList = state.pluginTokens.map(renderPluginTokenCard).join(""); + + return ` +
    +
    +

    Capture Plugin

    +

    采集插件

    +

    插件填入服务地址和 Token 后,就能把淘宝、小红书、1688 的图片、链接、价格提交到你的账号库。插件采集会消耗账号积分,多个 Token 共用同一个余额。

    +
    +
    + 余额 ${escapeHtml(String(state.user.credits || 0))} 积分 + +
    +
    +
    +
    +
    +
    +

    Extension Package

    +

    浏览器采集插件安装包

    +

    下载后解压,在 Chrome 扩展管理里开启开发者模式,再加载解压后的文件夹。

    +
    +
    +
    +
    + 推荐版本 + ${escapeHtml(version)} + ${escapeHtml(fileName)} +
    + 下载插件安装包 +
    +
      +
    1. 下载并解压安装包。
    2. +
    3. 打开 chrome://extensions
    4. +
    5. 开启开发者模式,选择“加载已解压的扩展程序”。
    6. +
    7. 把服务地址和插件 Token 填入插件设置。
    8. +
    +
    +
    +
    + + + +
    +
    +
    +
    +
    +

    已创建 Token

    +

    Token 只用于插件提交采集数据,不开放读取你的选品库。新版本生成的 Token 会保存在列表里,可随时复制配置;历史旧 Token 没有明文时需要重新生成。

    +
    +
    +
    ${tokenList || "

    还没有插件 Token。

    "}
    +
    +
    + `; +} + +function renderPluginTokenCard(token) { + const disabled = token.status === "disabled"; + const nextStatus = disabled ? "active" : "disabled"; + const actionLabel = disabled ? "启用" : "停用"; + const canCopyToken = Boolean(token.token); + return ` +
    +
    + +
    + ${escapeHtml(token.name || "浏览器采集插件")} + ${escapeHtml(pluginStatusLabel(token.status))} · 创建 ${escapeHtml(formatDateTime(token.createdAt))} +
    +
    +
    +
    采集次数${escapeHtml(String(token.usageCount || 0))}
    +
    最近使用${escapeHtml(formatDateTime(token.lastUsedAt))}
    +
    +
    + + + + +
    +
    + `; +} + +function renderAdminCenter() { + if (!isAdmin()) { + return ` +
    +
    +

    Admin Center

    +

    管理中心

    +

    只有管理员可以查看用户、插件 Token 使用情况和账号状态。

    +
    +
    +
    +
    +

    当前账号不是管理员

    +

    请使用管理员账号登录后再进入管理中心。

    +
    + +
    + `; + } + + const users = state.adminUsers || []; + const activeUsers = users.filter((user) => user.status !== "disabled").length; + const disabledUsers = users.filter((user) => user.status === "disabled").length; + const pluginTokens = users.reduce((sum, user) => sum + Number(user.pluginTokenCount || 0), 0); + const activePluginTokens = users.reduce((sum, user) => sum + Number(user.activePluginTokenCount || 0), 0); + const totalCredits = users.reduce((sum, user) => sum + Number(user.credits || 0), 0); + const totalAiTokens = users.reduce((sum, user) => sum + Number(user.aiTokens || 0), 0); + const rows = users.map(renderAdminUserRow).join(""); + const creditRows = (state.creditCodes || []).map(renderCreditCodeRow).join(""); + const aiSettings = state.aiSettings || {}; + + return ` +
    +
    +

    Admin Center

    +

    管理中心

    +

    管理 SaaS 用户、账号状态和插件采集入口。管理员可以停用异常账号,查看每个买手沉淀了多少选品线索和采集 Token。

    +
    +
    + + +
    +
    +
    + ${adminKpi("注册用户", users.length, "已进入 SaaS 的买手账号")} + ${adminKpi("活跃账号", activeUsers, "可登录并使用系统")} + ${adminKpi("已停用", disabledUsers, "无法登录或继续采集")} + ${adminKpi("插件 Token", `${activePluginTokens}/${pluginTokens}`, "启用 / 全部")} + ${adminKpi("账户积分", totalCredits, "所有用户当前余额")} + ${adminKpi("AI Tokens", totalAiTokens, "用户 AI 分析可用余额")} +
    +
    +
    +
    +

    AI Engine

    +

    大模型能力配置

    +

    管理员配置模型厂商、OpenAI 兼容代理、API Key 和提示词;用户只看到可用 AI 功能和自己的 AI token 余额,不接触密钥。

    +
    + ${aiSettings.enabled ? "已启用" : "未启用"}${aiSettings.apiKeyMasked ? ` · ${escapeHtml(aiSettings.apiKeyMasked)}` : ""} +
    +
    + + ${aiInput("aiProviderInput", "模型厂商", aiSettings.provider || "openai-compatible", "deepseek / openai / qwen / proxy")} + ${aiInput("aiBaseUrlInput", "Base URL", aiSettings.baseUrl || "", "https://api.deepseek.com/v1")} + ${aiInput("aiModelInput", "模型名称", aiSettings.model || "", "deepseek-chat / gpt-4o-mini / qwen-plus")} + ${aiInput("aiApiKeyInput", "API Key", "", aiSettings.apiKeyMasked ? `已配置:${aiSettings.apiKeyMasked},留空则不修改` : "sk-...", "password")} + ${aiInput("aiTemperatureInput", "温度", aiSettings.temperature ?? 0.2, "0.2", "number", "0.1")} + ${aiInput("aiMaxOutputInput", "最大输出 tokens", aiSettings.maxOutputTokens ?? 900, "900", "number", "1")} + ${aiInput("aiTimeoutInput", "超时毫秒", aiSettings.timeoutMs ?? 20000, "20000", "number", "1000")} + ${aiInput("aiTokenUnitCostInput", "计费倍率", aiSettings.tokenUnitCost ?? 1, "1", "number", "1")} +
    +
    + ${aiTextarea("aiPromptItemInput", "商品分析提示词", aiSettings.prompts?.itemAnalysis || "")} + ${aiTextarea("aiPromptProcurementInput", "采购报告提示词", aiSettings.prompts?.procurementReport || "")} + ${aiTextarea("aiPromptRulesInput", "规则生成提示词", aiSettings.prompts?.ruleGeneration || "")} +
    +
    + + + ${escapeHtml(state.aiAdminMessage || "支持 OpenAI 兼容 / 代理网关,Base URL 到 /v1 即可。")} +
    +
    +
    +
    +
    +

    兑换码充值

    +

    管理员生成兑换码,用户在账号页兑换后增加采集积分或 AI tokens。多个插件 Token 共用采集积分,AI 分析共用 AI token 余额。

    +
    +
    +
    + + + + +
    +
    + ${creditRows || "

    还没有兑换码。

    "} +
    +
    +
    +
    +
    +

    用户管理

    +

    ${state.adminLoading ? "正在同步用户列表..." : "停用账号后,该用户已有登录态和插件 Token 都会被后端拦截。"}

    +
    + ${state.adminError ? escapeHtml(state.adminError) : state.adminLoading ? "同步中" : `${users.length} 个账号`} +
    +
    + ${rows || renderAdminEmptyState()} +
    +
    + `; +} + +function renderCreditCodeRow(code) { + const redeemed = code.status === "redeemed"; + const parts = []; + if (Number(code.credits || 0) > 0) parts.push(`${Number(code.credits || 0)} 采集积分`); + if (Number(code.aiTokens || 0) > 0) parts.push(`${Number(code.aiTokens || 0)} AI Tokens`); + return ` +
    +
    + ${escapeHtml(code.code)} + ${escapeHtml(code.note || "无备注")} · ${escapeHtml(formatDateTime(code.createdAt))} +
    + ${redeemed ? "已兑换" : "可兑换"} + ${escapeHtml(parts.join(" / ") || "0 额度")} + +
    + `; +} + +function renderAdminUserRow(user) { + const isSelf = user.id === state.user?.id; + const disabled = user.status === "disabled"; + const actionStatus = disabled ? "active" : "disabled"; + const actionLabel = disabled ? "启用" : "停用"; + return ` +
    +
    + +
    + ${escapeHtml(user.name || user.email || "未命名用户")} + ${escapeHtml(user.email || "")} +
    +
    +
    + ${escapeHtml(roleLabel(user.role))} + ${escapeHtml(userStatusLabel(user.status))} +
    +
    +
    线索${escapeHtml(String(user.itemCount || 0))}
    +
    Token${escapeHtml(String(user.activePluginTokenCount || 0))}/${escapeHtml(String(user.pluginTokenCount || 0))}
    +
    积分${escapeHtml(String(user.credits || 0))}
    +
    AI${escapeHtml(String(user.aiTokens || 0))}
    +
    +
    + 创建 ${escapeHtml(formatDateTime(user.createdAt))} + 更新 ${escapeHtml(formatDateTime(user.updatedAt || user.createdAt))} +
    + +
    + `; +} + +function aiInput(id, label, value, placeholder = "", type = "text", step = "1") { + return ` + + `; +} + +function aiTextarea(id, label, value) { + return ` + + `; +} + +function adminKpi(label, value, hint) { + return ` +
    + ${escapeHtml(label)} + ${escapeHtml(String(value))} + ${escapeHtml(hint)} +
    + `; +} + +function renderAdminEmptyState() { + if (state.adminLoading) { + return ` +
    + + 正在加载用户 +

    同步账号、插件 Token 和选品线索统计。

    +
    + `; + } + return ` +
    + + 暂无用户数据 +

    创建账号后,用户会出现在这里。

    +
    + `; +} + +function renderRuleSettings() { + if (!state.user) { + return ` +
    +
    +

    Rule Studio

    +

    规则配置

    +

    这里管理赛道关键词、风险词、采集提示、自动标签和第一批跑量利润模型。

    +
    +
    +
    +
    +

    Rule Studio

    +

    登录后编辑评分和采集规则

    +

    规则会影响后续采集的赛道识别、风险提醒、利润模型和自动标签。

    +
    + +
    + `; + } + + const rules = state.rules || { tracks: [], riskKeywords: [], profitModel: {}, captureHints: [], autoTags: [] }; + const trackRows = (rules.tracks || []).map(renderTrackRuleRow).join(""); + const profitModel = rules.profitModel || {}; + + return ` +
    +
    +

    Rule Studio

    +

    选品评分规则工作室

    +

    把系统判断“能不能跑量、该不该拿样、有没有风险”的规则集中在这里维护。先调赛道识别,再调利润模型,最后补风险词和采集提示。

    +
    +
    +
    + 赛道 ${escapeHtml(String((rules.tracks || []).length))} + 跑量价 ≤ ${money(profitModel.maxRunningPrice)} +
    +

    修改后会影响后续采集评分、风险提醒和拿样建议。

    + +
    +
    +
    + ${ruleSummaryCard("赛道识别", `${(rules.tracks || []).length} 组`, "影响耳饰、项链、藏式、发饰等内部赛道归类。")} + ${ruleSummaryCard("风险词库", `${(rules.riskKeywords || []).length} 个`, "影响高风险提醒和详情页话术避坑。", "danger")} + ${ruleSummaryCard("采集提示", `${(rules.captureHints || []).length} 条`, "指导插件采集时优先保留哪些页面线索。")} + ${ruleSummaryCard("自动标签", `${(rules.autoTags || []).length} 个`, "用于批量清洗和直播选品筛选。", "gold")} +
    +
    +
    +
    +
    +

    Track Matching

    +

    赛道关键词

    +

    用关键词把采集款自动归入藏式/民族风、耳饰、项链、发饰等赛道。关键词越贴近直播搜索词,后续筛选越准。

    +
    + +
    +
    ${trackRows}
    +
    + +
    + `; +} + +function ruleSummaryCard(label, value, hint, tone = "") { + return ` +
    + ${escapeHtml(label)} + ${escapeHtml(value)} +

    ${escapeHtml(hint)}

    +
    + `; +} + +function renderTrackRuleRow(track, index) { + return ` +
    +
    + ${escapeHtml(String(index + 1).padStart(2, "0"))} +
    + + + +
    + `; +} + +function profileQuickAction(label, body, action, tone = "") { + return ` + + `; +} + +function profileStat(label, value, hint = "", tone = "") { + return ` +
    + ${escapeHtml(label)} + ${escapeHtml(String(value))} + ${hint ? `${escapeHtml(hint)}` : ""} +
    + `; +} + +function profileInfo(label, value) { + return ` +
    + ${escapeHtml(label)} + ${escapeHtml(String(value || "待确认"))} +
    + `; +} + +function renderProfile() { + const userName = state.user?.name || state.user?.email?.split("@")[0] || "未登录买手"; + const userEmail = state.user?.email || "未登录"; + const role = state.user ? roleLabel(state.user.role) : "本地模式"; + const status = state.user ? userStatusLabel(state.user.status) : "未启用"; + const credits = Number(state.user?.credits || 0); + const aiTokens = Number(state.user?.aiTokens || 0); + const aiReady = Boolean(state.aiStatus?.ai?.enabled); + const tokenCount = state.pluginTokens.length; + const accountMode = state.user ? "SaaS" : "本地单人"; + const accountInitial = (userName || "饰").slice(0, 1).toUpperCase(); + + return ` +
    +
    + +
    +

    Account Center

    +

    ${escapeHtml(userName)}

    +

    ${escapeHtml(userEmail)}

    +
    + ${escapeHtml(role)} + ${escapeHtml(status)} + ${escapeHtml(accountMode)} +
    +
    +
    + + +
    +
    + +
    + ${profileStat("账户积分", credits, "插件采集共用余额", "gold")} + ${profileStat("AI Tokens", aiTokens, aiReady ? "AI 分析可用额度" : "AI 引擎待管理员启用", "jade")} + ${profileStat("插件 Token", tokenCount, "可停用 / 可复制", "jade")} + ${profileStat("账号角色", role, "权限和管理范围")} + ${profileStat("账号状态", status, "当前使用状态")} +
    + +
    + + +
    +
    +
    +

    Shortcuts

    +

    常用入口

    +

    按真实工作顺序进入管理、插件、规则和采集。

    +
    +
    +
    + ${isAdmin() ? profileQuickAction("管理中心", "用户 / 积分 / 兑换码", "admin", "primary") : ""} + ${profileQuickAction("采集插件", "服务地址 / Token / 更新", "plugin")} + ${profileQuickAction("规则配置", "赛道 / 利润 / 风险词", "rules", "gold")} + ${profileQuickAction("采集中心", "继续整理候选款", "capture")} +
    +
    +
    + ${ + state.user + ? ` +
    +
    +

    Recharge

    +

    兑换采集积分 / AI Tokens

    +

    输入管理员发放的兑换码,为当前账号充值采集积分或 AI tokens。插件每保存 1 条线索消耗 1 积分,AI 分析按模型返回 tokens 计费。

    +
    +
    + + +
    +
    + ` + : "" + } +
    + `; +} + +function renderBulkToolbar() { + const selectedCount = state.selectedIds.size; + const hasSelection = selectedCount > 0; + els.bulkToolbar.hidden = !hasSelection || !SELECTABLE_VIEWS.has(state.view); + els.bulkToolbar.classList.toggle("confirming", state.bulkDeletePending); + els.bulkStatusText.textContent = state.bulkDeletePending ? "确认删除" : "已选"; + els.selectedCount.textContent = selectedCount; + els.selectVisible.hidden = state.bulkDeletePending; + els.clearSelection.hidden = state.bulkDeletePending; + els.deleteSelected.hidden = state.bulkDeletePending; + els.confirmDelete.hidden = !state.bulkDeletePending; + els.cancelDelete.hidden = !state.bulkDeletePending; + els.mobileStatusMenu.hidden = state.bulkDeletePending; + els.deleteSelected.disabled = !hasSelection; + els.clearSelection.disabled = !hasSelection; + els.tagSelected.disabled = !hasSelection; + els.mobileStatusMenu.disabled = !hasSelection; + els.confirmDelete.disabled = !hasSelection; + document.querySelectorAll("[data-bulk-status]").forEach((button) => { + button.disabled = !hasSelection; + button.hidden = state.bulkDeletePending; + }); + els.tagSelected.hidden = state.bulkDeletePending; + els.selectVisible.disabled = visibleItems().length === 0 || selectedCount === visibleItems().length; +} + +function renderCard(item) { + const decisionClass = item.decision === "拿样" ? "sample" : item.decision === "继续观察" ? "watch" : "reject"; + const selected = state.selectedIds.has(item.id); + const riskClass = item.riskLevel === "high" ? "danger" : item.riskLevel === "medium" ? "warning" : "success"; + const riskText = item.riskLevel === "high" ? "高风险" : item.riskLevel === "medium" ? "有风险" : "低风险"; + const profitClass = + item.profit?.recommendation === "可跑量" ? "success" : item.profit?.recommendation === "可测试" ? "warning" : "danger"; + const image = item.image + ? `${escapeAttr(item.title || ` + : `
    待补商品图
    `; + const tags = [...(item.tags || []), ...(item.autoTags || [])] + .slice(0, 4) + .map((tag) => `${escapeHtml(tag)}`) + .join(""); + const supplier = item.supplier || item.shop || (item.platform === "1688" ? "待确认厂家" : "竞品店铺"); + const priceLabel = item.platform === "1688" ? "拿样价" : "竞品价"; + + return ` +
    + + ${image} +
    +
    + ${escapeHtml(item.platform)} + ${escapeHtml(item.decision)} · ${escapeHtml(String(item.score || 0))} +
    +

    ${escapeHtml(item.title || "未命名候选款")}

    +
    + ${escapeHtml(item.profit?.recommendation || "待测算")} + ${riskText} + ${item.platform === "1688" ? '1688 货源' : ""} +
    +
    + ${escapeHtml(supplier)} + ${escapeHtml(priceLabel)} ${money(item.price)} +
    +
    ${tags}
    +
    +
    赛道${escapeHtml(item.track || "未分类")}
    +
    建议直播价${money(item.profit?.targetPrice)}
    +
    毛利${money(item.profit?.grossProfit)}
    +
    状态${escapeHtml(statusLabel(item.status))}
    +
    +
    + + 打开原页 +
    +
    +
    + `; +} + +function renderCompactItem(item) { + return ` + + `; +} + +function renderPipelineItem(item) { + return ` +
    +
    +

    ${escapeHtml(item.title || "未命名候选款")}

    +

    ${escapeHtml(item.platform)} · ${escapeHtml(item.track)} · ${money(item.price)} → ${money(item.profit?.targetPrice)}

    +
    +
    + + + + +
    +
    + `; +} + +function bindCardActions() { + els.grid.querySelectorAll("[data-action='select-item']").forEach((checkbox) => { + checkbox.addEventListener("change", () => setItemSelected(checkbox.dataset.id, checkbox.checked)); + }); + + els.grid.querySelectorAll("[data-action='detail']").forEach((button) => { + button.addEventListener("click", () => { + const card = button.closest(".item-card"); + const item = findItem(card.dataset.id); + if (item) showDetail(item); + }); + }); +} + +function bindWorkbenchActions() { + els.workbench.querySelectorAll("[data-switch-view]").forEach((button) => { + button.addEventListener("click", () => setView(button.dataset.switchView)); + }); + els.workbench.querySelectorAll("[data-clear-filters]").forEach((button) => { + button.addEventListener("click", clearFilters); + }); + els.workbench.querySelectorAll("[data-source-1688]").forEach((button) => { + button.addEventListener("click", () => { + state.filters.source = "only1688"; + els.source.value = "only1688"; + clearSelection(); + loadItems(); + }); + }); + els.workbench.querySelectorAll("[data-open-id]").forEach((button) => { + button.addEventListener("click", () => { + const item = findItem(button.dataset.openId); + if (item) showDetail(item); + }); + }); + els.workbench.querySelectorAll("[data-quick-status]").forEach((button) => { + button.addEventListener("click", async () => { + const [id, status] = button.dataset.quickStatus.split(":"); + await updateItem(id, { status }); + await loadItems(); + }); + }); +} + +function bindSettingsActions() { + els.workbench.querySelector("[data-open-auth-gate]")?.addEventListener("click", () => { + openAuthPage(); + }); + els.workbench.querySelector("#addTrackRuleBtn")?.addEventListener("click", () => { + state.rules.tracks.push({ name: "新赛道", keywords: ["关键词"] }); + render(); + }); + els.workbench.querySelectorAll("[data-remove-track]").forEach((button) => { + button.addEventListener("click", () => { + state.rules.tracks.splice(Number(button.dataset.removeTrack), 1); + render(); + }); + }); + els.workbench.querySelector("#saveRulesBtn")?.addEventListener("click", saveRulesFromEditor); + els.workbench.querySelector("#createPluginTokenBtn")?.addEventListener("click", createTokenFromEditor); + els.workbench.querySelector("#copyPluginConfigBtn")?.addEventListener("click", copyPluginConfig); + els.workbench.querySelectorAll("[data-copy-token]").forEach((button) => { + button.addEventListener("click", () => { + copyPluginToken(button.dataset.copyToken).catch((error) => alert(error.message || "复制 Token 失败")); + }); + }); + els.workbench.querySelectorAll("[data-copy-token-config]").forEach((button) => { + button.addEventListener("click", () => { + copyPluginToken(button.dataset.copyTokenConfig, true).catch((error) => alert(error.message || "复制配置失败")); + }); + }); + els.workbench.querySelectorAll("[data-plugin-status]").forEach((button) => { + button.addEventListener("click", () => { + const [id, status] = button.dataset.pluginStatus.split(":"); + updatePluginTokenStatus(id, status).catch((error) => alert(error.message || "更新 Token 失败")); + }); + }); + els.workbench.querySelectorAll("[data-plugin-delete]").forEach((button) => { + button.addEventListener("click", () => { + deletePluginToken(button.dataset.pluginDelete).catch((error) => alert(error.message || "删除 Token 失败")); + }); + }); +} + +function bindAdminActions() { + bindWorkbenchActions(); + els.workbench.querySelector("[data-refresh-admin]")?.addEventListener("click", () => { + loadAdminUsers().catch((error) => alert(error.message || "刷新用户失败")); + }); + els.workbench.querySelectorAll("[data-user-status]").forEach((button) => { + button.addEventListener("click", () => { + const [id, status] = button.dataset.userStatus.split(":"); + updateAdminUserStatus(id, status).catch((error) => alert(error.message || "更新用户状态失败")); + }); + }); + els.workbench.querySelector("#createCreditCodeBtn")?.addEventListener("click", () => { + createCreditCodeFromAdmin().catch((error) => alert(error.message || "生成兑换码失败")); + }); + els.workbench.querySelector("#saveAiSettingsBtn")?.addEventListener("click", () => { + saveAiSettingsFromAdmin().catch((error) => { + state.aiAdminMessage = error.message || "保存 AI 配置失败"; + render(); + }); + }); + els.workbench.querySelector("#testAiSettingsBtn")?.addEventListener("click", () => { + testAiSettingsFromAdmin().catch((error) => { + state.aiAdminMessage = error.message || "AI 连接测试失败"; + render(); + }); + }); + els.workbench.querySelectorAll("[data-copy-credit-code]").forEach((button) => { + button.addEventListener("click", () => { + copyText(button.dataset.copyCreditCode).catch((error) => alert(error.message || "复制兑换码失败")); + }); + }); +} + +function bindProfileActions() { + bindWorkbenchActions(); + els.workbench.querySelectorAll("[data-export-csv]").forEach((button) => { + button.addEventListener("click", exportCsv); + }); + els.workbench.querySelector("#redeemCreditBtn")?.addEventListener("click", () => { + redeemCreditCodeFromProfile().catch((error) => alert(error.message || "兑换失败")); + }); +} + +function showDetail(item) { + const reasons = (item.reasons || []).map((reason) => `
  • ${escapeHtml(reason)}
  • `).join(""); + const risks = (item.risks || []).map((risk) => `
  • ${escapeHtml(risk)}
  • `).join(""); + const aiResult = state.aiAnalysisByItem[item.id] || null; + const pitch = item.livePitch || {}; + const talkingPoints = (pitch.talkingPoints || []).map((point) => `
  • ${escapeHtml(point)}
  • `).join(""); + const scenes = (pitch.scenes || []).map((scene) => `${escapeHtml(scene)}`).join(""); + const questions = (pitch.supplierQuestions || []).map((question) => `
  • ${escapeHtml(question)}
  • `).join(""); + const image = item.image + ? `${escapeAttr(item.title || ` + : `
    无图片
    `; + const riskClass = item.riskLevel === "high" ? "danger" : item.riskLevel === "medium" ? "warning" : "success"; + const profitClass = + item.profit?.recommendation === "可跑量" ? "success" : item.profit?.recommendation === "可测试" ? "warning" : "danger"; + + els.dialogContent.innerHTML = ` +
    +
    + ${image} +
    + ${escapeHtml(item.profit?.recommendation || "待测算")} + ${escapeHtml(item.riskLevel === "high" ? "高风险" : item.riskLevel === "medium" ? "有风险" : "低风险")} + ${escapeHtml(statusLabel(item.status))} +
    +
    +
    +
    +

    ${escapeHtml(item.platform)} · ${escapeHtml(item.track)}

    +

    ${escapeHtml(item.title || "未命名候选款")}

    +
    +
    +
    综合评分${escapeHtml(String(item.score || 0))}
    +
    建议动作${escapeHtml(item.decision || "待判断")}
    +
    进货价${money(item.price)}
    +
    建议直播价${money(item.profit?.targetPrice)}
    +
    预计毛利${money(item.profit?.grossProfit)}
    +
    毛利率${percent(item.profit?.grossMarginRate)}
    +
    +
    +
    + 利润模型 + ${escapeHtml(item.profit?.recommendation || "待测算")} +
    +
    +
    总成本${money(item.profit?.totalCost)}
    +
    快递${money(item.profit?.shippingFee)}
    +
    包装${money(item.profit?.packagingFee)}
    +
    让利${money(item.profit?.promoFee)}
    +
    +
    + + + +
    +
    + AI 采购参谋 + ${escapeHtml(aiStatusLabel())} +
    + ${renderAiAnalysisBlock(aiResult)} +
    + + ${escapeHtml(aiUsageHint())} +
    +
    +
    +
    + 直播卖点 + ${escapeHtml(pitch.audience || "直播间用户")} +
    +
      ${talkingPoints || "
    • 暂无
    • "}
    +
    ${scenes}
    +

    ${escapeHtml(pitch.priceScript || "")}

    +
    +
    +
    + 问供应商 + ${escapeHtml(item.supplier || item.shop || "待确认")} +
    +
      ${questions || "
    • 暂无
    • "}
    +
    +
    +
    + 评分理由 + ${escapeHtml(String(item.score || 0))} 分 +
    +
      ${reasons || "
    • 暂无
    • "}
    +
    +
    +
    + 风险提醒 + ${escapeHtml(item.riskLevel || "low")} +
    +
      ${risks || "
    • 暂无
    • "}
    +
    +
    + + +
    +
    +
    + `; + + if (!els.dialog.open) els.dialog.showModal(); + document.querySelector("#saveDetailBtn").addEventListener("click", async () => { + await updateItem(item.id, { + status: document.querySelector("#dialogStatus").value, + tags: splitTags(document.querySelector("#dialogTags").value), + notes: document.querySelector("#dialogNotes").value, + }); + els.dialog.close(); + await loadItems(); + }); + document.querySelector("#deleteDetailBtn").addEventListener("click", async () => { + if (!confirm("确认删除这条候选款?")) return; + await fetchJson(`/api/items/${encodeURIComponent(item.id)}`, { method: "DELETE" }); + state.selectedIds.delete(item.id); + els.dialog.close(); + await loadItems(); + }); + document.querySelector("#runAiAnalysisBtn")?.addEventListener("click", async () => { + await runAiAnalysisForItem(item.id); + const freshItem = findItem(item.id) || item; + showDetail(freshItem); + }); +} + +function canRunAiAnalysis() { + return Boolean(state.aiStatus?.ai?.enabled && Number(state.user?.aiTokens || 0) > 0); +} + +function aiStatusLabel() { + if (!state.aiStatus?.ai?.enabled) return "管理员未启用"; + if (Number(state.user?.aiTokens || 0) <= 0) return "AI Tokens 不足"; + return `${state.aiStatus.ai.model || "AI"} · 余额 ${Number(state.user?.aiTokens || 0)}`; +} + +function aiUsageHint() { + if (!state.aiStatus?.ai?.enabled) return "请管理员先在管理中心配置模型。"; + if (Number(state.user?.aiTokens || 0) <= 0) return "请先在账号页兑换 AI token 充值码。"; + return `按实际模型用量扣费,当前倍率 ${state.aiStatus.ai.tokenUnitCost || 1}。`; +} + +function renderAiAnalysisBlock(result) { + if (!result) { + return ` +
    + 让 AI 帮你复核这条商品 +

    会基于标题、图片链接、价格、平台、供应商、评分规则和利润模型,输出采购建议、直播卖点、风险和供应商问题。

    +
    + `; + } + const analysis = result.analysis || {}; + return ` +
    +

    ${escapeHtml(analysis.summary || "AI 已返回分析结果。")}

    + ${analysis.purchaseAdvice ? `
    采购建议${escapeHtml(analysis.purchaseAdvice)}
    ` : ""} + ${renderAiList("直播卖点", analysis.sellingPoints)} + ${renderAiList("风险提醒", analysis.risks, "danger")} + ${renderAiList("问供应商", analysis.supplierQuestions)} + 本次扣费 ${escapeHtml(String(result.billing?.deductedAiTokens || 0))} AI Tokens,剩余 ${escapeHtml(String(result.billing?.remainingAiTokens ?? state.user?.aiTokens ?? 0))}。 +
    + `; +} + +function renderAiList(label, values, tone = "") { + const list = Array.isArray(values) ? values.filter(Boolean) : values ? [values] : []; + if (list.length === 0) return ""; + return ` +
    + ${escapeHtml(label)} +
      ${list.map((value) => `
    • ${escapeHtml(value)}
    • `).join("")}
    +
    + `; +} + +async function runAiAnalysisForItem(itemId) { + const button = document.querySelector("#runAiAnalysisBtn"); + if (button) { + button.disabled = true; + button.textContent = "AI 分析中..."; + } + try { + const payload = await fetchJson("/api/ai/analyze", { + method: "POST", + body: { type: "item_analysis", itemId }, + }); + state.aiAnalysisByItem[itemId] = payload; + state.user = { + ...state.user, + aiTokens: payload.billing?.remainingAiTokens ?? state.user?.aiTokens ?? 0, + }; + await loadConfig(); + } catch (error) { + alert(error.status === 402 ? "AI Tokens 不足,请先兑换充值码。" : error.message || "AI 分析失败"); + } +} + +function setItemSelected(id, selected) { + if (!id) return; + if (selected) state.selectedIds.add(id); + else state.selectedIds.delete(id); + state.bulkDeletePending = false; + render(); +} + +function selectVisibleItems() { + visibleItems().forEach((item) => state.selectedIds.add(item.id)); + state.bulkDeletePending = false; + render(); +} + +function clearSelection() { + state.selectedIds.clear(); + state.bulkDeletePending = false; + render(); +} + +function requestBulkDelete() { + if (state.selectedIds.size === 0) return; + state.bulkDeletePending = true; + render(); +} + +async function confirmBulkDelete() { + const ids = Array.from(state.selectedIds); + if (ids.length === 0) return; + await fetchJson("/api/items/bulk-delete", { + method: "POST", + body: { ids }, + }); + ids.forEach((id) => state.selectedIds.delete(id)); + state.bulkDeletePending = false; + await loadItems(); +} + +async function bulkUpdateSelected(patch = {}, addTags = []) { + const ids = Array.from(state.selectedIds); + if (ids.length === 0) return; + await fetchJson("/api/items/bulk-update", { + method: "POST", + body: { ids, patch, addTags }, + }); + await loadItems(); +} + +function cancelBulkDelete() { + state.bulkDeletePending = false; + render(); +} + +function pruneSelection() { + const ids = new Set([...state.items, ...state.sampleItems].map((item) => item.id)); + for (const id of state.selectedIds) { + if (!ids.has(id)) state.selectedIds.delete(id); + } +} + +async function updateItem(id, patch) { + return fetchJson(`/api/items/${encodeURIComponent(id)}`, { + method: "PATCH", + body: patch, + }); +} + +async function saveRulesFromEditor() { + const tracks = Array.from(els.workbench.querySelectorAll("[data-track-row]")) + .map((row) => ({ + name: row.querySelector("[data-track-name]").value.trim(), + keywords: splitTags(row.querySelector("[data-track-keywords]").value), + })) + .filter((track) => track.name && track.keywords.length > 0); + const rules = { + tracks, + riskKeywords: linesFrom("#riskKeywordsInput"), + captureHints: linesFrom("#captureHintsInput"), + autoTags: linesFrom("#autoTagsInput"), + profitModel: { + shippingFee: numberFrom("#profit-shippingFee"), + packagingFee: numberFrom("#profit-packagingFee"), + platformFeeRate: numberFrom("#profit-platformFeeRate"), + promoFee: numberFrom("#profit-promoFee"), + maxRunningPrice: numberFrom("#profit-maxRunningPrice"), + }, + }; + const payload = await fetchJson("/api/rules", { method: "PUT", body: { rules } }); + state.rules = payload.rules; + await loadItems(); +} + +async function createTokenFromEditor() { + const payload = await fetchJson("/api/plugin-tokens", { + method: "POST", + body: { name: "Chrome 云采集插件" }, + }); + state.lastPluginToken = payload.token; + state.lastPluginTokenId = payload.pluginToken?.id || ""; + await loadConfig(); + render(); +} + +async function updatePluginTokenStatus(id, status) { + await fetchJson(`/api/plugin-tokens/${encodeURIComponent(id)}`, { + method: "PATCH", + body: { status }, + }); + await loadConfig(); + render(); +} + +async function deletePluginToken(id) { + if (!confirm("确认删除这个插件 Token?删除后浏览器插件需要重新生成 Token 才能采集。")) return; + await fetchJson(`/api/plugin-tokens/${encodeURIComponent(id)}`, { method: "DELETE" }); + await loadConfig(); + render(); +} + +async function updateAdminUserStatus(id, status) { + await fetchJson(`/api/admin/users/${encodeURIComponent(id)}/status`, { + method: "PATCH", + body: { status }, + }); + await loadAdminUsers(); +} + +async function createCreditCodeFromAdmin() { + const credits = Number(els.workbench.querySelector("#creditAmountInput")?.value || 0); + const aiTokens = Number(els.workbench.querySelector("#aiTokenAmountInput")?.value || 0); + const note = els.workbench.querySelector("#creditNoteInput")?.value || ""; + const payload = await fetchJson("/api/admin/credit-codes", { + method: "POST", + body: { credits, aiTokens, note }, + }); + await copyText(payload.code); + await loadAdminUsers(); +} + +async function saveAiSettingsFromAdmin() { + const body = { + enabled: Boolean(els.workbench.querySelector("#aiEnabledInput")?.checked), + provider: els.workbench.querySelector("#aiProviderInput")?.value || "", + baseUrl: els.workbench.querySelector("#aiBaseUrlInput")?.value || "", + model: els.workbench.querySelector("#aiModelInput")?.value || "", + apiKey: els.workbench.querySelector("#aiApiKeyInput")?.value || "", + temperature: numberFrom("#aiTemperatureInput"), + maxOutputTokens: numberFrom("#aiMaxOutputInput"), + timeoutMs: numberFrom("#aiTimeoutInput"), + tokenUnitCost: numberFrom("#aiTokenUnitCostInput"), + prompts: { + itemAnalysis: els.workbench.querySelector("#aiPromptItemInput")?.value || "", + procurementReport: els.workbench.querySelector("#aiPromptProcurementInput")?.value || "", + ruleGeneration: els.workbench.querySelector("#aiPromptRulesInput")?.value || "", + }, + }; + const payload = await fetchJson("/api/admin/ai-settings", { method: "PUT", body }); + state.aiSettings = payload.aiSettings; + state.aiAdminMessage = "AI 配置已保存"; + await loadConfig(); + render(); +} + +async function testAiSettingsFromAdmin() { + state.aiAdminMessage = "正在测试 AI 连接..."; + render(); + const payload = await fetchJson("/api/admin/ai-settings/test", { method: "POST", body: {} }); + state.aiAdminMessage = `连接正常 · ${payload.ai?.model || "模型"} · ${payload.usage?.totalTokens || 0} tokens`; + render(); +} + +async function redeemCreditCodeFromProfile() { + const input = els.workbench.querySelector("#creditCodeInput"); + const code = input?.value || ""; + const payload = await fetchJson("/api/credits/redeem", { + method: "POST", + body: { code }, + }); + state.user = payload.user; + if (input) input.value = ""; + await loadConfig(); + render(); +} + +async function copyPluginConfig() { + const text = `API_BASE=${location.origin}\nPLUGIN_TOKEN=${state.lastPluginToken || ""}`; + await copyText(text); + const tokenInput = document.querySelector("#latestPluginToken"); + if (tokenInput) tokenInput.value = state.lastPluginToken || "已复制服务地址,生成 Token 后再复制完整配置"; +} + +async function copyPluginToken(id, includeApiBase = false) { + const token = state.pluginTokens.find((candidate) => candidate.id === id)?.token; + if (!token) { + alert("这个旧 Token 没有保存明文。请重新生成一个 Token 后复制,并删除不用的旧 Token。"); + return; + } + const text = includeApiBase ? `API_BASE=${location.origin}\nPLUGIN_TOKEN=${token}` : token; + await copyText(text); + const tokenInput = document.querySelector("#latestPluginToken"); + if (tokenInput) tokenInput.value = token; +} + +async function copyText(text) { + const value = String(text || ""); + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + +async function exportCsv() { + const response = await apiFetch("/api/export.csv"); + if (!response.ok) throw new Error("导出失败"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "product-sourcing-captures.csv"; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +function recentCaptureItems() { + return [...state.items].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); +} + +function competitorItems() { + return state.items.filter((item) => item.platform !== "1688"); +} + +function supplierItems() { + return state.items.filter((item) => item.platform === "1688"); +} + +function procurementCandidates() { + const sampleIds = new Set(state.sampleItems.map((item) => item.id)); + return state.items + .filter( + (item) => + sampleIds.has(item.id) || + item.decision === "拿样" || + item.profit?.recommendation === "可跑量" || + item.score >= 70, + ) + .sort( + (a, b) => + Number(b.profit?.recommendation === "可跑量") - Number(a.profit?.recommendation === "可跑量") || + Number(b.decision === "拿样") - Number(a.decision === "拿样") || + (b.score || 0) - (a.score || 0), + ); +} + +function visibleItems() { + if (state.view === "capture") return recentCaptureItems().slice(0, 24); + if (state.view === "competitors") return competitorItems(); + if (state.view === "suppliers") return supplierItems(); + if (state.view === "testing") return state.sampleItems; + if (state.view === "decisions") return procurementCandidates(); + return []; +} + +function getEmptyStateCopy() { + return { + capture: { + title: "还没有采集线索", + body: "先配置浏览器插件,然后在淘宝、小红书或 1688 页面点击采集当前页。", + primaryLabel: "配置采集插件", + primaryView: "plugin", + secondaryLabel: "清空筛选", + }, + competitors: { + title: "竞品库还没有结果", + body: "去淘宝或小红书搜索饰品关键词,采集直播热款、搜索结果和评论/销量可见的页面。", + primaryLabel: "去采集中心", + primaryView: "capture", + secondaryLabel: "清空筛选", + }, + suppliers: { + title: "货源库还没有 1688 数据", + body: "去 1688 搜同款或近似款,采集主图、链接、拿样价、厂家和保障信息。", + primaryLabel: "配置插件", + primaryView: "plugin", + secondaryLabel: "清空筛选", + }, + testing: { + title: "还没有拿样测试款", + body: "在竞品库或采购决策里勾选款式,批量标记问供应商或已拿样。", + primaryLabel: "看采购决策", + primaryView: "decisions", + secondaryLabel: "清空筛选", + }, + decisions: { + title: "暂无采购决策候选", + body: "继续补充竞品和 1688 货源,系统会把高分、可跑量、建议拿样的款集中到这里。", + primaryLabel: "去竞品库", + primaryView: "competitors", + secondaryLabel: "清空筛选", + }, + }[state.view]; +} + +function filterValueLabel(key, value) { + if (key === "platform") { + return { xiaohongshu: "小红书", taobao: "淘宝", tmall: "天猫", 1688: "1688", other: "其他" }[value] || value; + } + if (key === "status") return statusLabel(value); + if (key === "priceBand") { + return { + under10: "10 元以内", + "10to30": "10-30 元", + "30to100": "30-100 元", + over100: "100 元以上", + unknown: "待确认", + }[value] || value; + } + if (key === "source") return { only1688: "只看 1688", non1688: "竞品平台" }[value] || value; + if (key === "risk") return { any: "有风险提醒", high: "高风险", low: "低风险" }[value] || value; + return value; +} + +function findItem(id) { + return [...state.items, ...state.sampleItems, ...state.groups.flatMap((group) => group.items || [])].find((item) => item.id === id); +} + +function setView(view, button) { + if (view === "admin" && !isAdmin()) view = "account"; + state.view = view; + syncActiveNav(view, button); + clearSelection(); + if (view === "admin") { + loadAdminUsers(); + return; + } + render(); +} + +function setDecisionFilter(value, button) { + state.view = "competitors"; + state.filters.decision = value; + els.decision.value = value; + syncActiveNav("competitors", button); + clearSelection(); + loadItems(); +} + +function syncActiveNav(view, activeButton) { + document.querySelectorAll(".nav-button").forEach((item) => item.classList.remove("active")); + document.querySelectorAll(".mobile-tab").forEach((item) => item.classList.remove("active")); + if (activeButton?.classList.contains("nav-button")) activeButton.classList.add("active"); + const mobileTarget = document.querySelector(`[data-mobile-view="${view}"]`); + if (mobileTarget) mobileTarget.classList.add("active"); + const desktopTarget = document.querySelector(`.nav-button[data-view="${view}"]`); + if (!activeButton?.classList.contains("nav-button") && desktopTarget) desktopTarget.classList.add("active"); +} + +function openFilterDialog() { + els.filterSheetBody.innerHTML = ""; + els.filterSheetBody.append( + ...filterControlLabels(), + ); + els.filterDialog.showModal(); +} + +function restoreDesktopFilters() { + if (window.matchMedia("(min-width: 901px)").matches && els.filterSheetBody.contains(els.search)) { + els.filterToolbar.append(...filterControlLabels()); + } +} + +function filterControlLabels() { + return [ + els.search.closest("label"), + els.platform.closest("label"), + els.track.closest("label"), + els.decision.closest("label"), + els.status.closest("label"), + els.tag.closest("label"), + els.priceBand.closest("label"), + els.source.closest("label"), + els.risk.closest("label"), + ].filter(Boolean); +} + +function clearFilters() { + state.filters.q = ""; + state.filters.platform = ""; + state.filters.track = ""; + state.filters.decision = ""; + state.filters.status = ""; + state.filters.tag = ""; + state.filters.priceBand = ""; + state.filters.source = ""; + state.filters.risk = ""; + els.search.value = ""; + els.mobileSearch.value = ""; + els.platform.value = ""; + els.track.value = ""; + els.decision.value = ""; + els.status.value = ""; + els.tag.value = ""; + els.priceBand.value = ""; + els.source.value = ""; + els.risk.value = ""; + loadItems(); +} + +function setKeywordFilter(value) { + const query = value.trim(); + state.filters.q = query; + state.bulkDeletePending = false; + if (els.search.value !== query) els.search.value = query; + if (els.mobileSearch.value !== query) els.mobileSearch.value = query; + clearTimeout(setKeywordFilter._timer); + setKeywordFilter._timer = setTimeout(loadItems, 180); +} + +function setAppLocked(locked) { + state.locked = locked; + state.landingOpen = locked; + renderAuthShell(); + els.statsGrid.hidden = locked; + els.filterToolbar.hidden = locked; + els.workbench.hidden = locked; + els.grid.hidden = locked; + els.empty.hidden = true; + els.bulkToolbar.hidden = true; + els.logout.hidden = !state.user; + renderChrome(); + markAuthReady(); +} + +function renderAuthShell() { + const showLanding = state.locked || state.landingOpen; + els.appShell.classList.toggle("auth-locked", showLanding); + els.authGate.hidden = !showLanding; + els.landingBack.hidden = !state.user; + document.body.classList.toggle("landing-open", showLanding); +} + +function markAuthReady() { + document.body.classList.remove("auth-pending"); +} + +function openAuthPage() { + if (state.user) { + setView("account"); + return; + } + state.landingOpen = true; + renderAuthShell(); + els.authGate.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function openAuthDialog(mode = "login") { + setAuthMode(mode); + setAuthMessage(""); + els.authDialog.dataset.mode = mode === "register" ? "register" : "login"; + els.authDialog.showModal(); +} + +function closeAuthDialog() { + els.authDialog.close(); +} + +function setAuthMode(mode = "login") { + const isRegister = mode === "register"; + const normalizedMode = isRegister ? "register" : "login"; + els.authDialog.dataset.mode = normalizedMode; + els.registerForm.hidden = !isRegister; + els.loginForm.hidden = isRegister; + els.authDialogTitle.textContent = isRegister ? "创建账号" : "登录工作台"; + els.authDialogText.textContent = isRegister + ? "创建账号后会接管当前本地采集数据,并隔离插件 Token 和规则配置。" + : "登录后进入你的直播饰品选品采购工作台。"; + els.authSwitchText.textContent = isRegister ? "已经有账号?" : "还没有账号?"; + els.authSwitchButton.textContent = isRegister ? "登录工作台" : "创建账号"; + els.authSwitchButton.dataset.authSwitch = isRegister ? "login" : "register"; +} + +function closeAuthPage() { + if (state.locked) return; + state.landingOpen = false; + renderAuthShell(); +} + +async function handleAuthSubmit(event, mode) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + setAuthMessage("正在处理..."); + try { + const payload = await fetchJson(`/api/auth/${mode}`, { + skipAuth: true, + method: "POST", + body: Object.fromEntries(formData.entries()), + }); + setSession(payload.token, payload.user); + state.authRequired = true; + state.landingOpen = false; + closeAuthDialog(); + renderAuthShell(); + setAppLocked(false); + await loadConfig(); + await loadItems(); + form.reset(); + setAuthMessage(""); + } catch (error) { + setAuthMessage(error.message || "登录失败"); + } +} + +function setSession(token, user) { + state.sessionToken = token; + state.user = user; + localStorage.setItem(TOKEN_KEY, token); +} + +function clearSession() { + state.sessionToken = ""; + state.user = null; + state.rules = null; + state.pluginTokens = []; + state.adminUsers = []; + state.creditCodes = []; + state.aiSettings = null; + state.aiStatus = null; + state.aiAnalysisByItem = {}; + state.adminLoading = false; + state.adminError = ""; + state.aiAdminMessage = ""; + state.lastPluginToken = ""; + state.lastPluginTokenId = ""; + localStorage.removeItem(TOKEN_KEY); +} + +function logout() { + clearSession(); + setAppLocked(true); +} + +function setAuthMessage(message) { + els.authMessage.textContent = message; +} + +async function apiFetch(url, options = {}) { + const headers = { ...(options.headers || {}) }; + if (options.body !== undefined && !(options.body instanceof FormData)) headers["Content-Type"] = "application/json"; + if (!options.skipAuth && state.sessionToken) headers.Authorization = `Bearer ${state.sessionToken}`; + const response = await fetch(url, { + ...options, + headers, + body: options.body === undefined || options.body instanceof FormData ? options.body : JSON.stringify(options.body), + }); + if (response.status === 401 && !options.skipAuth) { + clearSession(); + setAppLocked(true); + } + return response; +} + +async function fetchJson(url, options = {}) { + const response = await apiFetch(url, options); + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") ? await response.json() : await response.text(); + if (!response.ok) { + const error = new Error(payload?.error || `请求失败:${url}`); + error.status = response.status; + throw error; + } + return payload; +} + +function statusOptions(value) { + const options = [ + ["new", "新采集"], + ["reviewing", "待复核"], + ["asking_supplier", "问供应商"], + ["ordered_sample", "已拿样"], + ["live_testing", "直播测试"], + ["rejected", "已放弃"], + ]; + return options.map(([key, label]) => ``).join(""); +} + +function statusLabel(value) { + return { + new: "新采集", + reviewing: "待复核", + asking_supplier: "问供应商", + ordered_sample: "已拿样", + live_testing: "直播测试", + rejected: "已放弃", + }[value] || value || "新采集"; +} + +function miniStat(label, value) { + return `
    ${escapeHtml(label)}${escapeHtml(String(value || 0))}
    `; +} + +function workflowStep(label, value, index) { + return ` +
    + ${escapeHtml(String(index).padStart(2, "0"))} + ${escapeHtml(label)} + ${escapeHtml(String(value || 0))} +
    + `; +} + +function numberInput(key, label, value, step = "1") { + return ` + + `; +} + +function money(value) { + if (value == null || Number.isNaN(Number(value))) return "待确认"; + return `¥${Number(value).toFixed(2)}`; +} + +function percent(value) { + if (value == null || Number.isNaN(Number(value))) return "待确认"; + return `${Math.round(Number(value) * 100)}%`; +} + +function splitTags(value = "") { + return String(value) + .split(/[,,\n]+/) + .map((tag) => tag.trim()) + .filter(Boolean); +} + +function linesFrom(selector) { + return splitTags(els.workbench.querySelector(selector)?.value || ""); +} + +function numberFrom(selector) { + const value = Number(els.workbench.querySelector(selector)?.value); + return Number.isFinite(value) ? value : null; +} + +function formatDate(value) { + if (!value) return "刚刚"; + return new Date(value).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }); +} + +function formatDateTime(value) { + if (!value) return "从未"; + return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); +} + +function isAdmin() { + return state.user?.role === "admin"; +} + +function roleLabel(value) { + return value === "admin" ? "管理员" : "买手"; +} + +function userStatusLabel(value) { + return value === "disabled" ? "已停用" : "活跃"; +} + +function pluginStatusLabel(value) { + return value === "disabled" ? "已停用" : "启用中"; +} + +function escapeHtml(value = "") { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeAttr(value = "") { + return escapeHtml(value).replaceAll("`", "`"); +} + +document.querySelectorAll(".nav-button").forEach((button) => { + button.addEventListener("click", () => { + if (button.dataset.view) setView(button.dataset.view, button); + }); +}); + +document.querySelectorAll(".mobile-tab").forEach((button) => { + button.addEventListener("click", () => { + const view = button.dataset.mobileView; + if (view) setView(view, button); + }); +}); + +document.querySelectorAll("[data-export-csv]").forEach((button) => { + button.addEventListener("click", () => exportCsv().catch((error) => alert(error.message || "导出失败"))); +}); + +els.registerForm.addEventListener("submit", (event) => handleAuthSubmit(event, "register")); +els.loginForm.addEventListener("submit", (event) => handleAuthSubmit(event, "login")); +document.querySelectorAll("[data-auth-mode]").forEach((button) => { + button.addEventListener("click", () => openAuthDialog(button.dataset.authMode || "login")); +}); +els.authSwitchButton.addEventListener("click", () => setAuthMode(els.authSwitchButton.dataset.authSwitch || "login")); +document.querySelector("[data-auth-close]").addEventListener("click", closeAuthDialog); +els.openAuthPage.addEventListener("click", openAuthPage); +els.mobileAuth.addEventListener("click", openAuthPage); +els.landingBack.addEventListener("click", closeAuthPage); +els.logout.addEventListener("click", logout); +els.refresh.addEventListener("click", loadItems); +els.mobileRefresh.addEventListener("click", loadItems); +els.mobileFilter.addEventListener("click", openFilterDialog); +els.clearFilters.addEventListener("click", clearFilters); +els.emptyPrimary.addEventListener("click", () => { + const targetView = els.emptyPrimary.dataset.emptyPrimaryView; + if (targetView) setView(targetView); +}); +els.emptySecondary.addEventListener("click", clearFilters); +els.filterDialog.addEventListener("close", restoreDesktopFilters); +window.addEventListener("resize", restoreDesktopFilters); +els.selectVisible.addEventListener("click", selectVisibleItems); +els.clearSelection.addEventListener("click", clearSelection); +els.deleteSelected.addEventListener("click", requestBulkDelete); +els.confirmDelete.addEventListener("click", () => { + confirmBulkDelete().catch((error) => { + alert(error.message || "批量删除失败"); + state.bulkDeletePending = false; + render(); + }); +}); +els.cancelDelete.addEventListener("click", cancelBulkDelete); +els.tagSelected.addEventListener("click", () => { + const tag = prompt("给已选款添加什么标签?例如:低价跑量、藏式主推、直播待测"); + if (!tag) return; + bulkUpdateSelected({}, splitTags(tag)).catch((error) => alert(error.message || "批量加标签失败")); +}); +els.mobileStatusMenu.addEventListener("click", () => { + if (state.selectedIds.size === 0) return; + els.statusActionDialog.showModal(); +}); +document.querySelectorAll("[data-bulk-status]").forEach((button) => { + button.addEventListener("click", () => { + bulkUpdateSelected({ status: button.dataset.bulkStatus }).catch((error) => alert(error.message || "批量改状态失败")); + }); +}); +document.querySelectorAll("[data-mobile-bulk-status]").forEach((button) => { + button.addEventListener("click", () => { + els.statusActionDialog.close(); + bulkUpdateSelected({ status: button.dataset.mobileBulkStatus }).catch((error) => alert(error.message || "批量改状态失败")); + }); +}); +els.search.addEventListener("input", () => setKeywordFilter(els.search.value)); +els.mobileSearch.addEventListener("input", () => setKeywordFilter(els.mobileSearch.value)); +els.platform.addEventListener("change", () => { + state.filters.platform = els.platform.value; + state.bulkDeletePending = false; + loadItems(); +}); +els.track.addEventListener("change", () => { + state.filters.track = els.track.value; + state.bulkDeletePending = false; + loadItems(); +}); +els.decision.addEventListener("change", () => { + state.filters.decision = els.decision.value; + if (!FILTERABLE_VIEWS.has(state.view)) state.view = "competitors"; + syncActiveNav(state.view); + state.bulkDeletePending = false; + clearSelection(); + loadItems(); +}); +els.status.addEventListener("change", () => { + state.filters.status = els.status.value; + state.bulkDeletePending = false; + loadItems(); +}); +els.tag.addEventListener("input", () => { + state.filters.tag = els.tag.value.trim(); + state.bulkDeletePending = false; + clearTimeout(els.tag._timer); + els.tag._timer = setTimeout(loadItems, 180); +}); +els.priceBand.addEventListener("change", () => { + state.filters.priceBand = els.priceBand.value; + state.bulkDeletePending = false; + loadItems(); +}); +els.source.addEventListener("change", () => { + state.filters.source = els.source.value; + state.bulkDeletePending = false; + loadItems(); +}); +els.risk.addEventListener("change", () => { + state.filters.risk = els.risk.value; + state.bulkDeletePending = false; + loadItems(); +}); + +init(); diff --git a/public/assets/brand-lockup.svg b/public/assets/brand-lockup.svg new file mode 100644 index 0000000..5834035 --- /dev/null +++ b/public/assets/brand-lockup.svg @@ -0,0 +1,25 @@ + + 选品采购台 + 竞品采集、货源匹配与采购决策 + + + + + + + + + + + + + + + + + + + + 选品采购台 + 竞品采集 · 货源匹配 · 采购决策 + diff --git a/public/assets/brand-mark.png b/public/assets/brand-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..676db1e2173896ce97fc7e039fd29428b352d5b2 GIT binary patch literal 18015 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 + 选品采购台品牌标识 + 扫描框汇聚多平台商品机会,中心标记代表被选中的高价值商品 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/jewelry-buyer-board.png b/public/assets/jewelry-buyer-board.png new file mode 100644 index 0000000000000000000000000000000000000000..883c3b1176dafabc9bd3b5bddf7a24d78c693bcb GIT binary patch literal 97629 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ė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 S*@(~p0000^D*pZy^xmG6)385e*6a z=O5}VOvpV5FtK;c&U#D?EV%#U{d_{#!UBt8 zXQyLUYXjB6=?%?%ls39)k!g~0kNUI zp(0V;{q#|Fcm7IJ7EZDFUQY`N5(aUEicqyK_HY# zpYO*~Bf~h*;O+rFW+*LIcPm(6&)N{M7E;Psz2{K;z|^wkf112;-<8KbNLBinJ{n;R z>XC7X3hWJK=~m0JT2uKShr0m~d`H-D6|DPcpSWdeuL~dH2=%fySK;5y0YT<~1y>35 z>HJg`{wwZnoq?1#Zh+ryxAe~=?0Bl|*whaS+&K-X@0xp`E0&7B3*-u5d`BG7QuXCK z)BuQ#cVrC1XQCfZB~>5@bZ&J=BvaU|GIg(hBFXJIFqVxW=(|DObCbu))49;fe+PnP z5kb$WunQhvLhm%mNR1!tdPR)(fr?5-E|lxh?d-f0VOK=ZY%zqyuOGVLMPRT8%;abh zsA9;RKtqYvEmlrv#X?5l>h>hX)v1~CR@xX$53&O>FbiCl*ZN5{>gcxa23HPdegHM9 z$`)I|Lxk5Njl!mFji6Zcce-sfxBOEX=@2D}JRVYEl#Qj@*Xs!MzrA?ym^pwB&&NYC zCwhOQa^{e}%Xm8r1Yf}v53VXd_G>?sh#TfTd)Uj?(k$EiFPH1Ep8W<*5LDRy8QdNi z5E$7=yxbeoXVK0#&ikcRc8NUW?i8MWg$^&pf9 zcq8+6pf3GVT|*>Zm~5juR{a!G8vChCQ_6Q^KpF8z77db)R6ve&53&*d17*HtMD%4e zAsQ3hR>k-X>lJZmwHS97d|B@ia^YJA<(zTiZpo`(=lKn#r44$DWkJUG=_n|`{-R(s z9YrkQeT5}YRpF^3T<}a`9-LI9==Vfb0wu(jL?JK=A16UEP6<}!6VU)Jglaf=)Q2v4 z_sE#K3-mOMOM&TnDOO?^e3s^rq>5-0puG=KZV#r>5`035jRXm3p~DD!rfh6Lxx{p! z;}BaKzao|`R@Dc$)ugRDJD2=d8ODrxr(5DF;z2~8a%P-6lEge1O5afY{`3U|QZ+pL z3wH@yLJR^S>E!MD=hw-aur#`SuyTlbM|&@niVZG>!~Eu2$GmP+0iMn1G|K>i_}SyX z;zojW()`hg!>CH}j;26M>Ftei@6LLIpc zgG~g@8xs|>L6j{9gncu!BmwpcDP!v%d|%W{(IGutJ4>Zy0s@JCZV${N!Q$6MLntBo ztv=nrN_lk;@<&^p4~V`AxiUq(Ix0jNoj(W(!l+7owE42&9CLU)bx(IPsfMY5@yg{z z;FQFXC)d?`{T{9GI~$u`qI4ySkpA$dxS%kWRvhB3j@jo6fpiaaREX*Rx%h#iZC1`o zRUgbuJVaT5bIZl)V^B)vMF=#zb?W`6$FI>K%#f(t7f$ORJ$@4)ieQ!UMZAVU4BMA7 zArNP$Vrpb)jwq>w;jLssMm*8rU;McOm{D%;5cqg#gjlAqH?Xh3$BEY=fQU}kBl>*c zpb>l^QyHOs+X~VFUikQ1jG4DI>g_Fzl2umczSygySk2ON1zIl@BkQ?bSHe5507f$a zML^6_Q3*4lqdtyz!VmEir7RkI=F1geb|<+A%TM8XyvG_X`Dc_hvYST#tuQ^$l0s@-90$zZl^}5h^su~--n4WF6%UQ~@ z5c76s>4{W$%vzSAypug{EMINTNY6JkbMABk0t*?sTyO=JnotJqks7HhxPP}4?8oAW zT)3U?@&4SoIR|sO&DY=)_YvfU22cOvFI!=!5AIrBfklOW(?B7dkY|iUO$$q;j6|ig zDnh~czauJw3>jKQ!*;=xwTo~jh`v$0?F51GAVZI+1&&mN@Ktj%a6|qYVD5)#=G*lR z`*FCo;S9S>Wht;!)PL%yq~1-JctwYuw;p9#m2!aIC}Nly_2DAxOb%?fz9`r$t%~5+ zIk}8Sk4It6{t13>aTUi^xPCLa&nXfrWyq+bXzSdGLtbceGibhq0hiA;f)7dLT~xelQZaU!<#O!PWysP`$8iQYT@FSzJj{k5yZKl5UClmCLGyX(_4|Bznm6a_*Y5|omV@8a4D{<(ITS*= zlyYJa?19`I{r!x_9vGp^y5Wq`4@&Mu#4G-beo$GMZqU?gRL8pYi1OWSbDfD+4T+^spKsW4Pn4JBTI z6Dik@bheI~BRy-)b39W#NVtZF( z^o0dmap!g#RQx#kWnxmC~7afOk;cGmM=f5;K>Z6SNKQLp3M z_hwuivtR2fN12V9v)W!u56T!ybs|1J6Qoa+^{rMNqc$!3vXrmzgoT9@FE=+YN+Yw` zlge7cGmz^32WirqLo}bV>N4eD>-R=jfmF%+j>e-KZZWM6-h-s)a{W0h*jTL*iBnIjw92oF-*vAhsN)9*Cm`BId$*0f%3ol{6Gy1pBtl*+qYjI~FT1s@(oJ2VGiBi}Z9}h|YnFx!Rs{V|?w*0%z6SU9gUhPKb?xS4}~UmyRu_=mCozG89G@ulyNy!o_GAL>-zVVx3#r$?zMP ze?*miRaMuMBE8s3k9aK9gr^u7n01=$Vt8T;8B5|G{0{UYp>Z=inM!ZfU5$)vyH+he zYE4&rv8Q6i`05S>!IYF}T?1 zNaA%CB#(JJ8WxeFUPGmDIyud|>npNKOn`^{aG*X!s=kDpZ*#bJ0)Ka&JFS%wCyE6JFwS27C*<{Tf> zl4oXuYOKOdzjgN}OXuu63-06dh~cpx&%50+(8T*mWJ!2TA;5$=FKlBp;u_ zMQT%3NOB%&TD;U8Vzm|bnTle}0Y}aEZ%&BAg>UAmFfQ`2@yw6IVN|UKG#T_!?>mtC zg5^lZ4_vPH+J6lExdwJ{F&nbFtAt5S0rhS>QFp8`}Ui0a>t_o(@DA(mtjw$2;& zS6S{OxFu=z7w2!|gLv^UP8XS?uwf#Isx|v(xqRSbQZJ^T(H_+@)C6s;bqos$T=q zgG}ZuA{lBQHfX<11*C`U|3IQ@6z6B>3bF|O5{-=_AG}a>f!Xu3*{XU!=^tf z>g~%5BVRQ!XV9nM58?4RtTn!cGQP(Nsmy%f(FA*`JwQzIJ7qO2Gr8r>|#okjh(R z)%gPaCw1k7VfJqCwHwx=^dPU;sgYNAH?9tZpL2n%x7 z$pEI;VmawOCMu)9L|=B~wW^QF2Z((Jj=RKtg;LbiIsU1s-0$Cy$9R^2=J(ieb4_V- zR1iiVpQau`?(Y&G$^-u;rZa@8xx=cN(`x?Q_{YVR1m@_FG}h2aw3USeM7J( zOX6dquBx)}>`72YeTmxYrQe&D*jc&Z-_r|Xu;v}PjZm1q+Q zD$ln^HGW(_#%@)&Sw$)(BHA0Lt9i!#;ssB{yA{8rPfOS5^{zy@mkQfN^TG`+*QXf) zDiS3vCzrDtwpo6GOfKSOUuJ>84{>sKP6skE@siJ#ehbqHypwOy1 zQ=B+x#^F|HzrNVQyVnN?$xq^Olr?^I_|xE z4lj=l6Z0o)>&D9lQTItL*@{iL5;K~G>msoJG5Suw&*6mxV@UTa+sO|F>s{4*26T2f zS3;uJRCEcm=gJhJ?U0Ndyjjp}{#iFo$_Xa?5$WK!}xCpXPiM?;N)>j%zwUpLR2i%x@Q47e1gnnS-MG*{SZ zIeq$!z)(I=&jy&E^5JD6!qz|P-NABXg>XmOXdyMrJ=MH`__co?oFL!m;Xh(lPFs2g zEkAy0VbK*5T3ziE@-C8G8CM-`Gk!zH*XPlIJlHD2hXVyiA^s-^`v>?+Z^;F{#(t)? z7}YmK9o%>(JDvS3MwIF-Mm@qlI@&I>_-SRZJw6Q$*f7tOhJ=LZb*XiW-7?o5pF*cy`0H2S>FC!s)?Q9cIsCC^ zDjt=o{*vlNsIp`8$h=w8cFdT}jJ8M`=mhl$E%B~<@Oi6=X3TThx$5eFf;rR87+V7) ze z5rrug{#Wo)+CY_ddT`{%13P?k5Au)Gr`BC`ACr6)-biRpMvqB~qP{7fhvZ#2p-h1E zVHOr;TIOS@CkXZ$1n63yMBhCaaVIU(?dtp+FgYM0eXvy17}7_`yQ%9u5uZ)`h9Ai6WUW{0%$%@X$u7vAdrfFU{a5 z+y&pFbii?bMOw6mPWg9i#C_y8^}O%H>5U2!0)J;j<}doLVI-1^ad-IN{L%&|Xq2s* zBj$oB_BR)WsK7%r58|(%qolY^aDjEqN`PDk?S?S!3rtho^?p*8i#m|yQv4tW?=F5O z#u%z8L(ttMOm%ZOrcG-WE!c7hGJi5|jI?xb&>2u5l%?@p*9|b@epCb}1hR~d<*9%t zAyZ5$k1_i7SyNPg{yq*~Orm;TPbsQHttCuryeRH}4(vEMZ}axbWc9%Xx}?e$2f8R2 z67zRqsW(5$r!x9{sp<4*w>dzNtg9b2jHYy!7M9lH&|y-3hFzdIub9fOb@Mlx+_{S; zyFU@NC1#YHhQt4c^xpN5Pi3eGXHwe#Y>F!yoI71Fg_fl}S)A={o@&PJ*btZk__uh+ z3#uW?+gnjBZ_#)9$~^eDIT&z_JVb*t$D>#9S{E*%t2Bv%{0q3e>+gRm`<$;V@Sk2O zKB9=k1lXq#_&$*z*WY~}r^Fk>aA;1sb#}p(j*~flh%U->RYlBFCj8<2$d>rLXRcAs zYp!?0gj|9Y83L05oM?UyQXEGPdlsOLu_%c=27eN9+ zJT#yOiMN-?P=_wK?RTil$+ue2pvvNL(K3MJ`aRSc+Z#n85OxcWEa=5TVG8G*3_zfe^mqdboBZecG> z$dSF((L&%Da8ZPM0xZ90j0VsbK>}ums0v3X1Urs{AiRqU0I5Wm0qL;0lke6I?v)=R zeE?E<_gIuIqlFPNV!Os&fQJTmc!T|Sd7#y(wxiF#T+;HhEBi{W+Pvg;wuc~iOsHr_n*Nfa zK#@?K?9ASK+*2wV0=7TSgKpItiiiVxWsEcVe;-o z+h^8uvPy`Lt#5eJa3g%7JS;Ue#^qOAo$>CxXDC|BSM!#-z(5~QRtO>o4-MU+A5O?r z7r6?E3WF-jxgQb9DO%76%gJh1y3n{>w9N}}k@V{E2wcj@p2o#jH+zB^dcLdJUx|`L z7ONHDN9P>v38NUZh%18*m5~eG)L0ee>EXdk$m>R#s77eGZC?O^Bjd{dn~mr?c~CSH1@AQ@00Vcu6O_YzBJ` zF|R$ZI88eq(fd-Zt)KrI4SGkAgH9=e&6`UfBSN@H*-qc4CyM|b;0e-^i3CaW;Xoy&e~Es6sqo2AyUr`Daj;aM z!G|*Qw>}CkpZfG8ywGEQBJsRv7=z9_4*50a{3&Xj#b1U+ttnI7IF01eq`q03@ zFdDSQj=hS{=jU%s^EHll+0dhSFuyCf{@I{&xsV%Jt!Yj3!xbu5OKbbm|CHwO_z#0; zxwsUgS(g6A{?{6O9(eRi%kpHL?yaY9E{#O3uOXc!O5Y_tuZG5ICR5XR z(Yi@S^C&hog7jX?A8+R=aF$n)y17ItFrk0{$eSj}ImB!7YiB{vS|R7plShoo`QnVb zyCMZS`Am&;?0orG6AuZm?%hl0r@|{JFEGB--3V$!1#I;W+4#r^VXt8hp9ZoYVUIky zSa^RF3y4r-V8siiG(NOlrdJ7_G;F5*)<0jH3es^xZVeMtL4xPM*FGYPHJ)F7{1KS7 zpKK|h(-P5i{>8$+^6O-|$z(%#x|p322G7pRkM^KJg=EO=$3;Xs#rjno$4vc$m+z)$ zX6(Ev1M2j@V;;{t?4Pc0nbTeA9(X0b^hs@wyJ88TAF zmqO_~v*gWOkm^TW5Q$zDpNQV|CdN4vsY21rwf+y{6$z_cAAI+9M~Bt5b^nr>_!DPm z)xD?ZP8;QC|7i0h6?f^<=jFYRkGFMcWWn8u3}a8_Qc2~SFdceaPN)bmSGzkXC@8i4 ztNRK_{GjV=UAsl6lw1KFAJqEA9IC1sYD}G0jMAG!*`r#2@kS6X5fczahLJvbJcNj3 z(4@RSRCXp}TRQ`fHn9DDx*QaIi0zrsAX@e$u7_n-Clu{{|EQ(>o1~0@**pb7w;Oo_ z{pU$SyWPV>3A$e|uRWI7gqC$*_4{)#8`-Agr_i=As4o)w};{UUs9#WQISA!gw0xe7KY z8*uAEjLr*6<-mT*uSznF=Tt=5l@}C8*tEjC3XOga>T#khtaki3K^A+LkT83;8(>4Z zwC7BXZD=j-1Ao#g{MnBbNYQS1Qkvx4z}OJ;iL9_BTtTDat-HX9#&`VV%d3WPgw+1= z?5ctWuN5gFu}rQQCjM}C<;Vk$w6OJI4mujGa8z`k+Vfv?-|qSL2;qYhJ2dzP^!o4I z%sP5I-`QMvn4l$~%6uj^I>lb+Xf#%QUKo)#4!`GXScJ_;%~E5p^+3pS+tz7i&w2mk ztHo&c`Y4&3*Ie`VT+gaplgF3I4u6gm2I8EFo1X7f2SyzX3>5Xw*FWt%jg^JSQr`G} zwwi3=<)+En?KMpzW;5TMmUZSCA!(O9VCXnIy*_Wh5U)S6cd}tNBDNN)O`{9|k4#O? zQfnPwEs{RDB%1a8u_R~=LHGfuIzaF*n`{iMiTU-vtiPK0&}zw(l+s6}mAgB6;iSo~ zfQQyac=pYhgm8-@DPDB0tfpMPr#ISpprm+O)oRf3fqBxb$c^pZPc{mtu;EXaOdo*F zIkH;;>guw|{m6eHTtk84z*!KR-(%RPzT-aWeE)k)!xT$+#?sTcuI!sHXkjV4#*NIgc(1AGmsF_5#dSlxLUXhy=D{7$^zYt^gl3Z>bj~L?XE{<{XR`l^_8Em zxA*f4G@TDk`8nSl5fgPl#cJ@UG2ykVVSO7=>mGgGPa+En)f#X#h&YJh1`ks-D$k9V z6Te({Led4%;AWEQe?3x%*wmOhAR28N>O5BEC#%?8q{Zn7lZng8G@8?GeaBc;KqW(B z!}Pow%gLM5O=Ql57oIEglxT}D`H(M03atXp8fBoXg@=s~> zR59?UiQ{-$=l-d2wnjfsRW9+%mS_%4KkD>1mZ|ba>4E3|fhi33Zp~=7%&qUd>qa9s zA%!d{{&7+#ZUQ9T6>vm^^IrwUKdV#urRlZZ{yu&2JLaLBgT18jo9XJK)k!MBHx1X1nUlH>;7wk}(>)nc3+n&Ua_l(~SGJi3i><;$Lm>T8#H9 z9KX@_u-Vmrhd4d1s6lGi%Vc2HskE5F%6yA;bd=;e-mtgy(7z`|t=udVO@YCf#>1Ck zcTUQ^9%!nN*DTCN%fti$B4+7%k~y#(ApoJG#fy~>DB^NxawqJEOZiGSFKwLvmq%Mz zeNlPt^dciPzz1DWqI1=~BZ2W`G_ztx~KTD@EVO+`OoWuT0^01oYA1?Ad)gX!EMYd9U!%oe7 zTo`4UK;@1s#&x{!SY34?Hbjt>tw3Dz%KeI(9L+^=Ztg{%v{@iSBkHf&E6I4$OBF6D zV{25a85S01jzlhQ&SUd$;~a~)4iUeR*v-)-zk{Tekb>qd8ue^w(=C9{xx6 z_CIH^XbTB|KCHxJRy$s?1Sffqs$SxcO>xCHxoF6xfzXVR*taAjKZB|{&{EkPD#PEE$*7S^9c7B|-^$Xc7Y$ZGy_obmh zmbr=Gi+$QwW3jor*L8V>o=1PyT7mPb*V!5h{7`6By7pizU*p8PAnrcI0R<_e z-ndO2?KVm+knFD}3)N|oJ#M#HTd3OiPe`Mox#n{`-SBmLRWziXj-*C0({TPvTc2Z> zM=ptbuV8#&xTmMfZ)<&HVQHq|V-$~E;@SB~e{Yv|v%|qGaet?AaRRp*3@ZKF73jG- zJinNPy2^^FTNfikp&F=6OxOC*tx>hp48TXv;p#eJT6L9+rEGB9!&It5aqB2Ck7#>M z2n-FmtzamPS1A?ZD~p)m3h>5;KveSbvJOu6M?H+?$mX)fiz4*>*-rO7q-+vIc=_vv zrm9iX?2ve+rFg>r^ch>SGItTR|E<>&aa)ojhn%$ba4=hp-k{<%Inw=dK+ z#N(e*Kj*K%4k5LUZ-A9O=cW;2;NSqkh^;GW-79AkY6t>?3`G&8S0=cP#1TegD=Rg} zJazg_b#**(t=bgDgNM}l z*_dKshgkMp^E&So={fGre8ulfP3wEYbyzt6=gi;9XZHIEg@H9){XU#XDN2*vMxuYN z-OW7>E4k;lV**!6&(cR9HgLwU6k<4R#5%dJq4{<(TCBZ5n^2PXimLfJEwk-u8=p@D z;KSV4ucymu|JimH@wVD!5al`NfmYi>RAxW%Z9_viMsgqoxy4k;Nrvw59L17 zvfQ4``YunxxwYO<ty31-Tw2v51onWJ>Mh& z^XOO`R8mIcGO#$2tLb9?L!e=`^@`K=vE1j+pM66tff?fW&_Mh8s{OlK^{%k(8MNoS zTqhei-{B$+xpPa0{Z>}c1HU-$AG7h9hw9SANPlD}d1R{QnBw|DZu4wH<2^%e?wX0| zxSd0ifKlTwD*hCC)xiZh4Z)OTj`@}b(_MBS%3$o%?Htm1ayny@rt3e~91Tu*$RG3= z6B+KM8{H!KQskmfyMtwW5TEzC5pUf%00xYu0hxif6j7GG@$~rfPAIqbr@MS3^Y$oB zNjwXGS+-H>|G-O>3S?Ybg#hRhUN^zx%ekJpEdFsS<<4x*7!Cij+$F*#_{I? z49Gs_`J>E6-gbaTLKdqWDKLABSRR1BD{gvC$ZaZZ85BLrhb?P)F$pq4GkH2rs?_+v zLw<8{vwZWOiFG?6f66pm#6m$vL(iNFq>9d(y_u9B`bcw&(%pw3o`F;1#pNh`jx+)y z;_<^ez>Jv4&{t4RMP}t&NC(7Q55Q9%LxUpI^#B4ac4N2c{pvY@?u?+`f^yb;{tJ{7 zgOq?5gAUx(Ndx~C07~la=Oj&t6=PK5j^6#CYV>}BpLdtB6Y*XB9UozRwhDlJ-epoY z);2MhS{KTcl$6+-*}gg5T$53xp?(LcbwOTM)}_9rIPuTH%DO6cFO%I)tg1`~)n(4N zvOavE(qtcRZ!d(>&DYrtkB$B8E4F8fXlxWDBO}YKmym$d(SED4n$`Z=v9hwF+2s0F zGEA7ClX&~|^2|3iwK*l_z6- zr%Wj?l-qnvkTTs|U45h7-^s({YRSFC*Y|gw9Rm$bdrFGd^5o6+)wD9RiX-EqEngV< zto6)<+H1;2yVcKeYC^cUrEP6c9DlZE-*5KO(b2lPx}tOJtgI7t2c@}kNj0X!snCp0 zm+H;2;w9TUPA`dTvm=?D;hp z?`N8bT;6bxOm(AxlU23puoxa%ou<{>w|esO$Hf|-&d<-A+;9g*MhLt}{oBzo&PN)Y zynI9{kySoBpv7P&lrgL1b}9GB7gYG9Lq|5t+E3s3;zh z43UB0&7>lp8~I9-zZaWvNVzoiFyl`r*|nI}x9S}VbLCS^&CHm8zZ)Ek$de~yVPvfR z_&k#F8J$Ui+kF4_G}Q~%n<-ku<+;}K4jJ%393!s91NvhmNBJA#aXYME(v zI}v&FCeUZrS@7D6*HWG1X+m*P5rb?z{qI#2gdHAQA;(1e15>{OkWf_qyw8~d{fsj; zHPs*goPmvP8*so=oQu`8xS%avQ&Vo2eXM=QICddYE)&{fgmHgrb}qe)J0lBAMOoRv zOk;tsCb?kkbml8_Ik~>AEeo-I_p9@c>6%jTI%W`9DK2~uG54#1`}t`$yQemS?IdP> zcfQ#10vo$N*;pn6R;tx-xIXOGN<=JnSkR%_V?oAG%sHK9?8!d&h5*DNvD;RmMUcJMNR)qnMTEVNr2@V>@4*{+jL11VA^~-LJjy8Dl;vhLWIf@|+8QFetWF&jgL|7ObML^>9 z=lNz2ahteS-R`yCE?uc@jmM;FwqkJA4|z_Sic*T27Qb7sE?PAbEhZH8`MK zX)&1#HSW>-I#s#;Yjqqt5NO{9H>Ib~T|BEmAl5n~ZUhAz0mamZUJU;;GO`6kJx#>x zDUU^^^NXT{1ir8znCR%ohlfOS>s(>Qzx@CbhA#nn(-L%1Vx( z#av@XZ9Lr&wAQfa^#TzT!|!}pAybD0GGbalfoAUbm}-RQ^c64wuj!SgNPB<&G9Y>UFC3;4t3^0Ya4(CTPo)Ows2oToR=`jMlU5cMj~+fGfB0}^aq%^t1kiplEUQ|YGvvaJH+#PuREwv& zrF`ia+kDa2?dx-M>FfMD&dJ@FU8`;NI1`leeo|Wl-R607J*%#+&dzSt{;|_ z^k?iRUZ;OlY4Muf&wBg&b!s1SvhQpzwBI~W=)E}EEqgcq<;$0Mmo{qO^a$~h!&-Hd z0VVgBNKjEfzk2m5jyfC#!}V}Y-`sq6VWG=_bG%e%6mUgEb9qNc5l|Ho zDu>H5hi3R#sYnf8=}0o+97h)pdQ)KVR^c4N`@7S0FI*RThDGMSpmNO)sD9 zc(P-u(C-Q^$uRP@iLXI9IY)qcKY|macJLoXGD*>_tPlaUx;9zC;le8<<=8A^Wsq1M)jryLBjGBSm4@aKWFMSdzvr__;=X{fC| zooyHgq+wI9RF$GrKD5}KZ@Jo<)Zgcc>k)Fc;W(o?=tSkFvfvQ_)B#K);&uJ&*RP3k z!=#Cc($)SnasaRTj5-rEmEVy}hu8D`Y#>T;cn4nGfnJ+*Ltr-3I z@q>-VFpD~+q8@s;>^#s?1=G&C;O zhgZLS_vCXu{1xy}KPTojGrJLx>epLb&0$ouXg*Kb_`I$kMc^ct_g7m^hXQnr`FP1w zAy=Po>{%zf^Dd`H{yJ-H)#>YWSYw1a(Z68{VIU zudR-kTVzp$wJ(I%Ojuk;;#%-%gr`U!N0)sOH!%s%Gfqg5M}DiM^ytyz1FXl)dx{<& z_c=Ir)k;$ZUcQVu{MaY#u<IW$FGF52$=m>kIPa`wGYj;H*2CH@2 zZ8_TH&4eMCn7VGRFY=txH^+nI&G-$wWo-v^B_t*yXx>U*gpqPcs$1d!4ECFn2 zylh`|^ipG^rjk+xP!b|8`vb!j+EMh)C@5#V*4h>2E#~5|VX;xIsAl<^B6v0|S%`Ka-i5n5fs6Yv#u=trQZu zX1lx9v)$R_@CgYg3)MsdCB4oMGYShEA|k$44j1aQ40c6H1_3*Xi5dEIQh~=Zh0hLn zMRYQ%(!FW45!sSigAzvHx_Wy@hK7jV`@@bW%}h*Y8k{!gnl$L#_BnpdHrzZEWNu7M zD=E?LTJ#SD?qpa1n&o79$k(q-b-I*cKbqXnlB=s73sot<|Xi~WaQ-PoOZ{6^W*ML8q3PuNlbjgO+^*@!`yXm@jcLB zt)Cuap{B^U*enF6re2@!DY3G$0u5YV&Z$vtVRwG`QA?`{75lVeAA)#PWkMB=?;29T zBg=3f0y9?;--ucOET5Zege>NAxTZav>*~eA3ce)p4n4B7f*>YlJZ=YRUoYLI< zsm93z6H~^{E+`UWGRxsGw*#TBj$U9((mCo5%Uk?(A({zNYcLC>p(Z00p zEcSKQ+x!v7ZKSPjvZ7*3xC~%AbewDV^`Xg`nWth0Tfja7-TSjsDsOnbqod}XgIq5S+FtTVh;PEqu35YjOz+If6eoA9+8iWHZ7jMU=x&fzVR$| z3JrBe{C3lP#7G@x~15fS0w;n6IM{hX1Z2!#r!lqg05V>S5g zTao#A>iIdUi%Z?{@ujJ!3YfxctmzVT!`gH zo%_mHoZ@8Q{68`v;LqiyATI9p+Xquq{J?0_)Z846?=HQ?uB+qYBqFCJdHgt(OkjO~ z8IzB1axp|JBcrug{f%~;n1)8fNBwzQg>tzhoTY%K%E}QqycT$Q!0M5Zn2KCJ^Z)TX z7ORWpm(2v|>7!?8&;bp&%8GnpA;azXvM?`i_xEu6OccWt z=zNpg!TRt6;EM0eUW^o~r6D-k<1}F59L-Hz26>IAr>6k{1Hf<3&PFCC=5=yX0Lt!o z8!WrzMS`rF8ZUj++4Yr%hK7K?KE9=;B{6Z0`8d1B73mAxC1T@6870F*bHYd@5M}?ai)7dIu}Togpok zjOYvHVqzre*X6w(;!aT*xWQXHGT0I3&h{p*x!NQk(EUg6%_@H z0Pwtp1O47_m20i%E~5qXO1y4G^Ejo)B3i8VC*Tsee|igWat(#vX*Uuide|Ux|!hM zLvQbr)Ku*yb>i(Q2SKmvO5lbRv$c#{S!|61t2I_SA5Q5!U2WOb-Ob6%%gfEpK@uvp zJMp!`*H>R!niGi%@mShMFS{ae%~=oSi)>HP&waxa(&B+dd=u5T_Xw}(x^dt!YaHr* z|K>9kqsGTm2Jn0+>z%ttHA;t&eCORJ-58JXHh!%SOYZEXg8U4eIoUG7Vde-U2=C3;#tUFt*1N6LsRe+vfXm32`q>D%I(m|wf|1tr;Hd_Rdy@kCMb)hH$C zl_{kD*4>y9qY(R*5YH9W)uozA!d zegc&UKoY6S&+xj3BbDl81E~C}o}LN3eN8HHKzaKNK}(1QmwrS@1kY`o3J&(c*ku9( z{Twg{=Ll?v#3}@%SfDtPmcW!*!u$gp)v1FrJ4U7p0LuV?6!4(JQ3<6&h;mE2-yXFg zxBB+NO(F}JR7$ z)Zl@X;UnZvxlrz1`hrey)SO=bhY1@Zih@|-hpWJ!8!@A{z-}oDIkKcGg3(Z+%lVl- zK^YXhgSBn=K1zQ427!(HBIt`S%BlN1gUV4Pi}Lwy-;E<_MzhQCGfh~`^70IYC8=DJ5|U-1sn_Mg)C>t^Mfc}efY z=X!SVWFhDv4;t+E0Q7Eo81xE5;-tt3vBYYRPOrb;e@J&OKOi5X6zy9PjbQ&<;n%$_ zz}!gYfDN;R|4svN#oK-O_l>`)V2|sA?`Hcb0v&eR0!ha*&>48SeY~G1R{3`sLsZyE zjBotP(OsRK~XyZjbWhdkI(aj$?BBI*LP9zOEmTNcE37C!lg zrhSan8oc_)o8TK457#IEQ^))7H!cWHPa?43Kq1j%d$V_6UVzvUOb$kp;WVDP2uf6E zra9CWq{oy^R{OifK!gVJ<4dAWE%y*F!3s{2>yxp#{e05O-C(MJzjD#Za=4=o>Y@?y z0J(X6p^?^zvvvtI)H$t=hj|eGkVYz%)LXrk84jAyn6`1jE|AMr@Sxa z3wU@5$6k#j3g0p?ViX{>(j9pSeV~8075wY(7c#;zKF?;A;8f~g6y_aL z74qM{5D^Xm<}^K4yi^es%HFXoP*6*4hyVSH4l+kK9>@^ysWa_9C1^qnE5-lws}BEt za4^~boiBF4J_4_Xg+}XG805Y-_{ks$2h8F7ptER*%id{gUQKCYjn(eLu($NGmyXx^ z$k)nf10PD)LkbHcQ&2cIP2BHJ%AH|u60M>BJSZk1A~pGOS~6Z50R=HW;X^X=Zf54h zDg%`)qdq#}<)M82IC(P5xm@uJ_Suvf!DH*27pK1kd?-JMCe0ucL`azCo1M)p%vOGF zF*~censs+XNxI>Ze6Y|f7&r&8Pv1M)+SoiE4O$JOC|RqhKirwO`!(Rik!E@3;#cYM zIV8>Y_x_nrqu`#upa*HMcD=N7PmYqLK6Tr!b_=I-FFf_W|3l=x%Y`Nm#fRV=2xm>t zK+n~nzon*B+hVM|?=US^iIJccU!>XP==vyVW~&HP!A0H}EgKnwG=P*~ju%gfR@phR zlQl)ec1?-qOAix@upbS?&K(`I!(DweG;DNK$1GxaC}(Fbb#?s|b4@O1?lWUV_)dD$o8P^9 zwS1gIphipgF_00&I{iH{hlkLGhKw|k?-Fo)WU;J44W})r6@V!%VKc`hrlcJ0@7*t_ z+S?kufB$tbq2S4I!}urSpV4mgAhkTxJvBD>oS$0uB_0tGQ8Ito$;$G<(k#xbYt{CQ z+-V@O0{QI0`0m!C-oBN(^!}jGjeJ4_D4hL;yW1=wxxWdJn>!P&$y>d+r8=ETY-jC~ zJrz$N%KDV79_@;xcSX#;tI7V+yzUGp>pKEv>DxkOBvB4MqDdJ;AtRSG}UJ@=KDn4AIY0oWoE8xAUn5W%eRD08e(Lj$8WdS ztrN$wWb=|(;i>6VV~lUliDwcmhF@lJRv`#63iC>{@_raqh_twg3m+a(+^A1Z1)gww zTy5=TkWCK_zeUj~+TKc=Yr!XfMDUPQ)++FZl*V~vI8RJ8OjzzxBf`mplvnFwHFQ?P z3lsNxYiApOAgjNaWmNe9GWtbLvb)IQ%!KMzKq>1FNuKE~a`{jig^?wuB#7aCYx(ih z+{|=)W`FZ{Lk*~G{IF2IQap`59-S359sKH&J)LRAhuy0Ue!l0|N5hg!+drGmJ#SwAxSE4X;lUef6n0x_eo}~V=`OuQ1UNMMFh|+lg+%$;z!O4j=8|C zURf%xHjeBY)we7vZuh^M9~|wTUjE@XJ-O&Pcn62QTY%@=!PcG1!&EAc(Dz^pZ{OR) z(rPaCyAYXd-%Es;*V76fKwAF4IG}&6rX-PVo!_{J%TvRjw73ZtDtos!I=+AYo>9ck zz}UYL<`Al~x4)OF-_;3ebZIu=Ho`O#q_xf%mQ$p4dT6j^&K^Vm?b~v;cE4-?TXy}p zlH<;sR%j~fZ#7sX7O})^Bwkh$CQ4-@fj+IwPCRczm)|2Gio4|G?zp(zPg=cae6RPt zW%O9GWf*HsVF8l&xKxsb8qEer<*cL?*&-g7hl4`Hb8$SQap5lGyDy%8qU-C$9)c;U znpOXY1#NUl5k8I2PB$^P&&YZMtJK1r8lQhk`t~Ct;C}_>#L$IH;+5@(H|A4W8e(n4 zH*2GcXAR{AnzkAmwyp^S;^}*ZgK<(v)N=V+Yexv4W5~fDd;3Q86%N_+7uXiJA?z)8 zn8kQU?LJq4nUGZFZL{-w*Z`r!;oF-Hd>m{?ipvU*~_hnJ)?h zzR?2uI9KbBPvGk!>^zCK|+ z{o&hDM?T|R?`O}X*;1+l0uqLF{>=aSRmI8Wc=h1)Bl+nu{(tF6YGuC|=o18#Mn<Lq~R#^Q;N*Y-wtYTa>y}3mFF(JBLY=Prv!iJWt);Hix~v z1c%Xnq$J1>g6Y-opW{maeaIg3rTv#8KfF}m<57J(O+pGD9x(@p;MB^Owp!wmqv8;B zXUsWij{gJEzomg#y0cdO^W z;cD=_k$alZQKv7z?2<5Pggo{auJ;!b3FZpr6t_6#RbK^lMT6pRuikGTbJVEqzJN_?4WaIJf`rKi0Jnb^#V4tNL zCV`dR1m3yGZe>nS-%QcxG_KDTx}RRm&35<`zPM>efHpI5R#I|S8W}q$txFrhQzpN5 zT=}hfppDtmBIV$^H8@p#4784?H1AzcuOO+%%QN-OfCO8t<%X|-8Dap8R?%L5J4GiS zgj`_nmKUz)wk^vSRkFm4tGHMLB`{!r2TJJPrZDiDwD{cd3TGP{WToCC?R?oC5DnR_ zD>!X>ahxq)U_Dox&pcdwILL>1dxu0X@gFvy$)Xhe+i&QFHO4Exa(K#1zHc6qUK(2= zzq_McX?6QIQ2m7^S?i0G_^-}!Cd=f(EDW_wnpfJtYOHz5lZTkovDui##F)j9s3p** zq+)-Rk+Ak&*&mXcua1(TpzG`7mERU0d5kGXv0pmHF$r_Ij8Q?hNr@?2y$?1KB4#Zh zeJ=k$-h=Sk#n}Fhc0Anni<8sACD2boMc4y<|pJ$Z}1+of{M&KRnKzRM>CN>H*OsG5g()@ftf|n?KuNOr?^j=Hkg7FPrNz z5>v({o;@YT$m0(Se@@C)zM5ylbB1}jbX}E|5Kx#%kaTe`pc1+!IDAj+|{>L71<*@X=MnXu4@zblKZ4z_pq#}3jH~#wT*D}Movk&_# zRn;es3j5VI-^@iF{P#dx>F`CGOWf#zkHg=%=TOao%W0{JwLYC}!MOnEpKXiT^=ElG zj?=h&RLMa}c`yd|EVF$JpCeTVHdvaUX-9|;ikD3%C%=VvU=WVyOEDi>rBHt65SlI{vi}|g7)e8^z3?HvL`+rjv>a-G0 zoZg?u3a+UnJCb6kzK!qYjE${i+wwEPb9@F0qzq#Hyjp^Z4Dn+MT&vG17&!OyjBxc(&|grz72#k{nGTNYHO(b(hmf z*e4LYt@(Z%QT8v+RKDHi*Wl17qSbmZ-FUX%R?T>sjlYG0Up|K)kDJoB> zm1!dD&mV%_uBk*t!&h#RlAh`x zKV($A=@|QPG?tHB@gkHYXy1#lo==t*q{7}(3A!5i*kU(N^h}FJ-O!<513I}~nGJhWu@Fp6YeSOJwh~4zJOy^yj2n zfenrPJM|K7X&Ke4cbLhqJ#lM9HyDi-5N+nVjjnKenO?I%-*5M2T9M9&5AHi?g!obeS6qXJkam#L_)~7o0cX zjVJm}^=S1saY?Q%PVO^l>5bV6d@+Bi7#GusTjVw|iGNg>A0hb_Kik8?4$Q>Zj9A0o zX6@Otz?5>YwsR`rJ#vK^2O1S6Kp7Z07dkfPBtx_R+zfNb6TPEgtl3(evO*;mBRftQ zQ!S&PQb zIF& z-Fbc|CdGeH9~xdcU={w*wp<^%&1*N);0yf(6tMXGhh#wa?|j8HbuzH#igz?G!|}4D zpv=y}iZkuB&>$CjsXa3}YiVl>bZf)&w`+)xXEJ6`l1(`|;cUl)e@)hlP3wRfX7u5i zuXu}_888h;V&m{kYG6jEEIuO6(_Wv6=VsOIux)sS9ksu>HI!IP`c9N!_02m95x=c{ z`F3B&TbGo9dwU}j6Qe

    wEufuJTq1s7`wp8E0i)-g(yTVnzmJ6ge@h9&guVGfsyd zB5!JWYgGT^(~f|{i^#ztw-0eG7hQp%V+Lf6`eMOsQITFovhnMDgBLb7xdlHZWM!c^ zkzN!eBzIe7qU9QA1sY6x85#EcttG!;NED!BGlUd_NKGJ7(%k}smj_{#Z%i-=B~P3M9|(7n3#GaTRe}xC=BX_ zePOBhg?7G=1pt5(ueKfhW8JR{g3aD-X#Qt0wGt1VnFmZq;HR zN#fa~c?1}X(P3N_IjclJOzriyTPIY+?h_(O$TQOn#la7U@sk|CaWn1^cV<)fIl_qZ-9ydk8+T*F;q5h~h6y3QGGO{|Uozrxo#w}i6z}w1Mxq0A0NxAaiL|C#o zFG50W#Q1!DtX{DlEB2eVx2Y(G8WAym(1W*T2tMJ9pkFJboS769oRmBYUS?;F@e5t# z&CS{u8^;L{PzQc2_4A)A7rOd8D8V*2jC|_J$9qtm9YuR6%P+gdH}Zxff2Houb|qf! zHrkKu{#G?tSU>eTEwx#zuFf~^@Q;XLaQkK%6Vx}a032r|eSO;W2a=2WX(C_s1OZlb zabYndJs$TrnUIiEcNg$7A;y1=B-u@lmk?x_WcoC5zPSaKY&l8JWhdJ#yH>ZDg z7=ua3;|19jD0iYROTs6l{4bFgpJW8a`azC4!cDPlD@B?DtokBn$lU&o>aYK) z)_R&Ik9Zg?w{^+(Dsz(jyIiiX28Ty)PwsjH^#gL@?XNdsMeKT^&%ploKhVnXB~n$x zWibF-8)RxlIZTn1OG zOp5l|<;Cc5A>Y5b7G!j!)!>q+3dc_DX)YjEY`vuN+dhG8ddiXtFzQ^FoQ$_EFBjt< z)g3Gwj{U~OumBPd=cl_@CVK?Al)K!MGGpJN^C8A->2!oslRcoNlkRD3FbM=DWDVac z+eAG4{5eR$?;jIkBIiLuYoO`l^zJ$faAaqpT6rU8X z1;ZTnE1@sMCBw$eKltZ}y6B-hX5thd0ix9alf6`M<{TU8D!{?M?a6rGEe`SX+hMpB z|5I8zEI8@}#f?CunxJ)0~1bT%qua zocGUM-dgO@a#(b@g7S9kv#{s5C^G~$aE0Y1IO?iF^Jv(t4(uHcNyRH`0K-h9vIH

    oO8w8#WuCK@Yag1tyD)>am1v1U=tJUq0>tqrUnROc%4fpp0Ve7xKYb9%hI{4d&^ z88*%K4h3H_)M|yQsMEXE`G;>pZ16DZgZ8mL`$Ihi8p~{8J&mL_UqQyuJ$Oa=H$`*5 zy)TB3AB`gQ1?<)GyZ@Us;#Ft#V{*e(l$P5!8C@F)Je-jWkwEY5+++ZF59&+9YwPP7 z0`toUfiAtCFC8ekdP*S;MMW`1Mf{qY`iZ3lg{8gI(=(IPv(qD^fb}OWT&re(3F?_ih#{L8vm>2O7}f77=yYd$II#C5QO?xk{jVjR?}BYG%xXqZl9U zgBb$Az6-kPdW(za=tiTeD5=1rQ2?1>Z!4}ZZ?DY^&99bsHGSw_5?BNwCQ8yFsjJA! zYzgI~@sf1(_jofz_Ra8#=k9Eo!_E=V7YO?ve$$g@o1U1et!h}y^+b2Vs8&dSW6Km+ zqE_zlJyBP+PcL=>q*7JGZyA|6cVq-r@_g#B)SQVKm|sw~l&@bYGRKjgp`W3zXJcza zOhQy=O*(EjUIoBP_($`zEoM805c~JT`4hX_DcVJr+b+hY!k7D2W|adIyclZIwy&Qf z*nW+ry-n2+eniULVoz;8zUL;ZW8ZuW^vG?`*CFu?>xb?4HoCfua}_CEnuRQ61mh-h zA43%+kY~pf{eN^pN2n)&isL=gjkMZHB^JID%EdC41Rh!(IXo~Z=~5OPO^#oxHFgoWfM18b|Li69z+v zo6a^%I~qI*Rq+uu*)c$n>(B4++MoFNIeC{c;pbu+;5C*)SOqF4pE+d%R>`A0r6S9N z?;LC-2LL{sPG)0%p54$8Q&860qpQW{VBfEj*=~V|&{ah3Y~F$CH@T)P$=;Abq9Xti zSDr>`%4`EtHj0_-%u0B#KUg`V(e?6-;koI_eE18O>IOWOa$!$`^voogf_l8&b$CPf zB~Gyf6e8j~$l^1Y^o>M&wxD=S6!H)!+kR(sNx^sFz~OSIiDQ@9X~u9xDZGDGub8{q zwy(`yqpmLtx|mK!v|fb8A}8CFeUD|7JslS2eV;?@gX_zYKXV0Y77hbN8eD`7^s)Ml z0z(e=Pg~EUvQcJ*W#kx1QtXfOb1wScBWmC$!714b3B8ur8A1iiXF$!net@DlK#liK z@Jr*GhxH=d{VA2NHeawUt`l-|{O{;?4=6GdxZQhy*K0B$1gNuc7uaGo1{J_KHm-uW3Y-ZwHmGJdiQXvziF zb0By=lwHS$w1hIpxl=H9=>c#<6Q&U#w zdJb{VJ9HH`F*!@d?;lZDEO5%1JrW{Q05hZ!PLKZ35+VZ&==K5R`fU?X2P|}hD>wi^ zELtxq=gT>TXgrxk7_CGYQ9U*xGJODy%j6~~jD(wQvDx5Y)CcH2{Y=)5B`rSS9~Sjx zKV1}ca=MJfl6)deuumsMYc{)G?k+q!FNe=*+f6 zK=*jow)sLT5z}a@J-_Z1( z!9<&e%BmO(?WlhZ50Hq-^*cM=*cU@Dq@`b3FZ|RQCUKzPduVtCv+_Vu=DuT)!lmkv|Uk#CX$$@E@N4hHdOU+MbUgAWIM4q0v6qD`tVgK z)We3G*Iena^!G?~6e{C-BQ19>7m9X12-kEcF9#>~0fXf3N=xA(nZkagM=wBW*`Y7CD=gD9)SD9W#Rpw=tSNpamhfTmlBE>H5FA^KpJO#vW-F!try zADievB!Ee5K8a{8S_wBAx&tFdNuh-4zEoK^6^5TayzX}A<&#cv$kU+vqh?i778!nH z!6U%kQfJfsw~*Db-LH4s*|EdR`@DBp#<8N61Wjj_EknuvVpNkeP?Fotwb$w+_~xj+ zM@v$UjsL!pw*QU$-DEW}1rf_<(;V}G39Or*a*-|&X)JlQ{aeUWx#kK{Z(;LWG@ZD) zgRLzq*bm^Yq@@E>D!d-n3I;r%7_If`VA0?kZ{HQd8drI(X%+yHGCnYr)RWW*9luv^lFr)Qb@&o z|J?Mpi7P_y2fmy;xdXhW*Jp9@2evl_$*TW$tg3i~B|*T=mltK?y%NLPhS;^uA zs2S;n%JlsYR(`ubjrEniVH4+A!?J$&a^)27_v52inVaJ#xm7LS$bK{UIu#-0To@9qU{nKrK{siAp#QdT_II>b&J zv7po3^}qkvdz$Ihkmte7!B10vv*EK-GvX$U>!3@DI}W`?o2%vB9x3T$-QxIUw)M#k zMgq{$Tv7eNiKwL53Uqt)HQWt7W7h@R<6U&(DMmN?g*)SG1;^be2lQA<1^{zlp@l`V zryW4dqXL?sg001$d6Xw%)iMuJ9H}B(AINt zISroLiNuD&>KV|W0yYx6gQD`_XTe&cSQu)p90jgpw-X)m8$yz|!{sbJr6M3sQ7@?q zhB=?bsCGqC(5aow^lJs%siZdnrilskM~8^bCcByaz@4$Dvi;-M(|F1mY~*5HW6K8S zjldB%9NQtQpeY&Hjn<#|{R#Z}MF}9m3znAFkIAOHUTAf9IrmvD#L1G9;#lZ#yNy|S z)S=w(c|2QC2(6nTZxXFY;)%ar%KEv!UQ8u{=_2CaRAclRfYzpHq; zRz-dLZagez^9inoQGx8>{d&7)Ihs(w%v6B7-8gQlgj?~E$`()Qpj$$ypW}tnW(9j0 ztY%YyGWf4&2=v;+Kj+IGl(>qVkR&TOz~$Y!_mh5Y)-u9Wa=3yYQqIr&VFDFT$Minv z`#pV{IT{`lL9z_Ke9*F#VQ4N9OP?~oE=llw%$eK%KyXga!oB}5=gB&3f1y%mnExSv zk7Dj2L+dP><28$~UfODeJBXeM?WG>I>_z_$O{PeqmTEWM!1E%r^!f62F zb~&i&Mq98F3DKA~xRvhhWd$K#F9R?mWt;uhVril!E=%oCJ875ZghajJ(y20yKfj*H zkI`uLthx3G>?_#xvof*#-dT5bloVpUq-=N596#CJICM8fko)3HjF&L+lGlG84GiTl zRu~(|(_mU69Xp=dzNci&(HH$uu+=v8;}S!kH{Zmgae#J-n&j(VXfU zymK0xg&G~L(6>$koeW`QuMc_~8QOTZ-$-Y4#1*QL*s<60_ zGZXry%4Ire!sv2HX+r1d1@Mg~d^J?o^+p&+b`TYI_$`Z{08mnah6R!%x>9YENEP~W zf4(mKEql9anbFUw04aq{4{Z-&RnDhQ@H1DpMr6n6%!69#tdvbDA zp~Knl;ooUusA@>mTT=%!O0TWE0I~ZWwhYBWjY0=fiISX-3g@#VlYdp8lrzBS0_yed zrR&(E^ZWPV!y^N1OiWc(1jv!Z%*6Yn%T`?+%S4cq!3)3%^H#)rxjL}FXe^6Gu1fS> zWm8jHujy-1f6F3`(m4(zWmUyoAxMgrJTyV|Si-2;xp!3ZY}bX1@#JboA3Jz`s>=VX zT$(7~s|V)!iG}!={vk4$#-<&-R}OFQ>G?%i6;+M*1)pKWhldbY&OLZGibPyILfDimv;DSYGhD+n8=96Ce+sXm=l&7& z@|lMCdM?_dy*M!F8Pc@o?Cr~6Dkf)uZ$}<#pLC!y;md&HWoC!8G2zx%tsN& zyWTssr(u<(TkQsV9eFv|M%910%W*btCLielbTI&%4NN%-CsTvK){(YjqO3pV64KK0 zzPw^0zDuP%Qp$q9=tqFRar#||ObGCRM@EE2ur0N&9gVgYSuy~>pg$IFZF&9<`vtbm z{4bjHU}2Aghl32T5yBPt2Tc)^E2q17TcFe{*68Uf%Dt||R(BJ1DjRYNdo-voD z{^92323&pnVtKH1vtq{4w5-Hzx1;IXj=(!?Ab8RHv3Ilg9H%kJxsLO?GVSlZz;OoO zwTR1&*H<~g=BynlsA^esEcZEcgd=61kbIzn6xIV;PseOvye4-U!n%{ZU8)^B_ z5u9b!JGf}O)Fw1(S!}Mbw|?-=W}-n|^6bi(QI&^}w_!9_q|NPnU7(TA#U`ZnT4jI5 z%@w{%pp#OGjcK_5`y$@uG;^Et&0M?E67{E70F=?mWnt<0+PS(qeZIB$1RM9qC5u$j z(!lZp3|Q`>#yQ zLY71pmll@F@4?6dU;IkL7Jj=Kl>VOhzp6-&uTT~TrWt<5QJs|Ob2ynPPPrN!o!dKqu*>};`H zuD~%d;-2Rz139gnMtBjouI`r#W?i(1 zkM^TTt-Y(C*umH=!KQYOGEB0S+Uza7P9y~9(utkU_4V=b5%eJfl**$!t)ShcsA32q zIms2@)tM9Y?q2T5UBUm^2fK4BKt_UtUiII-i-tfljN!e_lZ>4H4KHy^Ie!`|`rA%d zmEK$d@1^2{Sj8I&&$WTI_ux;3(KKw0!`1)+hZY>mg~^a9y?oh5Nx3g1GUMiY=IQ!p zXO~Hoq`IWZYeE98;Hz9{Va2-l2P3b6torJ`x&SdL>CX11EqA)?6;=3ioVlsF-v-WYejw8 z%v*=AQVJSptB~u4qwyjw{TF3z00qO)$0&?V&-#Pf)zwvnO2p!u_1`U55VAc{p-|<{ zgj=)Ly_1GNbEU+FRuMQ;JdY?OJRYa>NObHp_EL&CyH8#W;;F>g^Jjj3Wo*{L&6f^j zNyob^5~%*$>t+mp5l$%#iee`dSXTmH+88w)b&?%htwI7Z@oXeid)~AnTN0Kh;e;p79GPoLU zBK)>kn36;X|I+2BzPj$q&2k-WusWZGe-1fLCq|^Wp>! zrRR^v1|$egdEgsEFR#|US|FF~gWdS|ArFqiB%2T0Q`2!R1NoUr#;2$HjlBXDIlLyQ zlgSwQu3rtWQUpq)avM|YOKtpYS=24`l!}T2+hg72uiVx{KjN586jBFs@$mvAQp6um zrf(XPWG?_Q%<$mY+NqZghr!-zt8eZjuz1J4^ZJR4rkmzLLj1`c=A9>=r%kC1#A&r} z+I&o3G}bSr4)GrFoLamTcy)C+G4;3XzxWT}UN{^s-CRb_k*efY!b|)WG={1JWswia zfd#?dV9emAmevSA-4PSFx7lwUC=U||2wJZWZI5?P&K8E_V4VewF>Cnra>`4nohx*? z@z(CTTm#v*`Oc>K&N$yLZYFP=|7}Zi;cX&TZ1dZr@A*&M+*}*br?INtcb62iji5SG zbRx^?(>A|01SKT_Axpz6oyb{sepFkvosqwMq74OF8}%bpo6W!||0j@*{3JiIUJKq( z4qGP9);G?`+f6HlqCB@#>qSV|UqO=S3&9)xG=W%kPORnfCpU{Tlo`AXNs?`Yy>hBf z=5u3_veAE*4dUNjT^$su#6(33@bLmgwDfo#7MoYzrY8?7vD1xIYoDd|yvnT(*uz`*{cb*57f|A*pme8>+( zmFu^8-)>LctC~}bR{27c>u0Jax)TW#D-2s2zz0{QwZ(gTd-6*h8eS$Q`6+(Gn=Rfg z`g;08fSXAs(DY(6d{t>nyU zAFp-dWF%$uR{1+HKQJ&K_A&^TD=Gom&pIiP;M}OJ`;@^8uE|pZ2yipI?6kPQPG)`= zyi(SSGYT+gObix^=Yy6hRCm{x?#>^^XNdiRkbB1vw2+&&pOrFZfd#m91zzaU^>$A4 zEbMWDMsZUeFPAR24_)wQE&=WuejbnNYR}qQ!iO}2ca;QomPxJeZ_`_^38$uH+>h*; zrsWGN=ABRCdeZrw+PoHQ_oFlq=B&xJ|5*LGfxlyyUkNZ5bVjrcG`;{vP;>@ketkL} zq72L1^&WsWwq67Y>*y$CyY>}f1Wa%D{q4XE?`uea54EQ6T zp0KD>V|?@g9Gs>f$R33Fp+8-$m>3dL5?P6thZ8ji@)G`>!%?RkW zBz-aJ^8MrUyuTky4v%$ySNbusDi$9TzCAb3{Wu@qE%Q*>>2+4wnpaS$Rpb0vbsJx6 zBDZk_70fo9(SCp5;DFfKVm|9rE_^lI7fk{Y&g>?uv)?+1tFl4fkG$hm`%_<4_s|_6 z07$aDgy)ALmH_kd=9~vY_BZ#MGEKs7cjOb2zI==6?wp~hkkoA+Q&<>KqeSSxiRMIXXlO7$7WPY<46a(I}?S;T3NccPV9c zu7l22P?w_e^9|Qu$}EzvIgNuVi(6}>`Gj!FtW#A$0)Wkv;hm$hn5cpMpI)E)lixRY z9u3uc;*KM~#qNUyG?PW?Kub{Z` zi4!T{8NRf;J2?t$tX$={U6gy!jbDqO<)0l%NN=$i9vDb!aP8iS%>|Rs%uO(7ml%AI ztFeOTY?1&goyz-*L>WJ_s{>Zr_3_hL#L{vT!TfKBVNF?8l^h{yL0lPWscb>N<+0Ay zgie;Tr!tVW0D7P}21~WGFD})cH)_Osuqo zfp(3r;hmiFgCu=2V)_jLFm4gb5N)B_oSv2L6-TA+s}!Y(Wgy`QWu0K~bPS1yk*Sm-zfD$TLe-=fAZM%Y-Ae-{)K zbdFmuRU^h6MC=dk#oEP;p&A20GW`-6O z);;69!Qm)V3p0ANdQ6k@rA9}1^OM9uB?Tp6mz$@FgK47R$ysInh2Fy>uuqnd|Kh~L z#JHS0u#ZRelacsVr4KAte=XiYq#^M}El>tG@~gBcGhyZ+!_Et$+6dKmLMG;>%QYUu z6grQQ`t@Pq5kioC1867mS$iEYf%&i2rBH_vhN=cVP4*NgP7Y{5PSjt~pB z(B~Li>rg6b^Uc>oUpBU^h>3O9!%YU^mN6uzw)P6*k+g-h#s(?@J*MZ_*m&?ZG26f2 zyj22A^VPo?yiHBGyPw#AQi&buSFVP=6BEASOPyaNL2V89b;~{jX>tRwXM@E~FDq-2 ziW|Fp!jH2(`l(z%?RePkZgE`5S`-ojUIe{aEYB<~-IUkeMqSBTs#Kh$3^d*NN2XpP z#@C8Z3EiR5kXI5Aec47JLQ>QVnNf0S{l!*Jp=h7%@HEtE5n>4d)ZdrCZMay+Yc zkc^a!kwHvQanCl9S6WhOu5y2e>OH{7*Lw_d>tlslXclpx%!VWL{(aYV3w1}dd^;3e za|sVmP+)leo+h?XQ={f^xJmK;`@klz6X{NaG8orgQ3TSrUGw}$;orIAT70S$d!(ek zX02={e|L+A0IDHN{^L^2BL|V1h)A0+EVt2>UN(Fv)OSEC&*F#tE;^PidVjlNb#`?G z@l1XiWdiV^RW%Ld0^SexiaJj?jT@_K(gYm{gqmVhidED2xlV>IS0KkMEG#v34dgu4 z?v$c7OTo_ofzm%Fla+7bbsp}fKYvoeA#cfEFVtH#TB1fLrzU|^eN4&jsimo<;?*2P zYrhIP<5%Szq6g*@!|$7EI+l&F&Ss%zBXNRR7P`!*WJ)rg+7{cvDLYR!5;bEFCH@qa z_vTK9Jh)5JVB%8M?ZDg7h&!j7v^70ACTARg*1T_uCiz@q`C7rNvO)`Dd=oI}4hk{$@ZKE1^RC5txd)o?oyvkjdQr&8uxhT6 z|J|mgm5A@j$I}^V{zC~me{Ltq&oXRxHoZ61?ZO~8K91-|G8VqK#g>*wVxX&kg^>X3 zd;RJ@GC3#P?f%v+Tw>!^1hk5UMRk7*xy*W7ID<|}pSm(?;chbX)pz6RB%tONMc&wK z+M;y6Z%t0k`nN*x0qjIxv-bS#*#5qICcLRo?W`35v}+pCYuhp!pQlIWJ>Z==*R4dvf&9u($!?PFu(P? z%e<2+H;UO8MW@qNaus5Xw1%X#*=Lmy+U)Fet9XHHgBA_gM1E;;Xow$NY54YTXlz*j zbZ)k8WQ6y}yvx~UzGCV&E(JGmNxT;g?Yi%u06Q+Hdk+{HlqakT`eb6L=n*tnjG?8q zMd40BQV12VU)96Xvq_Dq&p$p}`upm0%*9BcM~8>)7K-ShU$N1{yRz3*K<^iq{rwJa z-xoFAECE>(wnMS;;JBSJyHvZhw6tJO&XFOM{FL9Xlgx3;QXQzkl?kRh zVNn01{^Et$HX4|d9D+hN%SNX<{Wo5|fJhZKU)+Re`D!o`akB~O>bAecTW~wAi6YY- zO4jPEw3@e{i0Qd&aUCU+ebrMLszBk&Of)2cvyE+8P^iYlgN2*cW0}iKVi_yh)jAeS`6_e0P5jkOZ!r-J4@=y%F|F3&?-806Bw8H1_+4^EG~;683p> z%LK&D3p7E_fyspT>*QlHy7Gxwb8GXzYpyl5H6k7eI?<=`o#rjRO_7I61?tbN0OudP zKs8nyp_d2({9r(h(CnT4$a4ME{nCAuZA1ig%=Tsp$iVnx|7V~ww!W^80haa|HIO52 z*e9|r zR_2yl5grz7F1MY?e`)HQQrzSk+G5<(Q;7W?Ms$Stuev{nJGp`6d>8b;*#J6q8C+Uh zD8FHCamy{Z`FG}^78BQ*)Nbud!smRl9k~!;SLLqT!>Ju#r`3c;frk$kUOO0TvaS41 zKn%{aCq%Rx>x@qGz|Jpnvf_}KVEa$ElU@}Ag9ST4jT^GW+zgO$vZM;*C9(O(sdESF7N+kW$SVbM8!WXA4y`sX2UD@sy?SPI^)FzH;~Ntr zf9Uy~{Gi>|p0&*uv0RQuFm+hpQK6XrQJa75#&R&w+G|Tvk8lzh&Zx;$D!N8T%+cg$ z;!*iNtkFUWS60OUx|!9*ijkEy{+Jri2_L;5mloTb3ANKKk_HO4 zu-iySRC9CqSJnTq-z6^3gA&?tabTmQ(eIxurAk<(HUS2kqm&Ecm*KYxUpVbF6BnU9 zGx2{j0iJh#iK3~+OGNl>D|0>UH>CYoQ?GKqzSwMvA!1)pkBwhVjha=}eI&m}r42r` z_Eg-z&H4QJ2fI#=X#4FtakNaqIa+;>S6nz+pR@`lA;kl;b!PZriFS&m{h~pj5i<$qjdUde* zaot$Z-`l9koK)^+iRhpJoam0UaJ~l2yuY znEC|wRfnArDh{TVi1LA zjptr%gpJ2x_68sPNMnc87)^})w8;h~ zAjOd0)zxbDzcOwnA!A(R?lT@L)xnqGRJ93EbM84p`7f#D(mToaGQQMnIa^FoUbK)M zN>+S$i0rY9dHpa@nyzcXufcRhvfWir6QyNvxVFDJ;Jahxr<~e8!oAF%hq7t+{B6as@QN$ zwAwX2KHup6y7q9W{D)Ayw5Zp0r&ZFQ4{mfP+r&Fd^-|wd z_5`>5$HuDoR(53rH^&GkRSB@8gxgrDM;R-ZX3Y1S6Lr@R3V+g}gm=FmUuMy(o_FU> z6<;1NHMn#Sj6AGmi#Q6qD&45linXUdxmTqc*D(f;2c^`SY{lCthek(cggFo=$$G;B z=!^+u2p)fA58^HP_+^cxK{y-JVqp8rx4USMdwgt|%jZIkYYxNdzcbS20xZEl^%GvP ztQmUstV4#)RroIct=tFBJ??GfBI6U?EJ(H5d!+QdKM1)pv zHX02{Ig2eUfbX3xR0UO$=lSN-5HzE3;a>03kqH2Dxuhz1kgm~_MJt~1RygOPN=9ul@IHlt-wqY_( z)x*H(wfv02{qy+|24CpgnW#brqB2ve4y~{Mx_vcuI4r^XPuoEKV>=CKtAkZ3$tAf( zO{u=`gYv*G6Je`apxOns=t2HD|2rprPca%DHHJE?%{Q`e9cy~GvM69fhnT>B9k1z+ zT=5tfkVYStxPNCfgj^x(EZ0N&m>oYOw#fO;zt)74@NktZG<9fTif5| zVPo-4Oh4mujOY~rMGE%y)i788ON?o+ZtBUruKeIQKnsX|y9!0_eyYJTtG&bB0Rlt@ zhV^A&8VSkagFw;jt3c9ou^W!&G7P!_&>n7Vk20#iSrm2ZyEZ_Rel#E(5<&zN&d5M?gRXnMHWi-kPU z*G!Bp12SFXryCb6{i|IM|Aq>mq*kzj-ltbpRzu$j-yP%^q%ib7QqG(OLcPbEDTVY@ z8<0AcFR&XMKjezoSN)>_m^kBNRmwKMN@(W&U=} zH%(dq=~wapc+&ri!D7O9&8sQ(qV@wG`xJ2N>QivS&2EncPHsCGv9;ddJXx26csx=# za;0u0Xnw=%uvA+J{jBmeIJ+JFt*gaY`mw$l=>K^73ZSUk_ibLKTT;3Nq!FYWlAZ)36bsw=~%jZrMtVOo26q}*#GhO&3rREJIq<;?4Bp?danDrns@A982z?f1w5Sg z+g%149)q6g4qkvGgYehRHtX%q<#PX^|92BWO)Dr@RA!@C_5JGp^ZNuEzlMwZ+Z%5Z za@vkdXg%?%7`&tA#ZR@O)S%i}Va+K5pm}IS5H0!-P>cX5#9ZVeGCkpB4~Jb*0XZE$ zdt195P8%xcF%w$CS-@?P8s261!Jgo+8W zyko9|M-8+6V`b0NXt#O+G$ck8!hj@2?&t^@nh4s#`-#7EX3*a*&%0lcu5E#V+l$BP!>Ejb*`B{6JG67-FyoMN*_-JSp~9Y z&)(j9Dnx_Bk1KOsojNg8Adu0^%rSXP$baA0uB6eF59fP(y9382Pl}Z8W}DP{XMvLu z?sQieB5eAb2A>lbK7YVL_Qn4KUf^9l4=E@~VqGdV4MKMk2*(F}z8uLutN$0Q!(YxS}ZCdZ42J zaDPWE(Tyk(b%<9J&}}Ff&1??$RgT&IUh}Qr%x%e?RLQ%bHGXSBfV+2Rdt$@yEB}S| z(xR@U6PA(DM-o!k9I$XcK0b~S4gl#+m1pup8WXvoMpiPTuD$`fS<%{KfO&J{S+UIN+I^bjzD#AEyq#*B2%wAQ z{2G3%_QZa%G=sTLg7NNi?+?U;CQOo4u_NhWdZN|huGAe(dUhCj%Hm!Ts~CE9Ve-<9 z3=?qsr^b@W;dUp_rh;})%4zyP<-xOevWCI2Y-P);S*`KYK&%I3kI#b~w(kw?-04-k}wUP_-Eb zdXVH!x#SDfS==jT=`^)Ba zP!_kv-?K&M0rwN;gZhazKhC36B>*FMrq?zF0COj-J?`(Xs}G)U@Pizf%cQu$%Rf+t zuGc2U{lncIf(%C*^52mrw*iY?#BsWsx4hJ`7y6{b8vmf`E!_oD69|7ICtP=!!I_~< zu3yFn@aU`ms1sM|cxC1Q{>5+dD3VIZZu4WU_^E6J4j(>z%n&pE6;+bOmAVe;)FRV$yiw#Zz{29teUZn` zyFhPFpdiQUqgxVFH<2QZpIcS`9dtbx*jbpp8!Ls3hu*`SKJwYn(2~)0*r{m+D^TP6A+e>D(@&+m75o zYyj}xz{%y>T<>^zO!{}VZxU3vSk$xsF~0mKJq<1ZK(Xg~`%Ab~RZW=Ex&J(X<3}*n zbNhI7R!>PV%8V6fY7AwoyZg}$!B+a?RqE8zxYo8O05txv-Zn@8DT(_Nono!l()RC!WAq^iqe zX|#2AIK=tUi%0W;)caVNGhS*|NRd*U5s&IDi}c`V7cG z-G?6s!K36fHat)e5G>!r>fIfmU{T__2WY-=X5KXa`!-F~0o-!zD7Jr0b_n0lC8#x- z(p-`M_YqJ~D-FiQ_+M%+P6>z+Ux~{HAZTPn zc#rlfPhXr-{of@@i-APOqhT;`73WnRsRZl4?+t=!)M4Rb08oh z^#185utlHq%zan%&D%YAr>1F+MFHt8>WkvPPJCh}7<6=(>t+cFn;~IfbPPZOC}+98 z{sH@`3fb!5pj}r_K+ga`q#WAVX+x8$MEs5p&P*#xWZsjc4vvnED@_bVDJVrM$wes1 zMJp*Et&BCBWTqsuC-&~Wc%Eto5SI*8vF}Sa9`*zCo6HNaugad}o+ka{X|2~3!_Dam z+&hg1VPYWzB-=lfbNRi!>8gEcpIG7MJBuOS3_rT(zHpL6!D`Z}0)Am_hgYTW!8zYVx@vV zSU_gd4L)5uK_TOQTw2L=f@v=X$V#4#A$6}qm6f-@U-T4|h*9w?ZZz?7C%#3k7WI3R z94EVRcYXEXH!(JnJ-PREk7LwTQC%S7b@9!ZEkoz#sA7A2t*56WxCdyu2i`k+W`Z6= zKLfmPK!}gF?30xrJXdkra0pik#6npE=~M%NW65_qt2DRd7sl-Sh; zoU;PuheIAg~{9U!>X~n<@$iB=thkMr;K|NIenFbPU|9Xy(2rab#z;Hwh8?(uGz;kG;c7Al1O~6JC=GCw# z_*C;^2&Iul4yFq1Cc~!<&1tZt@KfJlyRHEm1UP8|GnAm7x2>*do?8nCz_;>7Q_+Hl zCPq!3H}h7jH`%E~)^traihHtP!BrTkMD+rn2muOp%Xava^?6hn`$TZ`Vos-F?fth? zM$y$5lw|n;DpKa9gyc+XTZhs=Cii^k9Js)yJ)XLKt!luP*AP&C;*Maq>c^vkW~p80 zKaUd<>@A(Jbi?>F5>bMa1d~+jYHDP5v6{Toy-G5i@_IIw%M`tJ{s^VIij{R^f9$?#%hC+>c^*7 zrVjGVnb#s=t*gtL+kJR5GjM*reLMe)mz}Bk6P+lM>83oxO^icZ`$r#NVdz78DXF&0 zWrw4X7yRSk@JL<$f%)-?{fr%;m4MMsP;E~$y>2NV2;s3G8BtX#Y-#o zYLP{7D;0_ChpPhMS?zs0$&XgFT%xAi18gp3wjQ7X+U=cOl+ZZ1dVO8d*#H};H5Dc3 zjjZ7KAOQcE)2+#_NQ_mR?wR!10T{N^k|Qo(=&#QzM?Q^@t#u%+dcl35!ZPX#EiG2` z2YPClg6kuFON+gZ;;Y)gqPSnZ8Ez+fvC+7!%4tPH+A;wLijfHP@B(oO+l)FDK#P2q zhs_pZIWT1VVbqqU!UjhLHzh7b6rO8jTI+%b+H<=}jTeIpT7QLiRl=AE)hdMwjheQ3!dm$PEG zwjz6bkqiQ}1@p`krK_dD&eOkVhomA?<(O!8eoriD+gk&z-p|KJPdG?qfaGiUAA@d_ zu9v&sT~`?g-<4JLO-n$kgzN1}hGKxDV*rICz*0z=t!5ZEI5%Z8J2f~bzB%^i&)gqz z6Pe7@8sEoZH)okAtDN(dgf8nXFnG$Q$*MTG9oWoF>3YHKUK<_wFWr2({PmB0BoTY= zb}J95OgolvR)B0-X9W!)MYbd4K@G7qKyBuI0{+ngFXD}uXwaGv=+&Ax?GNZ?+-Y>-eg>X6fB4#li@ zHShE!`{%hU;f5q8BV|itM6$6lR@Z!8?=B#w6fqJulih2R*ZyqImkK@@`y1;7_L0Fw z1;U2~y;-112r4x=l#|sf*knKZC*1$0J3JO$ELw+V^GOcaxs}!vsV%E1=^6%$#MuaP zA;{TX-n((qRTC#TR4nh!(3-%g*x9YO5D~HQWC|v-);7bH8vR7E>;!4$Kna>iOKT@- z(WK;5ujfb5EKaWUz(Cuw{{788zR&sP>U{wop4ca8XVJjoNRwGA=nVl?myM;Fzw0em z_c|Ge3CH?v?%(re6cyA**^_=icGcrOKKhHnY{_c`JK?Z6$-JN8po&wjXN2e*tU|*1 z3W)+MEK)S*M?9)#o7pxqPurY|gZ+&Hqd^G$!LG3HZC2LOzLd>ZbBOO~U>r|jO$z56 z_m?BRL{q7W68~Q@dO?S}hdR??-2+bSV5MBe0(D}@($kGw&6lU}q0;FSzFWI3zC4*l zd3T-)TB6K=-F>6(^=^t4K|Q?}ud{*hp_!a4FuCZ)(EMi=1186QH8eSN2;XXDYjtAd z-kPHj;+Zo*37~lpv&Y*zQ>uwUSFn^c7xaq5$QL%YK4AsP_t~v?>hx_tT4Hi-u335r zrxHagnLUu83D#4;j{Iee7z+DXF}8^QO5%e_b1wMP#`)H!V$HM04e^$={_@Aej{^7q z_^%_zz5_}Jjd8xwG;@pdgt?8h8t-sPWwAoGPb(^vqN9+!IM&b!DBEujZ!xyopM~zJ zj|qsXVaD0Da&jQJ&_~l=tILl|WwtwxZdQl(_9r%WFCXkzYGTepsPR}>m7(AM3msAB^9Sbrk$U3YJEy=d{V~Y+I^+2oao|GjDlC*1 z(F*&Jj;-Eue_!;G;L{6!4#aM;2o$`ki4F|1!Y-B6;S*}x+UEblZSm03m%)mtQ;2yA zru!}ZX7=+RC*<$Wrq;w8)ama^>N#}>RTscGo zeJ?L(fz8lq0QI_ZWOe+Qk)P#v;k{U7AKVV~caz1CbG$brBp}?|B}Q#B@r6|couptH zX6>@x4M1QXLg$@NHGgyA&0fS8N^6efe>fHz%G&*G%lvGox6hr-G2be_U0vE}gKih^VN%mJrv!ez__%XF}gGNB=jbuxJVqM=rKU7!!L1k&$Y97SmYiCpS;Ft?3-V( zdJ2bFXz~aiHCm9;s~^Ss;fOw}sHtizJC+!~U)ZhL$Hu`rJ#nZgY2T_$uC98bn;W!W z`THNb3R892hdH&Ym;0J#w+7C{*rVs?zqM~(x(u>jJ;_JcES1H8+|<#wEl45xoC4xVidPqxPo+ zhh$!BA-@tPQTH>yWL=G{`6lZWCqw0VwY;)gENJKWk*pGr$(0ZCX}tsgv&q#&0%5mk z>~s=jy27|Fi*#DwP`l0Qnd52?VEZyQ5!XKYTV6Il&!R2mQ|d=w1OiI5loCu!gr2rK zOGOjToV;`_%`JV+v2>7b@+K#|#CSOl3w2d}ebtp2ytwnlA2Ph~>d1mib3UhOPtUk(BPqBh3DiEs<<*Se!y>Lk!X-H+d>oI75ae z2atGly7BGodfDGJ5QHmozm3{U92nhMx&qkjyvzpg=mtHpuUu16LX>FLZr0uH<_-tU z8IN@S+sIfp{xPC#`XN1lmF@V^e|&+>^l;e&`_;cR;@kQ3=Fs}cDw@!wNE zv|wFc!e8o|#lphg$|T1}8hq#P=%-QvFk7KTG0xc{SRkp=@6gk(?{tm9!+=N6-O9Bi+(Ord)Eq354Lz-%vnScQ#l$ugmDBPK@oGTlFzB1@cQCMYOZ?wF)HM4oi@h7v=v=g%84*sq1x_hZQi99}N_8=8r@LJ&j}Cq_O``Vu4jz6U)9tk!RY8ds%G(y&(A zXmu9+E_^6qBO}yrSm$6a+>BPSCmCMI{KVLl)u+LGzCxKQ&r6^B(|`h3`)N6CtvH1| zCbxbTq`{!tOoBOp8nn5d+f`kZR|t|ZP(~46SiDdzstNk;>TZb{MIR zzj-`Ct-TObW@~R}<|M0(KL5A8Q^-0D#HQzXd-;<-p}Y;c0)>_fE`tmBdRXl23ANi7 zgzva`3yO9#!aK8j1Vi_BJ))nAEKR3B1d$6e;9O9SjES2}y-V5@@$FnM&nfuvYPb1> zlnhDoO3#HvkDPZ^R%dW!>AfIZXMWh44>V4P4J(?@TG-0>YatjiFz=j3LK+73l`B+_ z*Ch|&d%ii7WhQ-xD*4IJVCDe-)gSJXde=9%5IqPgLMfvEzI(HAU`Db|=@JhOgTPU4 zr;yH=(R&kDaOu2Yx=-tEG*$+wMM-0f%lw>GY_8%u5h@}hd$?R*!t@U*j|Ph$${d?r z={j-MC6(NPhp5aW`6=!ldap&E#YCSnyxl40WGXAllv`HScN1w-WCPb4T*eLHi8^vc zrL*daj?>p6*D}~bL5v%kDo=Z8J5b&DP&uL4VT%|XY|>Q@PF7|H0YrD9ue{13)F>!Q->QFt8ljzj?NDKb-GlWc-qpqmqOwDG?tL>+z@SufS&iAmmSor!(r^O>m__x_?EX*GC#P5_MyCM z0<<#kZVlL!2*Mws??nt9G)a&SKj5MO;|+sJG}QZ1m6KIC8#@){;c1lX9n#oM8oRlLKO1aL0eUja_NgWrqJB0P-xQjiRBKJN z9F-JZw~td(PVDSUtgS^pQd@jTw~{szcD<7CV=JnhBtwEZ^TL|Gj<`c5t5&2&+Ejfc z4R}NoyB`ksQjIY6@tlYk1MX#o1}r$Gk)YA&r+2MM?f`r1d+_t*Bk1HMkw*vB#q0Sj zTIY0JiLc7|(c3`VH*W8yv60!HQ08F3de1P%F}cH|*&ZLaNg0jw_}HBbs1}(?%Ek#( z7c#IgW1+G|V>SmpI9YS$E2j{da0IwK_PL~*?c@qa9n>t}9CP);IzF4Zs4G`B7yO7@ zpdcWT{FZ}|M~!e3heI0@Y6JqzX6xg9GW(_ntR}MaM`og%nQwW*1=E!_<7j7+W)eiu z^I;%c_OVYu2`eWvv0jt8lX9I)jZI+Z+)vE^8EjeI)J#$PN!{=+ZUKXwcW~*CDs4Q0 zh=WovwPLj5Zf8f4`ceNy%DbrGU&rM^IA;YMTjd@}G)be5>WgoZEH@{KwY}`pYz8qK z8d-CErFz(9sB(|a4r`8roC7-oAj@QYrS!8XIAcQ`E#zd?1;exRbA@%mco7z}!ztw@ zz+!~1)G;@@B8^K)Gx_Rg#1c>l=%%fvr|%OGYIsdbOM^x1%SpqfVn{@NU9ZdPvp46q z8^xGdA9tocK-cMaq^r|}yROOJiA|lHBvLBQHSm7eaCzCDUkX_n7~tLvn*XF=NJow6 zy^o9aN__IWrs?AG_W-e>j`i3|zI7R{J6JHG4{aNx}(A%=M2?XcLF3%&i$ zv>GVYTh5a^HE57QLJ|a9$t)g!?8MQjfVnN+vAn6%Gq6l1q^f>WRxyxUhwp!~+#|CW zJ$u+AcXR;z-4{Ry2Ewc4aQnhIx_Efw`gNLj%+Td6F27~vQ+q39E`O8}@Tc<}+}{ z0s9PTeK?W; zVM^)W$f^8DtHHS4&*vIG@;WNn=@t#75q-I8-_uHN2Q)Q=>SIP0jk?yI9^VGDmhNVWs=706L*uh9fc$I|28E-;lTV_as?MM&!0XXa zMJ7mE34-5qkvt;3VY0mndZcGNS~!OKLZ#%ETVvZ?pt8#2`%u9fFY3(UZI{-QO@Bwj zuYXGs_LvSoJnR2zAKIH8sm&k}mKHz!4E7W0DhmCpG4(zUDdeV!#C7p}EzjxD=4t>L z1=>F3>k-CSq#o+{{@Bh!9ym`yKxi{`p1@_E!|-rB#s^9Y3CYG<8d7W#%JT0)4q8=G z=dfrHet7hkdO<-^2VOrL@XH|kkSj+k(jyBPR(NKoxfNJ`7@5}g0`rI3X(24>o zSjpMO2%DDQ;aL(lz4K$R=}l z@b~QSD{TL)hiiFr=WJKgmpy4!cz_Tg2dxn0zu(r92{oXbm2beVUi+0yPxZbkCZM6Q zA!RJ>X*LC18bb5q3&Cs~_Kb{LN>VBX_^mWy&XKqcI8LG2d0`M{D;G5%@5%{$0nnnyOlt zo#{lK9egCoywrl{W4+mNOp9?szl^o*6o0zL#2fW=bo?!8U;#)}v1{(-h!Y=Rc$t;$(I;yG0a-;oAaAea-8OE)`bWVa!5b@K$uFT zVK~L>1$tjw zD`TT!@=nd3{MB+8V-xdxKJ!`#T)vzrb(vcTco4XjNKTEZO&jY3I~6*?{cJWP6ig{9 zI*u}_%6uY*Nxa0KZ)HMd+^!&6np$ejY>HzQ<7`fYjpzv|P!z2NP2!XkcLr)<->r)8 zRU5y*1zyqjN+Dz8kjR=GNIrOk>V^L35biRm4Y6ZokC#;rk%0xk++8g9IK^_EQ#+T^ z&95$t9r^Hv10yUpW6~Z%#5@m9Kk%90n*`lyH!gIgs1BI)UOrO;*`hsGD%V$i$-lnM?CmP(hR%M>N#ortqV5 zAeNmIgKDU|LY{qgKMhV(6IMRE{vFDo=Rmyk2t0Hu+}$W}df*qZv9$r%Z$));QuU_F zkxF0rs--L6HUBH_f0LQFf8A4)xp?UH^n|QhIS4vOAWGU0oN% zryD(9KPP22k}rTm;0c0fCnSFsalceP^~;3&`3zt_LOz$RKz)5a$^tpyvj5BR()Y?& z)9lmK2^N}6O>U*c^1HYC3ZxkX+C$a&hr+rE8mi(PIK4A7lcjcrC?Q%(l}$mX(h3y% zBLlO zC8>HsxBYaoc(qbp+Ufb3a;J$ZjklO#$z`^V0RdW$mD{6eB#uEm**8lneRnSYzTHp2 z*^lqOwTH9{H1H_q@1~zU=0m^gaU+cU{r!a@Q6L}w=ea~> zj%P>^f1?F|d2!nI^lx6DoBh`Akyip9z-EVxg2aM_s$$go4qVx-ya3?Oq!jL#z5>3}MyO>fFud=t>4W0pk?bU6-*to+3|TiU(>vvtzk~|mmA3hwniS4dlHgV1@24-z~ zOKX|ybZXTEkmpj~z6=L26OM2i6Cn6@m2le4m4PPdeODgkAegAlRPk7^3g42wo!KGJ zQ+;?A3qsG(pP18QB}!i2DR&vf#1{D~ow7iOYIf4yd2Dz-75FT4-3IKmWX89Cs4U!W zVtldFKO_Z4G8XBr3#~VunFMxrjwkFWSId$PCC+~u8)y-|%@S$6pZnm0DEYFtv=Rh1 zD-(ZTQBesxxF&mebp?LaU5WsHt)rhod*15(Idjm@XM5?+SW`xWd#MsqxvSm(WfVln zEgPFxUM1Sx!Y@+2Hwbp)lggVnK6nBr_F5JgR7u?2e=TtBxnWa{exDIpW)akMD+b+4 zcyfVAm>aE6p?FBp;|+Jbk-{--HJ)0_+&Gh%^bIY{(w*TNj+3mGDyS;90(^k$St<}$ zqJy;N=bc48?*8oA-`M`r+MmfX@Q;N!f$q$PH)COZ@%zP%?2!o z)S{c_$+B*^oWJtO>^JLbauV@S+B&-pfeh^eiWhxkD3%du66oe#4g@t0gT{CH|8L^Q{x4-wDSQ3)oBO zc=+{+Ru8@jE)E-TQkF-S^F*or#AUuWnr-NF6b=z~&)#z*V$Mcm!i#L0mY;}od-c>z z8a`~+gcuHVCmClamu$EW(+JQ7+(*!%qhzs$CZuw^-lAkbz5!Oz%J7VTTHT9ZQgv$A z!)kW{;E$Q@EqgJ>qbXZw81~c^*ZR|1p6H8w=!EVbN}-S#f?9cvBy@na0U>RaOQW-k66(7u1p&bgH(^Zg<<&w9Dm}$ z4j;B)C+G2h88Y3HV|wB+N=6B}>0seu{8}2@EWBTB@30owWO-ax5GwGaTlFV0BsJyP z>wG)nV)f=6n}GZEv)Beu?U{QQvrrSiZR*^9w9aP&6lns!vM!I%(j56p&WNkrdmW6` za3=8lS@S<;B~0ad8wDUKZ-C;5rfz0d;{78S9??GzT5!hEaF0oU&o5VrH z;ojKj#EvhIdGVFv@778O&aNE;v`vvgLp1RAlXqXl#m~Dy+)YvJ5$x0(bw$Hfo2iYp zn^>*tjKdSA0CWl2Ga%!IL&g__*5hO_zA>3a^y#Xk`ff*LHuL2QE3i>5a{LB|pWL$; z`yREMl*;n6G7t2x9QS{qqjX4#!2?XIT>2%uOx$msWRN9fEtHq=q~~Tp#H@0d%Y6C^=B1 z@rOeyKO9xg*gFu}L;6`93%WOTI=7F$SX-6C*Q=gy!qG=r2*X^|#e_VhqQ@zBj{@nH zT~d=sDGCdhJCkdtIapGwACB&kS6Byu(%%c8S6Iq`6moVZ8R_Z6MK;wvrEuVNlYici z(B+j>fUtozUbh1v4bC=AE%OqJ9SsOrc~?h~-#GttZ57z+NZj}mR>0Uvan=}Z`B_F! zaer(|B8XC8`fd;E*W$T9_`e8kaBB6Hq!NH?S_`G=dI>sR($~{iZ+aJ1RJNR<1)&6c z2OeDZn$<21&Ihc;nL0LKLxYJycJ}BBktXTQH9K5s#nKEx)sH!C;~Jg}B37!0-)9*= zZ)^7hbedRzF6K|`!I3eC%bR5|WJz5^W?~TNy7~A=r54Tjr}LkFgGzabYU1nhXajX0 zLH!lViGUq-1HxC1k^N>zSKceaSQJV**s&%5t0xHKztPfS1d;SQl$EiLdr z$JaeTX zmCTB1Us`Z|zIN2RO4S1TRxO0TAVm(J zw&>&$smzX-srZxX1t)ZW(ap$cT@q5J5{VgC54=cM+*g5&lD?x94cfQct;FVi*(>*I z$mEVD@ew$IKc#vTbUB^dZ$4j)ZU*gRAUd<_m%E2*MW#>+`_=s})TNF8IzP9T1?>>_ z``%**K-eyAnTQyQ_XmoReSLiao)P_(l??rH_(uTE(deCVJns0~abADk2H-r0nXR(t z2zvDwy3~hbYeOUXsVKV+Zft#sM#T;MfUA8wW?boz$;_hh*{=Fr`QU3-|HK-l~i?R)5dX*HJ&`&URA`Z}npt3@iI`5K4p7Lshqy zpE=Zm&L~@pn?ZxAPL*mBa5%qF{#?sodNkI)n5|X?*IoJ0g=1gX@c1`ZcJXSXbUj=Q z{$QMOs~feqJBp|I(akx(SkJ>la(X6UKJ*QnmrInNbKfZ}zUmyqJWNd5eh|3n%Xz@P zVTU}0x*ee1r{4}MN(MX04vjqeU&6lUoZpmm@Hty4`Z7F%OgiF9CdNy*N2%&~KkS#e zkv-IH^2Cl(5bid+9>3$!4+AVGP{+^)!oRYzZ&Ve!%iZY8O?SEA+O{8rA6t%hgm8|Lz0-V}$Jbdx{nB&av!H^xPVDu4|V(jBh;Y&pUM*a*R1b zXizZf^CTqN?CNZ5p)2yp0w5QDjwo$%1|NscG+bHs=K0tzgpJ=%V}d#JDV-0;Rscrp^kDTtKdd@a=$)w2<<1 zd5w&Zd^Qfkp=&0k`e1_7&t}Ga2?PZ14ZXu7kl1pe4R58`*U7xfs%hh9-Og}AGX%mY zWFauNFb_cY>K4ZcSo=e?SEuAMExt}TV0G2o3lcu6s7$Ec;d>x&8T<_Ydbd=jMfJ09 zd;ZBQOW~KjP(((_!2GsrW6SZbn8y`3On-%8rEOqkrC88c8;Z<}Jh0#9>kPCd-VW?3 zpPxnWZE%!Vp-*bk9%XTSdU9U>M3)O6&ZF1ziJPa9^i%c$R9I#SyYHCEUy2P@ydeoa#*g6P%%Pb&!uAhD` z7L>PI1b5kEs6-33;4BY%x*}4?3qeMA6FF+@DM-r zGjc9{XRQ$FXRIl%%_}X=(C@m17FqrX2f6X{B1;2<=0j+C^Yc0vWZI9jfcVWC8+fRv z+5%a^R|tQ&yT5RDR5%D4hxan5q`(|0?%0E7KV?RBof8qi1n`{ZkrDsupTi>hA@Ee~ zu{o(*L+y<#f$Q<=KSC@#L>D?b$Gw``?6uXpww1A&d{yFmZaJ6RS9Ok)s>EI>=Ce)q zUG+hOs>FJWxhEuLN|l*38st$*)t&eB;r-|??lB|7f<(lhg=sNFo}Ei36E_2BEPlc~ zT|AOu2itYakeHlLLEF=5vWOv_GX>a)pA%}r#l=x4Db+)P^vB%xE5C_i#_hmGZN+|I zEAWgO3qdMW2zpo7#W9R1$~SkphtH|7a}@Iue@@Sl787u1PCRLP;_^G9`OC*f*ucs@ zd;#r&2PR$nY1_%PQ;fNzrme2DxUHp4UPfvsG>%5d$p~w*V>Nx9a%W3w{cC-V;Nt)a zdhEp7gPXSPPgIr>TUf$k5 zYQr#pSSyIe;;Z+*ksbENWIfS1Hs-YUqBlK{nkb9&GmIW17=;a$lE90@`Y_hy*E=&P z!nFMn6QTrvW(LIMl2-zE0V=&C98f01c8j?Z<~WBsuM%@gv37 zmn#3^lZpuzMW?s;dkNglEWwVuoA@e0cZ4Y|RZD`L(U}5ZS~2EeUD43{kQ4XZ@-QgX z1Z9C;)oDb_hr_4Ix>5PIJ9^}M7p2Fm14!|t37_8^9s`xw!C7raFuqRoTbWAp!XlAb zv5>X^VfGA4EcI>CCz|tms@>jCsW>DOd)%vZ1o1y)$wHN?&sG$pO>Ub#-fkT)KdyJh z$1gxKaX3oogIp~j6RV384E+GOV5x&#imh@nJSGo4zY6TmTUq+I8KdN#x~-zIXP{+2 z9VHopH)$MA2qmHqfEm!b@Se_w>8)pQJJ@^mVTt5eDVgs8_qy@P$V{me^gg(oUXpzz zkZ5%Q2a@H?bLF^wD;Ncg7b9wZP20%XZ1d33Pu3Keo>@dln+qd{!JPl<=7MQk(8WvX z1x0h!_yrCi&(hG|Od7fVj;NB7C!RFf7hDIskF=(2o7C`sT}DuS_1e2L>n!l!c|suz#}@kB6dB;YWStAUA%WE2ON50L2`=5cX3oh5+w*zQq)qY zA{bX82ne04i3iY}z^1Rgdu~F`CK~3o%^eRT-W*^yjxJZbc@#%`XJEC}exik^8mLLL z@!3+kmK9XR-=0y08wVUjZ<+J(+g(}Dqdkb_ib<>+^V;xB;PP>+lVxTcBf@@6H}lNR z0j47riy;+I-QCJtzdG`#C@rm~1DKt}oCCq%cRFF-c7}EWnV)kWBHo$#Xpsd|cM1A@ z{o|E;9?_Cl6R%7piWKKJ=Xq_TviZNCjs3r$txk;WI#-+{ti{hwZ83l0LTXp%<5IBR zSq*NM-Wb~2>!xFPLJSPJnmX!x%T@(l=�hrqtA%SA|2X*c|=tdG14O%IY4T`nlE1 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 cZ4XZeuLvEq8xu{k@Nc6?h{y_82 + 选品采购台分享封面 + 采集商品线索,匹配货源,完成采购决策 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 选品采购台 + 从商品机会,到放心采购 + 采集竞品 · 匹配货源 · 利润测算 · 拿样决策 + + + PRODUCT OPPORTUNITY FLOW + + + + + 发现高潜商品 + 淘宝 / 小红书 / 更多平台线索 + + + + + + + + 匹配优质货源 + 1688 厂家 / 价格 / 保障 + + + + + + + + 形成采购决策 + 筛选 / 利润 / 风险 / 拿样 + + + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..01b138ef6cdaff18d80a515e12982160da8d7316 GIT binary patch literal 39657 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 + + + + + 选品采购台|竞品采集、货源匹配与采购决策 + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    + + 正在进入选品采购台 +
    + + +
    +
    +
    +
    +

    Sourcing

    +

    选品采购 SaaS

    +
    +
    + + + +
    +
    + +
    + + + +
    +
    +
    +

    Sourcing Opportunity Desk

    +

    选品采购 SaaS

    +

    从淘宝、小红书竞品到 1688 货源,完成采集、清洗、匹配、拿样和采购决策。

    +
    + Expert Workbench + ¥100 内跑量 + 全品类可扩展 + 风险可控 +
    +
    +
    + 本地模式 + + + + +
    +
    + +
    +
    + 当前路径 + 竞品采集 → 1688 货源 → 拿样决策 +
    +
    + 首批策略 + 饰品全赛道 / 100 内跑量 +
    +
    + 管理员能力 + 用户 / 积分 / 兑换码 / Token +
    +
    + +
    + +
    +
    + 线索总数 + 0 +
    +
    + 待拿样 + 0 +
    +
    + 1688 货源 + 0 +
    +
    + 匹配款组 + 0 +
    +
    + +
    +
    +
    +

    Filter Bench

    +

    选品筛选台

    +

    按平台、赛道、状态、价格和风险快速缩小候选款。

    +
    +
    + 全部候选 + 0 + 未启用筛选 +
    +
    + + + + + + + + + +
    + + + + + +
    +
    +
    + + +
    + +
    +
    +
    + + +
    + +
    +

    SaaS Workspace

    +

    登录工作台

    +

    登录后进入你的直播饰品选品采购工作台。

    +
    +
    + + + +
    + +

    + 还没有账号? + +

    +

    +
    +
    + + +
    + +

    筛选

    +
    +
    + + +
    +
    +
    + + +
    + +

    改状态

    +
    + + + + +
    +
    +
    + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..298564a --- /dev/null +++ b/public/styles.css @@ -0,0 +1,5013 @@ +:root { + color-scheme: light; + --bg: #f7fbf4; + --bg-ink: #123c34; + --paper: #fffdf5; + --paper-strong: #ffffff; + --paper-soft: #edf7ef; + --tray: #dcebe3; + --text: #211810; + --muted: #65766a; + --line: #c7d9cc; + --line-strong: #628f7f; + --accent: #0d6a54; + --accent-strong: #073c31; + --turquoise: #249b90; + --gold: #a56f18; + --gold-soft: #fff0bd; + --warning: #8d5f14; + --danger: #b33b31; + --danger-soft: #ffe1dc; + --blush: #c9544a; + --blush-soft: #ffe8e3; + --success: #257948; + --success-soft: #dff3df; + --ds-jade-900: #073c31; + --ds-jade-700: #0d6a54; + --ds-jade-500: #158064; + --ds-turquoise-600: #249b90; + --ds-turquoise-100: #ddf2ef; + --ds-brass-700: #a56f18; + --ds-brass-100: #fff0bd; + --ds-cinnabar-700: #b33b31; + --ds-cinnabar-100: #ffe1dc; + --ds-paper-000: #fffdf5; + --ds-paper-100: #f7fbf4; + --ds-paper-200: #edf7ef; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --control-h: 40px; + --touch-h: 44px; + --motion-fast: 140ms; + --motion-medium: 220ms; + --shadow: 0 18px 34px rgba(13, 90, 72, 0.1); + --shadow-strong: 0 28px 64px rgba(13, 72, 60, 0.19); + --radius: 8px; + --display-font: "Songti SC", "STSong", "Noto Serif CJK SC", "Source Han Serif SC", Georgia, serif; + --body-font: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --num-font: Georgia, "Times New Roman", serif; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 8% 7%, rgba(36, 155, 144, 0.2), transparent 25%), + radial-gradient(circle at 89% 9%, rgba(255, 230, 172, 0.58), transparent 23%), + radial-gradient(circle at 78% 82%, rgba(201, 84, 74, 0.1), transparent 30%), + linear-gradient(90deg, rgba(18, 60, 52, 0.035) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(0deg, rgba(36, 155, 144, 0.04) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(135deg, #fbfff9 0%, #eff8f1 48%, #fffaf0 100%); + color: var(--text); + font-family: var(--body-font); + letter-spacing: 0; +} + +body::before { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + content: ""; + background-image: + repeating-linear-gradient(0deg, rgba(36, 26, 20, 0.022) 0, rgba(36, 26, 20, 0.022) 1px, transparent 1px, transparent 5px); + mix-blend-mode: multiply; +} + +.mobile-header, +.mobile-tabbar { + display: none; +} + +.startup-screen { + position: fixed; + inset: 0; + z-index: 1000; + display: none; + place-items: center; + align-content: center; + gap: 12px; + padding: 24px; + background: + radial-gradient(circle at 52% 42%, rgba(255, 241, 201, 0.52), transparent 26%), + linear-gradient(135deg, #fbfffd 0%, #edf8f3 52%, #fff9ef 100%); + color: var(--accent-strong); + text-align: center; +} + +.startup-screen span { + display: grid; + width: 58px; + height: 58px; + place-items: center; + border-radius: var(--radius); + background: linear-gradient(145deg, #1d8069, #0a4e3f); + color: #fff8e8; + font-family: var(--display-font); + font-size: 28px; + font-weight: 700; + box-shadow: 0 14px 30px rgba(13, 107, 88, 0.22); +} + +.startup-screen span img, +.landing-logo span img, +.brand-mark img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.startup-screen strong { + font-family: var(--display-font); + font-size: 18px; +} + +.auth-pending .startup-screen { + display: grid; +} + +.auth-pending .auth-gate, +.auth-pending .app-shell, +.auth-pending .mobile-tabbar { + display: none !important; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +a, +input, +select, +textarea { + -webkit-tap-highlight-color: transparent; +} + +.app-shell { + display: grid; + grid-template-columns: 292px minmax(0, 1fr); + min-height: 100vh; + min-width: 0; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 24px 20px; + overflow-y: auto; + background: + linear-gradient(180deg, rgba(255, 253, 245, 0.98), rgba(232, 246, 238, 0.94)), + radial-gradient(circle at 28% 9%, rgba(255, 240, 189, 0.9), transparent 22%); + border-right: 1px solid rgba(13, 106, 84, 0.22); + box-shadow: 14px 0 42px rgba(13, 90, 72, 0.08); +} + +.brand { + position: relative; + display: flex; + align-items: start; + gap: 12px; + margin-bottom: 26px; + padding: 10px 8px 18px 0; + border-bottom: 1px solid rgba(13, 106, 84, 0.18); +} + +.brand::after { + position: absolute; + right: 0; + bottom: -1px; + width: 74px; + height: 2px; + content: ""; + background: var(--accent); +} + +.brand-mark { + display: grid; + width: 50px; + height: 50px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.76); + border-radius: var(--radius); + background: + radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.42), transparent 28%), + linear-gradient(145deg, #238673, #083f34), + var(--accent); + color: #fff8e8; + font-family: var(--display-font); + font-size: 24px; + font-weight: 700; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18), 0 12px 28px rgba(13, 107, 88, 0.24); + overflow: hidden; +} + +.brand h1 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 18px; + line-height: 1.25; +} + +.brand p { + margin: 5px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.nav-stack { + display: grid; + gap: 16px; +} + +.nav-section { + display: grid; + gap: 7px; +} + +.nav-section-title { + margin: 0 0 2px; + padding: 0 4px; + color: rgba(49, 95, 85, 0.76); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.nav-admin { + padding-top: 14px; + border-top: 1px solid rgba(13, 107, 88, 0.16); +} + +.nav-button { + position: relative; + width: 100%; + min-height: 44px; + padding: 0 13px 0 38px; + border: 1px solid transparent; + border-radius: var(--radius); + background: transparent; + color: #514739; + text-align: left; + cursor: pointer; + transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease, transform 0.16s ease; +} + +.nav-button::before { + position: absolute; + top: 50%; + left: 14px; + width: 11px; + height: 11px; + border: 1px solid rgba(13, 107, 88, 0.48); + border-radius: 50%; + content: ""; + transform: translateY(-50%); +} + +.nav-button:hover, +.nav-button.active { + border-color: rgba(13, 106, 84, 0.28); + background: + linear-gradient(90deg, rgba(255, 240, 189, 0.62), rgba(255, 255, 255, 0.92)); + color: var(--accent-strong); + transform: translateX(2px); +} + +.nav-button.active::before { + background: var(--accent); + box-shadow: 0 0 0 4px rgba(13, 107, 88, 0.1); +} + +.nav-admin .nav-button { + min-height: 38px; + color: #665b4d; +} + +.install-panel { + position: relative; + margin-top: 28px; + padding: 15px; + border: 1px dashed rgba(165, 111, 24, 0.42); + border-radius: var(--radius); + background: + linear-gradient(135deg, rgba(255, 253, 245, 0.95), rgba(237, 247, 239, 0.9)); +} + +.install-panel h2 { + margin: 0 0 10px; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 15px; +} + +.install-panel ol { + margin: 0; + padding-left: 18px; + color: #675644; + font-size: 12px; + line-height: 1.8; +} + +.install-panel code { + display: block; + margin-top: 12px; + padding: 10px; + border: 1px solid rgba(13, 107, 88, 0.2); + border-radius: 6px; + background: rgba(13, 107, 88, 0.08); + color: var(--accent-strong); + font-size: 11px; + overflow-wrap: anywhere; +} + +.install-panel .button { + width: 100%; + min-height: 38px; + margin-top: 12px; + font-size: 12px; + font-weight: 900; +} + +.main { + min-width: 0; + padding: 22px 24px 28px; +} + +.topbar { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 18px; + padding: 20px 22px; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: + linear-gradient(110deg, rgba(255, 253, 245, 0.98), rgba(235, 248, 240, 0.9)), + var(--paper); + box-shadow: var(--shadow); + overflow: hidden; +} + +body[data-view="capture"] .topbar, +body[data-view="competitors"] .topbar, +body[data-view="suppliers"] .topbar, +body[data-view="testing"] .topbar, +body[data-view="decisions"] .topbar { + margin-bottom: 14px; + padding: 16px 20px; +} + +body[data-view="capture"] .topbar h2, +body[data-view="competitors"] .topbar h2, +body[data-view="suppliers"] .topbar h2, +body[data-view="testing"] .topbar h2, +body[data-view="decisions"] .topbar h2 { + font-size: 29px; +} + +.topbar::before { + position: absolute; + top: -24px; + left: 18px; + width: 220px; + height: 180px; + pointer-events: none; + content: ""; + background: + radial-gradient(circle at 28px 28px, rgba(255, 255, 255, 0.88) 0 8px, rgba(207, 184, 146, 0.3) 9px 10px, transparent 11px), + radial-gradient(circle at 92px 78px, rgba(255, 255, 255, 0.7) 0 6px, rgba(207, 184, 146, 0.22) 7px 8px, transparent 9px); + opacity: 0.48; +} + +.topbar > * { + position: relative; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--accent); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.topbar h2 { + margin: 0; + max-width: 680px; + color: var(--text); + font-family: var(--display-font); + font-size: 34px; + line-height: 1.18; + letter-spacing: 0; +} + +.topbar-subtitle { + margin: 8px 0 0; + max-width: 590px; + color: var(--muted); + font-size: 14px; + line-height: 1.5; +} + +.topbar-signals { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 13px; +} + +.topbar-signals span { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 9px; + border: 1px solid rgba(13, 106, 84, 0.15); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.72); + color: #315f55; + font-size: 11px; + font-weight: 900; +} + +.topbar-signals span:nth-child(2) { + border-color: rgba(165, 111, 24, 0.26); + background: var(--ds-brass-100); + color: var(--ds-brass-700); +} + +.topbar-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.session-badge { + display: inline-flex; + align-items: center; + min-height: 38px; + max-width: 172px; + padding: 0 10px; + border: 1px solid rgba(13, 107, 88, 0.18); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.76); + color: var(--accent-strong); + font-size: 12px; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 13px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--paper-strong); + color: var(--text); + text-decoration: none; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 4px 0 rgba(13, 90, 72, 0.09); + transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease; +} + +.topbar .button { + min-height: 38px; + padding: 0 12px; +} + +.button:hover, +.icon-button:hover, +.card-actions button:hover, +.card-actions a:hover, +.compact-item:hover { + transform: translateY(-1px); +} + +.button.primary { + border-color: var(--ds-jade-700); + background: + linear-gradient(180deg, var(--ds-jade-500), #0a5b49); + color: #fffaf0; + box-shadow: 0 8px 18px rgba(13, 107, 88, 0.18); +} + +.button.primary:hover { + background: var(--accent-strong); +} + +.button.secondary { + background: rgba(255, 255, 255, 0.92); + color: var(--text); +} + +.button.danger { + border-color: var(--ds-cinnabar-700); + background: var(--ds-cinnabar-700); + color: #fffaf0; +} + +.button.danger:hover { + background: #8d2b24; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--paper-strong); + color: var(--text); + box-shadow: 0 4px 0 rgba(13, 107, 88, 0.1); +} + +.button:focus-visible, +.icon-button:focus-visible, +.nav-button:focus-visible, +.mobile-tab:focus-visible, +.compact-item:focus-visible, +.card-actions button:focus-visible, +.card-actions a:focus-visible { + outline: 3px solid rgba(42, 156, 145, 0.28); + outline-offset: 2px; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.46; + transform: none; +} + +.auth-gate { + display: grid; + min-height: 100vh; + padding: 18px 20px 26px; + background: + radial-gradient(circle at 86% 16%, rgba(255, 240, 189, 0.82), transparent 24%), + radial-gradient(circle at 8% 14%, rgba(36, 155, 144, 0.2), transparent 24%), + linear-gradient(90deg, rgba(18, 60, 52, 0.035) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(0deg, rgba(36, 155, 144, 0.04) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(135deg, #fbfff9 0%, #eff8f1 50%, #fff7e5 100%); +} + +.landing-open .app-shell { + display: none; +} + +.landing-open .mobile-tabbar { + display: none !important; +} + +.landing-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + max-width: 1320px; + width: 100%; + margin: 0 auto 14px; + padding: 6px 0; +} + +.landing-logo { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--accent-strong); + text-decoration: none; +} + +.landing-logo span { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border-radius: var(--radius); + background: + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.44), transparent 28%), + linear-gradient(145deg, #238673, #083f34); + color: #fff8e8; + font-family: var(--display-font); + font-size: 22px; + font-weight: 700; + box-shadow: 0 12px 28px rgba(13, 107, 88, 0.22); + overflow: hidden; +} + +.landing-logo strong { + font-family: var(--display-font); + font-size: 18px; +} + +.landing-links { + display: flex; + gap: 18px; + margin: 0 auto; +} + +.landing-links a, +.landing-links button { + border: 0; + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 800; + font-family: inherit; + text-decoration: none; + cursor: pointer; +} + +.landing-links a:hover, +.landing-links button:hover { + color: var(--accent-strong); +} + +.landing-nav-actions, +.landing-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.landing-main { + display: grid; + gap: 14px; + max-width: 1320px; + width: 100%; + margin: 0 auto; +} + +.landing-hero, +.landing-flow, +.landing-signals { + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: rgba(255, 253, 247, 0.93); + box-shadow: var(--shadow); +} + +.landing-hero { + position: relative; + display: grid; + grid-template-columns: minmax(0, 0.96fr) minmax(420px, 0.84fr); + gap: 26px; + align-items: start; + min-height: min(590px, calc(100vh - 142px)); + padding: 30px 34px 28px; + overflow: hidden; +} + +.landing-hero::after { + position: absolute; + left: 33%; + bottom: -110px; + color: rgba(13, 106, 84, 0.055); + font-family: var(--display-font); + font-size: 410px; + line-height: 1; + content: "饰"; +} + +.landing-hero > * { + position: relative; + z-index: 1; +} + +.landing-hero-copy { + display: grid; + align-content: start; + min-width: 0; + padding: 8px 4px 8px 6px; +} + +.landing-hero h1 { + max-width: 720px; + margin: 12px 0 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: clamp(50px, 4.72vw, 74px); + line-height: 1.01; +} + +.landing-route { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + max-width: 720px; + margin: 4px 0 0; +} + +.landing-route span { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.76); + color: #315f55; + font-size: 12px; + font-weight: 900; +} + +.landing-route span:nth-child(5) { + border-color: rgba(165, 111, 24, 0.28); + background: var(--gold-soft); + color: var(--gold); +} + +.landing-route i { + width: 18px; + height: 1px; + background: rgba(13, 106, 84, 0.34); +} + +.landing-lead { + max-width: 690px; + margin: 17px 0 0; + color: #4d655b; + font-size: 17px; + line-height: 1.72; +} + +.landing-signal-panel { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + max-width: 720px; + margin-top: 22px; +} + +.landing-signal-panel div { + min-width: 0; + padding: 12px 13px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(237, 247, 239, 0.76)), + var(--paper-strong); +} + +.landing-signal-panel span, +.landing-signal-panel em { + display: block; + color: var(--muted); + font-size: 11px; + font-style: normal; + line-height: 1.45; +} + +.landing-signal-panel strong { + display: block; + margin: 5px 0 3px; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 19px; + line-height: 1.2; +} + +.landing-actions { + justify-content: flex-start; + margin-top: 22px; +} + +.landing-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 22px; +} + +.landing-metrics div { + min-width: 0; + padding: 14px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(36, 155, 144, 0.08), transparent), + rgba(237, 247, 239, 0.86); +} + +.landing-metrics strong { + display: block; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 17px; +} + +.landing-metrics span { + display: block; + margin-top: 5px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.landing-product-shot { + position: relative; + z-index: 1; + display: grid; + gap: 10px; + align-content: start; + min-width: 0; + padding: 12px; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(247, 255, 250, 0.94), rgba(255, 251, 238, 0.92)), + var(--paper); + box-shadow: var(--shadow-strong); +} + +.buyer-board-image { + display: block; + width: 100%; + aspect-ratio: 1.52; + object-fit: cover; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + box-shadow: 0 18px 34px rgba(13, 90, 72, 0.13); +} + +.product-shot-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 12px; +} + +.product-shot-top strong { + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 18px; +} + +.product-shot-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.product-shot-stats div, +.product-shot-table div { + padding: 10px; + border: 1px solid rgba(13, 106, 84, 0.13); + border-radius: var(--radius); + background: rgba(237, 247, 239, 0.84); +} + +.product-shot-stats span, +.product-shot-table span { + display: block; + color: var(--muted); + font-size: 11px; +} + +.product-shot-stats strong, +.product-shot-table strong { + display: block; + margin-top: 4px; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 22px; +} + +.product-shot-card { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 12px; + border: 1px solid rgba(165, 111, 24, 0.28); + border-radius: var(--radius); + background: var(--gold-soft); +} + +.shot-image { + display: grid; + width: 92px; + aspect-ratio: 1; + place-items: center; + border-radius: var(--radius); + background: + radial-gradient(circle at 36% 30%, rgba(255, 255, 255, 0.9), transparent 26%), + linear-gradient(145deg, #249b90, #0d6a54); + color: rgba(255, 255, 255, 0.9); + font-family: var(--display-font); + font-size: 44px; +} + +.product-shot-card p { + margin: 0; + color: var(--text); + font-family: var(--display-font); + font-size: 18px; + line-height: 1.25; +} + +.product-shot-card strong, +.product-shot-card span { + display: block; + margin-top: 4px; + color: var(--accent-strong); + font-size: 12px; +} + +.product-shot-card span { + color: var(--muted); +} + +.product-shot-table { + display: grid; + gap: 8px; +} + +.product-shot-table div { + display: flex; + align-items: center; + justify-content: space-between; +} + +.product-shot-table strong { + margin: 0; + font-size: 13px; + text-align: right; +} + +.landing-flow, +.landing-signals { + display: grid; + gap: 16px; + padding: 24px; +} + +.landing-section-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; +} + +.landing-section-head h2 { + max-width: 720px; + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 30px; + line-height: 1.25; +} + +.flow-rail, +.signal-board { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.flow-rail article, +.signal-board article { + min-width: 0; + min-height: 164px; + padding: 15px; + border: 1px solid rgba(13, 106, 84, 0.15); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(237, 247, 239, 0.76)); +} + +.flow-rail span, +.signal-board span { + color: var(--gold); + font-family: var(--num-font); + font-size: 13px; +} + +.flow-rail h3, +.signal-board strong { + display: block; + margin: 10px 0 0; + color: var(--text); + font-family: var(--display-font); + font-size: 20px; +} + +.flow-rail p, +.signal-board p { + margin: 10px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.65; +} + +.signal-board span { + display: block; + margin-top: 7px; +} + +.auth-dialog { + width: min(500px, calc(100vw - 32px)); + overflow: hidden; + border-color: rgba(13, 107, 88, 0.24); + background: + linear-gradient(160deg, rgba(255, 255, 255, 0.98), rgba(246, 255, 250, 0.96)), + var(--paper-strong); + box-shadow: 0 34px 90px rgba(13, 72, 60, 0.26); +} + +.auth-dialog-card { + display: grid; + gap: 16px; + padding: 28px; +} + +.auth-dialog-copy { + position: relative; + display: grid; + align-content: center; + padding-right: 34px; +} + +.auth-dialog-copy::after { + position: absolute; + right: 0; + top: 5px; + display: grid; + width: 46px; + height: 46px; + place-items: center; + border: 1px solid rgba(177, 120, 25, 0.24); + border-radius: var(--radius); + background: var(--gold-soft); + color: var(--gold); + font-family: var(--display-font); + font-size: 23px; + content: "饰"; +} + +.auth-dialog-copy h2 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 28px; + line-height: 1.2; +} + +.auth-dialog-copy p:last-child { + margin: 8px 0 0; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.auth-card { + display: grid; + align-content: start; + gap: 14px; + min-width: 0; + padding: 16px; + border: 1px solid rgba(13, 107, 88, 0.14); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(234, 246, 241, 0.58), rgba(255, 255, 255, 0.9)), + var(--paper-strong); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); +} + +.auth-card h3 { + margin: 0; + font-family: var(--display-font); + font-size: 20px; +} + +.auth-card label, +.settings-panel label, +.plugin-config label, +.rule-block label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.auth-card span, +.settings-panel label span, +.plugin-config label span, +.rule-block label span { + color: #315f55; + font-weight: 800; +} + +.auth-card input, +.settings-panel input, +.settings-panel textarea, +.plugin-config input, +.rule-block input, +.rule-block textarea { + width: 100%; + min-height: 39px; + border: 1px solid rgba(13, 107, 88, 0.2); + border-radius: var(--radius); + background: #fff; + color: var(--text); + padding: 8px 10px; +} + +.auth-card input:focus, +.settings-panel input:focus, +.settings-panel textarea:focus, +.plugin-config input:focus, +.rule-block input:focus, +.rule-block textarea:focus { + border-color: var(--turquoise); + outline: 3px solid rgba(42, 156, 145, 0.16); +} + +.auth-card .button { + min-height: 46px; + margin-top: 2px; + font-weight: 900; +} + +.auth-switch-line { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 24px; + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.auth-switch-line button { + border: 0; + background: transparent; + color: var(--accent-strong); + cursor: pointer; + font: inherit; + font-weight: 900; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 4px; +} + +.auth-switch-line button:focus-visible { + border-radius: 4px; + outline: 3px solid rgba(42, 156, 145, 0.22); + outline-offset: 2px; +} + +.auth-message { + min-height: 18px; + margin: 0; + text-align: center; + color: var(--danger); + font-size: 12px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.stat-card { + position: relative; + min-height: 108px; + padding: 16px 16px 15px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 253, 245, 0.96), rgba(238, 249, 242, 0.92)); + box-shadow: var(--shadow); + overflow: hidden; +} + +.stat-card::after { + position: absolute; + right: 13px; + bottom: 10px; + color: rgba(13, 106, 84, 0.1); + font-family: var(--display-font); + font-size: 42px; + content: "采"; +} + +.stat-card:nth-child(2)::after { + content: "样"; +} + +.stat-card:nth-child(3)::after { + content: "源"; +} + +.stat-card:nth-child(4)::after { + content: "配"; +} + +.stat-card span { + display: block; + margin-bottom: 11px; + color: var(--muted); + font-size: 12px; +} + +.stat-card strong { + position: relative; + z-index: 1; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 38px; + line-height: 1; +} + +body[data-view="capture"] .stats-grid, +body[data-view="competitors"] .stats-grid, +body[data-view="suppliers"] .stats-grid, +body[data-view="testing"] .stats-grid, +body[data-view="decisions"] .stats-grid { + gap: 9px; + margin-bottom: 12px; +} + +body[data-view="capture"] .stat-card, +body[data-view="competitors"] .stat-card, +body[data-view="suppliers"] .stat-card, +body[data-view="testing"] .stat-card, +body[data-view="decisions"] .stat-card { + min-height: 78px; + padding: 12px 14px; +} + +body[data-view="capture"] .stat-card span, +body[data-view="competitors"] .stat-card span, +body[data-view="suppliers"] .stat-card span, +body[data-view="testing"] .stat-card span, +body[data-view="decisions"] .stat-card span { + margin-bottom: 6px; +} + +body[data-view="capture"] .stat-card strong, +body[data-view="competitors"] .stat-card strong, +body[data-view="suppliers"] .stat-card strong, +body[data-view="testing"] .stat-card strong, +body[data-view="decisions"] .stat-card strong { + font-size: 31px; +} + +body[data-view="capture"] .stat-card::after, +body[data-view="competitors"] .stat-card::after, +body[data-view="suppliers"] .stat-card::after, +body[data-view="testing"] .stat-card::after, +body[data-view="decisions"] .stat-card::after { + font-size: 34px; +} + +.workbench-panel { + margin-bottom: 16px; + min-width: 0; +} + +.module-hero { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 18px; + align-items: end; + min-width: 0; + margin-bottom: 14px; + padding: 20px 22px; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: + linear-gradient(115deg, rgba(255, 253, 245, 0.98), rgba(235, 248, 240, 0.92) 58%, rgba(255, 240, 189, 0.68)), + var(--paper); + box-shadow: var(--shadow); + overflow: hidden; +} + +.module-hero::after { + position: absolute; + right: 18px; + top: 14px; + width: 126px; + height: 104px; + pointer-events: none; + content: ""; + background: + radial-gradient(circle, rgba(255, 255, 255, 0.92) 0 7px, rgba(177, 120, 25, 0.22) 8px 10px, transparent 11px), + radial-gradient(circle at 68px 52px, rgba(42, 156, 145, 0.18) 0 9px, transparent 10px); + opacity: 0.74; +} + +.module-hero > * { + position: relative; + z-index: 1; +} + +.module-hero h3 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 30px; + line-height: 1.18; +} + +.module-hero p:not(.eyebrow) { + max-width: 780px; + margin: 8px 0 0; + color: var(--muted); + font-size: 14px; + line-height: 1.65; +} + +.hero-command { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; + flex-wrap: wrap; +} + +.hero-chip { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 10px; + border: 1px solid rgba(36, 155, 144, 0.2); + border-radius: var(--radius); + background: #ddf2ef; + color: #0b5f54; + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.hero-chip.gold { + border-color: rgba(165, 111, 24, 0.3); + background: var(--gold-soft); + color: var(--gold); +} + +.hero-command strong { + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 36px; + line-height: 1; +} + +.hero-command span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.rule-save-card { + display: grid; + justify-items: end; + min-width: min(320px, 100%); + max-width: 380px; + padding: 12px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.82), rgba(255, 240, 189, 0.42)), + rgba(255, 255, 255, 0.68); + box-shadow: 0 12px 26px rgba(13, 90, 72, 0.08); +} + +.rule-save-card > div { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.rule-save-card p { + max-width: 280px; + margin: 8px 0 10px; + color: #315f55; + font-size: 12px; + line-height: 1.45; + text-align: right; +} + +.workflow-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.workflow-step { + position: relative; + min-width: 0; + min-height: 84px; + padding: 13px 13px 12px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: rgba(255, 253, 247, 0.92); + box-shadow: var(--shadow); + overflow: hidden; +} + +.workflow-step::after { + position: absolute; + right: -10px; + bottom: -18px; + color: rgba(13, 107, 88, 0.08); + font-family: var(--display-font); + font-size: 76px; + content: "饰"; +} + +.workflow-step span, +.workflow-step strong, +.workflow-step em { + position: relative; + z-index: 1; +} + +.workflow-step span { + display: block; + color: var(--gold); + font-family: var(--num-font); + font-size: 13px; +} + +.workflow-step strong { + display: block; + margin-top: 8px; + color: var(--text); + font-size: 14px; +} + +.workflow-step em { + display: block; + margin-top: 4px; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 22px; + font-style: normal; +} + +.capture-command { + display: grid; + grid-template-columns: 1fr 1fr 1.1fr; + gap: 12px; + margin-bottom: 14px; +} + +.capture-steps { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.capture-steps .workflow-step { + min-height: 68px; + box-shadow: none; +} + +.decision-kpis { + display: grid; + grid-template-columns: repeat(3, minmax(96px, 1fr)); + gap: 8px; + min-width: 330px; +} + +.decision-panel { + margin-bottom: 14px; +} + +.decision-table { + display: grid; + gap: 8px; +} + +.decision-row { + display: grid; + grid-template-columns: minmax(220px, 1fr) 78px 150px 96px; + gap: 12px; + align-items: center; + width: 100%; + min-height: 58px; + padding: 10px 12px; + border: 1px solid rgba(13, 107, 88, 0.15); + border-radius: var(--radius); + background: #fff; + color: var(--text); + text-align: left; + cursor: pointer; + box-shadow: 0 5px 14px rgba(13, 107, 88, 0.07); + transition: border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease; +} + +.decision-row:hover { + border-color: rgba(13, 107, 88, 0.42); + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(13, 107, 88, 0.12); +} + +.decision-row > span:not(.decision-main) { + color: var(--accent-strong); + font-size: 12px; + font-weight: 800; + text-align: right; +} + +.decision-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.decision-main strong { + overflow: hidden; + color: var(--text); + font-family: var(--display-font); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.decision-main small { + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-layout { + grid-template-columns: 1fr; +} + +.panel-grid { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 12px; + margin-bottom: 12px; + min-width: 0; +} + +.decision-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.panel { + min-width: 0; + padding: 16px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: rgba(255, 253, 247, 0.93); + box-shadow: var(--shadow); + overflow-wrap: anywhere; +} + +.panel-risk { + border-color: rgba(179, 59, 49, 0.28); + background: linear-gradient(180deg, #fffefe, var(--blush-soft)); +} + +.settings-grid { + display: grid; + gap: 14px; +} + +.settings-panel { + overflow: visible; +} + +.settings-empty { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.admin-kpi-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.admin-kpi-card { + position: relative; + min-width: 0; + min-height: 112px; + padding: 15px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: + linear-gradient(135deg, rgba(255, 253, 245, 0.98), rgba(221, 242, 239, 0.78)), + var(--paper-strong); + box-shadow: var(--shadow); + overflow: hidden; +} + +.admin-kpi-card::after { + position: absolute; + right: 10px; + bottom: -12px; + color: rgba(13, 106, 84, 0.08); + font-family: var(--display-font); + font-size: 76px; + content: "管"; +} + +.admin-kpi-card:nth-child(2)::after { + content: "户"; +} + +.admin-kpi-card:nth-child(3)::after { + content: "停"; +} + +.admin-kpi-card:nth-child(4)::after { + content: "采"; +} + +.admin-kpi-card:nth-child(5)::after { + content: "分"; +} + +.admin-kpi-card:nth-child(6)::after { + content: "AI"; +} + +.admin-kpi-card span, +.admin-kpi-card strong, +.admin-kpi-card em { + position: relative; + z-index: 1; + display: block; +} + +.admin-kpi-card span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.admin-kpi-card strong { + margin-top: 12px; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 36px; + line-height: 1; +} + +.admin-kpi-card em { + margin-top: 8px; + color: #315f55; + font-size: 12px; + font-style: normal; + line-height: 1.45; +} + +.admin-center-panel { + margin-bottom: 14px; +} + +.ai-engine-panel { + position: relative; + overflow: hidden; + border-color: rgba(36, 155, 144, 0.24); + background: + linear-gradient(135deg, rgba(255, 253, 245, 0.98), rgba(221, 242, 239, 0.84) 54%, rgba(255, 240, 189, 0.52)), + var(--paper); +} + +.ai-engine-panel::after { + position: absolute; + right: 16px; + bottom: -28px; + color: rgba(13, 106, 84, 0.06); + font-family: var(--display-font); + font-size: 140px; + line-height: 1; + content: "智"; + pointer-events: none; +} + +.ai-engine-panel > * { + position: relative; + z-index: 1; +} + +.ai-settings-grid { + display: grid; + grid-template-columns: 1.1fr repeat(4, minmax(150px, 1fr)); + gap: 10px; + margin-top: 14px; +} + +.ai-settings-grid label, +.ai-prompt-label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.ai-settings-grid label span, +.ai-prompt-label span { + color: #315f55; + font-weight: 900; +} + +.ai-settings-grid input, +.ai-prompt-label textarea { + width: 100%; + min-height: 40px; + padding: 8px 10px; + border: 1px solid rgba(13, 107, 88, 0.2); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.94); + color: var(--text); +} + +.ai-prompt-label textarea { + min-height: 132px; + resize: vertical; + line-height: 1.6; +} + +.ai-settings-grid input:focus, +.ai-prompt-label textarea:focus { + border-color: var(--turquoise); + outline: 3px solid rgba(42, 156, 145, 0.16); +} + +.ai-toggle-card { + grid-row: span 2; + align-content: stretch; + min-height: 118px; + padding: 13px; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(221, 242, 239, 0.74)); +} + +.ai-toggle-card input { + width: 22px; + min-height: 22px; + accent-color: var(--accent); +} + +.ai-toggle-card strong, +.ai-toggle-card em { + display: block; +} + +.ai-toggle-card strong { + margin-top: 8px; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 18px; +} + +.ai-toggle-card em { + margin-top: 6px; + color: var(--muted); + font-size: 12px; + font-style: normal; + line-height: 1.55; +} + +.ai-prompt-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.ai-admin-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 13px; +} + +.ai-admin-actions span { + color: #315f55; + font-size: 12px; + font-weight: 800; +} + +.admin-sync-state { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: var(--paper-soft); + color: var(--accent-strong); + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.admin-user-table, +.token-list, +.credit-code-list { + display: grid; + gap: 9px; +} + +.admin-user-row, +.token-card, +.credit-code-row { + display: grid; + gap: 12px; + align-items: center; + min-width: 0; + padding: 12px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.95), rgba(237, 247, 239, 0.7)), + var(--paper-strong); + box-shadow: 0 8px 18px rgba(13, 107, 88, 0.07); +} + +.admin-user-row { + grid-template-columns: minmax(220px, 1.15fr) minmax(130px, 0.6fr) minmax(160px, 0.72fr) minmax(160px, 0.76fr) auto; +} + +.token-card { + grid-template-columns: minmax(220px, 1fr) minmax(190px, 0.9fr) auto; +} + +.credit-code-row { + grid-template-columns: minmax(240px, 1fr) auto minmax(170px, 0.6fr) auto; +} + +.admin-user-row.is-disabled, +.token-card.is-disabled, +.credit-code-row.is-disabled { + border-color: rgba(179, 59, 49, 0.2); + background: + linear-gradient(90deg, rgba(255, 246, 243, 0.98), rgba(255, 253, 245, 0.86)), + var(--paper-strong); +} + +.admin-user-main, +.token-card-main { + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + gap: 10px; + align-items: center; + min-width: 0; +} + +.admin-user-main strong, +.token-card-main strong { + display: block; + overflow: hidden; + color: var(--text); + font-family: var(--display-font); + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-main small, +.token-card-main small { + display: block; + margin-top: 4px; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 4px rgba(37, 121, 72, 0.12); +} + +.status-dot.danger { + background: var(--danger); + box-shadow: 0 0 0 4px rgba(179, 59, 49, 0.12); +} + +.status-dot.success { + background: var(--success); +} + +.admin-user-meta, +.token-card-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; +} + +.role-chip { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 8px; + border: 1px solid rgba(36, 155, 144, 0.2); + border-radius: var(--radius); + background: var(--ds-turquoise-100); + color: #0b5f54; + font-size: 12px; + font-weight: 900; +} + +.role-chip.gold { + border-color: rgba(165, 111, 24, 0.28); + background: var(--gold-soft); + color: var(--gold); +} + +.role-chip.success { + border-color: rgba(37, 121, 72, 0.2); + background: var(--success-soft); + color: var(--success); +} + +.role-chip.danger { + border-color: rgba(179, 59, 49, 0.22); + background: var(--danger-soft); + color: var(--danger); +} + +.admin-user-counts, +.token-card-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(70px, 1fr)); + gap: 7px; +} + +.admin-user-counts div, +.token-card-stats div { + min-width: 0; + padding: 8px; + border: 1px solid rgba(13, 106, 84, 0.12); + border-radius: var(--radius); + background: rgba(237, 247, 239, 0.78); +} + +.admin-user-counts span, +.token-card-stats span { + display: block; + color: var(--muted); + font-size: 11px; +} + +.admin-user-counts strong, +.token-card-stats strong { + display: block; + margin-top: 4px; + overflow: hidden; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-time { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 11px; + line-height: 1.4; +} + +.admin-empty-state { + display: grid; + justify-items: center; + gap: 8px; + min-height: 220px; + padding: 34px 18px; + border: 1px dashed rgba(13, 107, 88, 0.26); + border-radius: var(--radius); + background: + radial-gradient(circle at 50% 0%, rgba(255, 240, 189, 0.48), transparent 28%), + rgba(237, 247, 239, 0.62); + text-align: center; +} + +.admin-empty-state span { + display: grid; + width: 48px; + height: 48px; + place-items: center; + border-radius: var(--radius); + background: linear-gradient(145deg, #238673, #083f34); + color: #fff8e8; + font-family: var(--display-font); + font-size: 24px; +} + +.admin-empty-state strong { + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 20px; +} + +.admin-empty-state p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.credit-code-form, +.credit-redeem-form { + display: grid; + grid-template-columns: minmax(150px, 0.34fr) minmax(220px, 1fr) auto; + gap: 10px; + align-items: end; +} + +.credit-code-form label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.credit-code-form label span { + color: #315f55; + font-weight: 800; +} + +.credit-code-form input, +.credit-redeem-form input { + width: 100%; + min-height: 39px; + border: 1px solid rgba(13, 107, 88, 0.2); + border-radius: var(--radius); + background: #fff; + color: var(--text); + padding: 8px 10px; +} + +.credit-code-list { + margin-top: 12px; +} + +.credit-code-row > div { + min-width: 0; +} + +.credit-code-row strong:first-child { + display: block; + overflow: hidden; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.credit-code-row small { + display: block; + margin-top: 4px; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.credit-code-row > strong:last-of-type { + color: var(--accent-strong); + font-family: var(--num-font); + text-align: right; +} + +.credit-redeem-panel { + display: grid; + gap: 12px; + margin-top: 16px; + padding: 14px; + border: 1px solid rgba(165, 111, 24, 0.24); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(255, 240, 189, 0.48), rgba(221, 242, 239, 0.38)), + rgba(255, 253, 247, 0.92); +} + +.credit-redeem-panel h3 { + margin: 0; +} + +.credit-redeem-form { + grid-template-columns: minmax(220px, 1fr) auto; +} + +.account-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 14px; +} + +.settings-empty-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.extension-package-panel { + display: grid; + gap: 14px; +} + +.extension-package-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 14px; + border: 1px solid rgba(13, 107, 88, 0.16); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(237, 247, 239, 0.84), rgba(255, 253, 245, 0.86)); +} + +.extension-package-card span, +.extension-package-card small { + display: block; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.extension-package-card strong { + display: block; + margin: 3px 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 22px; +} + +.extension-package-card small { + overflow-wrap: anywhere; +} + +.extension-install-steps { + margin: 0; + padding-left: 18px; + color: #4d655b; + font-size: 13px; + line-height: 1.8; +} + +.extension-install-steps code { + padding: 2px 5px; + border-radius: 5px; + background: rgba(13, 107, 88, 0.08); + color: var(--accent-strong); +} + +.plugin-config { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto; + gap: 10px; + align-items: end; + margin: 12px 0; +} + +.rules-layout { + display: grid; + grid-template-columns: minmax(360px, 1.2fr) minmax(260px, 0.8fr); + gap: 12px; +} + +.rule-block { + min-width: 0; + padding: 12px; + border: 1px solid rgba(13, 107, 88, 0.14); + border-radius: var(--radius); + background: #f8fffc; +} + +.track-rules-editor { + display: grid; + gap: 9px; + margin-top: 10px; +} + +.track-rule-row { + display: grid; + grid-template-columns: minmax(110px, 0.42fr) minmax(180px, 1fr) auto; + gap: 8px; + align-items: end; + min-width: 0; +} + +.compact-button { + min-height: 34px; + padding: 0 10px; + font-size: 12px; + box-shadow: none; +} + +.profit-rule-grid { + grid-template-columns: repeat(5, minmax(110px, 1fr)); + grid-column: 1 / -1; +} + +.panel h3, +.panel h4 { + margin: 0; + color: var(--text); + font-family: var(--display-font); +} + +.panel h3 { + font-size: 20px; +} + +.panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.panel-head p, +.muted { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.mini-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.mini-stat, +.metric-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 9px 10px; + border: 1px solid rgba(13, 106, 84, 0.12); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(36, 155, 144, 0.08), transparent), + var(--paper-soft); +} + +.mini-stat span, +.metric-row span { + color: var(--muted); + font-size: 12px; +} + +.mini-stat strong, +.metric-row strong { + color: var(--text); + font-family: var(--num-font); + font-size: 18px; +} + +.action-list { + margin: 12px 0 0; + padding-left: 18px; + color: var(--text); + font-size: 14px; + line-height: 1.75; +} + +.compact-list, +.pipeline-list, +.group-list, +.metric-list { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.compact-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-width: 0; + min-height: 44px; + padding: 9px 11px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: var(--paper-strong); + color: var(--text); + text-align: left; + cursor: pointer; + box-shadow: 0 5px 12px rgba(13, 107, 88, 0.07); +} + +.compact-item span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-item strong { + flex: none; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 13px; +} + +.pipeline-item, +.group-card { + display: grid; + gap: 12px; + padding: 13px; + border: 1px solid rgba(13, 107, 88, 0.16); + border-radius: var(--radius); + background: var(--paper-strong); +} + +.pipeline-item { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.pipeline-item h4 { + margin: 0 0 4px; + font-family: var(--display-font); + font-size: 16px; +} + +.pipeline-item p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.pipeline-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.group-items { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px; +} + +.group-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.group-summary span, +.group-summary strong { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 8px; + border: 1px solid rgba(13, 107, 88, 0.16); + border-radius: var(--radius); + background: var(--paper-soft); + color: var(--muted); + font-size: 12px; +} + +.group-summary strong { + border-color: rgba(177, 120, 25, 0.26); + background: var(--gold-soft); + color: var(--gold); +} + +.toolbar { + display: grid; + grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(132px, 1fr)); + gap: 12px; + margin-bottom: 18px; + padding: 14px; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(236, 248, 242, 0.95)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), var(--shadow); +} + +body[data-view="capture"] .toolbar, +body[data-view="competitors"] .toolbar, +body[data-view="suppliers"] .toolbar, +body[data-view="testing"] .toolbar, +body[data-view="decisions"] .toolbar { + gap: 9px; + margin-bottom: 14px; + padding: 10px 12px; +} + +body[data-view="capture"] .module-hero, +body[data-view="competitors"] .module-hero, +body[data-view="suppliers"] .module-hero, +body[data-view="testing"] .module-hero, +body[data-view="decisions"] .module-hero { + margin-bottom: 12px; + padding: 16px 20px; +} + +body[data-view="capture"] .filter-bench-head, +body[data-view="competitors"] .filter-bench-head, +body[data-view="suppliers"] .filter-bench-head, +body[data-view="testing"] .filter-bench-head, +body[data-view="decisions"] .filter-bench-head { + padding-bottom: 7px; +} + +body[data-view="capture"] .filter-bench-head h3, +body[data-view="competitors"] .filter-bench-head h3, +body[data-view="suppliers"] .filter-bench-head h3, +body[data-view="testing"] .filter-bench-head h3, +body[data-view="decisions"] .filter-bench-head h3 { + font-size: 21px; +} + +body[data-view="capture"] .toolbar input, +body[data-view="capture"] .toolbar select, +body[data-view="competitors"] .toolbar input, +body[data-view="competitors"] .toolbar select, +body[data-view="suppliers"] .toolbar input, +body[data-view="suppliers"] .toolbar select, +body[data-view="testing"] .toolbar input, +body[data-view="testing"] .toolbar select, +body[data-view="decisions"] .toolbar input, +body[data-view="decisions"] .toolbar select { + min-height: 36px; +} + +.filter-bench { + position: relative; + overflow: hidden; +} + +.filter-bench::before { + position: absolute; + right: 18px; + top: 18px; + width: 160px; + height: 112px; + pointer-events: none; + content: ""; + background: + linear-gradient(90deg, rgba(13, 106, 84, 0.06) 1px, transparent 1px) 0 0 / 18px 18px, + linear-gradient(0deg, rgba(165, 111, 24, 0.06) 1px, transparent 1px) 0 0 / 18px 18px; + opacity: 0.8; +} + +.filter-bench > * { + position: relative; + z-index: 1; +} + +.filter-bench-head { + display: flex; + grid-column: 1 / -1; + align-items: end; + justify-content: space-between; + gap: 14px; + min-width: 0; + padding: 2px 2px 10px; + border-bottom: 1px solid rgba(13, 106, 84, 0.13); +} + +.filter-bench-head h3 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 23px; + line-height: 1.2; +} + +.filter-bench-head p:not(.eyebrow) { + margin: 6px 0 0; + max-width: 760px; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.filter-bench-status { + display: grid; + grid-template-columns: auto auto; + gap: 3px 10px; + align-items: center; + flex: none; + min-width: 168px; + padding: 10px 12px; + border: 1px solid rgba(13, 106, 84, 0.15); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(221, 242, 239, 0.72), rgba(255, 240, 189, 0.44)), + rgba(255, 255, 255, 0.78); +} + +.filter-bench-status span { + color: var(--muted); + font-size: 11px; + font-weight: 900; +} + +.filter-bench-status strong { + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 28px; + line-height: 1; + text-align: right; +} + +.filter-bench-status em { + grid-column: 1 / -1; + min-width: 0; + overflow: hidden; + color: #315f55; + font-size: 11px; + font-style: normal; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar label:first-child, +.toolbar label:nth-child(6) { + min-width: 0; +} + +.toolbar label, +.dialog-fields label, +.filter-sheet-body label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.toolbar label span, +.dialog-fields label span, +.filter-sheet-body label span { + color: #315f55; + font-weight: 700; +} + +.toolbar input, +.toolbar select, +.dialog-card textarea, +.dialog-card select, +.dialog-fields input, +.filter-sheet-body input, +.filter-sheet-body select, +.mobile-search-field input { + width: 100%; + max-width: 100%; + min-height: 40px; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: #ffffff; + color: var(--text); + padding: 8px 10px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.toolbar input:focus, +.toolbar select:focus, +.dialog-card textarea:focus, +.dialog-card select:focus, +.dialog-fields input:focus, +.filter-sheet-body input:focus, +.filter-sheet-body select:focus, +.mobile-search-field input:focus { + border-color: var(--turquoise); + outline: 3px solid rgba(42, 156, 145, 0.16); +} + +.bulk-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 18px; + padding: 12px 14px; + border: 1px solid rgba(13, 107, 88, 0.32); + border-radius: var(--radius); + background: linear-gradient(90deg, #e6f2df, #fff8e9); + box-shadow: var(--shadow); +} + +.bulk-toolbar.confirming { + border-color: rgba(179, 59, 49, 0.32); + background: linear-gradient(90deg, #ffe7df, #fff8e9); +} + +.bulk-status { + color: var(--muted); + font-size: 14px; +} + +.bulk-status strong { + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 22px; +} + +.bulk-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mobile-bulk-status { + display: none; +} + +.items-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(318px, 1fr)); + gap: 18px; + min-width: 0; +} + +.item-card { + position: relative; + overflow: hidden; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: rgba(255, 253, 247, 0.96); + box-shadow: var(--shadow); + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.item-card::before { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + content: ""; + background: + linear-gradient(90deg, rgba(13, 106, 84, 0.045) 0 1px, transparent 1px 100%) 0 0 / 100% 100%, + linear-gradient(180deg, transparent 0, rgba(36, 155, 144, 0.055) 100%); + opacity: 0.45; +} + +.item-card::after { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 2; + height: 4px; + content: ""; + background: var(--ds-turquoise-600); +} + +.item-card[data-profit="可跑量"]::after { + background: var(--success); +} + +.item-card[data-profit="可测试"]::after { + background: var(--ds-brass-700); +} + +.item-card[data-risk="high"] { + border-color: rgba(179, 59, 49, 0.42); +} + +.item-card[data-risk="high"]::after { + background: var(--ds-cinnabar-700); +} + +.item-card > * { + position: relative; + z-index: 1; +} + +.item-card:hover { + border-color: rgba(13, 106, 84, 0.44); + transform: translateY(-3px) rotate(-0.15deg); + box-shadow: 0 26px 44px rgba(13, 107, 88, 0.14); +} + +.item-card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(42, 156, 145, 0.14), var(--shadow-strong); +} + +.select-box { + position: absolute; + top: 10px; + left: 10px; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(36, 26, 20, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: var(--text); + font-size: 12px; + font-weight: 800; + box-shadow: 0 8px 18px rgba(13, 107, 88, 0.14); + cursor: pointer; + backdrop-filter: blur(8px); +} + +.select-box input { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.item-image { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + background: var(--tray); + filter: saturate(1.06) contrast(1.02); +} + +.item-placeholder { + display: grid; + width: 100%; + aspect-ratio: 4 / 3; + place-items: center; + align-content: center; + gap: 8px; + background: + radial-gradient(circle at 48% 36%, rgba(255, 255, 255, 0.82), transparent 22%), + linear-gradient(135deg, rgba(255, 255, 255, 0.45), transparent), + var(--tray); + color: var(--accent-strong); +} + +.item-placeholder span { + display: grid; + width: 72px; + height: 72px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: var(--radius); + background: + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.4), transparent 28%), + linear-gradient(145deg, #249b90, #0d6a54); + color: #fff8e8; + font-family: var(--display-font); + font-size: 38px; + box-shadow: 0 12px 28px rgba(13, 90, 72, 0.18); +} + +.item-placeholder strong { + color: var(--muted); + font-size: 12px; +} + +.item-body { + padding: 14px; +} + +.item-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.86); + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.pill.sample { + border-color: rgba(37, 121, 72, 0.24); + background: var(--success-soft); + color: var(--success); +} + +.pill.watch { + border-color: rgba(177, 120, 25, 0.3); + background: var(--gold-soft); + color: var(--warning); +} + +.pill.reject { + border-color: rgba(179, 59, 49, 0.24); + background: var(--danger-soft); + color: var(--danger); +} + +.signal-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: -2px 0 12px; +} + +.signal { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border-radius: var(--radius); + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.signal.success { + border: 1px solid rgba(37, 121, 72, 0.2); + background: var(--success-soft); + color: var(--success); +} + +.signal.warning { + border: 1px solid rgba(165, 111, 24, 0.3); + background: var(--ds-brass-100); + color: var(--ds-brass-700); +} + +.signal.danger { + border: 1px solid rgba(179, 59, 49, 0.24); + background: var(--ds-cinnabar-100); + color: var(--ds-cinnabar-700); +} + +.signal.source { + border: 1px solid rgba(36, 155, 144, 0.24); + background: var(--ds-turquoise-100); + color: #0c6157; +} + +.supplier-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 34px; + margin: -2px 0 10px; + padding: 7px 9px; + border: 1px solid rgba(13, 106, 84, 0.12); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(255, 240, 189, 0.42), rgba(221, 242, 239, 0.38)), + rgba(255, 255, 255, 0.76); +} + +.supplier-line span, +.supplier-line strong { + min-width: 0; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.supplier-line span { + color: #5d665f; +} + +.supplier-line strong { + flex: none; + max-width: 48%; + color: var(--accent-strong); + font-family: var(--num-font); +} + +.item-title { + display: -webkit-box; + min-height: 46px; + margin: 0 0 12px; + overflow: hidden; + color: var(--text); + font-family: var(--display-font); + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 24px; + margin: -2px 0 10px; +} + +.tag { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border: 1px solid rgba(36, 155, 144, 0.2); + border-radius: var(--radius); + background: #ddf2ef; + color: #0c6157; + font-size: 11px; +} + +.tag:nth-child(2n) { + border-color: rgba(177, 120, 25, 0.24); + background: var(--gold-soft); + color: var(--gold); +} + +.item-facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} + +.fact { + padding: 9px; + border: 1px solid rgba(13, 106, 84, 0.12); + border-radius: var(--radius); + background: rgba(237, 247, 239, 0.82); +} + +.fact span { + display: block; + color: var(--muted); + font-size: 11px; +} + +.fact strong { + display: block; + margin-top: 4px; + color: var(--text); + font-family: var(--num-font); + font-size: 15px; +} + +.card-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.card-actions button, +.card-actions a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius); + background: var(--paper-strong); + color: var(--text); + text-align: center; + text-decoration: none; + cursor: pointer; + box-shadow: 0 4px 0 rgba(13, 107, 88, 0.09); +} + +.card-actions button { + color: var(--accent-strong); + font-weight: 800; +} + +.empty-state { + position: relative; + display: grid; + justify-items: center; + gap: 10px; + min-height: 296px; + padding: 42px 24px; + border: 1px dashed rgba(13, 107, 88, 0.28); + border-radius: var(--radius); + background: + radial-gradient(circle at 50% 8%, rgba(255, 240, 189, 0.58), transparent 28%), + linear-gradient(180deg, rgba(255, 253, 247, 0.96), rgba(237, 247, 239, 0.9)), + var(--paper-strong); + text-align: center; + box-shadow: var(--shadow); + overflow: hidden; +} + +.empty-state::after { + position: absolute; + right: -18px; + bottom: -58px; + color: rgba(13, 106, 84, 0.06); + font-family: var(--display-font); + font-size: 210px; + line-height: 1; + content: "样"; +} + +.empty-state > * { + position: relative; + z-index: 1; +} + +.empty-mark { + display: grid; + width: 62px; + height: 62px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: var(--radius); + background: + radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.42), transparent 28%), + linear-gradient(145deg, #238673, #083f34); + color: #fff8e8; + font-family: var(--display-font); + font-size: 30px; + font-weight: 700; + box-shadow: 0 14px 28px rgba(13, 90, 72, 0.18); +} + +.empty-state h3 { + margin: 4px 0 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 23px; +} + +.empty-state p { + max-width: 520px; + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.65; +} + +.empty-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + margin-top: 6px; +} + +dialog { + width: min(940px, calc(100vw - 32px)); + border: 1px solid rgba(13, 107, 88, 0.2); + border-radius: var(--radius); + padding: 0; + background: var(--paper-strong); + box-shadow: var(--shadow-strong); +} + +dialog::backdrop { + background: rgba(36, 26, 20, 0.42); + backdrop-filter: blur(2px); +} + +.dialog-card { + position: relative; + padding: 20px; +} + +.dialog-close { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; + width: 32px; + height: 32px; + border: 1px solid rgba(13, 107, 88, 0.2); + border-radius: 50%; + background: var(--paper-strong); + cursor: pointer; +} + +.dialog-grid { + display: grid; + grid-template-columns: 290px minmax(0, 1fr); + gap: 18px; +} + +.dialog-media { + position: sticky; + top: 0; + align-self: start; +} + +.dialog-grid img { + width: 100%; + border: 1px solid rgba(13, 107, 88, 0.16); + border-radius: var(--radius); + background: var(--tray); +} + +.dialog-quick-signals { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.dialog-fields { + display: grid; + gap: 12px; +} + +.dialog-fields h2 { + margin: 0; + padding-right: 28px; + color: var(--text); + font-family: var(--display-font); + font-size: 24px; + line-height: 1.35; + letter-spacing: 0; +} + +.detail-section { + padding: 12px; + border: 1px solid rgba(13, 107, 88, 0.16); + border-radius: var(--radius); + background: #f8fffc; +} + +.risk-section { + border-color: rgba(179, 59, 49, 0.24); + background: #fff0ed; +} + +.ai-detail-section { + border-color: rgba(36, 155, 144, 0.24); + background: + linear-gradient(135deg, rgba(221, 242, 239, 0.62), rgba(255, 253, 245, 0.96)), + var(--paper-strong); +} + +.ai-empty-state { + padding: 12px; + border: 1px dashed rgba(13, 107, 88, 0.26); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.72); +} + +.ai-empty-state strong { + display: block; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 18px; +} + +.ai-empty-state p { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.ai-detail-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 12px; +} + +.ai-detail-actions span { + color: #315f55; + font-size: 12px; + font-weight: 800; +} + +.ai-analysis-result { + display: grid; + gap: 10px; +} + +.ai-analysis-result > p { + margin: 0; + padding: 12px; + border-left: 3px solid var(--accent); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.78); + color: var(--text); + font-size: 14px; + line-height: 1.65; +} + +.ai-analysis-result > div, +.ai-result-list { + padding: 10px 12px; + border: 1px solid rgba(13, 107, 88, 0.15); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.7); +} + +.ai-analysis-result span, +.ai-result-list span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 11px; + font-weight: 900; +} + +.ai-analysis-result strong { + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 17px; + line-height: 1.35; +} + +.ai-result-list ul { + display: grid; + gap: 5px; + margin: 0; + padding-left: 18px; + color: var(--text); + font-size: 13px; + line-height: 1.55; +} + +.ai-result-list.is-danger { + border-color: rgba(179, 59, 49, 0.24); + background: rgba(255, 232, 227, 0.64); +} + +.ai-analysis-result small { + color: var(--gold); + font-size: 12px; + font-weight: 900; +} + +.section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.section-title strong { + font-family: var(--display-font); + font-size: 15px; +} + +.section-title span { + color: var(--muted); + font-size: 12px; + text-align: right; +} + +.profit-breakdown { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.profit-breakdown div { + padding: 8px; + border-radius: var(--radius); + background: var(--paper-soft); +} + +.profit-breakdown span { + display: block; + color: var(--muted); + font-size: 11px; +} + +.profit-breakdown strong { + display: block; + margin-top: 4px; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 14px; +} + +.risk-list, +.reason-list { + margin: 8px 0 0; + padding-left: 18px; + color: #66513a; + font-size: 13px; + line-height: 1.65; +} + +.action-sheet-dialog { + width: min(420px, calc(100vw - 32px)); +} + +.action-sheet h2, +.filter-sheet h2 { + margin: 0 0 14px; + font-family: var(--display-font); + font-size: 20px; +} + +.action-sheet-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } + + .button:hover, + .icon-button:hover, + .card-actions button:hover, + .card-actions a:hover, + .compact-item:hover, + .item-card:hover { + transform: none; + } +} + +@media (max-width: 1120px) { + .app-shell { + grid-template-columns: 252px minmax(0, 1fr); + } + + .topbar { + grid-template-columns: 1fr; + } + + .topbar-actions { + justify-content: flex-start; + } + + .toolbar { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .filter-bench-head { + align-items: start; + display: grid; + } + + .filter-bench-status { + width: 100%; + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workflow-strip, + .capture-command, + .admin-layout, + .admin-user-row, + .token-card, + .credit-code-row, + .credit-code-form, + .credit-redeem-form { + grid-template-columns: 1fr; + } + + .admin-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .ai-settings-grid, + .ai-prompt-grid { + grid-template-columns: 1fr 1fr; + } + + .ai-toggle-card { + grid-row: auto; + grid-column: 1 / -1; + } + + .decision-kpis { + min-width: 0; + } + + .decision-row { + grid-template-columns: minmax(190px, 1fr) 70px 136px 90px; + } + + .extension-package-card, + .plugin-config, + .rules-layout, + .profit-rule-grid { + grid-template-columns: 1fr; + } + + .profit-rule-grid { + grid-column: auto; + } +} + +@media (max-width: 900px) { + body { + padding-bottom: 74px; + } + + .landing-open { + padding-bottom: 0; + } + + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar, + .topbar, + .toolbar { + display: none; + } + + .mobile-header { + position: sticky; + top: 0; + z-index: 20; + display: grid; + gap: 12px; + min-width: 0; + padding: 12px 14px; + border-bottom: 1px solid rgba(13, 107, 88, 0.2); + background: rgba(255, 253, 247, 0.95); + backdrop-filter: blur(14px); + box-shadow: 0 10px 28px rgba(13, 107, 88, 0.08); + } + + .mobile-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + } + + .mobile-title-row > div:first-child { + min-width: 0; + } + + .mobile-header h1 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 22px; + line-height: 1.2; + } + + .mobile-header .eyebrow { + margin-bottom: 3px; + } + + .mobile-header-actions { + display: flex; + flex: none; + gap: 8px; + } + + .mobile-search-field { + min-width: 0; + } + + .main { + min-width: 0; + padding: 13px; + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .module-hero { + grid-template-columns: 1fr; + gap: 14px; + padding: 15px; + } + + .module-hero h3 { + font-size: 24px; + } + + .hero-command { + justify-content: flex-start; + } + + .rule-save-card { + justify-items: stretch; + max-width: none; + } + + .rule-save-card > div { + justify-content: flex-start; + } + + .rule-save-card p { + max-width: none; + text-align: left; + } + + .workflow-strip { + grid-template-columns: 1fr; + } + + .workflow-step { + min-height: 68px; + } + + .decision-kpis { + grid-template-columns: 1fr; + } + + .decision-table { + overflow-x: auto; + padding-bottom: 2px; + } + + .decision-row { + min-width: 680px; + } + + .stat-card { + min-height: 88px; + padding: 12px; + } + + .stat-card strong { + font-size: 30px; + } + + .panel-grid, + .decision-grid, + .ai-settings-grid, + .ai-prompt-grid { + grid-template-columns: 1fr; + } + + .ai-admin-actions .button, + .ai-detail-actions .button { + width: 100%; + } + + .auth-gate { + padding: 12px; + } + + .landing-nav, + .landing-main, + .landing-hero { + grid-template-columns: 1fr; + } + + .landing-nav { + display: grid; + gap: 12px; + } + + .landing-links { + order: 3; + justify-content: space-between; + margin: 0; + } + + .landing-nav-actions { + justify-content: stretch; + } + + .landing-nav-actions .button, + .landing-actions .button { + flex: 1 1 auto; + } + + .landing-hero { + min-height: auto; + padding: 24px 18px; + gap: 22px; + } + + .landing-hero-copy { + padding: 0; + } + + .landing-hero h1 { + font-size: 42px; + } + + .landing-route { + gap: 6px; + } + + .landing-route span { + min-height: 28px; + padding: 0 8px; + font-size: 11px; + } + + .landing-route i { + display: none; + } + + .landing-lead { + font-size: 15px; + line-height: 1.75; + } + + .landing-signal-panel { + grid-template-columns: 1fr; + gap: 8px; + margin-top: 16px; + } + + .landing-signal-panel div { + padding: 11px 12px; + } + + .landing-metrics, + .flow-rail, + .signal-board, + .product-shot-stats { + grid-template-columns: 1fr; + } + + .buyer-board-image { + aspect-ratio: 1.1; + } + + .landing-section-head { + display: grid; + } + + .landing-section-head h2 { + font-size: 24px; + } + + .product-shot-card { + grid-template-columns: 84px minmax(0, 1fr); + } + + .shot-image { + width: 84px; + font-size: 40px; + } + + .track-rule-row { + grid-template-columns: 1fr; + } + + .settings-empty { + display: grid; + } + + .admin-kpi-grid { + grid-template-columns: 1fr; + gap: 9px; + } + + .admin-kpi-card { + min-height: 94px; + } + + .admin-user-row, + .token-card, + .credit-code-row { + align-items: stretch; + } + + .admin-user-meta, + .token-card-actions { + justify-content: flex-start; + } + + .panel { + padding: 14px; + } + + .bulk-toolbar, + .bulk-actions { + align-items: stretch; + flex-direction: column; + } + + .bulk-toolbar { + position: fixed; + right: 12px; + bottom: 82px; + left: 12px; + z-index: 30; + max-width: calc(100vw - 24px); + margin: 0; + padding: 10px; + box-shadow: var(--shadow-strong); + } + + .bulk-actions { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .bulk-actions .button { + min-width: 0; + padding: 0 8px; + font-size: 12px; + } + + .bulk-actions [data-bulk-status] { + display: none; + } + + .mobile-bulk-status { + display: inline-flex; + } + + .bulk-toolbar.confirming .bulk-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .items-grid { + grid-template-columns: 1fr; + gap: 14px; + } + + .item-card:hover { + transform: none; + } + + .item-title { + min-height: 0; + } + + .dialog-grid { + grid-template-columns: 1fr; + } + + .dialog-media { + position: static; + } + + dialog { + width: 100vw; + max-width: none; + max-height: 92vh; + margin: auto 0 0; + border-radius: 14px 14px 0 0; + } + + .dialog-card { + max-height: 92vh; + overflow-y: auto; + padding: 18px 16px 24px; + } + + .auth-dialog { + width: min(440px, calc(100vw - 28px)); + max-width: calc(100vw - 28px); + max-height: calc(100dvh - 28px); + margin: auto; + border-radius: var(--radius); + } + + .auth-dialog .auth-dialog-card { + max-height: calc(100dvh - 28px); + overflow-y: auto; + padding: 22px 18px 20px; + } + + .auth-dialog-copy { + padding-right: 52px; + } + + .dialog-fields h2 { + font-size: 20px; + } + + .profit-breakdown { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .pipeline-item, + .panel-head { + grid-template-columns: 1fr; + } + + .pipeline-item { + align-items: stretch; + } + + .pipeline-actions { + justify-content: stretch; + } + + .filter-dialog { + width: 100vw; + max-width: none; + max-height: 86vh; + margin: auto 0 0; + border-radius: 16px 16px 0 0; + } + + .filter-sheet-body { + display: grid; + gap: 12px; + max-height: min(58vh, 520px); + margin-bottom: 16px; + overflow-y: auto; + padding-right: 2px; + } + + .filter-sheet-body label { + min-width: 0; + } + + .empty-state { + min-height: 260px; + padding: 34px 18px; + } + + .mobile-tabbar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 25; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 4px; + padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); + border-top: 1px solid rgba(13, 107, 88, 0.2); + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(14px); + box-shadow: 0 -12px 34px rgba(13, 107, 88, 0.1); + } + + .mobile-tab { + min-height: 44px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--muted); + font-size: 11px; + } + + .mobile-tab.active { + border: 1px solid rgba(13, 107, 88, 0.22); + background: #e2f1e7; + color: var(--accent-strong); + font-weight: 800; + box-shadow: 0 6px 18px rgba(13, 107, 88, 0.12); + } +} + +@media (max-width: 520px) { + .main { + padding: 12px; + } + + .mobile-header .eyebrow { + display: none; + } + + .item-body { + padding: 12px; + } + + .item-facts { + grid-template-columns: 1fr; + } + + .supplier-line { + display: grid; + gap: 4px; + } + + .supplier-line strong { + max-width: 100%; + } + + .empty-actions { + width: 100%; + } + + .empty-actions .button { + flex: 1 1 136px; + } + + .bulk-actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .bulk-actions .button { + min-height: 36px; + } + + .action-sheet-dialog { + width: 100vw; + max-width: none; + margin: auto 0 0; + border-radius: 16px 16px 0 0; + } + + .auth-dialog { + width: calc(100vw - 24px); + max-width: 420px; + } + + .auth-dialog-copy h2 { + font-size: 25px; + } + + .auth-card { + padding: 14px; + } +} + +/* UI/UX Pro Max refresh: productized buyer workbench */ +:root { + --bg: #f8fbf2; + --paper-warm: #fff7e7; + --ink-soft: #4a3a2b; + --shadow: 0 14px 34px rgba(15, 90, 70, 0.1); + --shadow-soft: 0 8px 22px rgba(15, 90, 70, 0.07); + --shadow-strong: 0 26px 60px rgba(13, 72, 60, 0.18); + --radius-sm: 6px; + --sidebar-w: 304px; +} + +html { + scroll-behavior: smooth; +} + +body { + overflow-x: hidden; + background: + radial-gradient(circle at 9% 8%, rgba(37, 150, 133, 0.16), transparent 25%), + radial-gradient(circle at 90% 7%, rgba(255, 219, 142, 0.52), transparent 22%), + radial-gradient(circle at 78% 82%, rgba(179, 59, 49, 0.08), transparent 28%), + linear-gradient(90deg, rgba(18, 60, 52, 0.03) 1px, transparent 1px) 0 0 / 34px 34px, + linear-gradient(0deg, rgba(36, 155, 144, 0.035) 1px, transparent 1px) 0 0 / 34px 34px, + linear-gradient(135deg, #fcfff8 0%, #eff8f2 46%, #fff7e8 100%); +} + +button, +a { + touch-action: manipulation; +} + +.app-shell { + grid-template-columns: var(--sidebar-w) minmax(0, 1fr); +} + +.sidebar { + padding: 24px 18px; + background: + linear-gradient(180deg, rgba(255, 253, 245, 0.99), rgba(235, 248, 241, 0.96)), + radial-gradient(circle at 26% 8%, rgba(255, 240, 189, 0.78), transparent 23%); + box-shadow: 16px 0 44px rgba(13, 90, 72, 0.08); +} + +.brand { + margin-bottom: 22px; +} + +.nav-stack { + gap: 14px; +} + +.nav-button { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 44px; + padding: 0 11px; +} + +.nav-button::before { + display: none; +} + +.nav-icon { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 1px solid rgba(13, 107, 88, 0.18); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.72); + color: var(--accent); + font-family: var(--num-font); + font-size: 11px; + font-weight: 900; +} + +.nav-button > span:last-child { + min-width: 0; + overflow: hidden; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-button.active .nav-icon, +.nav-button:hover .nav-icon { + border-color: rgba(13, 107, 88, 0.32); + background: var(--accent); + color: #fffaf0; + box-shadow: 0 0 0 4px rgba(13, 107, 88, 0.1); +} + +.nav-admin .nav-button { + min-height: 42px; +} + +.side-brief { + display: grid; + gap: 6px; + margin-top: 22px; + padding: 14px; + border: 1px solid rgba(165, 111, 24, 0.3); + border-radius: var(--radius); + background: + radial-gradient(circle at 84% 12%, rgba(255, 255, 255, 0.82), transparent 18%), + linear-gradient(135deg, rgba(255, 240, 189, 0.78), rgba(221, 242, 239, 0.68)); + box-shadow: var(--shadow-soft); +} + +.side-brief span { + color: var(--gold); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.side-brief strong { + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 17px; + line-height: 1.25; +} + +.side-brief p { + margin: 0; + color: #4d655b; + font-size: 12px; + line-height: 1.55; +} + +.install-panel { + margin-top: 18px; +} + +.main { + padding: 20px 24px 32px; +} + +.topbar { + margin-bottom: 12px; + padding: 18px 20px; +} + +.topbar h2 { + font-size: 32px; +} + +.topbar-actions { + max-width: 560px; +} + +.button { + min-height: 42px; + padding: 0 14px; + font-weight: 800; +} + +.button:active, +.icon-button:active, +.nav-button:active, +.compact-item:active { + transform: translateY(1px) scale(0.99); +} + +.command-strip { + display: grid; + grid-template-columns: 1.1fr 0.95fr 0.95fr; + gap: 10px; + margin-bottom: 14px; +} + +.command-strip article { + min-width: 0; + padding: 12px 14px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.94), rgba(237, 247, 239, 0.82)), + var(--paper-strong); + box-shadow: var(--shadow-soft); +} + +.command-strip span { + display: block; + color: var(--muted); + font-size: 11px; + font-weight: 900; +} + +.command-strip strong { + display: block; + margin-top: 5px; + overflow: hidden; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 16px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stats-grid { + gap: 10px; +} + +.stat-card { + min-height: 98px; + padding: 15px; +} + +.module-hero, +.panel, +.toolbar, +.stat-card, +.item-card, +.admin-kpi-card { + box-shadow: var(--shadow-soft); +} + +.module-hero { + background: + linear-gradient(115deg, rgba(255, 253, 245, 0.99), rgba(235, 248, 240, 0.94) 56%, rgba(255, 240, 189, 0.72)), + var(--paper); +} + +.panel { + background: + linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(246, 255, 250, 0.9)), + var(--paper-strong); +} + +.toolbar { + grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(126px, 1fr)); +} + +.toolbar input, +.toolbar select, +.dialog-card textarea, +.dialog-card select, +.dialog-fields input, +.filter-sheet-body input, +.filter-sheet-body select, +.mobile-search-field input, +.auth-card input, +.settings-panel input, +.settings-panel textarea, +.plugin-config input, +.rule-block input, +.rule-block textarea { + min-height: 44px; +} + +.bulk-toolbar { + border-color: rgba(13, 107, 88, 0.24); + background: + linear-gradient(90deg, rgba(221, 242, 239, 0.96), rgba(255, 248, 233, 0.96)), + var(--paper-strong); +} + +.items-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 14px; +} + +.item-card { + background: + linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(246, 255, 250, 0.9)), + var(--paper-strong); +} + +.item-card:hover { + transform: translateY(-2px); + box-shadow: 0 20px 38px rgba(13, 107, 88, 0.13); +} + +.item-image, +.item-placeholder { + aspect-ratio: 1.08; +} + +.item-body { + padding: 13px; +} + +.item-title { + min-height: 44px; +} + +.item-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.fact { + padding: 8px; +} + +.card-actions button, +.card-actions a { + min-height: 40px; + font-weight: 800; +} + +.admin-kpi-grid { + gap: 10px; +} + +.admin-user-row, +.token-card, +.credit-code-row { + box-shadow: var(--shadow-soft); +} + +.credit-code-form input, +.credit-redeem-form input { + min-height: 44px; +} + +dialog::backdrop { + background: rgba(36, 26, 20, 0.52); +} + +@media (max-width: 1180px) { + .app-shell { + grid-template-columns: 270px minmax(0, 1fr); + } + + .command-strip { + grid-template-columns: 1fr; + } + + .command-strip strong { + white-space: normal; + } +} + +@media (max-width: 900px) { + body { + overscroll-behavior-y: contain; + } + + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar, + .topbar, + .toolbar { + display: none; + } + + .command-strip { + display: none; + } + + .mobile-header { + padding-top: calc(12px + env(safe-area-inset-top)); + } + + .icon-button { + min-width: 44px; + min-height: 44px; + padding: 0 10px; + } + + .main { + padding: 12px 12px 92px; + } + + .stats-grid { + grid-template-columns: repeat(4, minmax(118px, 1fr)); + overflow-x: auto; + padding-bottom: 2px; + scroll-snap-type: x proximity; + } + + .stat-card { + scroll-snap-align: start; + } + + .bulk-toolbar { + bottom: calc(82px + env(safe-area-inset-bottom)); + } + + .mobile-tabbar { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .mobile-header-actions { + gap: 6px; + } + + .icon-button { + padding: 0 8px; + font-size: 12px; + } + + .module-hero h3 { + font-size: 23px; + } + + .stats-grid { + grid-template-columns: repeat(4, minmax(112px, 1fr)); + } + + .items-grid { + gap: 12px; + } + + .item-image, + .item-placeholder { + aspect-ratio: 1.18; + } + + .mobile-tab { + min-width: 0; + padding: 0 2px; + } +} + +/* Account center and rule studio refinement */ +.profile-dashboard { + display: grid; + gap: 14px; +} + +.profile-identity-panel { + position: relative; + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + min-height: 164px; + padding: 22px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: + radial-gradient(circle at 92% 14%, rgba(255, 240, 189, 0.84), transparent 24%), + linear-gradient(115deg, rgba(255, 253, 245, 0.99), rgba(235, 248, 240, 0.92)), + var(--paper-strong); + box-shadow: var(--shadow-soft); + overflow: hidden; +} + +.profile-identity-panel::after { + position: absolute; + right: 26px; + bottom: -72px; + color: rgba(13, 106, 84, 0.06); + font-family: var(--display-font); + font-size: 220px; + line-height: 1; + content: "账"; +} + +.profile-identity-panel > * { + position: relative; + z-index: 1; +} + +.profile-avatar { + display: grid; + width: 72px; + height: 72px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.78); + border-radius: var(--radius); + background: + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.44), transparent 28%), + linear-gradient(145deg, #238673, #083f34); + color: #fff8e8; + font-family: var(--display-font); + font-size: 34px; + font-weight: 800; + box-shadow: 0 16px 30px rgba(13, 90, 72, 0.2); +} + +.profile-identity-copy { + min-width: 0; +} + +.profile-identity-copy h3 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: clamp(30px, 3vw, 44px); + line-height: 1.08; +} + +.profile-identity-copy p:not(.eyebrow) { + margin: 8px 0 0; + color: var(--muted); + font-size: 14px; + overflow-wrap: anywhere; +} + +.profile-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.profile-badges span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 9px; + border: 1px solid rgba(13, 106, 84, 0.16); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.76); + color: var(--accent-strong); + font-size: 12px; + font-weight: 900; +} + +.profile-badges span:nth-child(2) { + border-color: rgba(37, 121, 72, 0.2); + background: var(--success-soft); + color: var(--success); +} + +.profile-badges span:nth-child(3) { + border-color: rgba(165, 111, 24, 0.26); + background: var(--gold-soft); + color: var(--gold); +} + +.profile-primary-actions { + display: grid; + gap: 10px; + justify-self: end; + min-width: 148px; +} + +.profile-primary-actions .button { + width: 100%; + min-height: 44px; + padding-inline: 16px; +} + +.profile-stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.profile-stat { + position: relative; + min-width: 0; + min-height: 118px; + padding: 16px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(237, 247, 239, 0.84)), + var(--paper-strong); + box-shadow: var(--shadow-soft); + overflow: hidden; +} + +.profile-stat::after { + position: absolute; + right: 10px; + bottom: -22px; + color: rgba(13, 106, 84, 0.07); + font-family: var(--display-font); + font-size: 88px; + content: "户"; +} + +.profile-stat.is-gold { + border-color: rgba(165, 111, 24, 0.26); + background: + linear-gradient(135deg, rgba(255, 240, 189, 0.78), rgba(255, 253, 247, 0.94)), + var(--paper-strong); +} + +.profile-stat.is-jade { + border-color: rgba(36, 155, 144, 0.25); + background: + linear-gradient(135deg, rgba(221, 242, 239, 0.96), rgba(255, 253, 247, 0.9)), + var(--paper-strong); +} + +.profile-stat span, +.profile-stat strong, +.profile-stat em { + position: relative; + z-index: 1; + display: block; +} + +.profile-stat span { + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.profile-stat strong { + margin-top: 12px; + overflow: hidden; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: clamp(25px, 2.1vw, 32px); + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-stat em { + margin-top: 9px; + color: #315f55; + font-size: 12px; + font-style: normal; + line-height: 1.45; +} + +.profile-content-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 14px; +} + +.account-service-panel, +.profile-action-panel { + min-height: 100%; +} + +.profile-info-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.profile-info-row { + display: grid; + grid-template-columns: minmax(118px, 0.32fr) minmax(0, 1fr); + gap: 12px; + align-items: center; + min-height: 48px; + padding: 10px 12px; + border: 1px solid rgba(13, 106, 84, 0.12); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(221, 242, 239, 0.68), rgba(255, 255, 255, 0.74)), + var(--paper-strong); +} + +.profile-info-row span { + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.profile-info-row strong { + min-width: 0; + overflow: hidden; + color: var(--text); + font-family: var(--num-font); + font-size: 16px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.profile-action-card { + display: grid; + align-content: space-between; + gap: 10px; + min-height: 112px; + padding: 14px; + border: 1px solid rgba(13, 106, 84, 0.15); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(237, 247, 239, 0.74)), + var(--paper-strong); + color: var(--text); + text-align: left; + cursor: pointer; + box-shadow: var(--shadow-soft); + transition: transform var(--motion-fast) ease, border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease; +} + +.profile-action-card:hover { + border-color: rgba(13, 106, 84, 0.34); + transform: translateY(-2px); + box-shadow: 0 16px 30px rgba(13, 107, 88, 0.12); +} + +.profile-action-card span { + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.profile-action-card strong { + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 18px; + line-height: 1.25; +} + +.profile-action-card.is-primary { + border-color: rgba(13, 106, 84, 0.34); + background: linear-gradient(145deg, #238673, #083f34); +} + +.profile-action-card.is-primary span, +.profile-action-card.is-primary strong { + color: #fff8e8; +} + +.profile-action-card.is-gold { + border-color: rgba(165, 111, 24, 0.28); + background: + linear-gradient(135deg, rgba(255, 240, 189, 0.9), rgba(255, 253, 247, 0.86)), + var(--paper-strong); +} + +.profile-credit-panel { + display: grid; + grid-template-columns: minmax(260px, 0.72fr) minmax(320px, 1fr); + gap: 18px; + align-items: end; + margin-top: 0; + padding: 18px; +} + +.credit-copy h3 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 22px; +} + +.profile-credit-panel .credit-redeem-form { + grid-template-columns: minmax(220px, 1fr) auto; +} + +.rule-hero .hero-command { + align-items: center; +} + +.rule-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.rule-summary-card { + min-width: 0; + min-height: 126px; + padding: 15px; + border: 1px solid rgba(13, 106, 84, 0.14); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(237, 247, 239, 0.82)), + var(--paper-strong); + box-shadow: var(--shadow-soft); +} + +.rule-summary-card.is-danger { + border-color: rgba(179, 59, 49, 0.24); + background: + linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(255, 232, 227, 0.72)), + var(--paper-strong); +} + +.rule-summary-card.is-gold { + border-color: rgba(165, 111, 24, 0.28); + background: + linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(255, 240, 189, 0.7)), + var(--paper-strong); +} + +.rule-summary-card span { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.rule-summary-card strong { + display: block; + margin-top: 10px; + color: var(--accent-strong); + font-family: var(--num-font); + font-size: 31px; + line-height: 1; +} + +.rule-summary-card p { + margin: 10px 0 0; + color: #4d655b; + font-size: 12px; + line-height: 1.55; +} + +.rules-workbench { + display: grid; + grid-template-columns: minmax(520px, 1.18fr) minmax(380px, 0.82fr); + gap: 14px; + align-items: start; +} + +.rule-panel { + overflow: visible; +} + +.rule-panel-head { + display: flex; + justify-content: space-between; + gap: 14px; + margin-bottom: 14px; +} + +.rule-panel-head.compact { + display: block; +} + +.rule-panel-head h3 { + margin: 0; + color: var(--accent-strong); + font-family: var(--display-font); + font-size: 22px; +} + +.rule-panel-head p:not(.eyebrow) { + max-width: 720px; + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.rules-side-stack { + display: grid; + gap: 14px; + min-width: 0; +} + +.track-rules-editor { + gap: 10px; + margin-top: 0; +} + +.track-rule-row { + grid-template-columns: 44px minmax(126px, 0.42fr) minmax(220px, 1fr) auto; + align-items: end; + padding: 12px; + border: 1px solid rgba(13, 106, 84, 0.12); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.96), rgba(237, 247, 239, 0.72)), + var(--paper-strong); + box-shadow: var(--shadow-soft); +} + +.track-rule-index { + display: grid; + align-self: stretch; + min-height: 44px; + place-items: center; +} + +.track-rule-index span { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: 1px solid rgba(13, 106, 84, 0.18); + border-radius: var(--radius-sm); + background: var(--ds-turquoise-100); + color: var(--accent); + font-family: var(--num-font); + font-size: 12px; + font-weight: 900; +} + +.track-rule-row label, +.rule-textarea-label { + display: grid; + gap: 7px; + min-width: 0; +} + +.track-rule-row label span, +.rule-textarea-label span, +.profit-rule-grid label span { + color: #315f55; + font-size: 12px; + font-weight: 900; +} + +.profit-rule-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.profit-rule-grid label:last-child { + grid-column: 1 / -1; +} + +.risk-panel { + border-color: rgba(179, 59, 49, 0.22); +} + +.helper-panel .dual-rule-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.rule-textarea-label textarea { + min-height: 132px; + resize: vertical; +} + +@media (max-width: 1180px) { + .profile-stat-grid, + .rule-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .profile-content-grid, + .profile-credit-panel, + .rules-workbench { + grid-template-columns: 1fr; + } + + .rules-side-stack { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .profile-identity-panel { + grid-template-columns: 58px minmax(0, 1fr); + padding: 16px; + } + + .profile-identity-panel > .profile-primary-actions { + grid-column: 1 / -1; + justify-self: stretch; + min-width: 0; + } + + .profile-avatar { + width: 58px; + height: 58px; + font-size: 28px; + } + + .profile-identity-copy h3 { + font-size: 30px; + } + + .profile-stat-grid, + .rule-summary-grid, + .profile-action-grid, + .helper-panel .dual-rule-grid, + .profit-rule-grid { + grid-template-columns: 1fr; + } + + .profile-info-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .profile-info-list { + grid-template-columns: 1fr; + } + + .profile-info-row strong { + text-align: left; + white-space: normal; + } + + .profile-credit-panel .credit-redeem-form { + grid-template-columns: 1fr; + } + + .rule-panel-head { + display: grid; + } + + .track-rule-row { + grid-template-columns: 38px minmax(0, 1fr); + } + + .track-rule-row label, + .track-rule-row button { + grid-column: 1 / -1; + } +} diff --git a/public/test-capture.html b/public/test-capture.html new file mode 100644 index 0000000..815453f --- /dev/null +++ b/public/test-capture.html @@ -0,0 +1,20 @@ + + + + + 藏式手链测试页 + + +
    + +
    + + diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py new file mode 100644 index 0000000..b3e1c80 --- /dev/null +++ b/scripts/generate_brand_assets.py @@ -0,0 +1,168 @@ +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parents[1] +PUBLIC = ROOT / "public" +ASSETS = PUBLIC / "assets" +EXT_ICONS = ROOT / "extension" / "icons" + +JADE_DARK = "#073C31" +JADE = "#0D6A54" +TURQUOISE = "#249B90" +PAPER = "#FFFDF5" +PAPER_GREEN = "#E8F6EF" +BRASS = "#A56F18" +BRASS_LIGHT = "#FFE6A1" +CINNABAR = "#C9544A" +MUTED = "#65766A" + +SERIF = "/System/Library/Fonts/Supplemental/Songti.ttc" +SANS = "/Users/wangyuqiang/Library/Fonts/SourceHanSansCN-Regular.otf" +SANS_HEAVY = "/Users/wangyuqiang/Library/Fonts/SourceHanSansCN-Heavy.otf" + + +def font(path, size): + return ImageFont.truetype(path, size) + + +def rounded(draw, xy, radius, fill, outline=None, width=1): + draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=outline, width=width) + + +def draw_mark(size=512): + scale = size / 256 + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + rounded(draw, (0, 0, size - 1, size - 1), int(56 * scale), PAPER) + rounded(draw, (int(12 * scale), int(12 * scale), int(244 * scale), int(244 * scale)), int(48 * scale), JADE) + rounded( + draw, + (int(25 * scale), int(25 * scale), int(231 * scale), int(231 * scale)), + int(38 * scale), + None, + "#2E8F7D", + max(1, int(2 * scale)), + ) + stroke = max(8, int(13 * scale)) + corner = "#FFF8E8" + segments = [ + ((57, 98), (57, 70), (70, 57), (98, 57)), + ((158, 57), (186, 57), (199, 70), (199, 98)), + ((199, 158), (199, 186), (186, 199), (158, 199)), + ((98, 199), (70, 199), (57, 186), (57, 158)), + ] + for points in segments: + scaled = [(int(x * scale), int(y * scale)) for x, y in points] + draw.line(scaled, fill=corner, width=stroke, joint="curve") + + diamond = [(128, 72), (184, 128), (128, 184), (72, 128)] + draw.polygon([(int(x * scale), int(y * scale)) for x, y in diamond], fill=BRASS_LIGHT) + inner = [(128, 91), (165, 128), (128, 165), (91, 128)] + draw.polygon([(int(x * scale), int(y * scale)) for x, y in inner], fill="#FFF8E8") + draw.ellipse( + (int(111 * scale), int(111 * scale), int(145 * scale), int(145 * scale)), + fill=JADE, + ) + draw.ellipse( + (int(162 * scale), int(70 * scale), int(186 * scale), int(94 * scale)), + fill=CINNABAR, + outline="#FFF8E8", + width=max(3, int(5 * scale)), + ) + return image + + +def center_text(draw, xy, text, font_obj, fill): + x1, y1, x2, y2 = xy + box = draw.textbbox((0, 0), text, font=font_obj) + width = box[2] - box[0] + height = box[3] - box[1] + draw.text((x1 + (x2 - x1 - width) / 2, y1 + (y2 - y1 - height) / 2 - box[1]), text, font=font_obj, fill=fill) + + +def draw_share_card(): + image = Image.new("RGB", (1200, 630), "#F8FFF9") + draw = ImageDraw.Draw(image) + for y in range(630): + ratio = y / 629 + r = int(248 * (1 - ratio) + 255 * ratio) + g = int(255 * (1 - ratio) + 245 * ratio) + b = int(249 * (1 - ratio) + 222 * ratio) + draw.line((0, y, 1200, y), fill=(r, g, b)) + + for x in range(0, 1200, 32): + draw.line((x, 0, x, 630), fill="#D8E8DE", width=1) + for y in range(0, 630, 32): + draw.line((0, y, 1200, y), fill="#D8E8DE", width=1) + + draw.ellipse((860, -228, 1380, 292), fill="#F3DE9E") + draw.ellipse((-210, 360, 290, 860), fill="#DDF2EF") + + mark = draw_mark(208) + image.paste(mark, (66, 62), mark) + + title_font = font(SERIF, 64) + subtitle_font = font(SANS_HEAVY, 30) + body_font = font(SANS, 22) + small_font = font(SANS_HEAVY, 17) + label_font = font(SANS, 15) + + draw.text((66, 306), "选品采购台", font=title_font, fill=JADE_DARK) + draw.text((69, 394), "从商品机会,到放心采购", font=subtitle_font, fill="#315F55") + draw.text((69, 452), "采集竞品 · 匹配货源 · 利润测算 · 拿样决策", font=body_font, fill=MUTED) + + rounded(draw, (660, 88, 1124, 540), 28, PAPER, "#B9D5C7", 2) + draw.text((694, 132), "PRODUCT OPPORTUNITY FLOW", font=small_font, fill=MUTED) + + cards = [ + ((694, 174, 1092, 252), "#E2F3EE", "#B9D5C7", JADE, "发现高潜商品", "淘宝 / 小红书 / 更多平台线索"), + ((694, 290, 1092, 368), "#FFF3CB", "#D9BD79", BRASS, "匹配优质货源", "1688 厂家 / 价格 / 保障"), + ((694, 406, 1092, 502), JADE, JADE, PAPER, "形成采购决策", "筛选 / 利润 / 风险 / 拿样"), + ] + for index, (box, fill, outline, accent, title, subtitle) in enumerate(cards): + rounded(draw, box, 14, fill, outline, 1) + x1, y1, x2, y2 = box + if index == 0: + draw.ellipse((x1 + 20, y1 + 19, x1 + 60, y1 + 59), fill=JADE) + center_text(draw, (x1 + 20, y1 + 19, x1 + 60, y1 + 59), "+", font(SANS, 29), PAPER) + text_fill = JADE_DARK + sub_fill = MUTED + elif index == 1: + draw.rectangle((x1 + 22, y1 + 25, x1 + 61, y1 + 55), fill=BRASS) + text_fill = "#5F4211" + sub_fill = "#7A6645" + else: + draw.ellipse((x1 + 18, y1 + 26, x1 + 62, y1 + 70), fill=PAPER) + draw.line((x1 + 30, y1 + 50, x1 + 38, y1 + 58, x1 + 53, y1 + 39), fill=JADE, width=5) + text_fill = PAPER + sub_fill = "#DDF2EF" + draw.ellipse((x2 - 39, y1 + 18, x2 - 23, y1 + 34), fill=CINNABAR) + draw.text((x1 + 76, y1 + 21), title, font=font(SANS_HEAVY, 21), fill=text_fill) + draw.text((x1 + 76, y1 + 49), subtitle, font=label_font, fill=sub_fill) + if index < 2: + cx = 892 + draw.line((cx, y2 + 8, cx, y2 + 32), fill=BRASS, width=4) + draw.line((cx - 8, y2 + 24, cx, y2 + 32, cx + 8, y2 + 24), fill=BRASS, width=4) + + return image + + +def main(): + ASSETS.mkdir(parents=True, exist_ok=True) + EXT_ICONS.mkdir(parents=True, exist_ok=True) + + mark = draw_mark(512) + mark.resize((256, 256), Image.Resampling.LANCZOS).save(ASSETS / "brand-mark.png") + draw_share_card().save(ASSETS / "share-card.png") + + icon_sizes = [16, 32, 48, 64, 128, 256] + icons = [mark.resize((size, size), Image.Resampling.LANCZOS) for size in icon_sizes] + icons[-1].save(PUBLIC / "favicon.ico", sizes=[(size, size) for size in icon_sizes]) + for size in [16, 32, 48, 128]: + mark.resize((size, size), Image.Resampling.LANCZOS).save(EXT_ICONS / f"icon{size}.png") + + +if __name__ == "__main__": + main() diff --git a/src/ai.js b/src/ai.js new file mode 100644 index 0000000..db71392 --- /dev/null +++ b/src/ai.js @@ -0,0 +1,239 @@ +export const DEFAULT_AI_SETTINGS = { + enabled: false, + provider: "openai-compatible", + baseUrl: "", + apiKey: "", + model: "", + temperature: 0.2, + maxOutputTokens: 900, + timeoutMs: 20000, + tokenUnitCost: 1, + prompts: { + itemAnalysis: + "你是资深电商选品采购顾问。请基于商品资料输出 JSON:summary, purchaseAdvice, sellingPoints, risks, supplierQuestions。", + procurementReport: + "你是直播电商采购负责人。请输出采购判断 JSON:summary, purchaseAdvice, priceStrategy, sellingPoints, risks, supplierQuestions。", + ruleGeneration: + "你是电商品类运营专家。请根据用户目标品类生成选品规则 JSON:tracks, riskKeywords, captureHints, autoTags, profitModel。", + }, +}; + +export class OpenAICompatibleClient { + async chat({ settings, messages }) { + const baseUrl = normalizeBaseUrl(settings.baseUrl); + if (!baseUrl) { + const error = new Error("AI Base URL is required"); + error.status = 400; + throw error; + } + if (!settings.apiKey) { + const error = new Error("AI API Key is required"); + error.status = 400; + throw error; + } + if (!settings.model) { + const error = new Error("AI model is required"); + error.status = 400; + throw error; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Number(settings.timeoutMs || DEFAULT_AI_SETTINGS.timeoutMs)); + try { + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + signal: controller.signal, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.apiKey}`, + }, + body: JSON.stringify({ + model: settings.model, + messages, + temperature: Number(settings.temperature ?? DEFAULT_AI_SETTINGS.temperature), + max_tokens: Number(settings.maxOutputTokens || DEFAULT_AI_SETTINGS.maxOutputTokens), + response_format: { type: "json_object" }, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload?.error?.message || `AI request failed with ${response.status}`); + error.status = response.status >= 400 && response.status < 500 ? 400 : 502; + throw error; + } + const text = payload?.choices?.[0]?.message?.content || ""; + return { + text, + usage: { + inputTokens: Number(payload?.usage?.prompt_tokens || 0), + outputTokens: Number(payload?.usage?.completion_tokens || 0), + totalTokens: Number(payload?.usage?.total_tokens || estimateTokens(messages.map((message) => message.content).join("\n") + text)), + }, + raw: payload, + }; + } catch (error) { + if (error.name === "AbortError") { + const timeoutError = new Error("AI request timed out"); + timeoutError.status = 504; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timeout); + } + } +} + +export function normalizeAiSettings(input = {}, previous = {}) { + const source = input && typeof input === "object" ? input : {}; + const current = { ...DEFAULT_AI_SETTINGS, ...(previous || {}) }; + const apiKeyInput = String(source.apiKey || "").trim(); + const apiKey = apiKeyInput || current.apiKey || ""; + return { + enabled: Boolean(source.enabled ?? current.enabled), + provider: cleanValue(source.provider || current.provider || DEFAULT_AI_SETTINGS.provider, 40), + baseUrl: normalizeBaseUrl(source.baseUrl || current.baseUrl || ""), + apiKey, + model: cleanValue(source.model || current.model || "", 80), + temperature: clampNumber(source.temperature ?? current.temperature, 0, 2, DEFAULT_AI_SETTINGS.temperature), + maxOutputTokens: Math.round(clampNumber(source.maxOutputTokens ?? current.maxOutputTokens, 128, 8000, DEFAULT_AI_SETTINGS.maxOutputTokens)), + timeoutMs: Math.round(clampNumber(source.timeoutMs ?? current.timeoutMs, 5000, 120000, DEFAULT_AI_SETTINGS.timeoutMs)), + tokenUnitCost: Math.round(clampNumber(source.tokenUnitCost ?? current.tokenUnitCost, 1, 100, DEFAULT_AI_SETTINGS.tokenUnitCost)), + prompts: { + ...DEFAULT_AI_SETTINGS.prompts, + ...(current.prompts || {}), + ...(source.prompts && typeof source.prompts === "object" ? source.prompts : {}), + }, + updatedAt: new Date().toISOString(), + }; +} + +export function publicAiSettings(settings = {}) { + const normalized = normalizeAiSettings(settings, settings); + return { + enabled: normalized.enabled, + provider: normalized.provider, + baseUrl: normalized.baseUrl, + model: normalized.model, + temperature: normalized.temperature, + maxOutputTokens: normalized.maxOutputTokens, + timeoutMs: normalized.timeoutMs, + tokenUnitCost: normalized.tokenUnitCost, + prompts: normalized.prompts, + apiKeyMasked: maskSecret(normalized.apiKey), + updatedAt: settings.updatedAt || normalized.updatedAt, + }; +} + +export function userAiStatus(settings = {}) { + const normalized = normalizeAiSettings(settings, settings); + return { + enabled: Boolean(normalized.enabled && normalized.baseUrl && normalized.apiKey && normalized.model), + provider: normalized.provider, + model: normalized.model, + tokenUnitCost: normalized.tokenUnitCost, + }; +} + +export function buildAiMessages({ type = "item_analysis", item = {}, rules = {}, settings = {}, input = "" } = {}) { + const prompts = { ...DEFAULT_AI_SETTINGS.prompts, ...(settings.prompts || {}) }; + const systemPrompt = + type === "rule_generation" + ? prompts.ruleGeneration + : type === "procurement_report" + ? prompts.procurementReport + : prompts.itemAnalysis; + const payload = + type === "rule_generation" + ? { target: input, currentRules: rules } + : { + type, + item: compactItem(item), + rules, + }; + return [ + { + role: "system", + content: `${systemPrompt}\n只输出 JSON,不要 Markdown。`, + }, + { + role: "user", + content: JSON.stringify(payload, null, 2), + }, + ]; +} + +export function parseAiJson(text = "") { + const raw = String(text || "").trim(); + if (!raw) return { summary: "AI 未返回内容", raw: "" }; + try { + return JSON.parse(raw); + } catch { + const match = raw.match(/\{[\s\S]*\}/); + if (match) { + try { + return JSON.parse(match[0]); + } catch { + return { summary: raw, raw }; + } + } + return { summary: raw, raw }; + } +} + +export function estimateTokens(text = "") { + return Math.max(1, Math.ceil(String(text || "").length / 2)); +} + +export function estimatedRequestTokens({ messages = [], maxOutputTokens = 0, tokenUnitCost = 1 } = {}) { + const input = estimateTokens(messages.map((message) => message.content).join("\n")); + return Math.max(1, Math.ceil((input + Number(maxOutputTokens || 0)) * Number(tokenUnitCost || 1))); +} + +export function billableTokens(usage = {}, tokenUnitCost = 1) { + return Math.max(1, Math.ceil(Number(usage.totalTokens || 0) * Number(tokenUnitCost || 1))); +} + +function compactItem(item = {}) { + return { + id: item.id, + platform: item.platform, + title: item.title, + url: item.url, + image: item.image, + price: item.price, + priceText: item.priceText, + supplier: item.supplier, + shop: item.shop, + track: item.track, + score: item.score, + decision: item.decision, + risks: item.risks, + reasons: item.reasons, + profit: item.profit, + metrics: item.metrics, + notes: item.notes, + tags: item.tags, + }; +} + +function normalizeBaseUrl(value = "") { + return String(value || "").trim().replace(/\/+$/, ""); +} + +function cleanValue(value = "", max = 120) { + return String(value || "").replace(/\s+/g, " ").trim().slice(0, max); +} + +function clampNumber(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(max, Math.max(min, number)); +} + +function maskSecret(secret = "") { + const value = String(secret || ""); + if (!value) return ""; + if (value.length <= 8) return "已配置"; + return `${value.slice(0, 3)}...${value.slice(-4)}`; +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..f0e8bdd --- /dev/null +++ b/src/auth.js @@ -0,0 +1,49 @@ +import { scrypt as scryptCallback, timingSafeEqual, randomBytes, createHash } from "node:crypto"; +import { promisify } from "node:util"; + +const scrypt = promisify(scryptCallback); +const PASSWORD_KEY_LENGTH = 64; + +export function normalizeEmail(email = "") { + return String(email).trim().toLowerCase(); +} + +export async function hashPassword(password = "") { + const salt = randomBytes(16).toString("hex"); + const key = await scrypt(String(password), salt, PASSWORD_KEY_LENGTH); + return `scrypt:${salt}:${Buffer.from(key).toString("hex")}`; +} + +export async function verifyPassword(password = "", storedHash = "") { + const [scheme, salt, hash] = String(storedHash).split(":"); + if (scheme !== "scrypt" || !salt || !hash) return false; + const expected = Buffer.from(hash, "hex"); + const actual = await scrypt(String(password), salt, expected.length); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +export function createSessionToken() { + return `sess_${randomBytes(32).toString("base64url")}`; +} + +export function createPluginToken() { + return `pst_${randomBytes(32).toString("base64url")}`; +} + +export function hashToken(token = "") { + return createHash("sha256").update(String(token)).digest("hex"); +} + +export function publicUser(user = {}) { + return { + id: user.id, + email: user.email, + name: user.name || "", + role: user.role || "buyer", + status: user.status || "active", + credits: Number(user.credits || 0), + aiTokens: Number(user.aiTokens || 0), + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} diff --git a/src/core.js b/src/core.js new file mode 100644 index 0000000..28ec75f --- /dev/null +++ b/src/core.js @@ -0,0 +1,624 @@ +const PLATFORM_RULES = [ + ["1688", /(^|\.)1688\.com/i], + ["xiaohongshu", /(^|\.)xiaohongshu\.com|(^|\.)xhslink\.com/i], + ["tmall", /(^|\.)tmall\.com/i], + ["taobao", /(^|\.)taobao\.com/i], +]; + +export const DEFAULT_RULES = { + tracks: [ + { name: "藏式/民族风", keywords: ["藏式", "民族风", "绿松石", "藏银", "天珠", "编绳", "旅行感", "南红", "蜜蜡"] }, + { name: "耳饰", keywords: ["耳环", "耳钉", "耳夹", "耳饰", "耳坠", "耳骨夹"] }, + { name: "项链", keywords: ["项链", "锁骨链", "吊坠", "颈链", "毛衣链"] }, + { name: "手链/手镯", keywords: ["手链", "手镯", "手串", "串珠", "叠戴"] }, + { name: "戒指", keywords: ["戒指", "开口戒", "指环"] }, + { name: "发饰", keywords: ["发夹", "抓夹", "发簪", "发箍", "发饰", "头饰"] }, + { name: "包挂/手机链", keywords: ["手机链", "手机挂", "包挂", "钥匙扣", "挂件", "挂链"] }, + { name: "银饰/珍珠/天然石风", keywords: ["银饰", "珍珠", "天然石", "水晶", "玛瑙", "贝母"] }, + ], + riskKeywords: ["天然", "纯银", "真绿松石", "老天珠", "开光", "转运", "招财", "保平安", "功效"], + profitModel: { + shippingFee: 4, + packagingFee: 1, + platformFeeRate: 0.05, + promoFee: 3, + maxRunningPrice: 100, + }, + captureHints: ["优先采集主图清晰、评论/销量可见、价格明确的款", "1688 货源优先补供应商、混批、拿样和售后信息"], + autoTags: ["低价跑量", "藏式专项", "利润可跑", "高风险复核", "优先拿样"], +}; + +const SAMPLE_STATUSES = new Set(["asking_supplier", "ordered_sample", "live_testing"]); + +export function normalizeRules(input = {}) { + const source = input && typeof input === "object" ? input : {}; + const defaultRules = cloneRules(DEFAULT_RULES); + const tracks = Array.isArray(source.tracks) + ? source.tracks + .map((track) => ({ + name: cleanText(track?.name || ""), + keywords: normalizeTags(track?.keywords || []), + })) + .filter((track) => track.name && track.keywords.length > 0) + : defaultRules.tracks; + const profitModel = { + ...defaultRules.profitModel, + ...(source.profitModel && typeof source.profitModel === "object" ? source.profitModel : {}), + }; + + return { + tracks: tracks.length > 0 ? tracks : defaultRules.tracks, + riskKeywords: normalizeTags(source.riskKeywords || defaultRules.riskKeywords), + profitModel: { + shippingFee: numberOrDefault(profitModel.shippingFee, defaultRules.profitModel.shippingFee), + packagingFee: numberOrDefault(profitModel.packagingFee, defaultRules.profitModel.packagingFee), + platformFeeRate: numberOrDefault(profitModel.platformFeeRate, defaultRules.profitModel.platformFeeRate), + promoFee: numberOrDefault(profitModel.promoFee, defaultRules.profitModel.promoFee), + maxRunningPrice: numberOrDefault(profitModel.maxRunningPrice, defaultRules.profitModel.maxRunningPrice), + }, + captureHints: normalizeTags(source.captureHints || defaultRules.captureHints), + autoTags: normalizeTags(source.autoTags || defaultRules.autoTags), + }; +} + +export function detectPlatform(url = "") { + let host = ""; + try { + host = new URL(url).hostname; + } catch { + host = url; + } + + for (const [platform, pattern] of PLATFORM_RULES) { + if (pattern.test(host)) return platform; + } + return "other"; +} + +export function extractPrice(text = "") { + const normalized = String(text).replace(/,/g, "").replace(/\s+/g, " "); + const match = normalized.match(/(?:¥|¥|价格|批发价|到手价|券后|RMB)?\s*(\d+(?:\.\d+)?)/i); + return match ? Number(match[1]) : null; +} + +export function inferJewelryTrack(text = "", rules) { + const source = String(text).toLowerCase(); + for (const { name: track, keywords } of normalizeRules(rules).tracks) { + if (keywords.some((keyword) => source.includes(keyword.toLowerCase()))) { + return track; + } + } + return "未分类"; +} + +export function normalizeCapture(raw = {}, rules) { + const combinedText = [ + raw.title, + raw.description, + raw.supplier, + raw.shop, + raw.priceText, + raw.url, + ] + .filter(Boolean) + .join(" "); + + const price = typeof raw.price === "number" ? raw.price : extractPrice(raw.priceText || combinedText); + const url = String(raw.url || raw.link || "").trim(); + + const item = { + id: raw.id || createId(), + createdAt: raw.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + platform: raw.platform || detectPlatform(url), + url, + title: cleanText(raw.title || raw.name || ""), + image: String(raw.image || raw.imageUrl || "").trim(), + price, + priceText: cleanText(raw.priceText || (price ? `¥${price}` : "")), + supplier: cleanText(raw.supplier || raw.shop || ""), + shop: cleanText(raw.shop || raw.supplier || ""), + sourcePage: raw.sourcePage || url, + track: raw.track || inferJewelryTrack(combinedText, rules), + status: raw.status || "new", + notes: cleanText(raw.notes || ""), + tags: Array.isArray(raw.tags) ? normalizeTags(raw.tags) : [], + targetPrice: numberOrNull(raw.targetPrice), + shippingFee: numberOrNull(raw.shippingFee), + packagingFee: numberOrNull(raw.packagingFee), + platformFeeRate: numberOrNull(raw.platformFeeRate), + promoFee: numberOrNull(raw.promoFee), + metrics: raw.metrics || {}, + }; + if (raw.userId) item.userId = String(raw.userId); + return item; +} + +export function scoreCapture(raw = {}, rules) { + const activeRules = normalizeRules(rules); + const item = normalizeCapture(raw, activeRules); + const text = [ + item.title, + item.supplier, + item.shop, + JSON.stringify(item.metrics || {}), + item.notes, + ] + .join(" ") + .toLowerCase(); + + let demand = 8; + if (/(求链接|怎么买|多少钱|问材质|同款|人付款|已售|销量|评价|评论|收藏|点赞|1000|1万|\d+\+)/.test(text)) demand += 7; + if (/(爆款|直播|小红书|种草|旅行|穿搭|出片)/.test(text)) demand += 4; + demand = clamp(demand, 0, 20); + + let liveAppeal = 10; + if (item.image) liveAppeal += 5; + if (matchesAnyTrackKeyword(text, activeRules)) liveAppeal += 7; + if (/(直播|上身|佩戴|实拍|视频|出片)/.test(text)) liveAppeal += 3; + if (item.platform === "1688" && item.price != null && item.price <= 10 && /手机链|包挂|钥匙扣|挂件|手链/.test(text)) { + liveAppeal += 3; + } + liveAppeal = clamp(liveAppeal, 0, 25); + + let impulse = 8; + if (item.price != null && item.price > 0 && item.price <= 25) impulse += 8; + if (item.price != null && item.price > 25 && item.price <= 69) impulse += 4; + if (/(套装|两件|组合|叠戴|加购|混批|低价|包挂|手机链)/.test(text)) impulse += 4; + if (item.platform === "1688" && item.price != null && item.price <= 10) impulse += 2; + impulse = clamp(impulse, 0, 20); + + let supplierQuality = item.platform === "1688" ? 8 : 5; + if (item.supplier) supplierQuality += 2; + if (/(工厂|源头|厂家|现货|混批|拿样|退换|实力商家)/.test(text)) supplierQuality += 4; + if (item.url.includes("1688.com")) supplierQuality += 1; + supplierQuality = clamp(supplierQuality, 0, 15); + + let profit = 6; + if (item.price != null && item.price > 0 && item.price <= 10) profit += 7; + else if (item.price != null && item.price <= 25) profit += 5; + else if (item.price != null && item.price <= 60) profit += 2; + profit = clamp(profit, 0, 15); + + const risks = []; + for (const keyword of activeRules.riskKeywords) { + if (text.includes(keyword.toLowerCase())) risks.push(`谨慎使用“${keyword}”相关宣传`); + } + if (!item.image) risks.push("缺少可保存主图,直播表现力需要人工确认"); + if (item.track === "戒指") risks.push("戒指圈口容易增加库存和退货复杂度"); + if (item.price != null && item.price > activeRules.profitModel.maxRunningPrice) risks.push("价格超过第一批跑量模型"); + + const compliancePenalty = -Math.min(5, risks.length * 2); + const score = clamp(demand + liveAppeal + impulse + supplierQuality + profit + compliancePenalty, 0, 100); + let decision = "暂不做"; + if (score >= 75 && risks.length <= 2) decision = "拿样"; + else if (score >= 60) decision = "继续观察"; + + const reasons = [ + `需求热度 ${demand}/20`, + `直播表现力 ${liveAppeal}/25`, + `加购潜力 ${impulse}/20`, + `货源质量 ${supplierQuality}/15`, + `利润空间 ${profit}/15`, + ]; + if (liveAppeal >= 18) reasons.push("适合直播镜头展示或快速讲解"); + if (item.track === "藏式/民族风") reasons.push("命中藏式/民族风专项赛道"); + + const profitModel = calculateProfit({ + costPrice: item.price, + targetPrice: item.targetPrice, + shippingFee: item.shippingFee ?? activeRules.profitModel.shippingFee, + packagingFee: item.packagingFee ?? activeRules.profitModel.packagingFee, + platformFeeRate: item.platformFeeRate ?? activeRules.profitModel.platformFeeRate, + promoFee: item.promoFee ?? activeRules.profitModel.promoFee, + maxRunningPrice: activeRules.profitModel.maxRunningPrice, + }); + const enriched = { + ...item, + score, + decision, + risks, + riskLevel: risks.length >= 3 ? "high" : risks.length > 0 ? "medium" : "low", + reasons, + profit: profitModel, + livePitch: buildLivePitch({ ...item, risks, decision }, activeRules), + styleKey: buildStyleKey(item, activeRules), + scoreBreakdown: { + demand, + liveAppeal, + impulse, + supplierQuality, + profit, + compliancePenalty, + }, + }; + enriched.tags = normalizeTags(item.tags); + enriched.autoTags = autoTags(enriched); + return enriched; +} + +export function calculateProfit(input = {}) { + const costPrice = numberOrNull(input.costPrice); + if (costPrice == null || costPrice <= 0) { + return { + costPrice: null, + targetPrice: null, + grossProfit: null, + grossMarginRate: null, + recommendation: "待补进货价", + }; + } + + const targetPrice = numberOrNull(input.targetPrice) || suggestRetailPrice(costPrice); + const shippingFee = numberOrNull(input.shippingFee) ?? 4; + const packagingFee = numberOrNull(input.packagingFee) ?? 1; + const platformFeeRate = numberOrNull(input.platformFeeRate) ?? 0.05; + const promoFee = numberOrNull(input.promoFee) ?? 3; + const maxRunningPrice = numberOrNull(input.maxRunningPrice) ?? 100; + const platformFee = roundMoney(targetPrice * platformFeeRate); + const totalCost = roundMoney(costPrice + shippingFee + packagingFee + platformFee + promoFee); + const grossProfit = roundMoney(targetPrice - totalCost); + const grossMarginRate = roundRate(grossProfit / targetPrice); + let recommendation = "谨慎"; + if (grossProfit >= 20 && grossMarginRate >= 0.45 && targetPrice <= maxRunningPrice) recommendation = "可跑量"; + else if (grossProfit >= 12 && grossMarginRate >= 0.35) recommendation = "可测试"; + + return { + costPrice: roundMoney(costPrice), + targetPrice: roundMoney(targetPrice), + shippingFee: roundMoney(shippingFee), + packagingFee: roundMoney(packagingFee), + platformFeeRate, + platformFee, + promoFee: roundMoney(promoFee), + maxRunningPrice: roundMoney(maxRunningPrice), + totalCost, + grossProfit, + grossMarginRate, + recommendation, + }; +} + +export function buildLivePitch(item = {}, rules) { + const activeRules = normalizeRules(rules); + const title = cleanText(item.title || "这款饰品"); + const track = item.track || inferJewelryTrack(title, activeRules); + const price = numberOrNull(item.price); + const talkingPoints = []; + const scenes = []; + const supplierQuestions = ["材质是什么,耳针/链条/配件是否可提供实拍说明?", "是否现货,混批和拿样数量怎么定?"]; + + if (track === "藏式/民族风") { + talkingPoints.push("藏式/民族风元素明显,适合直播间做风格款和叠戴款讲解"); + scenes.push("旅行、拍照、棉麻/新中式/民族风穿搭"); + } else if (track === "耳饰") { + talkingPoints.push("上脸效果直观,适合直播镜头近距离展示"); + scenes.push("通勤、约会、日常换装"); + supplierQuestions.push("耳针是否防过敏,是否有耳夹款?"); + } else if (track === "包挂/手机链") { + talkingPoints.push("低决策小物件,适合直播间做加购和组合购"); + scenes.push("手机壳、包包、钥匙串搭配"); + } else { + talkingPoints.push(`${track}赛道清晰,适合按风格和价格带快速讲解`); + scenes.push("日常搭配、礼物、直播间加购"); + } + + if (price != null && price <= 10) talkingPoints.push("进货价低,适合 39-69 元价格带测试跑量"); + if (String(item.title || "").includes("绿松石")) talkingPoints.push("绿松石色视觉记忆点强,但直播话术避免承诺天然材质"); + if (item.supplier) supplierQuestions.push("能否发细节图、佩戴图和质检/售后规则?"); + + return { + audience: audienceForTrack(track), + talkingPoints, + scenes, + priceScript: + price == null + ? "先补进货价,再定直播价。" + : `进货约 ¥${price.toFixed(2)},建议先测 ${ + calculateProfit({ costPrice: price, ...activeRules.profitModel }).targetPrice + } 元以内直播价。`, + supplierQuestions, + warnings: item.risks || [], + }; +} + +export function buildStyleGroups(items = [], rules) { + const activeRules = normalizeRules(rules); + const groups = new Map(); + for (const item of items.map((candidate) => scoreCapture(candidate, activeRules))) { + const key = item.styleKey || buildStyleKey(item, activeRules); + if (!groups.has(key)) { + groups.set(key, { + id: key, + title: styleGroupTitle(item), + track: item.track, + keywords: styleKeywords(item, activeRules), + items: [], + }); + } + groups.get(key).items.push(item); + } + + return Array.from(groups.values()) + .map((group) => { + const sortedItems = group.items.sort((a, b) => b.score - a.score); + const supplyItems = sortedItems.filter((item) => item.platform === "1688"); + const bestSupply = supplyItems.sort(compareSupply)[0] || sortedItems[0]; + const competitorItems = sortedItems.filter((item) => item.platform !== "1688"); + const suggestedRetailPrice = suggestRetailPrice(bestSupply?.price || sortedItems[0]?.price); + const minCost = minNumber(supplyItems.map((item) => item.price)); + const maxCompetitorPrice = maxNumber(competitorItems.map((item) => item.price)); + return { + ...group, + items: sortedItems, + platforms: unique(sortedItems.map((item) => item.platform)), + bestSupply, + suggestedRetailPrice, + minCost, + maxCompetitorPrice, + opportunityScore: groupOpportunityScore(sortedItems, bestSupply), + }; + }) + .sort((a, b) => b.opportunityScore - a.opportunityScore || b.items.length - a.items.length); +} + +export function buildDashboard(items = [], rules) { + const activeRules = normalizeRules(rules); + const scored = items.map((item) => scoreCapture(item, activeRules)); + const samplePipeline = scored.filter((item) => SAMPLE_STATUSES.has(item.status)).length; + const groups = buildStyleGroups(scored, activeRules); + const byTrack = countBy(scored, "track"); + const byDecision = countBy(scored, "decision"); + const byStatus = countBy(scored, "status"); + const nextActions = []; + + if (scored.some((item) => item.decision === "拿样" && item.status === "new")) { + nextActions.push("把建议拿样但还未处理的款批量标记为“问供应商”。"); + } + if (groups.some((group) => group.items.length >= 2 && group.bestSupply?.platform === "1688")) { + nextActions.push("优先推进已有 1688 货源的相似款组,先问价格、材质、售后。"); + } + if (scored.some((item) => item.riskLevel === "high")) { + nextActions.push("复核高风险宣传词,直播话术避免天然、功效、转运承诺。"); + } + if (scored.length > 0 && nextActions.length === 0) { + nextActions.push("从高分款里选 3-5 个低价款问供应商,先跑一轮直播测试。"); + } + + return { + totals: { + items: scored.length, + samplePipeline, + suggestedSample: scored.filter((item) => item.decision === "拿样").length, + suppliers1688: scored.filter((item) => item.platform === "1688").length, + styleGroups: groups.length, + }, + priceBands: { + under10: scored.filter((item) => item.price != null && item.price <= 10).length, + from10To30: scored.filter((item) => item.price > 10 && item.price <= 30).length, + from30To100: scored.filter((item) => item.price > 30 && item.price <= 100).length, + over100: scored.filter((item) => item.price > 100).length, + unknown: scored.filter((item) => item.price == null).length, + }, + byTrack, + byDecision, + byStatus, + topItems: scored.sort((a, b) => b.score - a.score).slice(0, 12), + lowPriceItems: scored + .filter((item) => item.price != null && item.price <= 10) + .sort((a, b) => b.score - a.score) + .slice(0, 8), + riskItems: scored + .filter((item) => item.riskLevel !== "low") + .sort((a, b) => riskWeight(b.riskLevel) - riskWeight(a.riskLevel) || b.score - a.score) + .slice(0, 8), + profitHealthyItems: scored + .filter((item) => item.profit?.recommendation === "可跑量" || item.profit?.recommendation === "可测试") + .sort((a, b) => (b.profit?.grossProfit || 0) - (a.profit?.grossProfit || 0)) + .slice(0, 8), + topGroups: groups.slice(0, 8), + nextActions, + }; +} + +export function toCsv(items = []) { + const headers = [ + "id", + "platform", + "track", + "decision", + "score", + "status", + "title", + "price", + "targetPrice", + "grossProfit", + "grossMarginRate", + "tags", + "supplier", + "url", + "image", + "risks", + "createdAt", + ]; + const lines = [headers.join(",")]; + for (const item of items) { + lines.push( + headers + .map((header) => { + const value = + header === "risks" + ? (item.risks || []).join(";") + : header === "tags" + ? (item.tags || []).join(";") + : header === "targetPrice" + ? item.profit?.targetPrice + : header === "grossProfit" + ? item.profit?.grossProfit + : header === "grossMarginRate" + ? item.profit?.grossMarginRate + : item[header]; + return csvCell(value); + }) + .join(","), + ); + } + return `${lines.join("\n")}\n`; +} + +export function cleanText(value = "") { + return String(value).replace(/\s+/g, " ").trim(); +} + +function csvCell(value) { + if (value == null) return ""; + const text = String(value); + if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; + return text; +} + +function createId() { + return `cap_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function numberOrNull(value) { + if (value == null || value === "") return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function numberOrDefault(value, fallback) { + const number = numberOrNull(value); + return number == null ? fallback : number; +} + +function roundMoney(value) { + return Math.round(Number(value) * 100) / 100; +} + +function roundRate(value) { + return Math.round(Number(value) * 100) / 100; +} + +function suggestRetailPrice(costPrice) { + const cost = numberOrNull(costPrice); + if (cost == null || cost <= 0) return null; + if (cost <= 5) return 29; + if (cost <= 10) return 39; + if (cost <= 18) return 59; + if (cost <= 30) return 79; + return Math.min(99, Math.ceil(cost * 2.2)); +} + +function autoTags(item) { + const tags = []; + if (item.track === "藏式/民族风") tags.push("藏式专项"); + if (item.price != null && item.price <= 10) tags.push("低价跑量"); + if (item.profit?.recommendation === "可跑量") tags.push("利润可跑"); + if (item.riskLevel === "high") tags.push("高风险复核"); + if (item.score >= 75) tags.push("优先拿样"); + return tags; +} + +function normalizeTags(tags = []) { + return unique(tags.map((tag) => cleanText(tag)).filter(Boolean)); +} + +function unique(values = []) { + return Array.from(new Set(values.filter(Boolean))); +} + +function buildStyleKey(item = {}, rules) { + const keywords = styleKeywords(item, rules).slice(0, 3); + return [item.track || "未分类", ...keywords].join("|").toLowerCase(); +} + +function styleKeywords(item = {}, rules) { + const activeRules = normalizeRules(rules); + const text = `${item.title || ""} ${item.notes || ""}`.toLowerCase(); + const keywords = []; + for (const track of activeRules.tracks) { + for (const word of track.keywords) { + if (text.includes(word.toLowerCase())) keywords.push(word); + } + } + const colorWords = ["绿松石", "珍珠", "银饰", "编绳", "叠戴", "民族风", "藏式", "手机链", "耳环", "手链"]; + for (const word of colorWords) { + if (text.includes(word.toLowerCase())) keywords.push(word); + } + return unique(keywords).slice(0, 5); +} + +function matchesAnyTrackKeyword(text, rules) { + return normalizeRules(rules).tracks.some((track) => track.keywords.some((keyword) => text.includes(keyword.toLowerCase()))); +} + +function cloneRules(rules) { + return { + tracks: (rules.tracks || []).map((track) => ({ name: track.name, keywords: [...(track.keywords || [])] })), + riskKeywords: [...(rules.riskKeywords || [])], + profitModel: { ...(rules.profitModel || {}) }, + captureHints: [...(rules.captureHints || [])], + autoTags: [...(rules.autoTags || [])], + }; +} + +function styleGroupTitle(item = {}) { + const keywords = styleKeywords(item).slice(0, 3); + return `${item.track || "未分类"} · ${keywords.join("/") || cleanText(item.title || "相似款")}`; +} + +function compareSupply(a, b) { + const aPrice = a.price ?? Number.MAX_SAFE_INTEGER; + const bPrice = b.price ?? Number.MAX_SAFE_INTEGER; + return b.score - a.score || aPrice - bPrice; +} + +function groupOpportunityScore(items, bestSupply) { + const scoreAvg = items.reduce((sum, item) => sum + item.score, 0) / Math.max(1, items.length); + const supplyBonus = bestSupply?.platform === "1688" ? 10 : 0; + const repeatBonus = Math.min(12, items.length * 4); + return Math.round(scoreAvg + supplyBonus + repeatBonus); +} + +function minNumber(values = []) { + const numbers = values.filter((value) => typeof value === "number"); + return numbers.length ? Math.min(...numbers) : null; +} + +function maxNumber(values = []) { + const numbers = values.filter((value) => typeof value === "number"); + return numbers.length ? Math.max(...numbers) : null; +} + +function countBy(items, key) { + return items.reduce((result, item) => { + const value = item[key] || "未设置"; + result[value] = (result[value] || 0) + 1; + return result; + }, {}); +} + +function riskWeight(level) { + return { + high: 3, + medium: 2, + low: 1, + }[level] || 0; +} + +function audienceForTrack(track) { + return { + "藏式/民族风": "喜欢民族风、旅行感、叠戴感的用户", + 耳饰: "想快速提升穿搭精致感的用户", + 项链: "喜欢锁骨链、叠戴和显脖颈线条的用户", + "手链/手镯": "喜欢手部细节、叠戴和礼物感的用户", + 戒指: "喜欢小众细节但能接受圈口选择的用户", + 发饰: "喜欢快速换造型、拍照出片的用户", + "包挂/手机链": "喜欢低价小物、手机壳和包包搭配的用户", + }[track] || "喜欢小众饰品和直播间新款的用户"; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..9963e31 --- /dev/null +++ b/src/server.js @@ -0,0 +1,884 @@ +import { createReadStream } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { createServer as createHttpServer } from "node:http"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildDashboard, buildStyleGroups, toCsv } from "./core.js"; +import { JsonStore } from "./store.js"; +import { + OpenAICompatibleClient, + billableTokens, + buildAiMessages, + parseAiJson, + publicAiSettings, + userAiStatus, +} from "./ai.js"; +import { + createPluginToken, + createSessionToken, + hashPassword, + hashToken, + normalizeEmail, + publicUser, + verifyPassword, +} from "./auth.js"; + +const ROOT_DIR = resolve(fileURLToPath(new URL("..", import.meta.url))); +const DEFAULT_PUBLIC_DIR = join(ROOT_DIR, "public"); +const DEFAULT_PORT = Number(process.env.PORT || 4777); +const EXTENSION_DIR = join(ROOT_DIR, "extension"); +const EXTENSION_DOWNLOAD_PATH = "/downloads/product-sourcing-capture-extension.zip"; + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".ico": "image/x-icon", +}; + +export function createServer({ store = new JsonStore(), publicDir = DEFAULT_PUBLIC_DIR, aiClient = new OpenAICompatibleClient() } = {}) { + return createHttpServer(async (req, res) => { + setCorsHeaders(res); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + try { + const url = new URL(req.url || "/", "http://127.0.0.1"); + + if (url.pathname === "/api/health" && req.method === "GET") { + sendJson(res, 200, { + ok: true, + name: "product-sourcing-capture", + authRequired: (await store.userCount()) > 0, + extension: await extensionUpdateInfo(), + }); + return; + } + + if (url.pathname === EXTENSION_DOWNLOAD_PATH && (req.method === "GET" || req.method === "HEAD")) { + await sendExtensionPackage(req, res); + return; + } + + if (url.pathname.startsWith("/downloads/") && (req.method === "GET" || req.method === "HEAD")) { + sendJson(res, 404, { error: "Download not found" }); + return; + } + + if (url.pathname === "/api/auth/register" && req.method === "POST") { + const body = await readJson(req); + const email = normalizeEmail(body.email); + const password = String(body.password || ""); + const firstUser = (await store.userCount()) === 0; + if (!email || !email.includes("@")) { + sendJson(res, 400, { error: "Valid email is required" }); + return; + } + if (password.length < 6) { + sendJson(res, 400, { error: "Password must be at least 6 characters" }); + return; + } + const user = await store.createUser({ + email, + name: cleanName(body.name || email.split("@")[0]), + passwordHash: await hashPassword(password), + }); + if (firstUser) await store.claimLocalItems(user.id); + const token = createSessionToken(); + await store.createSession({ userId: user.id, tokenHash: hashToken(token) }); + sendJson(res, 201, { user: publicUser(user), token }); + return; + } + + if (url.pathname === "/api/auth/login" && req.method === "POST") { + const body = await readJson(req); + const user = await store.findUserByEmail(normalizeEmail(body.email)); + if (!user || !(await verifyPassword(String(body.password || ""), user.passwordHash))) { + sendJson(res, 401, { error: "Invalid email or password" }); + return; + } + if (user.status === "disabled") { + sendJson(res, 403, { error: "User is disabled" }); + return; + } + const token = createSessionToken(); + await store.createSession({ userId: user.id, tokenHash: hashToken(token) }); + sendJson(res, 200, { user: publicUser(user), token }); + return; + } + + if (url.pathname === "/api/me" && req.method === "GET") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + sendJson(res, 200, { user: publicUser(auth.user), authType: auth.type }); + return; + } + + if (url.pathname === "/api/rules" && req.method === "GET") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + sendJson(res, 200, { rules: await store.getRules(auth.user.id) }); + return; + } + + if (url.pathname === "/api/rules" && req.method === "PUT") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + sendJson(res, 200, { rules: await store.updateRules(auth.user.id, body.rules || body) }); + return; + } + + if (url.pathname === "/api/plugin-tokens" && req.method === "GET") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const pluginTokens = await store.listPluginTokens(auth.user.id); + sendJson(res, 200, { pluginTokens: pluginTokens.map(publicPluginToken) }); + return; + } + + if (url.pathname === "/api/plugin-tokens" && req.method === "POST") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + const token = createPluginToken(); + const pluginToken = await store.createPluginToken({ + userId: auth.user.id, + name: cleanName(body.name || "浏览器采集插件"), + token, + tokenHash: hashToken(token), + }); + sendJson(res, 201, { pluginToken: publicPluginToken(pluginToken), token }); + return; + } + + const pluginTokenMatch = url.pathname.match(/^\/api\/plugin-tokens\/([^/]+)$/); + if (pluginTokenMatch && req.method === "PATCH") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + const pluginToken = await store.updatePluginTokenStatus(decodeURIComponent(pluginTokenMatch[1]), body.status, { + userId: auth.user.id, + }); + sendJson(res, 200, { pluginToken: publicPluginToken(pluginToken) }); + return; + } + + if (pluginTokenMatch && req.method === "DELETE") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const result = await store.deletePluginToken(decodeURIComponent(pluginTokenMatch[1]), { userId: auth.user.id }); + sendJson(res, 200, result); + return; + } + + if (url.pathname === "/api/admin/users" && req.method === "GET") { + const auth = await requireAdmin(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const users = await store.listUsersWithStats(); + sendJson(res, 200, { users: users.map(publicAdminUser) }); + return; + } + + const adminUserStatusMatch = url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/status$/); + if (adminUserStatusMatch && req.method === "PATCH") { + const auth = await requireAdmin(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const userId = decodeURIComponent(adminUserStatusMatch[1]); + if (userId === auth.user.id) { + sendJson(res, 400, { error: "Admin cannot disable own account" }); + return; + } + const body = await readJson(req); + const user = await store.updateUserStatus(userId, body.status); + sendJson(res, 200, { user: publicAdminUser(user) }); + return; + } + + if (url.pathname === "/api/admin/credit-codes" && req.method === "GET") { + const auth = await requireAdmin(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const creditCodes = await store.listCreditCodes(); + sendJson(res, 200, { creditCodes: creditCodes.map(publicCreditCode) }); + return; + } + + if (url.pathname === "/api/admin/credit-codes" && req.method === "POST") { + const auth = await requireAdmin(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + const creditCode = await store.createCreditCode({ + credits: body.credits, + aiTokens: body.aiTokens, + note: cleanName(body.note || ""), + createdBy: auth.user.id, + }); + sendJson(res, 201, { creditCode: publicCreditCode(creditCode), code: creditCode.code }); + return; + } + + if (url.pathname === "/api/admin/ai-settings" && req.method === "GET") { + const auth = await requireAdmin(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const aiSettings = await store.getAiSettings({ includeSecret: true }); + sendJson(res, 200, { aiSettings: publicAiSettings(aiSettings) }); + return; + } + + if (url.pathname === "/api/admin/ai-settings" && req.method === "PUT") { + const auth = await requireAdmin(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + const aiSettings = await store.updateAiSettings(body); + sendJson(res, 200, { aiSettings: publicAiSettings(aiSettings) }); + return; + } + + if (url.pathname === "/api/admin/ai-settings/test" && req.method === "POST") { + const auth = await requireAdmin(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const settings = await store.getAiSettings({ includeSecret: true }); + const status = userAiStatus(settings); + if (!status.enabled) { + sendJson(res, 409, { error: "AI settings are incomplete", ai: status }); + return; + } + const result = await aiClient.chat({ + settings, + messages: [ + { role: "system", content: "你是系统连通性测试助手。只输出 JSON。" }, + { role: "user", content: '{"ping":"ok","expect":"pong"}' }, + ], + }); + sendJson(res, 200, { + ok: true, + ai: { provider: settings.provider, model: settings.model }, + usage: result.usage, + result: parseAiJson(result.text), + }); + return; + } + + if (url.pathname === "/api/credits/redeem" && req.method === "POST") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + const result = await store.redeemCreditCode(body.code, auth.user.id); + sendJson(res, 200, { user: publicUser(result.user), creditCode: publicCreditCode(result.creditCode) }); + return; + } + + if (url.pathname === "/api/ai/status" && req.method === "GET") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const settings = await store.getAiSettings({ includeSecret: true }); + sendJson(res, 200, { + ai: userAiStatus(settings), + aiTokens: Number(auth.user.aiTokens || 0), + }); + return; + } + + if (url.pathname === "/api/ai/analyze" && req.method === "POST") { + const auth = await requireAuth(req, store); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const settings = await store.getAiSettings({ includeSecret: true }); + const status = userAiStatus(settings); + if (!status.enabled) { + sendJson(res, 409, { error: "AI settings are incomplete", ai: status }); + return; + } + if (Number(auth.user.aiTokens || 0) <= 0) { + sendJson(res, 402, { error: "Insufficient AI tokens", aiTokens: { required: 1, remaining: 0 } }); + return; + } + const body = await readJson(req); + const rules = await store.getRules(auth.user.id); + const item = await aiItemFromRequest(body, store, auth.user.id); + const messages = buildAiMessages({ + type: body.type || "item_analysis", + item, + rules, + settings, + input: body.input || "", + }); + const result = await aiClient.chat({ settings, messages }); + const analysis = parseAiJson(result.text); + const deductedAiTokens = billableTokens(result.usage, settings.tokenUnitCost); + const billing = await store.deductAiTokens(auth.user.id, deductedAiTokens, body.type || "ai_analysis", { + itemId: item?.id || body.itemId || null, + model: settings.model, + provider: settings.provider, + usage: result.usage, + }); + sendJson(res, 200, { + analysis, + usage: result.usage, + billing: { + deductedAiTokens: billing.deducted, + remainingAiTokens: billing.remaining, + }, + ai: { + provider: settings.provider, + model: settings.model, + }, + }); + return; + } + + if (url.pathname === "/api/items" && req.method === "GET") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const filters = Object.fromEntries(url.searchParams.entries()); + const items = await store.list(filters, { userId: auth.userId }); + sendJson(res, 200, { items }); + return; + } + + if (url.pathname === "/api/dashboard" && req.method === "GET") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const rules = auth.userId ? await store.getRules(auth.userId) : undefined; + const items = await store.list({}, { userId: auth.userId }); + sendJson(res, 200, { dashboard: buildDashboard(items, rules) }); + return; + } + + if (url.pathname === "/api/style-groups" && req.method === "GET") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const rules = auth.userId ? await store.getRules(auth.userId) : undefined; + const items = await store.list({}, { userId: auth.userId }); + sendJson(res, 200, { groups: buildStyleGroups(items, rules) }); + return; + } + + if (url.pathname === "/api/sample-queue" && req.method === "GET") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const items = await store.list({}, { userId: auth.userId }); + const sampleStatuses = new Set(["asking_supplier", "ordered_sample", "live_testing"]); + sendJson(res, 200, { + items: items.filter((item) => sampleStatuses.has(item.status) || item.decision === "拿样"), + }); + return; + } + + if (url.pathname === "/api/captures" && req.method === "POST") { + const auth = await dataAuth(req, store, { allowPlugin: true }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + const rawItems = Array.isArray(body.items) ? body.items : body.item ? [body.item] : []; + if (rawItems.length === 0) { + sendJson(res, 400, { error: "No capture items provided" }); + return; + } + if (auth.pluginToken) { + const result = await store.insertManyWithCreditDeduction(rawItems, { + userId: auth.userId, + pluginTokenId: auth.pluginToken.id, + reason: "plugin_capture", + }); + sendJson(res, 201, result); + return; + } + const items = await store.insertMany(rawItems, { userId: auth.userId }); + sendJson(res, 201, { items, credits: null }); + return; + } + + if (url.pathname === "/api/items/bulk-delete" && req.method === "POST") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + if (!Array.isArray(body.ids)) { + sendJson(res, 400, { error: "ids must be an array" }); + return; + } + const result = await store.deleteMany(body.ids, { userId: auth.userId }); + sendJson(res, 200, result); + return; + } + + if (url.pathname === "/api/items/bulk-update" && req.method === "POST") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + if (!Array.isArray(body.ids)) { + sendJson(res, 400, { error: "ids must be an array" }); + return; + } + const result = await store.bulkUpdate({ + ids: body.ids, + patch: body.patch || {}, + addTags: Array.isArray(body.addTags) ? body.addTags : [], + removeTags: Array.isArray(body.removeTags) ? body.removeTags : [], + }, { userId: auth.userId }); + sendJson(res, 200, result); + return; + } + + const itemMatch = url.pathname.match(/^\/api\/items\/([^/]+)$/); + if (itemMatch && req.method === "PATCH") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const body = await readJson(req); + const item = await store.update(decodeURIComponent(itemMatch[1]), body, { userId: auth.userId }); + sendJson(res, 200, { item }); + return; + } + + if (itemMatch && req.method === "DELETE") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const result = await store.delete(decodeURIComponent(itemMatch[1]), { userId: auth.userId }); + sendJson(res, 200, result); + return; + } + + if (url.pathname === "/api/export.csv" && req.method === "GET") { + const auth = await dataAuth(req, store, { allowPlugin: false }); + if (!auth.ok) { + sendAuthError(res, auth); + return; + } + const filters = Object.fromEntries(url.searchParams.entries()); + const items = await store.list(filters, { userId: auth.userId }); + const csv = toCsv(items); + res.writeHead(200, { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="product-sourcing-captures.csv"', + }); + res.end(csv); + return; + } + + await serveStatic(req, res, publicDir, url.pathname); + } catch (error) { + const status = error.status || 500; + sendJson(res, status, { error: error.message || "Internal server error", credits: error.credits, aiTokens: error.aiTokens }); + } + }); +} + +async function aiItemFromRequest(body = {}, store, userId) { + if (body.itemId) { + const items = await store.list({}, { userId }); + const item = items.find((candidate) => candidate.id === body.itemId); + if (!item) { + const error = new Error("Item not found"); + error.status = 404; + throw error; + } + return item; + } + if (body.item && typeof body.item === "object" && !Array.isArray(body.item)) return body.item; + return {}; +} + +async function dataAuth(req, store, { allowPlugin } = {}) { + const hasUsers = (await store.userCount()) > 0; + if (!hasUsers) return { ok: true, userId: null, type: "local" }; + return requireAuth(req, store, { allowPlugin }); +} + +async function requireAuth(req, store, { allowPlugin = false } = {}) { + const token = bearerToken(req); + if (!token) return { ok: false, status: 401, message: "Authentication required" }; + const tokenHash = hashToken(token); + + if (token.startsWith("sess_")) { + const result = await store.findSessionByTokenHash(tokenHash); + if (result?.user?.status === "disabled") return { ok: false, status: 403, message: "User is disabled" }; + if (result?.user) return { ok: true, type: "session", user: result.user, userId: result.user.id }; + } + + if (token.startsWith("pst_")) { + const result = await store.findPluginTokenByHash(tokenHash); + if (result?.user?.status === "disabled") return { ok: false, status: 403, message: "User is disabled" }; + if (result?.pluginToken?.status === "disabled") return { ok: false, status: 403, message: "Plugin token is disabled" }; + if (result?.user && allowPlugin) { + return { + ok: true, + type: "plugin", + user: result.user, + userId: result.user.id, + pluginToken: result.pluginToken, + }; + } + if (result?.user) return { ok: false, status: 403, message: "Plugin token can only submit captures" }; + } + + return { ok: false, status: 401, message: "Invalid token" }; +} + +async function requireAdmin(req, store) { + const auth = await requireAuth(req, store); + if (!auth.ok) return auth; + if (auth.user.role !== "admin") return { ok: false, status: 403, message: "Admin access required" }; + return auth; +} + +function bearerToken(req) { + const header = req.headers.authorization || ""; + const match = String(header).match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : ""; +} + +function sendAuthError(res, auth) { + sendJson(res, auth.status || 401, { error: auth.message || "Authentication required" }); +} + +function publicPluginToken(pluginToken = {}) { + return { + id: pluginToken.id, + name: pluginToken.name, + token: pluginToken.token, + status: pluginToken.status || "active", + usageCount: Number(pluginToken.usageCount || 0), + createdAt: pluginToken.createdAt, + updatedAt: pluginToken.updatedAt, + lastUsedAt: pluginToken.lastUsedAt || null, + }; +} + +function publicAdminUser(user = {}) { + return { + ...publicUser(user), + itemCount: Number(user.itemCount || 0), + pluginTokenCount: Number(user.pluginTokenCount || 0), + activePluginTokenCount: Number(user.activePluginTokenCount || 0), + }; +} + +function publicCreditCode(creditCode = {}) { + return { + id: creditCode.id, + code: creditCode.code, + credits: Number(creditCode.credits || 0), + aiTokens: Number(creditCode.aiTokens || 0), + note: creditCode.note || "", + status: creditCode.status || "active", + createdBy: creditCode.createdBy || null, + redeemedBy: creditCode.redeemedBy || null, + redeemedAt: creditCode.redeemedAt || null, + createdAt: creditCode.createdAt, + updatedAt: creditCode.updatedAt, + }; +} + +function cleanName(name = "") { + return String(name).replace(/\s+/g, " ").trim().slice(0, 80); +} + +async function serveStatic(req, res, publicDir, pathname) { + const safePath = pathname === "/" ? "/index.html" : pathname; + const filePath = normalize(join(publicDir, safePath)); + const publicRoot = resolve(publicDir); + + if (!resolve(filePath).startsWith(publicRoot)) { + sendJson(res, 403, { error: "Forbidden" }); + return; + } + + try { + const info = await stat(filePath); + if (!info.isFile()) throw new Error("Not a file"); + const ext = extname(filePath); + if (ext === ".html") { + const html = await readFile(filePath, "utf8"); + res.writeHead(200, { "Content-Type": MIME_TYPES[ext] }); + res.end(withShareOrigin(html, req)); + return; + } + res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream" }); + createReadStream(filePath).pipe(res); + } catch { + const fallback = join(publicDir, "index.html"); + try { + await stat(fallback); + res.writeHead(200, { "Content-Type": MIME_TYPES[".html"] }); + res.end(withShareOrigin(await readFile(fallback, "utf8"), req)); + } catch { + sendJson(res, 404, { error: "Not found" }); + } + } +} + +async function sendExtensionPackage(req, res) { + const manifest = await readExtensionManifest(); + const buffer = await buildZipFromDirectory(EXTENSION_DIR); + const fileName = extensionPackageFileName(manifest.version); + res.writeHead(200, { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${fileName}"`, + "Content-Length": buffer.length, + "Cache-Control": "no-cache", + }); + if (req.method === "HEAD") { + res.end(); + return; + } + res.end(buffer); +} + +function withShareOrigin(html = "", req = {}) { + if (!html.includes("__SHARE_ORIGIN__")) return html; + const proto = String(req.headers?.["x-forwarded-proto"] || "").split(",")[0].trim() || (req.socket?.encrypted ? "https" : "http"); + const host = String(req.headers?.["x-forwarded-host"] || req.headers?.host || "127.0.0.1").split(",")[0].trim(); + return html.replaceAll("__SHARE_ORIGIN__", `${proto}://${host}`); +} + +async function readJson(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + if (chunks.length === 0) return {}; + const raw = Buffer.concat(chunks).toString("utf8"); + try { + return JSON.parse(raw); + } catch { + const error = new Error("Invalid JSON body"); + error.status = 400; + throw error; + } +} + +function sendJson(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +function setCorsHeaders(res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.setHeader("X-Content-Type-Options", "nosniff"); +} + +async function extensionUpdateInfo() { + try { + const manifest = await readExtensionManifest(); + return { + name: manifest.name || "直播饰品选品采集器", + recommendedVersion: manifest.version || "0.0.0", + installUrl: EXTENSION_DOWNLOAD_PATH, + fileName: extensionPackageFileName(manifest.version), + reloadRequiredForUnpacked: true, + updateNote: "修复 Token 保存需要 storage 权限,新增积分扣费、兑换码和 Token 复制能力。", + }; + } catch { + return { + name: "直播饰品选品采集器", + recommendedVersion: "0.0.0", + installUrl: EXTENSION_DOWNLOAD_PATH, + fileName: extensionPackageFileName("0.0.0"), + reloadRequiredForUnpacked: true, + updateNote: "扩展信息暂不可用。", + }; + } +} + +async function readExtensionManifest() { + return JSON.parse(await readFile(join(EXTENSION_DIR, "manifest.json"), "utf8")); +} + +function extensionPackageFileName(version = "0.0.0") { + return `product-sourcing-capture-extension-${version || "0.0.0"}.zip`; +} + +async function buildZipFromDirectory(rootDir) { + const files = await listPackageFiles(rootDir); + const chunks = []; + const centralDirectory = []; + let offset = 0; + + for (const file of files) { + const data = await readFile(file.absolutePath); + const nameBuffer = Buffer.from(file.relativePath, "utf8"); + const crc = crc32(data); + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0x0800, 6); + localHeader.writeUInt16LE(0, 8); + localHeader.writeUInt16LE(0, 10); + localHeader.writeUInt16LE(0, 12); + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(data.length, 18); + localHeader.writeUInt32LE(data.length, 22); + localHeader.writeUInt16LE(nameBuffer.length, 26); + localHeader.writeUInt16LE(0, 28); + chunks.push(localHeader, nameBuffer, data); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0x0800, 8); + centralHeader.writeUInt16LE(0, 10); + centralHeader.writeUInt16LE(0, 12); + centralHeader.writeUInt16LE(0, 14); + centralHeader.writeUInt32LE(crc, 16); + centralHeader.writeUInt32LE(data.length, 20); + centralHeader.writeUInt32LE(data.length, 24); + centralHeader.writeUInt16LE(nameBuffer.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(offset, 42); + centralDirectory.push(centralHeader, nameBuffer); + + offset += localHeader.length + nameBuffer.length + data.length; + } + + const centralDirectorySize = centralDirectory.reduce((sum, chunk) => sum + chunk.length, 0); + const endRecord = Buffer.alloc(22); + endRecord.writeUInt32LE(0x06054b50, 0); + endRecord.writeUInt16LE(0, 4); + endRecord.writeUInt16LE(0, 6); + endRecord.writeUInt16LE(files.length, 8); + endRecord.writeUInt16LE(files.length, 10); + endRecord.writeUInt32LE(centralDirectorySize, 12); + endRecord.writeUInt32LE(offset, 16); + endRecord.writeUInt16LE(0, 20); + + return Buffer.concat([...chunks, ...centralDirectory, endRecord]); +} + +async function listPackageFiles(rootDir, relativeDir = "") { + const dir = join(rootDir, relativeDir); + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listPackageFiles(rootDir, relativePath))); + } else if (entry.isFile()) { + files.push({ relativePath, absolutePath: join(rootDir, relativePath) }); + } + } + return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +const CRC32_TABLE = new Uint32Array(256).map((_, index) => { + let crc = index; + for (let bit = 0; bit < 8; bit += 1) { + crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + return crc >>> 0; +}); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await readFile(join(DEFAULT_PUBLIC_DIR, "index.html")); + } catch { + // The API still works without the dashboard, but the warning helps during development. + console.warn(`Dashboard files not found in ${DEFAULT_PUBLIC_DIR}`); + } + + const dataFile = process.env.DATA_FILE || join(ROOT_DIR, "data", "items.json"); + const server = createServer({ store: new JsonStore(dataFile) }); + server.listen(DEFAULT_PORT, () => { + console.log(`Product sourcing capture running at http://127.0.0.1:${DEFAULT_PORT}`); + console.log(`Extension directory: ${EXTENSION_DIR}`); + }); +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..83944df --- /dev/null +++ b/src/store.js @@ -0,0 +1,724 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { DEFAULT_AI_SETTINGS, normalizeAiSettings, publicAiSettings } from "./ai.js"; +import { DEFAULT_RULES, normalizeRules, scoreCapture } from "./core.js"; + +export class JsonStore { + constructor(filePath = new URL("../data/items.json", import.meta.url).pathname) { + this.filePath = filePath; + } + + async list(filters = {}, options = {}) { + const data = await this.#readData(); + const rules = await this.getRules(options.userId); + const items = this.#scopeItems(data.items, options.userId); + return items + .map((item) => scoreCapture(item, rules)) + .filter((item) => matchesFilters(item, filters)) + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); + } + + async insertMany(rawItems = [], options = {}) { + const data = await this.#readData(); + const rules = this.#rulesForData(data, options.userId); + const inserted = this.#upsertCaptures(data, rawItems, options, rules); + await this.#writeData(data); + return inserted; + } + + async insertManyWithCreditDeduction(rawItems = [], options = {}) { + const data = await this.#readData(); + const rules = this.#rulesForData(data, options.userId); + const inserted = this.#upsertCaptures(data, rawItems, { userId: options.userId }, rules); + const quantity = inserted.length; + const userIndex = data.users.findIndex((user) => user.id === options.userId); + if (userIndex === -1) { + const error = new Error("User not found"); + error.status = 404; + throw error; + } + if (!Number.isInteger(quantity) || quantity <= 0) { + const error = new Error("No capture items provided"); + error.status = 400; + throw error; + } + + const current = Number(data.users[userIndex].credits || 0); + if (current < quantity) { + const error = new Error("Insufficient credits"); + error.status = 402; + error.credits = { required: quantity, remaining: current }; + throw error; + } + + const now = new Date().toISOString(); + data.users[userIndex] = { + ...data.users[userIndex], + credits: current - quantity, + updatedAt: now, + }; + data.creditLogs.push({ + id: createId("clg"), + userId: options.userId, + amount: -quantity, + reason: options.reason || "plugin_capture", + balance: data.users[userIndex].credits, + createdAt: now, + }); + + if (options.pluginTokenId) { + const tokenIndex = data.pluginTokens.findIndex( + (token) => token.id === options.pluginTokenId && token.userId === options.userId, + ); + if (tokenIndex >= 0) { + data.pluginTokens[tokenIndex] = { + ...data.pluginTokens[tokenIndex], + usageCount: Number(data.pluginTokens[tokenIndex].usageCount || 0) + 1, + lastUsedAt: now, + }; + } + } + + await this.#writeData(data); + return { + items: inserted, + credits: { deducted: quantity, remaining: data.users[userIndex].credits }, + }; + } + + async update(id, patch = {}, options = {}) { + const data = await this.#readData(); + const rules = await this.getRules(options.userId); + const index = data.items.findIndex((item) => item.id === id && this.#matchesScope(item, options.userId)); + if (index === -1) { + const error = new Error("Item not found"); + error.status = 404; + throw error; + } + + const merged = { + ...data.items[index], + ...patch, + id, + userId: data.items[index].userId, + updatedAt: new Date().toISOString(), + }; + data.items[index] = scoreCapture(merged, rules); + data.items[index].status = patch.status || merged.status; + data.items[index].notes = patch.notes ?? merged.notes; + + await this.#writeData(data); + return data.items[index]; + } + + async delete(id, options = {}) { + const data = await this.#readData(); + const next = data.items.filter((item) => !(item.id === id && this.#matchesScope(item, options.userId))); + const deleted = data.items.length - next.length; + data.items = next; + await this.#writeData(data); + return { deleted }; + } + + async deleteMany(ids = [], options = {}) { + const idSet = new Set(ids.map(String).filter(Boolean)); + const data = await this.#readData(); + const items = this.#scopeItems(data.items, options.userId); + const existingIds = new Set(items.map((item) => item.id)); + const next = data.items.filter((item) => !(idSet.has(item.id) && this.#matchesScope(item, options.userId))); + const missing = Array.from(idSet).filter((id) => !existingIds.has(id)); + + const deleted = data.items.length - next.length; + data.items = next; + await this.#writeData(data); + return { + deleted, + missing, + }; + } + + async bulkUpdate({ ids = [], patch = {}, addTags = [], removeTags = [] } = {}, options = {}) { + const idSet = new Set(ids.map(String).filter(Boolean)); + const addTagSet = new Set(addTags.map(String).map((tag) => tag.trim()).filter(Boolean)); + const removeTagSet = new Set(removeTags.map(String).map((tag) => tag.trim()).filter(Boolean)); + const data = await this.#readData(); + const rules = await this.getRules(options.userId); + const scopedItems = this.#scopeItems(data.items, options.userId); + let updated = 0; + + const next = data.items.map((item) => { + if (!idSet.has(item.id) || !this.#matchesScope(item, options.userId)) return item; + updated += 1; + const tags = new Set([...(item.tags || []), ...addTagSet]); + for (const tag of removeTagSet) tags.delete(tag); + const merged = { + ...item, + ...patch, + id: item.id, + tags: Array.from(tags), + updatedAt: new Date().toISOString(), + }; + const scored = scoreCapture(merged, rules); + scored.status = patch.status || merged.status; + scored.notes = patch.notes ?? merged.notes; + scored.tags = merged.tags; + return scored; + }); + + data.items = next; + await this.#writeData(data); + return { + updated, + missing: Array.from(idSet).filter((id) => !scopedItems.some((item) => item.id === id)), + }; + } + + async userCount() { + const data = await this.#readData(); + return data.users.length; + } + + async createUser(user) { + const data = await this.#readData(); + if (data.users.some((candidate) => candidate.email === user.email)) { + const error = new Error("Email already registered"); + error.status = 409; + throw error; + } + const now = new Date().toISOString(); + const isFirstUser = data.users.length === 0; + const created = { + id: user.id || createId("usr"), + email: user.email, + name: user.name || "", + role: user.role || (isFirstUser ? "admin" : "buyer"), + status: user.status || "active", + credits: Number(user.credits || 0), + aiTokens: Number(user.aiTokens || 0), + passwordHash: user.passwordHash, + createdAt: now, + updatedAt: now, + }; + data.users.push(created); + data.rules[created.id] = normalizeRules(user.rules || DEFAULT_RULES); + await this.#writeData(data); + return created; + } + + async findUserByEmail(email) { + const data = await this.#readData(); + return data.users.find((user) => user.email === email) || null; + } + + async findUserById(id) { + const data = await this.#readData(); + return data.users.find((user) => user.id === id) || null; + } + + async listUsersWithStats() { + const data = await this.#readData(); + return data.users.map((user) => ({ + ...user, + itemCount: data.items.filter((item) => item.userId === user.id).length, + pluginTokenCount: data.pluginTokens.filter((token) => token.userId === user.id).length, + activePluginTokenCount: data.pluginTokens.filter((token) => token.userId === user.id && token.status !== "disabled").length, + })); + } + + async updateUserStatus(id, status) { + if (!["active", "disabled"].includes(status)) { + const error = new Error("Invalid user status"); + error.status = 400; + throw error; + } + const data = await this.#readData(); + const index = data.users.findIndex((user) => user.id === id); + if (index === -1) { + const error = new Error("User not found"); + error.status = 404; + throw error; + } + data.users[index] = { + ...data.users[index], + status, + updatedAt: new Date().toISOString(), + }; + await this.#writeData(data); + return data.users[index]; + } + + async createCreditCode({ credits = 0, aiTokens = 0, note = "", createdBy }) { + const amount = Number(credits || 0); + const aiAmount = Number(aiTokens || 0); + if (!Number.isInteger(amount) || amount < 0 || !Number.isInteger(aiAmount) || aiAmount < 0 || amount + aiAmount <= 0) { + const error = new Error("Credits or AI tokens must be a positive integer"); + error.status = 400; + throw error; + } + const data = await this.#readData(); + const now = new Date().toISOString(); + const creditCode = { + id: createId("ccd"), + code: createCreditCodeValue(), + credits: amount, + aiTokens: aiAmount, + note: String(note || "").trim().slice(0, 120), + status: "active", + createdBy, + redeemedBy: null, + redeemedAt: null, + createdAt: now, + updatedAt: now, + }; + data.creditCodes.push(creditCode); + await this.#writeData(data); + return creditCode; + } + + async listCreditCodes() { + const data = await this.#readData(); + return data.creditCodes + .map((code) => ({ ...code })) + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); + } + + async redeemCreditCode(codeValue, userId) { + const normalizedCode = normalizeCreditCode(codeValue); + if (!normalizedCode) { + const error = new Error("Credit code is required"); + error.status = 400; + throw error; + } + const data = await this.#readData(); + const codeIndex = data.creditCodes.findIndex((code) => normalizeCreditCode(code.code) === normalizedCode); + if (codeIndex === -1) { + const error = new Error("Credit code not found"); + error.status = 404; + throw error; + } + if (data.creditCodes[codeIndex].status !== "active") { + const error = new Error("Credit code has already been redeemed"); + error.status = 409; + throw error; + } + const userIndex = data.users.findIndex((user) => user.id === userId); + if (userIndex === -1) { + const error = new Error("User not found"); + error.status = 404; + throw error; + } + const now = new Date().toISOString(); + data.users[userIndex] = { + ...data.users[userIndex], + credits: Number(data.users[userIndex].credits || 0) + Number(data.creditCodes[codeIndex].credits || 0), + aiTokens: Number(data.users[userIndex].aiTokens || 0) + Number(data.creditCodes[codeIndex].aiTokens || 0), + updatedAt: now, + }; + data.creditCodes[codeIndex] = { + ...data.creditCodes[codeIndex], + status: "redeemed", + redeemedBy: userId, + redeemedAt: now, + updatedAt: now, + }; + await this.#writeData(data); + return { + user: data.users[userIndex], + creditCode: data.creditCodes[codeIndex], + }; + } + + async deductCredits(userId, amount, reason = "") { + const quantity = Number(amount); + if (!Number.isInteger(quantity) || quantity <= 0) { + const error = new Error("Credit deduction amount must be a positive integer"); + error.status = 400; + throw error; + } + const data = await this.#readData(); + const index = data.users.findIndex((user) => user.id === userId); + if (index === -1) { + const error = new Error("User not found"); + error.status = 404; + throw error; + } + const current = Number(data.users[index].credits || 0); + if (current < quantity) { + const error = new Error("Insufficient credits"); + error.status = 402; + error.credits = { required: quantity, remaining: current }; + throw error; + } + data.users[index] = { + ...data.users[index], + credits: current - quantity, + updatedAt: new Date().toISOString(), + }; + data.creditLogs.push({ + id: createId("clg"), + userId, + amount: -quantity, + reason: reason || "capture", + balance: data.users[index].credits, + createdAt: data.users[index].updatedAt, + }); + await this.#writeData(data); + return { deducted: quantity, remaining: data.users[index].credits }; + } + + async getAiSettings({ includeSecret = false } = {}) { + const data = await this.#readData(); + const settings = normalizeAiSettings(data.aiSettings || DEFAULT_AI_SETTINGS, data.aiSettings || DEFAULT_AI_SETTINGS); + if (includeSecret) return settings; + return publicAiSettings(settings); + } + + async updateAiSettings(settings = {}) { + const data = await this.#readData(); + data.aiSettings = normalizeAiSettings(settings, data.aiSettings || DEFAULT_AI_SETTINGS); + await this.#writeData(data); + return data.aiSettings; + } + + async deductAiTokens(userId, amount, reason = "", meta = {}) { + const quantity = Number(amount); + if (!Number.isInteger(quantity) || quantity <= 0) { + const error = new Error("AI token deduction amount must be a positive integer"); + error.status = 400; + throw error; + } + const data = await this.#readData(); + const index = data.users.findIndex((user) => user.id === userId); + if (index === -1) { + const error = new Error("User not found"); + error.status = 404; + throw error; + } + const current = Number(data.users[index].aiTokens || 0); + if (current < quantity) { + const error = new Error("Insufficient AI tokens"); + error.status = 402; + error.aiTokens = { required: quantity, remaining: current }; + throw error; + } + const now = new Date().toISOString(); + data.users[index] = { + ...data.users[index], + aiTokens: current - quantity, + updatedAt: now, + }; + data.aiTokenLogs.push({ + id: createId("alg"), + userId, + amount: -quantity, + reason: reason || "ai_analysis", + balance: data.users[index].aiTokens, + meta: sanitizeLogMeta(meta), + createdAt: now, + }); + await this.#writeData(data); + return { deducted: quantity, remaining: data.users[index].aiTokens }; + } + + async createSession({ userId, tokenHash }) { + const data = await this.#readData(); + const now = new Date().toISOString(); + const session = { + id: createId("ses"), + userId, + tokenHash, + createdAt: now, + lastUsedAt: now, + }; + data.sessions.push(session); + await this.#writeData(data); + return session; + } + + async findSessionByTokenHash(tokenHash) { + const data = await this.#readData(); + const session = data.sessions.find((candidate) => candidate.tokenHash === tokenHash) || null; + if (!session) return null; + return { + session, + user: data.users.find((user) => user.id === session.userId) || null, + }; + } + + async createPluginToken({ userId, name, token, tokenHash }) { + const data = await this.#readData(); + const now = new Date().toISOString(); + const pluginToken = { + id: createId("ptk"), + userId, + name: name || "浏览器采集插件", + token, + tokenHash, + status: "active", + usageCount: 0, + createdAt: now, + lastUsedAt: null, + }; + data.pluginTokens.push(pluginToken); + await this.#writeData(data); + return pluginToken; + } + + async listPluginTokens(userId) { + const data = await this.#readData(); + return data.pluginTokens.filter((token) => token.userId === userId); + } + + async findPluginTokenByHash(tokenHash) { + const data = await this.#readData(); + const pluginToken = data.pluginTokens.find((candidate) => candidate.tokenHash === tokenHash) || null; + if (!pluginToken) return null; + return { + pluginToken, + user: data.users.find((user) => user.id === pluginToken.userId) || null, + }; + } + + async touchPluginToken(id) { + const data = await this.#readData(); + const index = data.pluginTokens.findIndex((token) => token.id === id); + if (index === -1) return null; + data.pluginTokens[index] = { + ...data.pluginTokens[index], + usageCount: Number(data.pluginTokens[index].usageCount || 0) + 1, + lastUsedAt: new Date().toISOString(), + }; + await this.#writeData(data); + return data.pluginTokens[index]; + } + + async updatePluginTokenStatus(id, status, options = {}) { + if (!["active", "disabled"].includes(status)) { + const error = new Error("Invalid plugin token status"); + error.status = 400; + throw error; + } + const data = await this.#readData(); + const index = data.pluginTokens.findIndex((token) => token.id === id && token.userId === options.userId); + if (index === -1) { + const error = new Error("Plugin token not found"); + error.status = 404; + throw error; + } + data.pluginTokens[index] = { + ...data.pluginTokens[index], + status, + updatedAt: new Date().toISOString(), + }; + await this.#writeData(data); + return data.pluginTokens[index]; + } + + async deletePluginToken(id, options = {}) { + const data = await this.#readData(); + const next = data.pluginTokens.filter((token) => !(token.id === id && token.userId === options.userId)); + const deleted = data.pluginTokens.length - next.length; + data.pluginTokens = next; + await this.#writeData(data); + return { deleted }; + } + + async getRules(userId) { + const data = await this.#readData(); + if (!userId) return normalizeRules(DEFAULT_RULES); + return normalizeRules(data.rules[userId] || DEFAULT_RULES); + } + + async updateRules(userId, rules = {}) { + const data = await this.#readData(); + const current = normalizeRules(data.rules[userId] || DEFAULT_RULES); + const next = normalizeRules({ + ...current, + ...rules, + profitModel: { + ...current.profitModel, + ...(rules.profitModel || {}), + }, + }); + data.rules[userId] = next; + await this.#writeData(data); + return next; + } + + async claimLocalItems(userId) { + const data = await this.#readData(); + let claimed = 0; + data.items = data.items.map((item) => { + if (item.userId) return item; + claimed += 1; + return { + ...item, + userId, + updatedAt: new Date().toISOString(), + }; + }); + await this.#writeData(data); + return { claimed }; + } + + async #readData() { + try { + const content = await readFile(this.filePath, "utf8"); + const parsed = JSON.parse(content); + return normalizeData(parsed); + } catch (error) { + if (error.code === "ENOENT") return normalizeData({}); + throw error; + } + } + + async #writeData(data) { + await mkdir(dirname(this.filePath), { recursive: true }); + const tmp = `${this.filePath}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + await rename(tmp, this.filePath); + } + + #scopeItems(items = [], userId) { + return userId ? items.filter((item) => item.userId === userId) : items.filter((item) => !item.userId); + } + + #matchesScope(item = {}, userId) { + return userId ? item.userId === userId : !item.userId; + } + + #rulesForData(data, userId) { + if (!userId) return normalizeRules(DEFAULT_RULES); + return normalizeRules(data.rules[userId] || DEFAULT_RULES); + } + + #upsertCaptures(data, rawItems = [], options = {}, rules) { + const affectedIndexes = new Set(); + + for (const raw of rawItems) { + const scored = scoreCapture({ ...raw, userId: options.userId || raw.userId }, rules); + const existingIndex = scored.url + ? data.items.findIndex((item) => item.url === scored.url && this.#matchesScope(item, options.userId)) + : -1; + if (existingIndex >= 0) { + data.items[existingIndex] = { + ...data.items[existingIndex], + ...scored, + id: data.items[existingIndex].id, + userId: data.items[existingIndex].userId, + createdAt: data.items[existingIndex].createdAt, + updatedAt: new Date().toISOString(), + }; + affectedIndexes.add(existingIndex); + } else { + data.items.push(scored); + affectedIndexes.add(data.items.length - 1); + } + } + + return Array.from(affectedIndexes).map((index) => data.items[index]); + } +} + +function normalizeData(parsed = {}) { + const users = Array.isArray(parsed.users) + ? parsed.users.map((user, index) => ({ + ...user, + role: user.role || (index === 0 ? "admin" : "buyer"), + status: user.status || "active", + credits: Number(user.credits || 0), + aiTokens: Number(user.aiTokens || 0), + })) + : []; + const pluginTokens = Array.isArray(parsed.pluginTokens) + ? parsed.pluginTokens.map((token) => ({ + ...token, + status: token.status || "active", + usageCount: Number(token.usageCount || 0), + lastUsedAt: token.lastUsedAt || null, + })) + : []; + return { + items: Array.isArray(parsed.items) ? parsed.items : [], + users, + sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [], + pluginTokens, + creditCodes: Array.isArray(parsed.creditCodes) + ? parsed.creditCodes.map((code) => ({ + ...code, + credits: Number(code.credits || 0), + aiTokens: Number(code.aiTokens || 0), + })) + : [], + creditLogs: Array.isArray(parsed.creditLogs) ? parsed.creditLogs : [], + aiTokenLogs: Array.isArray(parsed.aiTokenLogs) ? parsed.aiTokenLogs : [], + aiSettings: normalizeAiSettings(parsed.aiSettings || DEFAULT_AI_SETTINGS, parsed.aiSettings || DEFAULT_AI_SETTINGS), + rules: parsed.rules && typeof parsed.rules === "object" && !Array.isArray(parsed.rules) ? parsed.rules : {}, + }; +} + +function sanitizeLogMeta(meta = {}) { + if (!meta || typeof meta !== "object" || Array.isArray(meta)) return {}; + const safe = {}; + for (const [key, value] of Object.entries(meta)) { + if (value === undefined || value === null) continue; + try { + safe[String(key).slice(0, 60)] = typeof value === "object" ? JSON.parse(JSON.stringify(value)) : value; + } catch { + safe[String(key).slice(0, 60)] = String(value); + } + } + return safe; +} + +function createId(prefix) { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +} + +function createCreditCodeValue() { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let raw = ""; + for (let i = 0; i < 12; i += 1) raw += alphabet[Math.floor(Math.random() * alphabet.length)]; + return `CDX-${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; +} + +function normalizeCreditCode(value = "") { + return String(value).trim().toUpperCase(); +} + +function matchesFilters(item, filters = {}) { + if (filters.platform && item.platform !== filters.platform) return false; + if (filters.track && item.track !== filters.track) return false; + if (filters.decision && item.decision !== filters.decision) return false; + if (filters.status && item.status !== filters.status) return false; + if (filters.tag) { + const queryTag = String(filters.tag).toLowerCase(); + const tags = [...(item.tags || []), ...(item.autoTags || [])].map((tag) => String(tag).toLowerCase()); + if (!tags.some((tag) => tag.includes(queryTag))) return false; + } + if (filters.priceBand && !matchesPriceBand(item.price, filters.priceBand)) return false; + if (filters.source === "only1688" && item.platform !== "1688") return false; + if (filters.source === "non1688" && item.platform === "1688") return false; + if (filters.risk === "any" && item.riskLevel === "low") return false; + if (filters.risk === "high" && item.riskLevel !== "high") return false; + if (filters.risk === "low" && item.riskLevel !== "low") return false; + if (filters.q) { + const query = String(filters.q).toLowerCase(); + const text = [item.title, item.supplier, item.shop, item.track, item.url, item.notes, ...(item.tags || []), ...(item.autoTags || [])] + .join(" ") + .toLowerCase(); + if (!text.includes(query)) return false; + } + return true; +} + +function matchesPriceBand(price, priceBand) { + if (priceBand === "unknown") return price == null; + if (price == null) return false; + if (priceBand === "under10") return price <= 10; + if (priceBand === "10to30") return price > 10 && price <= 30; + if (priceBand === "30to100") return price > 30 && price <= 100; + if (priceBand === "over100") return price > 100; + return true; +} diff --git a/tests/core.test.js b/tests/core.test.js new file mode 100644 index 0000000..a2715d6 --- /dev/null +++ b/tests/core.test.js @@ -0,0 +1,303 @@ +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { test } from "node:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + buildDashboard, + buildLivePitch, + buildStyleGroups, + calculateProfit, + detectPlatform, + extractPrice, + inferJewelryTrack, + scoreCapture, + toCsv, +} from "../src/core.js"; +import { JsonStore } from "../src/store.js"; + +test("detectPlatform recognizes target marketplaces", () => { + assert.equal(detectPlatform("https://www.taobao.com/list?q=耳环"), "taobao"); + assert.equal(detectPlatform("https://detail.tmall.com/item.htm?id=1"), "tmall"); + assert.equal(detectPlatform("https://www.xiaohongshu.com/search_result?keyword=藏式手链"), "xiaohongshu"); + assert.equal(detectPlatform("https://detail.1688.com/offer/123.html"), "1688"); + assert.equal(detectPlatform("https://example.com/item"), "other"); +}); + +test("extractPrice parses Chinese ecommerce price strings", () => { + assert.equal(extractPrice("¥39.90 起"), 39.9); + assert.equal(extractPrice("¥ 12 - 18"), 12); + assert.equal(extractPrice("批发价 3.50 元"), 3.5); + assert.equal(extractPrice("暂无报价"), null); +}); + +test("inferJewelryTrack classifies jewelry categories and Tibetan style", () => { + assert.equal(inferJewelryTrack("藏式编绳手链 民族风 绿松石色"), "藏式/民族风"); + assert.equal(inferJewelryTrack("法式珍珠耳环耳夹"), "耳饰"); + assert.equal(inferJewelryTrack("复古发簪抓夹"), "发饰"); + assert.equal(inferJewelryTrack("手机链包挂钥匙扣"), "包挂/手机链"); + assert.equal(inferJewelryTrack("小众戒指开口戒"), "戒指"); +}); + +test("scoreCapture gives actionable sourcing decisions", () => { + const strong = scoreCapture({ + platform: "1688", + title: "藏式编绳手链 民族风 绿松石色 直播爆款", + price: 8, + image: "https://img.example/a.jpg", + supplier: "义乌源头饰品厂", + metrics: { sales: "1000+人付款", comments: "求链接 问材质" }, + }); + + assert.equal(strong.track, "藏式/民族风"); + assert.equal(strong.decision, "拿样"); + assert.ok(strong.score >= 75); + assert.ok(strong.reasons.some((reason) => reason.includes("直播"))); + + const risky = scoreCapture({ + platform: "taobao", + title: "天然绿松石纯银开光转运手串", + price: 188, + image: "", + metrics: {}, + }); + + assert.equal(risky.decision, "暂不做"); + assert.ok(risky.risks.length >= 2); +}); + +test("calculateProfit estimates live selling margin for low-ticket jewelry", () => { + const profit = calculateProfit({ + costPrice: 8, + targetPrice: 39, + shippingFee: 4, + packagingFee: 1, + platformFeeRate: 0.05, + promoFee: 3, + }); + + assert.equal(profit.grossProfit, 21.05); + assert.equal(profit.grossMarginRate, 0.54); + assert.equal(profit.recommendation, "可跑量"); +}); + +test("buildLivePitch creates practical livestream talking points and warnings", () => { + const item = scoreCapture({ + platform: "1688", + title: "藏式绿松石色编绳手链 民族风 直播叠戴", + price: 7.8, + supplier: "义乌源头饰品厂", + metrics: { visibleText: "现货 混批" }, + }); + + const pitch = buildLivePitch(item); + + assert.equal(pitch.audience, "喜欢民族风、旅行感、叠戴感的用户"); + assert.ok(pitch.talkingPoints.some((point) => point.includes("藏式"))); + assert.ok(pitch.supplierQuestions.some((question) => question.includes("材质"))); +}); + +test("buildStyleGroups groups similar captured products across platforms", () => { + const items = [ + scoreCapture({ + platform: "taobao", + title: "藏式绿松石编绳手链 民族风", + url: "https://item.taobao.com/item.htm?id=style-a", + price: 39, + }), + scoreCapture({ + platform: "1688", + title: "民族风绿松石色编织手链 藏式", + url: "https://detail.1688.com/offer/style-b.html", + price: 6.8, + supplier: "义乌源头厂", + }), + scoreCapture({ + platform: "1688", + title: "珍珠耳钉小众耳饰", + url: "https://detail.1688.com/offer/style-c.html", + price: 3.5, + }), + ]; + + const groups = buildStyleGroups(items); + + assert.equal(groups.length, 2); + assert.equal(groups[0].items.length, 2); + assert.equal(groups[0].bestSupply.title, "民族风绿松石色编织手链 藏式"); + assert.ok(groups[0].suggestedRetailPrice >= 29); +}); + +test("buildDashboard summarizes sourcing work and next actions", () => { + const items = [ + scoreCapture({ platform: "1688", title: "藏式手机链", price: 4.2, status: "ordered_sample" }), + scoreCapture({ platform: "1688", title: "民族风手链", price: 8.8, status: "asking_supplier" }), + scoreCapture({ platform: "taobao", title: "珍珠耳环", price: 59, status: "new" }), + ]; + + const dashboard = buildDashboard(items); + + assert.equal(dashboard.totals.items, 3); + assert.equal(dashboard.totals.samplePipeline, 2); + assert.equal(dashboard.priceBands.under10, 2); + assert.ok(dashboard.nextActions.length > 0); +}); + +test("JsonStore inserts, updates, lists, deletes, and exports captures", async () => { + const dir = join(tmpdir(), `sourcing-store-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + + const inserted = await store.insertMany([ + { + platform: "1688", + title: "民族风手机链", + url: "https://detail.1688.com/offer/1.html", + image: "https://img.example/1.jpg", + price: 4.2, + }, + ]); + + assert.equal(inserted.length, 1); + assert.equal(inserted[0].decision, "拿样"); + + const updated = await store.update(inserted[0].id, { status: "ordered_sample" }); + assert.equal(updated.status, "ordered_sample"); + + const listed = await store.list({ platform: "1688" }); + assert.equal(listed.length, 1); + + const csv = toCsv(listed); + assert.match(csv, /民族风手机链/); + assert.match(csv, /ordered_sample/); + const [header, firstRow] = csv.trim().split("\n"); + const targetPriceIndex = header.split(",").indexOf("targetPrice"); + assert.notEqual(firstRow.split(",")[targetPriceIndex], ""); + + await store.delete(inserted[0].id); + assert.equal((await store.list()).length, 0); + + await rm(dir, { recursive: true, force: true }); +}); + +test("JsonStore deletes multiple selected captures at once", async () => { + const dir = join(tmpdir(), `sourcing-store-bulk-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + + const inserted = await store.insertMany([ + { + platform: "1688", + title: "误采集款 A", + url: "https://detail.1688.com/offer/bulk-a.html", + price: 5, + }, + { + platform: "1688", + title: "误采集款 B", + url: "https://detail.1688.com/offer/bulk-b.html", + price: 6, + }, + { + platform: "1688", + title: "保留款 C", + url: "https://detail.1688.com/offer/bulk-c.html", + price: 7, + }, + ]); + + const result = await store.deleteMany([inserted[0].id, inserted[1].id, "not-found"]); + + assert.equal(result.deleted, 2); + assert.deepEqual(result.missing, ["not-found"]); + assert.deepEqual( + (await store.list()).map((item) => item.title), + ["保留款 C"], + ); + + await rm(dir, { recursive: true, force: true }); +}); + +test("JsonStore bulk-updates selected captures with status and tags", async () => { + const dir = join(tmpdir(), `sourcing-store-bulk-update-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + + const inserted = await store.insertMany([ + { + platform: "1688", + title: "待拿样藏式手链", + url: "https://detail.1688.com/offer/bulk-update-a.html", + price: 7, + }, + { + platform: "1688", + title: "待拿样手机链", + url: "https://detail.1688.com/offer/bulk-update-b.html", + price: 4, + }, + ]); + + const result = await store.bulkUpdate({ + ids: inserted.map((item) => item.id), + patch: { status: "asking_supplier" }, + addTags: ["低价跑量", "直播待测"], + }); + + assert.equal(result.updated, 2); + const listed = await store.list({ status: "asking_supplier" }); + assert.equal(listed.length, 2); + assert.deepEqual(listed[0].tags, ["低价跑量", "直播待测"]); + + await rm(dir, { recursive: true, force: true }); +}); + +test("JsonStore filters by live sourcing decision dimensions", async () => { + const dir = join(tmpdir(), `sourcing-store-live-filters-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + + await store.insertMany([ + { + platform: "1688", + title: "藏式编绳手机链 低价跑量", + url: "https://detail.1688.com/offer/filter-a.html", + price: 4.8, + image: "https://img.example/a.jpg", + supplier: "义乌源头饰品厂", + }, + { + platform: "taobao", + title: "天然绿松石纯银开光转运手串", + url: "https://item.taobao.com/item.htm?id=filter-b", + price: 188, + }, + { + platform: "taobao", + title: "珍珠耳环小众耳饰", + url: "https://item.taobao.com/item.htm?id=filter-c", + price: 18, + tags: ["直播待测"], + }, + ]); + + assert.deepEqual( + (await store.list({ priceBand: "under10", source: "only1688" })).map((item) => item.title), + ["藏式编绳手机链 低价跑量"], + ); + assert.deepEqual( + (await store.list({ risk: "high" })).map((item) => item.title), + ["天然绿松石纯银开光转运手串"], + ); + assert.deepEqual( + (await store.list({ tag: "低价" })).map((item) => item.title), + ["藏式编绳手机链 低价跑量"], + ); + assert.deepEqual( + (await store.list({ source: "non1688", priceBand: "10to30" })).map((item) => item.title), + ["珍珠耳环小众耳饰"], + ); + + await rm(dir, { recursive: true, force: true }); +}); diff --git a/tests/server.test.js b/tests/server.test.js new file mode 100644 index 0000000..2082f5f --- /dev/null +++ b/tests/server.test.js @@ -0,0 +1,1355 @@ +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { hashToken } from "../src/auth.js"; +import { createServer } from "../src/server.js"; +import { JsonStore } from "../src/store.js"; + +const ROOT_DIR = dirname(dirname(fileURLToPath(import.meta.url))); + +async function startServer(options = {}) { + const dir = join(tmpdir(), `sourcing-api-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + const server = createServer({ store, publicDir: join(dir, "public"), ...options }); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + return { + baseUrl, + async close() { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +async function jsonRequest(baseUrl, path, { method = "GET", token, body } = {}) { + const headers = {}; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") ? await response.json() : await response.text(); + return { response, payload }; +} + +test("local API accepts extension captures and exports CSV", async () => { + const dir = join(tmpdir(), `sourcing-api-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + const server = createServer({ store, publicDir: join(dir, "public") }); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const captureResponse = await fetch(`${baseUrl}/api/captures`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: [ + { + platform: "1688", + title: "藏式编绳手链 直播民族风", + url: "https://detail.1688.com/offer/100.html", + image: "https://img.example/100.jpg", + priceText: "¥6.80", + supplier: "义乌源头饰品厂", + metrics: { visibleText: "现货 混批 1000+人付款" }, + }, + ], + }), + }); + + assert.equal(captureResponse.status, 201); + const capturePayload = await captureResponse.json(); + assert.equal(capturePayload.items.length, 1); + assert.equal(capturePayload.items[0].decision, "拿样"); + + const listResponse = await fetch(`${baseUrl}/api/items?platform=1688`); + assert.equal(listResponse.status, 200); + const listPayload = await listResponse.json(); + assert.equal(listPayload.items.length, 1); + + const id = listPayload.items[0].id; + const updateResponse = await fetch(`${baseUrl}/api/items/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "ordered_sample", notes: "先拿样 3 件" }), + }); + assert.equal(updateResponse.status, 200); + const updated = await updateResponse.json(); + assert.equal(updated.item.status, "ordered_sample"); + + const csvResponse = await fetch(`${baseUrl}/api/export.csv`); + assert.equal(csvResponse.status, 200); + assert.match(await csvResponse.text(), /藏式编绳手链/); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("health API exposes recommended extension version for update prompts", async () => { + const app = await startServer(); + + try { + const health = await jsonRequest(app.baseUrl, "/api/health"); + assert.equal(health.response.status, 200); + assert.equal(health.payload.ok, true); + assert.equal(health.payload.extension.name, "选品采购采集器"); + assert.match(health.payload.extension.recommendedVersion, /^\d+\.\d+\.\d+$/); + assert.equal(health.payload.extension.installUrl, "/downloads/product-sourcing-capture-extension.zip"); + assert.match(health.payload.extension.fileName, /^product-sourcing-capture-extension-\d+\.\d+\.\d+\.zip$/); + assert.equal(health.payload.extension.reloadRequiredForUnpacked, true); + assert.ok(health.payload.extension.updateNote.includes("storage")); + } finally { + await app.close(); + } +}); + +test("server packages browser extension as a downloadable zip", async () => { + const app = await startServer(); + + try { + const headResponse = await fetch(`${app.baseUrl}/downloads/product-sourcing-capture-extension.zip`, { method: "HEAD" }); + assert.equal(headResponse.status, 200); + assert.equal(headResponse.headers.get("content-type"), "application/zip"); + assert.match( + headResponse.headers.get("content-disposition") || "", + /attachment; filename="product-sourcing-capture-extension-\d+\.\d+\.\d+\.zip"/, + ); + + const response = await fetch(`${app.baseUrl}/downloads/product-sourcing-capture-extension.zip`); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "application/zip"); + assert.match( + response.headers.get("content-disposition") || "", + /attachment; filename="product-sourcing-capture-extension-\d+\.\d+\.\d+\.zip"/, + ); + + const zip = Buffer.from(await response.arrayBuffer()); + assert.ok(zip.length > 1000); + assert.equal(zip.slice(0, 4).toString("binary"), "PK\u0003\u0004"); + assert.ok(zip.includes(Buffer.from("manifest.json"))); + assert.ok(zip.includes(Buffer.from("popup.html"))); + assert.ok(zip.includes(Buffer.from("content.js"))); + } finally { + await app.close(); + } +}); + +test("download routes do not fall back to the dashboard HTML", async () => { + const app = await startServer(); + + try { + const response = await fetch(`${app.baseUrl}/downloads/missing-extension.zip`); + assert.equal(response.status, 404); + assert.match(response.headers.get("content-type") || "", /application\/json/); + assert.deepEqual(await response.json(), { error: "Download not found" }); + } finally { + await app.close(); + } +}); + +test("dashboard exposes browser extension package download in plugin install areas", async () => { + const dir = join(tmpdir(), `sourcing-api-ui-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const server = createServer({ store: new JsonStore(join(dir, "items.json")), publicDir: join(ROOT_DIR, "public") }); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const homepage = await fetch(`${baseUrl}/`); + const html = await homepage.text(); + assert.equal(homepage.status, 200); + assert.match(html, /\/downloads\/product-sourcing-capture-extension\.zip/); + assert.match(html, /下载插件安装包/); + + const appJs = await fetch(`${baseUrl}/app.js`); + const js = await appJs.text(); + assert.equal(appJs.status, 200); + assert.match(js, /\/downloads\/product-sourcing-capture-extension\.zip/); + assert.match(js, /下载插件安装包/); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("homepage exposes absolute social sharing metadata and brand assets", async () => { + const dir = join(tmpdir(), `sourcing-api-brand-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const publicDir = join(dir, "public"); + await mkdir(join(publicDir, "assets"), { recursive: true }); + await writeFile( + join(publicDir, "index.html"), + [ + "", + '选品采购台|竞品采集、货源匹配与采购决策', + '', + '', + '', + '', + '', + "", + ].join(""), + "utf8", + ); + await writeFile(join(publicDir, "favicon.ico"), "icon", "utf8"); + await writeFile(join(publicDir, "assets", "share-card.png"), "share", "utf8"); + const server = createServer({ store: new JsonStore(join(dir, "items.json")), publicDir }); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const homepage = await fetch(`${baseUrl}/`); + const html = await homepage.text(); + assert.equal(homepage.status, 200); + assert.match(html, /选品采购台|竞品采集、货源匹配与采购决策/); + assert.match(html, /rel="icon" href="\/favicon\.ico"/); + assert.match(html, /property="og:image"/); + assert.ok(html.includes(`content="${baseUrl}/assets/share-card.png"`)); + assert.ok(html.includes(`content="${baseUrl}/"`)); + assert.doesNotMatch(html, /__SHARE_ORIGIN__/); + + assert.equal((await fetch(`${baseUrl}/favicon.ico`)).status, 200); + assert.equal((await fetch(`${baseUrl}/assets/share-card.png`)).status, 200); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("SaaS auth isolates captures and rules by user", async () => { + const app = await startServer(); + + try { + const aliceRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "alice@example.com", password: "secret123", name: "Alice 买手" }, + }); + assert.equal(aliceRegister.response.status, 201); + assert.ok(aliceRegister.payload.token); + assert.equal(aliceRegister.payload.user.email, "alice@example.com"); + assert.equal(aliceRegister.payload.user.passwordHash, undefined); + + const bobRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "bob@example.com", password: "secret123", name: "Bob 买手" }, + }); + assert.equal(bobRegister.response.status, 201); + + const unauthenticated = await jsonRequest(app.baseUrl, "/api/items"); + assert.equal(unauthenticated.response.status, 401); + + const aliceToken = aliceRegister.payload.token; + const bobToken = bobRegister.payload.token; + + const capture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: aliceToken, + body: { + items: [ + { + platform: "1688", + title: "Alice 藏式编绳手链", + url: "https://detail.1688.com/offer/alice-auth.html", + priceText: "¥6.80", + }, + ], + }, + }); + assert.equal(capture.response.status, 201); + assert.equal(capture.payload.items[0].title, "Alice 藏式编绳手链"); + + const aliceItems = await jsonRequest(app.baseUrl, "/api/items", { token: aliceToken }); + assert.equal(aliceItems.response.status, 200); + assert.deepEqual( + aliceItems.payload.items.map((item) => item.title), + ["Alice 藏式编绳手链"], + ); + + const bobItems = await jsonRequest(app.baseUrl, "/api/items", { token: bobToken }); + assert.equal(bobItems.response.status, 200); + assert.deepEqual(bobItems.payload.items, []); + + const aliceRules = await jsonRequest(app.baseUrl, "/api/rules", { token: aliceToken }); + assert.equal(aliceRules.response.status, 200); + assert.ok(aliceRules.payload.rules.tracks.some((track) => track.name === "藏式/民族风")); + + const updateRules = await jsonRequest(app.baseUrl, "/api/rules", { + method: "PUT", + token: aliceToken, + body: { + rules: { + riskKeywords: ["过敏包治"], + profitModel: { + shippingFee: 5, + packagingFee: 2, + platformFeeRate: 0.06, + promoFee: 4, + maxRunningPrice: 99, + }, + }, + }, + }); + assert.equal(updateRules.response.status, 200); + assert.deepEqual(updateRules.payload.rules.riskKeywords, ["过敏包治"]); + assert.equal(updateRules.payload.rules.profitModel.shippingFee, 5); + + const bobRules = await jsonRequest(app.baseUrl, "/api/rules", { token: bobToken }); + assert.equal(bobRules.response.status, 200); + assert.notDeepEqual(bobRules.payload.rules.riskKeywords, ["过敏包治"]); + + const login = await jsonRequest(app.baseUrl, "/api/auth/login", { + method: "POST", + body: { email: "alice@example.com", password: "secret123" }, + }); + assert.equal(login.response.status, 200); + assert.ok(login.payload.token); + assert.equal(login.payload.user.email, "alice@example.com"); + + const badLogin = await jsonRequest(app.baseUrl, "/api/auth/login", { + method: "POST", + body: { email: "alice@example.com", password: "wrong-password" }, + }); + assert.equal(badLogin.response.status, 401); + } finally { + await app.close(); + } +}); + +test("admin can manage users and disabled users cannot sign in", async () => { + const app = await startServer(); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "admin@example.com", password: "secret123", name: "平台管理员" }, + }); + assert.equal(adminRegister.response.status, 201); + assert.equal(adminRegister.payload.user.role, "admin"); + assert.equal(adminRegister.payload.user.status, "active"); + + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "buyer@example.com", password: "secret123", name: "买手用户" }, + }); + assert.equal(buyerRegister.response.status, 201); + assert.equal(buyerRegister.payload.user.role, "buyer"); + + const buyerAdminRead = await jsonRequest(app.baseUrl, "/api/admin/users", { token: buyerRegister.payload.token }); + assert.equal(buyerAdminRead.response.status, 403); + + const adminUsers = await jsonRequest(app.baseUrl, "/api/admin/users", { token: adminRegister.payload.token }); + assert.equal(adminUsers.response.status, 200); + assert.equal(adminUsers.payload.users.length, 2); + assert.deepEqual( + adminUsers.payload.users.map((user) => [user.email, user.role, user.status, user.itemCount, user.pluginTokenCount]), + [ + ["admin@example.com", "admin", "active", 0, 0], + ["buyer@example.com", "buyer", "active", 0, 0], + ], + ); + + const disableBuyer = await jsonRequest(app.baseUrl, `/api/admin/users/${buyerRegister.payload.user.id}/status`, { + method: "PATCH", + token: adminRegister.payload.token, + body: { status: "disabled" }, + }); + assert.equal(disableBuyer.response.status, 200); + assert.equal(disableBuyer.payload.user.status, "disabled"); + + const disabledLogin = await jsonRequest(app.baseUrl, "/api/auth/login", { + method: "POST", + body: { email: "buyer@example.com", password: "secret123" }, + }); + assert.equal(disabledLogin.response.status, 403); + + const disabledSessionRead = await jsonRequest(app.baseUrl, "/api/items", { token: buyerRegister.payload.token }); + assert.equal(disabledSessionRead.response.status, 403); + + const disableSelf = await jsonRequest(app.baseUrl, `/api/admin/users/${adminRegister.payload.user.id}/status`, { + method: "PATCH", + token: adminRegister.payload.token, + body: { status: "disabled" }, + }); + assert.equal(disableSelf.response.status, 400); + } finally { + await app.close(); + } +}); + +test("plugin tokens can capture to one user but cannot read private library", async () => { + const app = await startServer(); + + try { + const register = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "plugin-owner@example.com", password: "secret123", name: "插件用户" }, + }); + const sessionToken = register.payload.token; + + const createdToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { + method: "POST", + token: sessionToken, + body: { name: "Chrome 云采集插件" }, + }); + assert.equal(createdToken.response.status, 201); + assert.ok(createdToken.payload.token.startsWith("pst_")); + assert.equal(createdToken.payload.pluginToken.name, "Chrome 云采集插件"); + assert.equal(createdToken.payload.pluginToken.tokenHash, undefined); + + const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: sessionToken, + body: { credits: 3 }, + }); + const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: sessionToken, + body: { code: createdCode.payload.code }, + }); + assert.equal(redeemed.response.status, 200); + + const pluginCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: createdToken.payload.token, + body: { + items: [ + { + platform: "xiaohongshu", + title: "插件云端采集珍珠耳饰", + url: "https://www.xiaohongshu.com/explore/plugin-token", + priceText: "¥59", + }, + ], + }, + }); + assert.equal(pluginCapture.response.status, 201); + assert.equal(pluginCapture.payload.items[0].title, "插件云端采集珍珠耳饰"); + + const pluginRead = await jsonRequest(app.baseUrl, "/api/items", { token: createdToken.payload.token }); + assert.equal(pluginRead.response.status, 403); + + const ownerRead = await jsonRequest(app.baseUrl, "/api/items", { token: sessionToken }); + assert.equal(ownerRead.response.status, 200); + assert.deepEqual( + ownerRead.payload.items.map((item) => item.title), + ["插件云端采集珍珠耳饰"], + ); + } finally { + await app.close(); + } +}); + +test("plugin tokens track usage and can be disabled or deleted by owner", async () => { + const app = await startServer(); + + try { + const register = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "token-owner@example.com", password: "secret123", name: "Token 用户" }, + }); + const sessionToken = register.payload.token; + + const createdToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { + method: "POST", + token: sessionToken, + body: { name: "直播间采集插件" }, + }); + assert.equal(createdToken.response.status, 201); + assert.equal(createdToken.payload.pluginToken.status, "active"); + assert.equal(createdToken.payload.pluginToken.usageCount, 0); + assert.equal(createdToken.payload.pluginToken.lastUsedAt, null); + + const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: sessionToken, + body: { credits: 2 }, + }); + const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: sessionToken, + body: { code: createdCode.payload.code }, + }); + assert.equal(redeemed.response.status, 200); + + const pluginCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: createdToken.payload.token, + body: { + items: [ + { + platform: "1688", + title: "Token 统计藏式手链", + url: "https://detail.1688.com/offer/token-usage.html", + priceText: "¥6.80", + }, + ], + }, + }); + assert.equal(pluginCapture.response.status, 201); + + const tokenListAfterUse = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: sessionToken }); + assert.equal(tokenListAfterUse.response.status, 200); + assert.equal(tokenListAfterUse.payload.pluginTokens[0].usageCount, 1); + assert.ok(tokenListAfterUse.payload.pluginTokens[0].lastUsedAt); + + const disabledToken = await jsonRequest(app.baseUrl, `/api/plugin-tokens/${createdToken.payload.pluginToken.id}`, { + method: "PATCH", + token: sessionToken, + body: { status: "disabled" }, + }); + assert.equal(disabledToken.response.status, 200); + assert.equal(disabledToken.payload.pluginToken.status, "disabled"); + + const disabledCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: createdToken.payload.token, + body: { + items: [ + { + platform: "1688", + title: "禁用 token 不应采集", + url: "https://detail.1688.com/offer/token-disabled.html", + priceText: "¥7.80", + }, + ], + }, + }); + assert.equal(disabledCapture.response.status, 403); + + const deleteToken = await jsonRequest(app.baseUrl, `/api/plugin-tokens/${createdToken.payload.pluginToken.id}`, { + method: "DELETE", + token: sessionToken, + }); + assert.equal(deleteToken.response.status, 200); + assert.equal(deleteToken.payload.deleted, 1); + + const tokenListAfterDelete = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: sessionToken }); + assert.equal(tokenListAfterDelete.response.status, 200); + assert.deepEqual(tokenListAfterDelete.payload.pluginTokens, []); + } finally { + await app.close(); + } +}); + +test("plugin token owner can copy token again from token list", async () => { + const app = await startServer(); + + try { + const ownerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "copy-token-owner@example.com", password: "secret123", name: "可复制 Token 用户" }, + }); + const otherRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "copy-token-other@example.com", password: "secret123", name: "其他用户" }, + }); + const createdToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { + method: "POST", + token: ownerRegister.payload.token, + body: { name: "可再次复制的插件" }, + }); + assert.equal(createdToken.response.status, 201); + assert.ok(createdToken.payload.token.startsWith("pst_")); + + const ownerList = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: ownerRegister.payload.token }); + assert.equal(ownerList.response.status, 200); + assert.equal(ownerList.payload.pluginTokens.length, 1); + assert.equal(ownerList.payload.pluginTokens[0].token, createdToken.payload.token); + assert.equal(ownerList.payload.pluginTokens[0].tokenHash, undefined); + + const otherList = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { token: otherRegister.payload.token }); + assert.equal(otherList.response.status, 200); + assert.deepEqual(otherList.payload.pluginTokens, []); + } finally { + await app.close(); + } +}); + +test("admin can issue credit codes and buyers redeem them into account credits", async () => { + const app = await startServer(); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "credit-admin@example.com", password: "secret123", name: "积分管理员" }, + }); + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "credit-buyer@example.com", password: "secret123", name: "积分买手" }, + }); + + assert.equal(adminRegister.payload.user.credits, 0); + assert.equal(buyerRegister.payload.user.credits, 0); + + const buyerCannotIssue = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: buyerRegister.payload.token, + body: { credits: 30 }, + }); + assert.equal(buyerCannotIssue.response.status, 403); + + const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: adminRegister.payload.token, + body: { credits: 30, note: "首批采集额度" }, + }); + assert.equal(createdCode.response.status, 201); + assert.equal(createdCode.payload.creditCode.credits, 30); + assert.equal(createdCode.payload.creditCode.status, "active"); + assert.match(createdCode.payload.code, /^CDX-[A-Z0-9-]+$/); + + const codeList = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { token: adminRegister.payload.token }); + assert.equal(codeList.response.status, 200); + assert.equal(codeList.payload.creditCodes.length, 1); + assert.equal(codeList.payload.creditCodes[0].code, createdCode.payload.code); + assert.equal(codeList.payload.creditCodes[0].redeemedBy, null); + + const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: buyerRegister.payload.token, + body: { code: createdCode.payload.code.toLowerCase() }, + }); + assert.equal(redeemed.response.status, 200); + assert.equal(redeemed.payload.user.credits, 30); + assert.equal(redeemed.payload.creditCode.status, "redeemed"); + + const me = await jsonRequest(app.baseUrl, "/api/me", { token: buyerRegister.payload.token }); + assert.equal(me.response.status, 200); + assert.equal(me.payload.user.credits, 30); + + const redeemAgain = await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: buyerRegister.payload.token, + body: { code: createdCode.payload.code }, + }); + assert.equal(redeemAgain.response.status, 409); + } finally { + await app.close(); + } +}); + +test("admin manages AI settings without exposing API keys to buyers", async () => { + const app = await startServer(); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-admin@example.com", password: "secret123", name: "AI 管理员" }, + }); + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-buyer@example.com", password: "secret123", name: "AI 买手" }, + }); + + const buyerRead = await jsonRequest(app.baseUrl, "/api/admin/ai-settings", { token: buyerRegister.payload.token }); + assert.equal(buyerRead.response.status, 403); + + const saved = await jsonRequest(app.baseUrl, "/api/admin/ai-settings", { + method: "PUT", + token: adminRegister.payload.token, + body: { + enabled: true, + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + apiKey: "sk-secret-value", + model: "deepseek-chat", + temperature: 0.2, + maxOutputTokens: 900, + timeoutMs: 15000, + tokenUnitCost: 2, + prompts: { + itemAnalysis: "请分析这个商品", + }, + }, + }); + + assert.equal(saved.response.status, 200); + assert.equal(saved.payload.aiSettings.enabled, true); + assert.equal(saved.payload.aiSettings.provider, "deepseek"); + assert.equal(saved.payload.aiSettings.model, "deepseek-chat"); + assert.equal(saved.payload.aiSettings.apiKeyMasked, "sk-...alue"); + assert.equal(saved.payload.aiSettings.apiKey, undefined); + + const readBack = await jsonRequest(app.baseUrl, "/api/admin/ai-settings", { token: adminRegister.payload.token }); + assert.equal(readBack.response.status, 200); + assert.equal(readBack.payload.aiSettings.apiKeyMasked, "sk-...alue"); + assert.equal(readBack.payload.aiSettings.apiKey, undefined); + assert.equal(readBack.payload.aiSettings.prompts.itemAnalysis, "请分析这个商品"); + } finally { + await app.close(); + } +}); + +test("admin can issue AI token codes and buyers redeem them into AI token balance", async () => { + const app = await startServer(); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-token-admin@example.com", password: "secret123", name: "AI Token 管理员" }, + }); + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-token-buyer@example.com", password: "secret123", name: "AI Token 买手" }, + }); + assert.equal(buyerRegister.payload.user.aiTokens, 0); + + const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: adminRegister.payload.token, + body: { credits: 5, aiTokens: 12000, note: "AI 分析包" }, + }); + assert.equal(createdCode.response.status, 201); + assert.equal(createdCode.payload.creditCode.credits, 5); + assert.equal(createdCode.payload.creditCode.aiTokens, 12000); + + const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: buyerRegister.payload.token, + body: { code: createdCode.payload.code }, + }); + assert.equal(redeemed.response.status, 200); + assert.equal(redeemed.payload.user.credits, 5); + assert.equal(redeemed.payload.user.aiTokens, 12000); + + const me = await jsonRequest(app.baseUrl, "/api/me", { token: buyerRegister.payload.token }); + assert.equal(me.payload.user.aiTokens, 12000); + } finally { + await app.close(); + } +}); + +test("AI item analysis uses admin model settings and deducts user AI tokens", async () => { + const calls = []; + const fakeAiClient = { + async chat({ settings, messages }) { + calls.push({ settings, messages }); + return { + text: JSON.stringify({ + summary: "适合直播测试的低客单商品", + purchaseAdvice: "建议先拿样 3 件", + sellingPoints: ["视觉记忆点强", "适合组合购"], + risks: ["确认材质和售后"], + supplierQuestions: ["是否现货?"], + }), + usage: { inputTokens: 320, outputTokens: 180, totalTokens: 500 }, + raw: { id: "chatcmpl_fake" }, + }; + }, + }; + const app = await startServer({ aiClient: fakeAiClient }); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-use-admin@example.com", password: "secret123", name: "AI 使用管理员" }, + }); + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-use-buyer@example.com", password: "secret123", name: "AI 使用买手" }, + }); + + await jsonRequest(app.baseUrl, "/api/admin/ai-settings", { + method: "PUT", + token: adminRegister.payload.token, + body: { + enabled: true, + provider: "openai-compatible", + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-proxy-secret", + model: "gpt-4o-mini", + tokenUnitCost: 1, + }, + }); + + const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: adminRegister.payload.token, + body: { aiTokens: 1000, note: "AI 测试额度" }, + }); + await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: buyerRegister.payload.token, + body: { code: createdCode.payload.code }, + }); + + const capture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: buyerRegister.payload.token, + body: { + items: [ + { + platform: "1688", + title: "藏式编绳手机链 低价跑量", + url: "https://detail.1688.com/offer/ai-analysis.html", + priceText: "¥5.80", + supplier: "义乌源头饰品厂", + }, + ], + }, + }); + + const analysis = await jsonRequest(app.baseUrl, "/api/ai/analyze", { + method: "POST", + token: buyerRegister.payload.token, + body: { type: "item_analysis", itemId: capture.payload.items[0].id }, + }); + + assert.equal(analysis.response.status, 200); + assert.equal(analysis.payload.analysis.summary, "适合直播测试的低客单商品"); + assert.deepEqual(analysis.payload.analysis.sellingPoints, ["视觉记忆点强", "适合组合购"]); + assert.equal(analysis.payload.usage.totalTokens, 500); + assert.equal(analysis.payload.billing.deductedAiTokens, 500); + assert.equal(analysis.payload.billing.remainingAiTokens, 500); + assert.equal(calls.length, 1); + assert.equal(calls[0].settings.apiKey, "sk-proxy-secret"); + assert.equal(calls[0].settings.model, "gpt-4o-mini"); + assert.match(calls[0].messages.at(-1).content, /藏式编绳手机链/); + + const me = await jsonRequest(app.baseUrl, "/api/me", { token: buyerRegister.payload.token }); + assert.equal(me.payload.user.aiTokens, 500); + } finally { + await app.close(); + } +}); + +test("AI analysis requires enabled settings and enough AI tokens", async () => { + const app = await startServer({ + aiClient: { + async chat() { + throw new Error("AI client should not be called without enough tokens"); + }, + }, + }); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-limit-admin@example.com", password: "secret123", name: "AI 限额管理员" }, + }); + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "ai-limit-buyer@example.com", password: "secret123", name: "AI 限额买手" }, + }); + + const disabled = await jsonRequest(app.baseUrl, "/api/ai/analyze", { + method: "POST", + token: buyerRegister.payload.token, + body: { type: "item_analysis", item: { title: "未启用 AI" } }, + }); + assert.equal(disabled.response.status, 409); + + await jsonRequest(app.baseUrl, "/api/admin/ai-settings", { + method: "PUT", + token: adminRegister.payload.token, + body: { + enabled: true, + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-secret", + model: "gpt-4o-mini", + }, + }); + + const noBalance = await jsonRequest(app.baseUrl, "/api/ai/analyze", { + method: "POST", + token: buyerRegister.payload.token, + body: { type: "item_analysis", item: { title: "无 AI Token" } }, + }); + assert.equal(noBalance.response.status, 402); + assert.equal(noBalance.payload.aiTokens.remaining, 0); + } finally { + await app.close(); + } +}); + +test("plugin capture requires shared account credits and deducts per saved item", async () => { + const app = await startServer(); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "quota-admin@example.com", password: "secret123", name: "额度管理员" }, + }); + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "quota-buyer@example.com", password: "secret123", name: "额度买手" }, + }); + const buyerToken = buyerRegister.payload.token; + + const firstPluginToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { + method: "POST", + token: buyerToken, + body: { name: "淘宝采集插件" }, + }); + const secondPluginToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { + method: "POST", + token: buyerToken, + body: { name: "1688 采集插件" }, + }); + assert.ok(firstPluginToken.payload.token.startsWith("pst_")); + assert.ok(secondPluginToken.payload.token.startsWith("pst_")); + + const noCreditCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: firstPluginToken.payload.token, + body: { + items: [ + { + platform: "taobao", + title: "无积分不应采集", + url: "https://item.taobao.com/item.htm?id=no-credit", + priceText: "¥39", + }, + ], + }, + }); + assert.equal(noCreditCapture.response.status, 402); + + const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: adminRegister.payload.token, + body: { credits: 3 }, + }); + const redeemed = await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: buyerToken, + body: { code: createdCode.payload.code }, + }); + assert.equal(redeemed.payload.user.credits, 3); + + const firstCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: firstPluginToken.payload.token, + body: { + items: [ + { + platform: "taobao", + title: "积分采集淘宝耳饰", + url: "https://item.taobao.com/item.htm?id=credit-a", + priceText: "¥39", + }, + { + platform: "xiaohongshu", + title: "积分采集小红书手链", + url: "https://www.xiaohongshu.com/explore/credit-b", + priceText: "¥49", + }, + ], + }, + }); + assert.equal(firstCapture.response.status, 201); + assert.equal(firstCapture.payload.credits.remaining, 1); + assert.equal(firstCapture.payload.credits.deducted, 2); + + const secondCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: secondPluginToken.payload.token, + body: { + items: [ + { + platform: "1688", + title: "第二个 Token 共用积分", + url: "https://detail.1688.com/offer/shared-credit.html", + priceText: "¥6.80", + }, + ], + }, + }); + assert.equal(secondCapture.response.status, 201); + assert.equal(secondCapture.payload.credits.remaining, 0); + + const meAfterCapture = await jsonRequest(app.baseUrl, "/api/me", { token: buyerToken }); + assert.equal(meAfterCapture.payload.user.credits, 0); + + const insufficientCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: firstPluginToken.payload.token, + body: { + items: [ + { + platform: "1688", + title: "积分耗尽不应采集", + url: "https://detail.1688.com/offer/no-more-credit.html", + priceText: "¥8.80", + }, + ], + }, + }); + assert.equal(insufficientCapture.response.status, 402); + } finally { + await app.close(); + } +}); + +test("plugin capture charges duplicate urls in one batch as one saved lead", async () => { + const app = await startServer(); + + try { + const adminRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "dedupe-admin@example.com", password: "secret123", name: "去重管理员" }, + }); + const buyerRegister = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "dedupe-buyer@example.com", password: "secret123", name: "去重买手" }, + }); + const buyerToken = buyerRegister.payload.token; + + const pluginToken = await jsonRequest(app.baseUrl, "/api/plugin-tokens", { + method: "POST", + token: buyerToken, + body: { name: "重复链接采集插件" }, + }); + const createdCode = await jsonRequest(app.baseUrl, "/api/admin/credit-codes", { + method: "POST", + token: adminRegister.payload.token, + body: { credits: 1 }, + }); + await jsonRequest(app.baseUrl, "/api/credits/redeem", { + method: "POST", + token: buyerToken, + body: { code: createdCode.payload.code }, + }); + + const duplicateCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + token: pluginToken.payload.token, + body: { + items: [ + { + platform: "taobao", + title: "重复采集的藏式手链 A", + url: "https://item.taobao.com/item.htm?id=duplicate-credit", + priceText: "¥49", + }, + { + platform: "taobao", + title: "重复采集的藏式手链 B", + url: "https://item.taobao.com/item.htm?id=duplicate-credit", + priceText: "¥45", + }, + ], + }, + }); + + assert.equal(duplicateCapture.response.status, 201); + assert.equal(duplicateCapture.payload.items.length, 1); + assert.equal(duplicateCapture.payload.items[0].title, "重复采集的藏式手链 B"); + assert.equal(duplicateCapture.payload.credits.deducted, 1); + assert.equal(duplicateCapture.payload.credits.remaining, 0); + + const items = await jsonRequest(app.baseUrl, "/api/items", { token: buyerToken }); + assert.equal(items.payload.items.length, 1); + } finally { + await app.close(); + } +}); + +test("plugin capture keeps credits unchanged when saving fails", async () => { + const pluginTokenValue = "pst_failure_test_token"; + const fakeStore = { + user: { + id: "usr_failure", + email: "failure-buyer@example.com", + name: "失败买手", + role: "buyer", + status: "active", + credits: 1, + }, + pluginToken: { + id: "ptk_failure", + userId: "usr_failure", + tokenHash: hashToken(pluginTokenValue), + status: "active", + usageCount: 0, + }, + async userCount() { + return 1; + }, + async findPluginTokenByHash(tokenHash) { + if (tokenHash !== this.pluginToken.tokenHash) return null; + return { user: this.user, pluginToken: this.pluginToken }; + }, + async deductCredits(userId, amount) { + this.user.credits -= amount; + return { deducted: amount, remaining: this.user.credits }; + }, + async insertMany() { + const error = new Error("Simulated save failure"); + error.status = 503; + throw error; + }, + async insertManyWithCreditDeduction() { + const error = new Error("Simulated save failure"); + error.status = 503; + throw error; + }, + }; + const dir = join(tmpdir(), `sourcing-api-failed-save-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(dir, { recursive: true }); + const server = createServer({ store: fakeStore, publicDir: join(dir, "public") }); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const failedCapture = await jsonRequest(baseUrl, "/api/captures", { + method: "POST", + token: pluginTokenValue, + body: { + items: [ + { + platform: "1688", + title: "保存失败不应扣积分", + url: "https://detail.1688.com/offer/failure-credit.html", + priceText: "¥6.80", + }, + ], + }, + }); + + assert.equal(failedCapture.response.status, 503); + assert.equal(fakeStore.user.credits, 1); + assert.equal(fakeStore.pluginToken.usageCount, 0); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("legacy users without role or status are normalized for admin access", async () => { + const dir = join(tmpdir(), `sourcing-api-legacy-users-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(dir, { recursive: true }); + const filePath = join(dir, "items.json"); + const store = new JsonStore(filePath); + const server = createServer({ store, publicDir: join(dir, "public") }); + + const admin = await store.createUser({ + email: "legacy-admin@example.com", + name: "旧管理员", + passwordHash: "legacy-admin-hash", + }); + const buyer = await store.createUser({ + email: "legacy-buyer@example.com", + name: "旧买手", + passwordHash: "legacy-buyer-hash", + }); + + await writeFile( + filePath, + JSON.stringify( + { + users: [ + { id: admin.id, email: admin.email, name: admin.name, passwordHash: admin.passwordHash, createdAt: admin.createdAt }, + { id: buyer.id, email: buyer.email, name: buyer.name, passwordHash: buyer.passwordHash, createdAt: buyer.createdAt }, + ], + items: [{ id: "legacy-item", userId: buyer.id, title: "旧数据饰品", platform: "1688", createdAt: "2026-01-01T00:00:00.000Z" }], + pluginTokens: [{ id: "legacy-token", userId: buyer.id, name: "旧插件", tokenHash: "hash", createdAt: "2026-01-01T00:00:00.000Z" }], + sessions: [], + rules: {}, + }, + null, + 2, + ), + "utf8", + ); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const adminToken = "sess_legacy_admin_access"; + const adminSession = await store.createSession({ userId: admin.id, tokenHash: hashToken(adminToken) }); + assert.ok(adminSession.id); + + const adminUsers = await jsonRequest(baseUrl, "/api/admin/users", { token: adminToken }); + assert.equal(adminUsers.response.status, 200); + assert.deepEqual( + adminUsers.payload.users.map((user) => [user.email, user.role, user.status, user.itemCount, user.pluginTokenCount]), + [ + ["legacy-admin@example.com", "admin", "active", 0, 0], + ["legacy-buyer@example.com", "buyer", "active", 1, 1], + ], + ); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("first SaaS user claims existing local captures during migration", async () => { + const app = await startServer(); + + try { + const localCapture = await jsonRequest(app.baseUrl, "/api/captures", { + method: "POST", + body: { + items: [ + { + platform: "1688", + title: "注册前本地采集藏式手链", + url: "https://detail.1688.com/offer/local-before-register.html", + priceText: "¥5.80", + }, + ], + }, + }); + assert.equal(localCapture.response.status, 201); + + const register = await jsonRequest(app.baseUrl, "/api/auth/register", { + method: "POST", + body: { email: "first-user@example.com", password: "secret123", name: "首个用户" }, + }); + assert.equal(register.response.status, 201); + + const ownedItems = await jsonRequest(app.baseUrl, "/api/items", { token: register.payload.token }); + assert.equal(ownedItems.response.status, 200); + assert.deepEqual( + ownedItems.payload.items.map((item) => item.title), + ["注册前本地采集藏式手链"], + ); + } finally { + await app.close(); + } +}); + +test("local API deletes selected captures in one bulk request", async () => { + const dir = join(tmpdir(), `sourcing-api-bulk-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + const server = createServer({ store, publicDir: join(dir, "public") }); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const captureResponse = await fetch(`${baseUrl}/api/captures`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: [ + { + platform: "1688", + title: "误采集 A", + url: "https://detail.1688.com/offer/api-bulk-a.html", + priceText: "¥3.80", + }, + { + platform: "1688", + title: "误采集 B", + url: "https://detail.1688.com/offer/api-bulk-b.html", + priceText: "¥4.80", + }, + { + platform: "1688", + title: "保留 C", + url: "https://detail.1688.com/offer/api-bulk-c.html", + priceText: "¥5.80", + }, + ], + }), + }); + + const { items } = await captureResponse.json(); + const deleteResponse = await fetch(`${baseUrl}/api/items/bulk-delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: [items[0].id, items[1].id, "missing-id"] }), + }); + + assert.equal(deleteResponse.status, 200); + const deleted = await deleteResponse.json(); + assert.equal(deleted.deleted, 2); + assert.deepEqual(deleted.missing, ["missing-id"]); + + const listResponse = await fetch(`${baseUrl}/api/items`); + const listPayload = await listResponse.json(); + assert.equal(listPayload.items.length, 1); + assert.equal(listPayload.items[0].title, "保留 C"); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("local API exposes dashboard, groups, sample queue, and bulk updates", async () => { + const dir = join(tmpdir(), `sourcing-api-workbench-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const store = new JsonStore(join(dir, "items.json")); + const server = createServer({ store, publicDir: join(dir, "public") }); + + await new Promise((resolve) => server.listen(0, resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const captureResponse = await fetch(`${baseUrl}/api/captures`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: [ + { + platform: "taobao", + title: "藏式绿松石编绳手链 民族风", + url: "https://item.taobao.com/item.htm?id=api-style-a", + priceText: "¥39.90", + }, + { + platform: "1688", + title: "民族风绿松石色编织手链 藏式", + url: "https://detail.1688.com/offer/api-style-b.html", + priceText: "¥6.80", + supplier: "义乌源头厂", + }, + ], + }), + }); + const { items } = await captureResponse.json(); + + const bulkResponse = await fetch(`${baseUrl}/api/items/bulk-update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ids: [items[1].id], + patch: { status: "asking_supplier" }, + addTags: ["低价跑量"], + }), + }); + assert.equal(bulkResponse.status, 200); + assert.equal((await bulkResponse.json()).updated, 1); + + const dashboardResponse = await fetch(`${baseUrl}/api/dashboard`); + assert.equal(dashboardResponse.status, 200); + const dashboard = await dashboardResponse.json(); + assert.equal(dashboard.dashboard.totals.items, 2); + + const groupsResponse = await fetch(`${baseUrl}/api/style-groups`); + assert.equal(groupsResponse.status, 200); + const groups = await groupsResponse.json(); + assert.equal(groups.groups.length, 1); + assert.equal(groups.groups[0].items.length, 2); + + const sampleResponse = await fetch(`${baseUrl}/api/sample-queue`); + assert.equal(sampleResponse.status, 200); + const sampleQueue = await sampleResponse.json(); + assert.equal(sampleQueue.items.length, 1); + assert.equal(sampleQueue.items[0].status, "asking_supplier"); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/使用说明.md b/使用说明.md new file mode 100644 index 0000000..a8a8b1c --- /dev/null +++ b/使用说明.md @@ -0,0 +1,145 @@ +# 直播饰品选品采集系统 + +这是一个本地运行的 MVP,用来把淘宝、小红书、1688 当前浏览器页面里已经可见的商品/笔记信息采集到本地选品库。 + +## 能做什么 + +- 一键采集当前页面可见的标题、图片、链接、价格、店铺/供应商、可见热度文本。 +- 自动识别平台:淘宝、天猫、小红书、1688。 +- 自动识别饰品赛道:藏式/民族风、耳饰、项链、手链/手镯、发饰、包挂/手机链等。 +- 自动评分并给出:拿样、继续观察、暂不做。 +- 自动测算建议直播价、预计毛利、毛利率。 +- 自动生成直播卖点、供应商提问清单、风险提醒。 +- 自动合并相似款组,方便对比竞品和 1688 货源。 +- 在本地后台筛选、查看详情、批量改状态、批量打标签、写备注、导出 CSV。 + +## 后台工作区 + +电脑端左侧有主要工作区,手机端底部有 5 个 Tab: + +- 候选库:所有采集款的主列表,可筛选、勾选、批量清理、批量改状态。 +- 选品看板:看总数、价格带、赛道分布、优先推进款和系统建议动作。 +- 拿样清单:集中查看建议拿样、问供应商、已拿样、直播测试中的款。 +- 相似款组:系统把同款/近似款自动归组,方便找竞品对应的 1688 货源。 +- 我的工具:手机端入口,集中放导出 CSV、插件目录、本地服务地址和数据文件位置。 + +当前界面已经按《直播饰品选品系统 UI 提示词体系》实现,定位是内部采购决策台,不是商城页或营销官网。电脑端偏高信息密度和批量处理,手机端偏单手筛选、直播现场快速判断。 + +## 筛选与扫货 + +候选库支持这些筛选: + +- 关键词:标题、供应商、赛道、链接、备注、标签。 +- 平台:小红书、淘宝、天猫、1688、其他。 +- 赛道:藏式/民族风、耳饰、项链、手链/手镯、戒指、发饰、包挂/手机链等。 +- 建议动作:拿样、继续观察、暂不做。 +- 状态:新采集、待复核、问供应商、已拿样、直播测试、已放弃。 +- 标签:支持人工标签和系统自动标签的模糊匹配,例如“低价”“藏式”。 +- 价格带:10 元以内、10-30 元、30-100 元、100 元以上、待确认。 +- 货源:只看 1688 或只看竞品平台。 +- 风险:有风险提醒、高风险、低风险。 + +手机端顶部有常驻搜索框,可以直接搜标题、供应商和标签;点“筛选”会打开底部抽屉,适合直播间或外出看货时快速拉出“低价跑量款”“1688 货源”“高风险款”。 + +## 批量推进采购 + +1. 在候选库筛选出一批款。 +2. 勾选要处理的卡片。 +3. 电脑端可以批量点“问供应商”“已拿样”“直播测试”“放弃”。 +4. 手机端点底部操作条里的“改状态”,再选择“问供应商”“已拿样”“直播测试”或“放弃”。 +5. 可以点“加标签”,例如:低价跑量、藏式主推、直播待测。 +6. 已推进的款会进入“拿样清单”,后面继续跟进。 + +## 看板判断 + +选品看板会把采集结果拆成几个更适合直播选品的区域: + +- 采购动作:系统给下一步操作建议。 +- 价格带:判断第一批 100 元以内跑量款的比例。 +- 赛道分布:看藏式/民族风、耳饰、项链等赛道占比。 +- 低价跑量款:优先看 10 元以内、适合直播测试的款。 +- 利润健康款:优先看“可跑量”或“可测试”的款。 +- 高风险提醒:集中复核天然、纯银、转运、功效等敏感话术。 +- 优先推进款:按综合评分排序的候选。 + +## 利润和直播话术 + +点开任意候选款的“详情”,可以看到: + +- 建议直播价。 +- 预计毛利和毛利率。 +- 直播卖点。 +- 建议询问供应商的问题。 +- 风险提醒。 + +详情页会把“可跑量/可测试/谨慎”“风险等级”“处理状态”放在商品图旁边,手机端会以大抽屉方式打开,便于快速判断是否拿样。 + +利润测算第一版使用默认模型:包装 1 元、快递 4 元、平台/交易费用 5%、直播让利 3 元。后续可以继续做成可配置。 + +## 启动本地服务 + +在终端进入项目目录: + +```bash +cd /Users/wangyuqiang/Documents/Codex/2026-06-13/skills/outputs/product-sourcing-capture +npm start +``` + +打开后台: + +```text +http://127.0.0.1:4777 +``` + +数据会保存在: + +```text +/Users/wangyuqiang/Documents/Codex/2026-06-13/skills/outputs/product-sourcing-capture/data/items.json +``` + +## 安装 Chrome 插件 + +1. 打开 Chrome。 +2. 访问 `chrome://extensions/`。 +3. 开启右上角“开发者模式”。 +4. 点击“加载已解压的扩展程序”。 +5. 选择这个目录: + +```text +/Users/wangyuqiang/Documents/Codex/2026-06-13/skills/outputs/product-sourcing-capture/extension +``` + +如果你已经安装过旧版本插件: + +1. 回到 `chrome://extensions/`。 +2. 找到“直播饰品选品采集器”。 +3. 点击卡片上的刷新/重新加载按钮。 +4. 如果仍然没反应,先移除旧插件,再重新“加载已解压的扩展程序”。 + +## 使用流程 + +1. 先运行 `npm start`。 +2. 打开淘宝、小红书或 1688 搜索结果页/商品详情页。 +3. 点击浏览器右上角插件。 +4. 先点“测试保存到本地库”。如果后台出现一条测试款,说明插件到本地服务链路正常。 +5. 再点“采集当前页”或“只采集主商品/笔记”。 +6. 如果当前页没采到,先滚动页面让商品图片加载出来,再重新点击采集。 +7. 回到 `http://127.0.0.1:4777` 查看选品库。 + +## 批量清理误采集 + +1. 在后台用关键词、平台、赛道或左侧建议动作先筛出一批候选。 +2. 勾选商品卡片左上角的“选择”。 +3. 如果当前筛选结果都要清理,点“全选当前结果”。 +4. 误选了可以点单个勾选框取消,或点“取消选择”全部清空。 +5. 点“删除所选”,工具条会进入确认状态。 +6. 再点“确认删除”,会从本地选品库删除这些候选;点“返回”可以放弃删除。 + +批量删除只清理本地数据,不会影响淘宝、小红书或 1688 原页面。 + +## 注意边界 + +- 只采集当前页面已经显示出来的内容。 +- 不绕过登录、验证码、平台限制或后台权限。 +- 搜索量、成交量等如果页面没显示,系统不会凭空声称真实数据。 +- 第一版是启发式采集,后续可以针对淘宝、小红书、1688 页面结构继续优化命中率。 diff --git a/直播饰品选品系统_Design_System.md b/直播饰品选品系统_Design_System.md new file mode 100644 index 0000000..6a29c8a --- /dev/null +++ b/直播饰品选品系统_Design_System.md @@ -0,0 +1,129 @@ +# 直播饰品选品采购 SaaS Design System + +系统名:Buyer Atelier DS +主原型:Expert Workbench +副原型:Product Story +设计目标:让直播饰品买手在高频筛选、对比、拿样和采购判断中建立速度、信任和掌控感。 + +## Product DNA + +- Purpose: 帮助直播饰品团队在开店前完成竞品采集、价格判断、1688 货源匹配、利润测算和拿样决策。 +- Primary users: 内部买手、店主、直播运营。 +- Frequency: 开店筹备期和上新期高频使用。 +- Pressure: 中等偏高,需要快速判断款式是否值得拿样。 +- Density: 中高密度,图片、价格、平台、赛道、风险、供应商、状态必须同屏可扫。 +- Risk: 中等,误判会带来库存、话术、质量和资金风险。 +- Core actions: 采集、搜索、筛选、比较、批量处理、标状态、配置规则、导出。 +- Content types: 商品图片、链接、价格、供应商、热度线索、标签、状态、利润数字、风险提醒。 +- Device context: 桌面负责批量筛选和配置,手机负责直播现场快速查看与标记。 +- Emotional job: 从凭感觉找货,变成知道为什么这个款值得拿样。 +- Differentiator: 饰品直播买手采购判断系统,不是收藏夹,也不是泛后台。 + +## Product Archetype + +- Primary: Expert Workbench。登录后界面必须优先服务重复操作、密集信息、稳定布局和快速决策。 +- Secondary: Product Story。未登录首页和关键空状态需要讲清楚产品价值,并显示饰品行业视觉。 +- Density rule: 首页中等密度,工作台中高密度,手机端保留关键信号但减少配置噪音。 +- Risk rule: 删除、风险词、功效话术、供应商不确定性必须有显式状态和恢复路径。 + +## Reference Board + +1. Shopify Polaris: 商家工作流、资源列表、批量操作、设置页、空状态。 +2. Elastic UI: 搜索、过滤、标签、状态、结果列表密度。 +3. Apple HIG: 空间克制、移动端触控目标、材质层级。 +4. Mobbin: 真实产品登录、移动筛选、设置、账号和采集流程。 +5. Tremor: KPI、指标和轻量数据表达。 + +## Art Direction + +- Personality: 专业买手、轻奢材质、行动明确、风险克制、直播生意感。 +- Layout language: 左侧流程导航,顶部决策条,中部筛选台,主体样品卡/决策表,配置区作为工作室面板。 +- Palette: jade 主操作,turquoise 匹配识别,brass 利润机会,cinnabar 风险,paper 工作台底色。 +- Materials: 宣纸网格、玉石高光、旧金票据、样品板、细边框和浅阴影。 +- Motion: 只用于状态反馈、选择、打开弹窗和批量栏浮出。 +- Voice: 短句、业务动词、明确后果,例如“问供应商”“已拿样”“高风险话术”。 + +## Design Principles + +1. 先让买手看到款、价、风险和下一步动作。 +2. 让筛选和批量操作像工作台,不像后台表单。 +3. 风险、利润、拿样状态必须有稳定语义,不只靠颜色。 +4. 手机端保留判断能力,减少配置噪音。 +5. 视觉要有饰品行业信号,但不能影响高频扫读。 + +## Tokens + +- Color: jade 主操作,turquoise 匹配/识别,brass 利润/采购机会,cinnabar 风险,paper 工作台底色。 +- Typography: display 使用中文宋体气质,body 使用中文无衬线,number 使用 serif。 +- Spacing: 4px base,常用 8/12/16/24/32。 +- Radius: 卡片和控件 8px 内,状态 pill 可用胶囊。 +- Elevation: 工作卡轻阴影,模态强阴影,不用深色玻璃。 +- Motion: 控件反馈 120-180ms,页面/卡片轻入场 220ms,支持 reduced motion。 + +## State Tokens + +- Default: paper surface + jade text hierarchy. +- Hover: 轻微上移或边线加强,不改变布局尺寸。 +- Focus: turquoise 外描边,必须键盘可见。 +- Selected: jade 边线 + 浅青底 + 勾选控件。 +- Success: 可跑量、低风险、已完成。 +- Warning: 可测试、继续观察、信息待补。 +- Danger: 高风险、删除、放弃。 +- Empty: 给下一步动作,优先指向采集、插件或规则。 +- Loading: 保持原布局尺寸,不闪动工作台。 + +## Layout + +- Landing: Product Story,首屏包含产品名、核心动作、饰品视觉和工作台预览。 +- Desktop App: 左流程导航、顶部决策栏、KPI、筛选台、样品卡列表。 +- Mobile App: 顶部搜索、关键动作、底部流程导航,配置入口下沉。 + +## Components + +- BuyerHero: 首页主视觉与产品预览。 +- SourcingNav: 业务流程导航。 +- SignalKPI: 线索、拿样、货源、匹配组等状态指标。 +- FilterBench: 关键词、平台、赛道、动作、状态、标签、价格、货源、风险筛选。 +- SampleCard: 商品图片、平台、决策、风险、利润、状态和操作。 +- RiskSignal: 低风险/有风险/高风险。 +- ProfitSignal: 可跑量/可测试/暂不做。 +- BulkActionBar: 多选后浮出批量处理。 +- AuthDialog: 单模式登录/注册弹窗。 +- RuleEditor: 赛道关键词、风险词、利润模型配置。 +- PluginTokenPanel: 插件服务地址与 Token 管理。 + +## Patterns + +- Search/filter: 关键词优先,平台/赛道/状态/价格/风险随后。 +- Bulk actions: 选择后出现批量栏,删除二次确认。 +- Empty state: 给下一步操作,不只显示暂无数据。 +- Risk control: 红色只用于风险和破坏性操作。 +- Auth: 登录和注册单模式切换,不双表单同屏。 +- Mobile: 先看判断信息,再进入配置。 + +## Workbench Patterns + +- Decision first: 顶部先回答今天该做什么,再显示统计。 +- Filter bench: 筛选台必须有标题和当前语境,不能只是表单堆叠。 +- Sample card: 图片、平台、赛道、价格、利润、风险、状态、打开原页必须同卡可见。 +- Bulk action: 选择后批量栏浮出;危险操作必须二次确认。 +- Config studio: 插件和规则配置使用工作室面板,不使用灰色后台表单。 +- Mobile review: 移动端只保留搜索、筛选、状态标记、详情,不放复杂规则编辑在首屏。 + +## QA Criteria + +- 首屏 3 秒内能看出这是饰品直播选品采购系统。 +- 登录后第一屏必须像买手工作台,不像营销页或通用后台。 +- 桌面 1280px 下顶部、导航、KPI、筛选和第一组内容不重叠。 +- 手机 390px 下按钮文字不溢出,底部导航不遮住关键操作。 +- 风险、利润、选中、空状态不只靠颜色表达。 +- 控制台无错误,基础测试通过。 + +## Anti-Patterns + +- 灰暗后台模板。 +- 紫蓝渐变和深色玻璃。 +- 每个 section 都做浮卡或卡片套卡片。 +- 满屏等权重 KPI。 +- 隐藏商品图或用说明文字代替真实控件。 +- 风险和删除用同一个弱提示样式。 diff --git a/直播饰品选品系统_UIUX_Pro_Max_设计系统.md b/直播饰品选品系统_UIUX_Pro_Max_设计系统.md new file mode 100644 index 0000000..7840d8f --- /dev/null +++ b/直播饰品选品系统_UIUX_Pro_Max_设计系统.md @@ -0,0 +1,72 @@ +# 直播饰品选品系统 UI/UX Pro Max 设计系统 + +## Product DNA + +本系统不是通用后台,而是直播饰品买手的 SaaS 工作台。核心任务是把淘宝、小红书竞品线索和 1688 货源放到同一张决策画布里,帮助内部或付费买手快速判断“能不能直播卖、值不值得拿样、供应商是否可靠、利润是否可跑”。 + +产品气质:精明、明亮、带一点东方饰品感、信息密度高、操作直接。界面要像买手工作室的选品台,而不是灰暗开发后台。 + +## Reference Match + +- Airtable:结构化表格、批量选择、视图切换。 +- Shopify Admin:商品和订单式运营节奏,操作清楚。 +- Notion Database:轻量数据卡片、筛选和状态管理。 +- Linear:清晰任务流、紧凑列表和高对比状态。 +- Apple Store Online:克制的高级感、图片优先、留白有节奏。 + +## Tokens + +### Color + +- `bg`: 纸白到浅玉绿,承载长时间工作。 +- `surface`: 温润白、浅米白,用于面板和卡片。 +- `primary`: 翡翠绿,用于主操作、当前状态、关键路径。 +- `accent`: 黄铜金,用于利润、跑量、积分和高价值提示。 +- `danger`: 朱砂红,用于风险、删除、停用。 +- `info`: 松石青,用于采集、插件、链接状态。 +- `text`: 墨黑棕,避免冷灰后台感。 +- `muted`: 低饱和绿灰,用于辅助文字。 + +### Typography + +- 标题:系统中文宋体序列,形成饰品/买手感。 +- 正文:系统中文黑体序列,保证密集数据可读。 +- 数字:衬线数字,用于价格、积分、评分和 KPI。 +- 字号:12 / 13 / 14 / 16 / 20 / 28 / 36,按钮和表单最低 14。 + +### Layout + +- 桌面:左侧固定业务流程导航,右侧为顶部任务状态、KPI、工作台、筛选台、数据列表。 +- 移动端:顶部轻量搜索和操作,底部 6 个一级入口,筛选进入底部弹层。 +- 页面宽度:桌面内容保持可扫描,不做满屏灰表格;列表卡片使用稳定网格。 +- 间距:4/8px 节奏,核心面板 12-20px,触控目标不小于 44px。 + +## Components + +- Sidebar:以业务流程分组,不以技术模块分组。当前项要有清楚指示。 +- Topbar:展示当前账号、导出、刷新和产品定位,不抢主任务。 +- Module Hero:每个核心模块第一屏说明此页的决策用途和主动作。 +- Filter Bench:筛选状态必须可见,包括当前结果数量和已启用筛选。 +- Item Card:图片优先,价格、利润、风险、赛道和状态必须同屏可见。 +- Bulk Toolbar:只在已选中时出现,批量清理和改状态要比普通操作更醒目。 +- Admin Center:管理员统计、兑换码、用户管理分区清楚,危险动作空间隔离。 +- Plugin Settings:服务地址、Token 列表、复制配置和积分说明统一放在插件页。 + +## Motion And States + +- Hover/pressed:轻微上移或色层变化,150-220ms。 +- Focus:保留 3px 高可见 focus ring。 +- Disabled:降低透明度并禁用鼠标手势。 +- Dialog:使用强遮罩和清晰关闭入口。 +- Reduced motion:禁用非必要动画和 transform。 + +## Anti Patterns + +- 不做灰暗后台模板。 +- 不用一整套单色绿色。 +- 不用大面积紫蓝渐变。 +- 不让首页第一屏空旷。 +- 不用 emoji 作为结构图标。 +- 不让登录和注册同时出现。 +- 不把插件 Token 和系统地址长期暴露在插件外层。 +- 不让移动端出现横向滚动或底部栏遮挡内容。 diff --git a/直播饰品选品系统_UI提示词体系.md b/直播饰品选品系统_UI提示词体系.md new file mode 100644 index 0000000..1b490f4 --- /dev/null +++ b/直播饰品选品系统_UI提示词体系.md @@ -0,0 +1,443 @@ +# 直播饰品选品采购系统 UI 提示词体系 + +这套提示词适用于本系统的 UI 设计、前端重构、Figma 设计生成、v0/Claude/Cursor 等 AI 生成界面。系统定位是内部使用的直播饰品选品采购工作台,覆盖电脑端和手机端。 + +## 总设计方向 + +```text +设计一个内部使用的“直播饰品选品采购工作台”,不是营销官网,不要 landing page。 + +系统用于从淘宝、小红书、1688 采集商品/笔记信息,帮助团队筛选饰品竞品、评估货源、计算利润、生成直播卖点、管理拿样采购流程。 + +整体风格:专业、高效、清晰、偏运营工具。界面要像采购决策台,而不是电商商城。重点是快速扫描、批量操作、风险提示、利润判断、样品跟进。 + +品类特征:饰品、藏式/民族风、耳饰、项链、手链/手镯、戒指、发饰、包挂/手机链,第一批以 100 元以内直播跑量款为主。 + +视觉关键词:干净、细腻、轻奢但克制、运营效率、数据清楚、商品图片突出、状态明确。 +``` + +## 设计系统提示词 + +```text +请建立一套完整 UI Design System,适用于桌面端和移动端。 + +颜色体系: +- 主色:深青绿色,用于主按钮、选中状态、重点数据。 +- 辅助色:柔和金色或琥珀色,用于利润、机会分、优先推进。 +- 风险色:低饱和红色,用于高风险、删除、敏感宣传词。 +- 成功色:绿色,用于可跑量、已拿样、利润健康。 +- 背景:浅灰白,不要大面积纯白刺眼。 +- 卡片:白色或极浅灰,边框细,阴影轻。 + +字体: +- 中文优先使用系统无衬线字体。 +- 数字要清晰,适合看价格、毛利、评分。 +- 标题不要过大,整体信息密度要高。 + +圆角: +- 卡片和按钮 8px 左右。 +- 不要过度圆润,不要可爱风。 + +组件: +- 商品卡片 +- 数据指标卡 +- 筛选工具条 +- 批量操作条 +- 风险标签 +- 利润标签 +- 状态标签 +- 相似款组卡片 +- 拿样流程卡片 +- 商品详情抽屉或弹窗 +- 直播卖点卡 +- 供应商对比区 +- 空状态 +- 加载状态 +- 删除确认状态 + +交互原则: +- 支持快速筛选、批量勾选、批量改状态、批量打标签。 +- 重要操作要二次确认。 +- 手机端优先单手操作。 +- 电脑端优先高信息密度和批量处理。 +``` + +## 电脑端 UI 提示词 + +```text +设计桌面端 1440px 宽度的内部选品采购工作台。 + +布局: +左侧固定导航栏,宽约 260-300px。 +导航包含: +- 候选库 +- 选品看板 +- 拿样清单 +- 相似款组 +- 拿样 +- 继续观察 +- 暂不做 +- 插件安装提示 + +主区域顶部: +- 页面标题:直播饰品选品库 +- 副标题:淘宝 / 小红书 / 1688 可见页采集 +- 右侧按钮:刷新、导出 CSV + +主内容: +第一屏显示核心统计: +- 候选总数 +- 建议拿样 +- 1688 货源 +- 藏式/民族风 + +下面是筛选栏: +- 关键词搜索 +- 平台筛选 +- 赛道筛选 +- 状态筛选 +- 标签筛选 + +候选库视图: +使用响应式商品卡片网格,每行 3-4 张。 +每张卡片包含: +- 左上角多选框 +- 商品图片 +- 平台标签 +- 决策标签:拿样 / 继续观察 / 暂不做 +- 标题 +- 赛道 +- 进货价 / 建议直播价 +- 预计毛利 +- 状态 +- 人工标签和自动标签 +- 按钮:详情、打开原页 + +批量操作条: +当选中商品后出现,展示“已选 X 条”。 +按钮包括: +- 全选当前结果 +- 取消选择 +- 问供应商 +- 已拿样 +- 放弃 +- 加标签 +- 删除所选 +删除时进入确认状态,按钮变为“确认删除 / 返回”。 + +选品看板视图: +上方为数据卡片和趋势摘要。 +中间展示: +- 价格带分布 +- 赛道分布 +- 采购动作建议 +- 优先推进款列表 +- 高风险款提醒 +- 利润健康款 + +拿样清单视图: +采用任务流布局。 +每条任务包含: +- 商品标题 +- 平台 / 赛道 / 价格 +- 当前状态 +- 供应商 +- 快捷动作:问供应商、已拿样、直播测试、放弃 +- 备注入口 + +相似款组视图: +每个款式组是一张宽卡。 +展示: +- 组名,例如:藏式/民族风 · 绿松石/编绳 +- 线索数量 +- 包含平台 +- 最低 1688 货源价 +- 竞品售价 +- 建议直播价 +- 机会分 +- 组内商品缩略列表 +- 最优供应商推荐 + +详情弹窗或右侧抽屉: +展示商品大图、原链接、评分、利润模型、直播卖点、供应商提问、风险提醒、备注、状态、标签。 +详情页要适合运营人员快速判断是否拿样。 +``` + +## 手机端 UI 提示词 + +```text +设计移动端 390px 宽度的直播饰品选品采购工具。 + +布局: +使用底部 Tab 导航,包含: +- 候选 +- 看板 +- 拿样 +- 款式组 +- 我的 + +顶部: +- 简洁标题:选品库 +- 搜索框 +- 筛选按钮 +- 刷新按钮可以放在右上角图标 + +候选列表: +使用单列商品卡片。 +每张卡片包含: +- 商品图,比例 4:3 +- 多选框 +- 平台标签 +- 决策标签 +- 标题,两行截断 +- 进货价、建议直播价、毛利 +- 状态标签 +- 标签行 +- 底部操作:详情、打开原页 + +移动端批量操作: +当勾选商品后,底部出现固定操作条。 +显示: +- 已选 X 条 +- 改状态 +- 加标签 +- 删除 +点击“改状态”弹出底部操作面板: +- 问供应商 +- 已拿样 +- 直播测试 +- 放弃 + +筛选: +点击筛选按钮打开底部抽屉。 +包含: +- 平台 +- 赛道 +- 决策 +- 状态 +- 标签 +- 价格区间 +- 是否 1688 货源 +- 是否高风险 + +看板: +手机端不要堆太多图表,优先展示决策摘要。 +模块: +- 今日候选 +- 建议拿样 +- 低价跑量款 +- 高风险款 +- 藏式/民族风 +- 下一步动作建议 + +拿样清单: +使用任务卡片。 +每张卡片展示: +- 商品标题 +- 状态进度 +- 供应商 +- 进货价 +- 快捷按钮:问供应商、已拿样、直播测试 + +款式组: +使用折叠卡片。 +每组显示: +- 款式组标题 +- 线索数 +- 最低货源价 +- 建议直播价 +- 机会分 +点击展开后显示组内商品。 + +详情页: +手机端使用全屏详情页或底部大抽屉。 +顶部是商品图和标题。 +下方分 Tab: +- 基本信息 +- 利润 +- 直播话术 +- 供应商 +- 风险 +- 备注 + +所有按钮高度至少 44px,适合手指点击。 +底部主要操作固定,例如“标记问供应商”“加入拿样清单”。 +``` + +## 商品卡片组件提示词 + +```text +设计一个饰品选品商品卡片组件。 + +卡片用于内部采购筛选,不是面向消费者的商品销售卡。 + +必须包含: +- 多选框 +- 商品图片 +- 平台标签:淘宝 / 小红书 / 1688 +- 决策标签:拿样 / 继续观察 / 暂不做 +- 商品标题 +- 赛道标签 +- 进货价 +- 建议直播价 +- 预计毛利 +- 毛利率 +- 状态:新采集 / 待复核 / 问供应商 / 已拿样 / 直播测试 / 已放弃 +- 风险标签 +- 人工标签 +- 按钮:详情、打开原页 + +视觉要求: +信息密度高,但不要拥挤。 +价格和毛利要突出。 +风险用红色小标签。 +可跑量用绿色或金色标签。 +图片不能太小。 +``` + +## 详情页/弹窗提示词 + +```text +设计商品详情弹窗,适合采购人员快速判断是否拿样。 + +结构: +左侧展示商品图和原链接。 +右侧展示详细信息。 + +内容模块: +- 商品标题、平台、赛道 +- 综合评分 +- 建议动作 +- 进货价 +- 建议直播价 +- 预计毛利 +- 毛利率 +- 供应商/店铺 +- 状态选择 +- 人工标签输入 +- 备注输入 +- 直播卖点 +- 价格话术 +- 供应商提问清单 +- 风险提醒 +- 评分理由 + +操作: +- 保存状态 +- 标记问供应商 +- 标记已拿样 +- 删除 + +设计风格: +像内部审核面板,清晰、专业、方便快速决策。 +``` + +## 拿样清单提示词 + +```text +设计拿样清单页面,用于管理采购跟进。 + +页面目标: +让用户一眼看到哪些款需要问供应商、哪些已拿样、哪些要直播测试。 + +字段: +- 商品标题 +- 商品图 +- 赛道 +- 平台 +- 供应商 +- 进货价 +- 建议直播价 +- 当前状态 +- 备注 +- 最近更新时间 + +状态流程: +新采集 → 待复核 → 问供应商 → 已拿样 → 直播测试 → 放弃 / 复购 + +交互: +- 快捷改状态 +- 批量改状态 +- 批量加标签 +- 打开详情 +- 打开原页 + +视觉: +任务流或看板式都可以,但要偏实用,适合每天采购跟进。 +``` + +## 相似款组提示词 + +```text +设计相似款组页面,用于把淘宝、小红书竞品和 1688 货源自动归组。 + +每个组展示: +- 款式组名称 +- 赛道 +- 关键词 +- 线索数量 +- 包含平台 +- 最低 1688 进货价 +- 竞品参考售价 +- 建议直播价 +- 机会分 +- 最优供应商 +- 组内商品列表 + +重点: +让用户快速判断: +这个款是不是有人卖? +有没有 1688 货源? +利润够不够? +是否适合直播跑量? + +视觉: +组卡片要比普通商品卡更宽,适合对比。 +内部商品用紧凑列表或小卡片。 +``` + +## 选品看板提示词 + +```text +设计选品看板页面,用于老板或运营快速判断当前选品池质量。 + +核心模块: +- 候选总数 +- 建议拿样 +- 1688 货源数 +- 藏式/民族风数量 +- 价格带分布 +- 赛道分布 +- 状态分布 +- 高风险数量 +- 利润可跑款数量 +- 优先推进款 +- 下一步动作建议 + +设计要求: +不要做复杂炫酷大屏。 +要像实用运营看板。 +数据卡片清晰,能快速指导采购动作。 +``` + +## 负面提示词 + +```text +不要设计成电商商城首页。 +不要做营销落地页。 +不要大面积渐变背景。 +不要过度插画。 +不要低信息密度的大卡片堆叠。 +不要只展示商品图片而忽略价格、利润、状态、风险。 +不要用过于可爱、少女、网红风的视觉。 +不要把电脑端做成手机端放大版。 +不要把手机端做成电脑表格缩小版。 +不要隐藏批量操作。 +不要让删除、放弃等危险操作缺少确认状态。 +``` + +## 一句话总 Prompt + +```text +请为一个内部使用的直播饰品选品采购系统设计专业 UI,系统支持淘宝/小红书/1688 采集、竞品筛选、1688 货源对比、利润测算、直播卖点生成、风险提醒、批量操作、拿样清单和相似款归组。设计要同时覆盖桌面端和移动端,桌面端强调高信息密度和批量处理,移动端强调单手操作和快速决策。整体风格克制、专业、清晰,适合饰品直播卖货团队日常选品采购。 +```