加入卡密验证功能

This commit is contained in:
taiyi 2026-07-22 10:07:46 +08:00
parent 320e130ea5
commit b6080d5e16
9 changed files with 807 additions and 99 deletions

382
authApi.js Normal file
View File

@ -0,0 +1,382 @@
/**
* 授权验证 API 客户端
* 负责与后端 Python 验证服务进行通信
* Author: taiyi1224
*/
// 配置常量
const AUTH_CONFIG = {
// ⚠️ 请修改为你的实际服务器地址
API_URL: 'http://km.taisan.online/api/v1',
SOFTWARE_ID: 'QFFB', // 软件 ID与后端一致
SECRET_KEY: 'taiyi1224', // 密钥(与后端一致)
TIMEOUT: 10000, // 请求超时时间(毫秒)
RECHECK_INTERVAL: 60 * 60 * 1000 // ✅ 联网复查间隔1小时毫秒可按需调整
};
/**
* 生成签名
*/
async function generateSignature(data) {
const encoder = new TextEncoder();
const signatureData = `${data.software_id}${data.license_key}${data.machine_code}${data.timestamp}`;
const combined = signatureData + AUTH_CONFIG.SECRET_KEY;
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(combined));
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* 获取机器码基于浏览器指纹
*/
async function getMachineCode() {
const cacheKey = 'tb_auth_machine_code';
const cached = localStorage.getItem(cacheKey);
if (cached) return cached;
try {
const fingerprint = [
navigator.userAgent,
navigator.language,
navigator.platform,
screen.width,
screen.height,
new Date().getTimezoneOffset(),
].join('|');
const encoder = new TextEncoder();
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(fingerprint));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const machineCode = hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 32).toUpperCase();
localStorage.setItem(cacheKey, machineCode);
return machineCode;
} catch (error) {
console.error('[Auth] 生成机器码失败:', error);
const fallback = 'BROWSER_' + Date.now().toString(36).toUpperCase();
localStorage.setItem(cacheKey, fallback);
return fallback;
}
}
/**
* 测试服务器连接
*/
async function testConnection() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), AUTH_CONFIG.TIMEOUT);
const response = await fetch(`${AUTH_CONFIG.API_URL}/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
software_id: AUTH_CONFIG.SOFTWARE_ID,
license_key: 'TEST',
machine_code: await getMachineCode(),
timestamp: Math.floor(Date.now() / 1000),
signature: 'test'
}),
signal: controller.signal
});
clearTimeout(timeoutId);
return { success: true, message: '服务器连接正常' };
} catch (error) {
if (error.name === 'AbortError') {
return { success: false, message: `连接超时,服务器可能无响应:${AUTH_CONFIG.API_URL}` };
}
return { success: false, message: `无法连接到服务器:${error.message}\n请检查服务器地址是否正确:${AUTH_CONFIG.API_URL}` };
}
}
/**
* 在线验证卡密
*/
async function verifyLicense(licenseKey) {
try {
const machineCode = await getMachineCode();
const timestamp = Math.floor(Date.now() / 1000);
const signature = await generateSignature({
software_id: AUTH_CONFIG.SOFTWARE_ID,
license_key: licenseKey,
machine_code: machineCode,
timestamp: timestamp
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), AUTH_CONFIG.TIMEOUT);
const response = await fetch(`${AUTH_CONFIG.API_URL}/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
software_id: AUTH_CONFIG.SOFTWARE_ID,
license_key: licenseKey,
machine_code: machineCode,
timestamp: timestamp,
signature: signature,
software_version: chrome.runtime.getManifest().version
}),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `服务器返回错误:${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) {}
switch (response.status) {
case 503: errorMsg = '服务器暂时不可用,请稍后重试'; break;
case 500: errorMsg = '服务器内部错误,请联系管理员'; break;
case 404: errorMsg = 'API 接口不存在,请检查 API 地址'; break;
case 401: errorMsg = '签名验证失败,请检查密钥配置'; break;
}
return { success: false, message: errorMsg };
}
const result = await response.json();
if (!result.success) {
return { success: false, message: result.message || '验证失败' };
}
const data = result.data || {};
return {
success: true,
message: result.message || '验证成功',
data: {
expire_time: data.expire_time,
machine_code: machineCode,
last_check: new Date().toISOString(),
license_key: data.license_key || licenseKey,
type: data.type,
type_name: data.type_name || '',
remaining_days: data.remaining_days,
product_name: data.product_name || ''
}
};
} catch (error) {
if (error.name === 'AbortError') {
return { success: false, message: `连接超时(${AUTH_CONFIG.TIMEOUT / 1000}秒),请检查网络连接或服务器地址:${AUTH_CONFIG.API_URL}` };
}
return { success: false, message: `网络请求异常:${error.message}\n请检查网络连接和服务器地址:${AUTH_CONFIG.API_URL}` };
}
}
/**
* 解绑卡密
*/
async function unbindLicense(licenseKey) {
try {
const machineCode = await getMachineCode();
const timestamp = Math.floor(Date.now() / 1000);
const signature = await generateSignature({
software_id: AUTH_CONFIG.SOFTWARE_ID,
license_key: licenseKey,
machine_code: machineCode,
timestamp: timestamp
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), AUTH_CONFIG.TIMEOUT);
const response = await fetch(`${AUTH_CONFIG.API_URL}/auth/unbind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
software_id: AUTH_CONFIG.SOFTWARE_ID,
license_key: licenseKey,
machine_code: machineCode,
timestamp: timestamp,
signature: signature
}),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `服务器返回错误:${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) {}
switch (response.status) {
case 503: errorMsg = '服务器暂时不可用,请稍后重试'; break;
case 500: errorMsg = '服务器内部错误,请联系管理员'; break;
case 404: errorMsg = 'API 接口不存在,请检查 API 地址'; break;
case 401: errorMsg = '签名验证失败,请检查密钥配置'; break;
}
return { success: false, message: errorMsg };
}
const result = await response.json();
return {
success: result.success || false,
message: result.message || (result.success ? '解绑成功' : '解绑失败')
};
} catch (error) {
if (error.name === 'AbortError') {
return { success: false, message: `连接超时,请检查网络连接` };
}
return { success: false, message: `网络请求失败:${error.message}` };
}
}
/**
* 解绑设备
*/
async function unbindDevice() {
try {
const machineCode = await getMachineCode();
const timestamp = Math.floor(Date.now() / 1000);
const signature = await generateSignature({
software_id: AUTH_CONFIG.SOFTWARE_ID,
machine_code: machineCode,
timestamp: timestamp
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), AUTH_CONFIG.TIMEOUT);
const response = await fetch(`${AUTH_CONFIG.API_URL}/auth/unbind_device`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
software_id: AUTH_CONFIG.SOFTWARE_ID,
machine_code: machineCode,
timestamp: timestamp,
signature: signature
}),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorMsg = `服务器返回错误:${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) {}
switch (response.status) {
case 503: errorMsg = '服务器暂时不可用,请稍后重试'; break;
case 500: errorMsg = '服务器内部错误,请联系管理员'; break;
case 404: errorMsg = 'API 接口不存在,请检查 API 地址'; break;
case 401: errorMsg = '签名验证失败,请检查密钥配置'; break;
}
return { success: false, message: errorMsg };
}
const result = await response.json();
return {
success: result.success || false,
message: result.message || (result.success ? '设备解绑成功' : '解绑失败')
};
} catch (error) {
if (error.name === 'AbortError') {
return { success: false, message: `连接超时,请检查网络连接` };
}
return { success: false, message: `网络请求失败:${error.message}` };
}
}
/**
* 保存授权信息到本地存储
*/
async function saveAuthData(authData) {
await chrome.storage.local.set({ tb_auth_data: authData });
}
/**
* 获取本地存储的授权信息
*/
async function getAuthData() {
const result = await chrome.storage.local.get('tb_auth_data');
return result.tb_auth_data || null;
}
/**
* 清除授权信息
*/
async function clearAuthData() {
await chrome.storage.local.remove('tb_auth_data');
}
/**
* 核心修复检查是否已授权
*
* 逻辑
* 1. 本地无数据 未授权
* 2. 距上次联网复查超过 RECHECK_INTERVAL 联网验证
* - 服务器返回成功 刷新 last_check授权有效
* - 服务器返回失败卡密被删/禁用/过期 清除本地授权返回未授权
* 3. 未超过间隔 使用本地缓存避免频繁请求
*/
async function isAuthorized() {
const authData = await getAuthData();
// 1. 本地没有授权数据
if (!authData || !authData.license_key) {
return false;
}
const now = Date.now();
const lastCheck = authData.last_check ? new Date(authData.last_check).getTime() : 0;
const elapsed = now - lastCheck;
// 2. 超过复查间隔,联网重新验证
if (elapsed >= AUTH_CONFIG.RECHECK_INTERVAL) {
console.log('[Auth] 超过复查间隔,开始联网验证...');
const result = await verifyLicense(authData.license_key);
if (result.success) {
// 验证通过,更新本地缓存(刷新 last_check
await saveAuthData(result.data);
console.log('[Auth] 联网验证通过,授权有效');
return true;
} else {
// 验证失败:卡密已被删除/禁用/过期,清除本地授权
await clearAuthData();
console.warn('[Auth] 联网验证失败,已清除本地授权:', result.message);
return false;
}
}
// 3. 未超过间隔,使用本地缓存
const remainingMin = Math.round((AUTH_CONFIG.RECHECK_INTERVAL - elapsed) / 60000);
console.log(`[Auth] 使用本地缓存,距下次复查还有 ${remainingMin} 分钟`);
return true;
}
// 导出 API
export {
AUTH_CONFIG,
testConnection,
verifyLicense,
unbindLicense,
unbindDevice,
getMachineCode,
saveAuthData,
getAuthData,
clearAuthData,
isAuthorized
};

Binary file not shown.

Binary file not shown.

View File

@ -41,6 +41,11 @@ const createNoteRoutes = require('./routes/notes');
const createConfigRoutes = require('./routes/configs');
const createTaskRoutes = require('./routes/tasks');
const createGenerateRoutes = require('./routes/generate');
const createLicenseRoutes = require('./routes/license');
const { requireLicense } = require('./lib/licenseAuth');
app.use('/api/license', createLicenseRoutes());
app.use('/api', requireLicense({ allowPaths: ['/health'] }));
app.use('/api/shops', createShopRoutes(db));
app.use('/api/products', createProductRoutes(db));
@ -99,3 +104,4 @@ app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
console.log(`API: http://localhost:${PORT}/api`);
});

View File

@ -4,19 +4,18 @@ const { generateImage } = require('../lib/generate-image');
function createGenerateRoutes(db) {
const router = express.Router();
const runningJobs = new Set();
// Helper: call AI text generation for a single prompt
async function callAI(prompt, maxTokens = 2000) {
const aiConfig = {};
const keys = ['ai_api_key', 'ai_base_url', 'ai_model', 'ai_temperature'];
for (const k of keys) {
const row = db.prepare('SELECT value FROM configs WHERE key = ?').get(k);
aiConfig[k] = row?.value || '';
for (const key of keys) {
const row = db.prepare('SELECT value FROM configs WHERE key = ?').get(key);
aiConfig[key] = row?.value || '';
}
if (!aiConfig.ai_api_key) throw new Error('AI API key not configured. Please set it in Settings.');
const baseUrl = aiConfig.ai_base_url || 'https://api.deepseek.com';
const response = await fetch(`${baseUrl}/chat/completions`, {
const response = await fetch(`${aiConfig.ai_base_url || 'https://api.deepseek.com'}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -39,7 +38,6 @@ function createGenerateRoutes(db) {
return data.choices?.[0]?.message?.content || '';
}
// Helper: generate title for a note
async function generateTitle(productTitle) {
const titleRow = db.prepare('SELECT value FROM configs WHERE key = ?').get('title_prompt_template');
const fallbackRow = db.prepare('SELECT value FROM configs WHERE key = ?').get('prompt_template');
@ -50,7 +48,6 @@ function createGenerateRoutes(db) {
return title || productTitle;
}
// Helper: generate content for a note
async function generateContent(productTitle) {
const contentRow = db.prepare('SELECT value FROM configs WHERE key = ?').get('content_prompt_template');
const fallbackRow = db.prepare('SELECT value FROM configs WHERE key = ?').get('prompt_template');
@ -58,11 +55,10 @@ function createGenerateRoutes(db) {
const prompt = template.replace(/\{\{title\}\}/g, productTitle);
const generated = await callAI(prompt, 2000);
const topicMatches = generated.match(/#[^\s#]+/g) || [];
const topics = topicMatches.map(t => t.replace('#', '').trim());
const topics = topicMatches.map((topic) => topic.replace('#', '').trim());
return { content: generated.trim(), topics };
}
// Helper: generate title and content together (legacy fallback)
async function generateText(productTitle) {
const title = await generateTitle(productTitle);
const { content, topics } = await generateContent(productTitle);
@ -70,12 +66,10 @@ function createGenerateRoutes(db) {
}
async function createNoteForProduct(product, options = {}) {
// Dedup: if a note is already being generated for this product, skip
const existing = db.prepare(
"SELECT id, title, content, topics, image_paths, image_prompt FROM notes WHERE product_id = ? AND status = 'generating' ORDER BY created_at DESC LIMIT 1"
).get(product.id);
if (existing) {
console.log(`[generate] Note already generating for product ${product.id}, reusing ${existing.id}`);
return {
noteId: existing.id,
title: existing.title,
@ -87,9 +81,8 @@ function createGenerateRoutes(db) {
}
const noteId = uuidv4();
db.prepare(
"INSERT INTO notes (id, product_id, shop_id, title, content, status) VALUES (?, ?, ?, ?, '', 'generating')"
).run(noteId, product.id, product.shop_id, product.title);
db.prepare("INSERT INTO notes (id, product_id, shop_id, title, content, status) VALUES (?, ?, ?, ?, '', 'generating')")
.run(noteId, product.id, product.shop_id, product.title);
const { title, content, topics } = await generateText(product.title);
const imageResult = await generateImage(db, noteId, product.title, options);
@ -99,16 +92,99 @@ function createGenerateRoutes(db) {
db.prepare(
"UPDATE notes SET title=?, content=?, topics=?, image_paths=?, image_prompt=?, status='generated', updated_at=CURRENT_TIMESTAMP WHERE id=?"
).run(title, content, JSON.stringify(topics), JSON.stringify(imagePaths), imagePrompt, noteId);
db.prepare(
"UPDATE products SET status='generated', updated_at=CURRENT_TIMESTAMP WHERE id=?"
).run(product.id);
db.prepare("UPDATE products SET status='generated', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(product.id);
return { noteId, title, content, topics, imagePaths, imagePrompt };
}
// Generate note for a product using AI
router.post('/note', async (req, res) => {
const { product_id, shop_id } = req.body;
function updateJob(jobId, fields) {
const keys = Object.keys(fields);
if (keys.length === 0) return;
const setSql = keys.map((key) => `${key} = ?`).join(', ');
const values = keys.map((key) => fields[key]);
values.push(jobId);
db.prepare(`UPDATE generation_jobs SET ${setSql}, updated_at=CURRENT_TIMESTAMP WHERE id = ?`).run(...values);
}
async function runGenerationJob(jobId, productIds, countPerProduct, options = {}) {
if (runningJobs.has(jobId)) return;
runningJobs.add(jobId);
try {
updateJob(jobId, { status: 'running', message: '开始生成' });
let completedNotes = 0;
let failedNotes = 0;
for (const productId of productIds) {
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(productId);
if (!product) {
failedNotes += countPerProduct;
completedNotes += countPerProduct;
updateJob(jobId, {
completed_notes: completedNotes,
failed_notes: failedNotes,
current_product_id: productId,
current_product_title: '',
message: '商品不存在',
});
continue;
}
db.prepare("UPDATE products SET status='generating', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(product.id);
updateJob(jobId, {
current_product_id: product.id,
current_product_title: product.title,
message: `正在生成:${product.title}`,
});
for (let index = 0; index < countPerProduct; index += 1) {
try {
await createNoteForProduct(product, options);
} catch (error) {
failedNotes += 1;
} finally {
completedNotes += 1;
updateJob(jobId, {
completed_notes: completedNotes,
failed_notes: failedNotes,
message: `进度 ${completedNotes}/${productIds.length * countPerProduct}`,
});
}
}
db.prepare("UPDATE products SET status='generated', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(product.id);
}
updateJob(jobId, {
status: 'completed',
message: failedNotes > 0 ? `完成,失败 ${failedNotes}` : '全部完成',
});
} catch (error) {
updateJob(jobId, { status: 'failed', message: error.message });
} finally {
runningJobs.delete(jobId);
}
}
function enqueueGeneration(jobId, productIds, countPerProduct, options) {
setImmediate(() => {
runGenerationJob(jobId, productIds, countPerProduct, options);
});
}
router.get('/jobs', (req, res) => {
const { shop_id } = req.query;
let sql = 'SELECT * FROM generation_jobs WHERE 1=1';
const params = [];
if (shop_id) {
sql += ' AND shop_id = ?';
params.push(shop_id);
}
sql += ' ORDER BY created_at DESC LIMIT 20';
res.json(db.prepare(sql).all(...params));
});
router.post('/note', (req, res) => {
const { product_id, shop_id, count_per_product, image_count, image_size, image_prompt_template } = req.body;
if (!product_id || !shop_id) {
return res.status(400).json({ error: 'product_id and shop_id required' });
}
@ -116,21 +192,15 @@ function createGenerateRoutes(db) {
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(product_id);
if (!product) return res.status(404).json({ error: 'Product not found' });
db.prepare("UPDATE products SET status = 'generating', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(product_id);
try {
const result = await createNoteForProduct(product);
res.json({ note_id: result.noteId, title: result.title, content: result.content, topics: result.topics, image_paths: result.imagePaths });
} catch (error) {
db.prepare("UPDATE notes SET status='failed', error_message=?, updated_at=CURRENT_TIMESTAMP WHERE id=(SELECT id FROM notes WHERE product_id=? ORDER BY created_at DESC LIMIT 1)")
.run(error.message, product_id);
db.prepare("UPDATE products SET status='pending', updated_at=CURRENT_TIMESTAMP WHERE id=?")
.run(product_id);
res.status(500).json({ error: error.message });
}
const repeatCount = Math.max(1, parseInt(count_per_product || '1', 10) || 1);
const jobId = uuidv4();
db.prepare(
'INSERT INTO generation_jobs (id, shop_id, status, total_products, total_notes, message) VALUES (?, ?, ?, ?, ?, ?)'
).run(jobId, shop_id, 'pending', 1, repeatCount, '等待开始');
enqueueGeneration(jobId, [product_id], repeatCount, { image_count, image_size, image_prompt_template });
res.status(201).json({ job_id: jobId, status: 'pending' });
});
// Generate image for a note
router.post('/image', async (req, res) => {
const { note_id } = req.body;
if (!note_id) return res.status(400).json({ error: 'note_id required' });
@ -152,10 +222,10 @@ function createGenerateRoutes(db) {
});
async function batchGenerate(req, res) {
const { shop_id, product_ids, image_count, image_size, image_prompt_template } = req.body;
const { shop_id, product_ids, count_per_product, image_count, image_size, image_prompt_template } = req.body;
if (!shop_id) return res.status(400).json({ error: 'shop_id required' });
let sql = "SELECT * FROM products WHERE shop_id = ?";
let sql = 'SELECT * FROM products WHERE shop_id = ?';
const params = [shop_id];
if (Array.isArray(product_ids) && product_ids.length > 0) {
sql += ` AND id IN (${product_ids.map(() => '?').join(',')})`;
@ -164,19 +234,13 @@ function createGenerateRoutes(db) {
const products = db.prepare(sql).all(...params);
if (products.length === 0) return res.json({ count: 0, message: 'No pending products' });
const results = [];
for (const product of products) {
try {
db.prepare("UPDATE products SET status = 'generating', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(product.id);
const result = await createNoteForProduct(product, { image_count, image_size, image_prompt_template });
results.push({ product_id: product.id, note_id: result.noteId, title: result.title, images: result.imagePaths.length });
} catch (error) {
results.push({ product_id: product.id, error: error.message });
db.prepare("UPDATE products SET status = 'pending', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(product.id);
}
}
return res.json({ total: products.length, results, images_generated: results.reduce((sum, item) => sum + (item.images || 0), 0) });
const repeatCount = Math.max(1, parseInt(count_per_product || '1', 10) || 1);
const jobId = uuidv4();
db.prepare(
'INSERT INTO generation_jobs (id, shop_id, status, total_products, total_notes, message) VALUES (?, ?, ?, ?, ?, ?)'
).run(jobId, shop_id, 'pending', products.length, products.length * repeatCount, '等待开始');
enqueueGeneration(jobId, products.map((product) => product.id), repeatCount, { image_count, image_size, image_prompt_template });
res.status(201).json({ job_id: jobId, total: products.length, total_notes: products.length * repeatCount });
}
router.post('/', batchGenerate);

View File

@ -1,5 +1,9 @@
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
import { useState } from "react";
import { ToastProvider } from "./toast";
import ErrorBoundary from "./ErrorBoundary";
import LicenseGate from "./pages/LicenseGate";
import AuthToolbar from "./components/AuthToolbar";
import Dashboard from "./pages/Dashboard";
import Shops from "./pages/Shops";
import Products from "./pages/Products";
@ -8,30 +12,28 @@ import NotesUnapproved from "./pages/NotesUnapproved";
import SettingsDeepseek from "./pages/SettingsDeepseek";
import SettingsAgnes from "./pages/SettingsAgnes";
import SettingsPrompts from "./pages/SettingsPrompts";
import { useState } from "react";
import ErrorBoundary from "./ErrorBoundary";
const navSections = [
{
title: "小红书管理",
items: [
{ to: "/shops", label: "账号管理", icon: "🏪" },
{ to: "/products", label: "商品管理", icon: "📦" },
{ to: "/shops", label: "账号管理", icon: "" },
{ to: "/products", label: "商品管理", icon: "" },
],
},
{
title: "笔记管理",
items: [
{ to: "/notes/approved", label: "已审核笔记", icon: "" },
{ to: "/notes/unapproved", label: "未审核笔记", icon: "📝" },
{ to: "/notes/approved", label: "已审核笔记", icon: "" },
{ to: "/notes/unapproved", label: "未审核笔记", icon: "" },
],
},
{
title: "AI 管理",
title: "AI 配置",
items: [
{ to: "/settings/deepseek", label: "Deep Seek 配置", icon: "🤖" },
{ to: "/settings/agnes", label: "Agnes 配置", icon: "🎨" },
{ to: "/settings/prompts", label: "提示词管理", icon: "💬" },
{ to: "/settings/deepseek", label: "DeepSeek 配置", icon: "D" },
{ to: "/settings/agnes", label: "Agnes 配置", icon: "A" },
{ to: "/settings/prompts", label: "提示词配置", icon: "P" },
],
},
];
@ -47,32 +49,26 @@ function getNavClass(isActive) {
function Sidebar({ collapsed, onToggle }) {
return (
<aside className={"flex flex-col bg-gray-900 text-white transition-all duration-300 " + getCollapsedClass(collapsed) + " min-h-screen shrink-0"}>
<div className="flex items-center gap-2 px-3 py-4 border-b border-gray-700">
<span className="text-xl shrink-0">📕</span>
<aside className={`flex min-h-screen shrink-0 flex-col bg-gray-900 text-white transition-all duration-300 ${getCollapsedClass(collapsed)}`}>
<div className="flex items-center gap-2 border-b border-gray-700 px-3 py-4">
<span className="text-xl shrink-0"></span>
{!collapsed && (
<div className="min-w-0">
<div className="text-sm font-bold truncate">小红书矩阵</div>
<div className="text-[10px] text-gray-400 truncate">Matrix Console</div>
<div className="truncate text-sm font-bold">小红书矩阵</div>
<div className="truncate text-[10px] text-gray-400">Matrix Console</div>
</div>
)}
<button onClick={onToggle} className="ml-auto p-1 rounded hover:bg-gray-700 text-lg leading-none shrink-0">
{collapsed ? "»" : "}
<button onClick={onToggle} className="ml-auto rounded p-1 text-lg leading-none hover:bg-gray-700 shrink-0">
{collapsed ? ">" : "<"}
</button>
</div>
<nav className="flex-1 py-2 overflow-y-auto">
<nav className="flex-1 overflow-y-auto py-2">
{navSections.map((section) => (
<div key={section.title}>
{!collapsed && (
<div className="px-4 pt-3 pb-1 text-[11px] font-medium text-gray-500 uppercase tracking-wider">{section.title}</div>
)}
{collapsed && <div className="border-t border-gray-700 my-2" />}
{!collapsed && <div className="px-4 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wider text-gray-500">{section.title}</div>}
{collapsed && <div className="my-2 border-t border-gray-700" />}
{section.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => getNavClass(isActive)}
>
<NavLink key={item.to} to={item.to} className={({ isActive }) => getNavClass(isActive)}>
<span className="text-lg shrink-0">{item.icon}</span>
{!collapsed && <span className="truncate">{item.label}</span>}
</NavLink>
@ -86,13 +82,16 @@ function Sidebar({ collapsed, onToggle }) {
export default function App() {
const [collapsed, setCollapsed] = useState(false);
return (
<ToastProvider>
<ErrorBoundary>
<LicenseGate>
<div className="flex min-h-screen bg-gray-50">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((c) => !c)} />
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((value) => !value)} />
<main className="flex-1 overflow-auto">
<div className="p-6 max-w-7xl mx-auto">
<div className="mx-auto max-w-7xl p-6">
<AuthToolbar />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/shops" element={<Shops />} />
@ -107,6 +106,7 @@ export default function App() {
</div>
</main>
</div>
</LicenseGate>
</ErrorBoundary>
</ToastProvider>
);

117
web/src/auth.js Normal file
View File

@ -0,0 +1,117 @@
const STORAGE_KEY = "taiyi1224";
const MACHINE_KEY = "xhsp_machine_code";
export function getStoredAuth() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function saveStoredAuth(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
window.dispatchEvent(new CustomEvent("license-auth-changed", { detail: data }));
}
export function clearStoredAuth() {
localStorage.removeItem(STORAGE_KEY);
window.dispatchEvent(new CustomEvent("license-auth-changed", { detail: null }));
}
export function getAccessToken() {
return getStoredAuth()?.token || "";
}
async function sha256Hex(input) {
const bytes = new TextEncoder().encode(input);
const buffer = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(buffer)).map((item) => item.toString(16).padStart(2, "0")).join("");
}
export async function getMachineCode() {
const cached = localStorage.getItem(MACHINE_KEY);
if (cached) return cached;
const fingerprint = [
navigator.userAgent,
navigator.language,
navigator.platform,
screen.width,
screen.height,
new Date().getTimezoneOffset(),
].join("|");
const code = (await sha256Hex(fingerprint)).slice(0, 32).toUpperCase();
localStorage.setItem(MACHINE_KEY, code);
return code;
}
export async function verifyLicenseKey(licenseKey) {
const machineCode = await getMachineCode();
const response = await fetch("/api/license/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ licenseKey, machineCode }),
});
const data = await response.json().catch(() => ({ success: false, message: "授权服务返回异常" }));
if (!response.ok || data.success === false) {
throw new Error(data.message || "卡密验证失败");
}
const auth = {
token: data.token,
auth: data.auth,
recheckIntervalMs: data.recheckIntervalMs,
};
saveStoredAuth(auth);
return auth;
}
export async function refreshLicenseStatus() {
const token = getAccessToken();
if (!token) return null;
const response = await fetch("/api/license/status", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await response.json().catch(() => ({ success: false, message: "授权状态检查失败" }));
if (!response.ok || data.success === false) {
clearStoredAuth();
throw new Error(data.message || "授权已失效");
}
const auth = {
token: data.token || token,
auth: data.auth,
recheckIntervalMs: data.recheckIntervalMs,
};
saveStoredAuth(auth);
return auth;
}
export async function unbindCurrentLicense() {
const token = getAccessToken();
if (!token) {
throw new Error("当前没有有效授权");
}
const response = await fetch("/api/license/unbind", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json().catch(() => ({ success: false, message: "解绑服务返回异常" }));
if (!response.ok || data.success === false) {
throw new Error(data.message || "解绑失败");
}
clearStoredAuth();
return data;
}

View File

@ -0,0 +1,82 @@
import { useEffect, useMemo, useState } from "react";
import { clearStoredAuth, getStoredAuth, unbindCurrentLicense } from "../auth";
import { toast } from "../toast";
export default function AuthToolbar() {
const [authInfo, setAuthInfo] = useState(() => getStoredAuth());
const [working, setWorking] = useState(false);
useEffect(() => {
const onChange = (event) => setAuthInfo(event.detail || null);
window.addEventListener("license-auth-changed", onChange);
return () => window.removeEventListener("license-auth-changed", onChange);
}, []);
const maskedLicense = useMemo(() => {
const raw = authInfo?.auth?.licenseKey || "";
if (!raw) return "";
if (raw.length <= 8) return raw;
return `${raw.slice(0, 4)}****${raw.slice(-4)}`;
}, [authInfo]);
const expireText = useMemo(() => {
const raw = authInfo?.auth?.expiresAt;
if (!raw) return "";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString();
}, [authInfo]);
function handleLogout() {
clearStoredAuth();
toast("已退出当前授权", "success");
}
async function handleUnbind() {
if (!window.confirm("解绑后当前设备将失去授权,需要重新输入卡密。确认继续吗?")) {
return;
}
setWorking(true);
try {
const result = await unbindCurrentLicense();
toast(result.message || "解绑成功", "success");
} catch (error) {
toast(error.message || "解绑失败", "error");
} finally {
setWorking(false);
}
}
if (!authInfo?.token) return null;
return (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-800">当前已授权{maskedLicense || "未知卡密"}</div>
<div className="mt-1 text-xs text-slate-500">
设备码{authInfo?.auth?.machineCode || "未知"}
{expireText ? ` · 本地令牌到期:${expireText}` : ""}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleUnbind}
disabled={working}
className="rounded-xl border border-amber-300 px-3 py-2 text-sm text-amber-700 transition hover:bg-amber-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{working ? "解绑中..." : "解绑设备"}
</button>
<button
type="button"
onClick={handleLogout}
disabled={working}
className="rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
退出授权
</button>
</div>
</div>
);
}

View File

@ -30,14 +30,17 @@ function Modal({ open, onClose, title, children }) {
export default function Products() {
const [list, setList] = useState([]);
const [shopList, setShopList] = useState([]);
const [jobs, setJobs] = useState([]);
const [filterShop, setFilterShop] = useState("");
const [loading, setLoading] = useState(true);
const [jobLoading, setJobLoading] = useState(false);
const [modal, setModal] = useState(false);
const [batchModal, setBatchModal] = useState(false);
const [editing, setEditing] = useState(null);
const [form, setForm] = useState({ title: "", shop_id: "" });
const [batchText, setBatchText] = useState("");
const [batchShop, setBatchShop] = useState("");
const [generateCount, setGenerateCount] = useState(1);
const [saving, setSaving] = useState(false);
const [selected, setSelected] = useState(new Set());
const [generating, setGenerating] = useState(false);
@ -57,7 +60,20 @@ export default function Products() {
.finally(() => setLoading(false));
};
const loadJobs = () => {
setJobLoading(true);
generate.jobs(filterShop || undefined)
.then(setJobs)
.catch(() => setJobs([]))
.finally(() => setJobLoading(false));
};
useEffect(load, [filterShop]);
useEffect(loadJobs, [filterShop]);
useEffect(() => {
const timer = setInterval(loadJobs, 3000);
return () => clearInterval(timer);
}, [filterShop]);
useEffect(() => { setPage(1); setSelected(new Set()); }, [filterShop, search]);
const openCreate = () => { setEditing(null); setForm({ title: "", shop_id: filterShop || "" }); setModal(true); };
@ -155,8 +171,9 @@ export default function Products() {
if (!shopId) { toast("无法确定店铺", "error"); return; }
setGenerating(true);
try {
const res = await generate.batch({ shop_id: shopId, product_ids: productIds });
toast(`批量生成完成:共 ${res.total || 0} 个商品`, "success"); load();
const res = await generate.batch({ shop_id: shopId, product_ids: productIds, count_per_product: generateCount });
toast(`任务已提交:共 ${res.total || 0} 个商品,${res.total_notes || 0} 条笔记`, "success");
loadJobs();
} catch (e) { toast(e.message, "error"); }
finally { setGenerating(false); }
};
@ -166,12 +183,19 @@ export default function Products() {
const handleGenerateOne = async (product) => {
setGeneratingId(product.id);
try {
await generate.note({ product_id: product.id, shop_id: product.shop_id });
toast("笔记生成完成", "success"); load();
await generate.note({ product_id: product.id, shop_id: product.shop_id, count_per_product: generateCount });
toast(`任务已提交:${generateCount} 条笔记`, "success");
loadJobs();
} catch (e) { toast(e.message, "error"); }
finally { setGeneratingId(null); }
};
const progressOf = (job) => {
const total = job.total_notes || 0;
if (!total) return 0;
return Math.min(100, Math.round(((job.completed_notes || 0) / total) * 100));
};
const handleUploadImage = async (productId, file) => {
if (!file) return;
setUploadingId(productId);
@ -222,12 +246,45 @@ export default function Products() {
<input className="border rounded-lg px-3 py-2 text-sm w-48" placeholder="输入商品标题关键词" value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => {}} className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600">搜索</button>
<button onClick={openCreate} className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700">新增</button>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>每商品生成</span>
<input type="number" min="1" className="w-20 border rounded-lg px-2 py-2 text-sm" value={generateCount} onChange={(e) => setGenerateCount(Math.max(1, parseInt(e.target.value || '1', 10) || 1))} />
<span></span>
</div>
<button onClick={handleGenerate} disabled={selected.size === 0 || generating} className="px-4 py-2 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 disabled:opacity-40">{generating ? "生成中..." : "批量生成笔记"}</button>
<button onClick={() => { if (!filterShop) { toast("请先选择店铺", "error"); return; } setBatchShop(filterShop); setBatchModal(true); }} className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700">+ 导入 Excel</button>
<button onClick={batchDelete} disabled={selected.size === 0} className="px-3 py-2 bg-red-500 text-white text-sm rounded-lg hover:bg-red-600 disabled:opacity-40">批量删除</button>
</div>
</div>
<div className="bg-white rounded-xl shadow p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700">生成任务</h2>
<span className="text-xs text-gray-400">{jobLoading ? "刷新中..." : "自动刷新"}</span>
</div>
{jobs.length === 0 ? (
<div className="text-sm text-gray-400">暂无生成任务</div>
) : (
<div className="space-y-3">
{jobs.map((job) => (
<div key={job.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium text-gray-700 truncate">{job.current_product_title || "任务"}</span>
<span className="text-gray-500">{job.status}</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500" style={{ width: `${progressOf(job)}%` }} />
</div>
<div className="mt-2 text-xs text-gray-500 flex justify-between">
<span>{job.completed_notes || 0}/{job.total_notes || 0}</span>
<span>{job.message || ""}</span>
</div>
</div>
))}
</div>
)}
</div>
{selected.size > 0 && (
<div className="mb-3 flex items-center gap-3 bg-blue-50 rounded-lg px-4 py-2 text-sm">
<span className="text-blue-700">已选择 {selected.size} </span>