ework-web 0.10.30 → 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.30",
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/index.ts CHANGED
@@ -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>`;