feat: 认证简化为固定进入密钥 + 页面增强(API 文档/主题切换/版权署名)

- 移除管理员初始化/登录/JWT 流程,改为固定进入密钥(19735500),
  首次启动自动初始化到数据库 settings 表(可改库更新)
- analyze 接口支持可选密钥鉴权:X-Access-Key 请求头或 access_key 字段,
  带密钥强制校验(错误 401),不带密钥保持免鉴权
- 解锁后顶栏"复制 API 文档"按钮:以当前访问 URL 为基础地址动态生成
  Markdown 文档(免鉴权/带密钥两种 curl 示例,供智能体直接调用)
- 白昼/黑夜主题切换:CSS 变量驱动,localStorage 持久化,默认跟随系统
- 修复 hidden 属性被 display:flex 覆盖导致解锁层无法隐藏的问题
- 页面去除"免费/Agnes"文案,页脚署名青梧映星软件开发工作室
- build.bat/build.sh 简化为 Linux 交叉编译(CGO_ENABLED=0)
- 依赖精简:移除 golang-jwt/bcrypt,直接依赖仅 gin/sqlite/gorm/yaml

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-13 01:21:18 +08:00
parent a3bbf33358
commit f9cdeb5d64
20 changed files with 341 additions and 465 deletions

View File

@@ -12,7 +12,7 @@
- 🖼️ 多图对比、base64 直传(无需图床/公网 URL) - 🖼️ 多图对比、base64 直传(无需图床/公网 URL)
- 🔁 指数退避自动重试(429/5xx:2s → 5s → 8s → 11s) - 🔁 指数退避自动重试(429/5xx:2s → 5s → 8s → 11s)
- 🔧 配置:命令行 > 环境变量 > config.yaml > 内置默认 - 🔧 配置:命令行 > 环境变量 > config.yaml > 内置默认
- 🔒 JWT 登录 + 管理员初始化(脚手架模板能力) - 🔑 固定进入密钥(默认 `19735500`),密钥初始化到数据库,页面解锁后使用
## 🚀 快速开始 ## 🚀 快速开始
@@ -32,7 +32,7 @@ go run . # 或 ./vision-tool.exe
# 打开 http://localhost:8080 # 打开 http://localhost:8080
``` ```
1. 首次访问:初始化管理员账号(右上角) 1. 输入进入密钥(默认 `19735500`,存于数据库,可改库更新)解锁页面
2. 拖拽或点击选择图片(可多张) 2. 拖拽或点击选择图片(可多张)
3. (可选)输入自定义提问 3. (可选)输入自定义提问
4. 点击「开始识别」→ 得到识别结果,可一键复制 4. 点击「开始识别」→ 得到识别结果,可一键复制
@@ -57,14 +57,21 @@ Claude Code 在线调用方式:粘贴图片后执行
统一响应格式:`{code, message, data}`(`code=0` 为成功) 统一响应格式:`{code, message, data}`(`code=0` 为成功)
### 在线图片识别(免鉴权 + 限流) ### 在线图片识别(免鉴权或带密钥,限流防刷)
```bash ```bash
# 免鉴权调用
curl -F "images=@a.png" -F "images=@b.png" \ curl -F "images=@a.png" -F "images=@b.png" \
-F "prompt=比较这两张图的异同" \ -F "prompt=比较这两张图的异同" \
http://localhost:8080/api/v1/vision/analyze http://localhost:8080/api/v1/vision/analyze
# 带密钥调用(可选,密钥错误返回 401)
curl -H "X-Access-Key: 19735500" -F "images=@photo.png" \
http://localhost:8080/api/v1/vision/analyze
``` ```
鉴权规则:请求头 `X-Access-Key`(或表单字段 `access_key`)提供密钥时强制校验,不提供则放行。
响应: 响应:
```json ```json
@@ -84,11 +91,8 @@ curl -F "images=@a.png" -F "images=@b.png" \
| 接口 | 方法 | 说明 | | 接口 | 方法 | 说明 |
|---|---|---| |---|---|---|
| `/api/health` | GET | 健康检查 | | `/api/health` | GET | 健康检查 |
| `/api/v1/admin/check` | GET | 管理员是否已初始化 | | `/api/v1/auth/verify` | POST | 校验进入密钥 `{key}` → `{ok}`(密钥存于数据库) |
| `/api/v1/admin/init` | POST | 初始化管理员 `{username, password}` | | `/api/v1/vision/analyze` | POST | 图片识别(multipart:`images` × 1-N + 可选 `prompt`;可选 `X-Access-Key` 密钥鉴权) |
| `/api/v1/auth/login` | POST | 登录 `{username, password}` → `{token}` |
| `/api/v1/user/profile` | GET | 当前用户信息(需 `Authorization: Bearer <token>`) |
| `/api/v1/vision/analyze` | POST | 图片识别(multipart:`images` × 1-N + 可选 `prompt`) |
| `/uploads/*` | GET | 上传图片静态访问 | | `/uploads/*` | GET | 上传图片静态访问 |
## ⚙️ 配置(config.yaml) ## ⚙️ 配置(config.yaml)
@@ -100,9 +104,8 @@ database:
driver: sqlite # 当前仅 sqlite(pure-go,无需 CGO) driver: sqlite # 当前仅 sqlite(pure-go,无需 CGO)
path: ./data/app.db path: ./data/app.db
auto_migrate: true auto_migrate: true
jwt: auth:
secret: "change-me-in-production" access_key: "19735500" # 进入密钥,首次启动初始化到数据库(可改库更新)
expire_hours: 720
upload: upload:
path: uploads path: uploads
max_size: 10485760 # 10MB max_size: 10485760 # 10MB
@@ -125,12 +128,11 @@ vision-tool/
│ └── vision-cli/ # 独立 CLI 工具(打包为单个二进制) │ └── vision-cli/ # 独立 CLI 工具(打包为单个二进制)
├── internal/ ├── internal/
│ ├── config/ # 配置加载(yaml + 环境变量覆盖) │ ├── config/ # 配置加载(yaml + 环境变量覆盖)
│ ├── database/ # GORM + SQLite 初始化 + AutoMigrate │ ├── database/ # GORM + SQLite 初始化 + AutoMigrate + 密钥种子数据
│ ├── model/ # 数据模型 + 统一响应格式 │ ├── model/ # 数据模型 + 统一响应格式
│ ├── repository/ # 数据访问层 │ ├── service/ # 业务逻辑(密钥校验 / vision 免费模型调用)
│ ├── service/ # 业务逻辑(auth / vision 免费模型调用)
│ ├── handler/ # HTTP 处理器 │ ├── handler/ # HTTP 处理器
│ ├── middleware/ # CORS / JWT / 令牌桶限流 │ ├── middleware/ # CORS / 令牌桶限流
│ └── router/ # 路由注册 + 前端内嵌 + SPA 回退 │ └── router/ # 路由注册 + 前端内嵌 + SPA 回退
└── web/ # 原生单页(在线图片识别,内嵌进二进制) └── web/ # 原生单页(在线图片识别,内嵌进二进制)
├── index.html ├── index.html

