Add vue-router, login/setup flow and backend logging

Refactor frontend to route-based navigation with vue-router, add
system setup and login pages with API integration. Add structured
logging, access-log middleware and startup lifecycle to FastAPI
backend.
This commit is contained in:
2026-05-06 22:23:42 +08:00
parent 83d7da3d62
commit ae63766c91
35 changed files with 3762 additions and 404 deletions

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
APP_NAME=X-Financial
APP_ENV=local
APP_DEBUG=true
API_V1_PREFIX=/api/v1
SETUP_COMPLETED=false
VITE_SETUP_COMPLETED=false
COMPANY_NAME=
COMPANY_CODE=
ADMIN_EMAIL=
VITE_COMPANY_NAME=
VITE_COMPANY_CODE=
VITE_ADMIN_EMAIL=
# Admin login credentials are stored separately under server/.secrets/
WEB_HOST=127.0.0.1
WEB_PORT=5173
VITE_WEB_HOST=127.0.0.1
VITE_WEB_PORT=5173
SERVER_HOST=127.0.0.1
SERVER_PORT=8000
VITE_SERVER_HOST=127.0.0.1
VITE_SERVER_PORT=8000
SERVER_STARTUP_TIMEOUT=300
VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_DB=x_financial
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
VITE_POSTGRES_HOST=127.0.0.1
VITE_POSTGRES_PORT=5432
VITE_POSTGRES_DB=x_financial
VITE_POSTGRES_USER=postgres
DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/x_financial
SQLALCHEMY_ECHO=false
REDIS_URL=
VITE_REDIS_URL=
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]'

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ web/.vite/
*.log
.DS_Store
Thumbs.db
__pycache__/
*.pyc
server/.venv/
server/.secrets/

View File

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

View File

@@ -0,0 +1,71 @@
# Work Log - 2026-05-06
## Git Commits Today
无今日提交上次提交83d7da3
## Uncommitted Changes
### Modified Files (17 files, +1339/-397 lines)
| File | Lines | Description |
|------|-------|-------------|
| web/vite.config.js | +693 | 添加 Vite 配置 |
| web/start.sh | +176 | 修复 Windows/Git Bash 启动 |
| web/package-lock.json | +187 | 更新依赖 |
| web/src/App.vue | -196 | 重构布局 |
| web/composables/useNavigation.js | +89 | 更新导航 |
| web/composables/useAppShell.js | +87 | 重构 App Shell |
| web/src/views/scripts/RequestsView.js | +85 | 更新请求视图逻辑 |
| .gitignore | +4 | 更新忽略规则 |
| README.md | +25 | 更新文档 |
| server/src/app/core/config.py | +8 | 更新配置 |
### New Files (14 files)
| File | Description |
|------|-------------|
| document/work-log/ | 工作日志目录 |
| server/src/app/core/logging.py | 日志模块 |
| server/src/app/middleware/ | 中间件目录 |
| web/src/assets/styles/views/setup-view.css | 安装页面样式 |
| web/src/composables/useSetupView.js | 安装视图逻辑 |
| web/src/composables/useSystemState.js | 系统状态管理 |
| web/src/router/ | 路由目录 |
| web/src/services/ | 服务目录 |
| web/src/utils/ | 工具目录 |
| web/src/views/SetupView.vue | 安装页面 |
| web/src/views/AppShellRouteView.vue | App Shell 路由 |
| web/src/views/LoginRouteView.vue | 登录路由 |
| .env, .env.example | 环境变量文件 |
## Summary
1. **修复 server/start.sh**
- 问题Windows Git Bash 上无法运行,报错 "No module named pip"
- 原因:`.venv` 指向不存在的 `/usr/bin/python3`
- 解决:添加 `venv_valid()` 函数检测并重建虚拟环境
2. **创建 work-log 技能**
- 自动读取 git 提交记录
- 存储在 `document/work-log/` 目录
- 格式:`YYYY-MM-DD.md`
3. **前端重构**
- 重构 App.vue 布局
- 添加 SetupView 安装页面
- 添加路由和服务模块
## Notes
- 需要安装 PostgreSQL 并创建 `x_financial` 数据库
- 大量前端改动未提交
## Tasks
- [ ] 安装 PostgreSQL
- [ ] 创建数据库 `x_financial`
- [ ] 提交前端改动
---
*Created with work-log skill*

View File

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

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_LEVEL_COLORS: dict[int, str] = {
logging.DEBUG: "\033[36m",
logging.INFO: "\033[32m",
logging.WARNING: "\033[33m",
logging.ERROR: "\033[31m",
logging.CRITICAL: "\033[1;31m",
}
_RESET = "\033[0m"
class _ColorFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
color = _LEVEL_COLORS.get(record.levelno, "")
record.levelname = f"{color}{record.levelname:<8}{_RESET}"
return super().format(record)
def _build_console_handler(level: int) -> logging.StreamHandler:
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
handler.setFormatter(_ColorFormatter(LOG_FORMAT, datefmt=DATE_FORMAT))
return handler
def _build_file_handler(log_dir: Path, level: int) -> RotatingFileHandler:
log_dir.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_dir / "app.log",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
handler.setLevel(level)
handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
return handler
def setup_logging(
*,
level: str = "INFO",
log_dir: str = "logs",
enable_file: bool = True,
) -> None:
numeric_level = getattr(logging, level.upper(), logging.INFO)
root = logging.getLogger()
root.setLevel(numeric_level)
root.handlers.clear()
root.addHandler(_build_console_handler(numeric_level))
if enable_file:
from app.core.config import SERVER_DIR
file_path = SERVER_DIR / log_dir
root.addHandler(_build_file_handler(file_path, numeric_level))
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

View File

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

View File

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from app.core.logging import get_logger
logger = get_logger("app.middleware.access")
_SKIP_PATHS: frozenset[str] = frozenset({"/", "/docs", "/openapi.json", "/redoc"})
class AccessLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in _SKIP_PATHS:
return await call_next(request)
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex[:12])
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
level = logging.WARNING if response.status_code >= 500 else logging.INFO
logger.log(
level,
"%s %s %s %.1fms request_id=%s",
request.method,
request.url.path,
response.status_code,
duration_ms,
request_id,
)
response.headers["X-Request-ID"] = request_id
return response

View File

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

View File

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

187
web/package-lock.json generated
View File

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

View File

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

View File

