From ae63766c91e98b585dc184b4f73f7b300e2c174e Mon Sep 17 00:00:00 2001 From: "DESKTOP-72TV0V4\\caoxiaozhu" Date: Wed, 6 May 2026 22:23:42 +0800 Subject: [PATCH] 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. --- .env.example | 44 ++ .gitignore | 4 + README.md | 25 +- document/work-log/2026-05-06.md | 71 +++ server/src/app/core/config.py | 8 +- server/src/app/core/logging.py | 72 +++ server/src/app/main.py | 24 + server/src/app/middleware/__init__.py | 0 server/src/app/middleware/logging.py | 42 ++ server/src/app/services/employee.py | 20 +- server/src/app/services/reimbursement.py | 23 +- web/package-lock.json | 187 +++++- web/package.json | 4 +- web/src/App.vue | 196 +----- web/src/assets/styles/app.css | 62 ++ web/src/assets/styles/views/login-view.css | 17 + web/src/assets/styles/views/setup-view.css | 607 ++++++++++++++++++ web/src/composables/useAppShell.js | 87 ++- web/src/composables/useLoginView.js | 21 +- web/src/composables/useNavigation.js | 89 ++- web/src/composables/useSetupView.js | 383 ++++++++++++ web/src/composables/useSystemState.js | 278 +++++++++ web/src/composables/useToast.js | 18 +- web/src/main.js | 2 + web/src/router/index.js | 110 ++++ web/src/services/bootstrap.js | 78 +++ web/src/utils/requestViewModel.js | 87 +++ web/src/views/AppShellRouteView.vue | 176 ++++++ web/src/views/LoginRouteView.vue | 46 ++ web/src/views/LoginView.vue | 64 +- web/src/views/SetupRouteView.vue | 51 ++ web/src/views/SetupView.vue | 316 ++++++++++ web/src/views/scripts/RequestsView.js | 85 +-- web/start.sh | 176 ++++-- web/vite.config.js | 693 ++++++++++++++++++++- 35 files changed, 3762 insertions(+), 404 deletions(-) create mode 100644 .env.example create mode 100644 document/work-log/2026-05-06.md create mode 100644 server/src/app/core/logging.py create mode 100644 server/src/app/middleware/__init__.py create mode 100644 server/src/app/middleware/logging.py create mode 100644 web/src/assets/styles/views/setup-view.css create mode 100644 web/src/composables/useSetupView.js create mode 100644 web/src/composables/useSystemState.js create mode 100644 web/src/router/index.js create mode 100644 web/src/services/bootstrap.js create mode 100644 web/src/utils/requestViewModel.js create mode 100644 web/src/views/AppShellRouteView.vue create mode 100644 web/src/views/LoginRouteView.vue create mode 100644 web/src/views/SetupRouteView.vue create mode 100644 web/src/views/SetupView.vue diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5a890f --- /dev/null +++ b/.env.example @@ -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"]' diff --git a/.gitignore b/.gitignore index ce24ab9..7255c60 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ web/.vite/ *.log .DS_Store Thumbs.db +__pycache__/ +*.pyc +server/.venv/ +server/.secrets/ diff --git a/README.md b/README.md index c52a088..7018c32 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,34 @@ - `UI/`:界面参考稿 - `document/`:业务文档 -从根目录启动前端: +根目录统一环境变量: + +- `.env` +- `.env.example` + +这里集中维护: + +- 前端启动端口 +- 后端启动端口 +- PostgreSQL 连接参数 +- `DATABASE_URL` +- `REDIS_URL` + +从根目录统一启动: ```bash ./start.sh ``` -或手动进入前端目录: +可选模式: + +```bash +./start.sh web +./start.sh server +./start.sh all +``` + +手动进入前端目录: ```bash cd web diff --git a/document/work-log/2026-05-06.md b/document/work-log/2026-05-06.md new file mode 100644 index 0000000..4fe35e8 --- /dev/null +++ b/document/work-log/2026-05-06.md @@ -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* \ No newline at end of file diff --git a/server/src/app/core/config.py b/server/src/app/core/config.py index 62fcf91..504c8ee 100644 --- a/server/src/app/core/config.py +++ b/server/src/app/core/config.py @@ -44,7 +44,13 @@ class Settings(BaseSettings): redis_url: str | None = Field(default=None, alias="REDIS_URL") 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 def resolved_database_url(self) -> str: diff --git a/server/src/app/core/logging.py b/server/src/app/core/logging.py new file mode 100644 index 0000000..d5ddfb4 --- /dev/null +++ b/server/src/app/core/logging.py @@ -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) diff --git a/server/src/app/main.py b/server/src/app/main.py index 65d9758..774d9c5 100644 --- a/server/src/app/main.py +++ b/server/src/app/main.py @@ -5,17 +5,32 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.router import api_router 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: 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( title=settings.app_name, debug=settings.app_debug, version="0.1.0", ) + app.add_middleware(AccessLogMiddleware) + if settings.cors_origins: app.add_middleware( CORSMiddleware, @@ -31,6 +46,15 @@ def create_app() -> FastAPI: def root() -> dict[str, str]: 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 diff --git a/server/src/app/middleware/__init__.py b/server/src/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/app/middleware/logging.py b/server/src/app/middleware/logging.py new file mode 100644 index 0000000..0f027c1 --- /dev/null +++ b/server/src/app/middleware/logging.py @@ -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 diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index 7832a7d..5e500f0 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -1,20 +1,34 @@ from sqlalchemy.orm import Session +from app.core.logging import get_logger from app.models.employee import Employee from app.repositories.employee import EmployeeRepository from app.schemas.employee import EmployeeCreate +logger = get_logger("app.services.employee") + class EmployeeService: def __init__(self, db: Session) -> None: self.repository = EmployeeRepository(db) 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: - 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: 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 diff --git a/server/src/app/services/reimbursement.py b/server/src/app/services/reimbursement.py index 12eeadb..284a367 100644 --- a/server/src/app/services/reimbursement.py +++ b/server/src/app/services/reimbursement.py @@ -1,20 +1,37 @@ from sqlalchemy.orm import Session +from app.core.logging import get_logger from app.models.reimbursement import ReimbursementRequest from app.repositories.reimbursement import ReimbursementRepository from app.schemas.reimbursement import ReimbursementCreate +logger = get_logger("app.services.reimbursement") + class ReimbursementService: def __init__(self, db: Session) -> None: self.repository = ReimbursementRepository(db) 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: - 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: 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 diff --git a/web/package-lock.json b/web/package-lock.json index d2df42d..cbfd80f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,11 +12,13 @@ "@vitejs/plugin-vue": "^5.2.4", "@vueuse/motion": "^3.0.3", "chart.js": "^4.5.1", + "pg": "^8.13.1", "primeicons": "^7.0.0", "primevue": "^4.5.5", "vite": "^5.4.19", "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": { @@ -1015,6 +1017,12 @@ "@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": { "version": "3.5.33", "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", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -1501,12 +1508,114 @@ "license": "MIT", "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -1559,6 +1668,45 @@ "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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", @@ -1679,6 +1827,15 @@ "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": { "version": "5.1.2", "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", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -1830,7 +1986,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", @@ -1857,12 +2012,36 @@ "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": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT", "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" + } } } } diff --git a/web/package.json b/web/package.json index 58a5656..db45194 100644 --- a/web/package.json +++ b/web/package.json @@ -14,10 +14,12 @@ "@vitejs/plugin-vue": "^5.2.4", "@vueuse/motion": "^3.0.3", "chart.js": "^4.5.1", + "pg": "^8.13.1", "primeicons": "^7.0.0", "primevue": "^4.5.5", "vite": "^5.4.19", "vue": "^3.5.13", - "vue-chartjs": "^5.3.3" + "vue-chartjs": "^5.3.3", + "vue-router": "^4.5.1" } } diff --git a/web/src/App.vue b/web/src/App.vue index 076c5de..3d04b06 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,201 +1,17 @@ - + diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index a0720e1..ccd48f7 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -4,6 +4,68 @@ grid-template-columns: 220px minmax(0, 1fr); 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.overview-main { grid-template-rows: auto minmax(0, 1fr); diff --git a/web/src/assets/styles/views/login-view.css b/web/src/assets/styles/views/login-view.css index 39a3649..143e8c6 100644 --- a/web/src/assets/styles/views/login-view.css +++ b/web/src/assets/styles/views/login-view.css @@ -482,6 +482,16 @@ 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 { display: inline-flex; align-items: center; @@ -528,6 +538,13 @@ background: linear-gradient(135deg, #13c990, #047857); } +.submit-btn:disabled, +.sso-btn:disabled { + opacity: .6; + cursor: not-allowed; + box-shadow: none; +} + .divider { position: relative; display: grid; diff --git a/web/src/assets/styles/views/setup-view.css b/web/src/assets/styles/views/setup-view.css new file mode 100644 index 0000000..345433a --- /dev/null +++ b/web/src/assets/styles/views/setup-view.css @@ -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%; + } +} diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index 387f7ce..e74ece9 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -1,75 +1,74 @@ import { computed, ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' import { useNavigation, navItems } from './useNavigation.js' import { useRequests } from './useRequests.js' import { useChat } from './useChat.js' import { useToast } from './useToast.js' import { documents } from '../data/requests.js' +import { normalizeRequestForUi } from '../utils/requestViewModel.js' export function useAppShell() { - const loggedIn = ref(false) + const route = useRoute() + const router = useRouter() + const travelCreateMode = ref(false) - const detailMode = ref(false) - const selectedTravelRequest = ref(null) const smartEntryOpen = ref(false) const smartEntryContext = ref({ prompt: '', source: 'requests', request: null }) const smartEntrySessionId = ref(0) const { activeView, currentView, setView } = useNavigation() - const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = useRequests() - const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = useChat(activeView) - const { toastText, toast } = useToast() + const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = + useRequests() + const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = + useChat(activeView) + const { toast } = useToast() const docSearch = ref('') 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(() => { if (detailMode.value) { return { - title: '差旅报销详情', - desc: '查看报销单据详情、票据识别与审批进度' + title: '差旅申请详情', + desc: '查看申请单、票据、审批意见与风控提示。' } } + return currentView.value }) const filteredDocuments = computed(() => { const key = docSearch.value.trim().toLowerCase() - return documents.filter((doc) => { - const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key) - return matchesSearch - }) + return documents.filter((doc) => !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)) }) - function handleLogin(credentials) { - if (credentials.username && credentials.password) { - loggedIn.value = true - } - } - - function handleRecoverPassword() { - toast('请联系系统管理员重置密码。') - } - - function handleSsoLogin() { - toast('SSO 登录通道建设中。') - } - function handleApprove(request) { - const msg = approveRequest(request) - toast(msg) + const message = approveRequest(request) + toast(message) } function handleReject(request) { - const msg = rejectRequest(request) - toast(msg) + const message = rejectRequest(request) + toast(message) } function handleNavigate(view) { travelCreateMode.value = false - detailMode.value = false - selectedTravelRequest.value = null smartEntryOpen.value = false setView(view) } @@ -82,8 +81,6 @@ export function useAppShell() { function openTravelCreate() { smartEntryOpen.value = true travelCreateMode.value = false - detailMode.value = false - selectedTravelRequest.value = null smartEntryContext.value = { prompt: '', source: 'topbar', request: null } smartEntrySessionId.value += 1 } @@ -91,10 +88,7 @@ export function useAppShell() { function openSmartEntry(payload = {}) { smartEntryOpen.value = true travelCreateMode.value = false - if (payload.source !== 'detail') { - detailMode.value = false - selectedTravelRequest.value = null - } + smartEntryContext.value = { prompt: payload.prompt ?? '', source: payload.source ?? 'workbench', @@ -108,14 +102,14 @@ export function useAppShell() { } function openRequestDetail(request) { - selectedTravelRequest.value = request - detailMode.value = true - activeView.value = 'requests' + router.push({ + name: 'app-request-detail', + params: { requestId: request.id } + }) } function closeRequestDetail() { - detailMode.value = false - selectedTravelRequest.value = null + router.push({ name: 'app-requests' }) } return { @@ -133,14 +127,10 @@ export function useAppShell() { filteredRequests, filters, handleApprove, - handleLogin, handleNavigate, handleOpenChat, - handleRecoverPassword, handleReject, - handleSsoLogin, handleUpload, - loggedIn, messageList, messages, navItems, @@ -160,7 +150,6 @@ export function useAppShell() { smartEntryOpen, smartEntrySessionId, toast, - toastText, topBarView, travelCreateMode, travelPrompts, diff --git a/web/src/composables/useLoginView.js b/web/src/composables/useLoginView.js index dbc1f3a..d770bba 100644 --- a/web/src/composables/useLoginView.js +++ b/web/src/composables/useLoginView.js @@ -8,9 +8,24 @@ export function useLoginView() { const showPassword = ref(false) const features = [ - { title: '智能审单', 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' } + { + title: '智能审单', + 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 = { diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index f426707..4dfa72e 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -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' +export const appViews = ['overview', 'workbench', 'requests', 'approval', 'chat', 'policies', 'audit', 'employees'] + export const navItems = [ { id: 'overview', label: '总览', - navHint: '运营指标与趋势', + navHint: '查看系统总览与关键指标', icon: icons.dashboard, - title: '企业报销智能运营台', - desc: '面向财务共享中心的审批、风控、SLA与自动化运营看板' + title: '财务运营总览', + desc: '聚合差旅申请、审批效率、风险信号与 SLA 表现。' }, { id: 'workbench', label: '个人工作台', - navHint: '今日待办与报销进度', + navHint: '集中处理个人待办', icon: icons.workspace, title: '个人工作台', - desc: '集中处理今日待办、查看报销进度,并快速进入 AI 报销助手' + desc: '聚焦当前待办、快捷操作与助手入口。' }, { id: 'requests', - label: '差旅申请/报销', - navHint: '差旅单据与发起申请', + label: '申请单', + navHint: '查看和管理申请单', icon: icons.list, - title: '差旅申请/报销', - desc: '查看员工差旅报销单据、跟踪进度、发起新申请' + title: '差旅申请与单据', + desc: '集中查看申请单状态、处理进度和风险提示。' }, { id: 'approval', label: '审批中心', - navHint: '待审批单据与批量处理', + navHint: '处理审批任务', icon: icons.approval, title: '审批中心', - desc: '统一处理待审批单据,聚焦效率、风险与 SLA' + desc: '按优先级处理待审批事项,控制时效与风险。' }, { id: 'chat', - label: 'AI助手', - navHint: '财务知识问答与制度解释', + label: 'AI 助手', + navHint: '进入智能问答', icon: icons.message, - title: '财务AI助手', - desc: '面向员工与财务场景的智能问答助手,提供制度解读、报销指引与常见问题解答' + title: 'AI 财务助手', + desc: '围绕制度、票据、审批和差旅场景进行快速问答。' }, { id: 'policies', - label: '知识管理', - navHint: '制度、文档与知识库', + label: '制度知识', + navHint: '查看制度与知识库', icon: icons.file, - title: '财务知识管理中心', - desc: '上传制度文档、沉淀财务知识、构建面向员工问答与知识管理的统一知识库' + title: '制度与知识库', + desc: '统一管理制度文档、知识问答和搜索入口。' }, { id: 'audit', - label: '技能中心', - navHint: 'Skill 设计与版本配置', + label: '审计追踪', + navHint: '查看日志与追踪记录', icon: icons.skill, - title: '技能中心', - desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本' + title: '审计追踪', + desc: '记录关键操作、追踪审批链和系统行为。' }, { id: 'employees', label: '员工管理', - navHint: '员工档案、岗位与角色权限', + navHint: '维护员工与组织信息', icon: icons.users, - title: '员工管理', - desc: '集中维护员工基础信息、职级部门岗位,以及管理员、财务人员、使用者和高级管理人员等系统角色' + title: '员工与组织管理', + 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() { - 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( () => navItems.find((item) => item.id === activeView.value) ?? navItems[0] ) 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 } diff --git a/web/src/composables/useSetupView.js b/web/src/composables/useSetupView.js new file mode 100644 index 0000000..7f23dd2 --- /dev/null +++ b/web/src/composables/useSetupView.js @@ -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 + } +} diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js new file mode 100644 index 0000000..d562e03 --- /dev/null +++ b/web/src/composables/useSystemState.js @@ -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 + } +} diff --git a/web/src/composables/useToast.js b/web/src/composables/useToast.js index 9b853de..039d608 100644 --- a/web/src/composables/useToast.js +++ b/web/src/composables/useToast.js @@ -1,13 +1,15 @@ 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() { - const toastText = ref('') - - function toast(text) { - toastText.value = text - clearTimeout(toast.timer) - toast.timer = setTimeout(() => { toastText.value = '' }, 3200) - } - return { toastText, toast } } diff --git a/web/src/main.js b/web/src/main.js index f3b8022..a6b367f 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -4,10 +4,12 @@ import PrimeVue from 'primevue/config' import Aura from '@primevue/themes/aura' import 'primeicons/primeicons.css' import App from './App.vue' +import router from './router/index.js' const app = createApp(App) app.use(MotionPlugin) +app.use(router) app.use(PrimeVue, { theme: { preset: Aura, diff --git a/web/src/router/index.js b/web/src/router/index.js new file mode 100644 index 0000000..328fbb0 --- /dev/null +++ b/web/src/router/index.js @@ -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 diff --git a/web/src/services/bootstrap.js b/web/src/services/bootstrap.js new file mode 100644 index 0000000..95e0b1c --- /dev/null +++ b/web/src/services/bootstrap.js @@ -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) + }) +} diff --git a/web/src/utils/requestViewModel.js b/web/src/utils/requestViewModel.js new file mode 100644 index 0000000..470dd7c --- /dev/null +++ b/web/src/utils/requestViewModel.js @@ -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 + } +} diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue new file mode 100644 index 0000000..c2b23c7 --- /dev/null +++ b/web/src/views/AppShellRouteView.vue @@ -0,0 +1,176 @@ + + + diff --git a/web/src/views/LoginRouteView.vue b/web/src/views/LoginRouteView.vue new file mode 100644 index 0000000..d827155 --- /dev/null +++ b/web/src/views/LoginRouteView.vue @@ -0,0 +1,46 @@ + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index a995308..f6be3b5 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -2,7 +2,7 @@
- 星海科技 + {{ displayCompanyName }}
@@ -18,8 +18,8 @@
报销金额趋势 - ¥361,600 - 较昨日 ↑ 8.3% + ¥ 61,600 + 较昨日 +8.3%
@@ -36,19 +36,19 @@
风险预警 14 单 - 较昨日 ↑ 16.7% + 较昨日 +16.7%
审批效率 78% - 较昨日 ↑ 6.2% + 较昨日 +6.2%
SLA 达成率 96% - 较昨日 ↑ 3.1% + 较昨日 +3.1%
@@ -66,18 +66,19 @@
diff --git a/web/src/views/SetupRouteView.vue b/web/src/views/SetupRouteView.vue new file mode 100644 index 0000000..c276691 --- /dev/null +++ b/web/src/views/SetupRouteView.vue @@ -0,0 +1,51 @@ + + + diff --git a/web/src/views/SetupView.vue b/web/src/views/SetupView.vue new file mode 100644 index 0000000..1dcdab0 --- /dev/null +++ b/web/src/views/SetupView.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/web/src/views/scripts/RequestsView.js b/web/src/views/scripts/RequestsView.js index bf65d1f..c02ed15 100644 --- a/web/src/views/scripts/RequestsView.js +++ b/web/src/views/scripts/RequestsView.js @@ -1,11 +1,13 @@ -import { computed, ref, watch } from 'vue' +import { computed, ref, watch } from 'vue' + +import { normalizeRequestForUi } from '../../utils/requestViewModel.js' export default { name: 'RequestsView', props: { - filteredRequests: { type: Array, required: true } -}, - emits: ['ask', 'approve', 'reject', 'create-request'] , + filteredRequests: { type: Array, required: true } + }, + emits: ['ask', 'approve', 'reject', 'create-request'], setup(props, { emit }) { const activeTab = ref('全部') const tabs = ['全部', '待提交', '审批中', '待出行', '已完成'] @@ -18,49 +20,28 @@ export default { const appliedEnd = ref('') const dateRangeLabel = computed(() => { - if (appliedStart.value && appliedEnd.value) return `${appliedStart.value} ~ ${appliedEnd.value}` + if (appliedStart.value && appliedEnd.value) { + return `${appliedStart.value} ~ ${appliedEnd.value}` + } + return '选择时间段' }) function applyDateRange() { - if (!rangeStart.value || !rangeEnd.value) return + if (!rangeStart.value || !rangeEnd.value) { + return + } + appliedStart.value = rangeStart.value appliedEnd.value = rangeEnd.value datePopover.value = false } - const rows = [ - { 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' }, - { 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' }, - { 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' }, - { 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 rows = computed(() => + props.filteredRequests + .map((item) => normalizeRequestForUi(item)) + .filter(Boolean) + ) const currentPage = ref(1) const pageSize = ref(10) @@ -74,8 +55,27 @@ export default { } const filteredRows = computed(() => { - if (activeTab.value === '全部') return rows - return rows.filter((row) => row.approval === activeTab.value || row.travel.includes(activeTab.value.replace('待出行', '待订'))) + if (activeTab.value === '全部') { + 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) @@ -86,7 +86,9 @@ export default { return filteredRows.value.slice(start, start + pageSize.value) }) - watch(activeTab, () => { currentPage.value = 1 }) + watch([activeTab, rows], () => { + currentPage.value = 1 + }) return { emit, @@ -113,4 +115,3 @@ export default { } } } - diff --git a/web/start.sh b/web/start.sh index 7d6b797..597dfd7 100644 --- a/web/start.sh +++ b/web/start.sh @@ -1,14 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -# ============================================================ -# X-Financial Reimbursement Admin - Start Script -# ============================================================ - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ROOT_ENV_FILE="$ROOT_DIR/.env" +MODE="${1:-start}" -# Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -18,22 +16,29 @@ info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } -# ---------------------------------------------------------- -# Check Node.js -# ---------------------------------------------------------- -if ! command -v node &>/dev/null; then - error "Node.js is not installed. Install it first: https://nodejs.org" +if [ -f "$ROOT_ENV_FILE" ]; then + set -a + . "$ROOT_ENV_FILE" + set +a fi -if ! command -v npm &>/dev/null; then - error "npm is not installed. It should come with Node.js." -fi +WEB_HOST="${WEB_HOST:-127.0.0.1}" +WEB_PORT="${WEB_PORT:-5173}" -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() { grep -qi microsoft /proc/version 2>/dev/null } @@ -45,42 +50,125 @@ is_windows_mount() { esac } -if is_wsl && is_windows_mount && command -v powershell.exe &>/dev/null && command -v wslpath &>/dev/null; then - WIN_PATH="$(wslpath -w "$SCRIPT_DIR")" - WIN_PATH_PS="${WIN_PATH//\'/\'\'}" - info "Detected WSL on a Windows-mounted project" - info "Using Windows npm to avoid cross-platform node_modules installs" - info "Access: http://127.0.0.1:5173" - echo "" - exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$WIN_PATH_PS'; npm start" -fi +use_windows_npm() { + is_wsl && is_windows_mount && command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1 +} + +windows_project_path() { + wslpath -w "$SCRIPT_DIR" +} + +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() { [ -d "node_modules" ] || 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 - warn "Dependencies are missing or incomplete" - info "Running npm install..." - npm install +ensure_runtime_tools() { + if use_windows_npm; then + info "Detected WSL on a Windows-mounted project" + 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 - 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 -# ---------------------------------------------------------- -# Start dev server -# ---------------------------------------------------------- -info "Starting X-Financial Reimbursement Admin..." -info "Access: http://127.0.0.1:5173" -echo "" + info "Web dependencies are ready." +} -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 diff --git a/web/vite.config.js b/web/vite.config.js index 2e3d257..4b45029 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -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 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({ - plugins: [vue()] + envDir: '..', + plugins: [vue(), localSetupPlugin()] })