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
|
|
@ -14,3 +14,12 @@ ZEP_API_KEY=your_zep_api_key_here
|
||||||
LLM_BOOST_API_KEY=your_api_key_here
|
LLM_BOOST_API_KEY=your_api_key_here
|
||||||
LLM_BOOST_BASE_URL=your_base_url_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)及必要工具
|
WORKDIR /build
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends nodejs npm \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# 从 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/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 先复制依赖描述文件以利用缓存
|
# Copiar i instal·lar dependències Python (aprofita caché si pyproject.toml no canvia)
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
COPY frontend/package.json frontend/package-lock.json ./frontend/
|
|
||||||
COPY backend/pyproject.toml backend/uv.lock ./backend/
|
COPY backend/pyproject.toml backend/uv.lock ./backend/
|
||||||
|
RUN cd backend && uv sync --frozen --no-dev
|
||||||
|
|
||||||
# 安装依赖(Node + Python)
|
# Copiar el codi font del backend i els fitxers compartits
|
||||||
RUN npm ci \
|
COPY backend/ ./backend/
|
||||||
&& npm ci --prefix frontend \
|
COPY locales/ ./locales/
|
||||||
&& cd backend && uv sync --frozen
|
|
||||||
|
|
||||||
# 复制项目源码
|
# Copiar el frontend compilat al path que Flask utilitza per servir el SPA
|
||||||
COPY . .
|
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
|
||||||
|
|
||||||
# 同时启动前后端(开发模式)
|
EXPOSE 5001
|
||||||
CMD ["npm", "run", "dev"]
|
|
||||||
|
# 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,12 +9,16 @@ import warnings
|
||||||
# 需要在所有其他导入之前设置
|
# 需要在所有其他导入之前设置
|
||||||
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
|
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 flask_cors import CORS
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .utils.logger import setup_logger, get_logger
|
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):
|
def create_app(config_class=Config):
|
||||||
"""Flask应用工厂函数"""
|
"""Flask应用工厂函数"""
|
||||||
|
|
@ -48,6 +52,24 @@ def create_app(config_class=Config):
|
||||||
if should_log_startup:
|
if should_log_startup:
|
||||||
logger.info("已注册模拟进程清理函数")
|
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
|
@app.before_request
|
||||||
def log_request():
|
def log_request():
|
||||||
|
|
@ -62,8 +84,9 @@ def create_app(config_class=Config):
|
||||||
logger.debug(f"响应: {response.status_code}")
|
logger.debug(f"响应: {response.status_code}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# 注册蓝图
|
# 注册蓝图 (auth primer, luego els existents)
|
||||||
from .api import graph_bp, simulation_bp, report_bp
|
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(graph_bp, url_prefix='/api/graph')
|
||||||
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
||||||
app.register_blueprint(report_bp, url_prefix='/api/report')
|
app.register_blueprint(report_bp, url_prefix='/api/report')
|
||||||
|
|
@ -73,8 +96,21 @@ def create_app(config_class=Config):
|
||||||
def health():
|
def health():
|
||||||
return {'status': 'ok', 'service': 'MiroFish Backend'}
|
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:
|
if should_log_startup:
|
||||||
logger.info("MiroFish Backend 启动完成")
|
logger.info("MiroFish Backend 启动完成")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ from flask import Blueprint
|
||||||
graph_bp = Blueprint('graph', __name__)
|
graph_bp = Blueprint('graph', __name__)
|
||||||
simulation_bp = Blueprint('simulation', __name__)
|
simulation_bp = Blueprint('simulation', __name__)
|
||||||
report_bp = Blueprint('report', __name__)
|
report_bp = Blueprint('report', __name__)
|
||||||
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
from . import graph # noqa: E402, F401
|
from . import graph # noqa: E402, F401
|
||||||
from . import simulation # noqa: E402, F401
|
from . import simulation # noqa: E402, F401
|
||||||
from . import report # 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配置
|
# Flask配置
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
|
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'
|
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
|
||||||
|
|
||||||
# JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式)
|
# JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ dependencies = [
|
||||||
# 工具库
|
# 工具库
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
|
"pyjwt>=2.8.0",
|
||||||
|
"gunicorn>=22.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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 axios from 'axios'
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
|
import authState, { clearToken } from '../store/auth'
|
||||||
|
|
||||||
// 创建axios实例
|
// 创建axios实例
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
|
|
@ -14,6 +15,9 @@ const service = axios.create({
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
config => {
|
config => {
|
||||||
config.headers['Accept-Language'] = i18n.global.locale.value
|
config.headers['Accept-Language'] = i18n.global.locale.value
|
||||||
|
if (authState.token) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${authState.token}`
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
|
|
@ -38,6 +42,15 @@ service.interceptors.response.use(
|
||||||
error => {
|
error => {
|
||||||
console.error('Response error:', 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')) {
|
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||||
console.error('Request timeout')
|
console.error('Request timeout')
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,16 @@ import SimulationView from '../views/SimulationView.vue'
|
||||||
import SimulationRunView from '../views/SimulationRunView.vue'
|
import SimulationRunView from '../views/SimulationRunView.vue'
|
||||||
import ReportView from '../views/ReportView.vue'
|
import ReportView from '../views/ReportView.vue'
|
||||||
import InteractionView from '../views/InteractionView.vue'
|
import InteractionView from '../views/InteractionView.vue'
|
||||||
|
import LoginView from '../views/LoginView.vue'
|
||||||
|
import authState from '../store/auth'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: LoginView,
|
||||||
|
meta: { public: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
|
|
@ -49,4 +57,11 @@ const router = createRouter({
|
||||||
routes
|
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
|
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",
|
"layoutGraph": "Graf",
|
||||||
"layoutSplit": "Dividit",
|
"layoutSplit": "Dividit",
|
||||||
"layoutWorkbench": "Taulell",
|
"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": {
|
"step1": {
|
||||||
"ontologyGeneration": "Generació d'ontologia",
|
"ontologyGeneration": "Generació d'ontologia",
|
||||||
|
|
@ -661,5 +667,16 @@
|
||||||
"llmSelectAgentFailed": "La selecció d'agents per LLM ha fallat, usant la selecció per defecte: {error}",
|
"llmSelectAgentFailed": "La selecció d'agents per LLM ha fallat, usant la selecció per defecte: {error}",
|
||||||
"generateInterviewQuestionsFailed": "Error en generar les preguntes d'entrevista: {error}",
|
"generateInterviewQuestionsFailed": "Error en generar les preguntes d'entrevista: {error}",
|
||||||
"generateInterviewSummaryFailed": "Error en generar el resum de l'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",
|
"layoutGraph": "Graph",
|
||||||
"layoutSplit": "Split",
|
"layoutSplit": "Split",
|
||||||
"layoutWorkbench": "Workbench",
|
"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": {
|
"step1": {
|
||||||
"ontologyGeneration": "Ontology Generation",
|
"ontologyGeneration": "Ontology Generation",
|
||||||
|
|
@ -661,5 +667,16 @@
|
||||||
"llmSelectAgentFailed": "LLM agent selection failed, using default selection: {error}",
|
"llmSelectAgentFailed": "LLM agent selection failed, using default selection: {error}",
|
||||||
"generateInterviewQuestionsFailed": "Failed to generate interview questions: {error}",
|
"generateInterviewQuestionsFailed": "Failed to generate interview questions: {error}",
|
||||||
"generateInterviewSummaryFailed": "Failed to generate interview summary: {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",
|
"layoutGraph": "Grafo",
|
||||||
"layoutSplit": "Doble columna",
|
"layoutSplit": "Doble columna",
|
||||||
"layoutWorkbench": "Área de trabajo",
|
"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": {
|
"step1": {
|
||||||
"ontologyGeneration": "Generación de ontología",
|
"ontologyGeneration": "Generación de ontología",
|
||||||
|
|
@ -661,5 +667,16 @@
|
||||||
"llmSelectAgentFailed": "Selección de agente por LLM falló, usando selección por defecto: {error}",
|
"llmSelectAgentFailed": "Selección de agente por LLM falló, usando selección por defecto: {error}",
|
||||||
"generateInterviewQuestionsFailed": "Error al generar preguntas de entrevista: {error}",
|
"generateInterviewQuestionsFailed": "Error al generar preguntas de entrevista: {error}",
|
||||||
"generateInterviewSummaryFailed": "Error al generar resumen 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": "图谱",
|
"layoutGraph": "图谱",
|
||||||
"layoutSplit": "双栏",
|
"layoutSplit": "双栏",
|
||||||
"layoutWorkbench": "工作台",
|
"layoutWorkbench": "工作台",
|
||||||
"stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"]
|
"stepNames": [
|
||||||
|
"图谱构建",
|
||||||
|
"环境搭建",
|
||||||
|
"开始模拟",
|
||||||
|
"报告生成",
|
||||||
|
"深度互动"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"step1": {
|
"step1": {
|
||||||
"ontologyGeneration": "本体生成",
|
"ontologyGeneration": "本体生成",
|
||||||
|
|
@ -661,5 +667,16 @@
|
||||||
"llmSelectAgentFailed": "LLM选择Agent失败,使用默认选择: {error}",
|
"llmSelectAgentFailed": "LLM选择Agent失败,使用默认选择: {error}",
|
||||||
"generateInterviewQuestionsFailed": "生成采访问题失败: {error}",
|
"generateInterviewQuestionsFailed": "生成采访问题失败: {error}",
|
||||||
"generateInterviewSummaryFailed": "生成采访摘要失败: {error}"
|
"generateInterviewSummaryFailed": "生成采访摘要失败: {error}"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "访问",
|
||||||
|
"subtitle": "// 需要身份验证",
|
||||||
|
"username": "用户名",
|
||||||
|
"usernamePlaceholder": "demo",
|
||||||
|
"password": "密码",
|
||||||
|
"passwordPlaceholder": "••••••••",
|
||||||
|
"submit": "登录",
|
||||||
|
"loading": "验证中...",
|
||||||
|
"invalidCredentials": "用户名或密码错误"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue