加入卡密验证功能
This commit is contained in:
parent
320e130ea5
commit
b6080d5e16
382
authApi.js
Normal file
382
authApi.js
Normal 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.
@ -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`);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,27 +82,31 @@ function Sidebar({ collapsed, onToggle }) {
|
||||
|
||||
export default function App() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((c) => !c)} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/shops" element={<Shops />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/notes/approved" element={<NotesApproved />} />
|
||||
<Route path="/notes/unapproved" element={<NotesUnapproved />} />
|
||||
<Route path="/settings/deepseek" element={<SettingsDeepseek />} />
|
||||
<Route path="/settings/agnes" element={<SettingsAgnes />} />
|
||||
<Route path="/settings/prompts" element={<SettingsPrompts />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<LicenseGate>
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((value) => !value)} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<AuthToolbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/shops" element={<Shops />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/notes/approved" element={<NotesApproved />} />
|
||||
<Route path="/notes/unapproved" element={<NotesUnapproved />} />
|
||||
<Route path="/settings/deepseek" element={<SettingsDeepseek />} />
|
||||
<Route path="/settings/agnes" element={<SettingsAgnes />} />
|
||||
<Route path="/settings/prompts" element={<SettingsPrompts />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</LicenseGate>
|
||||
</ErrorBoundary>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
117
web/src/auth.js
Normal file
117
web/src/auth.js
Normal 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;
|
||||
}
|
||||
82
web/src/components/AuthToolbar.jsx
Normal file
82
web/src/components/AuthToolbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user