View File

@@ -1,10 +1,8 @@
@echo off @echo off
rem ============================================ rem Build Linux binaries: vision-tool / vision-cli
rem vision-tool 构建脚本(Windows 本地) set GOOS=linux
rem 产物:vision-tool.exe(Web 服务) / vision-cli.exe(CLI 工具) set GOARCH=amd64
rem ============================================
set CGO_ENABLED=0 set CGO_ENABLED=0
go build -o vision-tool.exe . go build -o vision-tool .
go build -o vision-cli.exe ./cmd/vision-cli go build -o vision-cli ./cmd/vision-cli
echo. echo Build done: vision-tool / vision-cli (linux/amd64)
echo Build done: vision-tool.exe / vision-cli.exe

View File

@@ -1,10 +1,5 @@
#!/bin/bash #!/bin/bash
# ============================================ # 构建 Linux 可执行文件(vision-tool 服务 / vision-cli 工具)
# vision-tool 构建脚本(Linux 交叉编译,用于部署) GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o vision-tool .
# 产物:vision-tool(Web 服务) / vision-cli(CLI 工具) GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o vision-cli ./cmd/vision-cli
# ============================================
set -e
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o vision-tool .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o vision-cli ./cmd/vision-cli
chmod +x vision-tool vision-cli
echo "Build done: vision-tool / vision-cli (linux/amd64)" echo "Build done: vision-tool / vision-cli (linux/amd64)"

View File

@@ -8,10 +8,9 @@ database:
path: ./data/app.db path: ./data/app.db
auto_migrate: true # 启动自动建表,生产环境建议 false auto_migrate: true # 启动自动建表,生产环境建议 false
# ========== JWT 认证 ========== # ========== 访问密钥 ==========
jwt: auth:
secret: "change-me-in-production" access_key: "19735500" # 进入密钥,首次启动初始化到数据库
expire_hours: 720 # Token 有效期(小时)
# ========== 文件上传 ========== # ========== 文件上传 ==========
upload: upload:

3
go.mod
View File

@@ -5,8 +5,6 @@ go 1.25.0
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.55.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.31.2 gorm.io/gorm v1.31.2
) )
@@ -42,6 +40,7 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.41.0 // indirect

2
go.sum
View File

@@ -33,8 +33,6 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=

View File

