ework-web 0.10.29 → 0.10.31

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.29",
3
+ "version": "0.10.31",
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/auth.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Config } from "./config";
2
2
  import { getUserByLogin, ensureUser, verifyPat, type UserRow } from "./store";
3
3
  import { runAuthHook } from "./auth-hook";
4
+ import { readFileSync, existsSync } from "fs";
4
5
 
5
6
  // Per-user token-cookie auth. Cookie value is HMAC-signed and carries login +
6
7
  // issued-at, so the server is stateless (no session table). Two cookie formats
@@ -191,7 +192,16 @@ function esc(s: string): string {
191
192
  .replace(/"/g, """);
192
193
  }
193
194
 
194
- export function loginHTML(next: string, error?: string): string {
195
+ export function loginHTML(next: string, error?: string, cfg?: Config): string {
196
+ if (cfg?.loginPage && existsSync(cfg.loginPage)) {
197
+ try {
198
+ const tpl = readFileSync(cfg.loginPage, "utf-8");
199
+ return tpl
200
+ .replace(/\{\{next\}\}/g, esc(sanitizeNext(next)))
201
+ .replace(/\{\{error\}\}/g, error ? esc(error) : "");
202
+ } catch {
203
+ }
204
+ }
195
205
  const err = error ? `<div class="err">${esc(error)}</div>` : "";
196
206
  return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
197
207
  <meta name="viewport" content="width=device-width,initial-scale=1">
package/src/config.ts CHANGED
@@ -81,6 +81,7 @@ export const configSchema = z.object({
81
81
  routerAdminToken: z.string().default(""),
82
82
  internalAuthHook: z.string().default(""),
83
83
  userAuthHook: z.string().default(""),
84
+ loginPage: z.string().default(""),
84
85
  // Default "provider/model" string passed to `opencode run --model <X>`.
85
86
  // Empty = let opencode pick per its own opencode.json + env. ework-daemon
86
87
  // pushes this (or the per-project override) on every spawn to defend
@@ -183,6 +184,7 @@ export async function loadConfig(): Promise<Config> {
183
184
  routerAdminToken: process.env.WORK_ROUTER_ADMIN_TOKEN ?? "",
184
185
  internalAuthHook: process.env.WORK_INTERNAL_AUTH_HOOK ?? "",
185
186
  userAuthHook: process.env.WORK_USER_AUTH_HOOK ?? "",
187
+ loginPage: process.env.WORK_LOGIN_PAGE ?? "",
186
188
  defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
187
189
  autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
188
190
  webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
package/src/index.ts CHANGED
@@ -481,7 +481,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
481
481
  const form = await req.formData().catch(() => new FormData());
482
482
  const next = sanitizeNext(String(form.get("next") ?? "/"));
483
483
  if (!rateLimit(`login:${ip}`, 5, 5 / (15 * 60))) {
484
- return html(loginHTML(next, "尝试过多,15 分钟后再试"), 429);
484
+ return html(loginHTML(next, "尝试过多,15 分钟后再试", cfg), 429);
485
485
  }
486
486
  const login = String(form.get("login") ?? "").trim();
487
487
  const password = String(form.get("password") ?? "");
@@ -516,10 +516,10 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
516
516
  : login || password
517
517
  ? "用户名或密码错误"
518
518
  : "请填写用户名密码或共享 token";
519
- return html(loginHTML(next, err), 401);
519
+ return html(loginHTML(next, err, cfg), 401);
520
520
  }
521
521
  const next = sanitizeNext(url.searchParams.get("next") ?? "/");
522
- return html(loginHTML(next));
522
+ return html(loginHTML(next, undefined, cfg));
523
523
  }
524
524
 
525
525
  if (url.pathname === "/logout" && req.method === "POST") {
@@ -611,8 +611,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
611
611
  if (url.pathname === "/issues") {
612
612
  const state = parseState(url.searchParams.get("state"));
613
613
  const q = url.searchParams.get("q")?.trim() ?? "";
614
+ const label = url.searchParams.get("label")?.trim() ?? "";
614
615
  try {
615
- return html(await buildIssuesFeed(state, q));
616
+ return html(await buildIssuesFeed(state, q, label));
616
617
  } catch (e) {
617
618
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
618
619
  }
@@ -1891,8 +1892,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1891
1892
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1892
1893
  const state = parseState(url.searchParams.get("state"));
1893
1894
  const q = url.searchParams.get("q")?.trim() ?? "";
1895
+ const label = url.searchParams.get("label")?.trim() ?? "";
1894
1896
  try {
1895
- return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q));
1897
+ return html(await buildIssueList(owner, repo, state, cfg.writesEnabled, q, label));
1896
1898
  } catch (e) {
1897
1899
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
1898
1900
  }
package/src/store.ts CHANGED
@@ -330,6 +330,7 @@ export async function getIssueWithMeta(projectId: number, number: number): Promi
330
330
  export interface ListIssuesOpts {
331
331
  state?: "open" | "closed" | "all";
332
332
  q?: string;
333
+ label?: string;
333
334
  limit?: number;
334
335
  }
335
336
 
@@ -351,6 +352,11 @@ export async function listIssues(projectId: number, opts: ListIssuesOpts = {}):
351
352
  const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
352
353
  args.push(like, like);
353
354
  }
355
+ const label = (opts.label ?? "").trim();
356
+ if (label) {
357
+ sql += " AND EXISTS (SELECT 1 FROM {{issue_labels}} il JOIN {{labels}} l ON l.id = il.label_id WHERE il.issue_id = i.id AND l.name = ?)";
358
+ args.push(label);
359
+ }
354
360
  sql += " ORDER BY i.updated_at DESC LIMIT ?";
355
361
  args.push(limit);
356
362
  return await getDB().all<IssueWithMeta>(sql, args);
@@ -374,6 +380,11 @@ export async function listAllIssues(opts: ListIssuesOpts = {}): Promise<IssueWit
374
380
  const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
375
381
  args.push(like, like, like, like);
376
382
  }
383
+ const label = (opts.label ?? "").trim();
384
+ if (label) {
385
+ sql += " AND EXISTS (SELECT 1 FROM {{issue_labels}} il JOIN {{labels}} l ON l.id = il.label_id WHERE il.issue_id = i.id AND l.name = ?)";
386
+ args.push(label);
387
+ }
377
388
  sql += " ORDER BY i.updated_at DESC LIMIT ?";
378
389
  args.push(limit);
379
390
  return await getDB().all<IssueWithMeta>(sql, args);
@@ -644,6 +655,43 @@ export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
644
655
  );