@@ -1,201 +1,17 @@
<template>
<!-- Login Page -->
<LoginView
v-if="!loggedIn"
@login="handleLogin"
@recover-password="handleRecoverPassword"
@sso-login="handleSsoLogin"
/>
<!-- Main App -->
<div v-else class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}"
>
<TopBar
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已筛出 23 个低风险单据可进入批量通过确认')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id} 已生成通过意见`)"
@reject-case="toast(`${activeCase?.id} 已转人工复核`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
<RouterView />
<ToastNotification :toast-text="toastText" />
</template>
<script setup>
import { RouterView } from 'vue-router'
import './assets/styles/global.css'
import SidebarRail from './components/layout/SidebarRail.vue'
import TopBar from './components/layout/TopBar.vue'
import FilterBar from './components/layout/FilterBar.vue'
import ToastNotification from './components/shared/ToastNotification.vue'
import LoginView from './views/LoginView.vue'
import OverviewView from './views/OverviewView.vue'
import PersonalWorkbenchView from './views/PersonalWorkbenchView.vue'
import ChatView from './views/ChatView.vue'
import TravelReimbursementCreateView from './views/TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './views/TravelRequestDetailView.vue'
import RequestsView from './views/RequestsView.vue'
import ApprovalCenterView from './views/ApprovalCenterView.vue'
import PoliciesView from './views/PoliciesView.vue'
import AuditView from './views/AuditView.vue'
import EmployeeManagementView from './views/EmployeeManagementView.vue'
import { useToast } from './composables/useToast.js'
import { useAppShell } from './composables/useAppShell.js'
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
const { toastText } = useToast()
</script>
<style scoped src="./assets/styles/app.css"></style>
<style src="./assets/styles/app.css"></style>

View File

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

View File

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

View File

