commit e9939ef9dc9bd911e03fb7ff6daabd170ad311bc Author: mosen <181942704@qq.com> Date: Mon Jun 15 00:52:31 2026 +0800 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 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 0000000..9254a41 Binary files /dev/null and b/extension/icons/icon128.png differ diff --git a/extension/icons/icon16.png b/extension/icons/icon16.png new file mode 100644 index 0000000..8d45190 Binary files /dev/null and b/extension/icons/icon16.png differ diff --git a/extension/icons/icon32.png b/extension/icons/icon32.png new file mode 100644 index 0000000..541b154 Binary files /dev/null and b/extension/icons/icon32.png differ diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png new file mode 100644 index 0000000..bfbd745 Binary files /dev/null and b/extension/icons/icon48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..7c08531 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,52 @@ +{ + "manifest_version": 3, + "name": "选品采购采集器", + "version": "0.2.0", + "description": "采集当前淘宝、小红书、1688 等页面可见的图片、链接、标题、价格等信息到选品采购台。", + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_title": "采集选品", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "permissions": ["activeTab", "scripting", "storage", "tabs"], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "http://127.0.0.1:4777/*", + "http://localhost:4777/*", + "https://*.taobao.com/*", + "https://*.tmall.com/*", + "https://*.xiaohongshu.com/*", + "https://*.xhslink.com/*", + "https://*.1688.com/*" + ], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "host_permissions": [ + "https://*/*", + "http://*/*", + "http://127.0.0.1:4777/*", + "http://localhost:4777/*", + "https://*.taobao.com/*", + "https://*.tmall.com/*", + "https://*.xiaohongshu.com/*", + "https://*.xhslink.com/*", + "https://*.1688.com/*" + ] +} diff --git a/extension/popup.css b/extension/popup.css new file mode 100644 index 0000000..a9112bf --- /dev/null +++ b/extension/popup.css @@ -0,0 +1,190 @@ +* { + box-sizing: border-box; +} + +body { + width: 350px; + margin: 0; + background: + radial-gradient(circle at 0 0, rgba(42, 156, 145, 0.18), transparent 34%), + linear-gradient(135deg, #fbfffd, #fff8f2); + color: #241a14; + font-family: Arial, "PingFang SC", "Microsoft YaHei", sans-serif; +} + +.popup { + padding: 14px; +} + +header { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.popup-brand { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.popup-brand img { + display: block; + width: 42px; + height: 42px; + border-radius: 8px; + box-shadow: 0 10px 22px rgba(13, 107, 88, 0.2); +} + +.popup-brand > 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)}。

` : ""} + + `; +} + +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)} + +
    + `; +} + +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 0000000..676db1e Binary files /dev/null and b/public/assets/brand-mark.png differ diff --git a/public/assets/brand-mark.svg b/public/assets/brand-mark.svg new file mode 100644 index 0000000..64e1c5c --- /dev/null +++ b/public/assets/brand-mark.svg @@ -0,0 +1,27 @@ + + 选品采购台品牌标识 + 扫描框汇聚多平台商品机会,中心标记代表被选中的高价值商品 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/jewelry-buyer-board.png b/public/assets/jewelry-buyer-board.png new file mode 100644 index 0000000..883c3b1 Binary files /dev/null and b/public/assets/jewelry-buyer-board.png differ diff --git a/public/assets/share-card.png b/public/assets/share-card.png new file mode 100644 index 0000000..3e6104e Binary files /dev/null and b/public/assets/share-card.png differ diff --git a/public/assets/share-card.svg b/public/assets/share-card.svg new file mode 100644 index 0000000..c0f136b --- /dev/null +++ b/public/assets/share-card.svg @@ -0,0 +1,73 @@ + + 选品采购台分享封面 + 采集商品线索,匹配货源,完成采购决策 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 选品采购台 + 从商品机会,到放心采购 + 采集竞品 · 匹配货源 · 利润测算 · 拿样决策 + + + PRODUCT OPPORTUNITY FLOW + + + + + 发现高潜商品 + 淘宝 / 小红书 / 更多平台线索 + + + + + + + + 匹配优质货源 + 1688 厂家 / 价格 / 保障 + + + + + + + + 形成采购决策 + 筛选 / 利润 / 风险 / 拿样 + + + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..01b138e Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a500b3a --- /dev/null +++ b/public/index.html @@ -0,0 +1,497 @@ + + + + + + 选品采购台|竞品采集、货源匹配与采购决策 + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + 正在进入选品采购台 +
    + + +
    +
    +
    +
    +

    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 货源对比、利润测算、直播卖点生成、风险提醒、批量操作、拿样清单和相似款归组。设计要同时覆盖桌面端和移动端,桌面端强调高信息密度和批量处理,移动端强调单手操作和快速决策。整体风格克制、专业、清晰,适合饰品直播卖货团队日常选品采购。 +```