- Gin + config.yaml + SQLite 后端骨架,JWT 登录 + 管理员初始化
- web/ 原生单页:拖拽多图上传 → Agnes 免费识别 → 结果复制
- HTTP API:/api/v1/vision/analyze 免鉴权 + 令牌桶限流,支持程序直调
- cmd/vision-cli 独立 CLI 工具(复用 service 层,不依赖 Web 服务)
- build.bat / build.sh 构建脚本,移除 mcp-server
- 统一响应格式 {code, message, data},CORS/JWT/限流中间件
Co-Authored-By: Claude <noreply@anthropic.com>
237 lines
7.3 KiB
JavaScript
237 lines
7.3 KiB
JavaScript
// ============================================================================
|
||
// Vision Tool — 在线免费图片识别(原生单页)
|
||
// ============================================================================
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
// ---------- API 封装 ----------
|
||
async function api(path, options = {}) {
|
||
const headers = options.headers || {};
|
||
if (!(options.body instanceof FormData)) {
|
||
headers["Content-Type"] = "application/json";
|
||
}
|
||
const token = localStorage.getItem("vt_token");
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
const resp = await fetch(path, { ...options, headers });
|
||
let data;
|
||
try {
|
||
data = await resp.json();
|
||
} catch {
|
||
throw new Error(`HTTP ${resp.status}: 响应解析失败`);
|
||
}
|
||
if (data.code !== 0) throw new Error(data.message || `HTTP ${resp.status}`);
|
||
return data.data;
|
||
}
|
||
|
||
// ---------- 状态 ----------
|
||
let selectedFiles = []; // 待识别的图片文件
|
||
let analyzing = false;
|
||
|
||
// ---------- 顶栏账号区 ----------
|
||
async function refreshAccount() {
|
||
const area = $("accountArea");
|
||
area.innerHTML = "";
|
||
|
||
const { initialized } = await api("/api/v1/admin/check");
|
||
|
||
if (!initialized) {
|
||
const btn = document.createElement("button");
|
||
btn.className = "btn btn-primary";
|
||
btn.textContent = "初始化管理员";
|
||
btn.onclick = () => showModal("initModal");
|
||
area.appendChild(btn);
|
||
return;
|
||
}
|
||
|
||
const token = localStorage.getItem("vt_token");
|
||
if (!token) {
|
||
const btn = document.createElement("button");
|
||
btn.className = "btn btn-ghost";
|
||
btn.textContent = "登录";
|
||
btn.onclick = () => showModal("loginModal");
|
||
area.appendChild(btn);
|
||
return;
|
||
}
|
||
|
||
// 校验 token 有效性
|
||
try {
|
||
const user = await api("/api/v1/user/profile");
|
||
const chip = document.createElement("span");
|
||
chip.className = "user-chip";
|
||
chip.textContent = `👤 ${user.username}`;
|
||
area.appendChild(chip);
|
||
const logout = document.createElement("button");
|
||
logout.className = "btn btn-ghost";
|
||
logout.textContent = "退出";
|
||
logout.onclick = () => {
|
||
localStorage.removeItem("vt_token");
|
||
refreshAccount();
|
||
};
|
||
area.appendChild(logout);
|
||
} catch {
|
||
localStorage.removeItem("vt_token");
|
||
refreshAccount();
|
||
}
|
||
}
|
||
|
||
// ---------- 模态框 ----------
|
||
function showModal(id) {
|
||
$(id).hidden = false;
|
||
$("initError").textContent = "";
|
||
$("loginError").textContent = "";
|
||
}
|
||
function hideModal(id) { $(id).hidden = true; }
|
||
$("initModal").addEventListener("click", (e) => { if (e.target === e.currentTarget) hideModal("initModal"); });
|
||
$("loginModal").addEventListener("click", (e) => { if (e.target === e.currentTarget) hideModal("loginModal"); });
|
||
|
||
// 初始化管理员
|
||
$("initBtn").addEventListener("click", async () => {
|
||
const username = $("initUsername").value.trim();
|
||
const password = $("initPassword").value;
|
||
const password2 = $("initPassword2").value;
|
||
if (!username || password.length < 6) {
|
||
$("initError").textContent = "用户名不能为空,密码至少 6 位";
|
||
return;
|
||
}
|
||
if (password !== password2) {
|
||
$("initError").textContent = "两次输入的密码不一致";
|
||
return;
|
||
}
|
||
try {
|
||
await api("/api/v1/admin/init", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
hideModal("initModal");
|
||
await login(username, password);
|
||
refreshAccount();
|
||
} catch (err) {
|
||
$("initError").textContent = err.message;
|
||
}
|
||
});
|
||
|
||
// 登录
|
||
$("loginBtn").addEventListener("click", async () => {
|
||
const username = $("loginUsername").value.trim();
|
||
const password = $("loginPassword").value;
|
||
if (!username || !password) {
|
||
$("loginError").textContent = "请输入用户名和密码";
|
||
return;
|
||
}
|
||
try {
|
||
await login(username, password);
|
||
hideModal("loginModal");
|
||
refreshAccount();
|
||
} catch (err) {
|
||
$("loginError").textContent = err.message;
|
||
}
|
||
});
|
||
|
||
async function login(username, password) {
|
||
const data = await api("/api/v1/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
localStorage.setItem("vt_token", data.token);
|
||
}
|
||
|
||
// ---------- 图片选择与预览 ----------
|
||
const dropzone = $("dropzone");
|
||
const fileInput = $("fileInput");
|
||
|
||
dropzone.addEventListener("click", () => fileInput.click());
|
||
dropzone.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("dragover"); });
|
||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("dragover"));
|
||
dropzone.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
dropzone.classList.remove("dragover");
|
||
addFiles(e.dataTransfer.files);
|
||
});
|
||
fileInput.addEventListener("change", () => { addFiles(fileInput.files); fileInput.value = ""; });
|
||
|
||
function addFiles(files) {
|
||
for (const f of files) {
|
||
if (!f.type.startsWith("image/")) continue;
|
||
if (f.size > 10 * 1024 * 1024) { alert(`图片过大(>10MB): ${f.name}`); continue; }
|
||
selectedFiles.push(f);
|
||
}
|
||
renderPreviews();
|
||
}
|
||
|
||
function renderPreviews() {
|
||
const list = $("previewList");
|
||
list.innerHTML = "";
|
||
selectedFiles.forEach((f, i) => {
|
||
const item = document.createElement("div");
|
||
item.className = "preview-item";
|
||
const img = document.createElement("img");
|
||
img.src = URL.createObjectURL(f);
|
||
img.alt = f.name;
|
||
const rm = document.createElement("button");
|
||
rm.className = "remove";
|
||
rm.textContent = "×";
|
||
rm.onclick = () => {
|
||
selectedFiles.splice(i, 1);
|
||
renderPreviews();
|
||
};
|
||
item.append(img, rm);
|
||
list.appendChild(item);
|
||
});
|
||
$("analyzeBtn").disabled = selectedFiles.length === 0 || analyzing;
|
||
}
|
||
|
||
// ---------- 识别 ----------
|
||
$("analyzeBtn").addEventListener("click", analyze);
|
||
|
||
async function analyze() {
|
||
if (analyzing || selectedFiles.length === 0) return;
|
||
analyzing = true;
|
||
renderPreviews();
|
||
|
||
const btn = $("analyzeBtn");
|
||
btn.innerHTML = '<span class="loading"><span class="spinner"></span>识别中…</span>';
|
||
const resultCard = $("resultCard");
|
||
resultCard.hidden = false;
|
||
$("resultBody").textContent = "正在调用免费视觉模型,请稍候(最长约 2 分钟)…";
|
||
$("resultMeta").textContent = "";
|
||
|
||
const fd = new FormData();
|
||
selectedFiles.forEach((f) => fd.append("images", f, f.name));
|
||
const prompt = $("promptInput").value.trim();
|
||
if (prompt) fd.append("prompt", prompt);
|
||
|
||
try {
|
||
const data = await api("/api/v1/vision/analyze", { method: "POST", body: fd });
|
||
$("resultBody").textContent = data.result;
|
||
if (data.usage) {
|
||
$("resultMeta").textContent =
|
||
`token 用量: prompt ${data.usage.prompt_tokens} / completion ${data.usage.completion_tokens} / total ${data.usage.total_tokens}`;
|
||
}
|
||
} catch (err) {
|
||
$("resultBody").textContent = "❌ 识别失败: " + err.message;
|
||
} finally {
|
||
analyzing = false;
|
||
btn.textContent = "开始识别";
|
||
renderPreviews();
|
||
}
|
||
}
|
||
|
||
// 复制结果
|
||
$("copyBtn").addEventListener("click", async () => {
|
||
const text = $("resultBody").textContent;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
$("copyBtn").textContent = "已复制 ✓";
|
||
setTimeout(() => ($("copyBtn").textContent = "复制"), 1500);
|
||
} catch {
|
||
alert("复制失败,请手动选择文本");
|
||
}
|
||
});
|
||
|
||
// ---------- 启动 ----------
|
||
refreshAccount().catch((err) => {
|
||
// check 失败(服务未启动等),提示
|
||
document.querySelector(".brand p").textContent = "⚠️ 服务连接失败: " + err.message;
|
||
});
|