@@ -0,0 +1,607 @@
.setup-page {
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(320px, 392px) minmax(0, 1fr);
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.24), transparent 24rem),
radial-gradient(circle at 36% 14%, rgba(16, 185, 129, 0.16), transparent 18rem),
linear-gradient(135deg, #04110d 0%, #0b1f18 26%, #10281f 26%, #eef5f1 26%, #f6fbf8 100%);
}
.setup-context {
padding: 42px 28px 32px;
color: rgba(255, 255, 255, 0.92);
display: grid;
align-content: start;
gap: 22px;
border-right: 1px solid rgba(110, 231, 183, 0.08);
background: linear-gradient(180deg, rgba(4, 17, 13, 0.92), rgba(16, 40, 31, 0.9));
}
.setup-brand {
display: flex;
gap: 18px;
align-items: flex-start;
}
.setup-brand-mark {
position: relative;
flex: 0 0 64px;
width: 64px;
height: 64px;
display: grid;
place-items: center;
}
.setup-brand-ring {
position: absolute;
inset: 0;
border-radius: 18px;
background:
linear-gradient(145deg, rgba(209, 250, 229, 0.96), rgba(52, 211, 153, 0.88)),
linear-gradient(145deg, rgba(16, 185, 129, 0.4), rgba(5, 150, 105, 0.6));
box-shadow:
0 18px 36px rgba(16, 185, 129, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.46);
transform: rotate(-8deg);
}
.setup-brand-ring::before {
content: '';
position: absolute;
inset: 7px;
border-radius: 14px;
border: 1px solid rgba(4, 120, 87, 0.22);
}
.setup-brand-core {
position: relative;
z-index: 1;
width: 42px;
height: 42px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(3, 32, 24, 0.92);
color: #d1fae5;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.14em;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.setup-kicker {
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(167, 243, 208, 0.86);
}
.setup-kicker-light {
color: rgba(209, 250, 229, 0.82);
}
.setup-context h1 {
color: #f4fff8;
font-size: clamp(1.9rem, 2.4vw, 2.5rem);
line-height: 1.08;
text-shadow: 0 12px 36px rgba(2, 12, 8, 0.34);
}
.setup-lead {
color: rgba(220, 252, 231, 0.84);
font-size: 14px;
line-height: 1.8;
}
.setup-nav {
display: grid;
gap: 10px;
}
.setup-nav-item {
width: 100%;
padding: 14px 14px 14px 12px;
border: 1px solid rgba(110, 231, 183, 0.12);
border-radius: 8px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 18px;
align-items: center;
gap: 12px;
background: linear-gradient(160deg, rgba(10, 23, 18, 0.82), rgba(15, 39, 31, 0.72));
color: inherit;
text-align: left;
transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.setup-nav-item:hover {
transform: translateY(-1px);
border-color: rgba(110, 231, 183, 0.22);
}
.setup-nav-item.is-active {
border-color: rgba(16, 185, 129, 0.4);
box-shadow: 0 14px 28px rgba(3, 10, 7, 0.22);
}
.setup-nav-item.is-complete {
background: linear-gradient(160deg, rgba(8, 31, 23, 0.96), rgba(12, 58, 44, 0.86));
}
.setup-nav-index {
width: 40px;
height: 40px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(16, 185, 129, 0.16);
color: #d1fae5;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.setup-nav-copy {
display: grid;
gap: 4px;
}
.setup-nav-copy strong {
color: #f0fdf4;
font-size: 14px;
}
.setup-nav-copy small {
color: rgba(209, 250, 229, 0.72);
font-size: 12px;
line-height: 1.55;
}
.setup-nav-check {
color: #6ee7b7;
font-size: 14px;
}
.setup-progress {
margin-top: 8px;
padding: 16px 18px;
border: 1px solid rgba(110, 231, 183, 0.14);
border-radius: 8px;
background: rgba(7, 33, 25, 0.76);
}
.setup-progress strong {
color: #f0fdf4;
font-size: 15px;
}
.setup-progress p {
margin-top: 8px;
color: rgba(209, 250, 229, 0.72);
font-size: 13px;
line-height: 1.65;
}
.setup-complete {
margin-top: auto;
padding: 16px 18px 0;
border-top: 1px solid rgba(110, 231, 183, 0.12);
display: grid;
gap: 12px;
}
.setup-complete p {
color: rgba(209, 250, 229, 0.76);
font-size: 13px;
line-height: 1.6;
}
.setup-complete-btn {
width: 100%;
}
.setup-panel {
padding: 36px;
display: grid;
align-content: start;
gap: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.08), transparent 16rem),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0));
}
.setup-panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 22px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 8px;
background: linear-gradient(135deg, #063b2e, #0f5f49);
box-shadow: 0 16px 34px rgba(6, 59, 46, 0.16);
}
.setup-panel-head h2 {
color: #ffffff;
font-size: 28px;
}
.setup-panel-desc {
margin-top: 10px;
color: rgba(236, 253, 245, 0.82);
font-size: 14px;
line-height: 1.65;
}
.setup-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(240, 253, 244, 0.14);
color: #d1fae5;
font-size: 12px;
font-weight: 700;
border: 1px solid rgba(209, 250, 229, 0.18);
}
.setup-chip.is-success {
background: rgba(16, 185, 129, 0.22);
}
.setup-form {
padding: 30px 32px;
border: 1px solid rgba(16, 185, 129, 0.18);
border-radius: 8px;
background: linear-gradient(180deg, rgba(244, 255, 248, 0.98), rgba(255, 255, 255, 0.94));
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
backdrop-filter: blur(14px);
}
.setup-stage {
display: grid;
gap: 22px;
}
.section-head {
display: grid;
gap: 6px;
}
.section-head h3 {
color: #065f46;
font-size: 18px;
}
.section-head p {
color: #5b6f67;
font-size: 13px;
line-height: 1.7;
}
.field-grid {
display: grid;
gap: 16px;
}
.field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field {
display: grid;
gap: 8px;
}
.field span {
color: #244239;
font-size: 13px;
font-weight: 600;
}
.field-note {
color: #5f7c72;
font-size: 12px;
line-height: 1.5;
}
.field-group-note {
margin-top: -6px;
color: #5f7c72;
font-size: 12px;
line-height: 1.6;
}
.optional-block {
padding: 18px 18px 0;
border: 1px dashed rgba(16, 185, 129, 0.22);
border-radius: 8px;
background: rgba(240, 253, 244, 0.52);
}
.optional-block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.optional-block-head strong {
color: #065f46;
font-size: 14px;
}
.optional-block-head span {
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
display: inline-flex;
align-items: center;
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-size: 12px;
font-weight: 700;
}
.field input {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid rgba(148, 163, 184, 0.78);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: #0f172a;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.field input:hover {
transform: translateY(-1px);
}
.field input:focus {
border-color: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.field-span-2 {
grid-column: span 2;
}
.setup-runtime {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.setup-runtime article {
position: relative;
overflow: hidden;
padding: 16px 18px;
border-radius: 8px;
border: 1px solid rgba(110, 231, 183, 0.14);
display: grid;
gap: 10px;
box-shadow: 0 14px 32px rgba(3, 10, 7, 0.2);
}
.setup-runtime article::before {
content: '';
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, rgba(209, 250, 229, 0.92), rgba(16, 185, 129, 0.48));
}
.setup-runtime article:nth-child(1) {
background: linear-gradient(150deg, rgba(7, 33, 25, 0.96), rgba(16, 67, 52, 0.9));
}
.setup-runtime article:nth-child(2) {
background: linear-gradient(150deg, rgba(7, 28, 26, 0.96), rgba(11, 59, 61, 0.9));
}
.setup-runtime article:nth-child(3) {
background: linear-gradient(150deg, rgba(16, 28, 19, 0.96), rgba(48, 74, 36, 0.9));
}
.setup-runtime span {
font-size: 12px;
text-transform: uppercase;
color: rgba(167, 243, 208, 0.7);
}
.setup-runtime strong {
color: #f8fffb;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
text-shadow: 0 2px 16px rgba(4, 9, 7, 0.22);
}
.setup-summary-grid {
display: grid;
gap: 12px;
}
.setup-summary-item {
padding: 16px 18px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
background: rgba(248, 250, 252, 0.9);
}
.setup-summary-item strong {
display: block;
color: #0f172a;
font-size: 14px;
}
.setup-summary-item span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.setup-summary-item .pi-check-circle {
color: #10b981;
font-size: 18px;
}
.setup-summary-item .pi-clock {
color: #f59e0b;
font-size: 18px;
}
.setup-error {
margin-top: 22px;
padding: 14px 16px;
border: 1px solid rgba(239, 68, 68, 0.18);
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
white-space: pre-line;
}
.setup-status {
margin-top: 22px;
padding: 14px 16px;
border-radius: 8px;
white-space: pre-line;
}
.setup-status.is-success {
border: 1px solid rgba(16, 185, 129, 0.18);
background: #ecfdf5;
color: #047857;
}
.setup-status.is-danger {
border: 1px solid rgba(239, 68, 68, 0.18);
background: #fef2f2;
color: #b91c1c;
}
.setup-gate {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 8px;
background: #fffbeb;
color: #b45309;
}
.setup-actions {
margin-top: 28px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
}
.setup-actions-right {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.primary-btn,
.secondary-btn {
min-height: 46px;
padding: 0 18px;
border-radius: 8px;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 700;
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
}
.primary-btn {
background: linear-gradient(135deg, #10b981, #0f766e);
color: #fff;
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
}
.secondary-btn {
background: rgba(240, 253, 244, 0.94);
color: #1f4f41;
border-color: rgba(16, 185, 129, 0.18);
}
.secondary-btn-strong {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(5, 150, 105, 0.12));
color: #065f46;
}
.primary-btn:hover,
.secondary-btn:hover {
transform: translateY(-1px);
}
.primary-btn:disabled,
.secondary-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
@media (max-width: 1180px) {
.setup-page {
grid-template-columns: 1fr;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.2), transparent 22rem),
linear-gradient(180deg, #04110d 0%, #10281f 36%, #eef5f1 36%, #f6fbf8 100%);
}
.setup-context,
.setup-panel {
padding: 28px 24px;
}
.setup-complete {
padding-inline: 0;
}
}
@media (max-width: 820px) {
.field-grid-2,
.setup-runtime {
grid-template-columns: 1fr;
}
.field-span-2 {
grid-column: auto;
}
.setup-actions {
flex-direction: column;
align-items: stretch;
}
.setup-actions-right {
width: 100%;
flex-direction: column;
}
.primary-btn,
.secondary-btn {
width: 100%;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,383 @@
import { computed, reactive, ref, watch } from 'vue'
function createForm(initialState) {
return {
company_name: initialState?.company?.name || '',
company_code: initialState?.company?.code || '',
admin_email: initialState?.company?.admin_email || '',
admin_username: '',
admin_password: '',
admin_password_confirm: '',
web_host: initialState?.web?.host || '127.0.0.1',
web_port: initialState?.web?.port || 5173,
server_host: initialState?.server?.host || '127.0.0.1',
server_port: initialState?.server?.port || 8000,
postgres_host: initialState?.database?.host || '127.0.0.1',
postgres_port: initialState?.database?.port || 5432,
postgres_db: initialState?.database?.name || 'x_financial',
postgres_user: initialState?.database?.username || 'postgres',
postgres_password: '',
redis_url: initialState?.redis?.url || ''
}
}
function buildPayload(form) {
return {
company_name: form.company_name.trim(),
company_code: form.company_code.trim(),
admin_email: form.admin_email.trim(),
admin_username: form.admin_username.trim(),
admin_password: String(form.admin_password || ''),
admin_password_confirm: String(form.admin_password_confirm || ''),
web_host: form.web_host.trim(),
web_port: Number(form.web_port),
server_host: form.server_host.trim(),
server_port: Number(form.server_port),
postgres_host: form.postgres_host.trim(),
postgres_port: Number(form.postgres_port),
postgres_db: form.postgres_db.trim(),
postgres_user: form.postgres_user.trim(),
postgres_password: String(form.postgres_password || ''),
redis_url: form.redis_url.trim()
}
}
function buildRuntimeFingerprint(form) {
return JSON.stringify({
web_host: form.web_host.trim(),
web_port: String(form.web_port).trim(),
server_host: form.server_host.trim(),
server_port: String(form.server_port).trim()
})
}
function buildDatabaseFingerprint(form) {
return JSON.stringify({
postgres_host: form.postgres_host.trim(),
postgres_port: String(form.postgres_port).trim(),
postgres_db: form.postgres_db.trim(),
postgres_user: form.postgres_user.trim(),
postgres_password: String(form.postgres_password || ''),
redis_url: form.redis_url.trim()
})
}
function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(String(value || '').trim())
}
export function useSetupView(props, emit) {
const form = reactive(createForm(props.initialState))
const activeSection = ref('company')
let syncingFromProps = false
watch(
() => props.initialState,
(state) => {
syncingFromProps = true
Object.assign(form, createForm(state))
queueMicrotask(() => {
syncingFromProps = false
})
},
{ deep: true }
)
watch(
() => buildRuntimeFingerprint(form),
(_value, oldValue) => {
if (oldValue !== undefined && !syncingFromProps) {
emit('runtime-dirty')
}
}
)
watch(
() => buildDatabaseFingerprint(form),
(_value, oldValue) => {
if (oldValue !== undefined && !syncingFromProps) {
emit('database-dirty')
}
}
)
const companyReady = computed(() => form.company_name.trim().length >= 2)
const adminReady = computed(() => {
return Boolean(
isEmail(form.admin_email) &&
form.admin_username.trim().length >= 4 &&
String(form.admin_password || '').length >= 5 &&
form.admin_password === form.admin_password_confirm
)
})
const runtimeInputsReady = computed(() => {
return Boolean(
form.web_host.trim() &&
String(form.web_port).trim() &&
form.server_host.trim() &&
String(form.server_port).trim()
)
})
const databaseInputsReady = computed(() => {
return Boolean(
form.postgres_host.trim() &&
String(form.postgres_port).trim() &&
form.postgres_db.trim() &&
form.postgres_user.trim() &&
String(form.postgres_password || '').length > 0
)
})
const runtimeReady = computed(() => runtimeInputsReady.value && props.runtimeTestPassed)
const databaseReady = computed(() => databaseInputsReady.value && props.databaseTestPassed)
const finalReady = computed(() => companyReady.value && adminReady.value && runtimeReady.value && databaseReady.value)
const sections = computed(() => [
{
id: 'company',
index: '01',
title: '企业信息',
desc: '填写企业名称与识别编码。',
complete: companyReady.value
},
{
id: 'admin',
index: '02',
title: '管理员安全',
desc: '配置管理员邮箱、账号与密码。',
complete: adminReady.value
},
{
id: 'runtime',
index: '03',
title: '运行端口',
desc: '单独检测 Web 与后端端口占用。',
complete: runtimeReady.value
},
{
id: 'database',
index: '04',
title: '数据库',
desc: '检测 PostgreSQL 连接Redis 暂时可选。',
complete: databaseReady.value
}
])
const activeStep = computed(() => sections.value.find((section) => section.id === activeSection.value) || sections.value[0])
const completionCount = computed(() => sections.value.filter((section) => section.complete).length)
const runtimeEndpoints = computed(() => [
{
label: 'Web',
value: `${form.web_host}:${form.web_port}`
},
{
label: 'Server',
value: `${form.server_host}:${form.server_port}`
}
])
const summaryItems = computed(() => [
{
label: '企业信息',
detail: form.company_name.trim() || '未完成',
complete: companyReady.value
},
{
label: '管理员安全',
detail: form.admin_username.trim() || form.admin_email.trim() || '未完成',
complete: adminReady.value
},
{
label: '运行端口',
detail: `${form.web_host}:${form.web_port} / ${form.server_host}:${form.server_port}`,
complete: runtimeReady.value
},
{
label: '数据库',
detail: `${form.postgres_host}:${form.postgres_port}/${form.postgres_db}`,
complete: databaseReady.value
}
])
const currentTestMessage = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTestMessage
}
if (activeSection.value === 'database') {
return props.databaseTestMessage
}
return ''
})
const currentTestPassed = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTestPassed
}
if (activeSection.value === 'database') {
return props.databaseTestPassed
}
return false
})
const showTestAction = computed(() => ['runtime', 'database'].includes(activeSection.value))
const testButtonLabel = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTesting ? '检测中...' : '检测端口占用'
}
if (activeSection.value === 'database') {
return props.databaseTesting ? '检测中...' : '检测数据库连接'
}
return ''
})
const testButtonIcon = computed(() => {
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
return 'pi pi-spin pi-spinner'
}
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
})
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
const canTest = computed(() => {
if (activeSection.value === 'runtime') {
return canRuntimeTest.value
}
if (activeSection.value === 'database') {
return canDatabaseTest.value
}
return false
})
const submitHint = computed(() => {
if (activeSection.value === 'admin') {
if (!form.admin_email.trim() && !form.admin_username.trim() && !String(form.admin_password || '').length) {
return ''
}
if (!form.admin_email.trim()) {
return '请填写管理员邮箱。'
}
if (!isEmail(form.admin_email)) {
return '管理员邮箱格式不正确。'
}
if (form.admin_username.trim() && form.admin_username.trim().length < 4) {
return '管理员账号至少 4 位。'
}
if (String(form.admin_password || '').length > 0 && String(form.admin_password || '').length < 5) {
return '管理员密码当前至少 5 位。'
}
if (
String(form.admin_password_confirm || '').length > 0 &&
form.admin_password !== form.admin_password_confirm
) {
return '两次输入的管理员密码不一致。'
}
}
if (activeSection.value === 'runtime') {
if (!runtimeInputsReady.value) {
return '请先填写 Web 与 Server 的主机和端口。'
}
if (!props.runtimeTestPassed) {
return '请先完成端口占用检测。'
}
}
if (activeSection.value === 'database') {
if (!databaseInputsReady.value) {
return '请先填写 PostgreSQL 连接信息。'
}
if (!props.databaseTestPassed) {
return '请先完成数据库连接检测。'
}
}
if (activeSection.value === 'company') {
return ''
}
if (!companyReady.value) {
return '请先完成企业信息。'
}
if (!adminReady.value) {
return '请先完成管理员安全配置。'
}
if (!runtimeReady.value) {
return '请先完成运行端口检测。'
}
if (!databaseReady.value) {
return '请先完成数据库连接检测。'
}
return ''
})
function goToSection(id) {
activeSection.value = id
}
function submitForm() {
if (!finalReady.value || props.submitting) {
return
}
emit('submit', buildPayload(form))
}
function testSetup() {
if (!canTest.value) {
return
}
const payload = buildPayload(form)
if (activeSection.value === 'runtime') {
emit('runtime-test', payload)
return
}
if (activeSection.value === 'database') {
emit('database-test', payload)
}
}
return {
activeSection,
activeStep,
canSubmit: finalReady,
canTest,
completionCount,
currentTestMessage,
currentTestPassed,
form,
goToSection,
runtimeEndpoints,
sections,
showTestAction,
submitForm,
submitHint,
summaryItems,
testButtonIcon,
testButtonLabel,
testSetup
}
}