@@ -11,7 +11,7 @@ import (
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"` Database DatabaseConfig `yaml:"database"`
JWT JWTConfig `yaml:"jwt"` Auth AuthConfig `yaml:"auth"`
Upload UploadConfig `yaml:"upload"` Upload UploadConfig `yaml:"upload"`
AI AIConfig `yaml:"ai"` AI AIConfig `yaml:"ai"`
Env EnvConfig `yaml:"env"` Env EnvConfig `yaml:"env"`
@@ -27,9 +27,8 @@ type DatabaseConfig struct {
AutoMigrate bool `yaml:"auto_migrate"` AutoMigrate bool `yaml:"auto_migrate"`
} }
type JWTConfig struct { type AuthConfig struct {
Secret string `yaml:"secret"` AccessKey string `yaml:"access_key"` // 进入密钥,首次启动初始化到数据库
ExpireHours int `yaml:"expire_hours"`
} }
type UploadConfig struct { type UploadConfig struct {
@@ -56,7 +55,7 @@ func DefaultConfig() *Config {
return &Config{ return &Config{
Server: ServerConfig{Port: 8080}, Server: ServerConfig{Port: 8080},
Database: DatabaseConfig{Driver: "sqlite", Path: "./data/app.db", AutoMigrate: true}, Database: DatabaseConfig{Driver: "sqlite", Path: "./data/app.db", AutoMigrate: true},
JWT: JWTConfig{Secret: "change-me-in-production", ExpireHours: 720}, Auth: AuthConfig{AccessKey: "19735500"},
Upload: UploadConfig{Path: "uploads", MaxSize: 10 * 1024 * 1024}, Upload: UploadConfig{Path: "uploads", MaxSize: 10 * 1024 * 1024},
AI: AIConfig{ AI: AIConfig{
APIKey: hardcodedAPIKey, APIKey: hardcodedAPIKey,

View File

@@ -13,9 +13,10 @@ import (
"vision-tool/internal/model" "vision-tool/internal/model"
) )
// Init 初始化数据库连接并执行 AutoMigrate。 // Init 初始化数据库连接,执行 AutoMigrate,并写入初始化数据(进入密钥)。
// 使用 pure-go 的 glebarez/sqlite 驱动,CGO_ENABLED=0 下可编译运行。 // 使用 pure-go 的 glebarez/sqlite 驱动,CGO_ENABLED=0 下可编译运行。
func Init(cfg *config.DatabaseConfig) (*gorm.DB, error) { // seedAccessKey 为空时跳过密钥初始化。
func Init(cfg *config.DatabaseConfig, seedAccessKey string) (*gorm.DB, error) {
var db *gorm.DB var db *gorm.DB
var err error var err error
@@ -39,10 +40,25 @@ func Init(cfg *config.DatabaseConfig) (*gorm.DB, error) {
// auto_migrate:自动建表/更新表结构,生产环境建议手动管理 // auto_migrate:自动建表/更新表结构,生产环境建议手动管理
if cfg.AutoMigrate { if cfg.AutoMigrate {
if err := db.AutoMigrate(&model.User{}); err != nil { if err := db.AutoMigrate(&model.Setting{}); err != nil {
return nil, fmt.Errorf("自动建表: %w", err) return nil, fmt.Errorf("自动建表: %w", err)
} }
} }
// init_data:首次启动写入默认进入密钥(仅当记录不存在时)
if seedAccessKey != "" {
var count int64
if err := db.Model(&model.Setting{}).
Where("key = ?", model.KeyAccessKey).
Count(&count).Error; err != nil {
return nil, fmt.Errorf("检查配置: %w", err)
}
if count == 0 {
if err := db.Create(&model.Setting{Key: model.KeyAccessKey, Value: seedAccessKey}).Error; err != nil {
return nil, fmt.Errorf("初始化进入密钥: %w", err)
}
}
}
return db, nil return db, nil
} }

View File

@@ -1,7 +1,6 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -10,79 +9,27 @@ import (
"vision-tool/internal/service" "vision-tool/internal/service"
) )
// AuthHandler HTTP 处理器:管理员初始化 / 登录 / 用户信息。 // AuthHandler HTTP 处理器:进入密钥校验。
type AuthHandler struct { type AuthHandler struct {
svc *service.AuthService svc *service.AccessService
} }
func NewAuthHandler(svc *service.AuthService) *AuthHandler { func NewAuthHandler(svc *service.AccessService) *AuthHandler {
return &AuthHandler{svc: svc} return &AuthHandler{svc: svc}
} }
type initRequest struct { // Verify POST /api/v1/auth/verify — 校验进入密钥(固定密钥,存于数据库)
Username string `json:"username" binding:"required,min=2,max=32"` func (h *AuthHandler) Verify(c *gin.Context) {
Password string `json:"password" binding:"required,min=6,max=64"` var req struct {
} Key string `json:"key" binding:"required"`
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Check GET /api/v1/admin/check — 管理员是否已初始化
func (h *AuthHandler) Check(c *gin.Context) {
initialized, err := h.svc.CheckAdmin()
if err != nil {
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
return
} }
c.JSON(http.StatusOK, model.OK(gin.H{"initialized": initialized}))
}
// Init POST /api/v1/admin/init — 初始化管理员(仅首次)
func (h *AuthHandler) Init(c *gin.Context) {
var req initRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "用户名 2-32 位,密码至少 6 位")) c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请输入进入密钥"))
return return
} }
if err := h.svc.InitAdmin(req.Username, req.Password); err != nil { if !h.svc.VerifyKey(req.Key) {
if errors.Is(err, service.ErrAdminExists) { c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "密钥错误"))
c.JSON(http.StatusConflict, model.Err(model.CodeConflict, err.Error()))
return return
} }
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error())) c.JSON(http.StatusOK, model.OK(gin.H{"ok": true}))
return
}
c.JSON(http.StatusOK, model.OK(gin.H{"message": "管理员初始化成功"}))
}
// Login POST /api/v1/auth/login — 登录获取 JWT token
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请输入用户名和密码"))
return
}
token, err := h.svc.Login(req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, err.Error()))
return
}
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(gin.H{"token": token}))
}
// Profile GET /api/v1/user/profile — 当前用户信息(JWT 保护)
func (h *AuthHandler) Profile(c *gin.Context) {
userID := c.GetUint("user_id")
user, err := h.svc.Profile(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(user))
} }

