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>
This commit is contained in:
2026-08-12 23:59:17 +08:00
parent 8bf77301bd
commit a3bbf33358
25 changed files with 1902 additions and 798 deletions

236
web/js/app.js Normal file
View File

@@ -0,0 +1,236 @@
// ============================================================================
// 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;
});