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:
Ubuntu 2026-04-24 18:46:39 +00:00
parent 437e37febf
commit b5c4d4a336
17 changed files with 756 additions and 48 deletions

View File

@ -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

View File

@ -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"]

220
azure/container-app.bicep Normal file
View File

@ -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

View File

@ -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

View File

@ -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

28
backend/app/api/auth.py Normal file
View File

@ -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})

View File

@ -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 格式)

View File

@ -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]

4
backend/wsgi.py Normal file
View File

@ -0,0 +1,4 @@
"""Punt d'entrada WSGI per a gunicorn en producció."""
from app import create_app
application = create_app()

View File

@ -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)))
}

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}

View File

@ -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": "用户名或密码错误"
}
}
}