ework-web 0.10.17 → 0.10.19

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.17",
3
+ "version": "0.10.19",
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/config.ts CHANGED
@@ -78,6 +78,7 @@ export const configSchema = z.object({
78
78
  daemonBotLogin: z.string().default(""),
79
79
  daemonWebhookUrl: z.string().default(""),
80
80
  daemonWebhookSecret: z.string().default(""),
81
+ routerAdminToken: z.string().default(""),
81
82
  // Default "provider/model" string passed to `opencode run --model <X>`.
82
83
  // Empty = let opencode pick per its own opencode.json + env. ework-daemon
83
84
  // pushes this (or the per-project override) on every spawn to defend
@@ -175,8 +176,9 @@ export async function loadConfig(): Promise<Config> {
175
176
  ttsDefaultBackend: db.ttsDefaultBackend ?? process.env.WORK_TTS_DEFAULT_BACKEND,
176
177
  ttsSpeed: db.ttsSpeed ?? process.env.WORK_TTS_SPEED,
177
178
  daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "",
178
- daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
179
- daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
179
+ daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
180
+ daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
181
+ routerAdminToken: process.env.WORK_ROUTER_ADMIN_TOKEN ?? "",
180
182
  defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
181
183
  autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
182
184
  webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
package/src/db.ts CHANGED
@@ -127,6 +127,20 @@ function migrateIssuesTable(db: Database): void {
127
127
  }
128
128
  }
129
129
 
130
+ function migrateLabelsTable(db: Database): void {
131
+ const have = tableColumns(db, "labels");
132
+ if (have.size === 0) return;
133
+ if (!have.has("description")) {
134
+ db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN description TEXT NOT NULL DEFAULT ''"));
135
+ }
136
+ if (!have.has("exclusive")) {
137
+ db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN exclusive INTEGER NOT NULL DEFAULT 0"));
138
+ }
139
+ if (!have.has("is_archived")) {
140
+ db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0"));
141
+ }
142
+ }
143
+
130
144
  // ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ----
131
145
  class SqliteDriver implements AsyncDatabase {
132
146
  readonly dialect = "sqlite" as const;
@@ -145,10 +159,11 @@ class SqliteDriver implements AsyncDatabase {
145
159
  )
146
160
  );
147
161
  // Migration must run BEFORE schema.sql (same ordering as the original file).
148
- migrateUsersTable(db);
149
- migratePatTable(db);
150
- migrateProjectsTable(db);
151
- migrateIssuesTable(db);
162
+ migrateUsersTable(db);
163
+ migratePatTable(db);
164
+ migrateProjectsTable(db);
165
+ migrateIssuesTable(db);
166
+ migrateLabelsTable(db);
152
167
  db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
153
168
  return new SqliteDriver(db);
154
169
  }
package/src/index.ts CHANGED
@@ -61,6 +61,13 @@ import {
61
61
  setProjectModel,
62
62
  listCachedModels,
63
63
  replaceCachedModels,
64
+ listLabels,
65
+ listLabelsForIssue,
66
+ createLabel,
67
+ updateLabel,
68
+ archiveLabel,
69
+ deleteLabel,
70
+ setIssueLabels,
64
71
  type ProjectRole,
65
72
  type UserRow,
66
73
  } from "./store";
@@ -91,6 +98,7 @@ import { buildWebhookDeliveriesPage } from "./views/webhookDeliveries";
91
98
  import { browseRemoteFile, proxyFileSince, RemoteFileError } from "./remote-file";
92
99
  import { buildProjectMembersPage } from "./views/projectMembers";
93
100
  import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
101
+ import { buildProjectLabelsPage } from "./views/projectLabels";
94
102
  import { buildProjectModelPage } from "./views/projectModel";
95
103
  import { handleGiteaApi } from "./giteaApi";
96
104
  import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy";
@@ -331,6 +339,10 @@ const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/
331
339
  const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
332
340
  const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
333
341
  const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
342
+ const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
343
+ const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
344
+ const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
345
+ const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
334
346
  const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
335
347
  const SESSIONS_RE = /^\/sessions$/;
336
348
  const SESSION_VIEW_RE = /^\/sessions\/([A-Za-z0-9_-]+)$/;
@@ -1048,16 +1060,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1048
1060
  if (url.pathname === "/api/router/strategy") {
1049
1061
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1050
1062
  const routerUrl = cfg.daemonWebhookUrl.replace(/\/$/, "");
1063
+ const routerHeaders: Record<string, string> = { "Content-Type": "application/json" };
1064
+ if (cfg.routerAdminToken) routerHeaders["Authorization"] = `Bearer ${cfg.routerAdminToken}`;
1051
1065
  try {
1052
1066
  if (req.method === "GET") {
1053
- const res = await fetch(`${routerUrl}/api/strategy`, { signal: AbortSignal.timeout(5000) });
1067
+ const res = await fetch(`${routerUrl}/api/strategy`, { headers: routerHeaders, signal: AbortSignal.timeout(5000) });
1054
1068
  return json(await res.json());
1055
1069
  }
1056
1070
  if (req.method === "POST") {
1057
1071
  const body = await req.text();
1058
1072
  const res = await fetch(`${routerUrl}/api/strategy`, {
1059
1073
  method: "POST",
1060
- headers: { "Content-Type": "application/json" },
1074
+ headers: routerHeaders,
1061
1075
  body,
1062
1076
  signal: AbortSignal.timeout(5000),
1063
1077
  });
@@ -1372,6 +1386,38 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1372
1386
  }
1373
1387
  }
1374
1388
 
1389
+ const issueLabelsApi = url.pathname.match(API_ISSUE_LABELS_RE);
1390
+ if (issueLabelsApi) {
1391
+ const [, owner, repo, numStr] = issueLabelsApi;
1392
+ if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
1393
+ const project = await getProject(owner, repo);
1394
+ if (!project) return json({ error: "project not found" }, 404);
1395
+ const issue = await getIssueWithMeta(project.id, Number(numStr));
1396
+ if (!issue) return json({ error: "issue not found" }, 404);
1397
+ if (req.method === "GET") {
1398
+ const [current, available] = await Promise.all([
1399
+ listLabelsForIssue(issue.id),
1400
+ listLabels(project.id),
1401
+ ]);
1402
+ return json({ current, available });
1403
+ }
1404
+ if (req.method === "POST") {
1405
+ if (!ctx.user || !(await canWriteProject(project.id, ctx.user))) {
1406
+ return json({ error: "forbidden: needs writer role on project" }, 403);
1407
+ }
1408
+ const body = await req.json().catch(() => ({}));
1409
+ const labelIds = Array.isArray(body.labelIds) ? body.labelIds.map((n: unknown) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0) : [];
1410
+ try {
1411
+ await setIssueLabels(issue.id, labelIds);
1412
+ const current = await listLabelsForIssue(issue.id);
1413
+ return json({ current });
1414
+ } catch (e) {
1415
+ return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
1416
+ }
1417
+ }
1418
+ return json({ error: "method not allowed" }, 405);
1419
+ }
1420
+
1375
1421
  if (req.method === "POST") {
1376
1422
  if (!cfg.writesEnabled) return json({ error: "writes disabled" }, 403);
1377
1423
  const up = url.pathname.match(UPLOAD_RE);
@@ -1663,6 +1709,61 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1663
1709
  }
1664
1710
  }
1665
1711
 
1712
+ const labelAdd = url.pathname.match(REPO_LABEL_ADD_RE);
1713
+ if (labelAdd) {
1714
+ const [, owner, repo] = labelAdd;
1715
+ if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1716
+ const project = await getProject(owner, repo);
1717
+ if (!project) return html(errorPage("项目不存在", ""), 404);
1718
+ const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
1719
+ if (!(await canAdminProject(project.id, ctx.user))) {
1720
+ return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1721
+ }
1722
+ const form = await req.formData().catch(() => new FormData());
1723
+ try {
1724
+ await createLabel(project.id, {
1725
+ name: String(form.get("name") ?? ""),
1726
+ color: String(form.get("color") ?? "#888888"),
1727
+ description: String(form.get("description") ?? ""),
1728
+ exclusive: form.get("exclusive") === "1",
1729
+ });
1730
+ return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("标签已创建")}`, 303);
1731
+ } catch (e) {
1732
+ return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
1733
+ }
1734
+ }
1735
+
1736
+ const labelAction = url.pathname.match(REPO_LABEL_ACTION_RE);
1737
+ if (labelAction) {
1738
+ const [, owner, repo, lidStr, action] = labelAction;
1739
+ if (!(owner && repo && lidStr && action)) return html(errorPage("bad path", ""), 400);
1740
+ const project = await getProject(owner, repo);
1741
+ if (!project) return html(errorPage("项目不存在", ""), 404);
1742
+ const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
1743
+ if (!(await canAdminProject(project.id, ctx.user))) {
1744
+ return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1745
+ }
1746
+ const lid = Number(lidStr);
1747
+ const form = await req.formData().catch(() => new FormData());
1748
+ try {
1749
+ if (action === "update") {
1750
+ await updateLabel(project.id, lid, {
1751
+ name: String(form.get("name") ?? ""),
1752
+ color: String(form.get("color") ?? "#888888"),
1753
+ description: String(form.get("description") ?? ""),
1754
+ exclusive: form.get("exclusive") === "1",
1755
+ });
1756
+ } else if (action === "archive" || action === "unarchive") {
1757
+ await archiveLabel(project.id, lid, action === "archive");
1758
+ } else if (action === "delete") {
1759
+ await deleteLabel(project.id, lid);
1760
+ }
1761
+ return Response.redirect(`${back}?ok=1`, 303);
1762
+ } catch (e) {
1763
+ return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
1764
+ }
1765
+ }
1766
+
1666
1767
  return json({ error: "not found" }, 404);
1667
1768
  }
1668
1769
 
@@ -1779,6 +1880,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1779
1880
  return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
1780
1881
  }
1781
1882
 
1883
+ const labelsPage = url.pathname.match(REPO_LABELS_RE);
1884
+ if (labelsPage) {
1885
+ const [, owner, repo] = labelsPage;
1886
+ if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1887
+ const project = await getProject(owner, repo);
1888
+ if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
1889
+ if (ctx.user!.is_admin === 1) await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1890
+ if (!(await canAdminProject(project.id, ctx.user))) {
1891
+ return html(errorPage("无权限", "需要该项目 admin 角色才能管理标签"), 403);
1892
+ }
1893
+ const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
1894
+ const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
1895
+ const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
1896
+ return html(await buildProjectLabelsPage(ctx.user!, project, flash));
1897
+ }
1898
+
1782
1899
  const modelPage = url.pathname.match(REPO_MODEL_RE);
1783
1900
  if (modelPage) {
1784
1901
  const [, owner, repo] = modelPage;
@@ -14,6 +14,8 @@ export interface LayoutProps {
14
14
  upstreamWebUrl?: string | null;
15
15
  translateEnabled?: boolean;
16
16
  ttsEnabled?: boolean;
17
+ labels?: { id: number; name: string; color: string }[];
18
+ canEditLabels?: boolean;
17
19
  }
18
20
 
19
21
  export const THEME_CSS = `
@@ -107,6 +109,20 @@ header.topbar .num{opacity:.7}
107
109
  .desc-toggle{margin-top:.4rem;background:none;border:none;color:var(--accent);font-size:13px;cursor:pointer;padding:.2rem 0}
108
110
  .upstream-link{font-size:12px;color:var(--accent);opacity:.85}
109
111
  .upstream-link:hover{opacity:1;text-decoration:underline}
112
+ .issue-label{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:.05rem .5rem;border:1px solid;border-radius:99px;line-height:1.6;background:color-mix(in srgb,currentColor 8%,transparent)}
113
+ .label-edit-btn{background:none;border:1px solid var(--border);border-radius:6px;padding:.05rem .35rem;cursor:pointer;font-size:13px;line-height:1.5;color:var(--text-muted)}
114
+ .label-edit-btn:hover{border-color:var(--accent);color:var(--accent)}
115
+ #labelDlg{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.1rem;max-width:380px;width:90vw}
116
+ #labelDlg::backdrop{background:rgba(0,0,0,.5)}
117
+ #labelDlg h3{margin:0 0 .6rem;font-size:14px}
118
+ .lp-list{max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:.15rem}
119
+ .lp-item{display:flex;align-items:center;gap:.4rem;padding:.3rem .4rem;border-radius:6px;cursor:pointer;font-size:13px}
120
+ .lp-item:hover{background:var(--bg-muted)}
121
+ .lp-item input{margin:0}
122
+ .lp-dot{width:10px;height:10px;border-radius:50%;border:1px solid var(--border);flex-shrink:0}
123
+ .lp-name{flex:1;overflow-wrap:anywhere}
124
+ .lp-scope{font-size:10px;color:var(--text-muted)}
125
+ .lp-empty{color:var(--text-muted);font-size:12px;padding:.6rem 0;text-align:center}
110
126
  `;
111
127
 
112
128
  export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
@@ -119,6 +135,12 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
119
135
  const [repoOwner, repoName] = props.repoPath.split("/");
120
136
  const repoIssuesHref = `/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues`;
121
137
  const op = props.operatorLogin ?? "operator";
138
+ const labelsHtml = (props.labels ?? []).length
139
+ ? props.labels!.map((l) => `<span class="issue-label" style="border-color:${escapeAttr(l.color)};color:${escapeAttr(l.color)}">${escapeHtml(l.name)}</span>`).join("")
140
+ : "";
141
+ const labelPickerBtn = props.canEditLabels
142
+ ? `<button type="button" class="label-edit-btn" id="labelEditBtn" title="管理标签">🏷️</button>`
143
+ : "";
122
144
  return `<!doctype html>
123
145
  <html lang="zh">
124
146
  <head>
@@ -140,6 +162,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
140
162
  <h1>${escapeHtml(props.issueTitle)}</h1>
141
163
  <div class="meta-status">
142
164
  <span class="state-badge ${stateClass}">${stateLabel}</span>
165
+ ${labelsHtml}${labelPickerBtn}
143
166
  <span class="count" id="count">…</span>
144
167
  ${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
145
168
  </div>
@@ -168,6 +191,8 @@ ${props.writesEnabled !== false
168
191
  <script type="application/json" id="initial-data">${inner}</script>
169
192
  <script src="/static/tts.js?v=${BUILD_ID}" defer></script>
170
193
  <script src="/static/app.js?v=${BUILD_ID}" defer></script>
194
+ ${props.canEditLabels ? `<dialog id="labelDlg"><h3>标签</h3><div class="lp-list" id="lpList"></div><div class="lp-empty hidden" id="lpEmpty">该项目还没有标签。先到设置页创建。</div></dialog>
195
+ <script src="/static/label-picker.js?v=${BUILD_ID}" defer></script>` : ""}
171
196
  </body>
172
197
  </html>`;
173
198
  }
@@ -74,10 +74,13 @@ CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
74
74
  CREATE INDEX comments_author ON {{comments}} (author);
75
75
 
76
76
  CREATE TABLE IF NOT EXISTS {{labels}} (
77
- id BIGINT AUTO_INCREMENT PRIMARY KEY,
78
- project_id BIGINT NOT NULL,
79
- name VARCHAR(255) NOT NULL,
80
- color VARCHAR(16) NOT NULL DEFAULT '#888888',
77
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
78
+ project_id BIGINT NOT NULL,
79
+ name VARCHAR(255) NOT NULL,
80
+ color VARCHAR(16) NOT NULL DEFAULT '#888888',
81
+ description VARCHAR(255) NOT NULL DEFAULT '',
82
+ exclusive TINYINT NOT NULL DEFAULT 0,
83
+ is_archived TINYINT NOT NULL DEFAULT 0,
81
84
  UNIQUE (project_id, name),
82
85
  CONSTRAINT {{fk_labels_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE
83
86
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
package/src/schema.sql CHANGED
@@ -78,10 +78,13 @@ CREATE INDEX IF NOT EXISTS comments_issue_created
78
78
  ON {{comments}} (issue_id, created_at);
79
79
 
80
80
  CREATE TABLE IF NOT EXISTS {{labels}} (
81
- id INTEGER PRIMARY KEY AUTOINCREMENT,
82
- project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
83
- name TEXT NOT NULL,
84
- color TEXT NOT NULL DEFAULT '#888888',
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
83
+ name TEXT NOT NULL,
84
+ color TEXT NOT NULL DEFAULT '#888888',
85
+ description TEXT NOT NULL DEFAULT '',
86
+ exclusive INTEGER NOT NULL DEFAULT 0,
87
+ is_archived INTEGER NOT NULL DEFAULT 0,
85
88
  UNIQUE (project_id, name)
86
89
  );
87
90
 
@@ -6,7 +6,7 @@
6
6
  const saveBtn = document.getElementById("strategy-save");
7
7
  const addBindingBtn = document.getElementById("binding-add");
8
8
 
9
- let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {} };
9
+ let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {}, groupConfigs: {} };
10
10
 
11
11
  function showResult(msg, ok) {
12
12
  result.textContent = msg;
@@ -14,6 +14,47 @@
14
14
  setTimeout(() => { result.textContent = ""; result.className = "db-result"; }, 3000);
15
15
  }
16
16
 
17
+ function esc(s) {
18
+ return String(s).replace(/[&<>"']/g, function (c) {
19
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
20
+ });
21
+ }
22
+
23
+ function collectGroupNames() {
24
+ var names = new Set();
25
+ var dg = currentStrategy.daemonGroups || {};
26
+ Object.values(dg).forEach(function (arr) { (arr || []).forEach(function (g) { names.add(g); }); });
27
+ var gb = currentStrategy.groupBindings || {};
28
+ Object.values(gb).forEach(function (g) { names.add(g); });
29
+ Object.keys(currentStrategy.groupConfigs || {}).forEach(function (g) { names.add(g); });
30
+ return Array.from(names).sort();
31
+ }
32
+
33
+ function renderGroupConfigs() {
34
+ var container = document.getElementById("group-configs-list");
35
+ if (!container) return;
36
+ var names = collectGroupNames();
37
+ if (!names.length) {
38
+ container.innerHTML = '<p class="hint" style="margin:0">先添加分组(上方给 daemon 打组或绑定 repo→组)后这里会出现配置项。</p>';
39
+ return;
40
+ }
41
+ container.innerHTML = names.map(function (g) {
42
+ var cfg = (currentStrategy.groupConfigs || {})[g] || {};
43
+ return '<details class="gc-card" style="border:1px solid var(--border);border-radius:8px;padding:.6rem .8rem;margin:.4rem 0">' +
44
+ '<summary style="cursor:pointer;font-weight:600;font-size:13px">' + esc(g) + '</summary>' +
45
+ '<div style="margin-top:.5rem">' +
46
+ '<label style="font-size:12px;color:var(--text-muted)">工作目录模板(变量: {owner} {repo} {issue} {session})</label>' +
47
+ '<input type="text" class="gc-workdir" data-group="' + esc(g) + '" value="' + esc(cfg.workdirTemplate || "") + '" placeholder="/data/work/{owner}/{repo}/{issue}" maxlength="512" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;margin-bottom:.5rem;font-family:ui-monospace,monospace">' +
48
+ '<label style="font-size:12px;color:var(--text-muted)">Init 脚本(投递时跑,cwd=workdir,env 有 $EWORK_OWNER/$EWORK_REPO/$EWORK_ISSUE/$EWORK_WORKDIR)</label>' +
49
+ '<textarea class="gc-init" data-group="' + esc(g) + '" placeholder="git clone https://gitea.example.com/$EWORK_OWNER/$EWORK_REPO.git ." rows="2" maxlength="4096" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:12px;margin-bottom:.5rem;font-family:ui-monospace,monospace;resize:vertical">' + esc(cfg.initScript || "") + '</textarea>' +
50
+ '<label style="font-size:12px;color:var(--text-muted)">Destroy 脚本(关闭时跑,cwd=workdir)</label>' +
51
+ '<textarea class="gc-destroy" data-group="' + esc(g) + '" rows="2" maxlength="4096" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:12px;margin-bottom:.5rem;font-family:ui-monospace,monospace;resize:vertical">' + esc(cfg.destroyScript || "") + '</textarea>' +
52
+ '<label style="font-size:12px;color:var(--text-muted)">Env-Init 脚本(机器级,预留)</label>' +
53
+ '<textarea class="gc-envinit" data-group="' + esc(g) + '" rows="1" disabled maxlength="4096" placeholder="预留,暂不执行" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg-muted);color:var(--text-muted);font-size:12px;resize:vertical">' + esc(cfg.envInitScript || "") + '</textarea>' +
54
+ '</div></details>';
55
+ }).join("");
56
+ }
57
+
17
58
  async function load() {
18
59
  try {
19
60
  const [daemonsRes, strategyRes] = await Promise.all([
@@ -35,6 +76,7 @@
35
76
  }).join("") || '<tr><td colspan="4" class="daemon-empty">没有已注册的 daemon(节点启动后自动出现)</td></tr>';
36
77
 
37
78
  renderBindings();
79
+ renderGroupConfigs();
38
80
  } catch (e) {
39
81
  tbody.innerHTML = '<tr><td colspan="4" class="daemon-empty">无法连接 router — 确认 router 已启动</td></tr>';
40
82
  }
@@ -67,6 +109,7 @@
67
109
  document.getElementById("binding-repo").value = "";
68
110
  document.getElementById("binding-group").value = "";
69
111
  renderBindings();
112
+ renderGroupConfigs();
70
113
  };
71
114
 
72
115
  saveBtn.onclick = async () => {
@@ -79,6 +122,24 @@
79
122
  });
80
123
  currentStrategy.daemonGroups = newGroups;
81
124
 
125
+ var newConfigs = {};
126
+ document.querySelectorAll("details.gc-card").forEach(function (card) {
127
+ var g = card.querySelector("summary").textContent.trim();
128
+ var workdir = (card.querySelector(".gc-workdir") || {}).value || "";
129
+ var init = (card.querySelector(".gc-init") || {}).value || "";
130
+ var destroy = (card.querySelector(".gc-destroy") || {}).value || "";
131
+ var envinit = (card.querySelector(".gc-envinit") || {}).value || "";
132
+ if (workdir.trim() || init.trim() || destroy.trim() || envinit.trim()) {
133
+ newConfigs[g] = {
134
+ workdirTemplate: workdir.trim() || undefined,
135
+ initScript: init.trim() || undefined,
136
+ destroyScript: destroy.trim() || undefined,
137
+ envInitScript: envinit.trim() || undefined,
138
+ };
139
+ }
140
+ });
141
+ currentStrategy.groupConfigs = newConfigs;
142
+
82
143
  try {
83
144
  const res = await fetch("/api/router/strategy", {
84
145
  method: "POST",
@@ -0,0 +1,106 @@
1
+ (function () {
2
+ var btn = document.getElementById("labelEditBtn");
3
+ if (!btn) return;
4
+ var dlg = document.getElementById("labelDlg");
5
+ var listEl = document.getElementById("lpList");
6
+ var emptyEl = document.getElementById("lpEmpty");
7
+ if (!dlg || !listEl) return;
8
+
9
+ var path = window.location.pathname.replace(/\/+$/, "");
10
+ var m = path.match(/^\/(.+)\/(.+)\/issues\/(\d+)$/);
11
+ if (!m) return;
12
+ var apiBase = "/api/" + encodeURIComponent(m[1]) + "/" + encodeURIComponent(m[2]) + "/issues/" + m[3] + "/labels";
13
+
14
+ function scopeOf(name) {
15
+ var i = name.indexOf("/");
16
+ return i > 0 ? name.slice(0, i) : "";
17
+ }
18
+
19
+ var state = { available: [], current: [] };
20
+
21
+ function render() {
22
+ if (!state.available.length) {
23
+ listEl.classList.add("hidden");
24
+ emptyEl.classList.remove("hidden");
25
+ return;
26
+ }
27
+ listEl.classList.remove("hidden");
28
+ emptyEl.classList.add("hidden");
29
+ var currentIds = new Set(state.current.map(function (l) { return l.id; }));
30
+ listEl.innerHTML = state.available.map(function (l) {
31
+ var checked = currentIds.has(l.id);
32
+ var sc = scopeOf(l.name);
33
+ var scopeHint = l.exclusive === 1 && sc ? '<span class="lp-scope">互斥 · ' + esc(sc) + "</span>" : "";
34
+ return '<label class="lp-item">' +
35
+ '<input type="checkbox" data-id="' + l.id + '"' + (l.exclusive === 1 ? ' data-exclusive="1"' : "") +
36
+ (sc ? ' data-scope="' + esc(sc) + '"' : "") +
37
+ (checked ? " checked" : "") + ">" +
38
+ '<span class="lp-dot" style="background:' + esc(l.color) + '"></span>' +
39
+ '<span class="lp-name">' + esc(l.name) + "</span>" + scopeHint +
40
+ "</label>";
41
+ }).join("");
42
+ }
43
+
44
+ function renderChips() {
45
+ var row = document.querySelector(".meta-status");
46
+ if (!row) return;
47
+ var existing = row.querySelectorAll(".issue-label");
48
+ existing.forEach(function (e) { e.remove(); });
49
+ var badge = row.querySelector(".state-badge");
50
+ var html = state.current.map(function (l) {
51
+ return '<span class="issue-label" style="border-color:' + esc(l.color) + ";color:" + esc(l.color) + '">' + esc(l.name) + "</span>";
52
+ }).join("");
53
+ badge.insertAdjacentHTML("afterend", html);
54
+ }
55
+
56
+ function esc(s) {
57
+ return String(s).replace(/[&<>"']/g, function (c) {
58
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
59
+ });
60
+ }
61
+
62
+ async function load() {
63
+ try {
64
+ var res = await fetch(apiBase);
65
+ if (!res.ok) return;
66
+ var data = await res.json();
67
+ state.available = data.available || [];
68
+ state.current = data.current || [];
69
+ render();
70
+ } catch (e) {}
71
+ }
72
+
73
+ async function save(labelIds) {
74
+ try {
75
+ var res = await fetch(apiBase, {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify({ labelIds: labelIds }),
79
+ });
80
+ if (!res.ok) return;
81
+ var data = await res.json();
82
+ state.current = data.current || [];
83
+ renderChips();
84
+ } catch (e) {}
85
+ }
86
+
87
+ btn.addEventListener("click", async function () {
88
+ await load();
89
+ dlg.showModal();
90
+ });
91
+
92
+ listEl.addEventListener("change", function (e) {
93
+ var cb = e.target;
94
+ if (cb.tagName !== "INPUT" || cb.type !== "checkbox") return;
95
+ if (cb.checked && cb.dataset.exclusive === "1" && cb.dataset.scope) {
96
+ var scope = cb.dataset.scope;
97
+ listEl.querySelectorAll('input[data-exclusive="1"]').forEach(function (other) {
98
+ if (other !== cb && other.dataset.scope === scope) other.checked = false;
99
+ });
100
+ }
101
+ var ids = Array.from(listEl.querySelectorAll("input:checked")).map(function (c) {
102
+ return Number(c.dataset.id);
103
+ });
104
+ save(ids);
105
+ });
106
+ })();
package/src/store.ts CHANGED
@@ -79,6 +79,17 @@ export interface LabelRow {
79
79
  project_id: number;
80
80
  name: string;
81
81
  color: string;
82
+ description: string;
83
+ exclusive: number;
84
+ is_archived: number;
85
+ }
86
+
87
+ /** For an exclusive label named "scope/name", the scope is "scope". A label
88
+ * with no "/" has no scope and is never exclusive. Issues may carry at most
89
+ * one label per scope (Gitea semantics). */
90
+ export function labelScope(name: string): string {
91
+ const i = name.indexOf("/");
92
+ return i > 0 ? name.slice(0, i) : "";
82
93
  }
83
94
 
84
95
  export interface AttachmentRow {
@@ -595,8 +606,12 @@ export async function removeReaction(commentId: number, userLogin: string, conte
595
606
  );
596
607
  }
597
608
 
598
- export async function listLabels(projectId: number): Promise<LabelRow[]> {
599
- return await getDB().all<LabelRow>("SELECT * FROM {{labels}} WHERE project_id = ? ORDER BY name", [projectId]);
609
+ const LABEL_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
610
+ const LABEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 _\-./]{0,62}$/;
611
+
612
+ export async function listLabels(projectId: number, includeArchived = false): Promise<LabelRow[]> {
613
+ const where = includeArchived ? "project_id = ?" : "project_id = ? AND is_archived = 0";
614
+ return await getDB().all<LabelRow>(`SELECT * FROM {{labels}} WHERE ${where} ORDER BY name`, [projectId]);
600
615
  }
601
616
 
602
617
  export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
@@ -608,19 +623,98 @@ export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
608
623
  );
609
624
  }
610
625
 
611
- export async function createLabel(projectId: number, name: string, color: string): Promise<LabelRow> {
612
- name = name.trim();
626
+ export async function getLabel(projectId: number, id: number): Promise<LabelRow | null> {
627
+ return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id])) ?? null;
628
+ }
629
+
630
+ interface LabelInput {
631
+ name: string;
632
+ color: string;
633
+ description?: string;
634
+ exclusive?: boolean;
635
+ }
636
+
637
+ function validateLabel(t: LabelInput): { name: string; color: string; description: string; exclusive: number } {
638
+ const name = t.name.trim();
613
639
  if (!name) throw new StoreError(400, "标签名不能为空");
614
- if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new StoreError(400, "颜色须为 #RRGGBB");
615
- const info = await getDB().run("INSERT INTO {{labels}} (project_id, name, color) VALUES (?, ?, ?)", [projectId, name, color]);
640
+ if (!LABEL_NAME_RE.test(name)) throw new StoreError(400, "标签名仅允许字母数字、空格、_ - . /,且不能以 / 开头");
641
+ if (!LABEL_COLOR_RE.test(t.color)) throw new StoreError(400, "颜色须为 #RRGGBB");
642
+ return { name, color: t.color, description: (t.description ?? "").trim(), exclusive: t.exclusive ? 1 : 0 };
643
+ }
644
+
645
+ export async function createLabel(projectId: number, input: LabelInput): Promise<LabelRow> {
646
+ const v = validateLabel(input);
647
+ const info = await getDB().run(
648
+ "INSERT INTO {{labels}} (project_id, name, color, description, exclusive) VALUES (?, ?, ?, ?, ?)",
649
+ [projectId, v.name, v.color, v.description, v.exclusive]
650
+ );
616
651
  return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [info.insertId]))!;
617
652
  }
618
653
 
654
+ export async function updateLabel(projectId: number, id: number, input: Partial<LabelInput>): Promise<LabelRow> {
655
+ const existing = await getLabel(projectId, id);
656
+ if (!existing) throw new StoreError(404, "标签不存在");
657
+ const merged: LabelInput = {
658
+ name: input.name ?? existing.name,
659
+ color: input.color ?? existing.color,
660
+ description: input.description ?? existing.description,
661
+ exclusive: input.exclusive ?? (existing.exclusive === 1),
662
+ };
663
+ const v = validateLabel(merged);
664
+ await getDB().run(
665
+ "UPDATE {{labels}} SET name = ?, color = ?, description = ?, exclusive = ? WHERE project_id = ? AND id = ?",
666
+ [v.name, v.color, v.description, v.exclusive, projectId, id]
667
+ );
668
+ return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [id]))!;
669
+ }
670
+
671
+ export async function archiveLabel(projectId: number, id: number, archived: boolean): Promise<LabelRow> {
672
+ const existing = await getLabel(projectId, id);
673
+ if (!existing) throw new StoreError(404, "标签不存在");
674
+ await getDB().run("UPDATE {{labels}} SET is_archived = ? WHERE project_id = ? AND id = ?", [archived ? 1 : 0, projectId, id]);
675
+ return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [id]))!;
676
+ }
677
+
678
+ export async function deleteLabel(projectId: number, id: number): Promise<void> {
679
+ await getDB().run("DELETE FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id]);
680
+ }
681
+
682
+ /** Attach (`on=true`) or detach a label. When attaching an exclusive label
683
+ * (scope/name with exclusive=1), any other already-attached label sharing the
684
+ * same scope is removed first, so an issue holds at most one label per scope. */
619
685
  export async function setIssueLabel(issueId: number, labelId: number, on: boolean): Promise<void> {
620
- if (on) {
621
- await getDB().run("INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)", [issueId, labelId]);
622
- } else {
686
+ if (!on) {
623
687
  await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, labelId]);
688
+ return;
689
+ }
690
+ const label = await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [labelId]);
691
+ if (!label) throw new StoreError(404, "标签不存在");
692
+ if (label.exclusive === 1) {
693
+ const scope = labelScope(label.name);
694
+ if (scope) {
695
+ const current = await listLabelsForIssue(issueId);
696
+ const siblings = current.filter((l) => l.exclusive === 1 && labelScope(l.name) === scope && l.id !== labelId);
697
+ for (const s of siblings) {
698
+ await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, s.id]);
699
+ }
700
+ }
701
+ }
702
+ const dialect = getDB().dialect;
703
+ const insert = dialect === "sqlite"
704
+ ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
705
+ : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
706
+ await getDB().run(insert, [issueId, labelId]);
707
+ }
708
+
709
+ export async function setIssueLabels(issueId: number, labelIds: number[]): Promise<void> {
710
+ const dialect = getDB().dialect;
711
+ await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ?", [issueId]);
712
+ const ids = Array.from(new Set(labelIds));
713
+ const insert = dialect === "sqlite"
714
+ ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
715
+ : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
716
+ for (const id of ids) {
717
+ await getDB().run(insert, [issueId, id]);
624
718
  }
625
719
  }
626
720
 
@@ -11,6 +11,7 @@ import {
11
11
  listCommentsPage,
12
12
  listCommentsSince,
13
13
  getDefaultUpstreamUrl,
14
+ listLabelsForIssue,
14
15
  type CommentRow,
15
16
  type IssueWithMeta,
16
17
  } from "../store";
@@ -110,6 +111,7 @@ export async function buildIssueThread(
110
111
  if (!clone) return null;
111
112
  return webUrlFromClone(clone);
112
113
  })();
114
+ const labels = await listLabelsForIssue(issue.id);
113
115
 
114
116
  const html = renderLayout(
115
117
  {
@@ -126,6 +128,8 @@ export async function buildIssueThread(
126
128
  upstreamWebUrl,
127
129
  translateEnabled: !!cfg.translateUrl && cfg.translateUrl.trim() !== "",
128
130
  ttsEnabled: cfg.ttsBackends.some((b) => b.url && b.url.trim() !== ""),
131
+ labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
132
+ canEditLabels: cfg.writesEnabled !== false,
129
133
  },
130
134
  safeJsonEmbed(payload),
131
135
  displayViews.map((v) => renderCommentCard(v, cfg)).join("")
@@ -0,0 +1,190 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
2
+ import { projectSettingsTabsHTML } from "./projectUpstreams";
3
+ import { listLabels, type ProjectRow, type UserRow, type LabelRow } from "../store";
4
+
5
+ interface Flash {
6
+ kind: "ok" | "err";
7
+ msg: string;
8
+ }
9
+
10
+ const PALETTE = [
11
+ "#888888", "#e11d48", "#db2777", "#9333ea", "#4f46e5",
12
+ "#2563eb", "#0891b2", "#0d9488", "#16a34a", "#ca8a04",
13
+ "#d97706", "#92400e",
14
+ ];
15
+
16
+ function colorDot(c: string): string {
17
+ return `<span class="dot" style="background:${escapeAttr(c)}"></span>`;
18
+ }
19
+
20
+ function labelRowHtml(l: LabelRow, owner: string, repo: string): string {
21
+ const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels/${l.id}`;
22
+ const exclusiveBadge = l.exclusive === 1 ? `<span class="tag" title="互斥标签(scope/name)">互斥</span>` : "";
23
+ const archivedBadge = l.is_archived === 1 ? `<span class="tag arch">已归档</span>` : "";
24
+ return `<tr>
25
+ <td class="nm">${colorDot(l.color)}${escapeHtml(l.name)} ${exclusiveBadge}${archivedBadge}</td>
26
+ <td class="desc">${escapeHtml(l.description || "—")}</td>
27
+ <td class="act">
28
+ <form method="POST" action="${escapeAttr(base)}/update" class="inline" data-edit="${l.id}">
29
+ <input type="hidden" name="name" value="${escapeAttr(l.name)}">
30
+ <input type="hidden" name="color" value="${escapeAttr(l.color)}">
31
+ <input type="hidden" name="description" value="${escapeAttr(l.description)}">
32
+ <input type="hidden" name="exclusive" value="${l.exclusive}">
33
+ <button type="button" class="lnk" data-edit-btn="${l.id}">编辑</button>
34
+ </form>
35
+ <form method="POST" action="${escapeAttr(base)}/${l.is_archived === 1 ? "unarchive" : "archive"}" class="inline">
36
+ <button type="submit" class="lnk">${l.is_archived === 1 ? "恢复" : "归档"}</button>
37
+ </form>
38
+ <form method="POST" action="${escapeAttr(base)}/delete" class="inline" onsubmit="return confirm('删除标签「${escapeAttr(l.name)}」?会从所有 issue 上移除。')">
39
+ <button type="submit" class="lnk danger">删除</button>
40
+ </form>
41
+ </td>
42
+ </tr>`;
43
+ }
44
+
45
+ export async function buildProjectLabelsPage(
46
+ _viewer: UserRow,
47
+ project: ProjectRow,
48
+ flash: Flash | null,
49
+ ): Promise<string> {
50
+ const labels = await listLabels(project.id, true);
51
+ const rowsHtml = labels.length
52
+ ? `<table>
53
+ <thead><tr><th>标签</th><th>描述</th><th>操作</th></tr></thead>
54
+ <tbody>${labels.map((l) => labelRowHtml(l, project.owner, project.name)).join("")}</tbody>
55
+ </table>`
56
+ : `<div class="hint">该项目还没有标签。在下方创建。</div>`;
57
+
58
+ const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
59
+ const createAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/labels/add`;
60
+ const paletteHtml = PALETTE.map((c) => `<button type="button" class="swatch" data-color="${c}" style="background:${c}" aria-label="${c}"></button>`).join("");
61
+ const labelsJson = JSON.stringify(labels.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description, exclusive: l.exclusive, is_archived: l.is_archived })));
62
+
63
+ return `<!doctype html>
64
+ <html lang="zh"><head><meta charset="utf-8">
65
+ <meta name="viewport" content="width=device-width,initial-scale=1">
66
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
67
+ <title>标签 · ${escapeHtml(project.owner)}/${escapeHtml(project.name)}</title>
68
+ <style>${THEME_CSS}
69
+ .flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
70
+ .flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
71
+ .flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
72
+ .wrap{max-width:920px;margin:0 auto;padding:1rem}
73
+ h1{font-size:18px;margin:0 0 .4rem}
74
+ h1 a{color:var(--text)}
75
+ .subtabs{display:flex;gap:.4rem;padding:.4rem 0 0;border-bottom:1px solid var(--border);margin-bottom:.9rem}
76
+ .subtab{padding:.35rem .8rem;border-radius:6px 6px 0 0;font-size:13px;color:var(--text-muted)}
77
+ .subtab.active{background:var(--bg-muted);color:var(--text);font-weight:600}
78
+ .card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
79
+ .card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
80
+ table{width:100%;border-collapse:collapse;font-size:13px}
81
+ th,td{text-align:left;padding:.55rem .45rem;border-bottom:1px solid var(--border);vertical-align:middle}
82
+ th{color:var(--text-muted);font-weight:600;font-size:12px}
83
+ td.nm{font-weight:500}
84
+ td.desc{color:var(--text-muted);font-size:12px}
85
+ td.act{white-space:nowrap;text-align:right}
86
+ .dot{display:inline-block;width:12px;height:12px;border-radius:50%;vertical-align:middle;margin-right:.4rem;border:1px solid var(--border)}
87
+ .chip{display:inline-block;padding:.1rem .5rem;border:1px solid;border-radius:99px;font-size:12px;font-weight:500}
88
+ .tag{display:inline-block;margin-left:.3rem;padding:.05rem .35rem;border-radius:4px;font-size:10px;font-weight:600;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent)}
89
+ .tag.arch{background:color-mix(in srgb,var(--text-muted) 16%,transparent);color:var(--text-muted)}
90
+ .hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin:.4rem 0}
91
+ label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
92
+ input[type=text],input[type=color]{width:100%;box-sizing:border-box;padding:.5rem .65rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font:inherit;font-size:13px;margin-bottom:.7rem}
93
+ input[type=color]{height:38px;padding:3px;cursor:pointer}
94
+ .row{display:flex;gap:.7rem;align-items:flex-start}
95
+ .row>div{flex:1}
96
+ .swatches{display:flex;flex-wrap:wrap;gap:.3rem;margin:.2rem 0 .7rem}
97
+ .swatch{width:22px;height:22px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0}
98
+ .swatch.sel{border-color:var(--text);box-shadow:0 0 0 2px var(--bg)}
99
+ .check{display:flex;align-items:center;gap:.4rem;font-size:13px;color:var(--text);margin-bottom:.7rem;cursor:pointer}
100
+ .check input{margin:0}
101
+ button.primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
102
+ .lnk{background:none;border:0;color:var(--accent);font-size:12px;cursor:pointer;padding:.15rem .25rem;text-decoration:underline}
103
+ .lnk.danger{color:#f85149}
104
+ form.inline{display:inline}
105
+ dialog{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.2rem;max-width:420px}
106
+ dialog::backdrop{background:rgba(0,0,0,.5)}
107
+ dialog h3{margin:0 0 .7rem;font-size:15px}
108
+ dialog .swatches{margin:.4rem 0}
109
+ dialog .row2{display:flex;gap:.6rem}
110
+ dialog .row2>div{flex:1}
111
+ </style></head><body>
112
+ <header class="topbar"><span style="font-weight:600">🏷️ ${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 标签</span></header>
113
+ <main class="wrap">
114
+ <h1><a href="/${escapeAttr(project.owner)}/${escapeAttr(project.name)}/issues">${escapeHtml(project.owner)}/${escapeHtml(project.name)}</a> · 标签</h1>
115
+ ${projectSettingsTabsHTML(project.owner, project.name, "labels")}
116
+ ${flashHtml}
117
+
118
+ <div class="card">
119
+ <h2>已有标签(${labels.length})</h2>
120
+ ${rowsHtml}
121
+ </div>
122
+
123
+ <form class="card" method="POST" action="${escapeAttr(createAction)}">
124
+ <h2>新建标签</h2>
125
+ <div class="row">
126
+ <div>
127
+ <label for="f-name">名称(支持 <code>scope/name</code> 互斥语法)</label>
128
+ <input id="f-name" name="name" type="text" required maxlength="63" placeholder="如 bug / priority/high">
129
+ </div>
130
+ <div style="flex:0 0 80px">
131
+ <label for="f-color">颜色</label>
132
+ <input id="f-color" name="color" type="color" value="#888888">
133
+ </div>
134
+ </div>
135
+ <div class="swatches" id="palette">${paletteHtml}</div>
136
+ <label for="f-desc">描述(可选)</label>
137
+ <input id="f-desc" name="description" type="text" maxlength="255" placeholder="简短说明">
138
+ <label class="check"><input type="checkbox" name="exclusive" value="1"> 互斥标签(同 scope 下只能选一个)</label>
139
+ <div class="hint">名称含 <code>/</code> 时,<code>/</code> 前的部分为 scope。同一 scope 下的互斥标签,一个 issue 只能贴一个。</div>
140
+ <button class="primary" type="submit">创建标签</button>
141
+ </form>
142
+ </main>
143
+
144
+ <dialog id="dlg">
145
+ <h3>编辑标签</h3>
146
+ <form method="POST" id="edit-form">
147
+ <div class="row2">
148
+ <div><label>名称</label><input name="name" type="text" required maxlength="63"></div>
149
+ <div style="flex:0 0 80px"><label>颜色</label><input name="color" type="color"></div>
150
+ </div>
151
+ <div class="swatches" id="dlg-palette"></div>
152
+ <label>描述</label><input name="description" type="text" maxlength="255">
153
+ <label class="check"><input type="checkbox" name="exclusive" value="1"> 互斥</label>
154
+ <div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.8rem">
155
+ <button type="button" id="dlg-cancel">取消</button>
156
+ <button type="submit" class="primary">保存</button>
157
+ </div>
158
+ </form>
159
+ </dialog>
160
+ <script>
161
+ const LABELS = ${labelsJson};
162
+ const palette = ${JSON.stringify(PALETTE)};
163
+ function bindSwatches(container, colorInput){
164
+ container.innerHTML = palette.map(c => '<button type="button" class="swatch" data-color="'+c+'" style="background:'+c+'" aria-label="'+c+'"></button>').join('');
165
+ const sync = ()=>{ container.querySelectorAll('.swatch').forEach(b=>b.classList.toggle('sel', b.dataset.color.toLowerCase()===colorInput.value.toLowerCase())); };
166
+ sync();
167
+ container.addEventListener('click', e=>{ const b=e.target.closest('.swatch'); if(!b)return; colorInput.value=b.dataset.color; sync(); });
168
+ colorInput.addEventListener('input', sync);
169
+ }
170
+ bindSwatches(document.getElementById('palette'), document.getElementById('f-color'));
171
+ const dlg=document.getElementById('dlg'), ef=document.getElementById('edit-form'), dp=document.getElementById('dlg-palette');
172
+ let dlgColor;
173
+ document.addEventListener('click', e=>{
174
+ const btn=e.target.closest('[data-edit-btn]');
175
+ if(!btn)return;
176
+ const id=Number(btn.dataset.editBtn);
177
+ const l=LABELS.find(x=>x.id===id); if(!l)return;
178
+ ef.action=btn.form.action;
179
+ ef.querySelector('[name=name]').value=l.name;
180
+ ef.querySelector('[name=color]').value=l.color;
181
+ dlgColor=ef.querySelector('[name=color]');
182
+ ef.querySelector('[name=description]').value=l.description;
183
+ ef.querySelector('[name=exclusive]').checked=l.exclusive===1;
184
+ bindSwatches(dp, dlgColor);
185
+ dlg.showModal();
186
+ });
187
+ document.getElementById('dlg-cancel').addEventListener('click', ()=>dlg.close());
188
+ </script>
189
+ </body></html>`;
190
+ }
@@ -16,7 +16,7 @@ interface Flash {
16
16
  export function projectSettingsTabsHTML(
17
17
  owner: string,
18
18
  name: string,
19
- active: "webhooks" | "members" | "upstreams" | "model",
19
+ active: "webhooks" | "members" | "upstreams" | "model" | "labels",
20
20
  ): string {
21
21
  const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`;
22
22
  const cls = (which: typeof active) => (active === which ? " active" : "");
@@ -24,6 +24,7 @@ export function projectSettingsTabsHTML(
24
24
  <a class="subtab${cls("webhooks")}" href="${escapeAttr(base)}/webhooks">Webhooks</a>
25
25
  <a class="subtab${cls("members")}" href="${escapeAttr(base)}/members">成员</a>
26
26
  <a class="subtab${cls("upstreams")}" href="${escapeAttr(base)}/upstreams">上游</a>
27
+ <a class="subtab${cls("labels")}" href="${escapeAttr(base)}/labels">🏷️ 标签</a>
27
28
  <a class="subtab${cls("model")}" href="${escapeAttr(base)}/model">🤖 模型</a>
28
29
  </nav>`;
29
30
  }
@@ -208,6 +208,11 @@ function buildGroupsSection(): string {
208
208
  <button type="button" id="binding-add" class="secondary">添加绑定</button>
209
209
  </div>
210
210
  </div>
211
+ <div id="group-configs-editor" style="margin-top:.7rem">
212
+ <h2 style="margin-bottom:.5rem">分组工作目录与脚本</h2>
213
+ <p class="hint" style="margin:0 0 .5rem">为每个分组配置工作目录模板和生命周期脚本。模板变量:<code>{owner}</code> <code>{repo}</code> <code>{issue}</code> <code>{session}</code>。脚本在 daemon 上以工作目录为 cwd 执行。</p>
214
+ <div id="group-configs-list"></div>
215
+ </div>
211
216
  <div class="db-controls" style="margin-top:.7rem">
212
217
  <button type="button" id="strategy-save">保存策略</button>
213
218
  </div>
package/src/webhooks.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  getDefaultUpstreamUrl,
27
27
  ensureUser,
28
28
  resolveModel,
29
+ listLabelsForIssue,
29
30
  type IssueRow,
30
31
  type ProjectRow,
31
32
  type CommentRow,
@@ -355,12 +356,18 @@ function buildRepository(project: ProjectRow, origin: string, model?: string): P
355
356
  return repo;
356
357
  }
357
358
 
359
+ async function toPayloadLabels(issueId: number): Promise<PayloadLabel[]> {
360
+ const rows = await listLabelsForIssue(issueId);
361
+ return rows.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description }));
362
+ }
363
+
358
364
  function buildIssue(
359
365
  issue: IssueRow,
360
366
  project: ProjectRow,
361
367
  commentCount: number,
362
368
  origin: string,
363
369
  model?: string,
370
+ labels: PayloadLabel[] = [],
364
371
  ): PayloadIssue {
365
372
  const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
366
373
  const issueUrl = `${repoUrl}/issues/${issue.number}`;
@@ -371,7 +378,7 @@ function buildIssue(
371
378
  number: issue.number,
372
379
  title: issue.title,
373
380
  body: issue.body ?? "",
374
- labels: [],
381
+ labels,
375
382
  milestone: null,
376
383
  assignee: null,
377
384
  assignees: null,
@@ -415,10 +422,11 @@ function buildCommentPayload(
415
422
  commentCount: number,
416
423
  origin: string,
417
424
  model?: string,
425
+ labels: PayloadLabel[] = [],
418
426
  ): CommentEventPayload {
419
427
  return {
420
428
  action: "created",
421
- issue: buildIssue(issue, project, commentCount, origin, model),
429
+ issue: buildIssue(issue, project, commentCount, origin, model, labels),
422
430
  comment: buildComment(issue, comment, project, origin),
423
431
  repository: buildRepository(project, origin, model),
424
432
  sender: buildUser(comment.author, origin),
@@ -432,10 +440,11 @@ function buildIssuePayload(
432
440
  action: IssueAction,
433
441
  origin: string,
434
442
  model?: string,
443
+ labels: PayloadLabel[] = [],
435
444
  ): IssueEventPayload {
436
445
  return {
437
446
  action,
438
- issue: buildIssue(issue, project, commentCount, origin, model),
447
+ issue: buildIssue(issue, project, commentCount, origin, model, labels),
439
448
  repository: buildRepository(project, origin, model),
440
449
  sender: buildUser(issue.author, origin),
441
450
  };
@@ -604,7 +613,8 @@ export async function emitIssueEvent(
604
613
  // comes from the config table via loadConfig() — cheap DB read.
605
614
  const globalDefault = (await loadConfig()).defaultModel;
606
615
  const model = resolveModel(project.model, globalDefault);
607
- const payload = buildIssuePayload(issue, project, commentCount, action, origin, model);
616
+ const labels = await toPayloadLabels(issueId);
617
+ const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
608
618
  const rawBody = JSON.stringify(payload);
609
619
  await fanOut(projectId, "issues", rawBody);
610
620
  } catch (e) {
@@ -633,7 +643,8 @@ export async function emitCommentEvent(
633
643
  const commentCount = await countCommentsSafe(issueId);
634
644
  const globalDefault = (await loadConfig()).defaultModel;
635
645
  const model = resolveModel(project.model, globalDefault);
636
- const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model);
646
+ const labels = await toPayloadLabels(issueId);
647
+ const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
637
648
  const rawBody = JSON.stringify(payload);
638
649
  await fanOut(projectId, "issue_comment", rawBody);
639
650
  } catch (e) {