Add vue-router, login/setup flow and backend logging
Refactor frontend to route-based navigation with vue-router, add system setup and login pages with API integration. Add structured logging, access-log middleware and startup lifecycle to FastAPI backend.
This commit is contained in:
44
.env.example
Normal file
44
.env.example
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
APP_NAME=X-Financial
|
||||||
|
APP_ENV=local
|
||||||
|
APP_DEBUG=true
|
||||||
|
API_V1_PREFIX=/api/v1
|
||||||
|
SETUP_COMPLETED=false
|
||||||
|
VITE_SETUP_COMPLETED=false
|
||||||
|
|
||||||
|
COMPANY_NAME=
|
||||||
|
COMPANY_CODE=
|
||||||
|
ADMIN_EMAIL=
|
||||||
|
VITE_COMPANY_NAME=
|
||||||
|
VITE_COMPANY_CODE=
|
||||||
|
VITE_ADMIN_EMAIL=
|
||||||
|
# Admin login credentials are stored separately under server/.secrets/
|
||||||
|
|
||||||
|
WEB_HOST=127.0.0.1
|
||||||
|
WEB_PORT=5173
|
||||||
|
VITE_WEB_HOST=127.0.0.1
|
||||||
|
VITE_WEB_PORT=5173
|
||||||
|
|
||||||
|
SERVER_HOST=127.0.0.1
|
||||||
|
SERVER_PORT=8000
|
||||||
|
VITE_SERVER_HOST=127.0.0.1
|
||||||
|
VITE_SERVER_PORT=8000
|
||||||
|
SERVER_STARTUP_TIMEOUT=300
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1
|
||||||
|
|
||||||
|
POSTGRES_HOST=127.0.0.1
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=x_financial
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
VITE_POSTGRES_HOST=127.0.0.1
|
||||||
|
VITE_POSTGRES_PORT=5432
|
||||||
|
VITE_POSTGRES_DB=x_financial
|
||||||
|
VITE_POSTGRES_USER=postgres
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/x_financial
|
||||||
|
SQLALCHEMY_ECHO=false
|
||||||
|
|
||||||
|
REDIS_URL=
|
||||||
|
VITE_REDIS_URL=
|
||||||
|
|
||||||
|
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]'
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,3 +11,7 @@ web/.vite/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
server/.venv/
|
||||||
|
server/.secrets/
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -8,13 +8,34 @@
|
|||||||
- `UI/`:界面参考稿
|
- `UI/`:界面参考稿
|
||||||
- `document/`:业务文档
|
- `document/`:业务文档
|
||||||
|
|
||||||
从根目录启动前端:
|
根目录统一环境变量:
|
||||||
|
|
||||||
|
- `.env`
|
||||||
|
- `.env.example`
|
||||||
|
|
||||||
|
这里集中维护:
|
||||||
|
|
||||||
|
- 前端启动端口
|
||||||
|
- 后端启动端口
|
||||||
|
- PostgreSQL 连接参数
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `REDIS_URL`
|
||||||
|
|
||||||
|
从根目录统一启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./start.sh
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
或手动进入前端目录:
|
可选模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh web
|
||||||
|
./start.sh server
|
||||||
|
./start.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
手动进入前端目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
|
|||||||
71
document/work-log/2026-05-06.md
Normal file
71
document/work-log/2026-05-06.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Work Log - 2026-05-06
|
||||||
|
|
||||||
|
## Git Commits Today
|
||||||
|
|
||||||
|
无今日提交(上次提交:83d7da3)
|
||||||
|
|
||||||
|
## Uncommitted Changes
|
||||||
|
|
||||||
|
### Modified Files (17 files, +1339/-397 lines)
|
||||||
|
|
||||||
|
| File | Lines | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| web/vite.config.js | +693 | 添加 Vite 配置 |
|
||||||
|
| web/start.sh | +176 | 修复 Windows/Git Bash 启动 |
|
||||||
|
| web/package-lock.json | +187 | 更新依赖 |
|
||||||
|
| web/src/App.vue | -196 | 重构布局 |
|
||||||
|
| web/composables/useNavigation.js | +89 | 更新导航 |
|
||||||
|
| web/composables/useAppShell.js | +87 | 重构 App Shell |
|
||||||
|
| web/src/views/scripts/RequestsView.js | +85 | 更新请求视图逻辑 |
|
||||||
|
| .gitignore | +4 | 更新忽略规则 |
|
||||||
|
| README.md | +25 | 更新文档 |
|
||||||
|
| server/src/app/core/config.py | +8 | 更新配置 |
|
||||||
|
|
||||||
|
### New Files (14 files)
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| document/work-log/ | 工作日志目录 |
|
||||||
|
| server/src/app/core/logging.py | 日志模块 |
|
||||||
|
| server/src/app/middleware/ | 中间件目录 |
|
||||||
|
| web/src/assets/styles/views/setup-view.css | 安装页面样式 |
|
||||||
|
| web/src/composables/useSetupView.js | 安装视图逻辑 |
|
||||||
|
| web/src/composables/useSystemState.js | 系统状态管理 |
|
||||||
|
| web/src/router/ | 路由目录 |
|
||||||
|
| web/src/services/ | 服务目录 |
|
||||||
|
| web/src/utils/ | 工具目录 |
|
||||||
|
| web/src/views/SetupView.vue | 安装页面 |
|
||||||
|
| web/src/views/AppShellRouteView.vue | App Shell 路由 |
|
||||||
|
| web/src/views/LoginRouteView.vue | 登录路由 |
|
||||||
|
| .env, .env.example | 环境变量文件 |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
1. **修复 server/start.sh**
|
||||||
|
- 问题:Windows Git Bash 上无法运行,报错 "No module named pip"
|
||||||
|
- 原因:`.venv` 指向不存在的 `/usr/bin/python3`
|
||||||
|
- 解决:添加 `venv_valid()` 函数检测并重建虚拟环境
|
||||||
|
|
||||||
|
2. **创建 work-log 技能**
|
||||||
|
- 自动读取 git 提交记录
|
||||||
|
- 存储在 `document/work-log/` 目录
|
||||||
|
- 格式:`YYYY-MM-DD.md`
|
||||||
|
|
||||||
|
3. **前端重构**
|
||||||
|
- 重构 App.vue 布局
|
||||||
|
- 添加 SetupView 安装页面
|
||||||
|
- 添加路由和服务模块
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 需要安装 PostgreSQL 并创建 `x_financial` 数据库
|
||||||
|
- 大量前端改动未提交
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] 安装 PostgreSQL
|
||||||
|
- [ ] 创建数据库 `x_financial`
|
||||||
|
- [ ] 提交前端改动
|
||||||
|
|
||||||
|
---
|
||||||
|
*Created with work-log skill*
|
||||||
@@ -44,7 +44,13 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||||
vite_api_base_url: str = Field(default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL")
|
vite_api_base_url: str = Field(
|
||||||
|
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||||
|
log_dir: str = Field(default="logs", alias="LOG_DIR")
|
||||||
|
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolved_database_url(self) -> str:
|
def resolved_database_url(self) -> str:
|
||||||
|
|||||||
72
server/src/app/core/logging.py
Normal file
72
server/src/app/core/logging.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||||
|
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
_LEVEL_COLORS: dict[int, str] = {
|
||||||
|
logging.DEBUG: "\033[36m",
|
||||||
|
logging.INFO: "\033[32m",
|
||||||
|
logging.WARNING: "\033[33m",
|
||||||
|
logging.ERROR: "\033[31m",
|
||||||
|
logging.CRITICAL: "\033[1;31m",
|
||||||
|
}
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
class _ColorFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
color = _LEVEL_COLORS.get(record.levelno, "")
|
||||||
|
record.levelname = f"{color}{record.levelname:<8}{_RESET}"
|
||||||
|
return super().format(record)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_console_handler(level: int) -> logging.StreamHandler:
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(level)
|
||||||
|
handler.setFormatter(_ColorFormatter(LOG_FORMAT, datefmt=DATE_FORMAT))
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _build_file_handler(log_dir: Path, level: int) -> RotatingFileHandler:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
log_dir / "app.log",
|
||||||
|
maxBytes=10 * 1024 * 1024,
|
||||||
|
backupCount=10,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
handler.setLevel(level)
|
||||||
|
handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
*,
|
||||||
|
level: str = "INFO",
|
||||||
|
log_dir: str = "logs",
|
||||||
|
enable_file: bool = True,
|
||||||
|
) -> None:
|
||||||
|
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(numeric_level)
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
|
root.addHandler(_build_console_handler(numeric_level))
|
||||||
|
|
||||||
|
if enable_file:
|
||||||
|
from app.core.config import SERVER_DIR
|
||||||
|
|
||||||
|
file_path = SERVER_DIR / log_dir
|
||||||
|
root.addHandler(_build_file_handler(file_path, numeric_level))
|
||||||
|
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
return logging.getLogger(name)
|
||||||
@@ -5,17 +5,32 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger, setup_logging
|
||||||
|
from app.middleware.logging import AccessLogMiddleware
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
setup_logging(
|
||||||
|
level=settings.log_level,
|
||||||
|
log_dir=settings.log_dir,
|
||||||
|
enable_file=settings.log_file_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger("app.main")
|
||||||
|
logger.info(
|
||||||
|
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
debug=settings.app_debug,
|
debug=settings.app_debug,
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(AccessLogMiddleware)
|
||||||
|
|
||||||
if settings.cors_origins:
|
if settings.cors_origins:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -31,6 +46,15 @@ def create_app() -> FastAPI:
|
|||||||
def root() -> dict[str, str]:
|
def root() -> dict[str, str]:
|
||||||
return {"message": f"{settings.app_name} is running"}
|
return {"message": f"{settings.app_name} is running"}
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _on_startup() -> None:
|
||||||
|
logger.info(
|
||||||
|
"Server ready — host=%s port=%s prefix=%s",
|
||||||
|
settings.app_host,
|
||||||
|
settings.app_port,
|
||||||
|
settings.api_v1_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
server/src/app/middleware/__init__.py
Normal file
0
server/src/app/middleware/__init__.py
Normal file
42
server/src/app/middleware/logging.py
Normal file
42
server/src/app/middleware/logging.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("app.middleware.access")
|
||||||
|
|
||||||
|
_SKIP_PATHS: frozenset[str] = frozenset({"/", "/docs", "/openapi.json", "/redoc"})
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
if request.url.path in _SKIP_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex[:12])
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
|
level = logging.WARNING if response.status_code >= 500 else logging.INFO
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
level,
|
||||||
|
"%s %s %s %.1fms request_id=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
duration_ms,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.repositories.employee import EmployeeRepository
|
from app.repositories.employee import EmployeeRepository
|
||||||
from app.schemas.employee import EmployeeCreate
|
from app.schemas.employee import EmployeeCreate
|
||||||
|
|
||||||
|
logger = get_logger("app.services.employee")
|
||||||
|
|
||||||
|
|
||||||
class EmployeeService:
|
class EmployeeService:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.repository = EmployeeRepository(db)
|
self.repository = EmployeeRepository(db)
|
||||||
|
|
||||||
def list_employees(self) -> list[Employee]:
|
def list_employees(self) -> list[Employee]:
|
||||||
return self.repository.list()
|
employees = self.repository.list()
|
||||||
|
logger.info("Listed employees (count=%d)", len(employees))
|
||||||
|
return employees
|
||||||
|
|
||||||
def get_employee(self, employee_id: str) -> Employee | None:
|
def get_employee(self, employee_id: str) -> Employee | None:
|
||||||
return self.repository.get(employee_id)
|
employee = self.repository.get(employee_id)
|
||||||
|
if employee:
|
||||||
|
logger.info("Fetched employee id=%s name=%s", employee_id, employee.name)
|
||||||
|
else:
|
||||||
|
logger.warning("Employee not found id=%s", employee_id)
|
||||||
|
return employee
|
||||||
|
|
||||||
def create_employee(self, payload: EmployeeCreate) -> Employee:
|
def create_employee(self, payload: EmployeeCreate) -> Employee:
|
||||||
employee = Employee(**payload.model_dump())
|
employee = Employee(**payload.model_dump())
|
||||||
return self.repository.create(employee)
|
created = self.repository.create(employee)
|
||||||
|
logger.info(
|
||||||
|
"Created employee id=%s no=%s name=%s", created.id, created.employee_no, created.name
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.repositories.reimbursement import ReimbursementRepository
|
from app.repositories.reimbursement import ReimbursementRepository
|
||||||
from app.schemas.reimbursement import ReimbursementCreate
|
from app.schemas.reimbursement import ReimbursementCreate
|
||||||
|
|
||||||
|
logger = get_logger("app.services.reimbursement")
|
||||||
|
|
||||||
|
|
||||||
class ReimbursementService:
|
class ReimbursementService:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.repository = ReimbursementRepository(db)
|
self.repository = ReimbursementRepository(db)
|
||||||
|
|
||||||
def list_reimbursements(self) -> list[ReimbursementRequest]:
|
def list_reimbursements(self) -> list[ReimbursementRequest]:
|
||||||
return self.repository.list()
|
items = self.repository.list()
|
||||||
|
logger.info("Listed reimbursements (count=%d)", len(items))
|
||||||
|
return items
|
||||||
|
|
||||||
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
|
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
|
||||||
return self.repository.get(request_id)
|
request = self.repository.get(request_id)
|
||||||
|
if request:
|
||||||
|
logger.info("Fetched reimbursement id=%s no=%s", request_id, request.request_no)
|
||||||
|
else:
|
||||||
|
logger.warning("Reimbursement not found id=%s", request_id)
|
||||||
|
return request
|
||||||
|
|
||||||
def create_reimbursement(self, payload: ReimbursementCreate) -> ReimbursementRequest:
|
def create_reimbursement(self, payload: ReimbursementCreate) -> ReimbursementRequest:
|
||||||
request = ReimbursementRequest(**payload.model_dump(), status="draft")
|
request = ReimbursementRequest(**payload.model_dump(), status="draft")
|
||||||
return self.repository.create(request)
|
created = self.repository.create(request)
|
||||||
|
logger.info(
|
||||||
|
"Created reimbursement id=%s no=%s amount=%s",
|
||||||
|
created.id,
|
||||||
|
created.request_no,
|
||||||
|
created.amount,
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|||||||
187
web/package-lock.json
generated
187
web/package-lock.json
generated
@@ -12,11 +12,13 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vueuse/motion": "^3.0.3",
|
"@vueuse/motion": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"pg": "^8.13.1",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.5.5",
|
"primevue": "^4.5.5",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.3"
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
@@ -1015,6 +1017,12 @@
|
|||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.33"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/devtools-api": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
|
||||||
@@ -1170,7 +1178,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -1501,12 +1508,114 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.12.0",
|
||||||
|
"pg-pool": "^3.13.0",
|
||||||
|
"pg-protocol": "^1.13.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pkg-types": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||||
@@ -1559,6 +1668,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/primeicons": {
|
"node_modules/primeicons": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
|
||||||
@@ -1679,6 +1827,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/style-value-types": {
|
"node_modules/style-value-types": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.1.2.tgz",
|
||||||
@@ -1770,7 +1927,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -1830,7 +1986,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
||||||
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.33",
|
||||||
"@vue/compiler-sfc": "3.5.33",
|
"@vue/compiler-sfc": "3.5.33",
|
||||||
@@ -1857,12 +2012,36 @@
|
|||||||
"vue": "^3.0.0-0 || ^2.7.0"
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-router": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^6.6.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webpack-virtual-modules": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vueuse/motion": "^3.0.3",
|
"@vueuse/motion": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"pg": "^8.13.1",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.5.5",
|
"primevue": "^4.5.5",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.3"
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
web/src/App.vue
196
web/src/App.vue
@@ -1,201 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Login Page -->
|
<RouterView />
|
||||||
<LoginView
|
|
||||||
v-if="!loggedIn"
|
|
||||||
@login="handleLogin"
|
|
||||||
@recover-password="handleRecoverPassword"
|
|
||||||
@sso-login="handleSsoLogin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Main App -->
|
|
||||||
<div v-else class="app">
|
|
||||||
<SidebarRail
|
|
||||||
:nav-items="navItems"
|
|
||||||
:active-view="activeView"
|
|
||||||
@navigate="handleNavigate"
|
|
||||||
@open-chat="handleOpenChat"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main
|
|
||||||
class="main"
|
|
||||||
:class="{
|
|
||||||
'chat-main': activeView === 'chat',
|
|
||||||
'overview-main': activeView === 'overview',
|
|
||||||
'workbench-main': activeView === 'workbench',
|
|
||||||
'requests-main': activeView === 'requests',
|
|
||||||
'approval-main': activeView === 'approval',
|
|
||||||
'policies-main': activeView === 'policies',
|
|
||||||
'audit-main': activeView === 'audit',
|
|
||||||
'employees-main': activeView === 'employees'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<TopBar
|
|
||||||
:current-view="topBarView"
|
|
||||||
:search="search"
|
|
||||||
:active-view="activeView"
|
|
||||||
:ranges="ranges"
|
|
||||||
:active-range="activeRange"
|
|
||||||
:custom-range="customRange"
|
|
||||||
@update:search="search = $event"
|
|
||||||
@update:active-range="activeRange = $event"
|
|
||||||
@update:custom-range="customRange = $event"
|
|
||||||
@batch-approve="toast('已筛出 23 个低风险单据,可进入批量通过确认。')"
|
|
||||||
@open-chat="handleOpenChat"
|
|
||||||
@new-application="openTravelCreate"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterBar
|
|
||||||
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
|
|
||||||
:compact="activeView === 'overview'"
|
|
||||||
:filters="filters"
|
|
||||||
:ranges="ranges"
|
|
||||||
:active-range="activeRange"
|
|
||||||
@update:active-range="activeRange = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section
|
|
||||||
class="workarea"
|
|
||||||
:class="{
|
|
||||||
'chat-workarea': activeView === 'chat',
|
|
||||||
'requests-workarea': activeView === 'requests',
|
|
||||||
'approval-workarea': activeView === 'approval',
|
|
||||||
'policies-workarea': activeView === 'policies',
|
|
||||||
'audit-workarea': activeView === 'audit',
|
|
||||||
'employees-workarea': activeView === 'employees'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<OverviewView
|
|
||||||
v-if="activeView === 'overview'"
|
|
||||||
:filtered-requests="filteredRequests"
|
|
||||||
@ask="handleOpenChat"
|
|
||||||
@approve="handleApprove"
|
|
||||||
@reject="handleReject"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PersonalWorkbenchView
|
|
||||||
v-else-if="activeView === 'workbench'"
|
|
||||||
@open-assistant="openSmartEntry"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChatView
|
|
||||||
v-else-if="activeView === 'chat'"
|
|
||||||
:documents="filteredDocuments"
|
|
||||||
:doc-search="docSearch"
|
|
||||||
:messages="messages"
|
|
||||||
:uploaded-files="uploadedFiles"
|
|
||||||
:active-case="activeCase"
|
|
||||||
:quick-prompts="travelPrompts"
|
|
||||||
:draft="draft"
|
|
||||||
:message-list="messageList"
|
|
||||||
@send="sendMessage"
|
|
||||||
@upload="handleUpload"
|
|
||||||
@draft="draft = $event"
|
|
||||||
@select-case="handleOpenChat"
|
|
||||||
@approve-case="toast(`${activeCase?.id} 已生成通过意见。`)"
|
|
||||||
@reject-case="toast(`${activeCase?.id} 已转人工复核。`)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TravelRequestDetailView
|
|
||||||
v-else-if="activeView === 'requests' && detailMode"
|
|
||||||
:request="selectedTravelRequest"
|
|
||||||
@back-to-requests="closeRequestDetail"
|
|
||||||
@open-assistant="openSmartEntry"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RequestsView
|
|
||||||
v-else-if="activeView === 'requests'"
|
|
||||||
:filtered-requests="filteredRequests"
|
|
||||||
@ask="openRequestDetail"
|
|
||||||
@approve="handleApprove"
|
|
||||||
@reject="handleReject"
|
|
||||||
@create-request="openTravelCreate"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
|
||||||
|
|
||||||
<PoliciesView v-else-if="activeView === 'policies'" />
|
|
||||||
|
|
||||||
<AuditView v-else-if="activeView === 'audit'" />
|
|
||||||
|
|
||||||
<EmployeeManagementView v-else />
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<TravelReimbursementCreateView
|
|
||||||
v-if="smartEntryOpen"
|
|
||||||
:key="smartEntrySessionId"
|
|
||||||
:initial-prompt="smartEntryContext.prompt"
|
|
||||||
:entry-source="smartEntryContext.source"
|
|
||||||
:request-context="smartEntryContext.request"
|
|
||||||
@close="closeSmartEntry"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToastNotification :toast-text="toastText" />
|
<ToastNotification :toast-text="toastText" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
|
||||||
import './assets/styles/global.css'
|
import './assets/styles/global.css'
|
||||||
|
|
||||||
import SidebarRail from './components/layout/SidebarRail.vue'
|
|
||||||
import TopBar from './components/layout/TopBar.vue'
|
|
||||||
import FilterBar from './components/layout/FilterBar.vue'
|
|
||||||
import ToastNotification from './components/shared/ToastNotification.vue'
|
import ToastNotification from './components/shared/ToastNotification.vue'
|
||||||
import LoginView from './views/LoginView.vue'
|
import { useToast } from './composables/useToast.js'
|
||||||
import OverviewView from './views/OverviewView.vue'
|
|
||||||
import PersonalWorkbenchView from './views/PersonalWorkbenchView.vue'
|
|
||||||
import ChatView from './views/ChatView.vue'
|
|
||||||
import TravelReimbursementCreateView from './views/TravelReimbursementCreateView.vue'
|
|
||||||
import TravelRequestDetailView from './views/TravelRequestDetailView.vue'
|
|
||||||
import RequestsView from './views/RequestsView.vue'
|
|
||||||
import ApprovalCenterView from './views/ApprovalCenterView.vue'
|
|
||||||
import PoliciesView from './views/PoliciesView.vue'
|
|
||||||
import AuditView from './views/AuditView.vue'
|
|
||||||
import EmployeeManagementView from './views/EmployeeManagementView.vue'
|
|
||||||
|
|
||||||
import { useAppShell } from './composables/useAppShell.js'
|
const { toastText } = useToast()
|
||||||
|
|
||||||
const {
|
|
||||||
activeCase,
|
|
||||||
activeRange,
|
|
||||||
activeView,
|
|
||||||
closeRequestDetail,
|
|
||||||
closeSmartEntry,
|
|
||||||
customRange,
|
|
||||||
detailMode,
|
|
||||||
docSearch,
|
|
||||||
draft,
|
|
||||||
filteredDocuments,
|
|
||||||
filteredRequests,
|
|
||||||
filters,
|
|
||||||
handleApprove,
|
|
||||||
handleLogin,
|
|
||||||
handleNavigate,
|
|
||||||
handleOpenChat,
|
|
||||||
handleRecoverPassword,
|
|
||||||
handleReject,
|
|
||||||
handleSsoLogin,
|
|
||||||
handleUpload,
|
|
||||||
loggedIn,
|
|
||||||
messageList,
|
|
||||||
messages,
|
|
||||||
navItems,
|
|
||||||
openRequestDetail,
|
|
||||||
openSmartEntry,
|
|
||||||
openTravelCreate,
|
|
||||||
ranges,
|
|
||||||
search,
|
|
||||||
selectedTravelRequest,
|
|
||||||
sendMessage,
|
|
||||||
smartEntryContext,
|
|
||||||
smartEntryOpen,
|
|
||||||
smartEntrySessionId,
|
|
||||||
toast,
|
|
||||||
toastText,
|
|
||||||
topBarView,
|
|
||||||
travelPrompts,
|
|
||||||
uploadedFiles
|
|
||||||
} = useAppShell()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="./assets/styles/app.css"></style>
|
<style src="./assets/styles/app.css"></style>
|
||||||
|
|||||||
@@ -4,6 +4,68 @@
|
|||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boot-state {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 24rem),
|
||||||
|
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.14), transparent 28rem),
|
||||||
|
#f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-card {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
padding: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-card h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-card p {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: #059669;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-badge-error {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-action {
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
|
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
|
||||||
.main.overview-main {
|
.main.overview-main {
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
|||||||
@@ -482,6 +482,16 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(239, 68, 68, .18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.remember {
|
.remember {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -528,6 +538,13 @@
|
|||||||
background: linear-gradient(135deg, #13c990, #047857);
|
background: linear-gradient(135deg, #13c990, #047857);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled,
|
||||||
|
.sso-btn:disabled {
|
||||||
|
opacity: .6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
607
web/src/assets/styles/views/setup-view.css
Normal file
607
web/src/assets/styles/views/setup-view.css
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
.setup-page {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 392px) minmax(0, 1fr);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(16, 185, 129, 0.24), transparent 24rem),
|
||||||
|
radial-gradient(circle at 36% 14%, rgba(16, 185, 129, 0.16), transparent 18rem),
|
||||||
|
linear-gradient(135deg, #04110d 0%, #0b1f18 26%, #10281f 26%, #eef5f1 26%, #f6fbf8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-context {
|
||||||
|
padding: 42px 28px 32px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 22px;
|
||||||
|
border-right: 1px solid rgba(110, 231, 183, 0.08);
|
||||||
|
background: linear-gradient(180deg, rgba(4, 17, 13, 0.92), rgba(16, 40, 31, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-brand-mark {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 64px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-brand-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(209, 250, 229, 0.96), rgba(52, 211, 153, 0.88)),
|
||||||
|
linear-gradient(145deg, rgba(16, 185, 129, 0.4), rgba(5, 150, 105, 0.6));
|
||||||
|
box-shadow:
|
||||||
|
0 18px 36px rgba(16, 185, 129, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.46);
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-brand-ring::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 7px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(4, 120, 87, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-brand-core {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(3, 32, 24, 0.92);
|
||||||
|
color: #d1fae5;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-kicker {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(167, 243, 208, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-kicker-light {
|
||||||
|
color: rgba(209, 250, 229, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-context h1 {
|
||||||
|
color: #f4fff8;
|
||||||
|
font-size: clamp(1.9rem, 2.4vw, 2.5rem);
|
||||||
|
line-height: 1.08;
|
||||||
|
text-shadow: 0 12px 36px rgba(2, 12, 8, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-lead {
|
||||||
|
color: rgba(220, 252, 231, 0.84);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 14px 14px 12px;
|
||||||
|
border: 1px solid rgba(110, 231, 183, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr) 18px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: linear-gradient(160deg, rgba(10, 23, 18, 0.82), rgba(15, 39, 31, 0.72));
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(110, 231, 183, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-item.is-active {
|
||||||
|
border-color: rgba(16, 185, 129, 0.4);
|
||||||
|
box-shadow: 0 14px 28px rgba(3, 10, 7, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-item.is-complete {
|
||||||
|
background: linear-gradient(160deg, rgba(8, 31, 23, 0.96), rgba(12, 58, 44, 0.86));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-index {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(16, 185, 129, 0.16);
|
||||||
|
color: #d1fae5;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-copy strong {
|
||||||
|
color: #f0fdf4;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-copy small {
|
||||||
|
color: rgba(209, 250, 229, 0.72);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-nav-check {
|
||||||
|
color: #6ee7b7;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-progress {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid rgba(110, 231, 183, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(7, 33, 25, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-progress strong {
|
||||||
|
color: #f0fdf4;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-progress p {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgba(209, 250, 229, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-complete {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 16px 18px 0;
|
||||||
|
border-top: 1px solid rgba(110, 231, 183, 0.12);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-complete p {
|
||||||
|
color: rgba(209, 250, 229, 0.76);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-complete-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-panel {
|
||||||
|
padding: 36px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(16, 185, 129, 0.08), transparent 16rem),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #063b2e, #0f5f49);
|
||||||
|
box-shadow: 0 16px 34px rgba(6, 59, 46, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-panel-head h2 {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-panel-desc {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: rgba(236, 253, 245, 0.82);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(240, 253, 244, 0.14);
|
||||||
|
color: #d1fae5;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid rgba(209, 250, 229, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-chip.is-success {
|
||||||
|
background: rgba(16, 185, 129, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-form {
|
||||||
|
padding: 30px 32px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(244, 255, 248, 0.98), rgba(255, 255, 255, 0.94));
|
||||||
|
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-stage {
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h3 {
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head p {
|
||||||
|
color: #5b6f67;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
color: #244239;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-note {
|
||||||
|
color: #5f7c72;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group-note {
|
||||||
|
margin-top: -6px;
|
||||||
|
color: #5f7c72;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-block {
|
||||||
|
padding: 18px 18px 0;
|
||||||
|
border: 1px dashed rgba(16, 185, 129, 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(240, 253, 244, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-block-head strong {
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-block-head span {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: #047857;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.78);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #0f172a;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-span-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime article {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(110, 231, 183, 0.14);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 14px 32px rgba(3, 10, 7, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime article::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, rgba(209, 250, 229, 0.92), rgba(16, 185, 129, 0.48));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime article:nth-child(1) {
|
||||||
|
background: linear-gradient(150deg, rgba(7, 33, 25, 0.96), rgba(16, 67, 52, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime article:nth-child(2) {
|
||||||
|
background: linear-gradient(150deg, rgba(7, 28, 26, 0.96), rgba(11, 59, 61, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime article:nth-child(3) {
|
||||||
|
background: linear-gradient(150deg, rgba(16, 28, 19, 0.96), rgba(48, 74, 36, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime span {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(167, 243, 208, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-runtime strong {
|
||||||
|
color: #f8fffb;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
text-shadow: 0 2px 16px rgba(4, 9, 7, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-item {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: rgba(248, 250, 252, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-item strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-item span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-item .pi-check-circle {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-summary-item .pi-clock {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-error {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-status {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-status.is-success {
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.18);
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-status.is-danger {
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-gate {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-actions {
|
||||||
|
margin-top: 28px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-actions-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn,
|
||||||
|
.secondary-btn {
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
background: linear-gradient(135deg, #10b981, #0f766e);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
background: rgba(240, 253, 244, 0.94);
|
||||||
|
color: #1f4f41;
|
||||||
|
border-color: rgba(16, 185, 129, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn-strong {
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(5, 150, 105, 0.12));
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:hover,
|
||||||
|
.secondary-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:disabled,
|
||||||
|
.secondary-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.setup-page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(16, 185, 129, 0.2), transparent 22rem),
|
||||||
|
linear-gradient(180deg, #04110d 0%, #10281f 36%, #eef5f1 36%, #f6fbf8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-context,
|
||||||
|
.setup-panel {
|
||||||
|
padding: 28px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-complete {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.field-grid-2,
|
||||||
|
.setup-runtime {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-span-2 {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-actions-right {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn,
|
||||||
|
.secondary-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +1,74 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useNavigation, navItems } from './useNavigation.js'
|
import { useNavigation, navItems } from './useNavigation.js'
|
||||||
import { useRequests } from './useRequests.js'
|
import { useRequests } from './useRequests.js'
|
||||||
import { useChat } from './useChat.js'
|
import { useChat } from './useChat.js'
|
||||||
import { useToast } from './useToast.js'
|
import { useToast } from './useToast.js'
|
||||||
import { documents } from '../data/requests.js'
|
import { documents } from '../data/requests.js'
|
||||||
|
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||||
|
|
||||||
export function useAppShell() {
|
export function useAppShell() {
|
||||||
const loggedIn = ref(false)
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const travelCreateMode = ref(false)
|
const travelCreateMode = ref(false)
|
||||||
const detailMode = ref(false)
|
|
||||||
const selectedTravelRequest = ref(null)
|
|
||||||
const smartEntryOpen = ref(false)
|
const smartEntryOpen = ref(false)
|
||||||
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
|
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
|
||||||
const smartEntrySessionId = ref(0)
|
const smartEntrySessionId = ref(0)
|
||||||
|
|
||||||
const { activeView, currentView, setView } = useNavigation()
|
const { activeView, currentView, setView } = useNavigation()
|
||||||
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = useRequests()
|
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } =
|
||||||
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = useChat(activeView)
|
useRequests()
|
||||||
const { toastText, toast } = useToast()
|
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } =
|
||||||
|
useChat(activeView)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const docSearch = ref('')
|
const docSearch = ref('')
|
||||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||||
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
|
const travelPrompts = ['生成差旅摘要', '识别报销风险', '核对审批链', '提取随附票据', '生成沟通建议']
|
||||||
|
|
||||||
|
const selectedTravelRequest = computed(() => {
|
||||||
|
const requestId = String(route.params.requestId || '')
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRequest = requests.value.find((item) => String(item.id) === requestId)
|
||||||
|
return normalizeRequestForUi(rawRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailMode = computed(() => route.name === 'app-request-detail')
|
||||||
|
|
||||||
const topBarView = computed(() => {
|
const topBarView = computed(() => {
|
||||||
if (detailMode.value) {
|
if (detailMode.value) {
|
||||||
return {
|
return {
|
||||||
title: '差旅报销详情',
|
title: '差旅申请详情',
|
||||||
desc: '查看报销单据详情、票据识别与审批进度'
|
desc: '查看申请单、票据、审批意见与风控提示。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentView.value
|
return currentView.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredDocuments = computed(() => {
|
const filteredDocuments = computed(() => {
|
||||||
const key = docSearch.value.trim().toLowerCase()
|
const key = docSearch.value.trim().toLowerCase()
|
||||||
return documents.filter((doc) => {
|
return documents.filter((doc) => !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key))
|
||||||
const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)
|
|
||||||
return matchesSearch
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleLogin(credentials) {
|
|
||||||
if (credentials.username && credentials.password) {
|
|
||||||
loggedIn.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRecoverPassword() {
|
|
||||||
toast('请联系系统管理员重置密码。')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSsoLogin() {
|
|
||||||
toast('SSO 登录通道建设中。')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleApprove(request) {
|
function handleApprove(request) {
|
||||||
const msg = approveRequest(request)
|
const message = approveRequest(request)
|
||||||
toast(msg)
|
toast(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReject(request) {
|
function handleReject(request) {
|
||||||
const msg = rejectRequest(request)
|
const message = rejectRequest(request)
|
||||||
toast(msg)
|
toast(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNavigate(view) {
|
function handleNavigate(view) {
|
||||||
travelCreateMode.value = false
|
travelCreateMode.value = false
|
||||||
detailMode.value = false
|
|
||||||
selectedTravelRequest.value = null
|
|
||||||
smartEntryOpen.value = false
|
smartEntryOpen.value = false
|
||||||
setView(view)
|
setView(view)
|
||||||
}
|
}
|
||||||
@@ -82,8 +81,6 @@ export function useAppShell() {
|
|||||||
function openTravelCreate() {
|
function openTravelCreate() {
|
||||||
smartEntryOpen.value = true
|
smartEntryOpen.value = true
|
||||||
travelCreateMode.value = false
|
travelCreateMode.value = false
|
||||||
detailMode.value = false
|
|
||||||
selectedTravelRequest.value = null
|
|
||||||
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
|
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
}
|
}
|
||||||
@@ -91,10 +88,7 @@ export function useAppShell() {
|
|||||||
function openSmartEntry(payload = {}) {
|
function openSmartEntry(payload = {}) {
|
||||||
smartEntryOpen.value = true
|
smartEntryOpen.value = true
|
||||||
travelCreateMode.value = false
|
travelCreateMode.value = false
|
||||||
if (payload.source !== 'detail') {
|
|
||||||
detailMode.value = false
|
|
||||||
selectedTravelRequest.value = null
|
|
||||||
}
|
|
||||||
smartEntryContext.value = {
|
smartEntryContext.value = {
|
||||||
prompt: payload.prompt ?? '',
|
prompt: payload.prompt ?? '',
|
||||||
source: payload.source ?? 'workbench',
|
source: payload.source ?? 'workbench',
|
||||||
@@ -108,14 +102,14 @@ export function useAppShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openRequestDetail(request) {
|
function openRequestDetail(request) {
|
||||||
selectedTravelRequest.value = request
|
router.push({
|
||||||
detailMode.value = true
|
name: 'app-request-detail',
|
||||||
activeView.value = 'requests'
|
params: { requestId: request.id }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRequestDetail() {
|
function closeRequestDetail() {
|
||||||
detailMode.value = false
|
router.push({ name: 'app-requests' })
|
||||||
selectedTravelRequest.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -133,14 +127,10 @@ export function useAppShell() {
|
|||||||
filteredRequests,
|
filteredRequests,
|
||||||
filters,
|
filters,
|
||||||
handleApprove,
|
handleApprove,
|
||||||
handleLogin,
|
|
||||||
handleNavigate,
|
handleNavigate,
|
||||||
handleOpenChat,
|
handleOpenChat,
|
||||||
handleRecoverPassword,
|
|
||||||
handleReject,
|
handleReject,
|
||||||
handleSsoLogin,
|
|
||||||
handleUpload,
|
handleUpload,
|
||||||
loggedIn,
|
|
||||||
messageList,
|
messageList,
|
||||||
messages,
|
messages,
|
||||||
navItems,
|
navItems,
|
||||||
@@ -160,7 +150,6 @@ export function useAppShell() {
|
|||||||
smartEntryOpen,
|
smartEntryOpen,
|
||||||
smartEntrySessionId,
|
smartEntrySessionId,
|
||||||
toast,
|
toast,
|
||||||
toastText,
|
|
||||||
topBarView,
|
topBarView,
|
||||||
travelCreateMode,
|
travelCreateMode,
|
||||||
travelPrompts,
|
travelPrompts,
|
||||||
|
|||||||
@@ -8,9 +8,24 @@ export function useLoginView() {
|
|||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
|
{
|
||||||
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
|
title: '智能审单',
|
||||||
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
|
desc: 'AI 自动识别票据与规则,提升准确率与处理效率',
|
||||||
|
icon: 'mdi mdi-file-document-outline',
|
||||||
|
tone: 'green'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '异常预警',
|
||||||
|
desc: '多维风险识别与预警,主动防控报销风险',
|
||||||
|
icon: 'mdi mdi-bell-outline',
|
||||||
|
tone: 'red'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SLA 监控',
|
||||||
|
desc: '实时监控服务水位,保障审批和处理时效',
|
||||||
|
icon: 'mdi mdi-sync',
|
||||||
|
tone: 'blue'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const LogoMark = {
|
const LogoMark = {
|
||||||
|
|||||||
@@ -1,82 +1,113 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { icons } from '../data/icons.js'
|
import { icons } from '../data/icons.js'
|
||||||
|
|
||||||
|
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'chat', 'policies', 'audit', 'employees']
|
||||||
|
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: '总览',
|
label: '总览',
|
||||||
navHint: '运营指标与趋势',
|
navHint: '查看系统总览与关键指标',
|
||||||
icon: icons.dashboard,
|
icon: icons.dashboard,
|
||||||
title: '企业报销智能运营台',
|
title: '财务运营总览',
|
||||||
desc: '面向财务共享中心的审批、风控、SLA与自动化运营看板'
|
desc: '聚合差旅申请、审批效率、风险信号与 SLA 表现。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'workbench',
|
id: 'workbench',
|
||||||
label: '个人工作台',
|
label: '个人工作台',
|
||||||
navHint: '今日待办与报销进度',
|
navHint: '集中处理个人待办',
|
||||||
icon: icons.workspace,
|
icon: icons.workspace,
|
||||||
title: '个人工作台',
|
title: '个人工作台',
|
||||||
desc: '集中处理今日待办、查看报销进度,并快速进入 AI 报销助手'
|
desc: '聚焦当前待办、快捷操作与助手入口。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'requests',
|
id: 'requests',
|
||||||
label: '差旅申请/报销',
|
label: '申请单',
|
||||||
navHint: '差旅单据与发起申请',
|
navHint: '查看和管理申请单',
|
||||||
icon: icons.list,
|
icon: icons.list,
|
||||||
title: '差旅申请/报销',
|
title: '差旅申请与单据',
|
||||||
desc: '查看员工差旅报销单据、跟踪进度、发起新申请'
|
desc: '集中查看申请单状态、处理进度和风险提示。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'approval',
|
id: 'approval',
|
||||||
label: '审批中心',
|
label: '审批中心',
|
||||||
navHint: '待审批单据与批量处理',
|
navHint: '处理审批任务',
|
||||||
icon: icons.approval,
|
icon: icons.approval,
|
||||||
title: '审批中心',
|
title: '审批中心',
|
||||||
desc: '统一处理待审批单据,聚焦效率、风险与 SLA'
|
desc: '按优先级处理待审批事项,控制时效与风险。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chat',
|
id: 'chat',
|
||||||
label: 'AI助手',
|
label: 'AI 助手',
|
||||||
navHint: '财务知识问答与制度解释',
|
navHint: '进入智能问答',
|
||||||
icon: icons.message,
|
icon: icons.message,
|
||||||
title: '财务AI助手',
|
title: 'AI 财务助手',
|
||||||
desc: '面向员工与财务场景的智能问答助手,提供制度解读、报销指引与常见问题解答'
|
desc: '围绕制度、票据、审批和差旅场景进行快速问答。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policies',
|
id: 'policies',
|
||||||
label: '知识管理',
|
label: '制度知识',
|
||||||
navHint: '制度、文档与知识库',
|
navHint: '查看制度与知识库',
|
||||||
icon: icons.file,
|
icon: icons.file,
|
||||||
title: '财务知识管理中心',
|
title: '制度与知识库',
|
||||||
desc: '上传制度文档、沉淀财务知识、构建面向员工问答与知识管理的统一知识库'
|
desc: '统一管理制度文档、知识问答和搜索入口。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'audit',
|
id: 'audit',
|
||||||
label: '技能中心',
|
label: '审计追踪',
|
||||||
navHint: 'Skill 设计与版本配置',
|
navHint: '查看日志与追踪记录',
|
||||||
icon: icons.skill,
|
icon: icons.skill,
|
||||||
title: '技能中心',
|
title: '审计追踪',
|
||||||
desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本'
|
desc: '记录关键操作、追踪审批链和系统行为。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'employees',
|
id: 'employees',
|
||||||
label: '员工管理',
|
label: '员工管理',
|
||||||
navHint: '员工档案、岗位与角色权限',
|
navHint: '维护员工与组织信息',
|
||||||
icon: icons.users,
|
icon: icons.users,
|
||||||
title: '员工管理',
|
title: '员工与组织管理',
|
||||||
desc: '集中维护员工基础信息、职级部门岗位,以及管理员、财务人员、使用者和高级管理人员等系统角色'
|
desc: '维护员工账号、组织结构与角色权限。'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const viewRouteNames = {
|
||||||
|
overview: 'app-overview',
|
||||||
|
workbench: 'app-workbench',
|
||||||
|
requests: 'app-requests',
|
||||||
|
approval: 'app-approval',
|
||||||
|
chat: 'app-chat',
|
||||||
|
policies: 'app-policies',
|
||||||
|
audit: 'app-audit',
|
||||||
|
employees: 'app-employees'
|
||||||
|
}
|
||||||
|
|
||||||
export function useNavigation() {
|
export function useNavigation() {
|
||||||
const activeView = ref('overview')
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const activeView = computed({
|
||||||
|
get() {
|
||||||
|
return route.meta.appView || 'overview'
|
||||||
|
},
|
||||||
|
set(view) {
|
||||||
|
setView(view)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const currentView = computed(
|
const currentView = computed(
|
||||||
() => navItems.find((item) => item.id === activeView.value) ?? navItems[0]
|
() => navItems.find((item) => item.id === activeView.value) ?? navItems[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
function setView(view) {
|
function setView(view) {
|
||||||
activeView.value = view
|
const targetName = viewRouteNames[view] || viewRouteNames.overview
|
||||||
|
|
||||||
|
if (route.name === targetName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({ name: targetName })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { activeView, currentView, setView, navItems }
|
return { activeView, currentView, setView, navItems }
|
||||||
|
|||||||
383
web/src/composables/useSetupView.js
Normal file
383
web/src/composables/useSetupView.js
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
function createForm(initialState) {
|
||||||
|
return {
|
||||||
|
company_name: initialState?.company?.name || '',
|
||||||
|
company_code: initialState?.company?.code || '',
|
||||||
|
admin_email: initialState?.company?.admin_email || '',
|
||||||
|
admin_username: '',
|
||||||
|
admin_password: '',
|
||||||
|
admin_password_confirm: '',
|
||||||
|
web_host: initialState?.web?.host || '127.0.0.1',
|
||||||
|
web_port: initialState?.web?.port || 5173,
|
||||||
|
server_host: initialState?.server?.host || '127.0.0.1',
|
||||||
|
server_port: initialState?.server?.port || 8000,
|
||||||
|
postgres_host: initialState?.database?.host || '127.0.0.1',
|
||||||
|
postgres_port: initialState?.database?.port || 5432,
|
||||||
|
postgres_db: initialState?.database?.name || 'x_financial',
|
||||||
|
postgres_user: initialState?.database?.username || 'postgres',
|
||||||
|
postgres_password: '',
|
||||||
|
redis_url: initialState?.redis?.url || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form) {
|
||||||
|
return {
|
||||||
|
company_name: form.company_name.trim(),
|
||||||
|
company_code: form.company_code.trim(),
|
||||||
|
admin_email: form.admin_email.trim(),
|
||||||
|
admin_username: form.admin_username.trim(),
|
||||||
|
admin_password: String(form.admin_password || ''),
|
||||||
|
admin_password_confirm: String(form.admin_password_confirm || ''),
|
||||||
|
web_host: form.web_host.trim(),
|
||||||
|
web_port: Number(form.web_port),
|
||||||
|
server_host: form.server_host.trim(),
|
||||||
|
server_port: Number(form.server_port),
|
||||||
|
postgres_host: form.postgres_host.trim(),
|
||||||
|
postgres_port: Number(form.postgres_port),
|
||||||
|
postgres_db: form.postgres_db.trim(),
|
||||||
|
postgres_user: form.postgres_user.trim(),
|
||||||
|
postgres_password: String(form.postgres_password || ''),
|
||||||
|
redis_url: form.redis_url.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntimeFingerprint(form) {
|
||||||
|
return JSON.stringify({
|
||||||
|
web_host: form.web_host.trim(),
|
||||||
|
web_port: String(form.web_port).trim(),
|
||||||
|
server_host: form.server_host.trim(),
|
||||||
|
server_port: String(form.server_port).trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDatabaseFingerprint(form) {
|
||||||
|
return JSON.stringify({
|
||||||
|
postgres_host: form.postgres_host.trim(),
|
||||||
|
postgres_port: String(form.postgres_port).trim(),
|
||||||
|
postgres_db: form.postgres_db.trim(),
|
||||||
|
postgres_user: form.postgres_user.trim(),
|
||||||
|
postgres_password: String(form.postgres_password || ''),
|
||||||
|
redis_url: form.redis_url.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmail(value) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(String(value || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetupView(props, emit) {
|
||||||
|
const form = reactive(createForm(props.initialState))
|
||||||
|
const activeSection = ref('company')
|
||||||
|
let syncingFromProps = false
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialState,
|
||||||
|
(state) => {
|
||||||
|
syncingFromProps = true
|
||||||
|
Object.assign(form, createForm(state))
|
||||||
|
queueMicrotask(() => {
|
||||||
|
syncingFromProps = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => buildRuntimeFingerprint(form),
|
||||||
|
(_value, oldValue) => {
|
||||||
|
if (oldValue !== undefined && !syncingFromProps) {
|
||||||
|
emit('runtime-dirty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => buildDatabaseFingerprint(form),
|
||||||
|
(_value, oldValue) => {
|
||||||
|
if (oldValue !== undefined && !syncingFromProps) {
|
||||||
|
emit('database-dirty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const companyReady = computed(() => form.company_name.trim().length >= 2)
|
||||||
|
const adminReady = computed(() => {
|
||||||
|
return Boolean(
|
||||||
|
isEmail(form.admin_email) &&
|
||||||
|
form.admin_username.trim().length >= 4 &&
|
||||||
|
String(form.admin_password || '').length >= 5 &&
|
||||||
|
form.admin_password === form.admin_password_confirm
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const runtimeInputsReady = computed(() => {
|
||||||
|
return Boolean(
|
||||||
|
form.web_host.trim() &&
|
||||||
|
String(form.web_port).trim() &&
|
||||||
|
form.server_host.trim() &&
|
||||||
|
String(form.server_port).trim()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const databaseInputsReady = computed(() => {
|
||||||
|
return Boolean(
|
||||||
|
form.postgres_host.trim() &&
|
||||||
|
String(form.postgres_port).trim() &&
|
||||||
|
form.postgres_db.trim() &&
|
||||||
|
form.postgres_user.trim() &&
|
||||||
|
String(form.postgres_password || '').length > 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const runtimeReady = computed(() => runtimeInputsReady.value && props.runtimeTestPassed)
|
||||||
|
const databaseReady = computed(() => databaseInputsReady.value && props.databaseTestPassed)
|
||||||
|
const finalReady = computed(() => companyReady.value && adminReady.value && runtimeReady.value && databaseReady.value)
|
||||||
|
|
||||||
|
const sections = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'company',
|
||||||
|
index: '01',
|
||||||
|
title: '企业信息',
|
||||||
|
desc: '填写企业名称与识别编码。',
|
||||||
|
complete: companyReady.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
index: '02',
|
||||||
|
title: '管理员安全',
|
||||||
|
desc: '配置管理员邮箱、账号与密码。',
|
||||||
|
complete: adminReady.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runtime',
|
||||||
|
index: '03',
|
||||||
|
title: '运行端口',
|
||||||
|
desc: '单独检测 Web 与后端端口占用。',
|
||||||
|
complete: runtimeReady.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'database',
|
||||||
|
index: '04',
|
||||||
|
title: '数据库',
|
||||||
|
desc: '检测 PostgreSQL 连接,Redis 暂时可选。',
|
||||||
|
complete: databaseReady.value
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const activeStep = computed(() => sections.value.find((section) => section.id === activeSection.value) || sections.value[0])
|
||||||
|
const completionCount = computed(() => sections.value.filter((section) => section.complete).length)
|
||||||
|
|
||||||
|
const runtimeEndpoints = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Web',
|
||||||
|
value: `${form.web_host}:${form.web_port}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
value: `${form.server_host}:${form.server_port}`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const summaryItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: '企业信息',
|
||||||
|
detail: form.company_name.trim() || '未完成',
|
||||||
|
complete: companyReady.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '管理员安全',
|
||||||
|
detail: form.admin_username.trim() || form.admin_email.trim() || '未完成',
|
||||||
|
complete: adminReady.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '运行端口',
|
||||||
|
detail: `${form.web_host}:${form.web_port} / ${form.server_host}:${form.server_port}`,
|
||||||
|
complete: runtimeReady.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '数据库',
|
||||||
|
detail: `${form.postgres_host}:${form.postgres_port}/${form.postgres_db}`,
|
||||||
|
complete: databaseReady.value
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentTestMessage = computed(() => {
|
||||||
|
if (activeSection.value === 'runtime') {
|
||||||
|
return props.runtimeTestMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'database') {
|
||||||
|
return props.databaseTestMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTestPassed = computed(() => {
|
||||||
|
if (activeSection.value === 'runtime') {
|
||||||
|
return props.runtimeTestPassed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'database') {
|
||||||
|
return props.databaseTestPassed
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showTestAction = computed(() => ['runtime', 'database'].includes(activeSection.value))
|
||||||
|
const testButtonLabel = computed(() => {
|
||||||
|
if (activeSection.value === 'runtime') {
|
||||||
|
return props.runtimeTesting ? '检测中...' : '检测端口占用'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'database') {
|
||||||
|
return props.databaseTesting ? '检测中...' : '检测数据库连接'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const testButtonIcon = computed(() => {
|
||||||
|
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
|
||||||
|
return 'pi pi-spin pi-spinner'
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
|
||||||
|
})
|
||||||
|
|
||||||
|
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||||
|
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||||
|
const canTest = computed(() => {
|
||||||
|
if (activeSection.value === 'runtime') {
|
||||||
|
return canRuntimeTest.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'database') {
|
||||||
|
return canDatabaseTest.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitHint = computed(() => {
|
||||||
|
if (activeSection.value === 'admin') {
|
||||||
|
if (!form.admin_email.trim() && !form.admin_username.trim() && !String(form.admin_password || '').length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.admin_email.trim()) {
|
||||||
|
return '请填写管理员邮箱。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmail(form.admin_email)) {
|
||||||
|
return '管理员邮箱格式不正确。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.admin_username.trim() && form.admin_username.trim().length < 4) {
|
||||||
|
return '管理员账号至少 4 位。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(form.admin_password || '').length > 0 && String(form.admin_password || '').length < 5) {
|
||||||
|
return '管理员密码当前至少 5 位。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
String(form.admin_password_confirm || '').length > 0 &&
|
||||||
|
form.admin_password !== form.admin_password_confirm
|
||||||
|
) {
|
||||||
|
return '两次输入的管理员密码不一致。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'runtime') {
|
||||||
|
if (!runtimeInputsReady.value) {
|
||||||
|
return '请先填写 Web 与 Server 的主机和端口。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.runtimeTestPassed) {
|
||||||
|
return '请先完成端口占用检测。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'database') {
|
||||||
|
if (!databaseInputsReady.value) {
|
||||||
|
return '请先填写 PostgreSQL 连接信息。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.databaseTestPassed) {
|
||||||
|
return '请先完成数据库连接检测。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'company') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyReady.value) {
|
||||||
|
return '请先完成企业信息。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminReady.value) {
|
||||||
|
return '请先完成管理员安全配置。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runtimeReady.value) {
|
||||||
|
return '请先完成运行端口检测。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!databaseReady.value) {
|
||||||
|
return '请先完成数据库连接检测。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToSection(id) {
|
||||||
|
activeSection.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitForm() {
|
||||||
|
if (!finalReady.value || props.submitting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', buildPayload(form))
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSetup() {
|
||||||
|
if (!canTest.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildPayload(form)
|
||||||
|
|
||||||
|
if (activeSection.value === 'runtime') {
|
||||||
|
emit('runtime-test', payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'database') {
|
||||||
|
emit('database-test', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSection,
|
||||||
|
activeStep,
|
||||||
|
canSubmit: finalReady,
|
||||||
|
canTest,
|
||||||
|
completionCount,
|
||||||
|
currentTestMessage,
|
||||||
|
currentTestPassed,
|
||||||
|
form,
|
||||||
|
goToSection,
|
||||||
|
runtimeEndpoints,
|
||||||
|
sections,
|
||||||
|
showTestAction,
|
||||||
|
submitForm,
|
||||||
|
submitHint,
|
||||||
|
summaryItems,
|
||||||
|
testButtonIcon,
|
||||||
|
testButtonLabel,
|
||||||
|
testSetup
|
||||||
|
}
|
||||||
|
}
|
||||||
278
web/src/composables/useSystemState.js
Normal file
278
web/src/composables/useSystemState.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
loginBootstrapAdmin,
|
||||||
|
saveBootstrapConfig,
|
||||||
|
testBootstrapDatabase,
|
||||||
|
testBootstrapRuntime
|
||||||
|
} from '../services/bootstrap.js'
|
||||||
|
import { useToast } from './useToast.js'
|
||||||
|
|
||||||
|
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
|
||||||
|
|
||||||
|
function readClientBootstrapState() {
|
||||||
|
const env = import.meta.env
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialized: String(env.VITE_SETUP_COMPLETED || '').toLowerCase() === 'true',
|
||||||
|
company: {
|
||||||
|
name: env.VITE_COMPANY_NAME || '',
|
||||||
|
code: env.VITE_COMPANY_CODE || '',
|
||||||
|
admin_email: env.VITE_ADMIN_EMAIL || ''
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
host: env.VITE_WEB_HOST || '127.0.0.1',
|
||||||
|
port: Number(env.VITE_WEB_PORT || 5173)
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: env.VITE_SERVER_HOST || '127.0.0.1',
|
||||||
|
port: Number(env.VITE_SERVER_PORT || 8000)
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
driver: 'postgresql',
|
||||||
|
host: env.VITE_POSTGRES_HOST || '127.0.0.1',
|
||||||
|
port: Number(env.VITE_POSTGRES_PORT || 5432),
|
||||||
|
name: env.VITE_POSTGRES_DB || 'x_financial',
|
||||||
|
username: env.VITE_POSTGRES_USER || 'postgres',
|
||||||
|
password_configured: false
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
enabled: Boolean(env.VITE_REDIS_URL),
|
||||||
|
url: env.VITE_REDIS_URL || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuthState() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAuthState(value) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrapState = ref(readClientBootstrapState())
|
||||||
|
const setupSubmitting = ref(false)
|
||||||
|
const setupError = ref('')
|
||||||
|
const runtimeTesting = ref(false)
|
||||||
|
const databaseTesting = ref(false)
|
||||||
|
const runtimeTestPassed = ref(false)
|
||||||
|
const databaseTestPassed = ref(false)
|
||||||
|
const runtimeTestMessage = ref('')
|
||||||
|
const databaseTestMessage = ref('')
|
||||||
|
const loginSubmitting = ref(false)
|
||||||
|
const loginError = ref('')
|
||||||
|
const loggedIn = ref(readAuthState())
|
||||||
|
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const companyProfile = computed(() => ({
|
||||||
|
name: bootstrapState.value.company?.name || '',
|
||||||
|
code: bootstrapState.value.company?.code || '',
|
||||||
|
adminEmail: bootstrapState.value.company?.admin_email || ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isInitialized = computed(() => Boolean(bootstrapState.value.initialized))
|
||||||
|
|
||||||
|
function applyBootstrapState(state) {
|
||||||
|
bootstrapState.value = state
|
||||||
|
|
||||||
|
if (!state.initialized) {
|
||||||
|
loggedIn.value = false
|
||||||
|
persistAuthState(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSetupRuntimeState() {
|
||||||
|
runtimeTesting.value = false
|
||||||
|
databaseTesting.value = false
|
||||||
|
runtimeTestPassed.value = false
|
||||||
|
databaseTestPassed.value = false
|
||||||
|
runtimeTestMessage.value = ''
|
||||||
|
databaseTestMessage.value = ''
|
||||||
|
setupError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFromClientEnv() {
|
||||||
|
applyBootstrapState(readClientBootstrapState())
|
||||||
|
clearSetupRuntimeState()
|
||||||
|
loginError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetupSubmit(payload) {
|
||||||
|
if (!runtimeTestPassed.value) {
|
||||||
|
setupError.value = '请先完成运行端口检测。'
|
||||||
|
toast(setupError.value)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!databaseTestPassed.value) {
|
||||||
|
setupError.value = '请先完成数据库连接检测。'
|
||||||
|
toast(setupError.value)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSubmitting.value = true
|
||||||
|
setupError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await saveBootstrapConfig(payload)
|
||||||
|
applyBootstrapState(state)
|
||||||
|
toast('初始化配置已写入。现在可以进入登录页。')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
setupError.value = error.message || '初始化配置写入失败,请稍后重试。'
|
||||||
|
toast(setupError.value)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setupSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRuntimeTest(payload) {
|
||||||
|
runtimeTesting.value = true
|
||||||
|
runtimeTestMessage.value = ''
|
||||||
|
setupError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testBootstrapRuntime(payload)
|
||||||
|
runtimeTestPassed.value = true
|
||||||
|
runtimeTestMessage.value = result.detail || '端口占用检测通过。'
|
||||||
|
toast(runtimeTestMessage.value)
|
||||||
|
} catch (error) {
|
||||||
|
runtimeTestPassed.value = false
|
||||||
|
runtimeTestMessage.value = error.message || '端口占用检测失败。'
|
||||||
|
toast(runtimeTestMessage.value)
|
||||||
|
} finally {
|
||||||
|
runtimeTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDatabaseTest(payload) {
|
||||||
|
databaseTesting.value = true
|
||||||
|
databaseTestMessage.value = ''
|
||||||
|
setupError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testBootstrapDatabase(payload)
|
||||||
|
databaseTestPassed.value = true
|
||||||
|
databaseTestMessage.value = result.detail || '数据库连接检测通过。'
|
||||||
|
toast(databaseTestMessage.value)
|
||||||
|
} catch (error) {
|
||||||
|
databaseTestPassed.value = false
|
||||||
|
databaseTestMessage.value = error.message || '数据库连接检测失败。'
|
||||||
|
toast(databaseTestMessage.value)
|
||||||
|
} finally {
|
||||||
|
databaseTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRuntimeDirty() {
|
||||||
|
runtimeTestPassed.value = false
|
||||||
|
runtimeTestMessage.value = ''
|
||||||
|
|
||||||
|
if (setupError.value === '请先完成运行端口检测。') {
|
||||||
|
setupError.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDatabaseDirty() {
|
||||||
|
databaseTestPassed.value = false
|
||||||
|
databaseTestMessage.value = ''
|
||||||
|
|
||||||
|
if (setupError.value === '请先完成数据库连接检测。') {
|
||||||
|
setupError.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(credentials) {
|
||||||
|
loginSubmitting.value = true
|
||||||
|
loginError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginBootstrapAdmin({
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password
|
||||||
|
})
|
||||||
|
|
||||||
|
loggedIn.value = true
|
||||||
|
persistAuthState(true)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
loggedIn.value = false
|
||||||
|
persistAuthState(false)
|
||||||
|
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
|
||||||
|
toast(loginError.value)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loginSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
loggedIn.value = false
|
||||||
|
persistAuthState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRecoverPassword() {
|
||||||
|
toast('请联系系统管理员重置密码。管理员密码不会写入 .env。')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSsoLogin() {
|
||||||
|
toast('SSO 登录暂未启用。')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntryRoute() {
|
||||||
|
if (!isInitialized.value) {
|
||||||
|
return { name: 'setup' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loggedIn.value) {
|
||||||
|
return { name: 'login' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: 'app-overview' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSystemState() {
|
||||||
|
return {
|
||||||
|
bootstrapState,
|
||||||
|
companyProfile,
|
||||||
|
databaseTestMessage,
|
||||||
|
databaseTestPassed,
|
||||||
|
databaseTesting,
|
||||||
|
handleDatabaseDirty,
|
||||||
|
handleDatabaseTest,
|
||||||
|
handleLogin,
|
||||||
|
handleRecoverPassword,
|
||||||
|
handleRuntimeDirty,
|
||||||
|
handleRuntimeTest,
|
||||||
|
handleSetupSubmit,
|
||||||
|
handleSsoLogin,
|
||||||
|
isInitialized,
|
||||||
|
loggedIn,
|
||||||
|
loginError,
|
||||||
|
loginSubmitting,
|
||||||
|
logout,
|
||||||
|
resetFromClientEnv,
|
||||||
|
resolveEntryRoute,
|
||||||
|
runtimeTestMessage,
|
||||||
|
runtimeTestPassed,
|
||||||
|
runtimeTesting,
|
||||||
|
setupError,
|
||||||
|
setupSubmitting
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const toastText = ref('')
|
||||||
|
|
||||||
|
function toast(text) {
|
||||||
|
toastText.value = text
|
||||||
|
clearTimeout(toast.timer)
|
||||||
|
toast.timer = setTimeout(() => {
|
||||||
|
toastText.value = ''
|
||||||
|
}, 3200)
|
||||||
|
}
|
||||||
|
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const toastText = ref('')
|
|
||||||
|
|
||||||
function toast(text) {
|
|
||||||
toastText.value = text
|
|
||||||
clearTimeout(toast.timer)
|
|
||||||
toast.timer = setTimeout(() => { toastText.value = '' }, 3200)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { toastText, toast }
|
return { toastText, toast }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import PrimeVue from 'primevue/config'
|
|||||||
import Aura from '@primevue/themes/aura'
|
import Aura from '@primevue/themes/aura'
|
||||||
import 'primeicons/primeicons.css'
|
import 'primeicons/primeicons.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router/index.js'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(MotionPlugin)
|
app.use(MotionPlugin)
|
||||||
|
app.use(router)
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
preset: Aura,
|
preset: Aura,
|
||||||
|
|||||||
110
web/src/router/index.js
Normal file
110
web/src/router/index.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import { appViews } from '../composables/useNavigation.js'
|
||||||
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
|
import AppShellRouteView from '../views/AppShellRouteView.vue'
|
||||||
|
import LoginRouteView from '../views/LoginRouteView.vue'
|
||||||
|
import SetupRouteView from '../views/SetupRouteView.vue'
|
||||||
|
|
||||||
|
const appChildRoutes = appViews
|
||||||
|
.filter((view) => view !== 'requests')
|
||||||
|
.map((view) => ({
|
||||||
|
path: view,
|
||||||
|
name: `app-${view}`,
|
||||||
|
component: AppShellRouteView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
appView: view
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'root',
|
||||||
|
redirect: () => {
|
||||||
|
const { resolveEntryRoute } = useSystemState()
|
||||||
|
return resolveEntryRoute()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/setup',
|
||||||
|
name: 'setup',
|
||||||
|
component: SetupRouteView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginRouteView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app',
|
||||||
|
redirect: { name: 'app-overview' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app/requests',
|
||||||
|
name: 'app-requests',
|
||||||
|
component: AppShellRouteView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
appView: 'requests'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app/requests/:requestId',
|
||||||
|
name: 'app-request-detail',
|
||||||
|
component: AppShellRouteView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
appView: 'requests'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...appChildRoutes.map((route) => ({
|
||||||
|
...route,
|
||||||
|
path: `/app/${route.path}`
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const { isInitialized, loggedIn, resolveEntryRoute } = useSystemState()
|
||||||
|
|
||||||
|
if (!isInitialized.value) {
|
||||||
|
if (to.name !== 'setup') {
|
||||||
|
return { name: 'setup' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.name === 'setup') {
|
||||||
|
return resolveEntryRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loggedIn.value && to.meta.requiresAuth) {
|
||||||
|
return {
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
redirect: to.fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loggedIn.value && to.name === 'login') {
|
||||||
|
return resolveEntryRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.name === 'root') {
|
||||||
|
return resolveEntryRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
78
web/src/services/bootstrap.js
vendored
Normal file
78
web/src/services/bootstrap.js
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const SETUP_API_BASE = '/__setup'
|
||||||
|
|
||||||
|
function formatValidationErrors(detail) {
|
||||||
|
if (!Array.isArray(detail)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail
|
||||||
|
.map((item) => {
|
||||||
|
const field = Array.isArray(item.loc) ? item.loc[item.loc.length - 1] : 'field'
|
||||||
|
return `${field}: ${item.msg}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
let response
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(`${SETUP_API_BASE}${path}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {})
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new Error('无法连接初始化服务,请确认本地配置桥已启动。')
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await response.json()
|
||||||
|
} catch {
|
||||||
|
data = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const validationMessage = formatValidationErrors(data?.detail)
|
||||||
|
const message = validationMessage || data?.detail || '初始化请求失败,请稍后重试。'
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBootstrapState() {
|
||||||
|
return request('/bootstrap')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBootstrapConfig(payload) {
|
||||||
|
return request('/bootstrap', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testBootstrapRuntime(payload) {
|
||||||
|
return request('/bootstrap/runtime', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testBootstrapDatabase(payload) {
|
||||||
|
return request('/bootstrap/database', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginBootstrapAdmin(payload) {
|
||||||
|
return request('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
87
web/src/utils/requestViewModel.js
Normal file
87
web/src/utils/requestViewModel.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
function parseRequestDateFromId(id) {
|
||||||
|
const match = String(id || '').match(/^REQ-(\d{4})-(\d{2})(\d{2})$/)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, year, month, day] = match
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTripWindow(range) {
|
||||||
|
const normalized = String(range || '')
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return '待补充'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('本月')) {
|
||||||
|
return '本月申请'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('本周')) {
|
||||||
|
return '本周申请'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('今天')) {
|
||||||
|
return '今日申请'
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApproval(status) {
|
||||||
|
if (status === 'success') {
|
||||||
|
return {
|
||||||
|
node: '已完成归档',
|
||||||
|
approval: '已完成',
|
||||||
|
approvalTone: 'success',
|
||||||
|
travel: '已完成行程',
|
||||||
|
travelTone: 'success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'danger') {
|
||||||
|
return {
|
||||||
|
node: '异常待复核',
|
||||||
|
approval: '待处理',
|
||||||
|
approvalTone: 'danger',
|
||||||
|
travel: '存在异常',
|
||||||
|
travelTone: 'danger'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: '财务审核中',
|
||||||
|
approval: '审批中',
|
||||||
|
approvalTone: 'info',
|
||||||
|
travel: '待安排行程',
|
||||||
|
travelTone: 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequestForUi(request) {
|
||||||
|
if (!request) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTime = parseRequestDateFromId(request.id) || '2026-04-18'
|
||||||
|
const reason = `${request.category || '差旅'}申请`
|
||||||
|
const city = request.entity || '待补充'
|
||||||
|
const period = formatTripWindow(request.range)
|
||||||
|
const approvalState = mapApproval(request.status)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
reason,
|
||||||
|
city,
|
||||||
|
period,
|
||||||
|
applyTime,
|
||||||
|
node: approvalState.node,
|
||||||
|
approval: approvalState.approval,
|
||||||
|
approvalTone: approvalState.approvalTone,
|
||||||
|
travel: approvalState.travel,
|
||||||
|
travelTone: approvalState.travelTone
|
||||||
|
}
|
||||||
|
}
|
||||||
176
web/src/views/AppShellRouteView.vue
Normal file
176
web/src/views/AppShellRouteView.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<SidebarRail
|
||||||
|
:nav-items="navItems"
|
||||||
|
:active-view="activeView"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
@open-chat="handleOpenChat"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="main"
|
||||||
|
:class="{
|
||||||
|
'chat-main': activeView === 'chat',
|
||||||
|
'overview-main': activeView === 'overview',
|
||||||
|
'workbench-main': activeView === 'workbench',
|
||||||
|
'requests-main': activeView === 'requests',
|
||||||
|
'approval-main': activeView === 'approval',
|
||||||
|
'policies-main': activeView === 'policies',
|
||||||
|
'audit-main': activeView === 'audit',
|
||||||
|
'employees-main': activeView === 'employees'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<TopBar
|
||||||
|
:current-view="topBarView"
|
||||||
|
:search="search"
|
||||||
|
:active-view="activeView"
|
||||||
|
:ranges="ranges"
|
||||||
|
:active-range="activeRange"
|
||||||
|
:custom-range="customRange"
|
||||||
|
@update:search="search = $event"
|
||||||
|
@update:active-range="activeRange = $event"
|
||||||
|
@update:custom-range="customRange = $event"
|
||||||
|
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||||
|
@open-chat="handleOpenChat"
|
||||||
|
@new-application="openTravelCreate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
|
||||||
|
:compact="activeView === 'overview'"
|
||||||
|
:filters="filters"
|
||||||
|
:ranges="ranges"
|
||||||
|
:active-range="activeRange"
|
||||||
|
@update:active-range="activeRange = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="workarea"
|
||||||
|
:class="{
|
||||||
|
'chat-workarea': activeView === 'chat',
|
||||||
|
'requests-workarea': activeView === 'requests',
|
||||||
|
'approval-workarea': activeView === 'approval',
|
||||||
|
'policies-workarea': activeView === 'policies',
|
||||||
|
'audit-workarea': activeView === 'audit',
|
||||||
|
'employees-workarea': activeView === 'employees'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<OverviewView
|
||||||
|
v-if="activeView === 'overview'"
|
||||||
|
:filtered-requests="filteredRequests"
|
||||||
|
@ask="handleOpenChat"
|
||||||
|
@approve="handleApprove"
|
||||||
|
@reject="handleReject"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PersonalWorkbenchView
|
||||||
|
v-else-if="activeView === 'workbench'"
|
||||||
|
@open-assistant="openSmartEntry"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatView
|
||||||
|
v-else-if="activeView === 'chat'"
|
||||||
|
:documents="filteredDocuments"
|
||||||
|
:doc-search="docSearch"
|
||||||
|
:messages="messages"
|
||||||
|
:uploaded-files="uploadedFiles"
|
||||||
|
:active-case="activeCase"
|
||||||
|
:quick-prompts="travelPrompts"
|
||||||
|
:draft="draft"
|
||||||
|
:message-list="messageList"
|
||||||
|
@send="sendMessage"
|
||||||
|
@upload="handleUpload"
|
||||||
|
@draft="draft = $event"
|
||||||
|
@select-case="handleOpenChat"
|
||||||
|
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过。`)"
|
||||||
|
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回。`)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TravelRequestDetailView
|
||||||
|
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
|
||||||
|
:request="selectedTravelRequest"
|
||||||
|
@back-to-requests="closeRequestDetail"
|
||||||
|
@open-assistant="openSmartEntry"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RequestsView
|
||||||
|
v-else-if="activeView === 'requests'"
|
||||||
|
:filtered-requests="filteredRequests"
|
||||||
|
@ask="openRequestDetail"
|
||||||
|
@approve="handleApprove"
|
||||||
|
@reject="handleReject"
|
||||||
|
@create-request="openTravelCreate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||||
|
<PoliciesView v-else-if="activeView === 'policies'" />
|
||||||
|
<AuditView v-else-if="activeView === 'audit'" />
|
||||||
|
<EmployeeManagementView v-else />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<TravelReimbursementCreateView
|
||||||
|
v-if="smartEntryOpen"
|
||||||
|
:key="smartEntrySessionId"
|
||||||
|
:initial-prompt="smartEntryContext.prompt"
|
||||||
|
:entry-source="smartEntryContext.source"
|
||||||
|
:request-context="smartEntryContext.request"
|
||||||
|
@close="closeSmartEntry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||||
|
import TopBar from '../components/layout/TopBar.vue'
|
||||||
|
import FilterBar from '../components/layout/FilterBar.vue'
|
||||||
|
import OverviewView from './OverviewView.vue'
|
||||||
|
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||||
|
import ChatView from './ChatView.vue'
|
||||||
|
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||||
|
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||||
|
import RequestsView from './RequestsView.vue'
|
||||||
|
import ApprovalCenterView from './ApprovalCenterView.vue'
|
||||||
|
import PoliciesView from './PoliciesView.vue'
|
||||||
|
import AuditView from './AuditView.vue'
|
||||||
|
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||||
|
|
||||||
|
import { useAppShell } from '../composables/useAppShell.js'
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCase,
|
||||||
|
activeRange,
|
||||||
|
activeView,
|
||||||
|
closeRequestDetail,
|
||||||
|
closeSmartEntry,
|
||||||
|
customRange,
|
||||||
|
detailMode,
|
||||||
|
docSearch,
|
||||||
|
draft,
|
||||||
|
filteredDocuments,
|
||||||
|
filteredRequests,
|
||||||
|
filters,
|
||||||
|
handleApprove,
|
||||||
|
handleNavigate,
|
||||||
|
handleOpenChat,
|
||||||
|
handleReject,
|
||||||
|
handleUpload,
|
||||||
|
messageList,
|
||||||
|
messages,
|
||||||
|
navItems,
|
||||||
|
openRequestDetail,
|
||||||
|
openSmartEntry,
|
||||||
|
openTravelCreate,
|
||||||
|
ranges,
|
||||||
|
search,
|
||||||
|
selectedTravelRequest,
|
||||||
|
sendMessage,
|
||||||
|
smartEntryContext,
|
||||||
|
smartEntryOpen,
|
||||||
|
smartEntrySessionId,
|
||||||
|
toast,
|
||||||
|
topBarView,
|
||||||
|
travelPrompts,
|
||||||
|
uploadedFiles
|
||||||
|
} = useAppShell()
|
||||||
|
</script>
|
||||||
46
web/src/views/LoginRouteView.vue
Normal file
46
web/src/views/LoginRouteView.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<LoginView
|
||||||
|
:company-name="companyProfile.name"
|
||||||
|
:submitting="loginSubmitting"
|
||||||
|
:error-message="loginError"
|
||||||
|
@login="submitLogin"
|
||||||
|
@recover-password="handleRecoverPassword"
|
||||||
|
@sso-login="handleSsoLogin"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
|
import LoginView from './LoginView.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const {
|
||||||
|
companyProfile,
|
||||||
|
handleLogin,
|
||||||
|
handleRecoverPassword,
|
||||||
|
handleSsoLogin,
|
||||||
|
loginError,
|
||||||
|
loginSubmitting,
|
||||||
|
resolveEntryRoute
|
||||||
|
} = useSystemState()
|
||||||
|
|
||||||
|
async function submitLogin(credentials) {
|
||||||
|
const passed = await handleLogin(credentials)
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
|
||||||
|
|
||||||
|
if (redirect.startsWith('/app/')) {
|
||||||
|
router.replace(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(resolveEntryRoute())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<main class="login-page">
|
<main class="login-page">
|
||||||
<header class="page-brand">
|
<header class="page-brand">
|
||||||
<LogoMark />
|
<LogoMark />
|
||||||
<strong>星海科技</strong>
|
<strong>{{ displayCompanyName }}</strong>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
|
|
||||||
<div class="metric-card amount">
|
<div class="metric-card amount">
|
||||||
<span>报销金额趋势</span>
|
<span>报销金额趋势</span>
|
||||||
<strong>¥361,600</strong>
|
<strong>¥ 61,600</strong>
|
||||||
<small>较昨日 <b class="up">↑ 8.3%</b></small>
|
<small>较昨日 <b class="up">+8.3%</b></small>
|
||||||
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
|
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,19 +36,19 @@
|
|||||||
<div class="metric-card risk">
|
<div class="metric-card risk">
|
||||||
<span>风险预警</span>
|
<span>风险预警</span>
|
||||||
<strong><i class="mdi mdi-alert"></i> 14 单</strong>
|
<strong><i class="mdi mdi-alert"></i> 14 单</strong>
|
||||||
<small>较昨日 <b class="danger">↑ 16.7%</b></small>
|
<small>较昨日 <b class="danger">+16.7%</b></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-card audit">
|
<div class="metric-card audit">
|
||||||
<span>审批效率</span>
|
<span>审批效率</span>
|
||||||
<strong>78%</strong>
|
<strong>78%</strong>
|
||||||
<small>较昨日 <b class="up">↑ 6.2%</b></small>
|
<small>较昨日 <b class="up">+6.2%</b></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-card sla">
|
<div class="metric-card sla">
|
||||||
<span>SLA 达成率</span>
|
<span>SLA 达成率</span>
|
||||||
<strong>96%</strong>
|
<strong>96%</strong>
|
||||||
<small>较昨日 <b class="up">↑ 3.1%</b></small>
|
<small>较昨日 <b class="up">+3.1%</b></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,18 +66,19 @@
|
|||||||
<section class="login-card" aria-label="登录表单">
|
<section class="login-card" aria-label="登录表单">
|
||||||
<div class="card-brand">
|
<div class="card-brand">
|
||||||
<LogoMark />
|
<LogoMark />
|
||||||
<strong>星海科技</strong>
|
<strong>{{ displayCompanyName }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="card-head">
|
<header class="card-head">
|
||||||
<h2>欢迎登录</h2>
|
<h2>欢迎登录</h2>
|
||||||
<p>登录企业报销智能运营台</p>
|
<p>使用初始化时创建的管理员账号进入系统</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="sr-only">账号</span>
|
<span class="sr-only">账号</span>
|
||||||
<i class="mdi mdi-account-outline"></i>
|
<i class="mdi mdi-account-outline"></i>
|
||||||
<input v-model="username" type="text" placeholder="请输入账号 / 邮箱 / 手机号" autocomplete="username" required />
|
<input v-model="username" type="text" placeholder="请输入管理员账号" autocomplete="username" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
placeholder="请输入密码"
|
placeholder="请输入管理员密码"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
>
|
>
|
||||||
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-slash'"></i>
|
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -112,16 +113,20 @@
|
|||||||
<div class="form-meta">
|
<div class="form-meta">
|
||||||
<label class="remember">
|
<label class="remember">
|
||||||
<input v-model="remember" type="checkbox" />
|
<input v-model="remember" type="checkbox" />
|
||||||
<span>记住我</span>
|
<span>记住账号</span>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
|
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="submit-btn" type="submit">登录</button>
|
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<button class="submit-btn" type="submit" :disabled="submitting">
|
||||||
|
{{ submitting ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="divider"><span>或</span></div>
|
<div class="divider"><span>或</span></div>
|
||||||
|
|
||||||
<button class="sso-btn" type="button" @click="emit('sso-login')">
|
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
|
||||||
<i class="mdi mdi-shield-outline"></i>
|
<i class="mdi mdi-shield-outline"></i>
|
||||||
<span>SSO 单点登录</span>
|
<span>SSO 单点登录</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -129,26 +134,37 @@
|
|||||||
|
|
||||||
<footer class="security-note">
|
<footer class="security-note">
|
||||||
<i class="mdi mdi-lock-outline"></i>
|
<i class="mdi mdi-lock-outline"></i>
|
||||||
<span>安全登录 · 数据加密传输 · 如需帮助请联系管理员</span>
|
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { useLoginView } from '../composables/useLoginView.js'
|
import { useLoginView } from '../composables/useLoginView.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
companyName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
submitting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
|
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
|
||||||
|
|
||||||
const {
|
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
||||||
features,
|
|
||||||
LogoMark,
|
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
|
||||||
password,
|
|
||||||
remember,
|
|
||||||
showPassword,
|
|
||||||
tenant,
|
|
||||||
username
|
|
||||||
} = useLoginView()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/views/login-view.css"></style>
|
<style scoped src="../assets/styles/views/login-view.css"></style>
|
||||||
|
|||||||
51
web/src/views/SetupRouteView.vue
Normal file
51
web/src/views/SetupRouteView.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<SetupView
|
||||||
|
:initial-state="bootstrapState || {}"
|
||||||
|
:submitting="setupSubmitting"
|
||||||
|
:runtime-testing="runtimeTesting"
|
||||||
|
:database-testing="databaseTesting"
|
||||||
|
:runtime-test-passed="runtimeTestPassed"
|
||||||
|
:database-test-passed="databaseTestPassed"
|
||||||
|
:runtime-test-message="runtimeTestMessage"
|
||||||
|
:database-test-message="databaseTestMessage"
|
||||||
|
:error-message="setupError"
|
||||||
|
@submit="submitSetup"
|
||||||
|
@runtime-test="handleRuntimeTest"
|
||||||
|
@database-test="handleDatabaseTest"
|
||||||
|
@runtime-dirty="handleRuntimeDirty"
|
||||||
|
@database-dirty="handleDatabaseDirty"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
|
import SetupView from './SetupView.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const {
|
||||||
|
bootstrapState,
|
||||||
|
databaseTestMessage,
|
||||||
|
databaseTestPassed,
|
||||||
|
databaseTesting,
|
||||||
|
handleDatabaseDirty,
|
||||||
|
handleDatabaseTest,
|
||||||
|
handleRuntimeDirty,
|
||||||
|
handleRuntimeTest,
|
||||||
|
handleSetupSubmit,
|
||||||
|
runtimeTestMessage,
|
||||||
|
runtimeTestPassed,
|
||||||
|
runtimeTesting,
|
||||||
|
setupError,
|
||||||
|
setupSubmitting
|
||||||
|
} = useSystemState()
|
||||||
|
|
||||||
|
async function submitSetup(payload) {
|
||||||
|
const completed = await handleSetupSubmit(payload)
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
router.replace({ name: 'login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
316
web/src/views/SetupView.vue
Normal file
316
web/src/views/SetupView.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<template>
|
||||||
|
<main class="setup-page">
|
||||||
|
<aside class="setup-context">
|
||||||
|
<div class="setup-brand">
|
||||||
|
<div class="setup-brand-mark" aria-hidden="true">
|
||||||
|
<span class="setup-brand-ring"></span>
|
||||||
|
<span class="setup-brand-core">XF</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="setup-kicker">INITIAL SETUP</p>
|
||||||
|
<h1>初始化配置</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="setup-lead">
|
||||||
|
先完成 4 个必要步骤,再进入主登录界面。扩展服务当前不参与初始化完成条件。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<nav class="setup-nav" aria-label="初始化步骤">
|
||||||
|
<button
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.id"
|
||||||
|
class="setup-nav-item"
|
||||||
|
:class="{ 'is-active': activeSection === section.id, 'is-complete': section.complete }"
|
||||||
|
type="button"
|
||||||
|
@click="goToSection(section.id)"
|
||||||
|
>
|
||||||
|
<span class="setup-nav-index">{{ section.index }}</span>
|
||||||
|
<span class="setup-nav-copy">
|
||||||
|
<strong>{{ section.title }}</strong>
|
||||||
|
<small>{{ section.desc }}</small>
|
||||||
|
</span>
|
||||||
|
<i v-if="section.complete" class="pi pi-check setup-nav-check"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="setup-progress">
|
||||||
|
<strong>{{ completionCount }} / {{ sections.length }} 已完成</strong>
|
||||||
|
<p>企业信息、管理员安全、运行端口、数据库连接都通过后,左下角会自动出现完成初始化按钮。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canSubmit" class="setup-complete">
|
||||||
|
<p>所有必要步骤已通过检测,可以写入配置并进入登录界面。</p>
|
||||||
|
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
|
||||||
|
<i class="pi pi-check"></i>
|
||||||
|
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="setup-panel">
|
||||||
|
<header class="setup-panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="setup-kicker setup-kicker-light">{{ activeStep.index }}</p>
|
||||||
|
<h2>{{ activeStep.title }}</h2>
|
||||||
|
<p class="setup-panel-desc">{{ activeStep.desc }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="setup-chip" :class="{ 'is-success': activeStep.complete }">
|
||||||
|
{{ activeStep.complete ? '已完成' : '待配置' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="setup-form">
|
||||||
|
<section v-if="activeSection === 'company'" class="setup-stage">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>企业基础信息</h3>
|
||||||
|
<p>这里仅保留企业名称与企业编码,不放管理员邮箱。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-grid field-grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>企业名称</span>
|
||||||
|
<input v-model.trim="form.company_name" type="text" placeholder="请输入企业名称" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>企业编码</span>
|
||||||
|
<input v-model.trim="form.company_code" type="text" placeholder="例如 FIN" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="activeSection === 'admin'" class="setup-stage">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>管理员安全</h3>
|
||||||
|
<p>管理员邮箱、账号和密码在这里配置。密码不会写入 `.env`,只会保存哈希后的密文。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-grid field-grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>管理员邮箱</span>
|
||||||
|
<input v-model.trim="form.admin_email" type="email" placeholder="admin@company.com" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>管理员账号</span>
|
||||||
|
<input v-model.trim="form.admin_username" type="text" placeholder="例如 superadmin" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>管理员密码</span>
|
||||||
|
<input
|
||||||
|
v-model="form.admin_password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入管理员密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>确认密码</span>
|
||||||
|
<input
|
||||||
|
v-model="form.admin_password_confirm"
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入管理员密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="field-group-note">管理员密码当前暂定至少 5 位。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="activeSection === 'runtime'" class="setup-stage">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>运行端口配置</h3>
|
||||||
|
<p>这一步只检测 Web 和 Server 端口占用情况,不检测数据库。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-grid field-grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Web Host</span>
|
||||||
|
<input v-model.trim="form.web_host" type="text" placeholder="127.0.0.1" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Web Port</span>
|
||||||
|
<input v-model.number="form.web_port" type="number" min="1" max="65535" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Server Host</span>
|
||||||
|
<input v-model.trim="form.server_host" type="text" placeholder="127.0.0.1" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Server Port</span>
|
||||||
|
<input v-model.number="form.server_port" type="number" min="1" max="65535" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-runtime">
|
||||||
|
<article v-for="item in runtimeEndpoints" :key="item.label">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="setup-stage">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>数据库连接</h3>
|
||||||
|
<p>这里检测 PostgreSQL 连接。Redis 作为扩展服务暂时可选,不影响完成初始化。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-grid field-grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>PostgreSQL Host</span>
|
||||||
|
<input v-model.trim="form.postgres_host" type="text" placeholder="127.0.0.1" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>PostgreSQL Port</span>
|
||||||
|
<input v-model.number="form.postgres_port" type="number" min="1" max="65535" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>数据库名称</span>
|
||||||
|
<input v-model.trim="form.postgres_db" type="text" placeholder="x_financial" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>数据库用户</span>
|
||||||
|
<input v-model.trim="form.postgres_user" type="text" placeholder="postgres" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-span-2">
|
||||||
|
<span>数据库密码</span>
|
||||||
|
<input
|
||||||
|
v-model="form.postgres_password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入数据库密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="optional-block">
|
||||||
|
<div class="optional-block-head">
|
||||||
|
<strong>扩展服务</strong>
|
||||||
|
<span>可选</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Redis URL</span>
|
||||||
|
<input v-model.trim="form.redis_url" type="text" placeholder="redis://127.0.0.1:6379/0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-summary-grid">
|
||||||
|
<article v-for="item in summaryItems" :key="item.label" class="setup-summary-item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.label }}</strong>
|
||||||
|
<span>{{ item.detail }}</span>
|
||||||
|
</div>
|
||||||
|
<i :class="['pi', item.complete ? 'pi-check-circle' : 'pi-clock']"></i>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="currentTestMessage" :class="['setup-status', currentTestPassed ? 'is-success' : 'is-danger']">
|
||||||
|
{{ currentTestMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="setup-error">{{ errorMessage }}</p>
|
||||||
|
<p v-if="submitHint" class="setup-gate">{{ submitHint }}</p>
|
||||||
|
|
||||||
|
<footer class="setup-actions">
|
||||||
|
<div class="setup-actions-right">
|
||||||
|
<button
|
||||||
|
v-if="showTestAction"
|
||||||
|
class="secondary-btn secondary-btn-strong"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canTest"
|
||||||
|
@click="testSetup"
|
||||||
|
>
|
||||||
|
<i :class="testButtonIcon"></i>
|
||||||
|
<span>{{ testButtonLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useSetupView } from '../composables/useSetupView.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
initialState: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
submitting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
runtimeTesting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
databaseTesting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
runtimeTestPassed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
databaseTestPassed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
runtimeTestMessage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
databaseTestMessage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'runtime-test', 'database-test', 'runtime-dirty', 'database-dirty'])
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeSection,
|
||||||
|
activeStep,
|
||||||
|
canSubmit,
|
||||||
|
canTest,
|
||||||
|
completionCount,
|
||||||
|
currentTestMessage,
|
||||||
|
currentTestPassed,
|
||||||
|
form,
|
||||||
|
goToSection,
|
||||||
|
runtimeEndpoints,
|
||||||
|
sections,
|
||||||
|
showTestAction,
|
||||||
|
submitForm,
|
||||||
|
submitHint,
|
||||||
|
summaryItems,
|
||||||
|
testButtonIcon,
|
||||||
|
testButtonLabel,
|
||||||
|
testSetup
|
||||||
|
} = useSetupView(props, emit)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/setup-view.css"></style>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RequestsView',
|
name: 'RequestsView',
|
||||||
props: {
|
props: {
|
||||||
filteredRequests: { type: Array, required: true }
|
filteredRequests: { type: Array, required: true }
|
||||||
},
|
},
|
||||||
emits: ['ask', 'approve', 'reject', 'create-request'] ,
|
emits: ['ask', 'approve', 'reject', 'create-request'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const activeTab = ref('全部')
|
const activeTab = ref('全部')
|
||||||
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
|
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
|
||||||
@@ -18,49 +20,28 @@ export default {
|
|||||||
const appliedEnd = ref('')
|
const appliedEnd = ref('')
|
||||||
|
|
||||||
const dateRangeLabel = computed(() => {
|
const dateRangeLabel = computed(() => {
|
||||||
if (appliedStart.value && appliedEnd.value) return `${appliedStart.value} ~ ${appliedEnd.value}`
|
if (appliedStart.value && appliedEnd.value) {
|
||||||
|
return `${appliedStart.value} ~ ${appliedEnd.value}`
|
||||||
|
}
|
||||||
|
|
||||||
return '选择时间段'
|
return '选择时间段'
|
||||||
})
|
})
|
||||||
|
|
||||||
function applyDateRange() {
|
function applyDateRange() {
|
||||||
if (!rangeStart.value || !rangeEnd.value) return
|
if (!rangeStart.value || !rangeEnd.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
appliedStart.value = rangeStart.value
|
appliedStart.value = rangeStart.value
|
||||||
appliedEnd.value = rangeEnd.value
|
appliedEnd.value = rangeEnd.value
|
||||||
datePopover.value = false
|
datePopover.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = [
|
const rows = computed(() =>
|
||||||
{ id: 'BR240715001', reason: '华东区域客户拜访', city: '上海、苏州、杭州', period: '07-14~07-17 (4天)', applyTime: '2024-07-13', amount: '¥4,280.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
|
props.filteredRequests
|
||||||
{ id: 'BR240714010', reason: '年度战略合作伙伴会议', city: '北京', period: '07-15~07-16 (2天)', applyTime: '2024-07-12', amount: '¥1,860.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
|
.map((item) => normalizeRequestForUi(item))
|
||||||
{ id: 'BR240713008', reason: '产品培训与交流', city: '深圳', period: '07-10~07-12 (3天)', applyTime: '2024-07-09', amount: '¥2,150.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
|
.filter(Boolean)
|
||||||
{ id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08~07-11 (4天)', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
|
)
|
||||||
{ id: 'BR240711005', reason: '华南区域市场调研', city: '广州、佛山', period: '07-09~07-11 (3天)', applyTime: '2024-07-06', amount: '¥1,920.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
|
|
||||||
{ id: 'BR240710003', reason: '供应商现场考察', city: '东莞', period: '07-06~07-07 (2天)', applyTime: '2024-07-05', amount: '¥680.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240709005', reason: '客户方案汇报', city: '北京', period: '07-06~07-08 (3天)', applyTime: '2024-07-05', amount: '¥1,980.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240708012', reason: '供应商现场考察', city: '广州', period: '07-04~07-05 (2天)', applyTime: '2024-07-03', amount: '¥860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
|
|
||||||
{ id: 'BR240707003', reason: '项目启动会', city: '成都', period: '07-01~07-03 (3天)', applyTime: '2024-06-29', amount: '¥2,420.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
|
|
||||||
{ id: 'BR240706009', reason: '客户拜访与市场调研', city: '南京、合肥', period: '06-28~06-30 (3天)', applyTime: '2024-06-26', amount: '¥1,750.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240705007', reason: '技术交流会', city: '武汉', period: '06-25~06-26 (2天)', applyTime: '2024-06-23', amount: '¥1,120.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
|
|
||||||
{ id: 'BR240704004', reason: '渠道合作洽谈', city: '西安', period: '06-20~06-21 (2天)', applyTime: '2024-06-18', amount: '¥780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240703011', reason: '新员工入职培训', city: '长沙', period: '06-18~06-19 (2天)', applyTime: '2024-06-16', amount: '¥920.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240702006', reason: '季度业绩复盘会', city: '杭州', period: '06-15~06-16 (2天)', applyTime: '2024-06-13', amount: '¥1,350.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240701002', reason: '智慧金融峰会参展', city: '上海', period: '06-12~06-14 (3天)', applyTime: '2024-06-10', amount: '¥5,680.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240630009', reason: '西南区域渠道拓展', city: '重庆、贵阳', period: '06-10~06-13 (4天)', applyTime: '2024-06-08', amount: '¥3,450.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240629003', reason: '信息安全合规审计', city: '深圳', period: '06-08~06-09 (2天)', applyTime: '2024-06-06', amount: '¥1,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
|
|
||||||
{ id: 'BR240628007', reason: '产学研合作对接', city: '南京', period: '06-05~06-07 (3天)', applyTime: '2024-06-03', amount: '¥2,260.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240627001', reason: 'ERP系统上线支持', city: '青岛', period: '06-03~06-05 (3天)', applyTime: '2024-06-01', amount: '¥1,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240626004', reason: '大客户续约洽谈', city: '天津', period: '06-01~06-02 (2天)', applyTime: '2024-05-29', amount: '¥890.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240625010', reason: '区域销售团队建设', city: '厦门', period: '05-28~05-30 (3天)', applyTime: '2024-05-26', amount: '¥2,780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240624002', reason: '供应链管理系统演示', city: '苏州', period: '05-25~05-26 (2天)', applyTime: '2024-05-23', amount: '¥650.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
|
|
||||||
{ id: 'BR240623008', reason: '行业白皮书发布会', city: '北京', period: '05-22~05-23 (2天)', applyTime: '2024-05-20', amount: '¥1,560.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
|
|
||||||
{ id: 'BR240622005', reason: '跨部门协同工作坊', city: '大连', period: '05-20~05-22 (3天)', applyTime: '2024-05-18', amount: '¥2,340.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
|
|
||||||
{ id: 'BR240621003', reason: '数字化转型的客户交流', city: '深圳、珠海', period: '05-16~05-18 (3天)', applyTime: '2024-05-14', amount: '¥3,120.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
|
|
||||||
{ id: 'BR240620006', reason: '年中预算评审会', city: '上海', period: '05-13~05-14 (2天)', applyTime: '2024-05-11', amount: '¥1,480.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240619001', reason: '医疗行业解决方案展', city: '成都', period: '05-10~05-12 (3天)', applyTime: '2024-05-08', amount: '¥3,860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240618009', reason: '东北区域客户回访', city: '沈阳、长春', period: '05-06~05-09 (4天)', applyTime: '2024-05-04', amount: '¥4,520.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
|
|
||||||
{ id: 'BR240617007', reason: '大数据平台技术对接', city: '杭州', period: '05-03~05-05 (3天)', applyTime: '2024-05-01', amount: '¥2,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
|
|
||||||
{ id: 'BR240616004', reason: '国际业务合规培训', city: '北京', period: '04-28~04-30 (3天)', applyTime: '2024-04-26', amount: '¥2,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
@@ -74,8 +55,27 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
if (activeTab.value === '全部') return rows
|
if (activeTab.value === '全部') {
|
||||||
return rows.filter((row) => row.approval === activeTab.value || row.travel.includes(activeTab.value.replace('待出行', '待订')))
|
return rows.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab.value === '待提交') {
|
||||||
|
return rows.value.filter((row) => row.approval === '待提交')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab.value === '审批中') {
|
||||||
|
return rows.value.filter((row) => row.approval === '审批中')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab.value === '待出行') {
|
||||||
|
return rows.value.filter((row) => row.travel.includes('待'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab.value === '已完成') {
|
||||||
|
return rows.value.filter((row) => row.approval === '已完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalCount = computed(() => filteredRows.value.length)
|
const totalCount = computed(() => filteredRows.value.length)
|
||||||
@@ -86,7 +86,9 @@ export default {
|
|||||||
return filteredRows.value.slice(start, start + pageSize.value)
|
return filteredRows.value.slice(start, start + pageSize.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(activeTab, () => { currentPage.value = 1 })
|
watch([activeTab, rows], () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emit,
|
emit,
|
||||||
@@ -113,4 +115,3 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
web/start.sh
176
web/start.sh
@@ -1,14 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# X-Financial Reimbursement Admin - Start Script
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
ROOT_ENV_FILE="$ROOT_DIR/.env"
|
||||||
|
MODE="${1:-start}"
|
||||||
|
|
||||||
# Colors
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
@@ -18,22 +16,29 @@ info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
|||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
if [ -f "$ROOT_ENV_FILE" ]; then
|
||||||
# Check Node.js
|
set -a
|
||||||
# ----------------------------------------------------------
|
. "$ROOT_ENV_FILE"
|
||||||
if ! command -v node &>/dev/null; then
|
set +a
|
||||||
error "Node.js is not installed. Install it first: https://nodejs.org"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v npm &>/dev/null; then
|
WEB_HOST="${WEB_HOST:-127.0.0.1}"
|
||||||
error "npm is not installed. It should come with Node.js."
|
WEB_PORT="${WEB_PORT:-5173}"
|
||||||
fi
|
|
||||||
|
|
||||||
info "Node.js $(node -v) | npm $(npm -v)"
|
export VITE_SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
||||||
|
export VITE_COMPANY_NAME="${COMPANY_NAME:-}"
|
||||||
|
export VITE_COMPANY_CODE="${COMPANY_CODE:-}"
|
||||||
|
export VITE_ADMIN_EMAIL="${ADMIN_EMAIL:-}"
|
||||||
|
export VITE_WEB_HOST="${WEB_HOST}"
|
||||||
|
export VITE_WEB_PORT="${WEB_PORT}"
|
||||||
|
export VITE_SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
|
||||||
|
export VITE_SERVER_PORT="${SERVER_PORT:-8000}"
|
||||||
|
export VITE_POSTGRES_HOST="${POSTGRES_HOST:-127.0.0.1}"
|
||||||
|
export VITE_POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||||
|
export VITE_POSTGRES_DB="${POSTGRES_DB:-x_financial}"
|
||||||
|
export VITE_POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
export VITE_REDIS_URL="${REDIS_URL:-}"
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# WSL on a Windows-mounted repo should reuse Windows Node
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
is_wsl() {
|
is_wsl() {
|
||||||
grep -qi microsoft /proc/version 2>/dev/null
|
grep -qi microsoft /proc/version 2>/dev/null
|
||||||
}
|
}
|
||||||
@@ -45,42 +50,125 @@ is_windows_mount() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_wsl && is_windows_mount && command -v powershell.exe &>/dev/null && command -v wslpath &>/dev/null; then
|
use_windows_npm() {
|
||||||
WIN_PATH="$(wslpath -w "$SCRIPT_DIR")"
|
is_wsl && is_windows_mount && command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1
|
||||||
WIN_PATH_PS="${WIN_PATH//\'/\'\'}"
|
}
|
||||||
info "Detected WSL on a Windows-mounted project"
|
|
||||||
info "Using Windows npm to avoid cross-platform node_modules installs"
|
windows_project_path() {
|
||||||
info "Access: http://127.0.0.1:5173"
|
wslpath -w "$SCRIPT_DIR"
|
||||||
echo ""
|
}
|
||||||
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$WIN_PATH_PS'; npm start"
|
|
||||||
fi
|
run_windows_powershell() {
|
||||||
|
local command="$1"
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$command"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_windows_npm_install() {
|
||||||
|
local win_path
|
||||||
|
local win_path_ps
|
||||||
|
|
||||||
|
win_path="$(windows_project_path)"
|
||||||
|
win_path_ps="${win_path//\'/\'\'}"
|
||||||
|
run_windows_powershell "Set-Location -LiteralPath '$win_path_ps'; npm install"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_windows_npm_start() {
|
||||||
|
local win_path
|
||||||
|
local win_path_ps
|
||||||
|
|
||||||
|
win_path="$(windows_project_path)"
|
||||||
|
win_path_ps="${win_path//\'/\'\'}"
|
||||||
|
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$win_path_ps'; npm start -- --host $WEB_HOST --port $WEB_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# Install dependencies only when they are missing or unusable
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
dependencies_ready() {
|
dependencies_ready() {
|
||||||
[ -d "node_modules" ] || return 1
|
[ -d "node_modules" ] || return 1
|
||||||
[ -f "node_modules/vite/bin/vite.js" ] || return 1
|
[ -f "node_modules/vite/bin/vite.js" ] || return 1
|
||||||
[ -e "node_modules/.bin/vite" ] || [ -e "node_modules/.bin/vite.cmd" ] || return 1
|
[ -f "node_modules/pg/package.json" ] || return 1
|
||||||
|
[ -f "node_modules/vue-router/package.json" ] || return 1
|
||||||
|
|
||||||
node -e "require('rollup')" >/dev/null 2>&1
|
if use_windows_npm; then
|
||||||
|
local win_path
|
||||||
|
local win_path_ps
|
||||||
|
|
||||||
|
win_path="$(windows_project_path)"
|
||||||
|
win_path_ps="${win_path//\'/\'\'}"
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$win_path_ps'; node -e \"require('rollup'); require('pg'); require('vue-router')\"" >/dev/null 2>&1
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -e "node_modules/.bin/vite" ] || [ -e "node_modules/.bin/vite.cmd" ] || return 1
|
||||||
|
node -e "require('rollup'); require('pg'); require('vue-router')" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
if ! dependencies_ready; then
|
ensure_runtime_tools() {
|
||||||
warn "Dependencies are missing or incomplete"
|
if use_windows_npm; then
|
||||||
info "Running npm install..."
|
info "Detected WSL on a Windows-mounted project"
|
||||||
npm install
|
info "Using Windows npm to manage web dependencies"
|
||||||
|
|
||||||
|
if ! run_windows_powershell "node -v > \$null; npm -v > \$null" >/dev/null 2>&1; then
|
||||||
|
error "Windows Node.js/npm is not available in PATH. Install Node.js on Windows first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
error "Node.js is not installed. Install it first: https://nodejs.org"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v npm >/dev/null 2>&1; then
|
||||||
|
error "npm is not installed. It should come with Node.js."
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Node.js $(node -v) | npm $(npm -v)"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dependencies() {
|
||||||
|
ensure_runtime_tools
|
||||||
|
|
||||||
|
if dependencies_ready; then
|
||||||
|
info "Web dependencies are ready."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Web dependencies are missing or incomplete"
|
||||||
|
info "Installing web dependencies..."
|
||||||
|
|
||||||
|
if use_windows_npm; then
|
||||||
|
run_windows_npm_install
|
||||||
|
else
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
if ! dependencies_ready; then
|
if ! dependencies_ready; then
|
||||||
error "Dependencies are still incomplete after npm install. Try deleting node_modules and running npm install manually."
|
error "Web dependencies are still incomplete after installation. Try deleting web/node_modules and running npm install manually."
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
info "Web dependencies are ready."
|
||||||
# Start dev server
|
}
|
||||||
# ----------------------------------------------------------
|
|
||||||
info "Starting X-Financial Reimbursement Admin..."
|
|
||||||
info "Access: http://127.0.0.1:5173"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
exec npm start
|
start_dev_server() {
|
||||||
|
info "Starting X-Financial Reimbursement Admin..."
|
||||||
|
info "Access: http://$WEB_HOST:$WEB_PORT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if use_windows_npm; then
|
||||||
|
run_windows_npm_start
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec npm start -- --host "$WEB_HOST" --port "$WEB_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
deps)
|
||||||
|
ensure_dependencies
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
ensure_dependencies
|
||||||
|
start_dev_server
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "Unknown mode: $MODE. Use one of: deps, start"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -1,6 +1,697 @@
|
|||||||
|
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import net from 'node:net'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const rootDir = path.resolve(__dirname, '..')
|
||||||
|
const envFile = path.join(rootDir, '.env')
|
||||||
|
const envExampleFile = path.join(rootDir, '.env.example')
|
||||||
|
const adminSecretDir = path.join(rootDir, 'server', '.secrets')
|
||||||
|
const adminSecretFile = path.join(adminSecretDir, 'admin.json')
|
||||||
|
const adminScryptOptions = { N: 16384, r: 8, p: 1 }
|
||||||
|
const adminScryptKeyLength = 64
|
||||||
|
|
||||||
|
function ensureEnvFile() {
|
||||||
|
if (fs.existsSync(envFile)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(envExampleFile)) {
|
||||||
|
fs.copyFileSync(envExampleFile, envFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(envFile, '', 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAdminSecretDir() {
|
||||||
|
fs.mkdirSync(adminSecretDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnv(text) {
|
||||||
|
const result = {}
|
||||||
|
|
||||||
|
for (const line of text.split(/\r?\n/u)) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = trimmed.indexOf('=')
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separatorIndex).trim()
|
||||||
|
let value = trimmed.slice(separatorIndex + 1).trim()
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvState() {
|
||||||
|
ensureEnvFile()
|
||||||
|
return parseEnv(fs.readFileSync(envFile, 'utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAdminSecret() {
|
||||||
|
if (!fs.existsSync(adminSecretFile)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(adminSecretFile, 'utf8'))
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
payload.algorithm === 'scrypt' &&
|
||||||
|
typeof payload.username === 'string' &&
|
||||||
|
typeof payload.salt === 'string' &&
|
||||||
|
typeof payload.derived_key === 'string'
|
||||||
|
) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashAdminPassword(password, salt, keyLength = adminScryptKeyLength, options = adminScryptOptions) {
|
||||||
|
return scryptSync(password, Buffer.from(salt, 'hex'), keyLength, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAdminCredentials(payload) {
|
||||||
|
ensureAdminSecretDir()
|
||||||
|
|
||||||
|
const existing = readAdminSecret()
|
||||||
|
const salt = randomBytes(16).toString('hex')
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const derivedKey = hashAdminPassword(String(payload.admin_password || ''), salt)
|
||||||
|
const record = {
|
||||||
|
version: 1,
|
||||||
|
algorithm: 'scrypt',
|
||||||
|
username: String(payload.admin_username || '').trim(),
|
||||||
|
salt,
|
||||||
|
derived_key: derivedKey.toString('hex'),
|
||||||
|
key_length: adminScryptKeyLength,
|
||||||
|
...adminScryptOptions,
|
||||||
|
created_at: existing?.created_at || now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(adminSecretFile, `${JSON.stringify(record, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAdminCredentials(username, password) {
|
||||||
|
const record = readAdminSecret()
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('管理员账号尚未初始化,请先完成初始化配置。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.username !== String(username || '').trim()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivedKey = hashAdminPassword(
|
||||||
|
String(password || ''),
|
||||||
|
record.salt,
|
||||||
|
Number(record.key_length || adminScryptKeyLength),
|
||||||
|
{
|
||||||
|
N: Number(record.N || adminScryptOptions.N),
|
||||||
|
r: Number(record.r || adminScryptOptions.r),
|
||||||
|
p: Number(record.p || adminScryptOptions.p)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const storedKey = Buffer.from(record.derived_key, 'hex')
|
||||||
|
|
||||||
|
if (storedKey.length !== derivedKey.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(storedKey, derivedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLoopbackHost(host) {
|
||||||
|
const normalized = String(host || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (normalized === 'localhost' || normalized === '::1') {
|
||||||
|
return '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === '::') {
|
||||||
|
return '0.0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClientHost(host) {
|
||||||
|
const normalizedHost = normalizeLoopbackHost(host)
|
||||||
|
|
||||||
|
if (!normalizedHost || normalizedHost === '0.0.0.0') {
|
||||||
|
return '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(host || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostsConflict(left, right) {
|
||||||
|
const normalizedLeft = normalizeLoopbackHost(left)
|
||||||
|
const normalizedRight = normalizeLoopbackHost(right)
|
||||||
|
|
||||||
|
if (!normalizedLeft || !normalizedRight) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedLeft === normalizedRight) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedLeft === '0.0.0.0' || normalizedRight === '0.0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEnvValue(value) {
|
||||||
|
const stringValue = value == null ? '' : String(value)
|
||||||
|
|
||||||
|
if (stringValue === '') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[A-Za-z0-9_./:-]+$/u.test(stringValue)) {
|
||||||
|
return stringValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return `'${stringValue.replace(/'/gu, `'\\''`)}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnvFile(updates) {
|
||||||
|
ensureEnvFile()
|
||||||
|
|
||||||
|
let content = fs.readFileSync(envFile, 'utf8')
|
||||||
|
const existingLines = content ? content.split(/\r?\n/u) : []
|
||||||
|
const remainingKeys = new Set(Object.keys(updates))
|
||||||
|
const nextLines = existingLines.map((line) => {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = line.indexOf('=')
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, separatorIndex).trim()
|
||||||
|
|
||||||
|
if (!remainingKeys.has(key)) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingKeys.delete(key)
|
||||||
|
return `${key}=${serializeEnvValue(updates[key])}`
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const key of remainingKeys) {
|
||||||
|
nextLines.push(`${key}=${serializeEnvValue(updates[key])}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
content = `${nextLines.join('\n').replace(/\n+$/u, '')}\n`
|
||||||
|
fs.writeFileSync(envFile, content, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDatabaseUrl(payload) {
|
||||||
|
const username = encodeURIComponent(payload.postgres_user)
|
||||||
|
const password = encodeURIComponent(payload.postgres_password)
|
||||||
|
return `postgresql+psycopg://${username}:${password}@${payload.postgres_host}:${payload.postgres_port}/${payload.postgres_db}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCorsOrigins(payload) {
|
||||||
|
const webHost = String(payload.web_host || '').trim()
|
||||||
|
const webPort = String(payload.web_port || '').trim()
|
||||||
|
const origins = new Set()
|
||||||
|
const normalizedHost = normalizeLoopbackHost(webHost)
|
||||||
|
|
||||||
|
if (normalizedHost === '0.0.0.0') {
|
||||||
|
origins.add(`http://127.0.0.1:${webPort}`)
|
||||||
|
origins.add(`http://localhost:${webPort}`)
|
||||||
|
origins.add(`http://0.0.0.0:${webPort}`)
|
||||||
|
} else {
|
||||||
|
origins.add(`http://${webHost}:${webPort}`)
|
||||||
|
|
||||||
|
if (normalizedHost === '127.0.0.1') {
|
||||||
|
origins.add(`http://127.0.0.1:${webPort}`)
|
||||||
|
origins.add(`http://localhost:${webPort}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify([...origins])
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiBaseUrl(payload, currentEnv) {
|
||||||
|
const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1'
|
||||||
|
const host = resolveClientHost(payload.server_host)
|
||||||
|
const port = String(payload.server_port || '').trim()
|
||||||
|
return `http://${host}:${port}${apiPrefix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientEnvUpdates(payload, apiBaseUrl) {
|
||||||
|
return {
|
||||||
|
VITE_SETUP_COMPLETED: 'true',
|
||||||
|
VITE_COMPANY_NAME: String(payload.company_name || '').trim(),
|
||||||
|
VITE_COMPANY_CODE: String(payload.company_code || '').trim(),
|
||||||
|
VITE_ADMIN_EMAIL: String(payload.admin_email || '').trim(),
|
||||||
|
VITE_WEB_HOST: String(payload.web_host || '').trim(),
|
||||||
|
VITE_WEB_PORT: String(payload.web_port || '').trim(),
|
||||||
|
VITE_SERVER_HOST: String(payload.server_host || '').trim(),
|
||||||
|
VITE_SERVER_PORT: String(payload.server_port || '').trim(),
|
||||||
|
VITE_POSTGRES_HOST: String(payload.postgres_host || '').trim(),
|
||||||
|
VITE_POSTGRES_PORT: String(payload.postgres_port || '').trim(),
|
||||||
|
VITE_POSTGRES_DB: String(payload.postgres_db || '').trim(),
|
||||||
|
VITE_POSTGRES_USER: String(payload.postgres_user || '').trim(),
|
||||||
|
VITE_REDIS_URL: String(payload.redis_url || '').trim(),
|
||||||
|
VITE_API_BASE_URL: apiBaseUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(env) {
|
||||||
|
return {
|
||||||
|
initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true',
|
||||||
|
company: {
|
||||||
|
name: env.COMPANY_NAME || '',
|
||||||
|
code: env.COMPANY_CODE || '',
|
||||||
|
admin_email: env.ADMIN_EMAIL || ''
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
configured: Boolean(readAdminSecret())
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
host: env.WEB_HOST || '127.0.0.1',
|
||||||
|
port: Number(env.WEB_PORT || 5173)
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: env.SERVER_HOST || '127.0.0.1',
|
||||||
|
port: Number(env.SERVER_PORT || 8000)
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
driver: 'postgresql',
|
||||||
|
host: env.POSTGRES_HOST || '127.0.0.1',
|
||||||
|
port: Number(env.POSTGRES_PORT || 5432),
|
||||||
|
name: env.POSTGRES_DB || 'x_financial',
|
||||||
|
username: env.POSTGRES_USER || 'postgres',
|
||||||
|
password_configured: Boolean(env.POSTGRES_PASSWORD)
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
enabled: Boolean(env.REDIS_URL),
|
||||||
|
url: env.REDIS_URL || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(req) {
|
||||||
|
const chunks = []
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8')
|
||||||
|
return raw ? JSON.parse(raw) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, statusCode, payload) {
|
||||||
|
res.statusCode = statusCode
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
res.end(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRuntimePayload(payload) {
|
||||||
|
const fields = [
|
||||||
|
['web_host', 'Web Host'],
|
||||||
|
['server_host', 'Server Host']
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [field, label] of fields) {
|
||||||
|
if (!String(payload[field] ?? '').trim()) {
|
||||||
|
return `请填写 ${label}。`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const portFields = [
|
||||||
|
['web_port', 'Web Port'],
|
||||||
|
['server_port', 'Server Port']
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [field, label] of portFields) {
|
||||||
|
const value = Number(payload[field])
|
||||||
|
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
||||||
|
return `${label} 必须在 1 到 65535 之间。`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDatabasePayload(payload) {
|
||||||
|
const fields = [
|
||||||
|
['postgres_host', 'PostgreSQL Host'],
|
||||||
|
['postgres_db', '数据库名称'],
|
||||||
|
['postgres_user', '数据库用户']
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [field, label] of fields) {
|
||||||
|
if (!String(payload[field] ?? '').trim()) {
|
||||||
|
return `请填写 ${label}。`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(payload.postgres_port)
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
return 'PostgreSQL Port 必须在 1 到 65535 之间。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String(payload.postgres_password || '').length) {
|
||||||
|
return '请填写数据库密码。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIdentityPayload(payload) {
|
||||||
|
const companyName = String(payload.company_name || '').trim()
|
||||||
|
const adminEmail = String(payload.admin_email || '').trim()
|
||||||
|
const adminUsername = String(payload.admin_username || '').trim()
|
||||||
|
const adminPassword = String(payload.admin_password || '')
|
||||||
|
const adminPasswordConfirm = String(payload.admin_password_confirm || '')
|
||||||
|
|
||||||
|
if (companyName.length < 2) {
|
||||||
|
return '企业名称至少 2 个字符。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminEmail) {
|
||||||
|
return '请填写管理员邮箱。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(adminEmail)) {
|
||||||
|
return '管理员邮箱格式不正确。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUsername.length < 4) {
|
||||||
|
return '管理员账号至少 4 位。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9._@-]+$/u.test(adminUsername)) {
|
||||||
|
return '管理员账号仅允许字母、数字、点、下划线、中划线和 @。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminPassword.length < 5) {
|
||||||
|
return '管理员密码当前至少 5 位。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminPassword !== adminPasswordConfirm) {
|
||||||
|
return '两次输入的管理员密码不一致。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSetupPayload(payload) {
|
||||||
|
return validateIdentityPayload(payload) || validateRuntimePayload(payload) || validateDatabasePayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReuseCurrentWebPort(payload, currentEnv) {
|
||||||
|
return (
|
||||||
|
Number(payload.web_port) === Number(currentEnv.WEB_PORT || 5173) &&
|
||||||
|
hostsConflict(String(payload.web_host || '').trim(), currentEnv.WEB_HOST || '127.0.0.1')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPortAvailable(host, port) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const tester = net.createServer()
|
||||||
|
|
||||||
|
tester.once('error', (error) => {
|
||||||
|
tester.close()
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
tester.once('listening', () => {
|
||||||
|
tester.close(() => resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
tester.listen(port, host)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRuntimePorts(payload, currentEnv) {
|
||||||
|
const webPort = Number(payload.web_port)
|
||||||
|
const serverPort = Number(payload.server_port)
|
||||||
|
const webHost = String(payload.web_host || '').trim()
|
||||||
|
const serverHost = String(payload.server_host || '').trim()
|
||||||
|
|
||||||
|
if (webPort === serverPort && hostsConflict(webHost, serverHost)) {
|
||||||
|
throw new Error('Web 与 Server 不能使用同一个主机与端口组合。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canReuseCurrentWebPort(payload, currentEnv)) {
|
||||||
|
try {
|
||||||
|
await assertPortAvailable(webHost, webPort)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Web 端口 ${webHost}:${webPort} 已被占用。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertPortAvailable(serverHost, serverPort)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Server 端口 ${serverHost}:${serverPort} 已被占用。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPgClient() {
|
||||||
|
try {
|
||||||
|
const module = await import('pg')
|
||||||
|
return module.Client
|
||||||
|
} catch {
|
||||||
|
throw new Error('缺少 Node 侧 PostgreSQL 驱动 pg(web/node_modules/pg)。请先执行 bash start.sh,或进入 web 目录执行 npm install。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDatabaseConnection(payload) {
|
||||||
|
const Client = await loadPgClient()
|
||||||
|
const client = new Client({
|
||||||
|
host: String(payload.postgres_host || '').trim(),
|
||||||
|
port: Number(payload.postgres_port),
|
||||||
|
database: String(payload.postgres_db || '').trim(),
|
||||||
|
user: String(payload.postgres_user || '').trim(),
|
||||||
|
password: String(payload.postgres_password || ''),
|
||||||
|
connectionTimeoutMillis: 5000
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
await client.query('SELECT 1')
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSetupPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'local-setup-api',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/__setup/auth/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
sendJson(res, 405, { detail: 'Method not allowed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readJsonBody(req)
|
||||||
|
const username = String(payload.username || '').trim()
|
||||||
|
const password = String(payload.password || '')
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
sendJson(res, 400, { detail: '请输入管理员账号和密码。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = verifyAdminCredentials(username, password)
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
sendJson(res, 401, { detail: '管理员账号或密码错误。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
detail: '登录成功。',
|
||||||
|
user: {
|
||||||
|
username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, {
|
||||||
|
detail: error instanceof Error ? error.message : '管理员登录校验失败。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'PUT') {
|
||||||
|
sendJson(res, 405, { detail: 'Method not allowed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readJsonBody(req)
|
||||||
|
const validationError = validateRuntimePayload(payload)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
sendJson(res, 400, { detail: validationError })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testRuntimePorts(payload, readEnvState())
|
||||||
|
sendJson(res, 200, { ok: true, detail: '端口占用检测通过。' })
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 400, {
|
||||||
|
ok: false,
|
||||||
|
detail: error instanceof Error ? error.message : '端口占用检测失败。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, {
|
||||||
|
detail: error instanceof Error ? error.message : '运行端口检测服务异常。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'PUT') {
|
||||||
|
sendJson(res, 405, { detail: 'Method not allowed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readJsonBody(req)
|
||||||
|
const validationError = validateDatabasePayload(payload)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
sendJson(res, 400, { detail: validationError })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testDatabaseConnection(payload)
|
||||||
|
sendJson(res, 200, { ok: true, detail: '数据库连接检测通过。' })
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 400, {
|
||||||
|
ok: false,
|
||||||
|
detail: error instanceof Error ? error.message : '数据库连接检测失败。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, {
|
||||||
|
detail: error instanceof Error ? error.message : '数据库检测服务异常。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.middlewares.use('/__setup/bootstrap', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
sendJson(res, 200, normalizeState(readEnvState()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
sendJson(res, 405, { detail: 'Method not allowed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readJsonBody(req)
|
||||||
|
const validationError = validateSetupPayload(payload)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
sendJson(res, 400, { detail: validationError })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testRuntimePorts(payload, readEnvState())
|
||||||
|
await testDatabaseConnection(payload)
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 400, {
|
||||||
|
detail: error instanceof Error ? error.message : '初始化校验失败。'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAdminCredentials(payload)
|
||||||
|
|
||||||
|
const currentEnv = readEnvState()
|
||||||
|
const apiBaseUrl = buildApiBaseUrl(payload, currentEnv)
|
||||||
|
|
||||||
|
updateEnvFile({
|
||||||
|
SETUP_COMPLETED: 'true',
|
||||||
|
COMPANY_NAME: String(payload.company_name || '').trim(),
|
||||||
|
COMPANY_CODE: String(payload.company_code || '').trim(),
|
||||||
|
ADMIN_EMAIL: String(payload.admin_email || '').trim(),
|
||||||
|
WEB_HOST: String(payload.web_host || '').trim(),
|
||||||
|
WEB_PORT: String(payload.web_port || '').trim(),
|
||||||
|
SERVER_HOST: String(payload.server_host || '').trim(),
|
||||||
|
SERVER_PORT: String(payload.server_port || '').trim(),
|
||||||
|
POSTGRES_HOST: String(payload.postgres_host || '').trim(),
|
||||||
|
POSTGRES_PORT: String(payload.postgres_port || '').trim(),
|
||||||
|
POSTGRES_DB: String(payload.postgres_db || '').trim(),
|
||||||
|
POSTGRES_USER: String(payload.postgres_user || '').trim(),
|
||||||
|
POSTGRES_PASSWORD: String(payload.postgres_password || ''),
|
||||||
|
DATABASE_URL: buildDatabaseUrl(payload),
|
||||||
|
REDIS_URL: String(payload.redis_url || '').trim(),
|
||||||
|
CORS_ORIGINS: buildCorsOrigins(payload),
|
||||||
|
VITE_API_BASE_URL: apiBaseUrl,
|
||||||
|
...buildClientEnvUpdates(payload, apiBaseUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
sendJson(res, 201, normalizeState(readEnvState()))
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, {
|
||||||
|
detail: error instanceof Error ? error.message : '初始化写入失败。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()]
|
envDir: '..',
|
||||||
|
plugins: [vue(), localSetupPlugin()]
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user