ework-web 0.10.16 → 0.10.18

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.16",
3
+ "version": "0.10.18",
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-admin.ts CHANGED
@@ -575,6 +575,75 @@ const DAEMON_DDL: Record<string, string> = {
575
575
  ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
576
576
  };
577
577
 
578
+ export interface DdlResult {
579
+ ok: true;
580
+ sql: string;
581
+ database: string;
582
+ webPrefix: string;
583
+ daemonPrefix: string;
584
+ }
585
+
586
+ /** Generate a runnable MySQL script for accounts whose DB user lacks CREATE
587
+ * (the wizard's auto-CREATE-DATABASE path is blocked). Reuses migrate()'s
588
+ * exact DDL so manual + auto paths stay in sync. Idempotent. */
589
+ export function generateMysqlDDL(
590
+ opts: MysqlTargetOpts,
591
+ daemonPrefixRaw: string,
592
+ ): DdlResult {
593
+ const webPrefix = validatePrefix(opts.prefix ?? "");
594
+ const daemonPrefix = validatePrefix(daemonPrefixRaw);
595
+ const lines: string[] = [];
596
+
597
+ lines.push(`-- ework-web + ework-daemon MySQL schema`);
598
+ lines.push(`-- Generated for restricted-privilege accounts (no CREATE).`);
599
+ lines.push(`-- Database: ${opts.database} | web prefix: "${webPrefix}" | daemon prefix: "${daemonPrefix}"`);
600
+ lines.push(`-- Idempotent — safe to re-run.`);
601
+ lines.push(``);
602
+ lines.push(`CREATE DATABASE IF NOT EXISTS ${ident(opts.database)} CHARACTER SET utf8mb4;`);
603
+ lines.push(`USE ${ident(opts.database)};`);
604
+ lines.push(``);
605
+ lines.push(`-- ─── ework-web tables (${webPrefix ? `prefix "${webPrefix}"` : "no prefix"}) ───`);
606
+
607
+ for (const stmt of readSchemaMysqlStatements(webPrefix)) {
608
+ lines.push(stmt + ";");
609
+ }
610
+
611
+ // config table — created in db.ts:SqliteDriver.create at boot, missing from
612
+ // schema-mysql.sql. Same shape as migrateSqliteToMysql L257-266. `key` is a
613
+ // MySQL reserved word → backticked.
614
+ lines.push(
615
+ applyTargetPrefix(
616
+ "CREATE TABLE IF NOT EXISTS {{config}} (" +
617
+ "`key` VARCHAR(255) PRIMARY KEY," +
618
+ "value TEXT NOT NULL," +
619
+ "updated_at VARCHAR(40) NOT NULL" +
620
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
621
+ webPrefix
622
+ )
623
+ );
624
+
625
+ lines.push(``);
626
+ lines.push(`-- ─── ework-daemon tables (${daemonPrefix ? `prefix "${daemonPrefix}"` : "no prefix"}) ───`);
627
+ lines.push(`-- Daemon columns match its SQLite schema; coordination columns`);
628
+ lines.push(`-- (owner_daemon_id etc.) are added by the daemon's ALTER TABLE on boot.`);
629
+ for (const table of DAEMON_TABLES) {
630
+ const ddl = applyTargetPrefix(
631
+ DAEMON_DDL[table]!.replace(/{{t}}/g, `{{${table}}}`),
632
+ daemonPrefix
633
+ );
634
+ lines.push(ddl + ";");
635
+ }
636
+
637
+ lines.push(``);
638
+ return {
639
+ ok: true,
640
+ sql: lines.join("\n"),
641
+ database: opts.database,
642
+ webPrefix,
643
+ daemonPrefix,
644
+ };
645
+ }
646
+
578
647
  export async function migrateDaemonSqliteToMysql(
579
648
  sqlitePath: string,
580
649
  opts: MysqlTargetOpts,
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
@@ -7,7 +7,7 @@ import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./
7
7
  import type { Config } from "./config";
8
8
  import { setConfig, initDB, getDB } from "./db";
9
9
  import { getActiveDaemons, listAllDaemons } from "./coordination";
10
- import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql } from "./db-admin";
10
+ import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
11
11
  import type { MysqlTargetOpts } from "./db-admin";
12
12
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
13
13
  import { OpencodeError, createOpencodeClient, MultiDaemonOpencodeClient, RemoteOpencodeClient, isLocalhost, type OpencodeClientInterface } from "./opencode";
