This commit is contained in:
2026-02-17 12:03:18 +08:00
parent b13f6df90e
commit 0f72cf7917
3 changed files with 796 additions and 4 deletions

View File

@@ -20,6 +20,8 @@ import json
import traceback import traceback
import re import re
import asyncio import asyncio
import shutil
from datetime import datetime
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -34,10 +36,10 @@ import matplotlib.pyplot as plt
from PIL import Image from PIL import Image
# FastAPI Imports # FastAPI Imports
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request, Depends, status, APIRouter from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request, Depends, status, APIRouter, Cookie
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, HTMLResponse, Response
# Dashscope (Aliyun Qwen) Imports # Dashscope (Aliyun Qwen) Imports
import dashscope import dashscope
@@ -62,6 +64,10 @@ os.makedirs(RESULT_IMAGE_DIR, exist_ok=True)
VALID_API_KEY = "123quant-speed" VALID_API_KEY = "123quant-speed"
API_KEY_HEADER_NAME = "X-API-Key" API_KEY_HEADER_NAME = "X-API-Key"
# Admin Config
ADMIN_PASSWORD = "admin_secure_password" # 可以根据需求修改
HISTORY_FILE = "history.json"
# Dashscope (Qwen-VL) 配置 # Dashscope (Qwen-VL) 配置
dashscope.api_key = 'sk-ce2404f55f744a1987d5ece61c6bac58' dashscope.api_key = 'sk-ce2404f55f744a1987d5ece61c6bac58'
QWEN_MODEL = 'qwen-vl-max' QWEN_MODEL = 'qwen-vl-max'
@@ -219,6 +225,25 @@ def is_english(text: str) -> bool:
return False return False
return True return True
def append_to_history(req_type: str, prompt: str, status: str, result_path: str = None, details: str = ""):
"""
记录请求历史到 history.json
"""
record = {
"timestamp": time.time(),
"type": req_type,
"prompt": prompt,
"status": status,
"result_path": result_path,
"details": details
}
try:
with open(HISTORY_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
print(f"Failed to write history: {e}")
def translate_to_sam3_prompt(text: str) -> str: def translate_to_sam3_prompt(text: str) -> str:
""" """
使用 Qwen 模型将中文提示词翻译为英文 使用 Qwen 模型将中文提示词翻译为英文
@@ -640,6 +665,7 @@ async def segment(
elif image_url: elif image_url:
image = load_image_from_url(image_url) image = load_image_from_url(image_url)
except Exception as e: except Exception as e:
append_to_history("general", prompt, "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}") raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor processor = request.app.state.processor
@@ -669,6 +695,7 @@ async def segment(
processor.confidence_threshold = original_confidence processor.confidence_threshold = original_confidence
except Exception as e: except Exception as e:
append_to_history("general", prompt, "failed", details=f"Inference Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}") raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
# 4. 结果可视化与保存 # 4. 结果可视化与保存
@@ -681,6 +708,7 @@ async def segment(
else: else:
filename = generate_and_save_result(image, inference_state) filename = generate_and_save_result(image, inference_state)
except Exception as e: except Exception as e:
append_to_history("general", prompt, "failed", details=f"Save Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"绘图保存错误: {str(e)}") raise HTTPException(status_code=500, detail=f"绘图保存错误: {str(e)}")
file_url = request.url_for("static", path=f"results/{filename}") file_url = request.url_for("static", path=f"results/{filename}")
@@ -712,6 +740,8 @@ async def segment(
}) })
except Exception as e: except Exception as e:
print(f"Error saving segments: {e}") print(f"Error saving segments: {e}")
# Don't fail the whole request just for this part, but log it? Or fail? Usually fail.
append_to_history("general", prompt, "partial_success", result_path=f"results/{filename}", details="Segments save failed")
raise HTTPException(status_code=500, detail=f"保存分割图片失败: {str(e)}") raise HTTPException(status_code=500, detail=f"保存分割图片失败: {str(e)}")
response_content = { response_content = {
@@ -724,6 +754,7 @@ async def segment(
if save_segment_images: if save_segment_images:
response_content["segmented_images"] = saved_segments_info response_content["segmented_images"] = saved_segments_info
append_to_history("general", prompt, "success", result_path=f"results/{filename}", details=f"Detected: {len(masks)}")
return JSONResponse(content=response_content) return JSONResponse(content=response_content)
# ------------------------------------------ # ------------------------------------------
@@ -753,6 +784,7 @@ async def segment_tarot(
elif image_url: elif image_url:
image = load_image_from_url(image_url) image = load_image_from_url(image_url)
except Exception as e: except Exception as e:
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}") raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor processor = request.app.state.processor
@@ -763,6 +795,7 @@ async def segment_tarot(
output = processor.set_text_prompt(state=inference_state, prompt="tarot card") output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
masks, boxes, scores = output["masks"], output["boxes"], output["scores"] masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
except Exception as e: except Exception as e:
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}") raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
# 核心逻辑:判断数量 # 核心逻辑:判断数量
@@ -781,6 +814,7 @@ async def segment_tarot(
except: except:
file_url = None file_url = None
append_to_history("tarot", f"expected: {expected_count}", "failed", result_path=f"results/{request_id}/{filename}" if file_url else None, details=f"Detected {detected_count} cards, expected {expected_count}")
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={ content={
@@ -795,6 +829,7 @@ async def segment_tarot(
try: try:
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir) saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
except Exception as e: except Exception as e:
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}") raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}")
# 生成 URL 列表和元数据 # 生成 URL 列表和元数据
@@ -816,6 +851,7 @@ async def segment_tarot(
except: except:
main_file_url = None main_file_url = None
append_to_history("tarot", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Successfully segmented {expected_count} cards")
return JSONResponse(content={ return JSONResponse(content={
"status": "success", "status": "success",
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (已执行透视矫正)", "message": f"成功识别并分割 {expected_count} 张塔罗牌 (已执行透视矫正)",
@@ -847,6 +883,7 @@ async def recognize_tarot(
elif image_url: elif image_url:
image = load_image_from_url(image_url) image = load_image_from_url(image_url)
except Exception as e: except Exception as e:
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}") raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor processor = request.app.state.processor
@@ -856,6 +893,7 @@ async def recognize_tarot(
output = processor.set_text_prompt(state=inference_state, prompt="tarot card") output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
masks, boxes, scores = output["masks"], output["boxes"], output["scores"] masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
except Exception as e: except Exception as e:
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}") raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
detected_count = len(masks) detected_count = len(masks)
@@ -883,6 +921,7 @@ async def recognize_tarot(
spread_info = recognize_spread_with_qwen(temp_raw_path) spread_info = recognize_spread_with_qwen(temp_raw_path)
if detected_count != expected_count: if detected_count != expected_count:
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Detected {detected_count}, expected {expected_count}")
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={ content={
@@ -898,6 +937,7 @@ async def recognize_tarot(
try: try:
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir) saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
except Exception as e: except Exception as e:
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}") raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}")
# 遍历每张卡片进行识别 # 遍历每张卡片进行识别
@@ -918,6 +958,7 @@ async def recognize_tarot(
"note": obj["note"] "note": obj["note"]
}) })
append_to_history("tarot-recognize", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Spread: {spread_info.get('spread_name', 'Unknown')}")
return JSONResponse(content={ return JSONResponse(content={
"status": "success", "status": "success",
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (含Qwen识别结果)", "message": f"成功识别并分割 {expected_count} 张塔罗牌 (含Qwen识别结果)",
@@ -967,6 +1008,7 @@ async def segment_face(
elif image_url: elif image_url:
image = load_image_from_url(image_url) image = load_image_from_url(image_url)
except Exception as e: except Exception as e:
append_to_history("face", prompt, "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}") raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor processor = request.app.state.processor
@@ -982,6 +1024,7 @@ async def segment_face(
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
append_to_history("face", prompt, "failed", details=f"Process Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}") raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")
# 补全 URL # 补全 URL
@@ -994,10 +1037,196 @@ async def segment_face(
relative_path = item.pop("relative_path") relative_path = item.pop("relative_path")
item["url"] = str(request.url_for("static", path=relative_path)) item["url"] = str(request.url_for("static", path=relative_path))
append_to_history("face", prompt, result["status"], details=f"Results: {len(result.get('results', []))}")
return JSONResponse(content=result) return JSONResponse(content=result)
# ========================================== # ==========================================
# 8. Main Entry Point (启动入口) # 9. Admin Management APIs (管理后台接口)
# ==========================================
@app.get("/admin", include_in_schema=False)
async def admin_page():
"""
Serve the admin HTML page
"""
# 检查 static/admin.html 是否存在,不存在则返回错误或简易页面
admin_html_path = os.path.join(STATIC_DIR, "admin.html")
if os.path.exists(admin_html_path):
with open(admin_html_path, "r", encoding="utf-8") as f:
content = f.read()
return HTMLResponse(content=content)
else:
return HTMLResponse(content="<h1>Admin page not found</h1>", status_code=404)
@app.post("/admin/login", include_in_schema=False)
async def admin_login(password: str = Form(...)):
"""
Simple Admin Login
"""
if password == ADMIN_PASSWORD:
content = {"status": "success", "message": "Logged in"}
response = JSONResponse(content=content)
# Set a simple cookie
response.set_cookie(key="admin_token", value="logged_in", httponly=True)
return response
else:
return JSONResponse(status_code=401, content={"status": "error", "message": "Invalid password"})
async def verify_admin(request: Request):
# Check cookie or header
token = request.cookies.get("admin_token")
# Also allow local dev without strict check if needed, but here we enforce password
if token != "logged_in":
raise HTTPException(status_code=401, detail="Unauthorized")
@app.get("/admin/api/history", dependencies=[Depends(verify_admin)])
async def get_history():
"""
Get request history
"""
if not os.path.exists(HISTORY_FILE):
return []
records = []
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
try:
records.append(json.loads(line))
except:
pass
# Limit to last 100 records
return records[-100:]
except Exception as e:
return {"error": str(e)}
@app.get("/admin/api/files", dependencies=[Depends(verify_admin)])
async def list_files(path: str = ""):
"""
List files in static/results
"""
# Security check: prevent directory traversal
if ".." in path or path.startswith("/"):
raise HTTPException(status_code=400, detail="Invalid path")
target_dir = os.path.join(RESULT_IMAGE_DIR, path)
if not os.path.exists(target_dir):
return []
items = []
try:
for entry in os.scandir(target_dir):
is_dir = entry.is_dir()
item = {
"name": entry.name,
"is_dir": is_dir,
"path": os.path.join(path, entry.name),
"mtime": entry.stat().st_mtime
}
if is_dir:
try:
item["count"] = len(os.listdir(entry.path))
except:
item["count"] = 0
else:
item["size"] = entry.stat().st_size
# Construct URL
# Assuming static mount is /static
# path is relative to results/
# so url is /static/results/path/name
rel_path = os.path.join("results", path, entry.name)
item["url"] = f"/static/{rel_path}"
items.append(item)
# Sort: Directories first, then by time (newest first)
items.sort(key=lambda x: (not x["is_dir"], -x["mtime"]))
return items
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/admin/api/files/{file_path:path}", dependencies=[Depends(verify_admin)])
async def delete_file(file_path: str):
"""
Delete file or directory
"""
# Security check
if ".." in file_path:
raise HTTPException(status_code=400, detail="Invalid path")
# file_path is relative to static/results/ (or passed as full relative path from API)
# The API is called with relative path from current view
target_path = os.path.join(RESULT_IMAGE_DIR, file_path)
if not os.path.exists(target_path):
raise HTTPException(status_code=404, detail="Not found")
try:
if os.path.isdir(target_path):
shutil.rmtree(target_path)
else:
os.remove(target_path)
return {"status": "success", "message": f"Deleted {file_path}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/admin/api/cleanup", dependencies=[Depends(verify_admin)])
async def trigger_cleanup():
"""
Manually trigger cleanup
"""
try:
# Re-use logic from cleanup_old_files but force it for all files > 0 seconds if we want deep clean?
# Or just use the standard lifetime. Let's use standard lifetime but run it now.
lifetime = int(os.getenv("FILE_LIFETIME_SECONDS", "3600"))
count = 0
current_time = time.time()
for root, dirs, files in os.walk(RESULT_IMAGE_DIR):
for file in files:
file_path = os.path.join(root, file)
try:
file_mtime = os.path.getmtime(file_path)
if current_time - file_mtime > lifetime:
os.remove(file_path)
count += 1
except:
pass
# Cleanup empty dirs
for root, dirs, files in os.walk(RESULT_IMAGE_DIR, topdown=False):
for dir in dirs:
dir_path = os.path.join(root, dir)
try:
if not os.listdir(dir_path):
os.rmdir(dir_path)
except:
pass
return {"status": "success", "message": f"Cleaned {count} files"}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.get("/admin/api/config", dependencies=[Depends(verify_admin)])
async def get_config(request: Request):
"""
Get system config info
"""
device = "Unknown"
if hasattr(request.app.state, "device"):
device = str(request.app.state.device)
return {
"device": device,
"cleanup_enabled": os.getenv("AUTO_CLEANUP_ENABLED"),
"file_lifetime": os.getenv("FILE_LIFETIME_SECONDS"),
"cleanup_interval": os.getenv("CLEANUP_INTERVAL_SECONDS")
}
# ==========================================
# 10. Main Entry Point (启动入口)
# ========================================== # ==========================================
if __name__ == "__main__": if __name__ == "__main__":

164
run_monitor.sh Executable file
View File

@@ -0,0 +1,164 @@
#!/bin/bash
# ==============================================================================
# SAM3 项目启动与监控脚本
# 功能:启动 Python FastAPI 服务,并持续监控健康状态
# 作者Trae AI
# 日期2026-02-17
# ==============================================================================
# 配置部分
PROJECT_DIR="/home/quant/data/dev/sam3" # 项目根目录
SCRIPT_NAME="fastAPI_tarot.py" # Python 启动脚本
LOG_FILE="${PROJECT_DIR}/log/monitor.log" # 监控日志文件
APP_LOG_FILE="${PROJECT_DIR}/log/app.log" # 应用输出日志文件
PORT=55600 # 服务端口
CHECK_INTERVAL=5 # 检查间隔(秒)
MAX_FAILURES=3 # 最大连续失败次数,超过则重启
STARTUP_TIMEOUT=300 # 启动超时时间(秒),等待模型加载
PYTHON_CMD="python" # Python 命令,根据环境可能是 python3
# 切换到项目目录
cd "$PROJECT_DIR" || exit 1
# 初始化变量
APP_PID=0
FAIL_COUNT=0
# ==============================================================================
# 函数:记录日志 (log_message)
# 功能:将带有时间戳的信息写入日志文件并输出到控制台
# 参数:$1 - 日志内容
# ==============================================================================
log_message() {
local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
echo "[$timestamp] $1" | tee -a "$LOG_FILE"
}
# ==============================================================================
# 函数:启动应用 (start_app)
# 功能:启动 FastAPI 服务,并记录 PID
# ==============================================================================
start_app() {
log_message "正在启动项目: $SCRIPT_NAME ..."
log_message "应用日志将输出到: $APP_LOG_FILE"
# 后台启动 Python 脚本,将 stdout 和 stderr 重定向到日志
# 使用 -u 参数启用无缓冲输出,确保日志实时更新
nohup $PYTHON_CMD -u "$SCRIPT_NAME" > "$APP_LOG_FILE" 2>&1 &
APP_PID=$!
log_message "项目已启动PID: $APP_PID"
log_message "正在等待服务初始化 (最多等待 ${STARTUP_TIMEOUT} 秒)..."
# 循环检查服务是否就绪
local elapsed=0
while [ $elapsed -lt $STARTUP_TIMEOUT ]; do
# 检查进程是否还活着
if ! kill -0 $APP_PID 2>/dev/null; then
log_message "错误: 进程在启动过程中退出。请检查应用日志。"
return 1
fi
# 检查端口响应
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/docs")
if [ "$HTTP_CODE" == "200" ]; then
log_message "服务启动成功!"
return 0
fi
sleep 5
elapsed=$((elapsed + 5))
# 每30秒打印一次等待日志
if [ $((elapsed % 30)) -eq 0 ]; then
log_message "仍在等待服务启动... (已耗时 ${elapsed} 秒)"
fi
done
log_message "错误: 服务启动超时 (${STARTUP_TIMEOUT} 秒)。正在终止进程..."
kill -9 $APP_PID 2>/dev/null
return 1
}
# ==============================================================================
# 函数:停止应用 (stop_app)
# 功能:通过 PID 停止应用,如果失败则强制杀死
# ==============================================================================
stop_app() {
if [ $APP_PID -gt 0 ]; then
log_message "正在停止项目 (PID: $APP_PID)..."
kill $APP_PID 2>/dev/null
# 等待进程结束
for i in {1..5}; do
if ! kill -0 $APP_PID 2>/dev/null; then
log_message "项目已停止"
return
fi
sleep 1
done
# 如果还在运行,强制杀死
log_message "项目未响应,正在强制终止..."
kill -9 $APP_PID 2>/dev/null
fi
}
# ==============================================================================
# 函数:检查健康状态 (check_health)
# 功能:检查进程是否存在以及端口是否响应
# 返回0 (正常) / 1 (异常)
# ==============================================================================
check_health() {
# 1. 检查进程是否存在
if ! kill -0 $APP_PID 2>/dev/null; then
log_message "警告: 进程 $APP_PID 不存在"
return 1
fi
# 2. 检查端口响应 (请求 /docs 接口)
# 使用 curl 获取 HTTP 状态码
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/docs")
if [ "$HTTP_CODE" == "200" ]; then
return 0
else
log_message "警告: 健康检查失败HTTP 状态码: $HTTP_CODE"
return 1
fi
}
# ==============================================================================
# 主循环
# ==============================================================================
# 初始启动
start_app
while true; do
if check_health; then
# 健康检查通过
FAIL_COUNT=0
# log_message "健康检查通过" # 可选:为了减少日志量,可以注释掉这行
else
# 健康检查失败
((FAIL_COUNT++))
log_message "健康检查失败 ($FAIL_COUNT/$MAX_FAILURES)"
if [ $FAIL_COUNT -ge $MAX_FAILURES ]; then
log_message "错误: 连续检测失败次数过多,准备重启项目..."
stop_app
start_app
FAIL_COUNT=0
elif ! kill -0 $APP_PID 2>/dev/null; then
# 如果进程直接没了,立即重启
log_message "错误: 进程意外退出,立即重启..."
start_app
FAIL_COUNT=0
fi
fi
sleep $CHECK_INTERVAL
done

399
static/admin.html Normal file
View File

@@ -0,0 +1,399 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAM3 项目管理后台</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
</head>
<body class="bg-gray-100 min-h-screen text-gray-800">
<div id="app" v-cloak>
<!-- 登录页 -->
<div v-if="!isLoggedIn" class="flex items-center justify-center min-h-screen">
<div class="bg-white p-8 rounded-lg shadow-lg w-96">
<h1 class="text-2xl font-bold mb-6 text-center text-blue-600">SAM3 管理后台</h1>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">管理员密码</label>
<input v-model="password" type="password" @keyup.enter="login" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="请输入密码">
</div>
<button @click="login" class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-300">
登录
</button>
<p v-if="loginError" class="text-red-500 text-xs italic mt-2">{{ loginError }}</p>
</div>
</div>
<!-- 主界面 -->
<div v-else class="flex h-screen overflow-hidden">
<!-- 侧边栏 -->
<aside class="w-64 bg-slate-800 text-white flex flex-col">
<div class="p-6 border-b border-slate-700">
<h2 class="text-xl font-bold flex items-center gap-2">
<i class="fas fa-layer-group"></i> SAM3 Admin
</h2>
</div>
<nav class="flex-1 p-4 space-y-2">
<a href="#" @click.prevent="currentTab = 'dashboard'" :class="{'bg-blue-600': currentTab === 'dashboard', 'hover:bg-slate-700': currentTab !== 'dashboard'}" class="block py-2.5 px-4 rounded transition duration-200">
<i class="fas fa-chart-line w-6"></i> 识别记录
</a>
<a href="#" @click.prevent="currentTab = 'files'" :class="{'bg-blue-600': currentTab === 'files', 'hover:bg-slate-700': currentTab !== 'files'}" class="block py-2.5 px-4 rounded transition duration-200">
<i class="fas fa-folder-open w-6"></i> 文件管理
</a>
<a href="#" @click.prevent="currentTab = 'settings'" :class="{'bg-blue-600': currentTab === 'settings', 'hover:bg-slate-700': currentTab !== 'settings'}" class="block py-2.5 px-4 rounded transition duration-200">
<i class="fas fa-cogs w-6"></i> 系统设置
</a>
</nav>
<div class="p-4 border-t border-slate-700">
<button @click="logout" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded transition duration-200">
<i class="fas fa-sign-out-alt"></i> 退出登录
</button>
</div>
</aside>
<!-- 内容区域 -->
<main class="flex-1 overflow-y-auto bg-gray-50 p-8">
<!-- 识别记录 Dashboard -->
<div v-if="currentTab === 'dashboard'">
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">最近识别记录</h2>
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full leading-normal">
<thead>
<tr>
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">时间</th>
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">类型</th>
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Prompt / 详情</th>
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">状态</th>
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(record, index) in history" :key="index" class="hover:bg-gray-50">
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
{{ formatDate(record.timestamp) }}
</td>
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
<span :class="getTypeBadgeClass(record.type)" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
{{ record.type }}
</span>
</td>
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm max-w-xs truncate" :title="record.details">
{{ record.details }}
</td>
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
<span :class="record.status === 'success' ? 'text-green-900 bg-green-200' : 'text-red-900 bg-red-200'" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
{{ record.status }}
</span>
</td>
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
<button v-if="record.result_path" @click="viewResult(record.result_path)" class="text-blue-600 hover:text-blue-900 mr-3">
查看
</button>
</td>
</tr>
<tr v-if="history.length === 0">
<td colspan="5" class="px-5 py-5 border-b border-gray-200 bg-white text-sm text-center text-gray-500">
暂无记录
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-4 flex justify-end">
<button @click="fetchHistory" class="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded text-sm">
<i class="fas fa-sync-alt"></i> 刷新
</button>
</div>
</div>
<!-- 文件管理 Files -->
<div v-if="currentTab === 'files'">
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">文件资源管理 (static/results)</h2>
<div class="bg-white rounded-lg shadow p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<div class="text-sm text-gray-600">
<span class="font-bold">当前路径:</span> /static/results/{{ currentPath }}
<button v-if="currentPath" @click="navigateUp" class="ml-2 text-blue-500 hover:underline text-xs">
<i class="fas fa-level-up-alt"></i> 返回上一级
</button>
</div>
<button @click="fetchFiles" class="text-blue-500 hover:text-blue-700">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div v-for="file in files" :key="file.name" class="border rounded-lg p-2 hover:shadow-md transition cursor-pointer relative group">
<!-- Folder -->
<div v-if="file.is_dir" @click="enterDir(file.name)" class="flex flex-col items-center justify-center h-32">
<i class="fas fa-folder text-yellow-400 text-4xl mb-2"></i>
<span class="text-xs text-center break-all px-1">{{ file.name }}</span>
<span class="text-xs text-gray-400">{{ file.count }} 项</span>
</div>
<!-- Image File -->
<div v-else-if="isImage(file.name)" @click="previewImage(file.path)" class="flex flex-col items-center justify-center h-32">
<img :src="file.url" class="h-20 w-auto object-contain mb-2 rounded" loading="lazy">
<span class="text-xs text-center break-all px-1 truncate w-full">{{ file.name }}</span>
</div>
<!-- Other File -->
<div v-else class="flex flex-col items-center justify-center h-32">
<i class="fas fa-file text-gray-400 text-4xl mb-2"></i>
<span class="text-xs text-center break-all px-1">{{ file.name }}</span>
</div>
<!-- Delete Button (Hover) -->
<button @click.stop="deleteFile(file.name)" class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition hover:bg-red-700" title="删除">
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
<div v-if="files.length === 0" class="text-center py-10 text-gray-500">
此目录下没有文件
</div>
</div>
</div>
<!-- 系统设置 Settings -->
<div v-if="currentTab === 'settings'">
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">系统设置</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-bold mb-4 text-gray-700">自动清理配置</h3>
<div class="space-y-3">
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">状态</span>
<span class="font-mono bg-green-100 text-green-800 px-2 rounded text-sm">运行中</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">文件保留时长</span>
<span class="font-mono">3600 秒 (1小时)</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">检查间隔</span>
<span class="font-mono">600 秒 (10分钟)</span>
</div>
<div class="mt-4">
<button @click="triggerCleanup" :disabled="cleaning" class="w-full bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded transition">
<i class="fas fa-broom mr-2"></i> {{ cleaning ? '清理中...' : '立即执行清理' }}
</button>
<p class="text-xs text-gray-500 mt-2 text-center">将删除所有超过保留时长的文件</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-bold mb-4 text-gray-700">系统信息</h3>
<div class="space-y-3">
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">模型</span>
<span class="font-mono">SAM3</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">多模态模型</span>
<span class="font-mono">Qwen-VL-Max</span>
</div>
<div class="flex justify-between border-b pb-2">
<span class="text-gray-600">设备</span>
<span class="font-mono">{{ deviceInfo }}</span>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 图片预览模态框 -->
<div v-if="previewUrl" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90" @click="previewUrl = null">
<div class="relative max-w-4xl max-h-screen p-4">
<img :src="previewUrl" class="max-h-[90vh] max-w-full rounded shadow-lg" @click.stop>
<button class="absolute top-0 right-0 m-4 text-white text-3xl font-bold hover:text-gray-300" @click="previewUrl = null">&times;</button>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted, computed } = Vue;
createApp({
setup() {
const isLoggedIn = ref(false);
const password = ref('');
const loginError = ref('');
const currentTab = ref('dashboard');
const history = ref([]);
const files = ref([]);
const currentPath = ref('');
const previewUrl = ref(null);
const cleaning = ref(false);
const deviceInfo = ref('Loading...');
// 检查登录状态
const checkLogin = () => {
const token = localStorage.getItem('admin_token');
if (token) {
isLoggedIn.value = true;
fetchHistory();
fetchSystemInfo();
}
};
const login = async () => {
try {
const formData = new FormData();
formData.append('password', password.value);
const res = await axios.post('/admin/login', formData);
if (res.data.status === 'success') {
localStorage.setItem('admin_token', 'logged_in'); // 简单标记实际由Cookie控制
isLoggedIn.value = true;
loginError.value = '';
fetchHistory();
fetchSystemInfo();
}
} catch (e) {
loginError.value = '密码错误';
}
};
const logout = () => {
localStorage.removeItem('admin_token');
document.cookie = "admin_session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
isLoggedIn.value = false;
password.value = '';
};
const fetchHistory = async () => {
try {
const res = await axios.get('/admin/api/history');
history.value = res.data.reverse(); // 最新在前
} catch (e) {
if (e.response && e.response.status === 401) logout();
}
};
const fetchFiles = async () => {
try {
const res = await axios.get(`/admin/api/files?path=${currentPath.value}`);
files.value = res.data;
} catch (e) {
console.error(e);
}
};
const fetchSystemInfo = async () => {
try {
const res = await axios.get('/admin/api/config');
deviceInfo.value = res.data.device;
} catch (e) {
console.error(e);
}
};
const enterDir = (dirName) => {
currentPath.value = currentPath.value ? `${currentPath.value}/${dirName}` : dirName;
fetchFiles();
};
const navigateUp = () => {
if (!currentPath.value) return;
const parts = currentPath.value.split('/');
parts.pop();
currentPath.value = parts.join('/');
fetchFiles();
};
const deleteFile = async (name) => {
if (!confirm(`确定要删除 ${name} 吗?`)) return;
try {
const fullPath = currentPath.value ? `${currentPath.value}/${name}` : name;
await axios.delete(`/admin/api/files/${fullPath}`);
fetchFiles();
} catch (e) {
alert('删除失败: ' + e.message);
}
};
const triggerCleanup = async () => {
cleaning.value = true;
try {
const res = await axios.post('/admin/api/cleanup');
alert(`清理完成: ${res.data.message}`);
fetchFiles(); // 刷新文件列表
} catch (e) {
alert('清理失败');
} finally {
cleaning.value = false;
}
};
const viewResult = (path) => {
// path like "results/..."
// We need to parse this. If it's a directory, go to files tab. If image, preview.
// For simplicity, let's assume it links to the folder in files tab
currentTab.value = 'files';
// Extract folder name from path if possible, or just go to root
const match = path.match(/results\/([^\/]+)/);
if (match) {
currentPath.value = match[1];
fetchFiles();
} else {
currentPath.value = '';
fetchFiles();
}
};
const previewImage = (path) => {
previewUrl.value = path;
};
const isImage = (name) => {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(name);
};
const formatDate = (ts) => {
return new Date(ts * 1000).toLocaleString();
};
const getTypeBadgeClass = (type) => {
const map = {
'general': 'bg-blue-100 text-blue-800',
'tarot': 'bg-purple-100 text-purple-800',
'face': 'bg-pink-100 text-pink-800'
};
return map[type] || 'bg-gray-100 text-gray-800';
};
// Watch tab change to fetch data
Vue.watch(currentTab, (newTab) => {
if (newTab === 'files') fetchFiles();
if (newTab === 'dashboard') fetchHistory();
});
onMounted(() => {
checkLogin();
});
return {
isLoggedIn, password, loginError, login, logout,
currentTab, history, files, currentPath,
enterDir, navigateUp, deleteFile, triggerCleanup,
viewResult, previewImage, isImage, previewUrl,
formatDate, getTypeBadgeClass, cleaning, deviceInfo
};
}
}).mount('#app');
</script>
</body>
</html>