Files
vision-tool/web/js/app.js
Geliebte a3bbf33358 feat: 重构为 Go Web 脚手架——在线图片识别 Web 服务 + vision-cli 独立工具
- 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>
2026-08-12 23:59:17 +08:00

237 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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;
});