View File

@@ -0,0 +1,278 @@
import { computed, ref } from 'vue'
import {
loginBootstrapAdmin,
saveBootstrapConfig,
testBootstrapDatabase,
testBootstrapRuntime
} from '../services/bootstrap.js'
import { useToast } from './useToast.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
function readClientBootstrapState() {
const env = import.meta.env
return {
initialized: String(env.VITE_SETUP_COMPLETED || '').toLowerCase() === 'true',
company: {
name: env.VITE_COMPANY_NAME || '',
code: env.VITE_COMPANY_CODE || '',
admin_email: env.VITE_ADMIN_EMAIL || ''
},
web: {
host: env.VITE_WEB_HOST || '127.0.0.1',
port: Number(env.VITE_WEB_PORT || 5173)
},
server: {
host: env.VITE_SERVER_HOST || '127.0.0.1',
port: Number(env.VITE_SERVER_PORT || 8000)
},
database: {
driver: 'postgresql',
host: env.VITE_POSTGRES_HOST || '127.0.0.1',
port: Number(env.VITE_POSTGRES_PORT || 5432),
name: env.VITE_POSTGRES_DB || 'x_financial',
username: env.VITE_POSTGRES_USER || 'postgres',
password_configured: false
},
redis: {
enabled: Boolean(env.VITE_REDIS_URL),
url: env.VITE_REDIS_URL || ''
}
}
}
function readAuthState() {
if (typeof window === 'undefined') {
return false
}
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
}
function persistAuthState(value) {
if (typeof window === 'undefined') {
return
}
if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
return
}
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
}
const bootstrapState = ref(readClientBootstrapState())
const setupSubmitting = ref(false)
const setupError = ref('')
const runtimeTesting = ref(false)
const databaseTesting = ref(false)
const runtimeTestPassed = ref(false)
const databaseTestPassed = ref(false)
const runtimeTestMessage = ref('')
const databaseTestMessage = ref('')
const loginSubmitting = ref(false)
const loginError = ref('')
const loggedIn = ref(readAuthState())
const { toast } = useToast()
const companyProfile = computed(() => ({
name: bootstrapState.value.company?.name || '',
code: bootstrapState.value.company?.code || '',
adminEmail: bootstrapState.value.company?.admin_email || ''
}))
const isInitialized = computed(() => Boolean(bootstrapState.value.initialized))
function applyBootstrapState(state) {
bootstrapState.value = state
if (!state.initialized) {
loggedIn.value = false
persistAuthState(false)
}
}
function clearSetupRuntimeState() {
runtimeTesting.value = false
databaseTesting.value = false
runtimeTestPassed.value = false
databaseTestPassed.value = false
runtimeTestMessage.value = ''
databaseTestMessage.value = ''
setupError.value = ''
}
function resetFromClientEnv() {
applyBootstrapState(readClientBootstrapState())
clearSetupRuntimeState()
loginError.value = ''
}
async function handleSetupSubmit(payload) {
if (!runtimeTestPassed.value) {
setupError.value = '请先完成运行端口检测。'
toast(setupError.value)
return false
}
if (!databaseTestPassed.value) {
setupError.value = '请先完成数据库连接检测。'
toast(setupError.value)
return false
}
setupSubmitting.value = true
setupError.value = ''
try {
const state = await saveBootstrapConfig(payload)
applyBootstrapState(state)
toast('初始化配置已写入。现在可以进入登录页。')
return true
} catch (error) {
setupError.value = error.message || '初始化配置写入失败,请稍后重试。'
toast(setupError.value)
return false
} finally {
setupSubmitting.value = false
}
}
async function handleRuntimeTest(payload) {
runtimeTesting.value = true
runtimeTestMessage.value = ''
setupError.value = ''
try {
const result = await testBootstrapRuntime(payload)
runtimeTestPassed.value = true
runtimeTestMessage.value = result.detail || '端口占用检测通过。'
toast(runtimeTestMessage.value)
} catch (error) {
runtimeTestPassed.value = false
runtimeTestMessage.value = error.message || '端口占用检测失败。'
toast(runtimeTestMessage.value)
} finally {
runtimeTesting.value = false
}
}
async function handleDatabaseTest(payload) {
databaseTesting.value = true
databaseTestMessage.value = ''
setupError.value = ''
try {
const result = await testBootstrapDatabase(payload)
databaseTestPassed.value = true
databaseTestMessage.value = result.detail || '数据库连接检测通过。'
toast(databaseTestMessage.value)
} catch (error) {
databaseTestPassed.value = false
databaseTestMessage.value = error.message || '数据库连接检测失败。'
toast(databaseTestMessage.value)
} finally {
databaseTesting.value = false
}
}
function handleRuntimeDirty() {
runtimeTestPassed.value = false
runtimeTestMessage.value = ''
if (setupError.value === '请先完成运行端口检测。') {
setupError.value = ''
}
}
function handleDatabaseDirty() {
databaseTestPassed.value = false
databaseTestMessage.value = ''
if (setupError.value === '请先完成数据库连接检测。') {
setupError.value = ''
}
}
async function handleLogin(credentials) {
loginSubmitting.value = true
loginError.value = ''
try {
await loginBootstrapAdmin({
username: credentials.username,
password: credentials.password
})
loggedIn.value = true
persistAuthState(true)
return true
} catch (error) {
loggedIn.value = false
persistAuthState(false)
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
toast(loginError.value)
return false
} finally {
loginSubmitting.value = false
}
}
function logout() {
loggedIn.value = false
persistAuthState(false)
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。管理员密码不会写入 .env。')
}
function handleSsoLogin() {
toast('SSO 登录暂未启用。')
}
function resolveEntryRoute() {
if (!isInitialized.value) {
return { name: 'setup' }
}
if (!loggedIn.value) {
return { name: 'login' }
}
return { name: 'app-overview' }
}
export function useSystemState() {
return {
bootstrapState,
companyProfile,
databaseTestMessage,
databaseTestPassed,
databaseTesting,
handleDatabaseDirty,
handleDatabaseTest,
handleLogin,
handleRecoverPassword,
handleRuntimeDirty,
handleRuntimeTest,
handleSetupSubmit,
handleSsoLogin,
isInitialized,
loggedIn,
loginError,
loginSubmitting,
logout,
resetFromClientEnv,
resolveEntryRoute,
runtimeTestMessage,
runtimeTestPassed,
runtimeTesting,
setupError,
setupSubmitting
}
}

