R33: 工作台热点数据页更新按钮(纯图标+30min冷却防重+整页重建+CDP冒烟全绿)
This commit is contained in:
1 parent
9445b76485
commit
fa55d3eecb
6 files changed
+243
-4
No files matched your search
@@ -275,4 +275,18 @@
|
||||
- **junction 重建**:node `unlinkSync` 删旧链(rmdir 对 junction 报 Not a directory)→ PowerShell New-Item Junction `短视频工作台`→`D:\...\project\短视频脚本创作\V1.0\`;验证 isSymbolicLink:true/children:7/frontmatter name=短视频工作台/md5 一致
|
||||
- **残留核查**:全仓剩 35 行全部可保留分类(产品叙事 13/流程术语 4/能力描述+变更记录 4/历史审计 4/repo 路径字面量 9/失误记录 1),技能名指称清零
|
||||
- 版本 ?v=20260903r;commit 057522c
|
||||
- **待办**:dsh 副本 short-video-script name 仍「短视频脚本创作」待授权同步;用户环境安装包技能名=短视频工作台
|
||||
- **待办**:用户环境安装包技能名=短视频工作台(仅剩此项,dsh 已同步见下)
|
||||
- **R32 补完:dsh 副本同步改名(09-03 16:5x,commit 5bc1f49)**:用户「按方案 B 完整档修改」授权 → 同步 `C:\Users\maidou\.dsh\skills\short-video-script\`:SKILL.md name→短视频工作台 + description 前缀「短视频工作台(短视频脚本创作全流程技能):」+ 标题 `# 短视频工作台 Skill` + last_change 前缀照源 V1.0 写法追加改名记录;帮助文档 L1→`# 短视频工作台 — 使用指南`(源格式无「技能」字样);deployment:"dsh"/deployed_at 保留;残留 5 处全保留(description 括号能力描述/last_change/L22 能力描述/references 创作师角色 2)与源处理一致;git add 精确两文件,不混入 .dsh 仓库其他未提交变更(mcn-data-insight 等仍在工作区)
|
||||
|
||||
## R33 工作台榜单页「更新」按钮(09-03 晚间)
|
||||
- **需求**:热点数据三榜页加更新图标(用户中途改纯图标去文字),点击确认后后台任务重抓三榜最新一期并落盘;执行前检查上次更新防重复
|
||||
- **后端 server.js 顶层新增**:`RANK_UPDATE_NAME='更新榜单数据'`(固定任务名=防重识别键)+ `RANK_UPDATE_COOLDOWN_MS=30min` + `wbDb()`(宿主库 `~/.workbuddy/workbuddy.db`)+ `createOnceAutomation(name,prompt,skillsArr)`(自 /api/run 抽公共一次性任务创建:once+next_run_at +5s/owner 取最近会话/model 动态跟随/skills 参数级挂载)+ `rankLastTaskState()`(按固定任务名查最近状态,running/queued/pending=进行中)
|
||||
- **接口**:`GET /api/dsh/ranking-update` 返回新鲜度(exists/files/latestMtime→lastUpdateAgeMin/lastUpdateDate/cooldownMin/task);`POST {prompt,force}` 双层防重:①任务进行中直接拦截 ②距上次落盘 <30min 且未 force → `needForce:true`;通过则 createOnceAutomation(name, prompt, ['mcn-data-insight'])
|
||||
- **数据层 dsh-data.js `rankingFreshness()`**:递归扫 RANKING_DIR(三榜子目录 账号周榜/视频热榜/点赞榜 + 根旧平铺回退)全部 .json → latestMtime/latestFile/latestDate/files(=最近成功落盘时刻)
|
||||
- **前端 data-pages.js**:`SKILL_HINT_RANKING` 常量(mcn-data-insight 更新语义:fetch_week_ranks.py --board both 全品类单次 + douyin-top-account 5 赛道 + 幂等铁律 ls 跳过跳 API + 环境落盘目录)+ toolbar 标题行右 `.toolbar-right` 加 `#rankUpdate`(**纯图标**:`.btn-icon` + svgIcon('refresh',16) + title 悬停说明全链路)+ `updateRankingData()`:GET 状态→running 拦截 toast→确认 modal(上次更新时间/三榜范围;冷却窗口内黄字警示 `#b7791f` 按钮变「仍要更新」=force)→ POST→轮询 15min 上限→完成 `view._reload=null; router()` **整页重建**(meta 缓存闭包内,不重建拉不到新数据期);busy 态**不动 innerHTML**,改 title='榜单更新中…' + `.btn-busy` 类图标旋转
|
||||
- **style.css**:`.btn-icon`(padding 5px 9px inline-flex center)+ `.btn-busy .ic`(animation: req-spin 1s linear infinite,复用弹窗 spinner)
|
||||
- **成本铁律**:modal 明示约 7 次红狐 API(2 作品榜调用 + 5 赛道账号榜),用户确认即豁免;抓取器幂等 ls 命中跳 API 防重复花钱
|
||||
- **版本**:index.html ?v=20260903r→s→t(style.css+data-pages.js 均到 t)
|
||||
- **验证**:node --check 3 JS ✓;curl --noproxy GET 状态正常(files=73/lastUpdateAgeMin=124/task=null);POST 空 prompt 400;CDP 冒烟 `tmp/cdp-rank-update-smoke.mjs`(9225)纯图标版 9 项全 PASS(text='' + svg + title + modal 标题/三榜范围/上次更新/按钮文案/取消)
|
||||
- **服务/工具坑(重踩)**:nohup+& 后台 node 随 bash 退出被回收 → 须 Bash run_in_background 直接托管前台;taskkill //PID Git Bash 语法错 → PowerShell Stop-Process -Force(返回 exit 1 但端口已释放);curl 全局 socks 代理 502 → 必须 --noproxy '*'
|
||||
- **待实测**:首次真实点击任务链路(调度拾取→技能会话→抓取执行→落盘→页面重建出最新期)
|
||||
@@ -409,6 +409,32 @@ function rankingMeta(board = 'week') {
|
||||
return { dates, categories: collectCategories(board, files) };
|
||||
}
|
||||
|
||||
// 榜单数据新鲜度(更新防重依据,09-03 新增):递归扫描 RANKING_DIR(三榜子目录 + 根目录旧平铺)全部 .json,
|
||||
// 返回最新落盘文件的 mtime(=最近一次成功抓取完成时刻,任务落盘即刷新)
|
||||
function rankingFreshness() {
|
||||
const out = { exists: fs.existsSync(RANKING_DIR), latestMtime: 0, latestFile: '', latestDate: '', files: 0 };
|
||||
if (!out.exists) return out;
|
||||
const walk = (dir) => {
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
||||
for (const en of entries) {
|
||||
const full = path.join(dir, en.name);
|
||||
if (en.isDirectory()) walk(full);
|
||||
else if (/\.json$/i.test(en.name)) {
|
||||
out.files++;
|
||||
try {
|
||||
const st = fs.statSync(full);
|
||||
if (st.mtimeMs > out.latestMtime) { out.latestMtime = st.mtimeMs; out.latestFile = full; }
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(RANKING_DIR);
|
||||
const m = String(out.latestFile).match(/(\d{4}-\d{2}-\d{2})\.json$/);
|
||||
if (m) out.latestDate = m[1];
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseNum(v) {
|
||||
const s = String(v ?? '');
|
||||
const m = s.match(/^([\d.]+)\s*(w|万)?$/i);
|
||||
@@ -541,6 +567,6 @@ function getRewriteStats(account = '') {
|
||||
module.exports = {
|
||||
dbAvailable, setFavorite, batchDelete, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
|
||||
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
|
||||
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR,
|
||||
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, rankingFreshness, DSH_DB, RANKING_DIR,
|
||||
listCustomScripts, getCustomScript, saveScript, getRewriteStats,
|
||||
};
|
||||
@@ -21,6 +21,10 @@ const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis
|
||||
参考选题创作(有原视频,如视频行 AI创作):传 videoId/awemeId → rewrite_log.video_id 关联原视频;
|
||||
自定义选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「自定义选题创作」tab。
|
||||
保存走本地服务 /api/dsh/script-save:server 自行解析 mcn-plugin.db 路径(跨开发机/dsh 环境最可靠),勿让 agent 猜库路径 */
|
||||
/* 榜单数据更新技能声明(09-03 新增:热点数据页「更新榜单」按钮 → 后台任务按 mcn-data-insight 落盘规范抓取三榜最新一期。
|
||||
范围与调用次数已在点击确认弹窗中向用户明示并获授权(账号周榜 5 赛道 + 视频热榜/点赞榜,约 7 次红狐 API);
|
||||
prompt 内保留「查询幂等铁律」与「落盘目录按环境解析」,禁止模型逐赛道/逐日循环浪费 API 配额 */
|
||||
const SKILL_HINT_RANKING = '请调用 Skill 工具加载「mcn-data-insight」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-data-insight/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为「榜单数据更新」(工作台热点数据页更新按钮触发,用户已确认本次调用范围与次数):按 SKILL.md「榜单数据落盘规范」将热点数据三榜最新一期数据落盘到本地 抖音榜单/ 目录:① 视频热榜 + 点赞榜:运行 scripts/fetch_week_ranks.py --board both(全品类单次调用,禁止逐赛道/逐日循环);② 账号周榜:按 subskills/douyin-top-account 技能流程抓取保留 5 赛道(生活vlog/小剧场/亲子/美食/旅行)最新一期账号周榜,逐赛道写入 账号周榜/抖音周榜_{赛道}_{周一}.json;③ 落盘目录按技能规范按环境解析(开发机/用户环境 = 桌面 MCNSkill项目\\三方数据\\红狐数据\\抖音榜单\\;dsh 环境 = D:\\dshworkspace\\抖音榜单\\),新产物入 账号周榜/视频热榜/点赞榜 三个子目录,文件名保持 抖音周榜_ 前缀;④ 查询幂等铁律:每次调用前先 ls 目标目录,同榜名+赛道+数据日期文件已存在则直接跳过对应 API 调用(红狐 API 按次计费);⑤ 数据日期取 API 返回的榜单对应日期(最新一期),非获取时刻。完成后汇报:三榜各写入的文件路径与数据期、缓存命中跳过项、实际 API 调用次数。技能边界:只做榜单数据抓取与落盘,不做账号分析/脚本创作等其他能力。';
|
||||
const saveHintOf = (ctx) => {
|
||||
const ref = Number(ctx.videoId) > 0;
|
||||
const lines = [];
|
||||
@@ -519,10 +523,14 @@ async function renderRanking(view) {
|
||||
let tab = 'week';
|
||||
const tabLabel = () => { const t = RANK_TABS.find(([k]) => k === tab); return t ? t[1] : ''; };
|
||||
// tab 切换 + 赛道选择 + 时间切换 同一行(标题行只放 返回 | 标题,tab + 筛选放标题下一行 .rank-toolbar)
|
||||
// 09-03 更新榜单按钮放标题行最右(.toolbar-right):三榜 tab 切换与空态(无本地数据文件)均可见,便于首次抓取
|
||||
const toolbar = (s, withFilters) => `
|
||||
<div class="page-toolbar">
|
||||
${toolbarBack()}
|
||||
<span class="page-title">热点数据</span>
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-icon" id="rankUpdate" title="更新榜单数据:后台任务重新抓取三榜(账号周榜 5 赛道 / 视频热榜 / 点赞榜)最新一期并落盘;30 分钟内已更新会拦截防重复">${svgIcon('refresh', 16, 'currentColor')}</button>
|
||||
</div>
|
||||
</div>
|
||||
${withFilters ? `<div class="rank-toolbar">
|
||||
<div class="rank-tabs" role="tablist">
|
||||
@@ -594,6 +602,8 @@ async function renderRanking(view) {
|
||||
|
||||
function bindShell() {
|
||||
bindBack(view);
|
||||
const updBtn = view.querySelector('#rankUpdate');
|
||||
if (updBtn) updBtn.addEventListener('click', () => updateRankingData(view, updBtn));
|
||||
view.querySelectorAll('.rank-tab').forEach((t) => t.addEventListener('click', () => {
|
||||
const k = t.dataset.rankTab;
|
||||
if (k === tab) return;
|
||||
@@ -666,6 +676,86 @@ async function renderRanking(view) {
|
||||
render();
|
||||
}
|
||||
|
||||
/* ===== 热点数据「更新榜单」按钮(09-03 新增):后台任务抓取三榜最新一期,执行前防重复校验 =====
|
||||
流程:GET 更新状态(上次成功落盘时间 + 是否有任务在跑)→ 有任务在跑直接拦截 → 确认弹窗(显示上次更新时间;
|
||||
冷却窗口内黄色警示 + 按钮变「仍要更新」= force)→ POST(后端二次防重)→ 轮询任务状态 → 完成后整页重建
|
||||
(meta 缓存丢弃重新拉取 → 跳到含最新数据期的列表) */
|
||||
async function updateRankingData(view, btn) {
|
||||
// 1) 查询更新状态(后端防重数据源:RANKING_DIR 最新 json mtime + 最近同名任务状态)
|
||||
let st = null;
|
||||
try { st = await api('/api/dsh/ranking-update'); } catch (e) { showToast('❌ 查询榜单更新状态失败: ' + e.message); return; }
|
||||
if (!st || st.ok === false) { showToast('❌ ' + ((st && st.error) || '查询失败')); return; }
|
||||
const taskState = st.task ? st.task.state : null;
|
||||
if (taskState && ['running', 'queued', 'pending'].includes(taskState)) {
|
||||
showToast('🔄 已有榜单更新任务在执行中,请到左侧会话栏查看进度'); return;
|
||||
}
|
||||
const inCooldown = st.lastUpdateAt != null && st.lastUpdateAgeMin != null && st.lastUpdateAgeMin < st.cooldownMin;
|
||||
// 2) 确认弹窗(显示上次更新时间;冷却窗口内警示并允许强制)
|
||||
const ageTxt = () => {
|
||||
if (st.lastUpdateAt == null) return null;
|
||||
const m = st.lastUpdateAgeMin;
|
||||
if (m < 1) return '刚刚';
|
||||
if (m < 60) return `${m} 分钟前`;
|
||||
return `${(m / 60).toFixed(1)} 小时前`;
|
||||
};
|
||||
const timeTxt = st.lastUpdateAt ? new Date(st.lastUpdateAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal" style="max-width:560px">
|
||||
<h3>${svgIcon('refresh', 18)} 更新榜单数据</h3>
|
||||
<p class="dim">将重新抓取热点数据三榜最新一期并落盘:账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 7 次红狐 API 调用(幂等铁律:同榜名+赛道+数据日期已存在则跳过)。后台任务执行需数分钟,完成后自动刷新榜单。</p>
|
||||
${st.lastUpdateAt ? `<div class="confirm-target">上次更新:${esc(st.lastUpdateDate || '—')} ${esc(timeTxt)}(${esc(ageTxt() || '')})</div>` : (st.exists === false ? '<div class="confirm-target">本地暂无榜单数据文件,本次为首次抓取</div>' : '')}
|
||||
${inCooldown ? `<p style="color:#b7791f;font-size:14px;margin:8px 0 0">⚠️ ${st.lastUpdateAgeMin} 分钟前刚更新过(防重复窗口 ${st.cooldownMin} 分钟),数据仍较新;确需强制刷新请点「仍要更新」。</p>` : ''}
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" data-cancel>取消</button>
|
||||
<button class="primary" data-ok>${inCooldown ? '仍要更新' : '确认更新'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
mask.querySelector('[data-ok]').addEventListener('click', async () => {
|
||||
close();
|
||||
const oldTitle = btn ? btn.title : '';
|
||||
const restore = () => { if (btn) { btn.disabled = false; btn.title = oldTitle; btn.classList.remove('btn-busy'); } };
|
||||
if (btn) { btn.disabled = true; btn.title = '榜单更新中…(完成后自动刷新)'; btn.classList.add('btn-busy'); } // 纯图标按钮:不动 innerHTML,图标经 .btn-busy .ic 旋转
|
||||
try {
|
||||
// 3) 提交(后端二次防重;冷却窗口内确认 = 传 force)
|
||||
const r = await api('/api/dsh/ranking-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: SKILL_HINT_RANKING, force: !!inCooldown }),
|
||||
});
|
||||
if (!r || r.ok === false) throw new Error((r && r.error) || '任务提交失败');
|
||||
showToast('✅ 榜单更新任务已提交,请到左侧会话栏查看执行');
|
||||
// 4) 轮询任务状态(最长 15 分钟;期间按钮保持 busy)
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
let state = 'running';
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((res) => setTimeout(res, 5000));
|
||||
let s = null;
|
||||
try { s = await api('/api/run/status?id=' + encodeURIComponent(r.id)); } catch (e) { continue; }
|
||||
if (!s || s.state === 'notfound') { state = 'done'; break; }
|
||||
if (s.state === 'done') { state = 'done'; break; }
|
||||
if (s.state === 'error' || s.state === 'review') { state = s.state; break; }
|
||||
}
|
||||
restore();
|
||||
if (state === 'done') {
|
||||
showToast('✅ 榜单数据已更新,正在刷新…');
|
||||
// 5) 整页重建:丢弃 _reload(同闭包 render 不清 meta 缓存)→ router 重新进入渲染 → 拉到最新数据期
|
||||
if (view) view._reload = null;
|
||||
router();
|
||||
} else {
|
||||
showToast(state === 'error' ? '❌ 榜单更新任务执行失败,请到左侧会话栏查看原因' : (state === 'review' ? '⏸ 榜单更新任务待确认,请到左侧会话栏处理' : '⏳ 榜单更新任务仍在执行,请到左侧会话栏查看进度'));
|
||||
}
|
||||
} catch (e) {
|
||||
restore();
|
||||
showToast('❌ 提交失败: ' + e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 账号列表 ================= */
|
||||
async function renderAccounts(view) {
|
||||
let state = { type: 'all', search: '', track: '', page: 1, pageSize: 20, batch: false };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCN工作台</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260903r">
|
||||
<link rel="stylesheet" href="style.css?v=20260903t">
|
||||
<script src="vendor/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -42,6 +42,6 @@
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260903r"></script>
|
||||
<script src="data-pages.js?v=20260903r"></script>
|
||||
<script src="data-pages.js?v=20260903t"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -426,6 +426,8 @@ main#view { height: calc(100vh - 55px); overflow: auto; } /* 55px=topbar 实际
|
||||
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-busy { background: var(--primary-soft) !important; color: var(--primary) !important; border-color: var(--primary) !important; opacity: .8; }
|
||||
.btn-icon { padding: 5px 9px; display: inline-flex; align-items: center; justify-content: center; } /* 纯图标按钮(09-03:榜单更新等) */
|
||||
.btn-busy .ic { animation: req-spin 1s linear infinite; } /* busy 态图标旋转(复用弹窗 spinner 动画) */
|
||||
.btn-sm { padding: 3px 10px; font-size: 14px; border-radius: 6px; }
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: transparent; }
|
||||
.btn-primary:hover { background: #2a5fd0; color: #fff; }
|
||||
|
||||
@@ -275,6 +275,66 @@ function log(msg) {
|
||||
console.log('[' + new Date().toLocaleTimeString('zh-CN', { hour12: false }) + '] ' + msg);
|
||||
}
|
||||
|
||||
// ---------- 榜单数据更新任务(09-03 新增:热点数据页「更新榜单」按钮后台任务)----------
|
||||
// 任务名固定用于防重识别(同名最近一次任务状态 = 是否有更新在跑 / 上次执行结果)
|
||||
const RANK_UPDATE_NAME = '更新榜单数据';
|
||||
const RANK_UPDATE_COOLDOWN_MS = 30 * 60 * 1000; // 防重复更新窗口:距上次成功落盘 < 30 分钟拦截(可 force 跳过)
|
||||
|
||||
// workbuddy 宿主库(automations/sessions/automation_runs),与 /api/run 同款路径解析
|
||||
function wbDb() {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
return new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'));
|
||||
}
|
||||
|
||||
// 创建一次性后台任务(08-31 实测约定:scheduled_at 与 next_run_at 均取 +5s 未来,调度器按 next_run_at 扫描拾取;
|
||||
// 模型跟随用户最近活跃手动会话选择,兜底 'auto')——逻辑与 /api/run 完全一致,供更新榜单等新入口复用
|
||||
function createOnceAutomation(name, prompt, skillsArr) {
|
||||
const db = wbDb();
|
||||
try {
|
||||
const now = Date.now();
|
||||
const id = 'automation-' + now;
|
||||
const cwd = 'D:\\AgentSkill\\mcn-workshop'; // 工作台触发的会话归入 mcn-workshop 空间分组
|
||||
const d = new Date(now + 5 * 1000);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
const nextRunAt = now + 5 * 1000;
|
||||
const uidRow = db.prepare("SELECT user_id FROM sessions WHERE user_id IS NOT NULL AND user_id <> '' ORDER BY created_at DESC LIMIT 1").get();
|
||||
const ownerUserId = uidRow ? uidRow.user_id : '';
|
||||
const modelRow = db.prepare(`SELECT model FROM sessions WHERE deleted_at IS NULL AND COALESCE(is_background_automation,0)=0 AND model IS NOT NULL AND model <> '' ORDER BY COALESCE(last_activity_at, updated_at) DESC LIMIT 1`).get();
|
||||
const modelId = (modelRow && modelRow.model) ? modelRow.model : 'auto';
|
||||
db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(id, name, prompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(Array.isArray(skillsArr) ? skillsArr : []), '[]', modelId, 'fullAccess', ownerUserId, 'confirmed');
|
||||
return { id };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 最近一次「更新榜单数据」任务状态(running/queued/pending = 进行中;done/error/review = 已结束)
|
||||
function rankLastTaskState() {
|
||||
try {
|
||||
const db = wbDb();
|
||||
try {
|
||||
const row = db.prepare('SELECT id, status, deleted_at FROM automations WHERE name=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1').get(RANK_UPDATE_NAME);
|
||||
if (!row) return null;
|
||||
const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(row.id);
|
||||
const runs = db.prepare('SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1').get(row.id);
|
||||
let state = 'queued';
|
||||
if (st && Number(st.running) === 1) state = 'running';
|
||||
else if (runs) {
|
||||
const s = String(runs.status || '').toUpperCase();
|
||||
if (s === 'IN_PROGRESS') state = 'running';
|
||||
else if (['DONE', 'COMPLETED', 'SUCCESS', 'FINISHED', 'ACCEPTED'].includes(s)) state = 'done';
|
||||
else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error';
|
||||
else if (s === 'PENDING_REVIEW') state = 'review';
|
||||
else state = 'pending';
|
||||
}
|
||||
return { id: row.id, state, error: (st && st.last_error) || null };
|
||||
} finally { db.close(); }
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// ---- 静态文件 / API ----
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://localhost');
|
||||
@@ -527,6 +587,53 @@ const server = http.createServer((req, res) => {
|
||||
} catch (e) { return sendErr(500, e.message); }
|
||||
}
|
||||
|
||||
// ---------- 榜单数据更新(09-03 新增:热点数据页「更新榜单」按钮;执行前防重复校验)----------
|
||||
// GET:返回数据新鲜度(最近成功落盘时刻 = RANKING_DIR 最新 json mtime)+ 最近同名更新任务状态,供前端展示与预拦截
|
||||
if (p === '/api/dsh/ranking-update' && req.method === 'GET') {
|
||||
try {
|
||||
const fr = dsh.rankingFreshness();
|
||||
return sendJSON(200, {
|
||||
ok: true,
|
||||
exists: fr.exists, files: fr.files,
|
||||
lastUpdateAt: fr.latestMtime || null,
|
||||
lastUpdateDate: fr.latestDate || '',
|
||||
lastUpdateAgeMin: fr.latestMtime ? Math.floor((Date.now() - fr.latestMtime) / 60000) : null,
|
||||
cooldownMin: RANK_UPDATE_COOLDOWN_MS / 60000,
|
||||
task: rankLastTaskState(),
|
||||
});
|
||||
} catch (e) { return sendErr(500, '查询更新状态失败: ' + e.message); }
|
||||
}
|
||||
// POST:防重校验(① 更新任务进行中拦截;② 距上次落盘 < 冷却窗口且未 force 拦截)→ 创建后台抓取任务
|
||||
if (p === '/api/dsh/ranking-update' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { prompt, force } = JSON.parse(body || '{}');
|
||||
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) return sendErr(400, 'prompt 不能为空');
|
||||
// 防重①:进行中(running/queued/pending)→ 拦截
|
||||
const task = rankLastTaskState();
|
||||
if (task && ['running', 'queued', 'pending'].includes(task.state)) {
|
||||
return sendJSON(200, { ok: false, error: '已有榜单更新任务在执行中(可在左侧会话栏查看进度),请勿重复提交' });
|
||||
}
|
||||
// 防重②:距上次成功落盘 < 冷却窗口且未 force → 拦截(needForce 供前端提示仍可强制更新)
|
||||
const fr = dsh.rankingFreshness();
|
||||
if (!force && fr.latestMtime && Date.now() - fr.latestMtime < RANK_UPDATE_COOLDOWN_MS) {
|
||||
const ageMin = Math.floor((Date.now() - fr.latestMtime) / 60000);
|
||||
return sendJSON(200, {
|
||||
ok: false, needForce: true,
|
||||
error: `榜单数据 ${Math.max(ageMin, 1)} 分钟前刚更新过(${fr.latestDate || ''}),${RANK_UPDATE_COOLDOWN_MS / 60000} 分钟内防重复更新;如确需强制刷新请再次点击并选择「仍要更新」`,
|
||||
});
|
||||
}
|
||||
// 通过 → 创建后台更新任务(skills 挂 mcn-data-insight:三榜抓取脚本与落盘规范所在)
|
||||
const r = createOnceAutomation(RANK_UPDATE_NAME, prompt.trim(), ['mcn-data-insight']);
|
||||
log('已提交榜单更新任务: ' + r.id);
|
||||
return sendJSON(200, { ok: true, id: r.id, message: '榜单更新任务已提交,请在左侧会话栏查看执行' });
|
||||
} catch (e) { return sendErr(500, '任务提交失败: ' + e.message); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------- 提交 AI 任务(Automation 一次性任务,WorkBuddy 自动执行,左侧会话栏可见)----------
|
||||
if (p === '/api/run' && req.method === 'POST') {
|
||||
let body = '';
|
||||
|
||||
Reference in new issue
Block a user