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:
32
README.md
32
README.md
@@ -12,7 +12,7 @@
|
||||
- 🖼️ 多图对比、base64 直传(无需图床/公网 URL)
|
||||
- 🔁 指数退避自动重试(429/5xx:2s → 5s → 8s → 11s)
|
||||
- 🔧 配置:命令行 > 环境变量 > config.yaml > 内置默认
|
||||
- 🔒 JWT 登录 + 管理员初始化(脚手架模板能力)
|
||||
- 🔑 固定进入密钥(默认 `19735500`),密钥初始化到数据库,页面解锁后使用
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -32,7 +32,7 @@ go run . # 或 ./vision-tool.exe
|
||||
# 打开 http://localhost:8080
|
||||
```
|
||||
|
||||
1. 首次访问:初始化管理员账号(右上角)
|
||||
1. 输入进入密钥(默认 `19735500`,存于数据库,可改库更新)解锁页面
|
||||
2. 拖拽或点击选择图片(可多张)
|
||||
3. (可选)输入自定义提问
|
||||
4. 点击「开始识别」→ 得到识别结果,可一键复制
|
||||
@@ -57,14 +57,21 @@ Claude Code 在线调用方式:粘贴图片后执行
|
||||
|
||||
统一响应格式:`{code, message, data}`(`code=0` 为成功)
|
||||
|
||||
### 在线图片识别(免鉴权 + 限流)
|
||||
### 在线图片识别(免鉴权或带密钥,限流防刷)
|
||||
|
||||
```bash
|
||||
# 免鉴权调用
|
||||
curl -F "images=@a.png" -F "images=@b.png" \
|
||||
-F "prompt=比较这两张图的异同" \
|
||||
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
|
||||
@@ -84,11 +91,8 @@ curl -F "images=@a.png" -F "images=@b.png" \
|
||||
| 接口 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/api/health` | GET | 健康检查 |
|
||||
| `/api/v1/admin/check` | GET | 管理员是否已初始化 |
|
||||
| `/api/v1/admin/init` | POST | 初始化管理员 `{username, password}` |
|
||||
| `/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`) |
|
||||
| `/api/v1/auth/verify` | POST | 校验进入密钥 `{key}` → `{ok}`(密钥存于数据库) |
|
||||
| `/api/v1/vision/analyze` | POST | 图片识别(multipart:`images` × 1-N + 可选 `prompt`;可选 `X-Access-Key` 密钥鉴权) |
|
||||
| `/uploads/*` | GET | 上传图片静态访问 |
|
||||
|
||||
## ⚙️ 配置(config.yaml)
|
||||
@@ -100,9 +104,8 @@ database:
|
||||
driver: sqlite # 当前仅 sqlite(pure-go,无需 CGO)
|
||||
path: ./data/app.db
|
||||
auto_migrate: true
|
||||
jwt:
|
||||
secret: "change-me-in-production"
|
||||
expire_hours: 720
|
||||
auth:
|
||||
access_key: "19735500" # 进入密钥,首次启动初始化到数据库(可改库更新)
|
||||
upload:
|
||||
path: uploads
|
||||
max_size: 10485760 # 10MB
|
||||
@@ -125,12 +128,11 @@ vision-tool/
|
||||
│ └── vision-cli/ # 独立 CLI 工具(打包为单个二进制)
|
||||
├── internal/
|
||||
│ ├── config/ # 配置加载(yaml + 环境变量覆盖)
|
||||
│ ├── database/ # GORM + SQLite 初始化 + AutoMigrate
|
||||
│ ├── database/ # GORM + SQLite 初始化 + AutoMigrate + 密钥种子数据
|
||||
│ ├── model/ # 数据模型 + 统一响应格式
|
||||
│ ├── repository/ # 数据访问层
|
||||
│ ├── service/ # 业务逻辑(auth / vision 免费模型调用)
|
||||
│ ├── service/ # 业务逻辑(密钥校验 / vision 免费模型调用)
|
||||
│ ├── handler/ # HTTP 处理器
|
||||
│ ├── middleware/ # CORS / JWT / 令牌桶限流
|
||||
│ ├── middleware/ # CORS / 令牌桶限流
|
||||
│ └── router/ # 路由注册 + 前端内嵌 + SPA 回退
|
||||
└── web/ # 原生单页(在线图片识别,内嵌进二进制)
|
||||
├── index.html
|
||||
|
||||
14
build.bat
14
build.bat
@@ -1,10 +1,8 @@
|
||||
@echo off
|
||||
rem ============================================
|
||||
rem vision-tool 构建脚本(Windows 本地)
|
||||
rem 产物:vision-tool.exe(Web 服务) / vision-cli.exe(CLI 工具)
|
||||
rem ============================================
|
||||
rem Build Linux binaries: vision-tool / vision-cli
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
go build -o vision-tool.exe .
|
||||
go build -o vision-cli.exe ./cmd/vision-cli
|
||||
echo.
|
||||
echo Build done: vision-tool.exe / vision-cli.exe
|
||||
go build -o vision-tool .
|
||||
go build -o vision-cli ./cmd/vision-cli
|
||||
echo Build done: vision-tool / vision-cli (linux/amd64)
|
||||
|
||||
11
build.sh
11
build.sh
@@ -1,10 +1,5 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# vision-tool 构建脚本(Linux 交叉编译,用于部署)
|
||||
# 产物:vision-tool(Web 服务) / vision-cli(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
|
||||
# 构建 Linux 可执行文件(vision-tool 服务 / vision-cli 工具)
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o vision-tool .
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o vision-cli ./cmd/vision-cli
|
||||
echo "Build done: vision-tool / vision-cli (linux/amd64)"
|
||||
|
||||
@@ -8,10 +8,9 @@ database:
|
||||
path: ./data/app.db
|
||||
auto_migrate: true # 启动自动建表,生产环境建议 false
|
||||
|
||||
# ========== JWT 认证 ==========
|
||||
jwt:
|
||||
secret: "change-me-in-production"
|
||||
expire_hours: 720 # Token 有效期(小时)
|
||||
# ========== 访问密钥 ==========
|
||||
auth:
|
||||
access_key: "19735500" # 进入密钥,首次启动初始化到数据库
|
||||
|
||||
# ========== 文件上传 ==========
|
||||
upload:
|
||||
|
||||
3
go.mod
3
go.mod
@@ -5,8 +5,6 @@ go 1.25.0
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.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
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
@@ -42,6 +40,7 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.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/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Upload UploadConfig `yaml:"upload"`
|
||||
AI AIConfig `yaml:"ai"`
|
||||
Env EnvConfig `yaml:"env"`
|
||||
@@ -27,9 +27,8 @@ type DatabaseConfig struct {
|
||||
AutoMigrate bool `yaml:"auto_migrate"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `yaml:"secret"`
|
||||
ExpireHours int `yaml:"expire_hours"`
|
||||
type AuthConfig struct {
|
||||
AccessKey string `yaml:"access_key"` // 进入密钥,首次启动初始化到数据库
|
||||
}
|
||||
|
||||
type UploadConfig struct {
|
||||
@@ -56,7 +55,7 @@ func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{Port: 8080},
|
||||
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},
|
||||
AI: AIConfig{
|
||||
APIKey: hardcodedAPIKey,
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
"vision-tool/internal/model"
|
||||
)
|
||||
|
||||
// Init 初始化数据库连接并执行 AutoMigrate。
|
||||
// Init 初始化数据库连接,执行 AutoMigrate,并写入初始化数据(进入密钥)。
|
||||
// 使用 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 err error
|
||||
|
||||
@@ -39,10 +40,25 @@ func Init(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
|
||||
// auto_migrate:自动建表/更新表结构,生产环境建议手动管理
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,79 +9,27 @@ import (
|
||||
"vision-tool/internal/service"
|
||||
)
|
||||
|
||||
// AuthHandler HTTP 处理器:管理员初始化 / 登录 / 用户信息。
|
||||
// AuthHandler HTTP 处理器:进入密钥校验。
|
||||
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}
|
||||
}
|
||||
|
||||
type initRequest struct {
|
||||
Username string `json:"username" binding:"required,min=2,max=32"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
// Verify POST /api/v1/auth/verify — 校验进入密钥(固定密钥,存于数据库)
|
||||
func (h *AuthHandler) Verify(c *gin.Context) {
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "用户名 2-32 位,密码至少 6 位"))
|
||||
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请输入进入密钥"))
|
||||
return
|
||||
}
|
||||
if err := h.svc.InitAdmin(req.Username, req.Password); err != nil {
|
||||
if errors.Is(err, service.ErrAdminExists) {
|
||||
c.JSON(http.StatusConflict, model.Err(model.CodeConflict, err.Error()))
|
||||
if !h.svc.VerifyKey(req.Key) {
|
||||
c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "密钥错误"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
|
||||
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))
|
||||
c.JSON(http.StatusOK, model.OK(gin.H{"ok": true}))
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ import (
|
||||
// VisionHandler HTTP 处理器:在线图片识别。
|
||||
type VisionHandler struct {
|
||||
svc *service.VisionService
|
||||
access *service.AccessService
|
||||
upload config.UploadConfig
|
||||
}
|
||||
|
||||
func NewVisionHandler(svc *service.VisionService, upload config.UploadConfig) *VisionHandler {
|
||||
return &VisionHandler{svc: svc, upload: upload}
|
||||
func NewVisionHandler(svc *service.VisionService, access *service.AccessService, upload config.UploadConfig) *VisionHandler {
|
||||
return &VisionHandler{svc: svc, access: access, upload: upload}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// prompt 可选,自定义分析提问(默认:结构化描述图片内容)
|
||||
//
|
||||
// 免鉴权 + 限流,方便 curl / Claude Code 直接调用。
|
||||
// 鉴权(可选):提供 X-Access-Key 请求头 或 access_key 表单字段时校验进入密钥,
|
||||
// 密钥错误返回 401;不提供时保持免鉴权调用,方便 curl / Claude Code 直接使用。
|
||||
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()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请使用 multipart/form-data 上传图片"))
|
||||
|
||||
@@ -3,12 +3,10 @@ package middleware
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 内存令牌桶限流(全局限流,防匿名刷接口)
|
||||
// ============================================================================
|
||||
|
||||
@@ -2,13 +2,17 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User 管理员账号。
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||
PasswordHash string `gorm:"size:255" json:"-"`
|
||||
Role string `gorm:"size:32" json:"role"`
|
||||
// 配置键
|
||||
const (
|
||||
KeyAccessKey = "access_key" // 页面进入密钥
|
||||
)
|
||||
|
||||
// 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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Response 统一响应格式 {code, message, data}。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -14,23 +13,20 @@ import (
|
||||
"vision-tool/internal/handler"
|
||||
"vision-tool/internal/middleware"
|
||||
"vision-tool/internal/model"
|
||||
"vision-tool/internal/repository"
|
||||
"vision-tool/internal/service"
|
||||
)
|
||||
|
||||
// Setup 构建路由,并完成分层依赖注入:
|
||||
// db → repository → service → handler → router。
|
||||
// Setup 构建路由,并完成分层依赖注入。
|
||||
// webFS 为 //go:embed web 的嵌入文件系统,用于内嵌前端。
|
||||
func Setup(cfg *config.Config, db *gorm.DB, webFS fs.FS) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||
|
||||
// ---------- 分层依赖注入 ----------
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
authSvc := service.NewAuthService(userRepo, cfg.JWT.Secret, time.Duration(cfg.JWT.ExpireHours)*time.Hour)
|
||||
accessSvc := service.NewAccessService(db)
|
||||
visionSvc := service.NewVisionService(&cfg.AI)
|
||||
authH := handler.NewAuthHandler(authSvc)
|
||||
visionH := handler.NewVisionHandler(visionSvc, cfg.Upload)
|
||||
authH := handler.NewAuthHandler(accessSvc)
|
||||
visionH := handler.NewVisionHandler(visionSvc, accessSvc, cfg.Upload)
|
||||
|
||||
// 识别接口免鉴权,用令牌桶限流防刷
|
||||
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")
|
||||
{
|
||||
// 管理员初始化(方式 A:check + init)
|
||||
v1.GET("/admin/check", authH.Check)
|
||||
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("/auth/verify", authH.Verify)
|
||||
|
||||
// 在线图片识别(免鉴权 + 限流)
|
||||
v1.POST("/vision/analyze", analyzeLimiter.Middleware(), visionH.Analyze)
|
||||
|
||||
26
internal/service/access_service.go
Normal file
26
internal/service/access_service.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
4
main.go
4
main.go
@@ -26,8 +26,8 @@ func main() {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 初始化数据库
|
||||
db, err := database.Init(&cfg.Database)
|
||||
// 2. 初始化数据库(首次启动自动写入进入密钥)
|
||||
db, err := database.Init(&cfg.Database, cfg.Auth.AccessKey)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化数据库失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* ===== 基础 ===== */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* 保证 hidden 属性始终生效(防止被 display:flex 等样式覆盖) */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
:root {
|
||||
--primary: #4f6ef7;
|
||||
--primary-dark: #3a55d8;
|
||||
@@ -11,6 +14,32 @@
|
||||
--border: #e5e9f2;
|
||||
--danger: #e5484d;
|
||||
--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 {
|
||||
@@ -39,13 +68,16 @@ body {
|
||||
|
||||
.account { display: flex; align-items: center; gap: 10px; font-size: 14px; }
|
||||
.account .user-chip {
|
||||
background: #eef1fe;
|
||||
color: var(--primary-dark);
|
||||
background: var(--chip-bg);
|
||||
color: var(--primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 顶栏常驻于解锁层之上,主题切换随时可用 */
|
||||
.topbar { position: relative; z-index: 150; }
|
||||
|
||||
/* ===== 布局 ===== */
|
||||
.container {
|
||||
flex: 1;
|
||||
@@ -68,15 +100,15 @@ body {
|
||||
|
||||
/* ===== 上传区 ===== */
|
||||
.dropzone {
|
||||
border: 2px dashed #c7d2fe;
|
||||
border: 2px dashed var(--dropzone-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
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-title { margin-top: 10px; font-weight: 600; font-size: 15px; }
|
||||
.dropzone-hint { margin-top: 6px; font-size: 12px; color: var(--text-light); }
|
||||
@@ -147,7 +179,7 @@ body {
|
||||
.result-head h2 { font-size: 16px; }
|
||||
.result-body {
|
||||
margin-top: 14px;
|
||||
background: #f8f9fc;
|
||||
background: var(--result-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
@@ -171,35 +203,40 @@ body {
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-mask {
|
||||
/* ===== 进入密钥解锁层 ===== */
|
||||
.unlock-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 20, 35, .45);
|
||||
background: linear-gradient(135deg, var(--unlock-bg-1), var(--unlock-bg-2));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
.unlock-card {
|
||||
background: var(--card);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
width: 380px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, .2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 36px 32px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(16, 24, 40, .12);
|
||||
}
|
||||
.modal h2 { font-size: 18px; }
|
||||
.modal-desc { font-size: 13px; color: var(--text-light); margin: 6px 0 16px; }
|
||||
.modal-input {
|
||||
.unlock-icon { font-size: 42px; }
|
||||
.unlock-card h2 { font-size: 20px; margin-top: 10px; }
|
||||
.unlock-desc { font-size: 13px; color: var(--text-light); margin: 6px 0 18px; }
|
||||
.unlock-input {
|
||||
width: 100%;
|
||||
padding: 11px 13px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 2px;
|
||||
text-align: center;
|
||||
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; }
|
||||
|
||||
/* ===== 页脚 ===== */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
@@ -12,11 +12,12 @@
|
||||
<span class="logo">👁️</span>
|
||||
<div>
|
||||
<h1>Vision Tool</h1>
|
||||
<p>免费图片识别 · Agnes-2.0-Flash</p>
|
||||
<p>在线图片识别</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account" id="accountArea">
|
||||
<!-- 登录状态由 JS 渲染 -->
|
||||
<button class="btn btn-ghost" id="themeBtn" title="切换白昼/黑夜">🌙</button>
|
||||
<!-- 解锁状态由 JS 渲染 -->
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -49,31 +50,19 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 初始化管理员模态框 -->
|
||||
<div class="modal-mask" id="initModal" hidden>
|
||||
<div class="modal">
|
||||
<h2>初始化管理员</h2>
|
||||
<p class="modal-desc">首次使用,请创建管理员账号</p>
|
||||
<input type="text" id="initUsername" placeholder="用户名" class="modal-input">
|
||||
<input type="password" id="initPassword" placeholder="密码(至少 6 位)" class="modal-input">
|
||||
<input type="password" id="initPassword2" placeholder="确认密码" class="modal-input">
|
||||
<div class="modal-error" id="initError"></div>
|
||||
<button class="btn btn-primary btn-block" id="initBtn">创建管理员</button>
|
||||
<!-- 进入密钥解锁层 -->
|
||||
<div class="unlock-mask" id="unlockLayer">
|
||||
<div class="unlock-card">
|
||||
<div class="unlock-icon">🔐</div>
|
||||
<h2>Vision Tool</h2>
|
||||
<p class="unlock-desc">请输入进入密钥</p>
|
||||
<input type="password" id="unlockKey" class="unlock-input" placeholder="进入密钥" autofocus>
|
||||
<div class="modal-error" id="unlockError"></div>
|
||||
<button class="btn btn-primary btn-block" id="unlockBtn">进入</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录模态框 -->
|
||||
<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>
|
||||
<footer class="footer">在线图片识别 · 识别接口支持 API 直接调用<br>© 青梧映星软件开发工作室 版权所有</footer>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
228
web/js/app.js
228
web/js/app.js
@@ -1,18 +1,37 @@
|
||||
// ============================================================================
|
||||
// Vision Tool — 在线免费图片识别(原生单页)
|
||||
// Vision Tool — 在线图片识别(原生单页)
|
||||
// 进入方式:固定进入密钥(存于数据库,首次启动自动初始化)
|
||||
// ============================================================================
|
||||
|
||||
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 封装 ----------
|
||||
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 {
|
||||
@@ -24,119 +43,141 @@ async function api(path, options = {}) {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ---------- 状态 ----------
|
||||
let selectedFiles = []; // 待识别的图片文件
|
||||
let analyzing = false;
|
||||
// ---------- 进入密钥 ----------
|
||||
const KEY_STORAGE = "vt_access_key";
|
||||
|
||||
// ---------- 顶栏账号区 ----------
|
||||
async function refreshAccount() {
|
||||
function isUnlocked() {
|
||||
return !!localStorage.getItem(KEY_STORAGE);
|
||||
}
|
||||
|
||||
function refreshUnlockState() {
|
||||
const unlocked = isUnlocked();
|
||||
$("unlockLayer").hidden = unlocked;
|
||||
const area = $("accountArea");
|
||||
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");
|
||||
chip.className = "user-chip";
|
||||
chip.textContent = `👤 ${user.username}`;
|
||||
chip.textContent = "🔓 已解锁";
|
||||
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");
|
||||
logout.className = "btn btn-ghost";
|
||||
logout.textContent = "退出";
|
||||
logout.onclick = () => {
|
||||
localStorage.removeItem("vt_token");
|
||||
refreshAccount();
|
||||
localStorage.removeItem(KEY_STORAGE);
|
||||
refreshUnlockState();
|
||||
};
|
||||
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"); });
|
||||
// 生成 API 文档(Markdown,供智能体/开发者直接调用)
|
||||
function buildApiDoc() {
|
||||
const base = window.location.origin;
|
||||
const key = localStorage.getItem(KEY_STORAGE) || "19735500";
|
||||
return `# Vision Tool — 在线图片识别 API
|
||||
|
||||
// 初始化管理员
|
||||
$("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;
|
||||
这是一个在线图片识别服务。
|
||||
智能体可通过下面的 HTTP API 直接调用,识别图片内容。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- 服务地址: \`${base}\`
|
||||
- 进入密钥: \`${key}\`(可选,用于鉴权调用)
|
||||
|
||||
## 接口:图片识别
|
||||
|
||||
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;
|
||||
}
|
||||
try {
|
||||
await api("/api/v1/admin/init", {
|
||||
await api("/api/v1/auth/verify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
hideModal("initModal");
|
||||
await login(username, password);
|
||||
refreshAccount();
|
||||
localStorage.setItem(KEY_STORAGE, key);
|
||||
$("unlockKey").value = "";
|
||||
$("unlockError").textContent = "";
|
||||
refreshUnlockState();
|
||||
} 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 fileInput = $("fileInput");
|
||||
|
||||
@@ -193,7 +234,7 @@ async function analyze() {
|
||||
btn.innerHTML = '<span class="loading"><span class="spinner"></span>识别中…</span>';
|
||||
const resultCard = $("resultCard");
|
||||
resultCard.hidden = false;
|
||||
$("resultBody").textContent = "正在调用免费视觉模型,请稍候(最长约 2 分钟)…";
|
||||
$("resultBody").textContent = "正在识别图片内容,请稍候(最长约 2 分钟)…";
|
||||
$("resultMeta").textContent = "";
|
||||
|
||||
const fd = new FormData();
|
||||
@@ -230,7 +271,4 @@ $("copyBtn").addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
// ---------- 启动 ----------
|
||||
refreshAccount().catch((err) => {
|
||||
// check 失败(服务未启动等),提示
|
||||
document.querySelector(".brand p").textContent = "⚠️ 服务连接失败: " + err.message;
|
||||
});
|
||||
refreshUnlockState();
|
||||
|
||||
Reference in New Issue
Block a user