View File

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

View File

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

110
web/src/router/index.js Normal file
View File

@@ -0,0 +1,110 @@
import { createRouter, createWebHistory } from 'vue-router'
import { appViews } from '../composables/useNavigation.js'
import { useSystemState } from '../composables/useSystemState.js'
import AppShellRouteView from '../views/AppShellRouteView.vue'
import LoginRouteView from '../views/LoginRouteView.vue'
import SetupRouteView from '../views/SetupRouteView.vue'
const appChildRoutes = appViews
.filter((view) => view !== 'requests')
.map((view) => ({
path: view,
name: `app-${view}`,
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: view
}
}))
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'root',
redirect: () => {
const { resolveEntryRoute } = useSystemState()
return resolveEntryRoute()
}
},
{
path: '/setup',
name: 'setup',
component: SetupRouteView
},
{
path: '/login',
name: 'login',
component: LoginRouteView
},
{
path: '/app',
redirect: { name: 'app-overview' }
},
{
path: '/app/requests',
name: 'app-requests',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
},
{
path: '/app/requests/:requestId',
name: 'app-request-detail',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
},
...appChildRoutes.map((route) => ({
...route,
path: `/app/${route.path}`
})),
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
]
})
router.beforeEach((to) => {
const { isInitialized, loggedIn, resolveEntryRoute } = useSystemState()
if (!isInitialized.value) {
if (to.name !== 'setup') {
return { name: 'setup' }
}
return true
}
if (to.name === 'setup') {
return resolveEntryRoute()
}
if (!loggedIn.value && to.meta.requiresAuth) {
return {
name: 'login',
query: {
redirect: to.fullPath
}
}
}
if (loggedIn.value && to.name === 'login') {
return resolveEntryRoute()
}
if (to.name === 'root') {
return resolveEntryRoute()
}
return true
})
export default router