645
656
  }
646
657
 
658
+ export async function listLabelsForIssues(issueIds: number[]): Promise<Map<number, LabelRow[]>> {
659
+ const result = new Map<number, LabelRow[]>();
660
+ if (issueIds.length === 0) return result;
661
+ const placeholders = issueIds.map(() => "?").join(",");
662
+ const rows = await getDB().all<{
663
+ issue_id: number;
664
+ label_id: number;
665
+ name: string;
666
+ color: string;
667
+ description: string | null;
668
+ exclusive: number;
669
+ is_archived: number;
670
+ }>(
671
+ `SELECT il.issue_id, l.id AS label_id, l.name, l.color, l.description, l.exclusive, l.is_archived
672
+ FROM {{issue_labels}} il JOIN {{labels}} l ON l.id = il.label_id
673
+ WHERE il.issue_id IN (${placeholders})
674
+ ORDER BY l.name`,
675
+ issueIds
676
+ );
677
+ for (const r of rows) {
678
+ const issueId = r.issue_id;
679
+ const label: LabelRow = {
680
+ id: r.label_id,
681
+ project_id: 0,
682
+ name: r.name,
683
+ color: r.color,
684
+ description: r.description ?? "",
685
+ exclusive: r.exclusive,
686
+ is_archived: r.is_archived,
687
+ };
688
+ const list = result.get(issueId);
689
+ if (list) list.push(label);
690
+ else result.set(issueId, [label]);
691
+ }
692
+ return result;
693
+ }
694
+
647
695
  export async function getLabel(projectId: number, id: number): Promise<LabelRow | null> {
648
696
  return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id])) ?? null;
649
697
  }
