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>
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
data/items.json
|
||||
@@ -0,0 +1,7 @@
|
||||
# 端口
|
||||
|
||||
| 环境 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| 本地开发 | 4777 | `npm start`,可通过 `PORT` 覆盖 |
|
||||
| 生产 Node | 9477 | PM2 `product-sourcing`,仅本机监听 |
|
||||
| 生产对外 | 443 | Nginx 反代 `https://sourcing.simosen.cn` |
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"items": [],
|
||||
"users": [],
|
||||
"sessions": [],
|
||||
"pluginTokens": [],
|
||||
"creditCodes": [],
|
||||
"creditLogs": [],
|
||||
"aiTokenLogs": [],
|
||||
"aiSettings": {},
|
||||
"rules": {}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 语法检查通过。
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 834 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -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/*"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
capturePageBtn.addEventListener("click", () => capture("page"));
|
||||
captureMainBtn.addEventListener("click", () => capture("main"));
|
||||
testSaveBtn.addEventListener("click", testSave);
|
||||
saveSettingsBtn.addEventListener("click", saveSettings);
|
||||
openDashboardBtn.addEventListener("click", () => chrome.tabs.create({ url: settings.apiBase }));
|
||||
updateGuideBtn.addEventListener("click", () => {
|
||||
const guide = latestExtensionInfo?.reloadRequiredForUnpacked
|
||||
? "请下载系统里的新版插件安装包,解压后到 chrome://extensions 重新加载解压后的文件夹。"
|
||||
: "请按系统提示更新插件。";
|
||||
resultBox.innerHTML = `<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}`;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 18 KiB |
@@ -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 |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -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 |
|
After Width: | Height: | Size: 39 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -0,0 +1,239 @@
|
||||
export const DEFAULT_AI_SETTINGS = {
|
||||
enabled: false,
|
||||
provider: "openai-compatible",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 900,
|
||||
timeoutMs: 20000,
|
||||
tokenUnitCost: 1,
|
||||
prompts: {
|
||||
itemAnalysis:
|
||||
"你是资深电商选品采购顾问。请基于商品资料输出 JSON:summary, purchaseAdvice, sellingPoints, risks, supplierQuestions。",
|
||||
procurementReport:
|
||||
"你是直播电商采购负责人。请输出采购判断 JSON:summary, purchaseAdvice, priceStrategy, sellingPoints, risks, supplierQuestions。",
|
||||
ruleGeneration:
|
||||
"你是电商品类运营专家。请根据用户目标品类生成选品规则 JSON:tracks, riskKeywords, captureHints, autoTags, profitModel。",
|
||||
},
|
||||
};
|
||||
|
||||
export class OpenAICompatibleClient {
|
||||
async chat({ settings, messages }) {
|
||||
const baseUrl = normalizeBaseUrl(settings.baseUrl);
|
||||
if (!baseUrl) {
|
||||
const error = new Error("AI Base URL is required");
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!settings.apiKey) {
|
||||
const error = new Error("AI API Key is required");
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!settings.model) {
|
||||
const error = new Error("AI model is required");
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), Number(settings.timeoutMs || DEFAULT_AI_SETTINGS.timeoutMs));
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${settings.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.model,
|
||||
messages,
|
||||
temperature: Number(settings.temperature ?? DEFAULT_AI_SETTINGS.temperature),
|
||||
max_tokens: Number(settings.maxOutputTokens || DEFAULT_AI_SETTINGS.maxOutputTokens),
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload?.error?.message || `AI request failed with ${response.status}`);
|
||||
error.status = response.status >= 400 && response.status < 500 ? 400 : 502;
|
||||
throw error;
|
||||
}
|
||||
const text = payload?.choices?.[0]?.message?.content || "";
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
inputTokens: Number(payload?.usage?.prompt_tokens || 0),
|
||||
outputTokens: Number(payload?.usage?.completion_tokens || 0),
|
||||
totalTokens: Number(payload?.usage?.total_tokens || estimateTokens(messages.map((message) => message.content).join("\n") + text)),
|
||||
},
|
||||
raw: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") {
|
||||
const timeoutError = new Error("AI request timed out");
|
||||
timeoutError.status = 504;
|
||||
throw timeoutError;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAiSettings(input = {}, previous = {}) {
|
||||
const source = input && typeof input === "object" ? input : {};
|
||||
const current = { ...DEFAULT_AI_SETTINGS, ...(previous || {}) };
|
||||
const apiKeyInput = String(source.apiKey || "").trim();
|
||||
const apiKey = apiKeyInput || current.apiKey || "";
|
||||
return {
|
||||
enabled: Boolean(source.enabled ?? current.enabled),
|
||||
provider: cleanValue(source.provider || current.provider || DEFAULT_AI_SETTINGS.provider, 40),
|
||||
baseUrl: normalizeBaseUrl(source.baseUrl || current.baseUrl || ""),
|
||||
apiKey,
|
||||
model: cleanValue(source.model || current.model || "", 80),
|
||||
temperature: clampNumber(source.temperature ?? current.temperature, 0, 2, DEFAULT_AI_SETTINGS.temperature),
|
||||
maxOutputTokens: Math.round(clampNumber(source.maxOutputTokens ?? current.maxOutputTokens, 128, 8000, DEFAULT_AI_SETTINGS.maxOutputTokens)),
|
||||
timeoutMs: Math.round(clampNumber(source.timeoutMs ?? current.timeoutMs, 5000, 120000, DEFAULT_AI_SETTINGS.timeoutMs)),
|
||||
tokenUnitCost: Math.round(clampNumber(source.tokenUnitCost ?? current.tokenUnitCost, 1, 100, DEFAULT_AI_SETTINGS.tokenUnitCost)),
|
||||
prompts: {
|
||||
...DEFAULT_AI_SETTINGS.prompts,
|
||||
...(current.prompts || {}),
|
||||
...(source.prompts && typeof source.prompts === "object" ? source.prompts : {}),
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function publicAiSettings(settings = {}) {
|
||||
const normalized = normalizeAiSettings(settings, settings);
|
||||
return {
|
||||
enabled: normalized.enabled,
|
||||
provider: normalized.provider,
|
||||
baseUrl: normalized.baseUrl,
|
||||
model: normalized.model,
|
||||
temperature: normalized.temperature,
|
||||
maxOutputTokens: normalized.maxOutputTokens,
|
||||
timeoutMs: normalized.timeoutMs,
|
||||
tokenUnitCost: normalized.tokenUnitCost,
|
||||
prompts: normalized.prompts,
|
||||
apiKeyMasked: maskSecret(normalized.apiKey),
|
||||
updatedAt: settings.updatedAt || normalized.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function userAiStatus(settings = {}) {
|
||||
const normalized = normalizeAiSettings(settings, settings);
|
||||
return {
|
||||
enabled: Boolean(normalized.enabled && normalized.baseUrl && normalized.apiKey && normalized.model),
|
||||
provider: normalized.provider,
|
||||
model: normalized.model,
|
||||
tokenUnitCost: normalized.tokenUnitCost,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAiMessages({ type = "item_analysis", item = {}, rules = {}, settings = {}, input = "" } = {}) {
|
||||
const prompts = { ...DEFAULT_AI_SETTINGS.prompts, ...(settings.prompts || {}) };
|
||||
const systemPrompt =
|
||||
type === "rule_generation"
|
||||
? prompts.ruleGeneration
|
||||
: type === "procurement_report"
|
||||
? prompts.procurementReport
|
||||
: prompts.itemAnalysis;
|
||||
const payload =
|
||||
type === "rule_generation"
|
||||
? { target: input, currentRules: rules }
|
||||
: {
|
||||
type,
|
||||
item: compactItem(item),
|
||||
rules,
|
||||
};
|
||||
return [
|
||||
{
|
||||
role: "system",
|
||||
content: `${systemPrompt}\n只输出 JSON,不要 Markdown。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify(payload, null, 2),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function parseAiJson(text = "") {
|
||||
const raw = String(text || "").trim();
|
||||
if (!raw) return { summary: "AI 未返回内容", raw: "" };
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try {
|
||||
return JSON.parse(match[0]);
|
||||
} catch {
|
||||
return { summary: raw, raw };
|
||||
}
|
||||
}
|
||||
return { summary: raw, raw };
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateTokens(text = "") {
|
||||
return Math.max(1, Math.ceil(String(text || "").length / 2));
|
||||
}
|
||||
|
||||
export function estimatedRequestTokens({ messages = [], maxOutputTokens = 0, tokenUnitCost = 1 } = {}) {
|
||||
const input = estimateTokens(messages.map((message) => message.content).join("\n"));
|
||||
return Math.max(1, Math.ceil((input + Number(maxOutputTokens || 0)) * Number(tokenUnitCost || 1)));
|
||||
}
|
||||
|
||||
export function billableTokens(usage = {}, tokenUnitCost = 1) {
|
||||
return Math.max(1, Math.ceil(Number(usage.totalTokens || 0) * Number(tokenUnitCost || 1)));
|
||||
}
|
||||
|
||||
function compactItem(item = {}) {
|
||||
return {
|
||||
id: item.id,
|
||||
platform: item.platform,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
image: item.image,
|
||||
price: item.price,
|
||||
priceText: item.priceText,
|
||||
supplier: item.supplier,
|
||||
shop: item.shop,
|
||||
track: item.track,
|
||||
score: item.score,
|
||||
decision: item.decision,
|
||||
risks: item.risks,
|
||||
reasons: item.reasons,
|
||||
profit: item.profit,
|
||||
metrics: item.metrics,
|
||||
notes: item.notes,
|
||||
tags: item.tags,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value = "") {
|
||||
return String(value || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function cleanValue(value = "", max = 120) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.min(max, Math.max(min, number));
|
||||
}
|
||||
|
||||
function maskSecret(secret = "") {
|
||||
const value = String(secret || "");
|
||||
if (!value) return "";
|
||||
if (value.length <= 8) return "已配置";
|
||||
return `${value.slice(0, 3)}...${value.slice(-4)}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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] || "喜欢小众饰品和直播间新款的用户";
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 页面结构继续优化命中率。
|
||||
@@ -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 货源对比、利润测算、直播卖点生成、风险提醒、批量操作、拿样清单和相似款归组。设计要同时覆盖桌面端和移动端,桌面端强调高信息密度和批量处理,移动端强调单手操作和快速决策。整体风格克制、专业、清晰,适合饰品直播卖货团队日常选品采购。
|
||||
```
|
||||