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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 00:52:31 +08:00
commit e9939ef9dc
45 changed files with 14376 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.env
.env.*
!.env.example
.DS_Store
*.log
data/items.json
+7
View File
@@ -0,0 +1,7 @@
# 端口
| 环境 | 端口 | 说明 |
|------|------|------|
| 本地开发 | 4777 | `npm start`,可通过 `PORT` 覆盖 |
| 生产 Node | 9477 | PM2 `product-sourcing`,仅本机监听 |
| 生产对外 | 443 | Nginx 反代 `https://sourcing.simosen.cn` |
View File
+11
View File
@@ -0,0 +1,11 @@
{
"items": [],
"users": [],
"sessions": [],
"pluginTokens": [],
"creditCodes": [],
"creditLogs": [],
"aiTokenLogs": [],
"aiSettings": {},
"rules": {}
}
+18
View File
@@ -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,
},
],
};
+17
View File
@@ -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;
}
+14
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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 语法检查通过。
+9
View File
@@ -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 });
}
});
+189
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+52
View File
@@ -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/*"
]
}
+190
View File
@@ -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;
}
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>选品采购采集器</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main class="popup">
<header>
<div class="popup-brand">
<img src="icons/icon48.png" alt="" />
<div>
<strong>选品采购采集器</strong>
<span id="statusText">连接本地服务中</span>
<span id="versionText">插件版本检测中</span>
</div>
</div>
</header>
<section class="actions">
<button id="capturePageBtn" class="primary">采集当前页</button>
<button id="captureMainBtn">只采集主商品/笔记</button>
<button id="testSaveBtn">测试保存到系统</button>
<button id="openDashboardBtn">打开选品库</button>
</section>
<details class="settings" id="settingsPanel">
<summary>设置</summary>
<div class="settings-body">
<label>
<span>系统地址</span>
<input id="apiBaseInput" placeholder="https://your-domain.com" />
</label>
<label>
<span>插件 Token</span>
<input id="pluginTokenInput" type="password" placeholder="pst_..." />
</label>
<button id="saveSettingsBtn">保存连接配置</button>
<section class="update-panel" id="updatePanel" hidden>
<strong>插件更新</strong>
<p id="updateText">系统推荐使用新版采集器。</p>
<button id="updateGuideBtn" type="button">查看更新方式</button>
</section>
</div>
</details>
<section class="result" id="resultBox">
<p>首次使用请打开“设置”配置系统地址和插件 Token。配置保存后会自动收起。</p>
</section>
</main>
<script type="module" src="popup.js"></script>
</body>
</html>
+272
View File
@@ -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 = "<p>连接配置已保存,下次打开会自动带出。</p>";
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 = "<p>正在采集当前页面...</p>";
setButtonsDisabled(true);
try {
const items = await runExtractor(mode);
if (!items.length) {
resultBox.innerHTML = "<p>没有识别到可采集的商品/笔记。可以滚动页面后再试,或打开详情页采集主商品。</p>";
return;
}
const payload = await postCaptures(items);
resultBox.innerHTML = renderSavedResult(payload.items || [], payload.credits);
} catch (error) {
resultBox.innerHTML = `
<p>采集失败:${escapeHtml(error.message)}</p>
<p>排查:确认系统地址和插件 Token;当前页不是浏览器内部页;淘宝/小红书/1688 页面可刷新后重试。</p>
`;
} 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 = "<p>正在测试写入系统...</p>";
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 = `
<p>测试保存成功。采集链路正常。</p>
<p>已写入:${escapeHtml(payload.items?.[0]?.title || "测试款")}</p>
${payload.credits ? `<p>已扣 ${escapeHtml(payload.credits.deducted)} 积分,剩余 ${escapeHtml(payload.credits.remaining)}。</p>` : ""}
`;
} catch (error) {
resultBox.innerHTML = `
<p>测试保存失败:${escapeHtml(error.message)}</p>
<p>请确认系统地址可访问,SaaS 模式需要填写插件 Token。</p>
`;
} finally {
setButtonsDisabled(false);
}
}
function renderSavedResult(items, credits) {
return `
<p>已保存 <strong>${items.length}</strong> 条候选。</p>
${credits ? `<p>已扣 ${escapeHtml(credits.deducted)} 积分,剩余 ${escapeHtml(credits.remaining)}。</p>` : ""}
<ul>
${items
.slice(0, 5)
.map((item) => `<li>${escapeHtml(item.decision)} · ${escapeHtml(item.title || "未命名")}</li>`)
.join("")}
</ul>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 = `<p>${escapeHtml(guide)}</p><p>安装包:<code>${escapeHtml(latestExtensionInfo?.fileName || "product-sourcing-capture-extension.zip")}</code></p>`;
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}`;
}
+11
View File
@@ -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"
}
}
+2510
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 180" role="img" aria-labelledby="title desc">
<title id="title">选品采购台</title>
<desc id="desc">竞品采集、货源匹配与采购决策</desc>
<defs>
<linearGradient id="jade" x1="22" y1="18" x2="158" y2="162" gradientUnits="userSpaceOnUse">
<stop stop-color="#249B90"/>
<stop offset=".52" stop-color="#0D6A54"/>
<stop offset="1" stop-color="#073C31"/>
</linearGradient>
<linearGradient id="brass" x1="58" y1="54" x2="122" y2="126" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE6A1"/>
<stop offset=".55" stop-color="#D39A36"/>
<stop offset="1" stop-color="#A56F18"/>
</linearGradient>
</defs>
<rect x="10" y="10" width="160" height="160" rx="36" fill="#FFFDF5"/>
<rect x="18" y="18" width="144" height="144" rx="31" fill="url(#jade)"/>
<path d="M48 74V57c0-5 4-9 9-9h17M106 48h17c5 0 9 4 9 9v17M132 106v17c0 5-4 9-9 9h-17M74 132H57c-5 0-9-4-9-9v-17" fill="none" stroke="#FFF8E8" stroke-linecap="round" stroke-width="8"/>
<path d="M90 57 123 90 90 123 57 90 90 57Z" fill="url(#brass)"/>
<path d="M90 70 110 90 90 110 70 90 90 70Z" fill="#FFF8E8"/>
<circle cx="90" cy="90" r="10" fill="#0D6A54"/>
<circle cx="125" cy="55" r="8" fill="#C9544A" stroke="#FFF8E8" stroke-width="3"/>
<text x="205" y="86" fill="#073C31" font-family="Songti SC, STSong, Noto Serif CJK SC, serif" font-size="47" font-weight="700">选品采购台</text>
<text x="207" y="128" fill="#65766A" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="21" font-weight="500">竞品采集 · 货源匹配 · 采购决策</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">选品采购台品牌标识</title>
<desc id="desc">扫描框汇聚多平台商品机会,中心标记代表被选中的高价值商品</desc>
<defs>
<linearGradient id="jade" x1="34" y1="24" x2="222" y2="232" gradientUnits="userSpaceOnUse">
<stop stop-color="#249B90"/>
<stop offset=".52" stop-color="#0D6A54"/>
<stop offset="1" stop-color="#073C31"/>
</linearGradient>
<linearGradient id="brass" x1="91" y1="80" x2="174" y2="181" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE6A1"/>
<stop offset=".54" stop-color="#D39A36"/>
<stop offset="1" stop-color="#A56F18"/>
</linearGradient>
<filter id="shadow" x="-25%" y="-25%" width="150%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#073C31" flood-opacity=".24"/>
</filter>
</defs>
<rect width="256" height="256" rx="56" fill="#FFFDF5"/>
<rect x="12" y="12" width="232" height="232" rx="48" fill="url(#jade)" filter="url(#shadow)"/>
<rect x="24" y="24" width="208" height="208" rx="38" fill="none" stroke="#FFF8E8" stroke-opacity=".24" stroke-width="2"/>
<path d="M57 98V70c0-7 6-13 13-13h28M158 57h28c7 0 13 6 13 13v28M199 158v28c0 7-6 13-13 13h-28M98 199H70c-7 0-13-6-13-13v-28" fill="none" stroke="#FFF8E8" stroke-linecap="round" stroke-width="13"/>
<path d="M128 72 184 128 128 184 72 128 128 72Z" fill="url(#brass)"/>
<path d="M128 91 165 128 128 165 91 128 128 91Z" fill="#FFF8E8"/>
<path d="M111 128c0-9 7-17 17-17s17 8 17 17-7 17-17 17-17-8-17-17Z" fill="#0D6A54"/>
<circle cx="174" cy="82" r="12" fill="#C9544A" stroke="#FFF8E8" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+73
View File
@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
<title id="title">选品采购台分享封面</title>
<desc id="desc">采集商品线索,匹配货源,完成采购决策</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8FFF9"/>
<stop offset=".52" stop-color="#E8F6EF"/>
<stop offset="1" stop-color="#FFF5DE"/>
</linearGradient>
<linearGradient id="jade" x1="50" y1="44" x2="242" y2="236" gradientUnits="userSpaceOnUse">
<stop stop-color="#249B90"/>
<stop offset=".52" stop-color="#0D6A54"/>
<stop offset="1" stop-color="#073C31"/>
</linearGradient>
<linearGradient id="brass" x1="102" y1="91" x2="184" y2="184" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE6A1"/>
<stop offset=".55" stop-color="#D39A36"/>
<stop offset="1" stop-color="#A56F18"/>
</linearGradient>
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M32 0H0V32" fill="none" stroke="#0D6A54" stroke-opacity=".055"/>
</pattern>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#073C31" flood-opacity=".16"/>
</filter>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<rect width="1200" height="630" fill="url(#grid)"/>
<circle cx="1120" cy="32" r="260" fill="#FFE6A1" opacity=".42"/>
<circle cx="42" cy="610" r="250" fill="#249B90" opacity=".12"/>
<g transform="translate(66 62)" filter="url(#shadow)">
<rect width="208" height="208" rx="46" fill="#FFFDF5"/>
<rect x="10" y="10" width="188" height="188" rx="40" fill="url(#jade)"/>
<path d="M52 88V66c0-6 5-11 11-11h22M123 55h22c6 0 11 5 11 11v22M156 123v22c0 6-5 11-11 11h-22M85 156H63c-6 0-11-5-11-11v-22" fill="none" stroke="#FFF8E8" stroke-linecap="round" stroke-width="10"/>
<path d="M104 64 144 104 104 144 64 104 104 64Z" fill="url(#brass)"/>
<path d="M104 80 128 104 104 128 80 104 104 80Z" fill="#FFF8E8"/>
<circle cx="104" cy="104" r="12" fill="#0D6A54"/>
<circle cx="151" cy="58" r="10" fill="#C9544A" stroke="#FFF8E8" stroke-width="4"/>
</g>
<text x="66" y="334" fill="#073C31" font-family="Songti SC, STSong, Noto Serif CJK SC, serif" font-size="64" font-weight="700">选品采购台</text>
<text x="69" y="405" fill="#315F55" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="28" font-weight="600">从商品机会,到放心采购</text>
<text x="69" y="458" fill="#65766A" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="21">采集竞品 · 匹配货源 · 利润测算 · 拿样决策</text>
<g transform="translate(660 88)">
<rect width="464" height="452" rx="28" fill="#FFFDF5" stroke="#B9D5C7" stroke-width="2" filter="url(#shadow)"/>
<text x="34" y="56" fill="#65766A" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="17" font-weight="600">PRODUCT OPPORTUNITY FLOW</text>
<g transform="translate(34 86)">
<rect width="396" height="78" rx="14" fill="#E5F4EE" stroke="#B9D5C7"/>
<circle cx="40" cy="39" r="20" fill="#0D6A54"/>
<path d="M31 39h18M40 30v18" stroke="#FFF8E8" stroke-linecap="round" stroke-width="4"/>
<text x="76" y="33" fill="#073C31" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="20" font-weight="700">发现高潜商品</text>
<text x="76" y="57" fill="#65766A" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="14">淘宝 / 小红书 / 更多平台线索</text>
</g>
<path d="M232 171v24" stroke="#A56F18" stroke-linecap="round" stroke-width="4"/>
<path d="m224 190 8 8 8-8" fill="none" stroke="#A56F18" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
<g transform="translate(34 202)">
<rect width="396" height="78" rx="14" fill="#FFF3CB" stroke="#D9BD79"/>
<path d="M20 24h40v30H20z" fill="#A56F18" rx="4"/>
<path d="M26 24v-8h28v8" fill="none" stroke="#A56F18" stroke-width="4"/>
<text x="76" y="33" fill="#5F4211" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="20" font-weight="700">匹配优质货源</text>
<text x="76" y="57" fill="#7A6645" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="14">1688 厂家 / 价格 / 保障</text>
</g>
<path d="M232 287v24" stroke="#A56F18" stroke-linecap="round" stroke-width="4"/>
<path d="m224 306 8 8 8-8" fill="none" stroke="#A56F18" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
<g transform="translate(34 318)">
<rect width="396" height="96" rx="14" fill="#0D6A54"/>
<circle cx="40" cy="48" r="22" fill="#FFF8E8"/>
<path d="m30 48 7 7 14-16" fill="none" stroke="#0D6A54" stroke-linecap="round" stroke-linejoin="round" stroke-width="5"/>
<text x="76" y="42" fill="#FFF8E8" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="20" font-weight="700">形成采购决策</text>
<text x="76" y="67" fill="#DDF2EF" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="14">筛选 / 利润 / 风险 / 拿样</text>
<circle cx="367" cy="25" r="8" fill="#C9544A"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+497
View File
@@ -0,0 +1,497 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>选品采购台|竞品采集、货源匹配与采购决策</title>
<meta name="description" content="采集淘宝、小红书等平台商品线索,匹配 1688 货源,完成筛选、利润测算、拿样与采购决策。" />
<meta name="theme-color" content="#0d6a54" />
<meta name="application-name" content="选品采购台" />
<meta name="apple-mobile-web-app-title" content="选品采购台" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:site_name" content="选品采购台" />
<meta property="og:title" content="选品采购台|从商品机会,到放心采购" />
<meta property="og:description" content="采集竞品、匹配货源、测算利润、推进拿样,一套系统完成选品采购决策。" />
<meta property="og:image" content="__SHARE_ORIGIN__/assets/share-card.png" />
<meta property="og:image:secure_url" content="__SHARE_ORIGIN__/assets/share-card.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content="__SHARE_ORIGIN__/" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="选品采购台|从商品机会,到放心采购" />
<meta name="twitter:description" content="采集竞品、匹配货源、测算利润、推进拿样,一套系统完成选品采购决策。" />
<meta name="twitter:image" content="__SHARE_ORIGIN__/assets/share-card.png" />
<meta itemprop="name" content="选品采购台|从商品机会,到放心采购" />
<meta itemprop="description" content="采集竞品、匹配货源、测算利润、推进拿样,一套系统完成选品采购决策。" />
<meta itemprop="image" content="__SHARE_ORIGIN__/assets/share-card.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/assets/brand-mark.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/assets/brand-mark.png" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="auth-pending">
<div id="startupScreen" class="startup-screen" role="status" aria-live="polite">
<span><img src="/assets/brand-mark.svg" alt="" /></span>
<strong>正在进入选品采购台</strong>
</div>
<section id="authGate" class="auth-gate" hidden>
<header class="landing-nav">
<a class="landing-logo" href="#authGate" aria-label="选品采购台">
<span><img src="/assets/brand-mark.svg" alt="" /></span>
<strong>选品采购台</strong>
</a>
<nav class="landing-links" aria-label="首页导航">
<a href="#landingFlow">流程</a>
<a href="#landingSignals">能力</a>
<button type="button" data-auth-mode="login">登录</button>
</nav>
<div class="landing-nav-actions">
<button id="landingBackBtn" class="button secondary" type="button" hidden>返回工作台</button>
<button class="button secondary" type="button" data-auth-mode="login">登录</button>
<button class="button primary" type="button" data-auth-mode="register">创建账号</button>
</div>
</header>
<main class="landing-main">
<section class="landing-hero">
<div class="landing-hero-copy">
<p class="eyebrow">Product Sourcing Intelligence</p>
<div class="landing-route" aria-label="选品采购路径">
<span>淘宝竞品</span>
<i aria-hidden="true"></i>
<span>小红书趋势</span>
<i aria-hidden="true"></i>
<span>1688 货源</span>
<i aria-hidden="true"></i>
<span>采购决策</span>
</div>
<h1>从商品机会,到放心采购</h1>
<p class="landing-lead">采集淘宝、小红书等平台商品的图片、链接、价格和热度线索,再匹配 1688 同款货源、测算利润、推进拿样,为任何品类建立可复用的采购判断。</p>
<div class="landing-signal-panel" aria-label="首批选品重点">
<div>
<span>第一批策略</span>
<strong>¥100 内跑量</strong>
<em>低价、轻决策、直播易讲</em>
</div>
<div>
<span>主攻赛道</span>
<strong>任意商品类目</strong>
<em>饰品首发 / 规则可自定义</em>
</div>
<div>
<span>采购判断</span>
<strong>利润 + 风险</strong>
<em>拿样价、供应商、话术一起看</em>
</div>
</div>
<div class="landing-actions">
<button class="button primary" type="button" data-auth-mode="register">创建账号开始选品</button>
<button class="button secondary" type="button" data-auth-mode="login">登录已有工作台</button>
</div>
<div class="landing-metrics" aria-label="适用场景">
<div><strong>¥100 内</strong><span>第一批跑量客单价</span></div>
<div><strong>全品类可扩展</strong><span>从饰品模板开始,自定义赛道</span></div>
<div><strong>直播优先</strong><span>拿样、话术、风险一起看</span></div>
</div>
</div>
<section class="landing-product-shot" aria-label="产品工作台预览">
<img class="buyer-board-image" src="/assets/jewelry-buyer-board.png" alt="直播饰品买手选品板,展示饰品图片、利润和货源判断" />
<div class="product-shot-top">
<span>Buyer Command</span>
<strong>今日选品采购</strong>
</div>
<div class="product-shot-stats">
<div><span>线索</span><strong>128</strong></div>
<div><span>可跑量</span><strong>24</strong></div>
<div><span>货源</span><strong>37</strong></div>
</div>
<div class="product-shot-card">
<div class="shot-image"></div>
<div>
<p>藏式绿松石编绳手链</p>
<strong>建议拿样 · 利润可跑</strong>
<span>1688 ¥8.60 → 直播价 ¥39.90</span>
</div>
</div>
<div class="product-shot-table">
<div><span>竞品热度</span><strong></strong></div>
<div><span>供应商保障</span><strong>实力厂家</strong></div>
<div><span>风险提醒</span><strong>避开功效话术</strong></div>
</div>
</section>
</section>
<section id="landingFlow" class="landing-flow" aria-label="选品采购流程">
<div class="landing-section-head">
<p class="eyebrow">Workflow</p>
<h2>从“看见别人卖得好”到“自己敢进货”。</h2>
</div>
<div class="flow-rail">
<article><span>01</span><h3>采集竞品</h3><p>淘宝、小红书页面里的图片、链接、价格、销量和可见话术,一键进库。</p></article>
<article><span>02</span><h3>清洗筛选</h3><p>按饰品赛道、价格带、热度、直播表现力、风险词和利润模型自动打分。</p></article>
<article><span>03</span><h3>匹配货源</h3><p>把 1688 同款/近似款放到竞品旁边,比厂家、拿样价、混批、售后保障。</p></article>
<article><span>04</span><h3>拿样采购</h3><p>批量标记问供应商、已拿样、直播测试,最后沉淀采购决策清单。</p></article>
</div>
</section>
<section id="landingSignals" class="landing-signals" aria-label="系统能力">
<div class="landing-section-head">
<p class="eyebrow">What The System Does</p>
<h2>不是收藏夹,是直播饰品买手的决策系统。</h2>
</div>
<div class="signal-board">
<article><strong>竞品库</strong><span>淘宝 / 小红书 / 天猫</span><p>聚合对标款,保留原图、链接、价格和平台线索。</p></article>
<article><strong>货源库</strong><span>1688</span><p>集中比较供应商、价格、同款相似度和采购保障。</p></article>
<article><strong>款式匹配</strong><span>同款 / 近似款</span><p>按赛道和关键词把竞品与货源自动归组。</p></article>
<article><strong>规则配置</strong><span>可视化后台</span><p>你自己维护赛道关键词、风险词、利润模型和自动标签。</p></article>
</div>
</section>
</main>
</section>
<div class="app-shell">
<header class="mobile-header">
<div class="mobile-title-row">
<div>
<p class="eyebrow">Sourcing</p>
<h1 id="mobileViewTitle">选品采购 SaaS</h1>
</div>
<div class="mobile-header-actions">
<button id="mobileAuthBtn" class="icon-button" type="button" aria-label="账号">账号</button>
<button id="mobileFilterBtn" class="icon-button" type="button" aria-label="筛选">筛选</button>
<button id="mobileRefreshBtn" class="icon-button" type="button" aria-label="刷新">刷新</button>
</div>
</div>
<label class="mobile-search-field">
<span class="sr-only">关键词搜索</span>
<input id="mobileSearchInput" type="search" placeholder="搜索标题、供应商、标签" />
</label>
</header>
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"><img src="/assets/brand-mark.svg" alt="" /></div>
<div>
<h1>选品采购台</h1>
<p>竞品采集 / 货源匹配 / 采购决策</p>
</div>
</div>
<nav class="nav-stack" aria-label="产品导航">
<div class="nav-section">
<p class="nav-section-title">业务流程</p>
<button class="nav-button active" data-view="overview"><span class="nav-icon" aria-hidden="true">01</span><span>首页总览</span></button>
<button class="nav-button" data-view="capture"><span class="nav-icon" aria-hidden="true">02</span><span>采集中心</span></button>
<button class="nav-button" data-view="competitors"><span class="nav-icon" aria-hidden="true">03</span><span>竞品库</span></button>
<button class="nav-button" data-view="suppliers"><span class="nav-icon" aria-hidden="true">04</span><span>货源库</span></button>
<button class="nav-button" data-view="matching"><span class="nav-icon" aria-hidden="true">05</span><span>款式匹配</span></button>
<button class="nav-button" data-view="testing"><span class="nav-icon" aria-hidden="true">06</span><span>拿样测试</span></button>
<button class="nav-button" data-view="decisions"><span class="nav-icon" aria-hidden="true">07</span><span>采购决策</span></button>
</div>
<div class="nav-section nav-admin">
<p class="nav-section-title">系统配置</p>
<button class="nav-button admin-only" data-view="admin" hidden><span class="nav-icon" aria-hidden="true">A</span><span>管理中心</span></button>
<button class="nav-button" data-view="plugin"><span class="nav-icon" aria-hidden="true">T</span><span>采集插件</span></button>
<button class="nav-button" data-view="rules"><span class="nav-icon" aria-hidden="true">R</span><span>规则配置</span></button>
<button class="nav-button" data-view="account"><span class="nav-icon" aria-hidden="true">U</span><span>账号设置</span></button>
</div>
</nav>
<section class="side-brief" aria-label="今日操作重点">
<span>Live Commerce</span>
<strong>从机会到采购决策</strong>
<p>先采集竞品,再补 1688 同款,最后看利润、风险和拿样状态。</p>
</section>
<section class="install-panel">
<h2>插件安装</h2>
<ol>
<li>下载插件安装包并解压。</li>
<li>打开 Chrome 扩展管理。</li>
<li>开启开发者模式。</li>
<li>加载解压后的文件夹。</li>
</ol>
<a id="extensionDownloadLink" class="button primary" href="/downloads/product-sourcing-capture-extension.zip" download>下载插件安装包</a>
<code id="extensionPath">product-sourcing-capture-extension.zip</code>
</section>
</aside>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow">Sourcing Opportunity Desk</p>
<h2>选品采购 SaaS</h2>
<p class="topbar-subtitle">从淘宝、小红书竞品到 1688 货源,完成采集、清洗、匹配、拿样和采购决策。</p>
<div class="topbar-signals" aria-label="产品定位">
<span>Expert Workbench</span>
<span>¥100 内跑量</span>
<span>全品类可扩展</span>
<span>风险可控</span>
</div>
</div>
<div class="topbar-actions">
<span id="sessionBadge" class="session-badge">本地模式</span>
<button id="openAuthPageBtn" class="button secondary" type="button">创建账号/登录</button>
<button id="refreshBtn" class="button secondary">刷新</button>
<button class="button primary" type="button" data-export-csv>导出 CSV</button>
<button id="logoutBtn" class="button secondary" type="button" hidden>退出</button>
</div>
</header>
<section class="command-strip" aria-label="今日任务状态">
<article>
<span>当前路径</span>
<strong>竞品采集 → 1688 货源 → 拿样决策</strong>
</article>
<article>
<span>首批策略</span>
<strong>饰品全赛道 / 100 内跑量</strong>
</article>
<article>
<span>管理员能力</span>
<strong>用户 / 积分 / 兑换码 / Token</strong>
</article>
</section>
<section id="workbenchPanel" class="workbench-panel"></section>
<section class="stats-grid" aria-label="统计">
<article class="stat-card">
<span>线索总数</span>
<strong id="statTotal">0</strong>
</article>
<article class="stat-card">
<span>待拿样</span>
<strong id="statSample">0</strong>
</article>
<article class="stat-card">
<span>1688 货源</span>
<strong id="stat1688">0</strong>
</article>
<article class="stat-card">
<span>匹配款组</span>
<strong id="statTibetan">0</strong>
</article>
</section>
<section id="filterToolbar" class="toolbar filter-bench" aria-label="筛选工具">
<div class="filter-bench-head">
<div>
<p class="eyebrow">Filter Bench</p>
<h3>选品筛选台</h3>
<p id="filterContextText">按平台、赛道、状态、价格和风险快速缩小候选款。</p>
</div>
<div class="filter-bench-status" aria-label="筛选状态">
<span id="filterModeLabel">全部候选</span>
<strong id="filterResultCount">0</strong>
<em id="filterActiveText">未启用筛选</em>
</div>
</div>
<label>
<span>关键词</span>
<input id="searchInput" type="search" placeholder="标题、供应商、赛道、链接" />
</label>
<label>
<span>平台</span>
<select id="platformSelect">
<option value="">全部平台</option>
<option value="xiaohongshu">小红书</option>
<option value="taobao">淘宝</option>
<option value="tmall">天猫</option>
<option value="1688">1688</option>
<option value="other">其他</option>
</select>
</label>
<label>
<span>赛道</span>
<select id="trackSelect">
<option value="">全部赛道</option>
<option value="藏式/民族风">藏式/民族风</option>
<option value="耳饰">耳饰</option>
<option value="项链">项链</option>
<option value="手链/手镯">手链/手镯</option>
<option value="戒指">戒指</option>
<option value="发饰">发饰</option>
<option value="包挂/手机链">包挂/手机链</option>
<option value="银饰/珍珠/天然石风">银饰/珍珠/天然石风</option>
<option value="未分类">未分类</option>
</select>
</label>
<label>
<span>建议动作</span>
<select id="decisionSelect">
<option value="">全部动作</option>
<option value="拿样">拿样</option>
<option value="继续观察">继续观察</option>
<option value="暂不做">暂不做</option>
</select>
</label>
<label>
<span>状态</span>
<select id="statusSelect">
<option value="">全部状态</option>
<option value="new">新采集</option>
<option value="reviewing">待复核</option>
<option value="asking_supplier">问供应商</option>
<option value="ordered_sample">已拿样</option>
<option value="live_testing">直播测试</option>
<option value="rejected">已放弃</option>
</select>
</label>
<label>
<span>标签</span>
<input id="tagInput" type="search" placeholder="低价跑量、藏式主推" />
</label>
<label>
<span>价格带</span>
<select id="priceBandSelect">
<option value="">全部价格</option>
<option value="under10">10 元以内</option>
<option value="10to30">10-30 元</option>
<option value="30to100">30-100 元</option>
<option value="over100">100 元以上</option>
<option value="unknown">待确认</option>
</select>
</label>
<label>
<span>货源</span>
<select id="sourceSelect">
<option value="">全部货源</option>
<option value="only1688">只看 1688</option>
<option value="non1688">竞品平台</option>
</select>
</label>
<label>
<span>风险</span>
<select id="riskSelect">
<option value="">全部风险</option>
<option value="any">有风险提醒</option>
<option value="high">高风险</option>
<option value="low">低风险</option>
</select>
</label>
</section>
<section id="bulkToolbar" class="bulk-toolbar" aria-label="批量清理" hidden>
<div class="bulk-status">
<span id="bulkStatusText">已选</span> <strong id="selectedCount">0</strong>
</div>
<div class="bulk-actions">
<button id="selectVisibleBtn" class="button secondary" type="button">全选当前结果</button>
<button id="clearSelectionBtn" class="button secondary" type="button">取消选择</button>
<button id="mobileStatusMenuBtn" class="button secondary mobile-bulk-status" type="button">改状态</button>
<button class="button secondary" type="button" data-bulk-status="asking_supplier">问供应商</button>
<button class="button secondary" type="button" data-bulk-status="ordered_sample">已拿样</button>
<button class="button secondary" type="button" data-bulk-status="live_testing">直播测试</button>
<button class="button secondary" type="button" data-bulk-status="rejected">放弃</button>
<button id="tagSelectedBtn" class="button secondary" type="button">加标签</button>
<button id="deleteSelectedBtn" class="button danger" type="button">删除所选</button>
<button id="confirmDeleteBtn" class="button danger" type="button" hidden>确认删除</button>
<button id="cancelDeleteBtn" class="button secondary" type="button" hidden>返回</button>
</div>
</section>
<section id="emptyState" class="empty-state" hidden>
<div class="empty-mark" aria-hidden="true"></div>
<h3>还没有采集数据</h3>
<p>先启动服务并安装插件,然后在淘宝、小红书或 1688 页面点击“采集当前页”。</p>
<div class="empty-actions">
<button id="emptyPrimaryBtn" class="button primary" type="button">配置插件</button>
<button id="emptySecondaryBtn" class="button secondary" type="button">清空筛选</button>
</div>
</section>
<section id="itemsGrid" class="items-grid" aria-live="polite"></section>
</main>
</div>
<dialog id="itemDialog">
<form method="dialog" class="dialog-card">
<button class="dialog-close" value="cancel" aria-label="关闭">×</button>
<div id="dialogContent"></div>
</form>
</dialog>
<dialog id="authDialog" class="auth-dialog">
<div class="dialog-card auth-dialog-card">
<button class="dialog-close" type="button" data-auth-close aria-label="关闭">×</button>
<div class="auth-dialog-copy">
<p class="eyebrow">SaaS Workspace</p>
<h2 id="authDialogTitle">登录工作台</h2>
<p id="authDialogText">登录后进入你的直播饰品选品采购工作台。</p>
</div>
<form id="loginForm" class="auth-card auth-mode-panel">
<label>
<span>邮箱</span>
<input name="email" type="email" autocomplete="email" required placeholder="you@example.com" />
</label>
<label>
<span>密码</span>
<input name="password" type="password" autocomplete="current-password" required placeholder="输入密码" />
</label>
<button class="button primary" type="submit">登录工作台</button>
</form>
<form id="registerForm" class="auth-card auth-mode-panel" hidden>
<label>
<span>昵称</span>
<input name="name" autocomplete="name" placeholder="直播饰品买手" />
</label>
<label>
<span>邮箱</span>
<input name="email" type="email" autocomplete="email" required placeholder="you@example.com" />
</label>
<label>
<span>密码</span>
<input name="password" type="password" autocomplete="new-password" minlength="6" required placeholder="至少 6 位" />
</label>
<button class="button primary" type="submit">创建并接管本地库</button>
</form>
<p class="auth-switch-line">
<span id="authSwitchText">还没有账号?</span>
<button id="authSwitchButton" type="button" data-auth-switch>创建账号</button>
</p>
<p id="authMessage" class="auth-message"></p>
</div>
</dialog>
<dialog id="filterDialog" class="filter-dialog">
<form method="dialog" class="dialog-card filter-sheet">
<button class="dialog-close" value="cancel" aria-label="关闭">×</button>
<h2>筛选</h2>
<div class="filter-sheet-body"></div>
<div class="topbar-actions">
<button id="clearFiltersBtn" class="button secondary" type="button">清空筛选</button>
<button class="button primary" value="confirm">完成</button>
</div>
</form>
</dialog>
<dialog id="statusActionDialog" class="action-sheet-dialog">
<form method="dialog" class="dialog-card action-sheet">
<button class="dialog-close" value="cancel" aria-label="关闭">×</button>
<h2>改状态</h2>
<div class="action-sheet-grid">
<button class="button secondary" type="button" data-mobile-bulk-status="asking_supplier">问供应商</button>
<button class="button secondary" type="button" data-mobile-bulk-status="ordered_sample">已拿样</button>
<button class="button secondary" type="button" data-mobile-bulk-status="live_testing">直播测试</button>
<button class="button danger" type="button" data-mobile-bulk-status="rejected">放弃</button>
</div>
</form>
</dialog>
<nav class="mobile-tabbar" aria-label="移动端导航">
<button class="mobile-tab active" data-mobile-view="overview" type="button">首页</button>
<button class="mobile-tab" data-mobile-view="capture" type="button">采集</button>
<button class="mobile-tab" data-mobile-view="competitors" type="button">竞品</button>
<button class="mobile-tab" data-mobile-view="suppliers" type="button">货源</button>
<button class="mobile-tab" data-mobile-view="testing" type="button">拿样</button>
<button class="mobile-tab" data-mobile-view="account" type="button">我的</button>
</nav>
<script type="module" src="/app.js"></script>
</body>
</html>
+5013
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>藏式手链测试页</title>
</head>
<body>
<main>
<article class="offer-card">
<a href="https://detail.1688.com/offer/998.html">
<img src="https://img.alicdn.com/imgextra/i1/test.jpg" alt="藏式编绳手链" />
<h2>藏式编绳手链 民族风 绿松石色 直播款</h2>
</a>
<p class="price">¥6.80 起</p>
<p class="supplier">义乌源头饰品厂 现货 混批 支持拿样</p>
<p>1000+人付款 求链接 问材质</p>
</article>
</main>
</body>
</html>
+168
View File
@@ -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()
+239
View File
@@ -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:
"你是资深电商选品采购顾问。请基于商品资料输出 JSONsummary, purchaseAdvice, sellingPoints, risks, supplierQuestions。",
procurementReport:
"你是直播电商采购负责人。请输出采购判断 JSONsummary, purchaseAdvice, priceStrategy, sellingPoints, risks, supplierQuestions。",
ruleGeneration:
"你是电商品类运营专家。请根据用户目标品类生成选品规则 JSONtracks, 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)}`;
}
+49
View File
@@ -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,
};
}
+624
View File
@@ -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] || "喜欢小众饰品和直播间新款的用户";
}
+884
View File
@@ -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}`);
});
}
+724
View File
@@ -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;
}
+303
View File
@@ -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 });
});
+1355
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -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 页面结构继续优化命中率。
+129
View File
@@ -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。
- 隐藏商品图或用说明文字代替真实控件。
- 风险和删除用同一个弱提示样式。
@@ -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 和系统地址长期暴露在插件外层。
- 不让移动端出现横向滚动或底部栏遮挡内容。
@@ -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 货源对比、利润测算、直播卖点生成、风险提醒、批量操作、拿样清单和相似款归组。设计要同时覆盖桌面端和移动端,桌面端强调高信息密度和批量处理,移动端强调单手操作和快速决策。整体风格克制、专业、清晰,适合饰品直播卖货团队日常选品采购。
```