78
web/src/services/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,78 @@
const SETUP_API_BASE = '/__setup'
function formatValidationErrors(detail) {
if (!Array.isArray(detail)) {
return ''
}
return detail
.map((item) => {
const field = Array.isArray(item.loc) ? item.loc[item.loc.length - 1] : 'field'
return `${field}: ${item.msg}`
})
.join('\n')
}
async function request(path, options = {}) {
let response
try {
response = await fetch(`${SETUP_API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
})
} catch {
throw new Error('无法连接初始化服务,请确认本地配置桥已启动。')
}
let data = null
try {
data = await response.json()
} catch {
data = null
}
if (!response.ok) {
const validationMessage = formatValidationErrors(data?.detail)
const message = validationMessage || data?.detail || '初始化请求失败,请稍后重试。'
throw new Error(message)
}
return data
}
export function fetchBootstrapState() {
return request('/bootstrap')
}
export function saveBootstrapConfig(payload) {
return request('/bootstrap', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function testBootstrapRuntime(payload) {
return request('/bootstrap/runtime', {
method: 'PUT',
body: JSON.stringify(payload)
})
}
export function testBootstrapDatabase(payload) {
return request('/bootstrap/database', {
method: 'PUT',
body: JSON.stringify(payload)
})
}
export function loginBootstrapAdmin(payload) {
return request('/auth/login', {
method: 'POST',
body: JSON.stringify(payload)
})
}

View File

@@ -0,0 +1,87 @@
function parseRequestDateFromId(id) {
const match = String(id || '').match(/^REQ-(\d{4})-(\d{2})(\d{2})$/)
if (!match) {
return ''
}
const [, year, month, day] = match
return `${year}-${month}-${day}`
}
function formatTripWindow(range) {
const normalized = String(range || '')
if (!normalized) {
return '待补充'
}
if (normalized.includes('本月')) {
return '本月申请'
}
if (normalized.includes('本周')) {
return '本周申请'
}
if (normalized.includes('今天')) {
return '今日申请'
}
return normalized
}
function mapApproval(status) {
if (status === 'success') {
return {
node: '已完成归档',
approval: '已完成',
approvalTone: 'success',
travel: '已完成行程',
travelTone: 'success'
}
}
if (status === 'danger') {
return {
node: '异常待复核',
approval: '待处理',
approvalTone: 'danger',
travel: '存在异常',
travelTone: 'danger'
}
}
return {
node: '财务审核中',
approval: '审批中',
approvalTone: 'info',
travel: '待安排行程',
travelTone: 'warning'
}
}
export function normalizeRequestForUi(request) {
if (!request) {
return null
}
const applyTime = parseRequestDateFromId(request.id) || '2026-04-18'
const reason = `${request.category || '差旅'}申请`
const city = request.entity || '待补充'
const period = formatTripWindow(request.range)
const approvalState = mapApproval(request.status)
return {
...request,
reason,
city,
period,
applyTime,
node: approvalState.node,
approval: approvalState.approval,
approvalTone: approvalState.approvalTone,
travel: approvalState.travel,
travelTone: approvalState.travelTone
}
}

View File

@@ -0,0 +1,176 @@
<template>
<div class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}"
>
<TopBar
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过`)"
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
</template>
<script setup>
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import ChatView from './ChatView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import { useAppShell } from '../composables/useAppShell.js'
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleNavigate,
handleOpenChat,
handleReject,
handleUpload,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
</script>

View File

@@ -0,0 +1,46 @@
<template>
<LoginView
:company-name="companyProfile.name"
:submitting="loginSubmitting"
:error-message="loginError"
@login="submitLogin"
@recover-password="handleRecoverPassword"
@sso-login="handleSsoLogin"
/>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useSystemState } from '../composables/useSystemState.js'
import LoginView from './LoginView.vue'
const route = useRoute()
const router = useRouter()
const {
companyProfile,
handleLogin,
handleRecoverPassword,
handleSsoLogin,
loginError,
loginSubmitting,
resolveEntryRoute
} = useSystemState()
async function submitLogin(credentials) {
const passed = await handleLogin(credentials)
if (!passed) {
return
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect.startsWith('/app/')) {
router.replace(redirect)
return
}
router.replace(resolveEntryRoute())
}
</script>

View File

@@ -2,7 +2,7 @@
<main class="login-page">
<header class="page-brand">
<LogoMark />
<strong>星海科技</strong>
<strong>{{ displayCompanyName }}</strong>
</header>
<section class="hero">
@@ -18,8 +18,8 @@
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>361,600</strong>
<small>较昨日 <b class="up"> 8.3%</b></small>
<strong>¥ 61,600</strong>
<small>较昨日 <b class="up">+8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
@@ -36,19 +36,19 @@
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger"> 16.7%</b></small>
<small>较昨日 <b class="danger">+16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up"> 6.2%</b></small>
<small>较昨日 <b class="up">+6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up"> 3.1%</b></small>
<small>较昨日 <b class="up">+3.1%</b></small>
</div>
</div>
@@ -66,18 +66,19 @@
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>星海科技</strong>
<strong>{{ displayCompanyName }}</strong>
</div>
<header class="card-head">
<h2>欢迎登录</h2>
<p>登录企业报销智能运营台</p>
<p>使用初始化时创建的管理员账号进入系统</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入账号 / 邮箱 / 手机号" autocomplete="username" required />
<input v-model="username" type="text" placeholder="请输入管理员账号" autocomplete="username" required />
</label>
<label class="field">
@@ -86,7 +87,7 @@
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
placeholder="请输入管理员密码"
autocomplete="current-password"
required
/>
@@ -96,7 +97,7 @@
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-slash'"></i>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
</button>
</label>
@@ -112,16 +113,20 @@
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住</span>
<span>记住账号</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div>
<button class="submit-btn" type="submit">登录</button>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
<button class="submit-btn" type="submit" :disabled="submitting">
{{ submitting ? '登录中...' : '登录' }}
</button>
<div class="divider"><span></span></div>
<button class="sso-btn" type="button" @click="emit('sso-login')">
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
@@ -129,26 +134,37 @@
<footer class="security-note">
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系管理员</span>
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
</footer>
</section>
</main>
</template>
<script setup>
import { computed } from 'vue'
import { useLoginView } from '../composables/useLoginView.js'
const props = defineProps({
companyName: {
type: String,
default: ''
},
submitting: {
type: Boolean,
default: false
},
errorMessage: {
type: String,
default: ''
}
})
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const {
features,
LogoMark,
password,
remember,
showPassword,
tenant,
username
} = useLoginView()
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
</script>
<style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -0,0 +1,51 @@
<template>
<SetupView
:initial-state="bootstrapState || {}"
:submitting="setupSubmitting"
:runtime-testing="runtimeTesting"
:database-testing="databaseTesting"
:runtime-test-passed="runtimeTestPassed"
:database-test-passed="databaseTestPassed"
:runtime-test-message="runtimeTestMessage"
:database-test-message="databaseTestMessage"
:error-message="setupError"
@submit="submitSetup"
@runtime-test="handleRuntimeTest"
@database-test="handleDatabaseTest"
@runtime-dirty="handleRuntimeDirty"
@database-dirty="handleDatabaseDirty"
/>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useSystemState } from '../composables/useSystemState.js'
import SetupView from './SetupView.vue'
const router = useRouter()
const {
bootstrapState,
databaseTestMessage,
databaseTestPassed,
databaseTesting,
handleDatabaseDirty,
handleDatabaseTest,
handleRuntimeDirty,
handleRuntimeTest,
handleSetupSubmit,
runtimeTestMessage,
runtimeTestPassed,
runtimeTesting,
setupError,
setupSubmitting
} = useSystemState()
async function submitSetup(payload) {
const completed = await handleSetupSubmit(payload)
if (completed) {
router.replace({ name: 'login' })
}
}
</script>