@@ -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";
@@ -201,7 +209,9 @@ function html(body: string, status = 200): Response {
201
209
  // The prefix regex below must match db.ts:WORK_DB_PREFIX exactly — if the API
202
210
  // accepts a prefix that boot rejects, the wizard writes a .env that restarts
203
211
  // into a crash. Any change here must be mirrored in db.ts + db-admin.ts.
204
- function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string } {
212
+ type ParsedDbTarget = MysqlTargetOpts & { daemonPrefix?: string };
213
+
214
+ function parseDbTargetOpts(payload: unknown): ParsedDbTarget | { error: string } {
205
215
  if (typeof payload !== "object" || payload === null) return { error: "invalid body" };
206
216
  const p = payload as Record<string, unknown>;
207
217
  const host = typeof p.host === "string" ? p.host.trim() : "";
@@ -210,17 +220,23 @@ function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string
210
220
  const password = typeof p.password === "string" ? p.password : "";
211
221
  const database = typeof p.database === "string" ? p.database.trim() : "";
212
222
  const prefix = typeof p.prefix === "string" ? p.prefix.trim() : "";
223
+ const daemonPrefix = typeof p.daemonPrefix === "string" ? p.daemonPrefix.trim() : "";
213
224
  if (!host) return { error: "host required" };
214
225
  if (!user) return { error: "user required" };
215
226
  if (!database) return { error: "database required" };
216
227
  if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
217
228
  return { error: "port must be an integer 1-65535" };
218
229
  }
219
- if (prefix && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(prefix)) {
230
+ const prefixRe = /^[A-Za-z_][A-Za-z0-9_]{0,31}$/;
231
+ if (prefix && !prefixRe.test(prefix)) {
220
232
  return { error: "prefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
221
233
  }
222
- const opts: MysqlTargetOpts = { host, port: portNum, user, password, database };
234
+ if (daemonPrefix && !prefixRe.test(daemonPrefix)) {
235
+ return { error: "daemonPrefix must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$" };
236
+ }
237
+ const opts: ParsedDbTarget = { host, port: portNum, user, password, database };
223
238
  if (prefix) opts.prefix = prefix;
239
+ if (daemonPrefix) opts.daemonPrefix = daemonPrefix;
224
240
  return opts;
225
241
  }
226
242
 
@@ -323,6 +339,10 @@ const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/
323
339
  const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
324
340
  const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
325
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$/;
326
346
  const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
327
347
  const SESSIONS_RE = /^\/sessions$/;
328
348
  const SESSION_VIEW_RE = /^\/sessions\/([A-Za-z0-9_-]+)$/;
@@ -805,7 +825,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
805
825
  const rawPayload = await req.json().catch(() => ({}));
806
826
  const parsed = parseDbTargetOpts(rawPayload);
807
827
  if ("error" in parsed) return json(parsed, 400);
808
- const daemonOpts: MysqlTargetOpts = { ...parsed, prefix: daemonPrefix(parsed.prefix ?? "") };
828
+ const { daemonPrefix: _dp, ...mysqlFields } = parsed;
829
+ const dPrefix = parsed.daemonPrefix ?? daemonPrefix(parsed.prefix ?? "");
830
+ const daemonOpts: MysqlTargetOpts = { ...mysqlFields, prefix: dPrefix };
809
831
  const envPath = daemonEnvPath();
810
832
  if (!envPath) {
811
833
  return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
@@ -825,6 +847,21 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
825
847
  }
826
848
  }
827
849
 
850
+ if (req.method === "POST" && url.pathname === "/api/db/ddl") {
851
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
852
+ if (!rateLimit(`db-ddl:${ip}`, 10, 10 / 60)) return json({ error: "rate limited" }, 429);
853
+ const payload = await req.json().catch(() => ({}));
854
+ const parsed = parseDbTargetOpts(payload);
855
+ if ("error" in parsed) return json(parsed, 400);
856
+ const dPrefix = parsed.daemonPrefix ?? daemonPrefix(parsed.prefix ?? "");
857
+ try {
858
+ const result = generateMysqlDDL(parsed, dPrefix);
859
+ return json(result);
860
+ } catch (e) {
861
+ return json({ ok: false, error: errMsg(e) }, 400);
862
+ }
863
+ }
864
+
828
865
  if (req.method === "POST" && url.pathname === "/api/db/revert") {
829
866
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
830
867
  if (!rateLimit(`db-revert:${ip}`, 2, 2 / 3600)) return json({ error: "rate limited" }, 429);
@@ -1023,16 +1060,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1023
1060
  if (url.pathname === "/api/router/strategy") {
1024
1061
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1025
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}`;
1026
1065
  try {
1027
1066
  if (req.method === "GET") {
1028
- 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) });
1029
1068
  return json(await res.json());
1030
1069
  }
1031
1070
  if (req.method === "POST") {
1032
1071
  const body = await req.text();
1033
1072
  const res = await fetch(`${routerUrl}/api/strategy`, {
1034
1073
  method: "POST",
1035
- headers: { "Content-Type": "application/json" },
1074
+ headers: routerHeaders,
1036
1075
  body,
1037
1076
  signal: AbortSignal.timeout(5000),
1038
1077
  });
@@ -1638,6 +1677,61 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1638
1677
  }
1639
1678
  }
1640
1679
 
1680
+ const labelAdd = url.pathname.match(REPO_LABEL_ADD_RE);
1681
+ if (labelAdd) {
1682
+ const [, owner, repo] = labelAdd;
1683
+ if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1684
+ const project = await getProject(owner, repo);
1685
+ if (!project) return html(errorPage("项目不存在", ""), 404);
1686
+ const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
1687
+ if (!(await canAdminProject(project.id, ctx.user))) {
1688
+ return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1689
+ }
1690
+ const form = await req.formData().catch(() => new FormData());
1691
+ try {
1692
+ await createLabel(project.id, {
1693
+ name: String(form.get("name") ?? ""),
1694
+ color: String(form.get("color") ?? "#888888"),
1695
+ description: String(form.get("description") ?? ""),
1696
+ exclusive: form.get("exclusive") === "1",
1697
+ });
1698
+ return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("标签已创建")}`, 303);
1699
+ } catch (e) {
1700
+ return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
1701
+ }
1702
+ }
1703
+
1704
+ const labelAction = url.pathname.match(REPO_LABEL_ACTION_RE);
1705
+ if (labelAction) {
1706
+ const [, owner, repo, lidStr, action] = labelAction;
1707
+ if (!(owner && repo && lidStr && action)) return html(errorPage("bad path", ""), 400);
1708
+ const project = await getProject(owner, repo);
1709
+ if (!project) return html(errorPage("项目不存在", ""), 404);
1710
+ const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
1711
+ if (!(await canAdminProject(project.id, ctx.user))) {
1712
+ return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1713
+ }
1714
+ const lid = Number(lidStr);
1715
+ const form = await req.formData().catch(() => new FormData());
1716
+ try {
1717
+ if (action === "update") {
1718
+ await updateLabel(project.id, lid, {
1719
+ name: String(form.get("name") ?? ""),
1720
+ color: String(form.get("color") ?? "#888888"),
1721
+ description: String(form.get("description") ?? ""),
1722
+ exclusive: form.get("exclusive") === "1",
1723
+ });
1724
+ } else if (action === "archive" || action === "unarchive") {
1725
+ await archiveLabel(project.id, lid, action === "archive");
1726
+ } else if (action === "delete") {
1727
+ await deleteLabel(project.id, lid);
1728
+ }
1729
+ return Response.redirect(`${back}?ok=1`, 303);
1730
+ } catch (e) {
1731
+ return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
1732
+ }
1733
+ }
1734
+
1641
1735
  return json({ error: "not found" }, 404);