View File

@@ -20,11 +20,12 @@ import (
// VisionHandler HTTP 处理器:在线图片识别。 // VisionHandler HTTP 处理器:在线图片识别。
type VisionHandler struct { type VisionHandler struct {
svc *service.VisionService svc *service.VisionService
access *service.AccessService
upload config.UploadConfig upload config.UploadConfig
} }
func NewVisionHandler(svc *service.VisionService, upload config.UploadConfig) *VisionHandler { func NewVisionHandler(svc *service.VisionService, access *service.AccessService, upload config.UploadConfig) *VisionHandler {
return &VisionHandler{svc: svc, upload: upload} return &VisionHandler{svc: svc, access: access, upload: upload}
} }
// Analyze POST /api/v1/vision/analyze — 上传 1-N 张图片并调用免费视觉模型识别。 // Analyze POST /api/v1/vision/analyze — 上传 1-N 张图片并调用免费视觉模型识别。
@@ -34,8 +35,19 @@ func NewVisionHandler(svc *service.VisionService, upload config.UploadConfig) *V
// images 图片文件(可多个,支持 png/jpg/gif/webp/bmp/tiff) // images 图片文件(可多个,支持 png/jpg/gif/webp/bmp/tiff)
// prompt 可选,自定义分析提问(默认:结构化描述图片内容) // prompt 可选,自定义分析提问(默认:结构化描述图片内容)
// //
// 免鉴权 + 限流,方便 curl / Claude Code 直接调用。 // 鉴权(可选):提供 X-Access-Key 请求头 或 access_key 表单字段时校验进入密钥,
// 密钥错误返回 401;不提供时保持免鉴权调用,方便 curl / Claude Code 直接使用。
func (h *VisionHandler) Analyze(c *gin.Context) { func (h *VisionHandler) Analyze(c *gin.Context) {
// 可选密钥校验:带密钥必须正确
key := c.GetHeader("X-Access-Key")
if key == "" {
key = c.PostForm("access_key")
}
if key != "" && !h.access.VerifyKey(key) {
c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "密钥错误"))
return
}
form, err := c.MultipartForm() form, err := c.MultipartForm()
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请使用 multipart/form-data 上传图片")) c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请使用 multipart/form-data 上传图片"))

View File

@@ -3,12 +3,10 @@ package middleware
import ( import (
"math" "math"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"vision-tool/internal/model" "vision-tool/internal/model"
) )
@@ -27,44 +25,6 @@ func CORS() gin.HandlerFunc {
} }
} }
// ============================================================================
// JWT 鉴权
// ============================================================================
// Claims JWT 载荷。
type Claims struct {
UserID uint `json:"uid"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// JWTAuth 校验 Authorization: Bearer <token>,通过后将用户信息写入 context。
func JWTAuth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
tokenStr, ok := strings.CutPrefix(header, "Bearer ")
if !ok || tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "未登录"))
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "登录已过期,请重新登录"))
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
// ============================================================================ // ============================================================================
// 内存令牌桶限流(全局限流,防匿名刷接口) // 内存令牌桶限流(全局限流,防匿名刷接口)
// ============================================================================ // ============================================================================

View File

@@ -2,13 +2,17 @@ package model
import "time" import "time"
// User 管理员账号。 // 配置键
type User struct { const (
ID uint `gorm:"primaryKey" json:"id"` KeyAccessKey = "access_key" // 页面进入密钥
Username string `gorm:"uniqueIndex;size:64" json:"username"` )
PasswordHash string `gorm:"size:255" json:"-"`
Role string `gorm:"size:32" json:"role"` // Setting 应用配置(key-value),初始数据在启动时写入数据库。
type Setting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
Value string `gorm:"size:255" json:"value"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// Response 统一响应格式 {code, message, data}。 // Response 统一响应格式 {code, message, data}。

View File

@@ -1,45 +0,0 @@
package repository
import (
"gorm.io/gorm"
"vision-tool/internal/model"
)
// UserRepository 数据访问层:用户表 CRUD。
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
// Count 返回用户总数(用于判断管理员是否已初始化)。
func (r *UserRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&model.User{}).Count(&count).Error
return count, err
}
func (r *UserRepository) Create(user *model.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) FindByUsername(username string) (*model.User, error) {
var user model.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) FindByID(id uint) (*model.User, error) {
var user model.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}

View File

