ework-web 0.1.0

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.
@@ -0,0 +1,166 @@
1
+ import type { Config } from "./config";
2
+
3
+ export class TranslateError extends Error {
4
+ status: number;
5
+ constructor(message: string, status: number) {
6
+ super(message);
7
+ this.name = "TranslateError";
8
+ this.status = status;
9
+ }
10
+ }
11
+
12
+ const SYSTEM_PROMPT =
13
+ "Translate the following English text to Simplified Chinese. Preserve ALL markdown formatting (bullet lists with - or *, **bold**, `code`, # headings). Output ONLY the translation, nothing else.";
14
+ const MAX_CHARS = 16_000;
15
+ const TIMEOUT_MS = 60_000;
16
+
17
+ // OpenAI-compatible chat-completion shapes (vLLM serves /v1/chat/completions).
18
+ interface ChatChoice {
19
+ message?: { content?: unknown };
20
+ delta?: { content?: unknown };
21
+ }
22
+ interface ChatResponse {
23
+ choices?: ChatChoice[];
24
+ error?: { message?: string } | string;
25
+ }
26
+
27
+ function endpoint(cfg: Config): string {
28
+ return `${cfg.translateUrl.replace(/\/$/, "")}/chat/completions`;
29
+ }
30
+
31
+ export async function translateText(cfg: Config, text: string): Promise<string> {
32
+ if (!cfg.translateUrl) throw new TranslateError("translation disabled", 503);
33
+ const body = text.slice(0, MAX_CHARS);
34
+ const ctrl = new AbortController();
35
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
36
+ let res: Response;
37
+ try {
38
+ res = await fetch(endpoint(cfg), {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({
42
+ model: cfg.translateModel,
43
+ stream: false,
44
+ messages: [
45
+ { role: "system", content: SYSTEM_PROMPT },
46
+ { role: "user", content: body },
47
+ ],
48
+ }),
49
+ signal: ctrl.signal,
50
+ });
51
+ } catch (e) {
52
+ const msg = e instanceof Error ? e.message : String(e);
53
+ throw new TranslateError(`translate service unreachable: ${msg}`, 502);
54
+ } finally {
55
+ clearTimeout(timer);
56
+ }
57
+ if (!res.ok) {
58
+ const t = await res.text().catch(() => "");
59
+ throw new TranslateError(`translate service → ${res.status}: ${t.slice(0, 200)}`, 502);
60
+ }
61
+ const data = (await res.json()) as ChatResponse;
62
+ if (data.error) {
63
+ const m = typeof data.error === "string" ? data.error : data.error.message;
64
+ throw new TranslateError(m || "translate error", 502);
65
+ }
66
+ const content = typeof data.choices?.[0]?.message?.content === "string" ? data.choices[0].message.content : "";
67
+ if (!content) throw new TranslateError("empty translation response", 502);
68
+ return content;
69
+ }
70
+
71
+ // Streaming variant over the OpenAI-compatible /v1/chat/completions (vLLM).
72
+ // vLLM streams Server-Sent-Events: lines `data: {choices:[{delta:{content}}]}`
73
+ // terminated by `data: [DONE]`. Long input is split on sentence boundaries into
74
+ // ~CHUNK_CHARS chunks, each its own request with its own timeout (so total can
75
+ // exceed TIMEOUT_MS); yields are one continuous stream so the caller sees fluent
76
+ // output regardless of input length.
77
+ const CHUNK_CHARS = 2000;
78
+
79
+ export async function* translateTextStream(cfg: Config, text: string): AsyncGenerator<string> {
80
+ if (!cfg.translateUrl) throw new TranslateError("translation disabled", 503);
81
+ for (const chunk of splitChunks(text)) {
82
+ yield* streamOneChunk(cfg, chunk);
83
+ }
84
+ }
85
+
86
+ // Split on the last sentence boundary in the latter half of each max-sized window,
87
+ // so chunks stay readable and boundaries land on 。.!?…\n;;,, rather than mid-word.
88
+ function splitChunks(text: string, max = CHUNK_CHARS): string[] {
89
+ if (text.length <= max) return [text];
90
+ const chunks: string[] = [];
91
+ let start = 0;
92
+ while (start < text.length) {
93
+ let end = Math.min(start + max, text.length);
94
+ if (end < text.length) {
95
+ const lo = start + Math.floor(max / 2);
96
+ const window = text.slice(lo, end);
97
+ const breaks = /[。.!?…!?\n;;,,]/g;
98
+ let last = -1;
99
+ let m: RegExpExecArray | null;
100
+ while ((m = breaks.exec(window)) !== null) last = m.index;
101
+ if (last >= 0) end = lo + last + 1;
102
+ }
103
+ chunks.push(text.slice(start, end));
104
+ start = end;
105
+ }
106
+ return chunks;
107
+ }
108
+
109
+ async function* streamOneChunk(cfg: Config, chunk: string): AsyncGenerator<string> {
110
+ const ctrl = new AbortController();
111
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
112
+ let res: Response;
113
+ try {
114
+ res = await fetch(endpoint(cfg), {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ model: cfg.translateModel,
119
+ stream: true,
120
+ messages: [
121
+ { role: "system", content: SYSTEM_PROMPT },
122
+ { role: "user", content: chunk },
123
+ ],
124
+ }),
125
+ signal: ctrl.signal,
126
+ });
127
+ } catch (e) {
128
+ const msg = e instanceof Error ? e.message : String(e);
129
+ throw new TranslateError(`translate service unreachable: ${msg}`, 502);
130
+ } finally {
131
+ clearTimeout(timer);
132
+ }
133
+ if (!res.ok || !res.body) {
134
+ const t = await res.text().catch(() => "");
135
+ throw new TranslateError(`translate service → ${res.status}: ${t.slice(0, 200)}`, 502);
136
+ }
137
+ const reader = res.body.getReader();
138
+ const dec = new TextDecoder();
139
+ let buf = "";
140
+ for (;;) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+ buf += dec.decode(value, { stream: true });
144
+ const lines = buf.split("\n");
145
+ buf = lines.pop() || "";
146
+ for (const line of lines) {
147
+ const s = line.trim();
148
+ if (!s.startsWith("data:")) continue;
149
+ const payload = s.slice(5).trim();
150
+ if (payload === "[DONE]") return;
151
+ if (!payload) continue;
152
+ let obj: ChatResponse;
153
+ try {
154
+ obj = JSON.parse(payload);
155
+ } catch {
156
+ continue;
157
+ }
158
+ if (obj.error) {
159
+ const m = typeof obj.error === "string" ? obj.error : obj.error.message;
160
+ throw new TranslateError(m || "translate error", 502);
161
+ }
162
+ const c = typeof obj.choices?.[0]?.delta?.content === "string" ? obj.choices[0].delta.content : "";
163
+ if (c) yield c;
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,122 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
2
+ import { parsePatIpAllowlist, type PatWithUser, type UserRow } from "../store";
3
+
4
+ interface Flash {
5
+ kind: "ok" | "err";
6
+ msg: string;
7
+ }
8
+
9
+ function statusBadge(row: PatWithUser): string {
10
+ if (row.revoked_at) return `<span class="badge revoked">已吊销</span>`;
11
+ if (row.expires_at && Date.parse(row.expires_at) < Date.now()) {
12
+ return `<span class="badge expired">已过期</span>`;
13
+ }
14
+ return `<span class="badge active">生效中</span>`;
15
+ }
16
+
17
+ function parseScopes(raw: string): string[] {
18
+ try {
19
+ const v = JSON.parse(raw);
20
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ export function buildAdminTokensPage(
27
+ viewer: UserRow,
28
+ rows: PatWithUser[],
29
+ flash: Flash | null,
30
+ ): string {
31
+ const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
32
+ const activeCount = rows.filter((r) => !r.revoked_at && !(r.expires_at && Date.parse(r.expires_at) < Date.now())).length;
33
+ const revokedCount = rows.length - activeCount;
34
+ const rowsHtml = rows.length
35
+ ? `<table>
36
+ <thead><tr><th>名称</th><th>用户</th><th>token</th><th>scopes</th><th>IP</th><th>状态</th><th>最近使用</th><th>创建</th><th>操作</th></tr></thead>
37
+ <tbody>
38
+ ${rows
39
+ .map((r) => {
40
+ const lastUsed = r.last_used_at ? escapeHtml(r.last_used_at.slice(0, 16).replace("T", " ")) : "—";
41
+ const scopes = parseScopes(r.scopes).map((s) => `<code>${escapeHtml(s)}</code>`).join(" ") || "—";
42
+ const ipList = parsePatIpAllowlist(r.ip_allowlist ?? "[]");
43
+ const ipCell = ipList.length === 0
44
+ ? `<span class="muted">全部</span>`
45
+ : ipList.map((c) => `<code class="cidr">${escapeHtml(c)}</code>`).join(" ");
46
+ const userBadges: string[] = [];
47
+ if (r.user_is_admin === 1) userBadges.push(`<span class="badge admin">admin</span>`);
48
+ if (r.user_kind === "bot") userBadges.push(`<span class="badge bot">bot</span>`);
49
+ else if (r.user_kind === "system") userBadges.push(`<span class="badge">system</span>`);
50
+ if (r.user_is_active === 0) userBadges.push(`<span class="badge inactive">禁用</span>`);
51
+ const revoked = !!r.revoked_at;
52
+ const ownerNote = r.user_login === viewer.login ? `<span class="meta">(你)</span>` : "";
53
+ return `<tr>
54
+ <td class="name">${escapeHtml(r.name)}</td>
55
+ <td class="user">${escapeHtml(r.user_login)} ${ownerNote} ${userBadges.join(" ")}</td>
56
+ <td class="tok"><code>…${escapeHtml(r.token_last_eight)}</code></td>
57
+ <td class="scopes">${scopes}</td>
58
+ <td class="ip">${ipCell}</td>
59
+ <td>${statusBadge(r)}</td>
60
+ <td class="muted">${lastUsed}</td>
61
+ <td class="muted">${escapeHtml(r.created_at.slice(0, 10))}</td>
62
+ <td class="act">${
63
+ revoked
64
+ ? ""
65
+ : `<form method="POST" action="/admin/tokens/${r.id}/revoke"><button type="submit" class="btn-danger" onclick="return confirm('吊销此 token?用户 ${escapeAttr(r.user_login)} 的 agent 会立即失去权限。')">吊销</button></form>`
66
+ }</td>
67
+ </tr>`;
68
+ })
69
+ .join("")}
70
+ </tbody>
71
+ </table>`
72
+ : `<div class="hint">系统里还没有任何 PAT。</div>`;
73
+ return `<!doctype html>
74
+ <html lang="zh"><head><meta charset="utf-8">
75
+ <meta name="viewport" content="width=device-width,initial-scale=1">
76
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
77
+ <title>Token 管理 · ework</title>
78
+ <style>${THEME_CSS}
79
+ .flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
80
+ .flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
81
+ .flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
82
+ .wrap{max-width:1180px;margin:0 auto;padding:1rem}
83
+ h1{font-size:18px;margin:0 0 .4rem}
84
+ .summary{color:var(--text-muted);font-size:13px;margin:0 0 .8rem}
85
+ .card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
86
+ .card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
87
+ table{width:100%;border-collapse:collapse;font-size:13px}
88
+ th,td{text-align:left;padding:.55rem .45rem;border-bottom:1px solid var(--border);vertical-align:middle}
89
+ th{color:var(--text-muted);font-weight:600;font-size:12px}
90
+ td.tok code{background:var(--bg);padding:.1rem .35rem;border-radius:4px;border:1px solid var(--border);font-size:12px}
91
+ td.scopes code{background:var(--bg-muted);padding:.05rem .3rem;border-radius:3px;font-size:11px;color:var(--text-muted);margin-right:.2rem}
92
+ td.ip code.cidr{background:var(--bg-muted);padding:.05rem .3rem;border-radius:3px;font-size:11px;color:var(--text-muted);margin-right:.2rem}
93
+ td.ip .muted{color:var(--text-muted);font-size:12px}
94
+ td.name{font-weight:600}
95
+ td.user{font-weight:600;white-space:nowrap}
96
+ td.muted{color:var(--text-muted);font-size:12px;white-space:nowrap}
97
+ td.act button,.btn-danger{padding:.3rem .65rem;border:0;border-radius:4px;background:#f85149;color:#fff;font:inherit;font-size:12px;cursor:pointer}
98
+ .badge{font-size:11px;font-weight:600;padding:.1rem .45rem;border-radius:4px;line-height:1.5}
99
+ .badge.active{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
100
+ .badge.revoked{background:var(--bg-muted);color:var(--text-muted)}
101
+ .badge.expired{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
102
+ .badge.admin{background:color-mix(in srgb,var(--system) 18%,transparent);color:var(--system)}
103
+ .badge.bot{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
104
+ .badge.inactive{background:#f85149;color:#fff}
105
+ .hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin:.4rem 0}
106
+ .meta{color:var(--text-muted);font-size:11px;font-weight:400}
107
+ </style></head><body>
108
+ <header class="topbar"><span style="font-weight:600">🔑 Token 管理(管理员)</span></header>
109
+ ${tabNavHTML("projects", { login: viewer.login, is_admin: viewer.is_admin })}
110
+ <main class="wrap">
111
+ <h1>所有 Personal Access Tokens</h1>
112
+ <p class="summary">共 ${rows.length} 个(生效中 ${activeCount},已吊销/过期 ${revokedCount})</p>
113
+ ${flashHtml}
114
+
115
+ <div class="card">
116
+ <h2>Token 列表</h2>
117
+ ${rowsHtml}
118
+ </div>
119
+
120
+ <div class="hint">吊销任意 token 后,使用该 token 的 agent / CLI 会立即失去权限(verifyPat 检查 revoked_at)。用户自己的 token 仍由 <a href="/me/tokens">/me/tokens</a> 自助管理。</div>
121
+ </main></body></html>`;
122
+ }
@@ -0,0 +1,109 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
2
+ import { listProjectsWithCounts, createProject, canAdminProject, StoreError, type ProjectWithCounts, type UserRow } from "../store";
3
+ import { relTime } from "../render/components";
4
+ import { getDefaultUpstreamUrl, webUrlFromClone } from "./projectUpstreams";
5
+
6
+ function projectCard(p: ProjectWithCounts, viewer: UserRow | null): string {
7
+ const issuesHref = `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/issues`;
8
+ const settingsHref = `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/settings/upstreams`;
9
+ const desc = p.description ? `<div class="pcard-desc">${escapeHtml(p.description)}</div>` : "";
10
+ const upstreamClone = getDefaultUpstreamUrl(p);
11
+ const upstreamWebUrl = upstreamClone ? webUrlFromClone(upstreamClone) : null;
12
+ const upstreamIcon = upstreamWebUrl
13
+ ? `<a class="pcard-icon" href="${escapeAttr(upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="查看上游:${escapeAttr(upstreamWebUrl)}" aria-label="查看上游">🔗</a>`
14
+ : "";
15
+ const adminIcon = canAdminProject(p.id, viewer)
16
+ ? `<a class="pcard-icon" href="${escapeAttr(settingsHref)}" title="项目设置(上游 / Webhooks / 成员)" aria-label="项目设置">⚙️</a>`
17
+ : "";
18
+ const iconsHtml = (upstreamIcon || adminIcon)
19
+ ? `<span class="pcard-icons">${upstreamIcon}${adminIcon}</span>`
20
+ : "";
21
+ return `<div class="pcard">
22
+ <a class="pcard-stretched" href="${escapeAttr(issuesHref)}" aria-label="${escapeAttr(p.owner + "/" + p.name)} issues"></a>
23
+ <div class="pcard-title">${escapeHtml(p.owner)}<span style="opacity:.55">/</span>${escapeHtml(p.name)}</div>
24
+ ${desc}
25
+ <div class="pcard-meta">
26
+ <span class="pcard-stats">📂 ${p.total_count} · 🟢 ${p.open_count} · ${relTime(p.updated_at)}</span>
27
+ ${iconsHtml}
28
+ </div>
29
+ </div>`;
30
+ }
31
+
32
+ export function buildHome(
33
+ viewer: UserRow | null,
34
+ flash: { kind: "ok" | "err"; msg: string } | null = null
35
+ ): string {
36
+ const projects = listProjectsWithCounts();
37
+ const list = projects.length
38
+ ? projects.map((p) => projectCard(p, viewer)).join("")
39
+ : `<div class="empty">还没有项目。在下面创建一个:</div>`;
40
+ const flashHtml = flash
41
+ ? `<div class="flash ${flash.kind === "ok" ? "ok" : "err"}">${escapeHtml(flash.msg)}</div>`
42
+ : "";
43
+ return `<!doctype html>
44
+ <html lang="zh"><head><meta charset="utf-8">
45
+ <meta name="viewport" content="width=device-width,initial-scale=1">
46
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
47
+ <title>ework-web · 项目</title>
48
+ <style>${THEME_CSS}
49
+ .home-wrap{max-width:900px;margin:0 auto;padding:.6rem 1rem 3rem}
50
+ .pcard{position:relative;display:block;padding:.7rem .9rem;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;color:var(--text)}
51
+ .pcard:hover{text-decoration:none;border-color:var(--accent)}
52
+ .pcard-stretched{position:absolute;inset:0;z-index:1}
53
+ .pcard-title{font-weight:600;font-size:15px;overflow-wrap:anywhere}
54
+ .pcard-desc{color:var(--text-muted);font-size:13px;margin-top:.2rem;line-height:1.4;overflow-wrap:anywhere}
55
+ .pcard-meta{color:var(--text-muted);font-size:12px;margin-top:.3rem;display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
56
+ .pcard-stats{min-width:0}
57
+ .pcard-icons{display:inline-flex;gap:.3rem;align-items:center;position:relative;z-index:2;margin-left:auto}
58
+ .pcard-icon{display:inline-flex;align-items:center;justify-content:center;min-width:24px;height:24px;padding:0 .35rem;border:1px solid var(--border);border-radius:5px;background:var(--bg-elev);color:var(--text-muted);font-size:13px;text-decoration:none}
59
+ .pcard-icon:hover{text-decoration:none;border-color:var(--accent);color:var(--accent)}
60
+ .empty{color:var(--text-muted);text-align:center;padding:2rem;font-size:13px}
61
+ .section{margin-top:1.6rem}
62
+ .section h2{font-size:14px;margin:0 0 .6rem;color:var(--text-muted);font-weight:600}
63
+ .new-form{display:flex;flex-direction:column;gap:.5rem;max-width:480px}
64
+ .new-form input,.new-form textarea{background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;font:inherit}
65
+ .new-form textarea{min-height:4em;resize:vertical}
66
+ .new-form button{align-self:flex-start;background:var(--green);color:#fff;border:none;border-radius:8px;padding:.5rem 1rem;font:600 13px system-ui,sans-serif;cursor:pointer}
67
+ .err{color:#f85149;font-size:13px}
68
+ .flash{padding:.55rem .8rem;border-radius:6px;font-size:13px;margin-bottom:.6rem;border:1px solid var(--border)}
69
+ .flash.ok{background:rgba(63,185,80,.12);color:#3fb950;border-color:rgba(63,185,80,.4)}
70
+ .flash.err{background:rgba(248,81,73,.12);color:#f85149;border-color:rgba(248,81,73,.4)}
71
+ </style></head><body>
72
+ <header class="topbar"><span style="font-weight:600">📦 ework</span></header>
73
+ ${tabNavHTML("projects")}
74
+ <main class="home-wrap">
75
+ ${flashHtml}
76
+ <div class="section">
77
+ <h2>项目</h2>
78
+ <div class="pcards">${list}</div>
79
+ </div>
80
+ <div class="section">
81
+ <h2>新建项目</h2>
82
+ <form class="new-form" method="POST" action="/projects">
83
+ <input type="text" name="owner" placeholder="owner(必填,字母数字 . _ -)" required pattern="[A-Za-z0-9_.-]+" maxlength="64">
84
+ <input type="text" name="name" placeholder="name(必填,字母数字 . _ -)" required pattern="[A-Za-z0-9_.-]+" maxlength="64">
85
+ <textarea name="description" placeholder="一句话描述(可选)"></textarea>
86
+ <button type="submit">创建</button>
87
+ </form>
88
+ </div>
89
+ </main>
90
+ </body></html>`;
91
+ }
92
+
93
+ export function handleCreateProject(
94
+ form: Record<string, string | undefined>
95
+ ): { location: string; error?: string; projectId?: number } {
96
+ const owner = (form.owner ?? "").trim();
97
+ const name = (form.name ?? "").trim();
98
+ const description = (form.description ?? "").trim();
99
+ if (!owner || !name) return { location: "/projects", error: "owner 和 name 必填" };
100
+ try {
101
+ const p = createProject(owner, name, description);
102
+ return { location: `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/issues`, projectId: p.id };
103
+ } catch (e) {
104
+ return {
105
+ location: "/projects",
106
+ error: e instanceof StoreError ? e.message : e instanceof Error ? e.message : "创建失败",
107
+ };
108
+ }
109
+ }
@@ -0,0 +1,89 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
2
+ import { getProject, listIssues, type IssueWithMeta } from "../store";
3
+ import { relTime } from "../render/components";
4
+
5
+ export const LIST_PAGE_SIZE = 50;
6
+
7
+ function issueRow(it: IssueWithMeta, owner: string, repo: string, q: string): string {
8
+ const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${it.number}`;
9
+ const title = q ? highlightAll(it.title, q) : escapeHtml(it.title);
10
+ return `<a class="row" href="${escapeAttr(href)}">
11
+ <div class="row-title">${title}</div>
12
+ <div class="row-meta">#${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
13
+ </a>`;
14
+ }
15
+
16
+ export function buildIssueList(
17
+ owner: string,
18
+ repo: string,
19
+ state: "open" | "closed" | "all",
20
+ writesEnabled: boolean,
21
+ q: string
22
+ ): string {
23
+ const project = getProject(owner, repo);
24
+ if (!project) {
25
+ return notFoundProject(owner, repo);
26
+ }
27
+ const issues = listIssues(project.id, { state, q, limit: LIST_PAGE_SIZE });
28
+ const matchesFirst = q
29
+ ? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
30
+ : issues;
31
+ const openActive = state === "open" ? " active" : "";
32
+ const closedActive = state === "closed" ? " active" : "";
33
+ const allActive = state === "all" ? " active" : "";
34
+ const qParam = q ? `&q=${encodeURIComponent(q)}` : "";
35
+ const newBtn = writesEnabled
36
+ ? `<a class="new-btn" href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/new">+ 新建</a>`
37
+ : "";
38
+ const rows = matchesFirst.length
39
+ ? matchesFirst.map((it) => issueRow(it, owner, repo, q)).join("")
40
+ : `<div class="empty">暂无工单</div>`;
41
+ const searchVal = escapeAttr(q);
42
+ return `<!doctype html>
43
+ <html lang="zh"><head><meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width,initial-scale=1">
45
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
46
+ <title>${escapeHtml(owner)}/${escapeHtml(repo)} · Issues</title>
47
+ <style>${THEME_CSS}
48
+ .list-wrap{max-width:900px;margin:0 auto;padding:.4rem 1rem 3rem}
49
+ .search{margin:.4rem 0 .8rem;display:flex;gap:.4rem}
50
+ .search input{flex:1;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;font:inherit}
51
+ .tabs-sub{display:flex;gap:.3rem;margin-bottom:.6rem;align-items:center}
52
+ .tabs-sub a{padding:.3rem .8rem;border-radius:6px;font-size:13px;color:var(--text-muted)}
53
+ .tabs-sub a.active{background:var(--bg-muted);color:var(--text);font-weight:600}
54
+ .new-btn{margin-left:auto;background:var(--green);color:#fff;padding:.3rem .8rem;border-radius:6px;font-size:13px;font-weight:600}
55
+ .row{display:block;padding:.6rem .2rem;border-bottom:1px solid var(--border);color:var(--text)}
56
+ .row:hover{text-decoration:none;background:var(--bg-muted)}
57
+ .row-title{font-weight:500;overflow-wrap:anywhere}
58
+ .row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
59
+ .empty{color:var(--text-muted);text-align:center;padding:2rem;font-size:13px}
60
+ </style></head><body>
61
+ <header class="topbar">
62
+ <a href="/" style="color:var(--header-text)">🏠</a>
63
+ <span style="opacity:.5">/</span>
64
+ <a href="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues" style="color:var(--header-text)">${escapeHtml(owner)}<span style="opacity:.55">/</span>${escapeHtml(repo)}</a>
65
+ </header>
66
+ ${tabNavHTML("issues")}
67
+ <main class="list-wrap">
68
+ <form class="search" method="GET" action="/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues">
69
+ <input type="text" name="q" value="${searchVal}" placeholder="搜索标题/正文…">
70
+ <input type="hidden" name="state" value="${state}">
71
+ <button type="submit" class="new-btn" style="background:var(--bg-muted);color:var(--text)">搜索</button>
72
+ </form>
73
+ <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>
77
+ ${newBtn}
78
+ </div>
79
+ <div class="rows">${rows}</div>
80
+ </main>
81
+ </body></html>`;
82
+ }
83
+
84
+ function notFoundProject(owner: string, repo: string): string {
85
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>项目不存在</title>
86
+ <style>body{font-family:system-ui,sans-serif;background:#1b1b1b;color:#e6e6e6;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}</style></head>
87
+ <body><div style="text-align:center"><h2>项目 ${escapeHtml(owner)}/${escapeHtml(repo)} 不存在</h2>
88
+ <p style="color:#9a9a9a"><a href="/projects" style="color:var(--accent)">← 返回项目列表</a></p></div></body></html>`;
89
+ }
@@ -0,0 +1,35 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
2
+
3
+ export function buildIssueNew(owner: string, repo: string, writesEnabled: boolean): string {
4
+ const listHref = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`;
5
+ const action = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`;
6
+ const body = writesEnabled
7
+ ? `<form class="new-form" method="POST" action="${escapeAttr(action)}">
8
+ <input type="text" name="title" placeholder="标题(必填)" required maxlength="255" class="new-title">
9
+ <textarea name="body" rows="14" placeholder="正文(支持 Markdown)…"></textarea>
10
+ <div class="new-actions"><a class="new-cancel" href="${escapeAttr(listHref)}">取消</a><button type="submit">创建工单</button></div>
11
+ </form>`
12
+ : `<div class="composer-ro">只读模式:创建工单未启用(WORK_WRITES_ENABLED=false)</div>`;
13
+ return `<!doctype html>
14
+ <html lang="zh"><head><meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width,initial-scale=1">
16
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
17
+ <title>新建工单 · ${escapeHtml(owner)}/${escapeHtml(repo)}</title>
18
+ <style>${THEME_CSS}
19
+ .new-wrap{max-width:900px;margin:0 auto;padding:.6rem 1rem}
20
+ .new-form{display:flex;flex-direction:column;gap:.6rem}
21
+ .new-title{width:100%;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.55rem .7rem;font:600 16px system-ui,sans-serif}
22
+ .new-form textarea{width:100%;resize:vertical;min-height:14em;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.6rem .7rem;font:14px/1.55 -apple-system,"PingFang SC",sans-serif}
23
+ .new-actions{display:flex;gap:.6rem;justify-content:flex-end;align-items:center}
24
+ .new-cancel{font-size:13px;color:var(--text-muted)}
25
+ .new-form button{background:var(--green);color:#fff;border:none;border-radius:8px;padding:.55rem 1.2rem;font:600 13px system-ui,sans-serif;cursor:pointer}
26
+ </style></head><body>
27
+ <header class="topbar">
28
+ <a href="/" style="color:var(--header-text)">🏠</a>
29
+ <span style="opacity:.5">/</span>
30
+ <a href="${escapeAttr(listHref)}" style="color:var(--header-text)">${escapeHtml(owner)}<span style="opacity:.55">/</span>${escapeHtml(repo)}</a>
31
+ <span class="num">新建工单</span>
32
+ </header>
33
+ <main class="new-wrap">${body}</main>
34
+ </body></html>`;
35
+ }