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:
236
web/js/app.js
Normal file
236
web/js/app.js
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user