1642
1736
  }
1643
1737
 
@@ -1671,6 +1765,38 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1671
1765
  }
1672
1766
  }
1673
1767
 
1768
+ const issueLabelsApi = url.pathname.match(API_ISSUE_LABELS_RE);
1769
+ if (issueLabelsApi) {
1770
+ const [, owner, repo, numStr] = issueLabelsApi;
1771
+ if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
1772
+ const project = await getProject(owner, repo);
1773
+ if (!project) return json({ error: "project not found" }, 404);
1774
+ const issue = await getIssueWithMeta(project.id, Number(numStr));
1775
+ if (!issue) return json({ error: "issue not found" }, 404);
1776
+ if (req.method === "GET") {
1777
+ const [current, available] = await Promise.all([
1778
+ listLabelsForIssue(issue.id),
1779
+ listLabels(project.id),
1780
+ ]);
1781
+ return json({ current, available });
1782
+ }
1783
+ if (req.method === "POST") {
1784
+ if (!ctx.user || !(await canWriteProject(project.id, ctx.user))) {
1785
+ return json({ error: "forbidden: needs writer role on project" }, 403);
1786
+ }
1787
+ const body = await req.json().catch(() => ({}));
1788
+ const labelIds = Array.isArray(body.labelIds) ? body.labelIds.map((n: unknown) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0) : [];
1789
+ try {
1790
+ await setIssueLabels(issue.id, labelIds);
1791
+ const current = await listLabelsForIssue(issue.id);
1792
+ return json({ current });
1793
+ } catch (e) {
1794
+ return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
1795
+ }
1796
+ }
1797
+ return json({ error: "method not allowed" }, 405);
1798
+ }
1799
+
1674
1800
  const isNew = url.pathname.match(REPO_NEW_RE);
