feat: AI assistant bubble + multi-provider temp-mail + settings view
Browse files- src/server/ai-agent.ts +19 -0
- src/server/index.ts +2 -0
- src/server/routes/ai.ts +10 -1
- src/server/routes/secrets.ts +28 -0
- src/web/src/api.ts +18 -0
- src/web/src/components/SettingsView.tsx +117 -4
- src/web/src/styles.css +18 -1
src/server/ai-agent.ts
CHANGED
|
@@ -310,6 +310,25 @@ async function callLLM(messages: ChatMessage[]): Promise<any> {
|
|
| 310 |
return JSON.parse(text);
|
| 311 |
}
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
export async function runAgent(
|
| 314 |
history: ChatMessage[],
|
| 315 |
maxSteps = 6
|
|
|
|
| 310 |
return JSON.parse(text);
|
| 311 |
}
|
| 312 |
|
| 313 |
+
// 拉取端点真实可用模型列表(GET {base}/models)。可传入未保存的 base/key 先试。
|
| 314 |
+
export async function listModels(baseUrl?: string, apiKey?: string): Promise<string[]> {
|
| 315 |
+
const base = (baseUrl || getAiBaseUrl())?.replace(/\/+$/, "");
|
| 316 |
+
const key = apiKey || getAiApiKey();
|
| 317 |
+
if (!base) throw new Error("先填模型端点 base_url");
|
| 318 |
+
if (!key) throw new Error("先填 API Key");
|
| 319 |
+
const res = await fetch(`${base}/models`, { headers: { authorization: `Bearer ${key}` } });
|
| 320 |
+
const text = await res.text();
|
| 321 |
+
if (!res.ok) {
|
| 322 |
+
let msg = `拉取模型失败 HTTP ${res.status}`;
|
| 323 |
+
try { const j = JSON.parse(text); msg = j.error?.message || j.message || msg; } catch { /* keep */ }
|
| 324 |
+
throw new Error(msg);
|
| 325 |
+
}
|
| 326 |
+
let j: any; try { j = JSON.parse(text); } catch { throw new Error("端点返回非 JSON(可能不支持 /models)"); }
|
| 327 |
+
const arr: any[] = Array.isArray(j) ? j : (j.data ?? j.models ?? []);
|
| 328 |
+
const ids = arr.map((m) => (typeof m === "string" ? m : m.id ?? m.name ?? m.model)).filter(Boolean);
|
| 329 |
+
return Array.from(new Set(ids)).sort();
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
export async function runAgent(
|
| 333 |
history: ChatMessage[],
|
| 334 |
maxSteps = 6
|
src/server/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { eventRoutes } from "./routes/events";
|
|
| 13 |
import { clawAuthRoutes } from "./routes/claw-auth";
|
| 14 |
import { cfMailRoutes } from "./routes/cf-mail";
|
| 15 |
import { aiRoutes } from "./routes/ai";
|
|
|
|
| 16 |
import { startAllMailboxListeners } from "./listener-manager";
|
| 17 |
import { hasClawMailConfig } from "./runtime-config";
|
| 18 |
import { hydrateFromSupabase } from "./hydrate";
|
|
@@ -60,6 +61,7 @@ await eventRoutes(app);
|
|
| 60 |
await clawAuthRoutes(app);
|
| 61 |
await cfMailRoutes(app);
|
| 62 |
await aiRoutes(app);
|
|
|
|
| 63 |
|
| 64 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 65 |
const webRoot = join(__dirname, "../web");
|
|
|
|
| 13 |
import { clawAuthRoutes } from "./routes/claw-auth";
|
| 14 |
import { cfMailRoutes } from "./routes/cf-mail";
|
| 15 |
import { aiRoutes } from "./routes/ai";
|
| 16 |
+
import { secretRoutes } from "./routes/secrets";
|
| 17 |
import { startAllMailboxListeners } from "./listener-manager";
|
| 18 |
import { hasClawMailConfig } from "./runtime-config";
|
| 19 |
import { hydrateFromSupabase } from "./hydrate";
|
|
|
|
| 61 |
await clawAuthRoutes(app);
|
| 62 |
await cfMailRoutes(app);
|
| 63 |
await aiRoutes(app);
|
| 64 |
+
await secretRoutes(app);
|
| 65 |
|
| 66 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 67 |
const webRoot = join(__dirname, "../web");
|
src/server/routes/ai.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
// /api/ai/* —— 右下角 AI 气泡的后端:跑助手 + 配置 LLM 端点。
|
| 2 |
import type { FastifyInstance } from "fastify";
|
| 3 |
import { z } from "zod";
|
| 4 |
-
import { runAgent, type ChatMessage } from "../ai-agent";
|
| 5 |
import { aiConfigured, getAiConfigStatus, saveAiConfig, clearAiConfig } from "../runtime-config";
|
| 6 |
|
| 7 |
const chatBody = z.object({
|
|
@@ -35,6 +35,15 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
|
|
| 35 |
return getAiConfigStatus();
|
| 36 |
});
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
app.post("/api/ai/chat", async (request, reply) => {
|
| 39 |
if (!aiConfigured()) {
|
| 40 |
return reply.code(409).send({ error: "AI 未配置:请先在设置里填入模型端点(base_url)和 key" });
|
|
|
|
| 1 |
// /api/ai/* —— 右下角 AI 气泡的后端:跑助手 + 配置 LLM 端点。
|
| 2 |
import type { FastifyInstance } from "fastify";
|
| 3 |
import { z } from "zod";
|
| 4 |
+
import { runAgent, listModels, type ChatMessage } from "../ai-agent";
|
| 5 |
import { aiConfigured, getAiConfigStatus, saveAiConfig, clearAiConfig } from "../runtime-config";
|
| 6 |
|
| 7 |
const chatBody = z.object({
|
|
|
|
| 35 |
return getAiConfigStatus();
|
| 36 |
});
|
| 37 |
|
| 38 |
+
app.post("/api/ai/models", async (request, reply) => {
|
| 39 |
+
const body = (request.body ?? {}) as { baseUrl?: string; apiKey?: string };
|
| 40 |
+
try {
|
| 41 |
+
return { models: await listModels(body.baseUrl, body.apiKey) };
|
| 42 |
+
} catch (error) {
|
| 43 |
+
return reply.code(502).send({ error: error instanceof Error ? error.message : String(error) });
|
| 44 |
+
}
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
app.post("/api/ai/chat", async (request, reply) => {
|
| 48 |
if (!aiConfigured()) {
|
| 49 |
return reply.code(409).send({ error: "AI 未配置:请先在设置里填入模型端点(base_url)和 key" });
|
src/server/routes/secrets.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// /api/secrets —— 在管理员门后揭示真实凭据,供设置页「凭据·可复制」用。
|
| 2 |
+
// 整个 /api/* 已被 ADMIN_PASSWORD 门拦住(index.ts onRequest),没登录的人取不到。
|
| 3 |
+
// 仅按需拉取(前端点「加载凭据」才调),平时不下发明文。
|
| 4 |
+
import type { FastifyInstance } from "fastify";
|
| 5 |
+
import { listProviders } from "../temp-providers";
|
| 6 |
+
import { getClawApiKey, getDashboardCookie, getAiApiKey } from "../runtime-config";
|
| 7 |
+
|
| 8 |
+
export async function secretRoutes(app: FastifyInstance): Promise<void> {
|
| 9 |
+
app.get("/api/secrets", async () => {
|
| 10 |
+
return {
|
| 11 |
+
temp: listProviders().map((p) => ({
|
| 12 |
+
id: p.id,
|
| 13 |
+
name: p.name,
|
| 14 |
+
type: p.type,
|
| 15 |
+
endpoint: p.endpoint,
|
| 16 |
+
domain: p.domain,
|
| 17 |
+
password: p.password || null
|
| 18 |
+
})),
|
| 19 |
+
claw: {
|
| 20 |
+
apiKey: getClawApiKey() ?? null,
|
| 21 |
+
hasCookie: Boolean(getDashboardCookie())
|
| 22 |
+
},
|
| 23 |
+
ai: {
|
| 24 |
+
apiKey: getAiApiKey() ?? null
|
| 25 |
+
}
|
| 26 |
+
};
|
| 27 |
+
});
|
| 28 |
+
}
|
src/web/src/api.ts
CHANGED
|
@@ -468,6 +468,24 @@ export async function saveAiConfig(input: {
|
|
| 468 |
});
|
| 469 |
}
|
| 470 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
export async function aiChat(
|
| 472 |
messages: AiChatMessage[]
|
| 473 |
): Promise<{ reply: string; toolTrace: Array<{ name: string; args: any }> }> {
|
|
|
|
| 468 |
});
|
| 469 |
}
|
| 470 |
|
| 471 |
+
export type SecretBundle = {
|
| 472 |
+
temp: Array<{ id: string; name: string; type: "php" | "cf"; endpoint: string; domain: string; password: string | null }>;
|
| 473 |
+
claw: { apiKey: string | null; hasCookie: boolean };
|
| 474 |
+
ai: { apiKey: string | null };
|
| 475 |
+
};
|
| 476 |
+
|
| 477 |
+
export async function fetchSecrets(): Promise<SecretBundle> {
|
| 478 |
+
return requestJson<SecretBundle>("/api/secrets");
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
export async function fetchAiModels(baseUrl?: string, apiKey?: string): Promise<string[]> {
|
| 482 |
+
const data = await requestJson<{ models: string[] }>("/api/ai/models", {
|
| 483 |
+
method: "POST",
|
| 484 |
+
body: JSON.stringify({ baseUrl, apiKey })
|
| 485 |
+
});
|
| 486 |
+
return data.models;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
export async function aiChat(
|
| 490 |
messages: AiChatMessage[]
|
| 491 |
): Promise<{ reply: string; toolTrace: Array<{ name: string; args: any }> }> {
|
src/web/src/components/SettingsView.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
import {
|
| 3 |
fetchAiConfig,
|
|
|
|
| 4 |
fetchCfStatus,
|
|
|
|
| 5 |
saveAiConfig,
|
| 6 |
type AiConfigStatus,
|
|
|
|
| 7 |
type TempProviderPublic
|
| 8 |
} from "../api";
|
| 9 |
import { usePrefs } from "../i18n";
|
|
@@ -89,11 +92,75 @@ export function SettingsView({
|
|
| 89 |
const [aiModel, setAiModel] = useState("");
|
| 90 |
const [aiKey, setAiKey] = useState("");
|
| 91 |
const [aiBusy, setAiBusy] = useState(false);
|
|
|
|
|
|
|
| 92 |
|
| 93 |
useEffect(() => {
|
| 94 |
fetchAiConfig().then((c) => { setAi(c); setAiBase(c.baseUrl); setAiModel(c.model); }).catch(() => {});
|
| 95 |
}, []);
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
async function saveAi() {
|
| 98 |
setAiBusy(true);
|
| 99 |
try {
|
|
@@ -177,8 +244,17 @@ export function SettingsView({
|
|
| 177 |
<label className="wide"><span>Base URL</span>
|
| 178 |
<input value={aiBase} onChange={(e) => setAiBase(e.target.value)} placeholder="https://api.example.com/v1" spellCheck={false} />
|
| 179 |
</label>
|
| 180 |
-
<label><span>
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
</label>
|
| 183 |
<label><span>API Key {ai?.hasKey ? L("(已存,留空不改)", "(saved)") : ""}</span>
|
| 184 |
<input type="password" value={aiKey} onChange={(e) => setAiKey(e.target.value)} placeholder="sk-…" autoComplete="new-password" />
|
|
@@ -191,6 +267,39 @@ export function SettingsView({
|
|
| 191 |
</div>
|
| 192 |
</div>
|
| 193 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</div>
|
| 195 |
);
|
| 196 |
|
|
@@ -213,9 +322,13 @@ export function SettingsView({
|
|
| 213 |
<label><span>{L("域名", "Domain")}</span>
|
| 214 |
<input value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="x.xyz" spellCheck={false} />
|
| 215 |
</label>
|
| 216 |
-
<label><span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password"
|
| 218 |
-
placeholder={editingExisting ? (editingProvider?.hasPassword ? L("已设 · 留空不改", "set ·
|
| 219 |
</label>
|
| 220 |
<div className="settings-form-foot">
|
| 221 |
{type === "cf" && (
|
|
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
import {
|
| 3 |
fetchAiConfig,
|
| 4 |
+
fetchAiModels,
|
| 5 |
fetchCfStatus,
|
| 6 |
+
fetchSecrets,
|
| 7 |
saveAiConfig,
|
| 8 |
type AiConfigStatus,
|
| 9 |
+
type SecretBundle,
|
| 10 |
type TempProviderPublic
|
| 11 |
} from "../api";
|
| 12 |
import { usePrefs } from "../i18n";
|
|
|
|
| 92 |
const [aiModel, setAiModel] = useState("");
|
| 93 |
const [aiKey, setAiKey] = useState("");
|
| 94 |
const [aiBusy, setAiBusy] = useState(false);
|
| 95 |
+
const [aiModels, setAiModels] = useState<string[]>([]);
|
| 96 |
+
const [modelsBusy, setModelsBusy] = useState(false);
|
| 97 |
|
| 98 |
useEffect(() => {
|
| 99 |
fetchAiConfig().then((c) => { setAi(c); setAiBase(c.baseUrl); setAiModel(c.model); }).catch(() => {});
|
| 100 |
}, []);
|
| 101 |
|
| 102 |
+
async function loadModels() {
|
| 103 |
+
if (!aiBase.trim()) { onError(L("先填 Base URL", "Fill Base URL first")); return; }
|
| 104 |
+
setModelsBusy(true);
|
| 105 |
+
try {
|
| 106 |
+
const list = await fetchAiModels(aiBase.trim(), aiKey || undefined);
|
| 107 |
+
setAiModels(list);
|
| 108 |
+
onStatus(L(`拉到 ${list.length} 个模型`, `${list.length} models`));
|
| 109 |
+
} catch (e) {
|
| 110 |
+
onError(e instanceof Error ? e.message : String(e));
|
| 111 |
+
} finally {
|
| 112 |
+
setModelsBusy(false);
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// ---- 凭据·可复制(B 方案:揭示真实密钥/口令)----
|
| 117 |
+
const [secrets, setSecrets] = useState<SecretBundle | null>(null);
|
| 118 |
+
const [secretsBusy, setSecretsBusy] = useState(false);
|
| 119 |
+
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
| 120 |
+
|
| 121 |
+
async function loadSecrets() {
|
| 122 |
+
setSecretsBusy(true);
|
| 123 |
+
try {
|
| 124 |
+
setSecrets(await fetchSecrets());
|
| 125 |
+
} catch (e) {
|
| 126 |
+
onError(e instanceof Error ? e.message : String(e));
|
| 127 |
+
} finally {
|
| 128 |
+
setSecretsBusy(false);
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function copyText(text: string) {
|
| 133 |
+
const done = () => onStatus(L("已复制到剪贴板", "Copied"));
|
| 134 |
+
if (navigator.clipboard?.writeText) {
|
| 135 |
+
navigator.clipboard.writeText(text).then(done).catch(() => onError(L("复制失败,手动选中吧", "Copy failed")));
|
| 136 |
+
} else {
|
| 137 |
+
onError(L("此浏览器不支持自动复制", "Clipboard unavailable"));
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function maskSecret(v: string) {
|
| 142 |
+
if (v.length <= 8) return "••••••";
|
| 143 |
+
return `${v.slice(0, 4)}••••••${v.slice(-3)}`;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function secretRow(key: string, label: string, value: string | null) {
|
| 147 |
+
const shown = revealed[key];
|
| 148 |
+
return (
|
| 149 |
+
<div className="secret-row" key={key}>
|
| 150 |
+
<span className="secret-label">{label}</span>
|
| 151 |
+
<code className="secret-val">{value ? (shown ? value : maskSecret(value)) : L("未设", "not set")}</code>
|
| 152 |
+
{value && (
|
| 153 |
+
<div className="secret-ops">
|
| 154 |
+
<button className="mini-link" onClick={() => setRevealed((r) => ({ ...r, [key]: !r[key] }))}>
|
| 155 |
+
{shown ? L("🙈 隐藏", "🙈 hide") : L("👁 查看", "👁 show")}
|
| 156 |
+
</button>
|
| 157 |
+
<button className="mini-link" onClick={() => copyText(value)}>{L("复制", "copy")}</button>
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
async function saveAi() {
|
| 165 |
setAiBusy(true);
|
| 166 |
try {
|
|
|
|
| 244 |
<label className="wide"><span>Base URL</span>
|
| 245 |
<input value={aiBase} onChange={(e) => setAiBase(e.target.value)} placeholder="https://api.example.com/v1" spellCheck={false} />
|
| 246 |
</label>
|
| 247 |
+
<label><span>
|
| 248 |
+
{L("模型", "Model")}
|
| 249 |
+
<button type="button" className="mini-link" onClick={loadModels} disabled={modelsBusy || !aiBase.trim()}>
|
| 250 |
+
{modelsBusy ? L("获取中…", "fetching…") : L("↻ 获取模型", "↻ fetch models")}
|
| 251 |
+
</button>
|
| 252 |
+
</span>
|
| 253 |
+
<input list="ai-model-list" value={aiModel} onChange={(e) => setAiModel(e.target.value)}
|
| 254 |
+
placeholder={aiModels.length ? L("选择或输入", "pick or type") : L("先点「获取模型」", "click fetch first")} spellCheck={false} />
|
| 255 |
+
<datalist id="ai-model-list">
|
| 256 |
+
{aiModels.map((m) => <option key={m} value={m} />)}
|
| 257 |
+
</datalist>
|
| 258 |
</label>
|
| 259 |
<label><span>API Key {ai?.hasKey ? L("(已存,留空不改)", "(saved)") : ""}</span>
|
| 260 |
<input type="password" value={aiKey} onChange={(e) => setAiKey(e.target.value)} placeholder="sk-…" autoComplete="new-password" />
|
|
|
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
+
|
| 271 |
+
{/* ===== 凭据·可复制 ===== */}
|
| 272 |
+
<div className="settings-card">
|
| 273 |
+
<div className="settings-head">
|
| 274 |
+
<div>
|
| 275 |
+
<h3>{L("凭据 · 可复制", "Credentials · copy")}</h3>
|
| 276 |
+
<p>{L("你存的真实密钥/口令:临时源密码、claw 出口 API key、AI key。点「查看」看明文、点「复制」进剪贴板。仅本后台(已过管理员门)可见。", "Real stored secrets: temp-source passwords, claw API key, AI key. Click show / copy. Visible only inside this admin panel.")}</p>
|
| 277 |
+
</div>
|
| 278 |
+
{!secrets && (
|
| 279 |
+
<button className="primary" onClick={loadSecrets} disabled={secretsBusy}>
|
| 280 |
+
{secretsBusy ? L("加载中…", "loading…") : L("🔓 加载凭据", "🔓 Load")}
|
| 281 |
+
</button>
|
| 282 |
+
)}
|
| 283 |
+
</div>
|
| 284 |
+
{secrets && (
|
| 285 |
+
<div className="secret-list">
|
| 286 |
+
{secrets.temp.map((t) =>
|
| 287 |
+
secretRow(
|
| 288 |
+
`t:${t.id}`,
|
| 289 |
+
`${t.name} · ${t.type === "cf" ? L("x-admin-auth 口令", "x-admin-auth") : L("管理员密码", "admin password")}`,
|
| 290 |
+
t.password
|
| 291 |
+
)
|
| 292 |
+
)}
|
| 293 |
+
{secretRow("claw", L("claw 出口 · CLAW_API_KEY", "claw · CLAW_API_KEY"), secrets.claw.apiKey)}
|
| 294 |
+
{secretRow("ai", L("AI 助手 · API Key", "AI · API Key"), secrets.ai.apiKey)}
|
| 295 |
+
<div className="settings-form-foot">
|
| 296 |
+
<span className="settings-note">{L("提醒:这些是真实密钥,复制后别贴到公开地方。", "These are real secrets — don't paste them anywhere public.")}</span>
|
| 297 |
+
<span style={{ flex: 1 }} />
|
| 298 |
+
<button onClick={() => { setSecrets(null); setRevealed({}); }}>{L("收起", "Hide all")}</button>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
)}
|
| 302 |
+
</div>
|
| 303 |
</div>
|
| 304 |
);
|
| 305 |
|
|
|
|
| 322 |
<label><span>{L("域名", "Domain")}</span>
|
| 323 |
<input value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="x.xyz" spellCheck={false} />
|
| 324 |
</label>
|
| 325 |
+
<label><span>
|
| 326 |
+
{type === "cf"
|
| 327 |
+
? L("管理员 auth(x-admin-auth 口令)", "Admin auth (x-admin-auth)")
|
| 328 |
+
: L("管理员密码(X-Admin-Password)", "Admin password (X-Admin-Password)")}
|
| 329 |
+
</span>
|
| 330 |
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password"
|
| 331 |
+
placeholder={editingExisting ? (editingProvider?.hasPassword ? L("已设 · 留空不改(出于安全不回显)", "set · blank = keep (never echoed)") : L("未设", "not set")) : L("必填", "required")} />
|
| 332 |
</label>
|
| 333 |
<div className="settings-form-foot">
|
| 334 |
{type === "cf" && (
|
src/web/src/styles.css
CHANGED
|
@@ -2201,7 +2201,24 @@ a:hover { text-decoration: underline; text-underline-offset: 2px; }
|
|
| 2201 |
}
|
| 2202 |
.settings-form label { display: flex; flex-direction: column; gap: 5px; }
|
| 2203 |
.settings-form label.wide { grid-column: 1 / -1; }
|
| 2204 |
-
.settings-form label span { font-size: 11.5px; color: var(--text-3); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2205 |
.settings-form input,
|
| 2206 |
.settings-form select {
|
| 2207 |
background: var(--surface-2);
|
|
|
|
| 2201 |
}
|
| 2202 |
.settings-form label { display: flex; flex-direction: column; gap: 5px; }
|
| 2203 |
.settings-form label.wide { grid-column: 1 / -1; }
|
| 2204 |
+
.settings-form label span { font-size: 11.5px; color: var(--text-3); display: flex; align-items: center; gap: 8px; }
|
| 2205 |
+
.mini-link {
|
| 2206 |
+
background: none; border: none; padding: 0; cursor: pointer;
|
| 2207 |
+
color: var(--accent); font-size: 11px; font-weight: 500;
|
| 2208 |
+
}
|
| 2209 |
+
.mini-link:disabled { color: var(--text-3); cursor: default; }
|
| 2210 |
+
.secret-list { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; }
|
| 2211 |
+
.secret-row {
|
| 2212 |
+
display: flex; align-items: center; gap: 12px;
|
| 2213 |
+
padding: 9px 10px; border-radius: var(--radius);
|
| 2214 |
+
}
|
| 2215 |
+
.secret-row:hover { background: var(--surface-2); }
|
| 2216 |
+
.secret-label { font-size: 12px; color: var(--text-2); min-width: 190px; flex-shrink: 0; }
|
| 2217 |
+
.secret-val {
|
| 2218 |
+
flex: 1; font-family: var(--mono, ui-monospace, monospace); font-size: 12px;
|
| 2219 |
+
color: var(--text); word-break: break-all; user-select: all;
|
| 2220 |
+
}
|
| 2221 |
+
.secret-ops { display: flex; gap: 12px; flex-shrink: 0; }
|
| 2222 |
.settings-form input,
|
| 2223 |
.settings-form select {
|
| 2224 |
background: var(--surface-2);
|