save
This commit is contained in:
parent
65e368c541
commit
a82f855743
6
.env
6
.env
@ -1,7 +1,7 @@
|
|||||||
# Mini AI Assistant 配置(可由设置页面修改)
|
# Mini AI Assistant 配置(可由设置页面修改)
|
||||||
BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3
|
BASE_URL=https://opencode.ai/zen/v1
|
||||||
API_KEY=ark-8d00ad01-f5b3-4d25-8c49-5ff9c69e1308-ebe56
|
API_KEY=sk-ihICiTIZTPdQ0FlhA7ru1kFMpVnxcvHNaK0oxem0ecnz7CpPx0xRoBMCPQhPDmmf
|
||||||
MODEL=ark-code-latest
|
MODEL=deepseek-v4-flash-free
|
||||||
WORKSPACE=D:\workbench\miniai
|
WORKSPACE=D:\workbench\miniai
|
||||||
CMD_APPROVE_LEVEL=medium
|
CMD_APPROVE_LEVEL=medium
|
||||||
PORT=3100
|
PORT=3100
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -10,6 +10,9 @@ logs/
|
|||||||
*.err
|
*.err
|
||||||
_be.*
|
_be.*
|
||||||
|
|
||||||
|
# 会话记忆数据
|
||||||
|
conversations/
|
||||||
|
|
||||||
# 环境变量(含密钥,勿提交;参考 .env.example)
|
# 环境变量(含密钥,勿提交;参考 .env.example)
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
133
server.ts
133
server.ts
@ -1,5 +1,5 @@
|
|||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import { readFileSync, existsSync, writeFileSync, statSync } from "fs";
|
import { readFileSync, existsSync, writeFileSync, statSync, mkdirSync, readdirSync, unlinkSync } from "fs";
|
||||||
import { appendFile, mkdir, readFile } from "fs/promises";
|
import { appendFile, mkdir, readFile } from "fs/promises";
|
||||||
|
|
||||||
// ---------- 配置加载 ----------
|
// ---------- 配置加载 ----------
|
||||||
@ -567,6 +567,38 @@ async function serveStatic(res: any, url: URL): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 会话记忆(本地 JSON 文件,按会话 id 持久化) ----------
|
||||||
|
const CONVERSATIONS_DIR = resolve("conversations");
|
||||||
|
function convFile(id: string): string {
|
||||||
|
const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
return resolve(CONVERSATIONS_DIR, safe + ".json");
|
||||||
|
}
|
||||||
|
function genConvId(): string {
|
||||||
|
return "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
}
|
||||||
|
function readConv(id: string): any {
|
||||||
|
try { return JSON.parse(readFileSync(convFile(id), "utf-8")); } catch { return null; }
|
||||||
|
}
|
||||||
|
function writeConv(id: string, data: any) {
|
||||||
|
mkdirSync(CONVERSATIONS_DIR, { recursive: true });
|
||||||
|
writeFileSync(convFile(id), JSON.stringify(data, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
function listConvs(): { id: string; title: string; updatedAt: number; count: number }[] {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CONVERSATIONS_DIR)) return [];
|
||||||
|
return readdirSync(CONVERSATIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".json"))
|
||||||
|
.map((f) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(readFileSync(resolve(CONVERSATIONS_DIR, f), "utf-8"));
|
||||||
|
return { id: d.id || f.replace(".json", ""), title: d.title || "对话", updatedAt: d.updatedAt || 0, count: (d.messages || []).length };
|
||||||
|
} catch { return null; }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- HTTP 服务 ----------
|
// ---------- HTTP 服务 ----------
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const url = new URL(req.url || "", `http://localhost:${config.PORT}`);
|
const url = new URL(req.url || "", `http://localhost:${config.PORT}`);
|
||||||
@ -631,6 +663,105 @@ const server = createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模型可用性测试
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/test_model") {
|
||||||
|
const urlLLM = config.BASE_URL.replace(/\/$/, "") + "/chat/completions";
|
||||||
|
const testBody: any = {
|
||||||
|
messages: [{ role: "user", content: "你好,请回复:测试通过" }],
|
||||||
|
stream: false,
|
||||||
|
max_tokens: 32,
|
||||||
|
temperature: config.TEMPERATURE,
|
||||||
|
};
|
||||||
|
if (config.MODEL) testBody.model = config.MODEL;
|
||||||
|
if (!config.API_KEY) {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: "未配置 API_KEY" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const r = await fetch(urlLLM, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` },
|
||||||
|
body: JSON.stringify(testBody),
|
||||||
|
});
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
const txt = await r.text().catch(() => "");
|
||||||
|
let reply = "";
|
||||||
|
let errMsg = "";
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(txt);
|
||||||
|
reply = j.choices?.[0]?.message?.content || "";
|
||||||
|
errMsg = j.error?.message || "";
|
||||||
|
} catch {}
|
||||||
|
if (r.ok) {
|
||||||
|
log("info", "model_test", { ok: true, model: config.MODEL, latencyMs: ms, reply: clip(reply, 100) });
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, model: config.MODEL, latencyMs: ms, reply: reply.slice(0, 200) }));
|
||||||
|
} else {
|
||||||
|
log("warn", "model_test", { ok: false, status: r.status, error: errMsg || txt.slice(0, 200) });
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, status: r.status, latencyMs: ms, error: errMsg || txt.slice(0, 200) }));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log("warn", "model_test", { ok: false, error: e.message });
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, latencyMs: Date.now() - t0, error: "无法连接模型服务: " + e.message }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话列表
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/conversations") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(listConvs()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 新建会话
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/conversations") {
|
||||||
|
const conv = { id: genConvId(), title: "新对话", messages: [], updatedAt: Date.now() };
|
||||||
|
writeConv(conv.id, conv);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(conv));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 单个会话:读取 / 保存 / 删除
|
||||||
|
const convMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)$/);
|
||||||
|
if (convMatch) {
|
||||||
|
const cid = convMatch[1];
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const conv = readConv(cid);
|
||||||
|
if (!conv) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; }
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(conv));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
try { unlinkSync(convFile(cid)); } catch {}
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "POST") {
|
||||||
|
let body = "";
|
||||||
|
for await (const c of req) body += c;
|
||||||
|
let payload: any;
|
||||||
|
try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||||
|
const old = readConv(cid) || { id: cid, messages: [] };
|
||||||
|
const msgs = Array.isArray(payload.messages) ? payload.messages : old.messages;
|
||||||
|
const first = msgs.find((x: any) => x.role === "user");
|
||||||
|
const title = typeof payload.title === "string" && payload.title
|
||||||
|
? payload.title
|
||||||
|
: old.title && old.title !== "新对话"
|
||||||
|
? old.title
|
||||||
|
: first ? String(first.content || "").slice(0, 20) : "新对话";
|
||||||
|
writeConv(cid, { id: cid, title, messages: msgs, updatedAt: Date.now() });
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, title }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 聊天:流式 SSE
|
// 聊天:流式 SSE
|
||||||
if (req.method === "POST" && url.pathname === "/api/chat") {
|
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||||
let body = "";
|
let body = "";
|
||||||
|
|||||||
135
src/App.vue
135
src/App.vue
@ -1,5 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app" :class="theme">
|
<div class="app" :class="theme">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<button class="new-chat" @click="newConversation">+ 新建对话</button>
|
||||||
|
<div class="conv-list">
|
||||||
|
<div
|
||||||
|
v-for="c in conversations"
|
||||||
|
:key="c.id"
|
||||||
|
class="conv-item"
|
||||||
|
:class="{ active: c.id === conversationId }"
|
||||||
|
@click="selectConversation(c.id)"
|
||||||
|
>
|
||||||
|
<span class="conv-title">{{ c.title }}</span>
|
||||||
|
<button class="del" title="删除对话" @click.stop="deleteConversation(c.id)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main-col">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="logo"><AssistantAvatar /></div>
|
<div class="logo"><AssistantAvatar /></div>
|
||||||
@ -39,6 +56,7 @@
|
|||||||
|
|
||||||
<SettingsModal v-if="showSettings" :api-base="API_BASE" @close="showSettings = false" />
|
<SettingsModal v-if="showSettings" :api-base="API_BASE" @close="showSettings = false" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -65,6 +83,8 @@ interface Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = ref<Msg[]>([]);
|
const messages = ref<Msg[]>([]);
|
||||||
|
const conversations = ref<{ id: string; title: string; updatedAt: number; count: number }[]>([]);
|
||||||
|
const conversationId = ref("");
|
||||||
const theme = ref<"light" | "dark">("light");
|
const theme = ref<"light" | "dark">("light");
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const chatEl = ref<HTMLElement | null>(null);
|
const chatEl = ref<HTMLElement | null>(null);
|
||||||
@ -134,6 +154,7 @@ async function consumeStream(response: Response, target: Msg) {
|
|||||||
async function send(text: string) {
|
async function send(text: string) {
|
||||||
const content = text.trim();
|
const content = text.trim();
|
||||||
if (!content || busy.value) return;
|
if (!content || busy.value) return;
|
||||||
|
if (!conversationId.value) await newConversation();
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
pushMsg("user", content);
|
pushMsg("user", content);
|
||||||
const assistant = pushMsg("assistant", "");
|
const assistant = pushMsg("assistant", "");
|
||||||
@ -151,10 +172,12 @@ async function send(text: string) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
await consumeStream(resp, assistant);
|
await consumeStream(resp, assistant);
|
||||||
|
await saveConversation();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
assistant.content = "⚠️ 请求失败:" + e.message;
|
assistant.content = "⚠️ 请求失败:" + e.message;
|
||||||
assistant.loading = false;
|
assistant.loading = false;
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
|
await saveConversation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,6 +197,7 @@ async function handleDecision(approved: boolean, m: Msg) {
|
|||||||
body: JSON.stringify({ approved, tool_call_id: toolCallId }),
|
body: JSON.stringify({ approved, tool_call_id: toolCallId }),
|
||||||
});
|
});
|
||||||
await consumeStream(resp, assistant);
|
await consumeStream(resp, assistant);
|
||||||
|
await saveConversation();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
assistant.content = "⚠️ 请求失败:" + e.message;
|
assistant.content = "⚠️ 请求失败:" + e.message;
|
||||||
assistant.loading = false;
|
assistant.loading = false;
|
||||||
@ -186,6 +210,67 @@ const onReject = (m: Msg) => handleDecision(false, m);
|
|||||||
|
|
||||||
function clearChat() {
|
function clearChat() {
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
|
saveConversation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 会话记忆 ----------
|
||||||
|
async function refreshList() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/api/conversations`);
|
||||||
|
conversations.value = await r.json();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newConversation() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/api/conversations`, { method: "POST" });
|
||||||
|
const conv = await r.json();
|
||||||
|
conversationId.value = conv.id;
|
||||||
|
messages.value = [];
|
||||||
|
await refreshList();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert("新建对话失败:" + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(id: string) {
|
||||||
|
if (id === conversationId.value) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/api/conversations/${id}`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const conv = await r.json();
|
||||||
|
conversationId.value = id;
|
||||||
|
messages.value = (conv.messages || []).map((x: any) => ({
|
||||||
|
id: idSeq++,
|
||||||
|
role: x.role === "assistant" ? "assistant" : "user",
|
||||||
|
content: x.content || "",
|
||||||
|
}));
|
||||||
|
await refreshList();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteConversation(id: string) {
|
||||||
|
await fetch(`${API_BASE}/api/conversations/${id}`, { method: "DELETE" });
|
||||||
|
if (id === conversationId.value) {
|
||||||
|
conversationId.value = "";
|
||||||
|
messages.value = [];
|
||||||
|
}
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConversation() {
|
||||||
|
if (!conversationId.value) return;
|
||||||
|
const msgs = messages.value
|
||||||
|
.filter((m) => !m.approval)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content || "" }));
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/api/conversations/${conversationId.value}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: msgs }),
|
||||||
|
});
|
||||||
|
await refreshList();
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
@ -194,12 +279,18 @@ function toggleTheme() {
|
|||||||
localStorage.setItem("theme", theme.value);
|
localStorage.setItem("theme", theme.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const saved = localStorage.getItem("theme");
|
const saved = localStorage.getItem("theme");
|
||||||
if (saved === "dark") {
|
if (saved === "dark") {
|
||||||
theme.value = "dark";
|
theme.value = "dark";
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
}
|
}
|
||||||
|
await refreshList();
|
||||||
|
if (conversations.value.length) {
|
||||||
|
await selectConversation(conversations.value[0].id);
|
||||||
|
} else {
|
||||||
|
await newConversation();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -251,13 +342,49 @@ body {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.app {
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
max-width: 880px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 250px; flex-shrink: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.new-chat {
|
||||||
|
padding: 9px; border: 1px solid var(--primary);
|
||||||
|
background: var(--primary); color: #fff; border-radius: 10px;
|
||||||
|
font-weight: 600; font-size: 14px; cursor: pointer; transition: 0.15s;
|
||||||
|
}
|
||||||
|
.new-chat:hover { background: var(--primary-hover); }
|
||||||
|
.conv-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.conv-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||||
|
padding: 8px 10px; border-radius: 9px; cursor: pointer;
|
||||||
|
background: var(--surface-2); transition: 0.15s;
|
||||||
|
}
|
||||||
|
.conv-item:hover { background: var(--border); }
|
||||||
|
.conv-item.active { background: var(--primary); color: #fff; }
|
||||||
|
.conv-title {
|
||||||
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.conv-item .del {
|
||||||
|
background: none; border: none; color: inherit; opacity: 0.6;
|
||||||
|
cursor: pointer; font-size: 12px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.conv-item .del:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.main-col {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
display: flex; flex-direction: column; height: 100%;
|
||||||
|
max-width: 880px; margin: 0 auto; width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -39,6 +39,16 @@
|
|||||||
<span>温度</span>
|
<span>温度</span>
|
||||||
<input v-model.number="form.TEMPERATURE" type="number" min="0" max="2" step="0.1" />
|
<input v-model.number="form.TEMPERATURE" type="number" min="0" max="2" step="0.1" />
|
||||||
</label>
|
</label>
|
||||||
|
<div class="row test-row">
|
||||||
|
<span></span>
|
||||||
|
<button class="test" :disabled="testing" @click="testModel">
|
||||||
|
{{ testing ? "测试中…" : "测试模型可用性" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="testMsg" class="test-result" :class="testOk ? 'ok' : 'err'">
|
||||||
|
<div>{{ testMsg }}</div>
|
||||||
|
<div v-if="testReply" class="test-reply">{{ testReply }}</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@ -95,6 +105,32 @@ const form = reactive({
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const msg = ref("");
|
const msg = ref("");
|
||||||
const ok = ref(true);
|
const ok = ref(true);
|
||||||
|
const testing = ref(false);
|
||||||
|
const testMsg = ref("");
|
||||||
|
const testOk = ref(true);
|
||||||
|
const testReply = ref("");
|
||||||
|
|
||||||
|
async function testModel() {
|
||||||
|
testing.value = true;
|
||||||
|
testMsg.value = "";
|
||||||
|
testReply.value = "";
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${props.apiBase}/api/test_model`, { method: "POST" });
|
||||||
|
const d = await r.json();
|
||||||
|
testOk.value = !!d.ok;
|
||||||
|
if (d.ok) {
|
||||||
|
testMsg.value = `✅ 模型可用 · ${d.model || "未知"} · 延迟 ${d.latencyMs}ms`;
|
||||||
|
testReply.value = d.reply || "";
|
||||||
|
} else {
|
||||||
|
testMsg.value = `❌ ${d.error || "测试失败"}` + (d.status ? ` · HTTP ${d.status}` : "");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
testOk.value = false;
|
||||||
|
testMsg.value = "❌ 请求失败:" + e.message;
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@ -190,4 +226,19 @@ h4 { margin: 0 0 10px; font-size: 13px; color: var(--text-dim); text-transform:
|
|||||||
.msg.err { color: #dc2626; }
|
.msg.err { color: #dc2626; }
|
||||||
.save { padding: 9px 20px; border: none; border-radius: 9px; background: var(--primary); color: #fff; font-weight: 600; cursor: pointer; }
|
.save { padding: 9px 20px; border: none; border-radius: 9px; background: var(--primary); color: #fff; font-weight: 600; cursor: pointer; }
|
||||||
.save:disabled { opacity: 0.6; cursor: not-allowed; }
|
.save:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.test-row { margin-top: 10px; }
|
||||||
|
.test {
|
||||||
|
flex: 0 0 auto; padding: 8px 14px; border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2); color: var(--text); border-radius: 8px;
|
||||||
|
font: inherit; font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.test:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.test:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.test-result {
|
||||||
|
margin: 6px 0 0 96px; padding: 8px 12px; border-radius: 8px;
|
||||||
|
font-size: 12px; line-height: 1.5; word-break: break-all;
|
||||||
|
}
|
||||||
|
.test-result.ok { background: rgba(22, 163, 74, 0.12); color: #16a34a; }
|
||||||
|
.test-result.err { background: rgba(220, 38, 38, 0.12); color: #dc2626; }
|
||||||
|
.test-reply { margin-top: 4px; color: var(--text-dim); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user