@@ -1,14 +1,30 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
2
- import { getProject, listIssues, type IssueWithMeta } from "../store";
2
+ import { getProject, listIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
3
3
  import { relTime } from "../render/components";
4
4
 
5
5
  export const LIST_PAGE_SIZE = 50;
6
6
 
7
- function issueRow(it: IssueWithMeta, owner: string, repo: string, q: string): string {
7
+ function labelChip(label: LabelRow, owner: string, repo: string, state: string): string {
8
+ const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}&label=${encodeURIComponent(label.name)}`;
9
+ return `<a class="issue-label" href="${escapeAttr(href)}" style="background:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}">${escapeHtml(label.name)}</a>`;
10
+ }
11
+
12
+ function issueRow(
13
+ it: IssueWithMeta,
14
+ owner: string,
15
+ repo: string,
16
+ q: string,
17
+ state: string,
18
+ labels: LabelRow[],
19
+ ): string {
8
20
  const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${it.number}`;
9
21
  const title = q ? highlightAll(it.title, q) : escapeHtml(it.title);
22
+ const chips = labels.length
23
+ ? `<div class="row-labels">${labels.map((l) => labelChip(l, owner, repo, state)).join("")}</div>`
24
+ : "";
10
25
  return `<a class="row" href="${escapeAttr(href)}">
11
26
  <div class="row-title">${title}</div>
27
+ ${chips}
12
28
  <div class="row-meta">#${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
13
29
  </a>`;
14
30
  }
@@ -18,13 +34,15 @@ export async function buildIssueList(
18
34
  repo: string,
19
35
  state: "open" | "closed" | "all",
20
36
  writesEnabled: boolean,
21
- q: string
37
+ q: string,
38
+ label: string = "",
22
39
  ): Promise<string> {
23
40
  const project = await getProject(owner, repo);
24
41
  if (!project) {
25
42
  return notFoundProject(owner, repo);
26
43
  }
27
- const issues = await listIssues(project.id, { state, q, limit: LIST_PAGE_SIZE });
44
+ const issues = await listIssues(project.id, { state, q, label, limit: LIST_PAGE_SIZE });
45
+ const labelMap = await listLabelsForIssues(issues.map((it) => it.id));
28
46
  const matchesFirst = q
29
47
  ? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
30
48
  : issues;
@@ -32,11 +50,15 @@ export async function buildIssueList(
32
50
  const closedActive = state === "closed" ? " active" : "";
33
51
  const allActive = state === "all" ? " active" : "";
34
52
  const qParam = q ? `&q=${encodeURIComponent(q)}` : "";
53
+ const labelParam = label ? `&label=${encodeURIComponent(label)}` : "";
54
+ const labelFilterHint = label
55
+ ? `<div class="label-filter">标签: <strong>${escapeHtml(label)}</strong> <a href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}${qParam}" class="label-clear">✕</a></div>`
56
+ : "";
35
57
  const newBtn = writesEnabled
36
58
  ? `<a class="new-btn" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/new">+ 新建</a>`
37
59
  : "";
38
60
  const rows = matchesFirst.length
39
- ? matchesFirst.map((it) => issueRow(it, owner, repo, q)).join("")
61
+ ? matchesFirst.map((it) => issueRow(it, owner, repo, q, state, labelMap.get(it.id) ?? [])).join("")
40
62
  : `<div class="empty">暂无工单</div>`;
41
63
  const searchVal = escapeAttr(q);
42
64
  return `<!doctype html>
@@ -57,6 +79,13 @@ export async function buildIssueList(
57
79
  .row-title{font-weight:500;overflow-wrap:anywhere}
58
80
  .row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
59
81
  .empty{color:var(--text-muted);text-align:center;padding:2rem;font-size:13px}
82
+ .row-labels{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.2rem}
83
+ .issue-label{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:500;color:#fff;text-decoration:none;line-height:18px;white-space:nowrap}
84
+ .issue-label:hover{opacity:.85;text-decoration:none}
85
+ .label-filter{font-size:13px;color:var(--text-muted);margin-bottom:.4rem}
86
+ .label-filter strong{color:var(--text)}
87
+ .label-clear{color:var(--text-muted);text-decoration:none;margin-left:.3rem}
88
+ .label-clear:hover{color:var(--text)}
60
89
  </style></head><body>
61
90
  <header class="topbar">
62
91
  <a href="/" style="color:var(--header-text)">🏠</a>
@@ -71,11 +100,12 @@ ${tabNavHTML("issues")}
71
100
  <button type="submit" class="new-btn" style="background:var(--bg-muted);color:var(--text)">搜索</button>
72
101
  </form>
73
102
  <div class="tabs-sub">
74
- <a class="tab${openActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=open${qParam}">Open</a>
75
- <a class="tab${closedActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=closed${qParam}">Closed</a>
76
- <a class="tab${allActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=all${qParam}">All</a>
103
+ <a class="tab${openActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=open${qParam}${labelParam}">Open</a>
104
+ <a class="tab${closedActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=closed${qParam}${labelParam}">Closed</a>
105
+ <a class="tab${allActive}" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=all${qParam}${labelParam}">All</a>
77
106
  ${newBtn}
78
107
  </div>
108
+ ${labelFilterHint}
79
109
  <div class="rows">${rows}</div>
80
110
  </main>
81
111
  </body></html>`;
@@ -1,24 +1,35 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
2
- import { listAllIssues, type IssueWithMeta } from "../store";
2
+ import { listAllIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
3
3
  import { relTime } from "../render/components";
4
4
 
5
5
  export const FEED_PAGE_SIZE = 50;
6
6
 
7
- function issueRow(it: IssueWithMeta, q: string): string {
7
+ function labelChip(label: LabelRow, owner: string, repo: string, state: string): string {
8
+ const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}&label=${encodeURIComponent(label.name)}`;
9
+ return `<a class="issue-label" href="${escapeAttr(href)}" style="background:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}">${escapeHtml(label.name)}</a>`;
10
+ }
11
+
12
+ function issueRow(it: IssueWithMeta, q: string, state: string, labels: LabelRow[]): string {
8
13
  const href = `/${encodeURIComponent(it.project_owner)}/${encodeURIComponent(it.project_name)}/issues/${it.number}`;
9
14
  const title = q ? highlightAll(it.title, q) : escapeHtml(it.title);
10
15
  const projectStr = `${escapeHtml(it.project_owner)}<span style="opacity:.55">/</span>${escapeHtml(it.project_name)}`;
16
+ const chips = labels.length
17
+ ? `<div class="row-labels">${labels.map((l) => labelChip(l, it.project_owner, it.project_name, state)).join("")}</div>`
18
+ : "";
11
19
  return `<a class="row" href="${escapeAttr(href)}">
12
20
  <div class="row-title">${title}</div>
21
+ ${chips}
13
22
  <div class="row-meta">${projectStr} · #${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
14
23
  </a>`;
15
24
  }
16
25
 
17
26
  export async function buildIssuesFeed(
18
27
  state: "open" | "closed" | "all",
19
- q: string
28
+ q: string,
29
+ label: string = "",
20
30
  ): Promise<string> {
21
- const issues = await listAllIssues({ state, q, limit: FEED_PAGE_SIZE });
31
+ const issues = await listAllIssues({ state, q, label, limit: FEED_PAGE_SIZE });
32
+ const labelMap = await listLabelsForIssues(issues.map((it) => it.id));
22
33
  const matchesFirst = q
23
34
  ? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
24
35
  : issues;
@@ -26,8 +37,11 @@ export async function buildIssuesFeed(
26
37
  const closedActive = state === "closed" ? " active" : "";
27
38
  const allActive = state === "all" ? " active" : "";
28
39
  const qParam = q ? `&q=${encodeURIComponent(q)}` : "";
40
+ const labelFilterHint = label
41
+ ? `<div class="label-filter">标签: <strong>${escapeHtml(label)}</strong></div>`
42
+ : "";
29
43
  const rows = matchesFirst.length
30
- ? matchesFirst.map((it) => issueRow(it, q)).join("")
44
+ ? matchesFirst.map((it) => issueRow(it, q, state, labelMap.get(it.id) ?? [])).join("")
31
45
  : `<div class="empty">暂无工单</div>`;
32
46
  return `<!doctype html>
33
47
  <html lang="zh"><head><meta charset="utf-8">
@@ -46,6 +60,11 @@ export async function buildIssuesFeed(
46
60
  .row-title{font-weight:500;overflow-wrap:anywhere}
47
61
  .row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
48
62
  .empty{color:var(--text-muted);text-align:center;padding:2rem;font-size:13px}
63
+ .row-labels{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.2rem}
64
+ .issue-label{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:500;color:#fff;text-decoration:none;line-height:18px;white-space:nowrap}
65
+ .issue-label:hover{opacity:.85;text-decoration:none}
66
+ .label-filter{font-size:13px;color:var(--text-muted);margin-bottom:.4rem}
67
+ .label-filter strong{color:var(--text)}
49
68
  </style></head><body>
50
69
  <header class="topbar"><span style="font-weight:600">📦 ework</span></header>
51
70
  ${tabNavHTML("issues")}
@@ -60,6 +79,7 @@ ${tabNavHTML("issues")}
60
79
  <a class="tab${closedActive}" href="/issues?state=closed${qParam}">Closed</a>
61
80
  <a class="tab${allActive}" href="/issues?state=all${qParam}">All</a>
62
81
  </div>
82
+ ${labelFilterHint}
63
83
  <div class="rows">${rows}</div>
64
84
  </main>
65
85
  </body></html>`;