@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -14,23 +13,20 @@ import (
"vision-tool/internal/handler" "vision-tool/internal/handler"
"vision-tool/internal/middleware" "vision-tool/internal/middleware"
"vision-tool/internal/model" "vision-tool/internal/model"
"vision-tool/internal/repository"
"vision-tool/internal/service" "vision-tool/internal/service"
) )
// Setup 构建路由,并完成分层依赖注入: // Setup 构建路由,并完成分层依赖注入。
// db → repository → service → handler → router。
// webFS 为 //go:embed web 的嵌入文件系统,用于内嵌前端。 // webFS 为 //go:embed web 的嵌入文件系统,用于内嵌前端。
func Setup(cfg *config.Config, db *gorm.DB, webFS fs.FS) *gin.Engine { func Setup(cfg *config.Config, db *gorm.DB, webFS fs.FS) *gin.Engine {
r := gin.New() r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS()) r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
// ---------- 分层依赖注入 ---------- // ---------- 分层依赖注入 ----------
userRepo := repository.NewUserRepository(db) accessSvc := service.NewAccessService(db)
authSvc := service.NewAuthService(userRepo, cfg.JWT.Secret, time.Duration(cfg.JWT.ExpireHours)*time.Hour)
visionSvc := service.NewVisionService(&cfg.AI) visionSvc := service.NewVisionService(&cfg.AI)
authH := handler.NewAuthHandler(authSvc) authH := handler.NewAuthHandler(accessSvc)
visionH := handler.NewVisionHandler(visionSvc, cfg.Upload) visionH := handler.NewVisionHandler(visionSvc, accessSvc, cfg.Upload)
// 识别接口免鉴权,用令牌桶限流防刷 // 识别接口免鉴权,用令牌桶限流防刷
analyzeLimiter := middleware.NewRateLimiter(10, 20) analyzeLimiter := middleware.NewRateLimiter(10, 20)
@@ -43,13 +39,8 @@ func Setup(cfg *config.Config, db *gorm.DB, webFS fs.FS) *gin.Engine {
v1 := api.Group("/v1") v1 := api.Group("/v1")
{ {
// 管理员初始化(方式 A:check + init) // 进入密钥校验(固定密钥,初始化在数据库)
v1.GET("/admin/check", authH.Check) v1.POST("/auth/verify", authH.Verify)
v1.POST("/admin/init", authH.Init)
// 认证
v1.POST("/auth/login", authH.Login)
v1.GET("/user/profile", middleware.JWTAuth(cfg.JWT.Secret), authH.Profile)
// 在线图片识别(免鉴权 + 限流) // 在线图片识别(免鉴权 + 限流)
v1.POST("/vision/analyze", analyzeLimiter.Middleware(), visionH.Analyze) v1.POST("/vision/analyze", analyzeLimiter.Middleware(), visionH.Analyze)

View File

@@ -0,0 +1,26 @@
package service
import (
"gorm.io/gorm"
"vision-tool/internal/model"
)
// AccessService 访问密钥校验。密钥为固定进入密钥,
// 首次启动由 database.Init 初始化到数据库(可后续直接改库)。
type AccessService struct {
db *gorm.DB
}
func NewAccessService(db *gorm.DB) *AccessService {
return &AccessService{db: db}
}
// VerifyKey 校验进入密钥是否与数据库中的值一致。
func (s *AccessService) VerifyKey(key string) bool {
var setting model.Setting
if err := s.db.Where("key = ?", model.KeyAccessKey).First(&setting).Error; err != nil {
return false
}
return setting.Value == key
}

View File

@@ -1,89 +0,0 @@
package service
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"vision-tool/internal/middleware"
"vision-tool/internal/model"
"vision-tool/internal/repository"
)
var (
ErrAdminExists = errors.New("管理员已初始化,禁止重复初始化")
ErrInvalidCredentials = errors.New("用户名或密码错误")
)
// AuthService 认证业务:管理员初始化 / 登录 / 用户信息。
type AuthService struct {
repo *repository.UserRepository
secret string
expire time.Duration
}
func NewAuthService(repo *repository.UserRepository, secret string, expire time.Duration) *AuthService {
return &AuthService{repo: repo, secret: secret, expire: expire}
}
// CheckAdmin 判断是否已存在管理员(方式 A:check + init)。
func (s *AuthService) CheckAdmin() (bool, error) {
count, err := s.repo.Count()
return count > 0, err
}
// InitAdmin 初始化管理员,已存在时拒绝。
func (s *AuthService) InitAdmin(username, password string) error {
initialized, err := s.CheckAdmin()
if err != nil {
return err
}
if initialized {
return ErrAdminExists
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := &model.User{
Username: username,
PasswordHash: string(hash),
Role: "admin",
}
return s.repo.Create(user)
}
// Login 校验账号密码,签发 JWT token。
func (s *AuthService) Login(username, password string) (string, error) {
user, err := s.repo.FindByUsername(username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", ErrInvalidCredentials
}
return "", err
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return "", ErrInvalidCredentials
}
claims := middleware.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.expire)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.secret))
}
// Profile 按 ID 返回用户信息。
func (s *AuthService) Profile(userID uint) (*model.User, error) {
return s.repo.FindByID(userID)
}

View File

@@ -26,8 +26,8 @@ func main() {
log.Fatalf("加载配置失败: %v", err) log.Fatalf("加载配置失败: %v", err)
} }
// 2. 初始化数据库 // 2. 初始化数据库(首次启动自动写入进入密钥)
db, err := database.Init(&cfg.Database) db, err := database.Init(&cfg.Database, cfg.Auth.AccessKey)
if err != nil { if err != nil {
log.Fatalf("初始化数据库失败: %v", err) log.Fatalf("初始化数据库失败: %v", err)
} }