1675
1801
  if (isNew) {
1676
1802
  const [, owner, repo] = isNew;
@@ -1754,6 +1880,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1754
1880
  return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
1755
1881
  }
1756
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
+
1757
1899
  const modelPage = url.pathname.match(REPO_MODEL_RE);
1758
1900
  if (modelPage) {
1759
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",
@@ -12,7 +12,8 @@
12
12
  user: get('db-user').trim(),
13
13
  password: get('db-password'),
14
14
  database: get('db-database').trim(),
15
- prefix: get('db-prefix').trim()
15
+ prefix: get('db-prefix').trim(),
16
+ daemonPrefix: get('db-daemon-prefix').trim()
16
17
  });
17
18
  }
18
19
  function setBtns(d) {
@@ -74,6 +75,58 @@
74
75
  document.getElementById('db-daemon').onclick = function () { act('daemon-config'); };
75
76
  document.getElementById('db-enable').onclick = function () { act('enable'); };
76
77
 
78
+ var ddlBtn = document.getElementById('db-ddl');
79
+ if (ddlBtn) {
80
+ ddlBtn.onclick = async function () {
81
+ setBtns(true);
82
+ R.className = 'db-result db-loading';
83
+ R.textContent = '生成建表 SQL…';
84
+ try {
85
+ var res = await fetch('/api/db/ddl', {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: gather()
89
+ });
90
+ var data = await res.json();
91
+ if (data.ok) {
92
+ R.className = 'db-result db-ok';
93
+ R.textContent = '✓ SQL 已生成(' + data.database + ' · web 前缀 "' + (data.webPrefix || '(无)') + '" · daemon 前缀 "' + (data.daemonPrefix || '(无)') + '")。复制下方 SQL,用有 CREATE 权限的账号在 MySQL 里跑一遍,再回来点 ② 迁移。';
94
+ var out = document.getElementById('db-ddl-out');
95
+ var ta = document.getElementById('db-ddl-text');
96
+ var meta = document.getElementById('db-ddl-meta');
97
+ ta.value = data.sql;
98
+ meta.textContent = data.sql.length + ' 字符';
99
+ out.style.display = '';
100
+ } else {
101
+ R.className = 'db-result db-err';
102
+ R.textContent = '✗ ' + (data.error || '未知错误');
103
+ }
104
+ } catch (e) {
105
+ R.className = 'db-result db-err';
106
+ R.textContent = '✗ ' + (e.message || String(e));
107
+ } finally {
108
+ setBtns(false);
109
+ }
110
+ };
111
+ }
112
+
113
+ var ddlCopy = document.getElementById('db-ddl-copy');
114
+ if (ddlCopy) {
115
+ ddlCopy.onclick = async function () {
116
+ var ta = document.getElementById('db-ddl-text');
117
+ try {
118
+ await navigator.clipboard.writeText(ta.value);
119
+ ddlCopy.textContent = '✓ 已复制';
120
+ setTimeout(function () { ddlCopy.textContent = '📋 复制 SQL'; }, 1500);
121
+ } catch (e) {
122
+ ta.select();
123
+ document.execCommand('copy');
124
+ ddlCopy.textContent = '✓ 已复制';
125
+ setTimeout(function () { ddlCopy.textContent = '📋 复制 SQL'; }, 1500);
126
+ }
127
+ };
128
+ }
129
+
77
130
  var revertBtn = document.getElementById('db-revert');
78
131
  if (revertBtn) {
79
132
  revertBtn.onclick = async function () {
@@ -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
  }
@@ -49,20 +49,23 @@ function buildDbSection(viewer: UserRow): string {
49
49
  return `<section class="sg db-section">
50
50
  <h2>数据库后端</h2>
51
51
  <p class="db-badge">当前后端: <strong>${escapeHtml(driver.toUpperCase())}</strong> · ${escapeHtml(currentTarget)}</p>
52
- <div class="db-warn">⚠ 切换到 MySQL 后 Web 进程会重启并以 MySQL 为存储。若 MySQL 不可达,进程无法启动——需手动编辑 <code>.env</code> 将 <code>WORK_DB_DRIVER</code> 改回 <code>sqlite</code> 才能恢复。流程:先 ① 测试连接、再 ② 迁移数据、最后 ③ 启用。</div>
52
+ <div class="db-warn">⚠ 切换到 MySQL 后 Web 进程会重启并以 MySQL 为存储。若 MySQL 不可达,进程无法启动——需手动编辑 <code>.env</code> 将 <code>WORK_DB_DRIVER</code> 改回 <code>sqlite</code> 才能恢复。流程:先 ① 测试连接、再 ② 迁移数据、最后 ③ 启用。若你的 MySQL 账号无 CREATE 权限,先点 ⑤ 生成建表 SQL,用有权限的账号跑一遍,再回来 ② 迁移。</div>
53
53
  <label class="sf"><span>MySQL 主机</span><input type="text" id="db-host" placeholder="127.0.0.1" autocomplete="off"></label>
54
54
  <label class="sf"><span>端口</span><input type="number" id="db-port" value="3306" min="1" max="65535" autocomplete="off"></label>
55
55
  <label class="sf"><span>用户名</span><input type="text" id="db-user" placeholder="ework" autocomplete="off"></label>
56
56
  <label class="sf"><span>密码</span><input type="password" id="db-password" placeholder="••••••" autocomplete="new-password"></label>
57
57
  <label class="sf"><span>数据库名</span><input type="text" id="db-database" placeholder="ework" autocomplete="off"></label>
58
58
  <label class="sf"><span>表前缀(可选)</span><input type="text" id="db-prefix" placeholder="留空 = 无前缀" autocomplete="off"></label>
59
+ <label class="sf"><span>daemon 前缀(可选)</span><input type="text" id="db-daemon-prefix" placeholder="留空 = 自动用「表前缀」+d_" autocomplete="off"></label>
59
60
  <div class="db-controls">
60
61
  <button type="button" id="db-test">① 测试连接</button>
61
62
  <button type="button" id="db-migrate" class="secondary">② 迁移数据</button>
62
63
  <button type="button" id="db-daemon" class="secondary">③ 配置 daemon</button>
63
64
  <button type="button" id="db-enable" class="secondary">④ 启用并重启</button>
65
+ <button type="button" id="db-ddl" class="secondary">⑤ 生成建表 SQL(手动建库)</button>
64
66
  </div>
65
67
  <div id="db-result" class="db-result"></div>
68
+ <div id="db-ddl-out" style="display:none;margin-top:.6rem"><div class="db-controls" style="align-items:center"><button type="button" id="db-ddl-copy" class="secondary">📋 复制 SQL</button><span id="db-ddl-meta" style="font-size:12px;color:var(--text-muted)"></span></div><textarea id="db-ddl-text" readonly style="width:100%;min-height:240px;padding:.5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" wrap="off"></textarea></div>
66
69
  ${isMysql ? `<hr style="border:0;border-top:1px solid var(--border);margin:1rem 0"><div class="db-controls"><button type="button" id="db-revert" class="secondary">⚠ 切回 SQLite(安全网)</button></div><div id="db-revert-result" class="db-result"></div>` : ""}
67
70
  <script src="/static/db-wizard.js"></script>
68
71
  </section>`;
@@ -205,6 +208,11 @@ function buildGroupsSection(): string {
205
208
  <button type="button" id="binding-add" class="secondary">添加绑定</button>
206
209
  </div>
207
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>
208
216
  <div class="db-controls" style="margin-top:.7rem">
209
217
  <button type="button" id="strategy-save">保存策略</button>
210
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) {