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 +1 -1
- package/src/index.ts +4 -2
- package/src/store.ts +48 -0
- package/src/views/issueList.ts +38 -8
- package/src/views/issues.ts +25 -5
package/package.json
CHANGED
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
|
}
|
package/src/views/issueList.ts
CHANGED
|
@@ -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
|
|
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>`;
|
package/src/views/issues.ts
CHANGED
|
@@ -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
|
|
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>`;
|