View File

@@ -1,6 +1,9 @@
/* ===== 基础 ===== */ /* ===== 基础 ===== */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
/* 保证 hidden 属性始终生效(防止被 display:flex 等样式覆盖) */
[hidden] { display: none !important; }
:root { :root {
--primary: #4f6ef7; --primary: #4f6ef7;
--primary-dark: #3a55d8; --primary-dark: #3a55d8;
@@ -11,6 +14,32 @@
--border: #e5e9f2; --border: #e5e9f2;
--danger: #e5484d; --danger: #e5484d;
--radius: 12px; --radius: 12px;
/* 组件级颜色 */
--chip-bg: #eef1fe;
--dropzone-border: #c7d2fe;
--dropzone-bg: #fafbff;
--dropzone-hover: #eef1fe;
--result-bg: #f8f9fc;
--unlock-bg-1: #eef1fe;
--unlock-bg-2: #f4f6fb;
}
/* 黑夜模式 */
[data-theme="dark"] {
--primary: #6d8bff;
--primary-dark: #8aa4ff;
--bg: #0f1524;
--card: #1a2234;
--text: #e6e9f0;
--text-light: #9aa3b5;
--border: #2b3548;
--chip-bg: rgba(109, 139, 255, .18);
--dropzone-border: #3a4760;
--dropzone-bg: #141c2e;
--dropzone-hover: #1e2a42;
--result-bg: #121a2b;
--unlock-bg-1: #0f1524;
--unlock-bg-2: #1a2234;
} }
body { body {
@@ -39,13 +68,16 @@ body {
.account { display: flex; align-items: center; gap: 10px; font-size: 14px; } .account { display: flex; align-items: center; gap: 10px; font-size: 14px; }
.account .user-chip { .account .user-chip {
background: #eef1fe; background: var(--chip-bg);
color: var(--primary-dark); color: var(--primary);
padding: 6px 12px; padding: 6px 12px;
border-radius: 999px; border-radius: 999px;
font-weight: 600; font-weight: 600;
} }
/* 顶栏常驻于解锁层之上,主题切换随时可用 */
.topbar { position: relative; z-index: 150; }
/* ===== 布局 ===== */ /* ===== 布局 ===== */
.container { .container {
flex: 1; flex: 1;
@@ -68,15 +100,15 @@ body {
/* ===== 上传区 ===== */ /* ===== 上传区 ===== */
.dropzone { .dropzone {
border: 2px dashed #c7d2fe; border: 2px dashed var(--dropzone-border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 40px 20px; padding: 40px 20px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all .15s ease; transition: all .15s ease;
background: #fafbff; background: var(--dropzone-bg);
} }
.dropzone:hover, .dropzone.dragover { border-color: var(--primary); background: #eef1fe; } .dropzone:hover, .dropzone.dragover { border-color: var(--primary); background: var(--dropzone-hover); }
.dropzone-icon { font-size: 40px; } .dropzone-icon { font-size: 40px; }
.dropzone-title { margin-top: 10px; font-weight: 600; font-size: 15px; } .dropzone-title { margin-top: 10px; font-weight: 600; font-size: 15px; }
.dropzone-hint { margin-top: 6px; font-size: 12px; color: var(--text-light); } .dropzone-hint { margin-top: 6px; font-size: 12px; color: var(--text-light); }
@@ -147,7 +179,7 @@ body {
.result-head h2 { font-size: 16px; } .result-head h2 { font-size: 16px; }
.result-body { .result-body {
margin-top: 14px; margin-top: 14px;
background: #f8f9fc; background: var(--result-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
@@ -171,35 +203,40 @@ body {
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* ===== 模态框 ===== */ /* ===== 进入密钥解锁层 ===== */
.modal-mask { .unlock-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 20, 35, .45); background: linear-gradient(135deg, var(--unlock-bg-1), var(--unlock-bg-2));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
} }
.modal { .unlock-card {
background: var(--card); background: var(--card);
border-radius: 14px; border: 1px solid var(--border);
padding: 28px; border-radius: 16px;
width: 380px; padding: 36px 32px;
box-shadow: 0 20px 60px rgba(0, 0, 0, .2); width: 360px;
text-align: center;
box-shadow: 0 20px 60px rgba(16, 24, 40, .12);
} }
.modal h2 { font-size: 18px; } .unlock-icon { font-size: 42px; }
.modal-desc { font-size: 13px; color: var(--text-light); margin: 6px 0 16px; } .unlock-card h2 { font-size: 20px; margin-top: 10px; }
.modal-input { .unlock-desc { font-size: 13px; color: var(--text-light); margin: 6px 0 18px; }
.unlock-input {
width: 100%; width: 100%;
padding: 11px 13px; padding: 12px 14px;
margin-bottom: 10px; margin-bottom: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
font-size: 14px; font-size: 16px;
letter-spacing: 2px;
text-align: center;
outline: none; outline: none;
} }
.modal-input:focus { border-color: var(--primary); } .unlock-input:focus { border-color: var(--primary); }
.modal-error { color: var(--danger); font-size: 13px; min-height: 18px; margin-bottom: 4px; } .modal-error { color: var(--danger); font-size: 13px; min-height: 18px; margin-bottom: 4px; }
/* ===== 页脚 ===== */ /* ===== 页脚 ===== */

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vision Tool — 免费图片识别</title> <title>Vision Tool — 在线图片识别</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
@@ -12,11 +12,12 @@
<span class="logo">👁️</span> <span class="logo">👁️</span>
<div> <div>
<h1>Vision Tool</h1> <h1>Vision Tool</h1>
<p>免费图片识别 · Agnes-2.0-Flash</p> <p>在线图片识别</p>
</div> </div>
</div> </div>
<div class="account" id="accountArea"> <div class="account" id="accountArea">
<!-- 登录状态由 JS 渲染 --> <button class="btn btn-ghost" id="themeBtn" title="切换白昼/黑夜">🌙</button>
<!-- 解锁状态由 JS 渲染 -->
</div> </div>
</header> </header>
@@ -49,31 +50,19 @@
</section> </section>
</main> </main>
<!-- 初始化管理员模态框 --> <!-- 进入密钥解锁层 -->
<div class="modal-mask" id="initModal" hidden> <div class="unlock-mask" id="unlockLayer">
<div class="modal"> <div class="unlock-card">
<h2>初始化管理员</h2> <div class="unlock-icon">🔐</div>
<p class="modal-desc">首次使用,请创建管理员账号</p> <h2>Vision Tool</h2>
<input type="text" id="initUsername" placeholder="用户名" class="modal-input"> <p class="unlock-desc">请输入进入密钥</p>
<input type="password" id="initPassword" placeholder="密码(至少 6 位)" class="modal-input"> <input type="password" id="unlockKey" class="unlock-input" placeholder="进入密钥" autofocus>
<input type="password" id="initPassword2" placeholder="确认密码" class="modal-input"> <div class="modal-error" id="unlockError"></div>
<div class="modal-error" id="initError"></div> <button class="btn btn-primary btn-block" id="unlockBtn">进入</button>
<button class="btn btn-primary btn-block" id="initBtn">创建管理员</button>
</div> </div>
</div> </div>
<!-- 登录模态框 --> <footer class="footer">在线图片识别 · 识别接口支持 API 直接调用<br>© 青梧映星软件开发工作室 版权所有</footer>
<div class="modal-mask" id="loginModal" hidden>
<div class="modal">
<h2>登录</h2>
<input type="text" id="loginUsername" placeholder="用户名" class="modal-input">
<input type="password" id="loginPassword" placeholder="密码" class="modal-input">
<div class="modal-error" id="loginError"></div>
<button class="btn btn-primary btn-block" id="loginBtn">登录</button>
</div>
</div>
<footer class="footer">推理模型专心推理,图片理解交给免费小模型 · 识别接口支持 API 直接调用</footer>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>

View File

@@ -1,18 +1,37 @@
// ============================================================================ // ============================================================================
// Vision Tool — 在线免费图片识别(原生单页) // Vision Tool — 在线图片识别(原生单页)
// 进入方式:固定进入密钥(存于数据库,首次启动自动初始化)
// ============================================================================ // ============================================================================
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
// ---------- 白昼/黑夜主题 ----------
const THEME_KEY = "vt_theme";
function currentTheme() {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "dark" || saved === "light") return saved;
// 未设置时跟随系统偏好
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem(THEME_KEY, theme);
$("themeBtn").textContent = theme === "dark" ? "☀️" : "🌙";
}
applyTheme(currentTheme());
$("themeBtn").addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
});
// ---------- API 封装 ---------- // ---------- API 封装 ----------
async function api(path, options = {}) { async function api(path, options = {}) {
const headers = options.headers || {}; const headers = options.headers || {};
if (!(options.body instanceof FormData)) { if (!(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
const token = localStorage.getItem("vt_token");
if (token) headers["Authorization"] = `Bearer ${token}`;
const resp = await fetch(path, { ...options, headers }); const resp = await fetch(path, { ...options, headers });
let data; let data;
try { try {
@@ -24,119 +43,141 @@ async function api(path, options = {}) {
return data.data; return data.data;
} }
// ---------- 状态 ---------- // ---------- 进入密钥 ----------
let selectedFiles = []; // 待识别的图片文件 const KEY_STORAGE = "vt_access_key";
let analyzing = false;
// ---------- 顶栏账号区 ---------- function isUnlocked() {
async function refreshAccount() { return !!localStorage.getItem(KEY_STORAGE);
}
function refreshUnlockState() {
const unlocked = isUnlocked();
$("unlockLayer").hidden = unlocked;
const area = $("accountArea"); const area = $("accountArea");
area.innerHTML = ""; area.innerHTML = "";
if (!unlocked) return;
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"); const chip = document.createElement("span");
chip.className = "user-chip"; chip.className = "user-chip";
chip.textContent = `👤 ${user.username}`; chip.textContent = "🔓 已解锁";
area.appendChild(chip); area.appendChild(chip);
// 一键复制 API 文档(以当前 URL 为基础地址)
const docBtn = document.createElement("button");
docBtn.className = "btn btn-ghost";
docBtn.textContent = "📋 复制 API 文档";
docBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(buildApiDoc());
docBtn.textContent = "已复制 ✓";
setTimeout(() => (docBtn.textContent = "📋 复制 API 文档"), 1500);
} catch {
alert("复制失败,请手动复制");
}
};
area.appendChild(docBtn);
const logout = document.createElement("button"); const logout = document.createElement("button");
logout.className = "btn btn-ghost"; logout.className = "btn btn-ghost";
logout.textContent = "退出"; logout.textContent = "退出";
logout.onclick = () => { logout.onclick = () => {
localStorage.removeItem("vt_token"); localStorage.removeItem(KEY_STORAGE);
refreshAccount(); refreshUnlockState();
}; };
area.appendChild(logout); area.appendChild(logout);
} catch {
localStorage.removeItem("vt_token");
refreshAccount();
}
} }
// ---------- 模态框 ---------- // 生成 API 文档(Markdown,供智能体/开发者直接调用)
function showModal(id) { function buildApiDoc() {
$(id).hidden = false; const base = window.location.origin;
$("initError").textContent = ""; const key = localStorage.getItem(KEY_STORAGE) || "19735500";
$("loginError").textContent = ""; return `# Vision Tool — 在线图片识别 API
}
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 () => { 智能体可通过下面的 HTTP API 直接调用,识别图片内容。
const username = $("initUsername").value.trim();
const password = $("initPassword").value; ## 基础信息
const password2 = $("initPassword2").value;
if (!username || password.length < 6) { - 服务地址: \`${base}\`
$("initError").textContent = "用户名不能为空,密码至少 6 位"; - 进入密钥: \`${key}\`(可选,用于鉴权调用)
return;
## 接口:图片识别
POST \`${base}/api/v1/vision/analyze\`
multipart/form-data 字段:
- \`images\`: 图片文件,可传多个(支持 png/jpg/gif/webp/bmp/tiff)
- \`prompt\`: 可选,自定义分析提问(不传则默认详细描述图片内容)
### 免鉴权调用示例
\`\`\`bash
curl -X POST ${base}/api/v1/vision/analyze \\
-F "images=@photo.png" \\
-F "prompt=这张图里有什么?"
\`\`\`
### 带密钥调用示例
\`\`\`bash
curl -X POST ${base}/api/v1/vision/analyze \\
-H "X-Access-Key: ${key}" \\
-F "images=@photo.png"
\`\`\`
## 响应格式
\`\`\`json
{
"code": 0,
"message": "ok",
"data": {
"result": "识别结果文本",
"images": ["/uploads/xxx.png"],
"usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 }
} }
if (password !== password2) { }
$("initError").textContent = "两次输入的密码不一致"; \`\`\`
## 其他接口
- GET \`${base}/api/health\` — 健康检查
- POST \`${base}/api/v1/auth/verify\` — 校验进入密钥 \`{"key": "${key}"}\`
## 用法建议
需要识别图片内容时,将图片路径交给智能体,让它执行上述 curl 命令即可。
`;
}
$("unlockBtn").addEventListener("click", unlock);
$("unlockKey").addEventListener("keydown", (e) => { if (e.key === "Enter") unlock(); });
async function unlock() {
const key = $("unlockKey").value.trim();
if (!key) {
$("unlockError").textContent = "请输入进入密钥";
return; return;
} }
try { try {
await api("/api/v1/admin/init", { await api("/api/v1/auth/verify", {
method: "POST", method: "POST",
body: JSON.stringify({ username, password }), body: JSON.stringify({ key }),
}); });
hideModal("initModal"); localStorage.setItem(KEY_STORAGE, key);
await login(username, password); $("unlockKey").value = "";
refreshAccount(); $("unlockError").textContent = "";
refreshUnlockState();
} catch (err) { } catch (err) {
$("initError").textContent = err.message; $("unlockError").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);
} }
// ---------- 图片选择与预览 ---------- // ---------- 图片选择与预览 ----------
let selectedFiles = [];
let analyzing = false;
const dropzone = $("dropzone"); const dropzone = $("dropzone");
const fileInput = $("fileInput"); const fileInput = $("fileInput");
@@ -193,7 +234,7 @@ async function analyze() {
btn.innerHTML = '<span class="loading"><span class="spinner"></span>识别中…</span>'; btn.innerHTML = '<span class="loading"><span class="spinner"></span>识别中…</span>';
const resultCard = $("resultCard"); const resultCard = $("resultCard");
resultCard.hidden = false; resultCard.hidden = false;
$("resultBody").textContent = "正在调用免费视觉模型,请稍候(最长约 2 分钟)…"; $("resultBody").textContent = "正在识别图片内容,请稍候(最长约 2 分钟)…";
$("resultMeta").textContent = ""; $("resultMeta").textContent = "";
const fd = new FormData(); const fd = new FormData();
@@ -230,7 +271,4 @@ $("copyBtn").addEventListener("click", async () => {
}); });
// ---------- 启动 ---------- // ---------- 启动 ----------
refreshAccount().catch((err) => { refreshUnlockState();
// check 失败(服务未启动等),提示
document.querySelector(".brand p").textContent = "⚠️ 服务连接失败: " + err.message;
});