316
web/src/views/SetupView.vue Normal file
View File

@@ -0,0 +1,316 @@
<template>
<main class="setup-page">
<aside class="setup-context">
<div class="setup-brand">
<div class="setup-brand-mark" aria-hidden="true">
<span class="setup-brand-ring"></span>
<span class="setup-brand-core">XF</span>
</div>
<div>
<p class="setup-kicker">INITIAL SETUP</p>
<h1>初始化配置</h1>
</div>
</div>
<p class="setup-lead">
先完成 4 个必要步骤再进入主登录界面扩展服务当前不参与初始化完成条件
</p>
<nav class="setup-nav" aria-label="初始化步骤">
<button
v-for="section in sections"
:key="section.id"
class="setup-nav-item"
:class="{ 'is-active': activeSection === section.id, 'is-complete': section.complete }"
type="button"
@click="goToSection(section.id)"
>
<span class="setup-nav-index">{{ section.index }}</span>
<span class="setup-nav-copy">
<strong>{{ section.title }}</strong>
<small>{{ section.desc }}</small>
</span>
<i v-if="section.complete" class="pi pi-check setup-nav-check"></i>
</button>
</nav>
<div class="setup-progress">
<strong>{{ completionCount }} / {{ sections.length }} 已完成</strong>
<p>企业信息管理员安全运行端口数据库连接都通过后左下角会自动出现完成初始化按钮</p>
</div>
<div v-if="canSubmit" class="setup-complete">
<p>所有必要步骤已通过检测可以写入配置并进入登录界面</p>
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
<i class="pi pi-check"></i>
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
</button>
</div>
</aside>
<section class="setup-panel">
<header class="setup-panel-head">
<div>
<p class="setup-kicker setup-kicker-light">{{ activeStep.index }}</p>
<h2>{{ activeStep.title }}</h2>
<p class="setup-panel-desc">{{ activeStep.desc }}</p>
</div>
<span class="setup-chip" :class="{ 'is-success': activeStep.complete }">
{{ activeStep.complete ? '已完成' : '待配置' }}
</span>
</header>
<div class="setup-form">
<section v-if="activeSection === 'company'" class="setup-stage">
<div class="section-head">
<h3>企业基础信息</h3>
<p>这里仅保留企业名称与企业编码不放管理员邮箱</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>企业名称</span>
<input v-model.trim="form.company_name" type="text" placeholder="请输入企业名称" required />
</label>
<label class="field">
<span>企业编码</span>
<input v-model.trim="form.company_code" type="text" placeholder="例如 FIN" />
</label>
</div>
</section>
<section v-else-if="activeSection === 'admin'" class="setup-stage">
<div class="section-head">
<h3>管理员安全</h3>
<p>管理员邮箱账号和密码在这里配置密码不会写入 `.env`只会保存哈希后的密文</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>管理员邮箱</span>
<input v-model.trim="form.admin_email" type="email" placeholder="admin@company.com" />
</label>
<label class="field">
<span>管理员账号</span>
<input v-model.trim="form.admin_username" type="text" placeholder="例如 superadmin" required />
</label>
<label class="field">
<span>管理员密码</span>
<input
v-model="form.admin_password"
type="password"
placeholder="请输入管理员密码"
autocomplete="new-password"
required
/>
</label>
<label class="field">
<span>确认密码</span>
<input
v-model="form.admin_password_confirm"
type="password"
placeholder="请再次输入管理员密码"
autocomplete="new-password"
required
/>
</label>
</div>
<p class="field-group-note">管理员密码当前暂定至少 5 </p>
</section>
<section v-else-if="activeSection === 'runtime'" class="setup-stage">
<div class="section-head">
<h3>运行端口配置</h3>
<p>这一步只检测 Web Server 端口占用情况不检测数据库</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>Web Host</span>
<input v-model.trim="form.web_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>Web Port</span>
<input v-model.number="form.web_port" type="number" min="1" max="65535" required />
</label>
<label class="field">
<span>Server Host</span>
<input v-model.trim="form.server_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>Server Port</span>
<input v-model.number="form.server_port" type="number" min="1" max="65535" required />
</label>
</div>
<div class="setup-runtime">
<article v-for="item in runtimeEndpoints" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section v-else class="setup-stage">
<div class="section-head">
<h3>数据库连接</h3>
<p>这里检测 PostgreSQL 连接Redis 作为扩展服务暂时可选不影响完成初始化</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>PostgreSQL Host</span>
<input v-model.trim="form.postgres_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>PostgreSQL Port</span>
<input v-model.number="form.postgres_port" type="number" min="1" max="65535" required />
</label>
<label class="field">
<span>数据库名称</span>
<input v-model.trim="form.postgres_db" type="text" placeholder="x_financial" required />
</label>
<label class="field">
<span>数据库用户</span>
<input v-model.trim="form.postgres_user" type="text" placeholder="postgres" required />
</label>
<label class="field field-span-2">
<span>数据库密码</span>
<input
v-model="form.postgres_password"
type="password"
placeholder="请输入数据库密码"
autocomplete="new-password"
required
/>
</label>
</div>
<div class="optional-block">
<div class="optional-block-head">
<strong>扩展服务</strong>
<span>可选</span>
</div>
<label class="field">
<span>Redis URL</span>
<input v-model.trim="form.redis_url" type="text" placeholder="redis://127.0.0.1:6379/0" />
</label>
</div>
<div class="setup-summary-grid">
<article v-for="item in summaryItems" :key="item.label" class="setup-summary-item">
<div>
<strong>{{ item.label }}</strong>
<span>{{ item.detail }}</span>
</div>
<i :class="['pi', item.complete ? 'pi-check-circle' : 'pi-clock']"></i>
</article>
</div>
</section>
<p v-if="currentTestMessage" :class="['setup-status', currentTestPassed ? 'is-success' : 'is-danger']">
{{ currentTestMessage }}
</p>
<p v-if="errorMessage" class="setup-error">{{ errorMessage }}</p>
<p v-if="submitHint" class="setup-gate">{{ submitHint }}</p>
<footer class="setup-actions">
<div class="setup-actions-right">
<button
v-if="showTestAction"
class="secondary-btn secondary-btn-strong"
type="button"
:disabled="!canTest"
@click="testSetup"
>
<i :class="testButtonIcon"></i>
<span>{{ testButtonLabel }}</span>
</button>
</div>
</footer>
</div>
</section>
</main>
</template>
<script setup>
import { useSetupView } from '../composables/useSetupView.js'
const props = defineProps({
initialState: {
type: Object,
default: () => ({})
},
submitting: {
type: Boolean,
default: false
},
runtimeTesting: {
type: Boolean,
default: false
},
databaseTesting: {
type: Boolean,
default: false
},
runtimeTestPassed: {
type: Boolean,
default: false
},
databaseTestPassed: {
type: Boolean,
default: false
},
runtimeTestMessage: {
type: String,
default: ''
},
databaseTestMessage: {
type: String,
default: ''
},
errorMessage: {
type: String,
default: ''
}
})
const emit = defineEmits(['submit', 'runtime-test', 'database-test', 'runtime-dirty', 'database-dirty'])
const {
activeSection,
activeStep,
canSubmit,
canTest,
completionCount,
currentTestMessage,
currentTestPassed,
form,
goToSection,
runtimeEndpoints,
sections,
showTestAction,
submitForm,
submitHint,
summaryItems,
testButtonIcon,
testButtonLabel,
testSetup
} = useSetupView(props, emit)
</script>
<style scoped src="../assets/styles/views/setup-view.css"></style>

View File

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

View File

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

View File

@@ -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 驱动 pgweb/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()]
})