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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Config, SettingGroup } from "../config";
|
|
2
|
+
import { SETTINGS_GROUPS } from "../config";
|
|
3
|
+
import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
|
|
4
|
+
import type { UserRow } from "../store";
|
|
5
|
+
|
|
6
|
+
function fieldInput(group: SettingGroup, cfg: Config): string {
|
|
7
|
+
return group.fields
|
|
8
|
+
.map((f) => {
|
|
9
|
+
const cur = String(cfg[f.key] ?? "");
|
|
10
|
+
if (f.type === "backend") {
|
|
11
|
+
const opts = cfg.ttsBackends
|
|
12
|
+
.map(
|
|
13
|
+
(b) =>
|
|
14
|
+
`<option value="${escapeAttr(b.id)}"${b.id === cur ? " selected" : ""}>${escapeHtml(b.label)}</option>`
|
|
15
|
+
)
|
|
16
|
+
.join("");
|
|
17
|
+
return `<label class="sf"><span>${escapeHtml(f.label)}</span><select name="${escapeAttr(String(f.key))}">${opts}</select></label>`;
|
|
18
|
+
}
|
|
19
|
+
const inp = f.type === "number" ? "number" : "text";
|
|
20
|
+
return `<label class="sf"><span>${escapeHtml(f.label)}</span><input type="${inp}" name="${escapeAttr(String(f.key))}" value="${escapeAttr(cur)}"></label>`;
|
|
21
|
+
})
|
|
22
|
+
.join("");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow): { html: string } {
|
|
26
|
+
const groups = SETTINGS_GROUPS.map(
|
|
27
|
+
(g) =>
|
|
28
|
+
`<section class="sg"><h2>${escapeHtml(g.title)}</h2>${fieldInput(g, cfg)}</section>`
|
|
29
|
+
).join("");
|
|
30
|
+
const banner = saved ? `<div class="saved">✓ 已保存,立即生效</div>` : "";
|
|
31
|
+
const ttsLink = viewer.is_admin === 1
|
|
32
|
+
? `<p class="hint">要增删朗读后端(kokoro / cosyvoice3 等),去 <a href="/admin/tts-backends">朗读后端管理</a>。</p>`
|
|
33
|
+
: "";
|
|
34
|
+
const html = `<!doctype html>
|
|
35
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
36
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
37
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
38
|
+
<title>ework-web · 设置</title>
|
|
39
|
+
<style>${THEME_CSS}
|
|
40
|
+
.nav{display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px}
|
|
41
|
+
.nav a{color:var(--header-text);opacity:.95}
|
|
42
|
+
.wrap{max-width:680px;margin:0 auto;padding:1rem}
|
|
43
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
44
|
+
.hint{color:var(--text-muted);font-size:13px;margin:0 0 1rem}
|
|
45
|
+
.sg{border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem;background:var(--bg-elev)}
|
|
46
|
+
.sg h2{font-size:14px;margin:0 0 .7rem;color:var(--text)}
|
|
47
|
+
.sf{display:flex;align-items:center;gap:.7rem;margin:.45rem 0}
|
|
48
|
+
.sf span{flex:0 0 150px;color:var(--text-muted);font-size:13px}
|
|
49
|
+
.sf input,.sf select{flex:1;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;font-family:inherit}
|
|
50
|
+
.sf input:focus,.sf select:focus{outline:none;border-color:var(--accent)}
|
|
51
|
+
.saved{background:#1f6feb;color:#fff;padding:.5rem .8rem;border-radius:6px;font-size:13px;margin-bottom:.9rem}
|
|
52
|
+
.bar{display:flex;gap:.6rem;align-items:center;margin-top:.4rem}
|
|
53
|
+
button{padding:.5rem 1.2rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
|
|
54
|
+
.a-back{color:var(--text-muted);font-size:13px}
|
|
55
|
+
</style></head><body>
|
|
56
|
+
<header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · 设置</span></header>
|
|
57
|
+
<main class="wrap">
|
|
58
|
+
<h1>后台配置</h1>
|
|
59
|
+
<p class="hint">改完保存立即生效,无需重启。密钥/启动项(token、端口等)仍在 <code>.env</code>,不在此处。</p>
|
|
60
|
+
${banner}
|
|
61
|
+
<form method="POST" action="/settings">${groups}
|
|
62
|
+
<div class="bar"><button type="submit">保存</button><a class="a-back" href="/">返回</a></div>
|
|
63
|
+
</form>
|
|
64
|
+
${ttsLink}
|
|
65
|
+
</main></body></html>`;
|
|
66
|
+
return { html };
|
|
67
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { THEME_CSS, escapeHtml, tabNavHTML } from "../render/layout";
|
|
2
|
+
import { parsePatIpAllowlist, type PatRow, type UserRow } from "../store";
|
|
3
|
+
|
|
4
|
+
interface Flash {
|
|
5
|
+
kind: "ok" | "err";
|
|
6
|
+
msg: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function statusBadge(row: PatRow): 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 ipBadge(row: PatRow): string {
|
|
18
|
+
const list = parsePatIpAllowlist(row.ip_allowlist ?? "[]");
|
|
19
|
+
if (list.length === 0) return `<span class="muted">全部</span>`;
|
|
20
|
+
return list.map((c) => `<code class="cidr">${escapeHtml(c)}</code>`).join(" ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildTokensPage(
|
|
24
|
+
viewer: UserRow,
|
|
25
|
+
rows: PatRow[],
|
|
26
|
+
flash: Flash | null
|
|
27
|
+
): string {
|
|
28
|
+
const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
|
|
29
|
+
const rowsHtml = rows.length
|
|
30
|
+
? `<table>
|
|
31
|
+
<thead><tr><th>名称</th><th>token</th><th>状态</th><th>IP 限制</th><th>最近使用</th><th>创建</th><th>过期</th><th>操作</th></tr></thead>
|
|
32
|
+
<tbody>
|
|
33
|
+
${rows
|
|
34
|
+
.map((r) => {
|
|
35
|
+
const lastUsed = r.last_used_at ? escapeHtml(r.last_used_at.slice(0, 16).replace("T", " ")) : "—";
|
|
36
|
+
const expires = r.expires_at ? escapeHtml(r.expires_at.slice(0, 10)) : "—";
|
|
37
|
+
const revoked = !!r.revoked_at;
|
|
38
|
+
return `<tr>
|
|
39
|
+
<td class="name">${escapeHtml(r.name)}</td>
|
|
40
|
+
<td class="tok"><code>…${escapeHtml(r.token_last_eight)}</code></td>
|
|
41
|
+
<td>${statusBadge(r)}</td>
|
|
42
|
+
<td class="ip">${ipBadge(r)}</td>
|
|
43
|
+
<td class="muted">${lastUsed}</td>
|
|
44
|
+
<td class="muted">${escapeHtml(r.created_at.slice(0, 10))}</td>
|
|
45
|
+
<td class="muted">${expires}</td>
|
|
46
|
+
<td class="act">${
|
|
47
|
+
revoked
|
|
48
|
+
? ""
|
|
49
|
+
: `<form method="POST" action="/me/tokens/${r.id}/revoke"><button type="submit" class="btn-danger" onclick="return confirm('吊销此 token?使用它的 agent 会立即失去权限。')">吊销</button></form>`
|
|
50
|
+
}</td>
|
|
51
|
+
</tr>`;
|
|
52
|
+
})
|
|
53
|
+
.join("")}
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>`
|
|
56
|
+
: `<div class="hint">还没有 token。下方创建一个给 agent / CLI 用。</div>`;
|
|
57
|
+
return `<!doctype html>
|
|
58
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
60
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
61
|
+
<title>Tokens · ${escapeHtml(viewer.login)}</title>
|
|
62
|
+
<style>${THEME_CSS}
|
|
63
|
+
.flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
|
|
64
|
+
.flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
|
|
65
|
+
.flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
|
|
66
|
+
.wrap{max-width:920px;margin:0 auto;padding:1rem}
|
|
67
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
68
|
+
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
|
|
69
|
+
.card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
|
|
70
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
71
|
+
th,td{text-align:left;padding:.55rem .45rem;border-bottom:1px solid var(--border);vertical-align:middle}
|
|
72
|
+
th{color:var(--text-muted);font-weight:600;font-size:12px}
|
|
73
|
+
td.tok code{background:var(--bg);padding:.1rem .35rem;border-radius:4px;border:1px solid var(--border);font-size:12px}
|
|
74
|
+
td.name{font-weight:600}
|
|
75
|
+
td.muted{color:var(--text-muted);font-size:12px;white-space:nowrap}
|
|
76
|
+
td.act button,.btn-danger{padding:.3rem .65rem;border:0;border-radius:4px;background:#f85149;color:#fff;font:inherit;font-size:12px;cursor:pointer}
|
|
77
|
+
.badge{font-size:11px;font-weight:600;padding:.1rem .45rem;border-radius:4px;line-height:1.5}
|
|
78
|
+
.badge.active{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
|
|
79
|
+
.badge.revoked{background:var(--bg-muted);color:var(--text-muted)}
|
|
80
|
+
.badge.expired{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
|
|
81
|
+
.hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin:.4rem 0}
|
|
82
|
+
label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
|
|
83
|
+
input[type=text],input[type=date]{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:14px;margin-bottom:.7rem}
|
|
84
|
+
textarea{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:14px;margin-bottom:.7rem;resize:vertical;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px}
|
|
85
|
+
input:focus,textarea:focus{outline:none;border-color:var(--accent)}
|
|
86
|
+
.ip code.cidr{background:var(--bg-muted);padding:.05rem .3rem;border-radius:3px;font-size:11px;color:var(--text-muted);margin-right:.2rem}
|
|
87
|
+
.ip .muted{color:var(--text-muted);font-size:12px}
|
|
88
|
+
button.primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
|
|
89
|
+
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}
|
|
90
|
+
</style></head><body>
|
|
91
|
+
<header class="topbar"><span style="font-weight:600">🔑 Tokens · ${escapeHtml(viewer.login)}</span></header>
|
|
92
|
+
${tabNavHTML("me", { login: viewer.login, is_admin: viewer.is_admin })}
|
|
93
|
+
<main class="wrap">
|
|
94
|
+
<h1>Personal Access Tokens</h1>
|
|
95
|
+
${flashHtml}
|
|
96
|
+
|
|
97
|
+
<div class="card">
|
|
98
|
+
<h2>已有 token(${rows.length})</h2>
|
|
99
|
+
${rowsHtml}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<form class="card" method="POST" action="/me/tokens/create">
|
|
103
|
+
<h2>新建 token</h2>
|
|
104
|
+
<div class="form-grid">
|
|
105
|
+
<div><label for="n-name">名称(用于识别用途,例如 "echo-bot")</label><input id="n-name" name="name" type="text" required maxlength="64" pattern="[A-Za-z0-9_\\u4e00-\\u9fa5 .\\-()]{1,64}"></div>
|
|
106
|
+
<div><label for="n-expires">过期日期(可选,留空=永久)</label><input id="n-expires" name="expires_at" type="date"></div>
|
|
107
|
+
</div>
|
|
108
|
+
<label for="n-ip">IP 白名单(可选,留空=不限制)</label>
|
|
109
|
+
<textarea id="n-ip" name="ip_allowlist" rows="2" placeholder="203.0.113.0/24, 198.51.100.5(逗号或换行分隔,仅 IPv4)"></textarea>
|
|
110
|
+
<div class="hint">创建后明文只显示一次。scopes 暂未启用细粒度(token 等同账户全部权限),细粒度权限走 <a href="/projects">项目成员</a> 控制。</div>
|
|
111
|
+
<button class="primary" type="submit">生成 token</button>
|
|
112
|
+
</form>
|
|
113
|
+
</main></body></html>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildTokenCreatedPage(viewer: UserRow, plaintext: string, name: string): string {
|
|
117
|
+
return `<!doctype html>
|
|
118
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
119
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
120
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
121
|
+
<title>Token 已创建 · ${escapeHtml(viewer.login)}</title>
|
|
122
|
+
<style>${THEME_CSS}
|
|
123
|
+
.wrap{max-width:680px;margin:0 auto;padding:1rem}
|
|
124
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
125
|
+
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:1rem;margin-bottom:.9rem}
|
|
126
|
+
.card h2{font-size:14px;margin:0 0 .6rem;font-weight:600}
|
|
127
|
+
.warn{background:color-mix(in srgb,#f85149 12%,transparent);border:1px solid #f85149;color:#f85149;padding:.7rem .9rem;border-radius:8px;font-size:13px;margin-bottom:.7rem}
|
|
128
|
+
.tok-box{display:flex;gap:.5rem;align-items:flex-start;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.6rem;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;word-break:break-all}
|
|
129
|
+
.tok-box code{flex:1;color:var(--text)}
|
|
130
|
+
.tok-box button{padding:.3rem .6rem;border:0;border-radius:4px;background:var(--accent);color:#fff;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}
|
|
131
|
+
.hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin-top:.6rem}
|
|
132
|
+
.btn-primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer;text-decoration:none;display:inline-block;margin-top:.6rem}
|
|
133
|
+
</style></head><body>
|
|
134
|
+
<header class="topbar"><span style="font-weight:600">🔑 Token 已创建</span></header>
|
|
135
|
+
${tabNavHTML("me", { login: viewer.login, is_admin: viewer.is_admin })}
|
|
136
|
+
<main class="wrap">
|
|
137
|
+
<h1>${escapeHtml(name)}</h1>
|
|
138
|
+
<div class="card">
|
|
139
|
+
<div class="warn">⚠️ 这是 token 的明文,<b>仅显示这一次</b>。关闭此页后无法再次查看。请立即复制到密码管理器或 agent 配置中。</div>
|
|
140
|
+
<h2>token</h2>
|
|
141
|
+
<div class="tok-box"><code id="t">${escapeHtml(plaintext)}</code><button type="button" onclick="navigator.clipboard.writeText(document.getElementById('t').innerText).then(()=>{this.textContent='已复制 ✓';setTimeout(()=>this.textContent='复制',1500)})">复制</button></div>
|
|
142
|
+
<div class="hint">用法:HTTP 请求加 <code>Authorization: Bearer <token></code>。任何接受登录 cookie 的端点都接受这个 header。</div>
|
|
143
|
+
</div>
|
|
144
|
+
<a class="btn-primary" href="/me/tokens">← 返回 token 列表</a>
|
|
145
|
+
</main></body></html>`;
|
|
146
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
|
|
2
|
+
import type { TtsBackend } from "../config";
|
|
3
|
+
import type { UserRow } from "../store";
|
|
4
|
+
|
|
5
|
+
interface Flash {
|
|
6
|
+
kind: "ok" | "err";
|
|
7
|
+
msg: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function backendCard(b: TtsBackend, defaultId: string): string {
|
|
11
|
+
const isDefault = b.id === defaultId;
|
|
12
|
+
const idAttr = escapeAttr(b.id);
|
|
13
|
+
const action = `/admin/tts-backends/${encodeURIComponent(b.id)}/update`;
|
|
14
|
+
const deleteForm = isDefault
|
|
15
|
+
? `<span class="muted">默认后端不能删除(先在<a href="/settings">设置</a>里改默认)</span>`
|
|
16
|
+
: `<form method="POST" action="/admin/tts-backends/${encodeURIComponent(b.id)}/delete" class="inline">
|
|
17
|
+
<button type="submit" class="btn-danger" onclick="return confirm('删除后端 ${escapeAttr(b.id)}?')">删除</button>
|
|
18
|
+
</form>`;
|
|
19
|
+
return `<div class="card backend">
|
|
20
|
+
<div class="backend-head">
|
|
21
|
+
<code>${escapeHtml(b.id)}</code>
|
|
22
|
+
${isDefault ? `<span class="badge default">默认</span>` : ""}
|
|
23
|
+
</div>
|
|
24
|
+
<form method="POST" action="${action}" class="grid-form">
|
|
25
|
+
<label class="sf"><span>ID</span><input type="text" name="id" value="${idAttr}" required pattern="[A-Za-z0-9_-]+"></label>
|
|
26
|
+
<label class="sf"><span>显示名</span><input type="text" name="label" value="${escapeAttr(b.label)}" required></label>
|
|
27
|
+
<label class="sf"><span>API URL</span><input type="text" name="url" value="${escapeAttr(b.url)}" placeholder="http://localhost:9880/v1"></label>
|
|
28
|
+
<label class="sf"><span>voice</span><input type="text" name="voice" value="${escapeAttr(b.voice)}"></label>
|
|
29
|
+
<div class="actions">
|
|
30
|
+
<button type="submit">保存</button>
|
|
31
|
+
${deleteForm}
|
|
32
|
+
</div>
|
|
33
|
+
</form>
|
|
34
|
+
</div>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildTtsBackendsPage(
|
|
38
|
+
viewer: UserRow,
|
|
39
|
+
backends: TtsBackend[],
|
|
40
|
+
defaultId: string,
|
|
41
|
+
flash: Flash | null,
|
|
42
|
+
): string {
|
|
43
|
+
const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
|
|
44
|
+
const listHtml = backends.length
|
|
45
|
+
? backends.map((b) => backendCard(b, defaultId)).join("")
|
|
46
|
+
: `<div class="hint">还没有任何后端,先添加一个。</div>`;
|
|
47
|
+
|
|
48
|
+
return `<!doctype html>
|
|
49
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
50
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
51
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
52
|
+
<title>朗读后端管理 · ework</title>
|
|
53
|
+
<style>${THEME_CSS}
|
|
54
|
+
.flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
|
|
55
|
+
.flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
|
|
56
|
+
.flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
|
|
57
|
+
.wrap{max-width:760px;margin:0 auto;padding:1rem}
|
|
58
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
59
|
+
.hint{color:var(--text-muted);font-size:13px;line-height:1.55;margin:0 0 1rem}
|
|
60
|
+
.hint code{background:var(--bg-muted);padding:.05rem .3rem;border-radius:3px;font-size:12px;color:var(--text-muted)}
|
|
61
|
+
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
|
|
62
|
+
.card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
|
|
63
|
+
.backend .backend-head{display:flex;align-items:center;gap:.5rem;margin-bottom:.6rem}
|
|
64
|
+
.backend code{background:var(--bg-muted);padding:.15rem .45rem;border-radius:4px;font-size:13px;color:var(--text-muted)}
|
|
65
|
+
.badge.default{background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent);font-size:11px;font-weight:600;padding:.1rem .45rem;border-radius:4px}
|
|
66
|
+
.grid-form{display:grid;grid-template-columns:1fr 1fr;gap:.55rem .8rem}
|
|
67
|
+
.sf{display:flex;flex-direction:column;gap:.2rem;font-size:12px;color:var(--text-muted)}
|
|
68
|
+
.sf input{padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;font-family:inherit}
|
|
69
|
+
.sf input:focus{outline:none;border-color:var(--accent)}
|
|
70
|
+
.actions{grid-column:1 / -1;display:flex;gap:.6rem;align-items:center;margin-top:.2rem}
|
|
71
|
+
.actions button,.btn-danger{padding:.4rem .9rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font:inherit;font-size:13px;cursor:pointer}
|
|
72
|
+
.btn-danger{background:#f85149}
|
|
73
|
+
.inline{display:inline}
|
|
74
|
+
.muted{color:var(--text-muted);font-size:12px}
|
|
75
|
+
.muted a{color:var(--text-muted);text-decoration:underline}
|
|
76
|
+
</style></head><body>
|
|
77
|
+
<header class="topbar"><span style="font-weight:600">🔊 朗读后端管理(管理员)</span></header>
|
|
78
|
+
${tabNavHTML("projects", { login: viewer.login, is_admin: viewer.is_admin })}
|
|
79
|
+
<main class="wrap">
|
|
80
|
+
<h1>朗读 (TTS) 后端</h1>
|
|
81
|
+
<p class="hint">每个后端四个字段:<code>id</code>(机器标识,仅 <code>A-Z a-z 0-9 _ -</code>)、<code>label</code>(用户可见名)、<code>url</code>(OpenAI-compatible TTS API)、<code>voice</code>(声纹名)。改完保存立即生效,不入 .env、不重启。</p>
|
|
82
|
+
${flashHtml}
|
|
83
|
+
|
|
84
|
+
<div class="card add">
|
|
85
|
+
<h2>添加新后端</h2>
|
|
86
|
+
<form method="POST" action="/admin/tts-backends/add" class="grid-form">
|
|
87
|
+
<label class="sf"><span>ID</span><input type="text" name="id" required pattern="[A-Za-z0-9_-]+" placeholder="kokoro"></label>
|
|
88
|
+
<label class="sf"><span>显示名</span><input type="text" name="label" required placeholder="Kokoro(快)"></label>
|
|
89
|
+
<label class="sf"><span>API URL</span><input type="text" name="url" placeholder="http://localhost:9880/v1"></label>
|
|
90
|
+
<label class="sf"><span>voice</span><input type="text" name="voice" placeholder="zf_001"></label>
|
|
91
|
+
<div class="actions"><button type="submit">添加</button></div>
|
|
92
|
+
</form>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<h2 style="font-size:14px;margin:1rem 0 .55rem">现有后端(${backends.length})</h2>
|
|
96
|
+
${listHtml}
|
|
97
|
+
</main></body></html>`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { THEME_CSS, escapeHtml, tabNavHTML } from "../render/layout";
|
|
2
|
+
import type { UserRow } from "../store";
|
|
3
|
+
|
|
4
|
+
export function buildMePage(user: UserRow, flash: { kind: "ok" | "err"; msg: string } | null): string {
|
|
5
|
+
const flashHtml = flash
|
|
6
|
+
? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>`
|
|
7
|
+
: "";
|
|
8
|
+
const adminBadge = user.is_admin ? ` <span class="badge admin">admin</span>` : "";
|
|
9
|
+
const lastUpdated = user.updated_at ? `<span class="meta">资料更新于 ${escapeHtml(user.updated_at)}</span>` : "";
|
|
10
|
+
return `<!doctype html>
|
|
11
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
13
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
14
|
+
<title>${escapeHtml(user.login)} · 我的</title>
|
|
15
|
+
<style>${THEME_CSS}
|
|
16
|
+
.flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
|
|
17
|
+
.flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
|
|
18
|
+
.flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
|
|
19
|
+
.wrap{max-width:560px;margin:0 auto;padding:1rem}
|
|
20
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
21
|
+
.meta{color:var(--text-muted);font-size:12px;margin-left:.5rem}
|
|
22
|
+
.badge{font-size:11px;font-weight:600;padding:.05rem .4rem;border-radius:4px;line-height:1.5;background:var(--bg-muted);color:var(--text-muted)}
|
|
23
|
+
.badge.admin{background:color-mix(in srgb,var(--system) 18%,transparent);color:var(--system)}
|
|
24
|
+
.badge.bot{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
|
|
25
|
+
.badge.inactive{background:#f85149;color:#fff}
|
|
26
|
+
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
|
|
27
|
+
.card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
|
|
28
|
+
label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
|
|
29
|
+
input{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:14px;margin-bottom:.7rem}
|
|
30
|
+
input:focus{outline:none;border-color:var(--accent)}
|
|
31
|
+
button{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
|
|
32
|
+
.hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin-top:.5rem}
|
|
33
|
+
</style></head><body>
|
|
34
|
+
<header class="topbar"><span style="font-weight:600">👤 我的账户</span></header>
|
|
35
|
+
${tabNavHTML("projects")}
|
|
36
|
+
<main class="wrap">
|
|
37
|
+
<h1>${escapeHtml(user.login)}${adminBadge}${user.is_active ? "" : ' <span class="badge inactive">未激活</span>'}</h1>
|
|
38
|
+
${lastUpdated}
|
|
39
|
+
${flashHtml}
|
|
40
|
+
|
|
41
|
+
<form class="card" method="POST" action="/me/password">
|
|
42
|
+
<h2>修改密码</h2>
|
|
43
|
+
${user.password_hash ? `<label for="old">当前密码</label><input id="old" name="old" type="password" autocomplete="current-password" required>` : `<div class="hint">当前账户没有设置密码(通过共享 token 登录)。下面设置一个新密码后即可使用用户名+密码登录。</div>`}
|
|
44
|
+
<label for="new">新密码(≥ 8 位)</label><input id="new" name="new" type="password" autocomplete="new-password" minlength="8" required>
|
|
45
|
+
<label for="confirm">确认新密码</label><input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" required>
|
|
46
|
+
<button type="submit">保存</button>
|
|
47
|
+
</form>
|
|
48
|
+
|
|
49
|
+
<div class="card">
|
|
50
|
+
<h2>账户信息</h2>
|
|
51
|
+
<div>登录名:<code>${escapeHtml(user.login)}</code></div>
|
|
52
|
+
<div>类型:<span class="badge ${user.kind === "bot" ? "bot" : ""}">${escapeHtml(user.kind)}</span></div>
|
|
53
|
+
${user.email ? `<div>邮箱:${escapeHtml(user.email)}</div>` : ""}
|
|
54
|
+
${user.display_name ? `<div>显示名:${escapeHtml(user.display_name)}</div>` : ""}
|
|
55
|
+
<div class="hint">PAT (Personal Access Token) 用于 agent / CLI 认证,<a href="/me/tokens">前往管理 →</a></div>
|
|
56
|
+
</div>
|
|
57
|
+
</main></body></html>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface AdminFlash {
|
|
61
|
+
kind: "ok" | "err";
|
|
62
|
+
msg: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildAdminUsersPage(viewer: UserRow, users: UserRow[], flash: AdminFlash | null): string {
|
|
66
|
+
const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
|
|
67
|
+
const rows = users
|
|
68
|
+
.map((u) => {
|
|
69
|
+
const adminBadge = u.is_admin ? `<span class="badge admin">admin</span>` : "";
|
|
70
|
+
const inactiveBadge = u.is_active ? "" : `<span class="badge inactive">禁用</span>`;
|
|
71
|
+
const pwState = u.password_hash
|
|
72
|
+
? `<span class="pw yes">已设密码</span>`
|
|
73
|
+
: `<span class="pw no">无密码(token 登录)</span>`;
|
|
74
|
+
const kindBadge = u.kind === "bot" ? `<span class="badge bot">bot</span>` : u.kind === "system" ? `<span class="badge">system</span>` : "";
|
|
75
|
+
const toggleAdminLabel = u.is_admin ? "取消 admin" : "设为 admin";
|
|
76
|
+
const toggleActiveLabel = u.is_active ? "禁用账户" : "启用账户";
|
|
77
|
+
const isSelf = u.login === viewer.login;
|
|
78
|
+
const selfNote = isSelf ? `<span class="meta">(你)</span>` : "";
|
|
79
|
+
return `<tr>
|
|
80
|
+
<td class="login">${escapeHtml(u.login)} ${selfNote} ${kindBadge} ${adminBadge} ${inactiveBadge}</td>
|
|
81
|
+
<td class="pw-cell">${pwState}</td>
|
|
82
|
+
<td class="created">${escapeHtml(u.created_at.slice(0, 10))}</td>
|
|
83
|
+
<td class="actions">
|
|
84
|
+
<form method="POST" action="/admin/users/${encodeURIComponent(u.login)}/reset-password"><input type="password" name="password" placeholder="新密码(≥8)" minlength="8" required><button type="submit">重置密码</button></form>
|
|
85
|
+
<button type="submit" formaction="/admin/users/${encodeURIComponent(u.login)}/toggle-admin" formmethod="POST" class="btn-mini" ${isSelf ? "disabled title='不能改自己'" : ""}>${toggleAdminLabel}</button>
|
|
86
|
+
<button type="submit" formaction="/admin/users/${encodeURIComponent(u.login)}/toggle-active" formmethod="POST" class="btn-mini" ${isSelf ? "disabled title='不能改自己'" : ""}>${toggleActiveLabel}</button>
|
|
87
|
+
</td>
|
|
88
|
+
</tr>`;
|
|
89
|
+
})
|
|
90
|
+
.join("");
|
|
91
|
+
return `<!doctype html>
|
|
92
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
93
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
94
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
95
|
+
<title>用户管理 · ework</title>
|
|
96
|
+
<style>${THEME_CSS}
|
|
97
|
+
.flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
|
|
98
|
+
.flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
|
|
99
|
+
.flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
|
|
100
|
+
.wrap{max-width:960px;margin:0 auto;padding:1rem}
|
|
101
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
102
|
+
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
|
|
103
|
+
.card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
|
|
104
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
105
|
+
th,td{text-align:left;padding:.5rem .4rem;border-bottom:1px solid var(--border);vertical-align:top}
|
|
106
|
+
th{color:var(--text-muted);font-weight:600;font-size:12px}
|
|
107
|
+
td.login{font-weight:600}
|
|
108
|
+
td.pw-cell{color:var(--text-muted);font-size:12px;white-space:nowrap}
|
|
109
|
+
td.created{color:var(--text-muted);font-size:12px;white-space:nowrap}
|
|
110
|
+
td.actions{display:flex;flex-direction:column;gap:.3rem;align-items:flex-start}
|
|
111
|
+
td.actions form{display:flex;gap:.3rem;align-items:center}
|
|
112
|
+
td.actions input{padding:.3rem .4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--text);font:inherit;font-size:12px;width:140px}
|
|
113
|
+
td.actions button, .btn-mini{padding:.3rem .55rem;border:0;border-radius:4px;background:var(--bg-muted);color:var(--text);font:inherit;font-size:12px;cursor:pointer;border:1px solid var(--border)}
|
|
114
|
+
td.actions button:hover, .btn-mini:hover{border-color:var(--accent)}
|
|
115
|
+
td.actions button[disabled]{opacity:.4;cursor:not-allowed}
|
|
116
|
+
.pw.yes{color:var(--green)}
|
|
117
|
+
.pw.no{color:var(--text-muted)}
|
|
118
|
+
.badge{font-size:11px;font-weight:600;padding:.05rem .4rem;border-radius:4px;line-height:1.5;background:var(--bg-muted);color:var(--text-muted)}
|
|
119
|
+
.badge.admin{background:color-mix(in srgb,var(--system) 18%,transparent);color:var(--system)}
|
|
120
|
+
.badge.bot{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
|
|
121
|
+
.badge.inactive{background:#f85149;color:#fff}
|
|
122
|
+
.meta{color:var(--text-muted);font-size:11px;font-weight:400}
|
|
123
|
+
label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
|
|
124
|
+
input[type=text],input[type=password],input[type=email]{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:14px;margin-bottom:.7rem}
|
|
125
|
+
input:focus{outline:none;border-color:var(--accent)}
|
|
126
|
+
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}
|
|
127
|
+
button.primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--green);color:#fff;font-size:13px;cursor:pointer}
|
|
128
|
+
.admin-nav{display:flex;gap:.5rem;margin-bottom:.9rem}
|
|
129
|
+
.admin-nav a{padding:.35rem .8rem;border-radius:6px;background:var(--bg-elev);border:1px solid var(--border);font-size:13px;color:var(--text)}
|
|
130
|
+
.admin-nav a:hover{border-color:var(--accent)}
|
|
131
|
+
</style></head><body>
|
|
132
|
+
<header class="topbar"><span style="font-weight:600">👥 用户管理</span></header>
|
|
133
|
+
${tabNavHTML("projects")}
|
|
134
|
+
<main class="wrap">
|
|
135
|
+
<h1>用户</h1>
|
|
136
|
+
${flashHtml}
|
|
137
|
+
|
|
138
|
+
<div class="admin-nav">
|
|
139
|
+
<a href="/admin/users">用户</a>
|
|
140
|
+
<a href="/admin/tokens">所有 Token</a>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<form class="card" method="POST" action="/admin/users/create">
|
|
144
|
+
<h2>新建用户</h2>
|
|
145
|
+
<div class="form-grid">
|
|
146
|
+
<div><label for="n-login">登录名(A-Za-z0-9_-,1-64)</label><input id="n-login" name="login" type="text" pattern="[A-Za-z0-9_-]{1,64}" required></div>
|
|
147
|
+
<div><label for="n-pw">初始密码(≥ 8 位)</label><input id="n-pw" name="password" type="password" autocomplete="new-password" minlength="8" required></div>
|
|
148
|
+
<div><label for="n-email">邮箱(可选)</label><input id="n-email" name="email" type="email"></div>
|
|
149
|
+
<div><label for="n-kind">类型</label><select id="n-kind" name="kind" style="width:100%;padding:.5rem .65rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font:inherit;font-size:14px"><option value="human" selected>human</option><option value="bot">bot</option><option value="system">system</option></select></div>
|
|
150
|
+
</div>
|
|
151
|
+
<label><input type="checkbox" name="is_admin" value="1" style="width:auto;margin-right:.4rem">设为管理员</label>
|
|
152
|
+
<button class="primary" type="submit">创建用户</button>
|
|
153
|
+
</form>
|
|
154
|
+
|
|
155
|
+
<div class="card">
|
|
156
|
+
<h2>已有用户(${users.length})</h2>
|
|
157
|
+
<table>
|
|
158
|
+
<thead><tr><th>用户</th><th>认证</th><th>注册</th><th>操作</th></tr></thead>
|
|
159
|
+
<tbody>${rows}</tbody>
|
|
160
|
+
</table>
|
|
161
|
+
</div>
|
|
162
|
+
</main></body></html>`;
|
|
163
|
+
}
|