admin
This commit is contained in:
235
fastAPI_tarot.py
235
fastAPI_tarot.py
@@ -20,6 +20,8 @@ import json
|
||||
import traceback
|
||||
import re
|
||||
import asyncio
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -34,10 +36,10 @@ import matplotlib.pyplot as plt
|
||||
from PIL import Image
|
||||
|
||||
# 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.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, HTMLResponse, Response
|
||||
|
||||
# Dashscope (Aliyun Qwen) Imports
|
||||
import dashscope
|
||||
@@ -62,6 +64,10 @@ os.makedirs(RESULT_IMAGE_DIR, exist_ok=True)
|
||||
VALID_API_KEY = "123quant-speed"
|
||||
API_KEY_HEADER_NAME = "X-API-Key"
|
||||
|
||||
# Admin Config
|
||||
ADMIN_PASSWORD = "admin_secure_password" # 可以根据需求修改
|
||||
HISTORY_FILE = "history.json"
|
||||
|
||||
# Dashscope (Qwen-VL) 配置
|
||||
dashscope.api_key = 'sk-ce2404f55f744a1987d5ece61c6bac58'
|
||||
QWEN_MODEL = 'qwen-vl-max'
|
||||
@@ -219,6 +225,25 @@ def is_english(text: str) -> bool:
|
||||
return False
|
||||
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:
|
||||
"""
|
||||
使用 Qwen 模型将中文提示词翻译为英文
|
||||
@@ -640,6 +665,7 @@ async def segment(
|
||||
elif image_url:
|
||||
image = load_image_from_url(image_url)
|
||||
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)}")
|
||||
|
||||
processor = request.app.state.processor
|
||||
@@ -669,6 +695,7 @@ async def segment(
|
||||
processor.confidence_threshold = original_confidence
|
||||
|
||||
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)}")
|
||||
|
||||
# 4. 结果可视化与保存
|
||||
@@ -681,6 +708,7 @@ async def segment(
|
||||
else:
|
||||
filename = generate_and_save_result(image, inference_state)
|
||||
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)}")
|
||||
|
||||
file_url = request.url_for("static", path=f"results/{filename}")
|
||||
@@ -712,6 +740,8 @@ async def segment(
|
||||
})
|
||||
except Exception as 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)}")
|
||||
|
||||
response_content = {
|
||||
@@ -724,6 +754,7 @@ async def segment(
|
||||
if save_segment_images:
|
||||
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)
|
||||
|
||||
# ------------------------------------------
|
||||
@@ -753,6 +784,7 @@ async def segment_tarot(
|
||||
elif image_url:
|
||||
image = load_image_from_url(image_url)
|
||||
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)}")
|
||||
|
||||
processor = request.app.state.processor
|
||||
@@ -763,6 +795,7 @@ async def segment_tarot(
|
||||
output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
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)}")
|
||||
|
||||
# 核心逻辑:判断数量
|
||||
@@ -781,6 +814,7 @@ async def segment_tarot(
|
||||
except:
|
||||
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(
|
||||
status_code=400,
|
||||
content={
|
||||
@@ -795,6 +829,7 @@ async def segment_tarot(
|
||||
try:
|
||||
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
|
||||
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)}")
|
||||
|
||||
# 生成 URL 列表和元数据
|
||||
@@ -816,6 +851,7 @@ async def segment_tarot(
|
||||
except:
|
||||
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={
|
||||
"status": "success",
|
||||
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (已执行透视矫正)",
|
||||
@@ -847,6 +883,7 @@ async def recognize_tarot(
|
||||
elif image_url:
|
||||
image = load_image_from_url(image_url)
|
||||
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)}")
|
||||
|
||||
processor = request.app.state.processor
|
||||
@@ -856,6 +893,7 @@ async def recognize_tarot(
|
||||
output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
|
||||
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
|
||||
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)}")
|
||||
|
||||
detected_count = len(masks)
|
||||
@@ -883,6 +921,7 @@ async def recognize_tarot(
|
||||
spread_info = recognize_spread_with_qwen(temp_raw_path)
|
||||
|
||||
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(
|
||||
status_code=400,
|
||||
content={
|
||||
@@ -898,6 +937,7 @@ async def recognize_tarot(
|
||||
try:
|
||||
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
|
||||
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)}")
|
||||
|
||||
# 遍历每张卡片进行识别
|
||||
@@ -918,6 +958,7 @@ async def recognize_tarot(
|
||||
"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={
|
||||
"status": "success",
|
||||
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (含Qwen识别结果)",
|
||||
@@ -967,6 +1008,7 @@ async def segment_face(
|
||||
elif image_url:
|
||||
image = load_image_from_url(image_url)
|
||||
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)}")
|
||||
|
||||
processor = request.app.state.processor
|
||||
@@ -982,6 +1024,7 @@ async def segment_face(
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
append_to_history("face", prompt, "failed", details=f"Process Error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")
|
||||
|
||||
# 补全 URL
|
||||
@@ -994,10 +1037,196 @@ async def segment_face(
|
||||
relative_path = item.pop("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)
|
||||
|
||||
# ==========================================
|
||||
# 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__":
|
||||
|
||||
164
run_monitor.sh
Executable file
164
run_monitor.sh
Executable 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
399
static/admin.html
Normal 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">×</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>
|
||||
Reference in New Issue
Block a user