feat(auth): add JWT login screen, production Dockerfile and Azure Container App Bicep
- Backend: POST /api/auth/login endpoint (PyJWT HS256, 24h expiry) require_auth before_request middleware protecting all /api/* routes except /login and /health; wsgi.py entry point for gunicorn; Flask serves compiled Vue SPA in production - Frontend: LoginView.vue (MiroFish dark aesthetic), auth.js reactive store, Axios Bearer token injection + 401 → /login redirect, Vue Router global guard protecting all routes - i18n: login keys added to en/zh/es/ca locale files - Dockerfile: multi-stage build (node:20-slim → python:3.11-slim + gunicorn), single port 5001 - Azure: container-app.bicep following CTTI guidelines — Log Analytics (NOR0016-C 90d retention), Container Apps Environment, all .env vars as env vars (secrets via secretRef, plain values inline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
437e37febf
commit
b5c4d4a336
11
.env.example
11
.env.example
|
|
@ -13,4 +13,13 @@ ZEP_API_KEY=your_zep_api_key_here
|
|||
# 注意如果不使用加速配置,env文件中就不要出现下面的配置项
|
||||
LLM_BOOST_API_KEY=your_api_key_here
|
||||
LLM_BOOST_BASE_URL=your_base_url_here
|
||||
LLM_BOOST_MODEL_NAME=your_model_name_here
|
||||
LLM_BOOST_MODEL_NAME=your_model_name_here
|
||||
|
||||
# ===== Autenticació =====
|
||||
# Contrasenya de l'usuari "demo" — OBLIGATORI establir en producció
|
||||
# Default buit = login deshabilitat fins que s'estableixi
|
||||
DEMO_PASSWORD=
|
||||
|
||||
# Flask secret key — per signar tokens JWT
|
||||
# Generar amb: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=your-secret-key-here
|
||||
66
Dockerfile
66
Dockerfile
|
|
@ -1,29 +1,59 @@
|
|||
FROM python:3.11
|
||||
# ─────────────────────────────────────────────
|
||||
# Stage 1: Build del frontend Vue
|
||||
# ─────────────────────────────────────────────
|
||||
FROM node:20-slim AS frontend-builder
|
||||
|
||||
# 安装 Node.js (满足 >=18)及必要工具
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /build
|
||||
|
||||
# 从 uv 官方镜像复制 uv
|
||||
# Copiar manifests primer per aprofitar la caché de capes Docker
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copiar el codi font i els fitxers de localització compartits
|
||||
COPY frontend/ ./
|
||||
COPY locales/ ../locales/
|
||||
|
||||
# Build de producció (genera dist/)
|
||||
RUN npm run build
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Stage 2: Imatge de producció Flask + gunicorn
|
||||
# ─────────────────────────────────────────────
|
||||
FROM python:3.11-slim AS production
|
||||
|
||||
# Instal·lar uv per gestionar dependències Python
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制依赖描述文件以利用缓存
|
||||
COPY package.json package-lock.json ./
|
||||
COPY frontend/package.json frontend/package-lock.json ./frontend/
|
||||
# Copiar i instal·lar dependències Python (aprofita caché si pyproject.toml no canvia)
|
||||
COPY backend/pyproject.toml backend/uv.lock ./backend/
|
||||
RUN cd backend && uv sync --frozen --no-dev
|
||||
|
||||
# 安装依赖(Node + Python)
|
||||
RUN npm ci \
|
||||
&& npm ci --prefix frontend \
|
||||
&& cd backend && uv sync --frozen
|
||||
# Copiar el codi font del backend i els fitxers compartits
|
||||
COPY backend/ ./backend/
|
||||
COPY locales/ ./locales/
|
||||
|
||||
# 复制项目源码
|
||||
COPY . .
|
||||
# Copiar el frontend compilat al path que Flask utilitza per servir el SPA
|
||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
||||
|
||||
EXPOSE 3000 5001
|
||||
# Variables d'entorn de producció
|
||||
ENV FLASK_DEBUG=False \
|
||||
FLASK_HOST=0.0.0.0 \
|
||||
FLASK_PORT=5001 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# 同时启动前后端(开发模式)
|
||||
CMD ["npm", "run", "dev"]
|
||||
EXPOSE 5001
|
||||
|
||||
# gunicorn: 1 worker (Container Apps escala horitzontalment via rèpliques)
|
||||
# threads=4 per gestionar concurrència sense multiprocessing
|
||||
# timeout=120s per a les operacions LLM de llarga durada
|
||||
CMD ["backend/.venv/bin/gunicorn", \
|
||||
"--bind", "0.0.0.0:5001", \
|
||||
"--workers", "1", \
|
||||
"--threads", "4", \
|
||||
"--timeout", "120", \
|
||||
"--worker-class", "gthread", \
|
||||
"--chdir", "/app/backend", \
|
||||
"wsgi:application"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MiroFish — Azure Container App
|
||||
// Segueix les directrius CTTI per a desplegament d'aplicacions a Azure
|
||||
// (DA/TM: Azure Container Apps com a plataforma de desplegament de contenidors)
|
||||
//
|
||||
// Paràmetres que l'equip d'ops ha de proporcionar en desplegar:
|
||||
// Secrets (@secure): demoPassword, llmApiKey, llmBoostApiKey, zepApiKey, secretKey
|
||||
// Valors: containerImage, llmBaseUrl, llmModelName, llmBoostBaseUrl,
|
||||
// llmBoostModelName, oasisDefaultMaxRounds,
|
||||
// reportAgentMaxToolCalls, reportAgentMaxReflectionRounds,
|
||||
// reportAgentTemperature
|
||||
//
|
||||
// Extensions pendents per a l'equip d'operacions:
|
||||
// - DNS: afegir CNAME a *.intranet.gencat.cat (PRE) / *.gencat.cat (PRO)
|
||||
// - Xarxa: integrar en VNet Hub-Spoke + Private Link per a serveis interns
|
||||
// (descomentar el bloc vnetConfiguration a l'entorn)
|
||||
// - TLS: certificat gestionat via Container Apps o Azure Front Door
|
||||
// - ingress.external: canviar a false per a accés exclusiu per intranet
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@description('Nom base del projecte (es fa servir per als noms dels recursos)')
|
||||
param projectName string = 'mirofish'
|
||||
|
||||
@description('Localització Azure dels recursos')
|
||||
param location string = resourceGroup().location
|
||||
|
||||
@description('Imatge Docker completa (registry/imatge:tag)')
|
||||
param containerImage string
|
||||
|
||||
// ─── Paràmetres secrets (@secure — mai visibles als logs de desplegament) ────
|
||||
|
||||
@description('Contrasenya de l\'usuari demo')
|
||||
@secure()
|
||||
param demoPassword string
|
||||
|
||||
@description('Clau de l\'API LLM principal (OpenAI-compatible)')
|
||||
@secure()
|
||||
param llmApiKey string
|
||||
|
||||
@description('Clau de l\'API LLM acceleradora (opcional, deixar buit si no s\'usa)')
|
||||
@secure()
|
||||
param llmBoostApiKey string = ''
|
||||
|
||||
@description('Clau de l\'API Zep Cloud')
|
||||
@secure()
|
||||
param zepApiKey string
|
||||
|
||||
@description('SECRET_KEY de Flask per a JWT (generar amb: python -c "import secrets; print(secrets.token_hex(32))")')
|
||||
@secure()
|
||||
param secretKey string
|
||||
|
||||
// ─── Paràmetres LLM principal ─────────────────────────────────────────────────
|
||||
|
||||
@description('URL base de l\'API LLM principal')
|
||||
param llmBaseUrl string = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
|
||||
@description('Nom del model LLM principal')
|
||||
param llmModelName string = 'qwen-plus'
|
||||
|
||||
// ─── Paràmetres LLM accelerador (opcionals) ──────────────────────────────────
|
||||
|
||||
@description('URL base de l\'API LLM acceleradora (opcional)')
|
||||
param llmBoostBaseUrl string = ''
|
||||
|
||||
@description('Nom del model LLM accelerador (opcional)')
|
||||
param llmBoostModelName string = ''
|
||||
|
||||
// ─── Paràmetres de simulació OASIS ───────────────────────────────────────────
|
||||
|
||||
@description('Nombre màxim de rondes per a la simulació OASIS')
|
||||
param oasisDefaultMaxRounds string = '10'
|
||||
|
||||
// ─── Paràmetres del Report Agent ─────────────────────────────────────────────
|
||||
|
||||
@description('Nombre màxim de crides a eines del Report Agent')
|
||||
param reportAgentMaxToolCalls string = '5'
|
||||
|
||||
@description('Nombre màxim de rondes de reflexió del Report Agent')
|
||||
param reportAgentMaxReflectionRounds string = '2'
|
||||
|
||||
@description('Temperatura del model LLM per al Report Agent')
|
||||
param reportAgentTemperature string = '0.5'
|
||||
|
||||
// ─── Log Analytics Workspace ──────────────────────────────────────────────────
|
||||
// NOR0016-C: retenció mínima de logs de seguretat = 90 dies
|
||||
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
|
||||
name: '${projectName}-logs'
|
||||
location: location
|
||||
properties: {
|
||||
sku: {
|
||||
name: 'PerGB2018'
|
||||
}
|
||||
retentionInDays: 90
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Container Apps Environment ───────────────────────────────────────────────
|
||||
resource containerAppsEnv 'Microsoft.App/managedEnvironments@2023-05-01' = {
|
||||
name: '${projectName}-env'
|
||||
location: location
|
||||
properties: {
|
||||
appLogsConfiguration: {
|
||||
destination: 'log-analytics'
|
||||
logAnalyticsConfiguration: {
|
||||
customerId: logAnalytics.properties.customerId
|
||||
sharedKey: logAnalytics.listKeys().primarySharedKey
|
||||
}
|
||||
}
|
||||
// TODO (ops): descomentar per integrar en VNet Hub-Spoke CTTI
|
||||
// vnetConfiguration: {
|
||||
// infrastructureSubnetId: '/subscriptions/.../subnets/container-apps-subnet'
|
||||
// internal: true // true = accés únicament per intranet
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Container App ─────────────────────────────────────────────────────────────
|
||||
resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
|
||||
name: projectName
|
||||
location: location
|
||||
properties: {
|
||||
managedEnvironmentId: containerAppsEnv.id
|
||||
|
||||
configuration: {
|
||||
// Secrets de Container Apps — mai en text pla a les variables d'entorn
|
||||
secrets: [
|
||||
{ name: 'demo-password', value: demoPassword }
|
||||
{ name: 'llm-api-key', value: llmApiKey }
|
||||
{ name: 'llm-boost-api-key', value: llmBoostApiKey }
|
||||
{ name: 'zep-api-key', value: zepApiKey }
|
||||
{ name: 'secret-key', value: secretKey }
|
||||
]
|
||||
|
||||
// Ingrés: port únic 5001 (Flask serveix frontend + API)
|
||||
ingress: {
|
||||
external: true // TODO (ops): canviar a false en entorn intranet CTTI
|
||||
targetPort: 5001
|
||||
transport: 'http'
|
||||
// TODO (ops): afegir domini corporatiu quan estigui assignat
|
||||
// customDomains: [
|
||||
// {
|
||||
// name: 'mirofish.intranet.gencat.cat' // PRE
|
||||
// certificateId: '<id-certificat-aca>'
|
||||
// bindingType: 'SniEnabled'
|
||||
// }
|
||||
// ]
|
||||
}
|
||||
|
||||
// Revisió única activa (zero-downtime via revision-based deployments)
|
||||
activeRevisionsMode: 'Single'
|
||||
}
|
||||
|
||||
template: {
|
||||
containers: [
|
||||
{
|
||||
name: projectName
|
||||
image: containerImage
|
||||
|
||||
env: [
|
||||
// ── Secrets referenciats per nom (mai valor en text pla) ──
|
||||
{ name: 'DEMO_PASSWORD', secretRef: 'demo-password' }
|
||||
{ name: 'LLM_API_KEY', secretRef: 'llm-api-key' }
|
||||
{ name: 'LLM_BOOST_API_KEY', secretRef: 'llm-boost-api-key' }
|
||||
{ name: 'ZEP_API_KEY', secretRef: 'zep-api-key' }
|
||||
{ name: 'SECRET_KEY', secretRef: 'secret-key' }
|
||||
|
||||
// ── Variables no sensibles ──
|
||||
{ name: 'LLM_BASE_URL', value: llmBaseUrl }
|
||||
{ name: 'LLM_MODEL_NAME', value: llmModelName }
|
||||
{ name: 'LLM_BOOST_BASE_URL', value: llmBoostBaseUrl }
|
||||
{ name: 'LLM_BOOST_MODEL_NAME', value: llmBoostModelName }
|
||||
|
||||
// ── Simulació OASIS ──
|
||||
{ name: 'OASIS_DEFAULT_MAX_ROUNDS', value: oasisDefaultMaxRounds }
|
||||
|
||||
// ── Report Agent ──
|
||||
{ name: 'REPORT_AGENT_MAX_TOOL_CALLS', value: reportAgentMaxToolCalls }
|
||||
{ name: 'REPORT_AGENT_MAX_REFLECTION_ROUNDS', value: reportAgentMaxReflectionRounds }
|
||||
{ name: 'REPORT_AGENT_TEMPERATURE', value: reportAgentTemperature }
|
||||
|
||||
// ── Flask ──
|
||||
{ name: 'FLASK_DEBUG', value: 'False' }
|
||||
]
|
||||
|
||||
// Recursos mínim viable — escalar horitzontalment via rèpliques
|
||||
resources: {
|
||||
cpu: json('0.5')
|
||||
memory: '1Gi'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Escalat: mínim 1 rèplica (evita cold start en PRO), màxim 10
|
||||
scale: {
|
||||
minReplicas: 1
|
||||
maxReplicas: 10
|
||||
rules: [
|
||||
{
|
||||
name: 'http-scaling'
|
||||
http: {
|
||||
metadata: {
|
||||
concurrentRequests: '20'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Outputs ──────────────────────────────────────────────────────────────────
|
||||
@description('FQDN de l\'aplicació desplegada')
|
||||
output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn
|
||||
|
||||
@description('Nom del recurs Container App')
|
||||
output containerAppName string = containerApp.name
|
||||
|
||||
@description('ID del workspace de Log Analytics')
|
||||
output logAnalyticsWorkspaceId string = logAnalytics.id
|
||||
|
|
@ -9,45 +9,67 @@ import warnings
|
|||
# 需要在所有其他导入之前设置
|
||||
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
|
||||
|
||||
from flask import Flask, request
|
||||
import jwt
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
from .config import Config
|
||||
from .utils.logger import setup_logger, get_logger
|
||||
|
||||
# Rutes públiques que no requereixen token JWT
|
||||
_PUBLIC_PATHS = {'/health', '/api/auth/login'}
|
||||
|
||||
|
||||
def create_app(config_class=Config):
|
||||
"""Flask应用工厂函数"""
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
|
||||
# 设置JSON编码:确保中文直接显示(而不是 \uXXXX 格式)
|
||||
# Flask >= 2.3 使用 app.json.ensure_ascii,旧版本使用 JSON_AS_ASCII 配置
|
||||
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
|
||||
app.json.ensure_ascii = False
|
||||
|
||||
|
||||
# 设置日志
|
||||
logger = setup_logger('mirofish')
|
||||
|
||||
|
||||
# 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次)
|
||||
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
||||
debug_mode = app.config.get('DEBUG', False)
|
||||
should_log_startup = not debug_mode or is_reloader_process
|
||||
|
||||
|
||||
if should_log_startup:
|
||||
logger.info("=" * 50)
|
||||
logger.info("MiroFish Backend 启动中...")
|
||||
logger.info("=" * 50)
|
||||
|
||||
|
||||
# 启用CORS
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
|
||||
# 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程)
|
||||
from .services.simulation_runner import SimulationRunner
|
||||
SimulationRunner.register_cleanup()
|
||||
if should_log_startup:
|
||||
logger.info("已注册模拟进程清理函数")
|
||||
|
||||
|
||||
# Middleware d'autenticació JWT — s'executa ABANS del log_request (ordre FIFO)
|
||||
@app.before_request
|
||||
def require_auth():
|
||||
if request.path in _PUBLIC_PATHS or request.method == 'OPTIONS':
|
||||
return None
|
||||
if not request.path.startswith('/api/'):
|
||||
return None # rutes estàtiques del SPA no requereixen token
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return jsonify({'success': False, 'error': 'Missing token'}), 401
|
||||
token = auth_header[len('Bearer '):]
|
||||
try:
|
||||
jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'success': False, 'error': 'Token expired'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'success': False, 'error': 'Invalid token'}), 401
|
||||
|
||||
# 请求日志中间件
|
||||
@app.before_request
|
||||
def log_request():
|
||||
|
|
@ -55,26 +77,40 @@ def create_app(config_class=Config):
|
|||
logger.debug(f"请求: {request.method} {request.path}")
|
||||
if request.content_type and 'json' in request.content_type:
|
||||
logger.debug(f"请求体: {request.get_json(silent=True)}")
|
||||
|
||||
|
||||
@app.after_request
|
||||
def log_response(response):
|
||||
logger = get_logger('mirofish.request')
|
||||
logger.debug(f"响应: {response.status_code}")
|
||||
return response
|
||||
|
||||
# 注册蓝图
|
||||
from .api import graph_bp, simulation_bp, report_bp
|
||||
|
||||
# 注册蓝图 (auth primer, luego els existents)
|
||||
from .api import graph_bp, simulation_bp, report_bp, auth_bp
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
||||
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
||||
app.register_blueprint(report_bp, url_prefix='/api/report')
|
||||
|
||||
|
||||
# 健康检查
|
||||
@app.route('/health')
|
||||
def health():
|
||||
return {'status': 'ok', 'service': 'MiroFish Backend'}
|
||||
|
||||
|
||||
# Servir el SPA de Vue compilat (producció: quan existeix frontend/dist/)
|
||||
# La catch-all es registra al final perquè les rutes /api/* tinguin prioritat
|
||||
import os as _os
|
||||
from flask import send_from_directory, send_file as _send_file
|
||||
_dist = _os.path.join(_os.path.dirname(__file__), '../../frontend/dist')
|
||||
if _os.path.isdir(_dist):
|
||||
@app.route('/', defaults={'path': ''})
|
||||
@app.route('/<path:path>')
|
||||
def serve_spa(path):
|
||||
f = _os.path.join(_dist, path)
|
||||
if path and _os.path.isfile(f):
|
||||
return send_from_directory(_dist, path)
|
||||
return _send_file(_os.path.join(_dist, 'index.html'))
|
||||
|
||||
if should_log_startup:
|
||||
logger.info("MiroFish Backend 启动完成")
|
||||
|
||||
return app
|
||||
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ from flask import Blueprint
|
|||
graph_bp = Blueprint('graph', __name__)
|
||||
simulation_bp = Blueprint('simulation', __name__)
|
||||
report_bp = Blueprint('report', __name__)
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
from . import graph # noqa: E402, F401
|
||||
from . import simulation # noqa: E402, F401
|
||||
from . import report # noqa: E402, F401
|
||||
from . import auth # noqa: E402, F401
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"""
|
||||
Autenticació bàsica: POST /api/auth/login
|
||||
Retorna JWT HS256 amb 24h d'expiració.
|
||||
Si DEMO_PASSWORD és buida (no configurada), sempre retorna 401.
|
||||
"""
|
||||
import jwt
|
||||
import datetime
|
||||
from flask import request, jsonify, current_app
|
||||
from . import auth_bp
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get('username', '')
|
||||
password = data.get('password', '')
|
||||
|
||||
expected = current_app.config.get('DEMO_PASSWORD', '')
|
||||
if username != 'demo' or not expected or password != expected:
|
||||
return jsonify({'success': False, 'error': 'Invalid credentials'}), 401
|
||||
|
||||
payload = {
|
||||
'sub': username,
|
||||
'iat': datetime.datetime.utcnow(),
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24),
|
||||
}
|
||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
return jsonify({'success': True, 'token': token})
|
||||
|
|
@ -22,6 +22,7 @@ class Config:
|
|||
|
||||
# Flask配置
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
|
||||
DEMO_PASSWORD = os.environ.get('DEMO_PASSWORD', '')
|
||||
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
|
||||
|
||||
# JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ dependencies = [
|
|||
# 工具库
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"gunicorn>=22.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
"""Punt d'entrada WSGI per a gunicorn en producció."""
|
||||
from app import create_app
|
||||
|
||||
application = create_app()
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import axios from 'axios'
|
||||
import i18n from '../i18n'
|
||||
import authState, { clearToken } from '../store/auth'
|
||||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
|
|
@ -14,6 +15,9 @@ const service = axios.create({
|
|||
service.interceptors.request.use(
|
||||
config => {
|
||||
config.headers['Accept-Language'] = i18n.global.locale.value
|
||||
if (authState.token) {
|
||||
config.headers['Authorization'] = `Bearer ${authState.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
|
|
@ -26,28 +30,37 @@ service.interceptors.request.use(
|
|||
service.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
|
||||
|
||||
// 如果返回的状态码不是success,则抛出错误
|
||||
if (!res.success && res.success !== undefined) {
|
||||
console.error('API Error:', res.error || res.message || 'Unknown error')
|
||||
return Promise.reject(new Error(res.error || res.message || 'Error'))
|
||||
}
|
||||
|
||||
|
||||
return res
|
||||
},
|
||||
error => {
|
||||
console.error('Response error:', error)
|
||||
|
||||
|
||||
// Token invàlid o expirat: netejar sessió i redirigir al login
|
||||
if (error.response?.status === 401) {
|
||||
clearToken()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 处理超时
|
||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||
console.error('Request timeout')
|
||||
}
|
||||
|
||||
|
||||
// 处理网络错误
|
||||
if (error.message === 'Network Error') {
|
||||
console.error('Network error - please check your connection')
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
|
@ -59,7 +72,7 @@ export const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000)
|
|||
return await requestFn()
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) throw error
|
||||
|
||||
|
||||
console.warn(`Request failed, retrying (${i + 1}/${maxRetries})...`)
|
||||
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,16 @@ import SimulationView from '../views/SimulationView.vue'
|
|||
import SimulationRunView from '../views/SimulationRunView.vue'
|
||||
import ReportView from '../views/ReportView.vue'
|
||||
import InteractionView from '../views/InteractionView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import authState from '../store/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
|
|
@ -49,4 +57,11 @@ const router = createRouter({
|
|||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta?.public) return next()
|
||||
if (!authState.isAuthenticated) return next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
if (to.name === 'Login') return next({ name: 'Home' })
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { reactive } from 'vue'
|
||||
|
||||
const AUTH_KEY = 'mirofish_token'
|
||||
|
||||
const state = reactive({
|
||||
token: localStorage.getItem(AUTH_KEY) || null,
|
||||
isAuthenticated: !!localStorage.getItem(AUTH_KEY)
|
||||
})
|
||||
|
||||
export function setToken(token) {
|
||||
state.token = token
|
||||
state.isAuthenticated = true
|
||||
localStorage.setItem(AUTH_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
state.token = null
|
||||
state.isAuthenticated = false
|
||||
localStorage.removeItem(AUTH_KEY)
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return state.token
|
||||
}
|
||||
|
||||
export default state
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
<template>
|
||||
<div class="login-container">
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">MIROFISH</div>
|
||||
</nav>
|
||||
|
||||
<main class="login-main">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<span class="tag">AUTH</span>
|
||||
<h1 class="title">{{ $t('login.title') }}</h1>
|
||||
<p class="subtitle">{{ $t('login.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<div class="field">
|
||||
<label class="field-label" for="login-username">{{ $t('login.username') }}</label>
|
||||
<input
|
||||
id="login-username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
class="field-input"
|
||||
autocomplete="username"
|
||||
:disabled="loading"
|
||||
:placeholder="$t('login.usernamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="login-password">{{ $t('login.password') }}</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="current-password"
|
||||
:disabled="loading"
|
||||
:placeholder="$t('login.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-msg" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-btn"
|
||||
:disabled="loading || !canSubmit"
|
||||
>
|
||||
<span v-if="loading">{{ $t('login.loading') }}</span>
|
||||
<span v-else>{{ $t('login.submit') }} <span class="btn-arrow" aria-hidden="true">→</span></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import service from '../api/index'
|
||||
import { setToken } from '../store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = ref({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const canSubmit = computed(
|
||||
() => form.value.username.trim() !== '' && form.value.password !== ''
|
||||
)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await service.post('/api/auth/login', {
|
||||
username: form.value.username,
|
||||
password: form.value.password
|
||||
})
|
||||
setToken(res.token)
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
error.value = t('login.invalidCredentials')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
|
||||
color: #000000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
height: 60px;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.login-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: 1px solid #e5e5e5;
|
||||
padding: 48px 40px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #ff4500;
|
||||
color: #ffffff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
border: 1px solid #e5e5e5;
|
||||
background: #fafafa;
|
||||
padding: 12px 16px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #000000;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: #000000;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.field-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #ff4500;
|
||||
border-left: 3px solid #ff4500;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 14px 24px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #ff4500;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: #e5e5e5;
|
||||
color: #999999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-arrow {
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -75,7 +75,13 @@
|
|||
"layoutGraph": "Graf",
|
||||
"layoutSplit": "Dividit",
|
||||
"layoutWorkbench": "Taulell",
|
||||
"stepNames": ["Construcció del graf", "Configuració de l'entorn", "Execució de la simulació", "Generació de l'informe", "Interacció profunda"]
|
||||
"stepNames": [
|
||||
"Construcció del graf",
|
||||
"Configuració de l'entorn",
|
||||
"Execució de la simulació",
|
||||
"Generació de l'informe",
|
||||
"Interacció profunda"
|
||||
]
|
||||
},
|
||||
"step1": {
|
||||
"ontologyGeneration": "Generació d'ontologia",
|
||||
|
|
@ -661,5 +667,16 @@
|
|||
"llmSelectAgentFailed": "La selecció d'agents per LLM ha fallat, usant la selecció per defecte: {error}",
|
||||
"generateInterviewQuestionsFailed": "Error en generar les preguntes d'entrevista: {error}",
|
||||
"generateInterviewSummaryFailed": "Error en generar el resum de l'entrevista: {error}"
|
||||
},
|
||||
"login": {
|
||||
"title": "Accés",
|
||||
"subtitle": "// Accés autenticat requerit",
|
||||
"username": "Usuari",
|
||||
"usernamePlaceholder": "demo",
|
||||
"password": "Contrasenya",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"submit": "Entrar",
|
||||
"loading": "Autenticant...",
|
||||
"invalidCredentials": "Usuari o contrasenya incorrectes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,13 @@
|
|||
"layoutGraph": "Graph",
|
||||
"layoutSplit": "Split",
|
||||
"layoutWorkbench": "Workbench",
|
||||
"stepNames": ["Graph Build", "Env Setup", "Run Simulation", "Report Generation", "Deep Interaction"]
|
||||
"stepNames": [
|
||||
"Graph Build",
|
||||
"Env Setup",
|
||||
"Run Simulation",
|
||||
"Report Generation",
|
||||
"Deep Interaction"
|
||||
]
|
||||
},
|
||||
"step1": {
|
||||
"ontologyGeneration": "Ontology Generation",
|
||||
|
|
@ -661,5 +667,16 @@
|
|||
"llmSelectAgentFailed": "LLM agent selection failed, using default selection: {error}",
|
||||
"generateInterviewQuestionsFailed": "Failed to generate interview questions: {error}",
|
||||
"generateInterviewSummaryFailed": "Failed to generate interview summary: {error}"
|
||||
},
|
||||
"login": {
|
||||
"title": "Access",
|
||||
"subtitle": "// Authenticated access required",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "demo",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"submit": "Enter",
|
||||
"loading": "Authenticating...",
|
||||
"invalidCredentials": "Invalid username or password"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,13 @@
|
|||
"layoutGraph": "Grafo",
|
||||
"layoutSplit": "Doble columna",
|
||||
"layoutWorkbench": "Área de trabajo",
|
||||
"stepNames": ["Construcción del grafo", "Configuración del entorno", "Ejecutar simulación", "Generación de informe", "Interacción profunda"]
|
||||
"stepNames": [
|
||||
"Construcción del grafo",
|
||||
"Configuración del entorno",
|
||||
"Ejecutar simulación",
|
||||
"Generación de informe",
|
||||
"Interacción profunda"
|
||||
]
|
||||
},
|
||||
"step1": {
|
||||
"ontologyGeneration": "Generación de ontología",
|
||||
|
|
@ -661,5 +667,16 @@
|
|||
"llmSelectAgentFailed": "Selección de agente por LLM falló, usando selección por defecto: {error}",
|
||||
"generateInterviewQuestionsFailed": "Error al generar preguntas de entrevista: {error}",
|
||||
"generateInterviewSummaryFailed": "Error al generar resumen de entrevista: {error}"
|
||||
},
|
||||
"login": {
|
||||
"title": "Acceso",
|
||||
"subtitle": "// Acceso autenticado requerido",
|
||||
"username": "Usuario",
|
||||
"usernamePlaceholder": "demo",
|
||||
"password": "Contraseña",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"submit": "Entrar",
|
||||
"loading": "Autenticando...",
|
||||
"invalidCredentials": "Usuario o contraseña incorrectos"
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,13 @@
|
|||
"layoutGraph": "图谱",
|
||||
"layoutSplit": "双栏",
|
||||
"layoutWorkbench": "工作台",
|
||||
"stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"]
|
||||
"stepNames": [
|
||||
"图谱构建",
|
||||
"环境搭建",
|
||||
"开始模拟",
|
||||
"报告生成",
|
||||
"深度互动"
|
||||
]
|
||||
},
|
||||
"step1": {
|
||||
"ontologyGeneration": "本体生成",
|
||||
|
|
@ -661,5 +667,16 @@
|
|||
"llmSelectAgentFailed": "LLM选择Agent失败,使用默认选择: {error}",
|
||||
"generateInterviewQuestionsFailed": "生成采访问题失败: {error}",
|
||||
"generateInterviewSummaryFailed": "生成采访摘要失败: {error}"
|
||||
},
|
||||
"login": {
|
||||
"title": "访问",
|
||||
"subtitle": "// 需要身份验证",
|
||||
"username": "用户名",
|
||||
"usernamePlaceholder": "demo",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"submit": "登录",
|
||||
"loading": "验证中...",
|
||||
"invalidCredentials": "用户名或密码错误"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue