ework-web 0.10.55 → 0.10.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.55",
3
+ "version": "0.10.57",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1279,6 +1279,32 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1279
1279
  return json({ ok: true, enabled });
1280
1280
  }
1281
1281
 
1282
+ if (req.method === "GET" && url.pathname === "/api/wake-policy") {
1283
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1284
+ const cfg = await getConfigAll();
1285
+ const appCfg = await loadConfig();
1286
+ const list = (k: string, d: string) => (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
1287
+ return json({
1288
+ wakeKinds: list("wakeKinds", "human"),
1289
+ wakeLogins: list("wakeLogins", ""),
1290
+ noWakeLogins: list("noWakeLogins", appCfg.daemonBotLogin),
1291
+ });
1292
+ }
1293
+
1294
+ if (req.method === "POST" && url.pathname === "/api/wake-policy") {
1295
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1296
+ const payload = await req.json().catch(() => ({} as Record<string, unknown>));
1297
+ const p = payload as { wakeKinds?: unknown; wakeLogins?: unknown; noWakeLogins?: unknown };
1298
+ const join = (v: unknown) => Array.isArray(v) ? v.filter((s) => typeof s === "string").join(",") : (typeof v === "string" ? v : "");
1299
+ const wk = join(p.wakeKinds);
1300
+ const wl = join(p.wakeLogins);
1301
+ const nw = join(p.noWakeLogins);
1302
+ if (wk) await setConfig("wakeKinds", wk); else await deleteConfig("wakeKinds");
1303
+ if (wl) await setConfig("wakeLogins", wl); else await deleteConfig("wakeLogins");
1304
+ if (nw) await setConfig("noWakeLogins", nw); else await deleteConfig("noWakeLogins");
1305
+ return json({ ok: true });
1306
+ }
1307
+
1282
1308
  if (url.pathname === "/api/router/daemons" && req.method === "GET") {
1283
1309
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1284
1310
  try {
@@ -49,6 +49,8 @@ header.topbar .num{opacity:.7}
49
49
  .meta-bar{display:flex;flex-direction:column;gap:.3rem;padding:.7rem max(1rem,calc((100% - 900px)/2));border-bottom:1px solid var(--border);background:var(--bg-muted)}
50
50
  .meta-bar h1{font-size:18px;margin:0;font-weight:600;overflow-wrap:anywhere;word-break:break-word}
51
51
  .meta-status{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;font-size:13px;color:var(--text-muted)}
52
+ .action-group{display:flex;gap:.3rem;align-items:center;padding-left:.5rem;margin-left:.2rem;border-left:1px solid var(--border)}
53
+ .label-group{display:flex;gap:.3rem;align-items:center;padding-left:.5rem;margin-left:.2rem;border-left:1px solid var(--border);flex-wrap:wrap}
52
54
  .count{color:var(--text-muted);font-size:12px;white-space:nowrap}
53
55
  #list{padding:.5rem .6rem 4rem;max-width:900px;margin:0 auto}
54
56
  .sentinel{height:1px}
@@ -141,10 +143,14 @@ header.topbar .num{opacity:.7}
141
143
  .ai-completed{background:#1a7f37;color:#fff}
142
144
  .ai-failed{background:#cf222e;color:#fff}
143
145
  @keyframes ai-pulse{0%,100%{opacity:1}50%{opacity:.6}}
144
- .halt-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#cf222e;cursor:pointer;font-weight:600}
145
- .halt-btn:hover{background:#cf222e;color:#fff}
146
- .dispatch-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#6f7781;cursor:pointer;font-weight:600}
147
- .dispatch-btn:hover{background:#6f7781;color:#fff}
146
+ .action-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#cf222e;cursor:pointer;font-weight:600}
147
+ .action-btn:hover{background:#cf222e;color:#fff}
148
+ .action-btn.resume-btn{color:#1a7f37}
149
+ .action-btn.resume-btn:hover{background:#1a7f37;color:#fff}
150
+ .action-btn.dispatch-btn{color:#6f7781}
151
+ .action-btn.dispatch-btn:hover{background:#6f7781;color:#fff}
152
+ .action-btn.custom-btn{color:var(--accent)}
153
+ .action-btn.custom-btn:hover{background:var(--accent);color:#fff}
148
154
  `;
149
155
 
150
156
  export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
@@ -165,11 +171,10 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
165
171
  : "";
166
172
  const aiBadgeHtml = (() => {
167
173
  const s = props.aiStatus ?? "";
168
- if (!s) return "";
174
+ // halted/dispatch_off are represented by their action buttons — skip badge
175
+ if (!s || s === "halted" || s === "dispatch_off") return "";
169
176
  const map: Record<string, { cls: string; label: string }> = {
170
177
  processing: { cls: "ai-processing", label: "⚙️ 处理中" },
171
- halted: { cls: "ai-halted", label: "⏸️ 已暂停" },
172
- dispatch_off: { cls: "ai-dispatch-off", label: "🔕 不接单" },
173
178
  completed: { cls: "ai-completed", label: "✓ 已完成" },
174
179
  failed: { cls: "ai-failed", label: "✗ 失败" },
175
180
  ...props.extraStatusBadges,
@@ -178,15 +183,17 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
178
183
  if (!m) return "";
179
184
  return `<span class="ai-badge ${m.cls}">${m.label}</span>`;
180
185
  })();
181
- const haltBtnHtml = props.writesEnabled !== false
182
- ? props.aiStatus === "halted"
183
- ? `<button type="button" class="halt-btn resume-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/resume" data-action-confirm="确认恢复 AI 处理?" title="恢复 AI 处理">▶ 恢复</button>`
184
- : `<button type="button" class="halt-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
186
+ const isTerminal = props.aiStatus === "completed";
187
+ const showActions = props.writesEnabled !== false && !isTerminal;
188
+ const haltBtnHtml = showActions
189
+ ? (props.aiStatus === "halted" || props.aiStatus === "failed")
190
+ ? `<button type="button" class="action-btn resume-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/resume" data-action-confirm="确认恢复 AI 处理?" title="恢复 AI 处理">▶ 恢复</button>`
191
+ : `<button type="button" class="action-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
185
192
  : "";
186
- const dispatchBtnHtml = props.writesEnabled !== false
193
+ const dispatchBtnHtml = showActions
187
194
  ? props.aiStatus === "dispatch_off"
188
- ? `<button type="button" class="dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
189
- : `<button type="button" class="dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
195
+ ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
196
+ : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
190
197
  : "";
191
198
  return `<!doctype html>
192
199
  <html lang="zh">
@@ -209,15 +216,16 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
209
216
  <h1>${escapeHtml(props.issueTitle)}</h1>
210
217
  <div class="meta-status">
211
218
  <span class="state-badge ${stateClass}">${stateLabel}</span>
212
- ${aiBadgeHtml}${haltBtnHtml}${dispatchBtnHtml}${(props.customActions ?? []).map((a) => {
219
+ ${aiBadgeHtml}
220
+ ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${(props.customActions ?? []).map((a) => {
213
221
  const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
214
222
  if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
215
223
  if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);
216
224
  if (a.reloadOnOk === false) attrs.push(`data-action-reload="false"`);
217
- const cls = a.className ? `${escapeAttr(a.className)}` : "custom-action-btn";
225
+ const cls = a.className ? `${escapeAttr(a.className)}` : "action-btn custom-btn";
218
226
  return `<button type="button" class="${cls}" ${attrs.join(" ")}${a.title ? ` title="${escapeAttr(a.title)}"` : ""}>${escapeHtml(a.label)}</button>`;
219
- }).join("")}
220
- ${labelsHtml}${labelPickerBtn}
227
+ }).join("")}</span>` : ""}
228
+ ${(labelsHtml || labelPickerBtn) ? `<span class="label-group">${labelsHtml}${labelPickerBtn}</span>` : ""}
221
229
  <span class="count" id="count">…</span>
222
230
  ${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
223
231
  </div>
package/src/webhooks.ts CHANGED
@@ -695,8 +695,20 @@ export async function emitCommentEvent(
695
695
  log.info(`webhook: dispatch disabled but waking via comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
696
696
  }
697
697
  const authorUser = await getUserByLogin(comment.author);
698
- if (authorUser && authorUser.kind !== "human") {
699
- log.info(`webhook: comment wake denied (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
698
+ const appCfg = await loadConfig();
699
+ const cfgList = (k: string, d: string) =>
700
+ (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
701
+ const noWake = cfgList("noWakeLogins", appCfg.daemonBotLogin);
702
+ const okLogins = cfgList("wakeLogins", "");
703
+ const okKinds = cfgList("wakeKinds", "human");
704
+ const author = comment.author;
705
+ const denied =
706
+ noWake.includes(author) ? "deny-list" :
707
+ okLogins.includes(author) ? null :
708
+ (authorUser && !okKinds.includes(authorUser.kind)) ? `kind=${authorUser.kind}` :
709
+ null;
710
+ if (denied) {
711
+ log.info(`webhook: comment wake denied (author=${author} reason=${denied}) for ${scopeKey}#${issue.number}`);
700
712
  return;
701
713
  }
702
714
  if (authorUser && !(await canWriteProject(projectId, { login: comment.author, is_admin: authorUser.is_admin }))) {