diff --git a/.env.example b/.env.example index 59809a0..3ae5bab 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,7 @@ VITE_SERVER_HOST=0.0.0.0 VITE_SERVER_PORT=8000 SERVER_STARTUP_TIMEOUT=300 SERVER_BLOCKING_STARTUP_TIMEOUT=12 -VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1 +VITE_API_BASE_URL=/api/v1 VITE_AUTH_IDLE_TIMEOUT_MINUTES=30 POSTGRES_HOST=127.0.0.1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..260630c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +.env text eol=lf +.env.example text eol=lf diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae5a2b3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + main: + image: ubuntu-dev:latest + container_name: x-financial-main + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + WEB_HOST: 0.0.0.0 + SERVER_HOST: 0.0.0.0 + SERVER_VENV_DIR: /tmp/x-financial-server-venv + ports: + - "${WEB_PORT:-5173}:${WEB_PORT:-5173}" + - "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}" + - "2223:22" + working_dir: /app + command: + - /bin/sh + - -lc + - > + apt-get update && + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends + python3 python3-pip python3-venv && + mkdir -p /run/sshd && /usr/sbin/sshd && + sed -i 's/\r$//' /app/start.sh /app/web/web_start.sh /app/server/server_start.sh && + chmod +x /app/start.sh /app/web/web_start.sh /app/server/server_start.sh && + ./start.sh all + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 180s + + postgres: + image: postgres:16-alpine + container_name: x-financial-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-x_financial} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + ports: + - "${POSTGRES_EXPOSE_PORT:-55432}:5432" + volumes: + - ./postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-x_financial}"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + postgres_data: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..14a6d4c --- /dev/null +++ b/docker/README.md @@ -0,0 +1,63 @@ +# Docker Compose + +This project currently uses the Vite `__setup/*` middleware during the initial setup flow. +Because of that, the Docker deployment keeps the web frontend and FastAPI startup chain in +the same main container and runs the existing root `start.sh`. + +## Start + +```bash +cp .env.example .env +docker compose up -d +``` + +Open: + +```text +http://:5173 +``` + +## Container Layout + +- `main`: web + FastAPI main container +- `postgres`: PostgreSQL database container + +The project root is mounted directly into the main container: + +```text +.:/app +``` + +That means the container reads your existing `.env`, source code, `server/.secrets`, logs, +and generated dependency directories directly from the mapped project folder. + +This is a `compose`-only setup. There is no custom `Dockerfile`. +The tradeoff is that the `main` container installs the Python runtime packages it needs +when it starts. + +## Persistence + +The PostgreSQL data directory is stored in the named volume `postgres_data`. + +## Notes + +- Most configuration should be maintained in the project root `.env`. +- The first `docker compose up -d` does not require an existing `.env`; the compose file + uses built-in defaults for the PostgreSQL container and the main container database URL. +- Docker Compose only overrides a few values that must differ inside containers: + - `WEB_HOST=0.0.0.0` + - `SERVER_HOST=0.0.0.0` + - `POSTGRES_HOST=postgres` + - `POSTGRES_PORT=5432` + - `DATABASE_URL=...@postgres:...` +- PostgreSQL is also published to the host by default as `127.0.0.1:55432`. +- First boot with `SETUP_COMPLETED=false` starts the setup UI only. +- After you complete setup in the browser, the Vite setup bridge will start FastAPI in the + same container using the saved runtime configuration. +- On later restarts, `start.sh` will detect the saved setup state and start both web and + server automatically. +- If you access the system from another machine, make sure `CORS_ORIGINS` in `.env` includes + the frontend address you actually use. +- For Navicat or any host-side client, use `127.0.0.1:55432`. +- For the setup page, using `127.0.0.1` is acceptable in this Docker layout; the internal + test bridge will resolve that back to the Docker PostgreSQL service. diff --git a/document/work-log/2026-05-08.md b/document/work-log/2026-05-08.md new file mode 100644 index 0000000..409a60a --- /dev/null +++ b/document/work-log/2026-05-08.md @@ -0,0 +1,30 @@ +# Work Log - 2026-05-08 + +## 今日工作 + +- **提交 adda87a** (08:56) + - feat: add system settings with model connectivity and encrypted storage + - 为系统设置页面新增了配置管理功能,支持管理员修改 AI 模型连接参数 + - 引入加密存储方案(secret_box.py),对敏感配置(如 API Key)使用对称加密保护 + - 后端新增 settings 端点、repository 层和 service 层,实现配置的增删改查 + - 新增 model_connectivity.py 服务,支持测试 AI 模型连接是否正常 + - 前端设置页面大幅重构,增加了模型配置表单和连接测试功能 + - 新增数据库表 system_setting 和 system_setting_secret 存储配置和加密值 + - 编写了 settings 相关的单元测试,确保配置持久化和服务逻辑正确 + +- **提交 c5486dd** (10:52) + - feat: 启用后端自动启动与 Setup 引导流程增强 + - 启用了后端自动启动功能,用户访问前端时后端自动拉起,无需手动启动 server + - 增强了 Setup 引导流程,新增后端启动进度追踪,分 5 步展示(config → deps → server → health → done) + - 网络绑定从 127.0.0.1 扩展到 0.0.0.0,支持远程浏览器访问部署的系統 + - API URL 动态化,通过 localStorage 持久化配置,支持运行时修改 + - 新增后端启动探针(probe),自动检测后端就绪状态后才允许浏览器继续操作 + - Setup 表单智能判断浏览器 host,将本地地址自动转换为 0.0.0.0 供远程访问 + +- **提交 8656866** (11:14) + - feat: 重构模型配置存储与 API Key 加密管理 + - 新增 SystemModelSetting 模型(slot 为 PK),支持 main/backup/vlm/embedding 四个模型槽位配置 + - 废弃旧的加密存储方案,改用更规范的数据库表存储加密的 API Key + - 兼容旧版 admin secret 格式,将历史密码记录迁移到标准 scrypt 哈希格式 + - 前端 API URL 智能解析,当后端配置为回环地址但浏览器非回环时,自动使用浏览器 host 访问 + - 改进 API Key 输入体验,聚焦时自动清除遮罩显示,并提示已从数据库加载密钥 diff --git a/server/server_start.sh b/server/server_start.sh index 512208d..87b0ef1 100755 --- a/server/server_start.sh +++ b/server/server_start.sh @@ -1,14 +1,22 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env sh +set -eu + +if (set -o pipefail) >/dev/null 2>&1; then + set -o pipefail +fi export MSYS_NO_PATHCONV=1 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="$0" +case "$SCRIPT_PATH" in + /*) ;; + *) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;; +esac +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)" cd "$SCRIPT_DIR" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT_ENV_FILE="$ROOT_DIR/.env" ROOT_ENV_EXAMPLE_FILE="$ROOT_DIR/.env.example" -VENV_DIR="$SCRIPT_DIR/.venv" MODE="${1:-start}" RED='\033[0;31m' @@ -16,9 +24,59 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -info() { echo -e "${GREEN}[INFO]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +info() { printf '%b\n' "${GREEN}[INFO]${NC} $*"; } +warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; } +error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; } + +is_container() { + [ -f "/.dockerenv" ] && return 0 + + if [ -r /proc/1/cgroup ] && grep -Eq "(docker|containerd|kubepods)" /proc/1/cgroup 2>/dev/null; then + return 0 + fi + + return 1 +} + +is_wsl() { + grep -qi microsoft /proc/version 2>/dev/null +} + +is_msys() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +to_windows_path() { + target_path="$1" + + if is_wsl && command -v wslpath >/dev/null 2>&1; then + wslpath -w "$target_path" + return 0 + fi + + if is_msys && command -v cygpath >/dev/null 2>&1; then + cygpath -aw "$target_path" + return 0 + fi + + printf '%s\n' "$target_path" +} + +DEFAULT_VENV_DIR="$SCRIPT_DIR/.venv" +DEFAULT_VENV_DISPLAY_PATH="./server/.venv" +if [ -n "${SERVER_VENV_DIR:-}" ]; then + VENV_DIR="$SERVER_VENV_DIR" + VENV_DISPLAY_PATH="$SERVER_VENV_DIR" +elif is_container; then + VENV_DIR="/tmp/x-financial-server-venv" + VENV_DISPLAY_PATH="$VENV_DIR" +else + VENV_DIR="$DEFAULT_VENV_DIR" + VENV_DISPLAY_PATH="$DEFAULT_VENV_DISPLAY_PATH" +fi if [ ! -f "$ROOT_ENV_FILE" ]; then if [ -f "$ROOT_ENV_EXAMPLE_FILE" ]; then @@ -29,10 +87,41 @@ if [ ! -f "$ROOT_ENV_FILE" ]; then fi fi +ENV_OVERRIDE_SERVER_HOST_SET=false +ENV_OVERRIDE_POSTGRES_HOST_SET=false +ENV_OVERRIDE_DATABASE_URL_SET=false + +if [ "${SERVER_HOST+x}" = x ]; then + ENV_OVERRIDE_SERVER_HOST_SET=true + ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" +fi + +if [ "${POSTGRES_HOST+x}" = x ]; then + ENV_OVERRIDE_POSTGRES_HOST_SET=true + ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST" +fi + +if [ "${DATABASE_URL+x}" = x ]; then + ENV_OVERRIDE_DATABASE_URL_SET=true + ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL" +fi + set -a . "$ROOT_ENV_FILE" set +a +if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then + SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" +fi + +if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then + POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST" +fi + +if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then + DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL" +fi + SERVER_HOST="${SERVER_HOST:-0.0.0.0}" SERVER_PORT="${SERVER_PORT:-8000}" DEFAULT_SERVER_RELOAD="false" @@ -49,17 +138,6 @@ fi SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}" -is_wsl() { - grep -qi microsoft /proc/version 2>/dev/null -} - -is_msys() { - case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) return 0 ;; - *) return 1 ;; - esac -} - needs_windows_python() { is_msys || is_wsl } @@ -119,11 +197,18 @@ pip_ready() { } create_venv() { - info "Creating virtual environment at ./server/.venv" + info "Creating virtual environment at $VENV_DISPLAY_PATH" if [ -d "$VENV_DIR" ]; then rm -rf "$VENV_DIR" fi - run_bootstrap_python -m venv "$VENV_DIR" + mkdir -p "$(dirname "$VENV_DIR")" + + venv_target="$VENV_DIR" + if [ "$PYTHON_BOOTSTRAP_IS_WINDOWS" = "true" ]; then + venv_target="$(to_windows_path "$VENV_DIR")" + fi + + run_bootstrap_python -m venv "$venv_target" if ! PYTHON_BIN="$(venv_python_path)"; then error "Virtual environment was not created successfully." @@ -155,10 +240,13 @@ ensure_pip() { } ensure_python_bootstrap() { + PYTHON_BOOTSTRAP_IS_WINDOWS="false" + if needs_windows_python; then if find_windows_python >/dev/null 2>&1; then PYTHON_BOOTSTRAP="$(find_windows_python)" - info "Detected Windows bash environment — using Windows Python" + PYTHON_BOOTSTRAP_IS_WINDOWS="true" + info "Detected Windows bash environment, using Windows Python" return 0 fi @@ -179,6 +267,10 @@ ensure_python_bootstrap() { ensure_dependencies() { ensure_python_bootstrap + if is_container && [ "$VENV_DIR" != "$DEFAULT_VENV_DIR" ]; then + info "Docker runtime detected, using isolated Python environment at $VENV_DISPLAY_PATH" + fi + if ! PYTHON_BIN="$(venv_python_path)"; then warn "Python virtual environment not found" create_venv diff --git a/server/src/x_financial_server.egg-info/SOURCES.txt b/server/src/x_financial_server.egg-info/SOURCES.txt index 7a7ec76..3211542 100644 --- a/server/src/x_financial_server.egg-info/SOURCES.txt +++ b/server/src/x_financial_server.egg-info/SOURCES.txt @@ -8,14 +8,19 @@ src/app/api/router.py src/app/api/v1/__init__.py src/app/api/v1/router.py src/app/api/v1/endpoints/__init__.py +src/app/api/v1/endpoints/auth.py src/app/api/v1/endpoints/bootstrap.py src/app/api/v1/endpoints/employees.py src/app/api/v1/endpoints/health.py src/app/api/v1/endpoints/reimbursements.py +src/app/api/v1/endpoints/settings.py src/app/core/__init__.py +src/app/core/admin_secret.py src/app/core/bootstrap.py src/app/core/config.py src/app/core/logging.py +src/app/core/secret_box.py +src/app/core/security.py src/app/db/__init__.py src/app/db/base.py src/app/db/base_class.py @@ -25,20 +30,37 @@ src/app/middleware/logging.py src/app/models/__init__.py src/app/models/approval.py src/app/models/employee.py +src/app/models/employee_change_log.py +src/app/models/organization.py src/app/models/reimbursement.py +src/app/models/role.py +src/app/models/system_model_setting.py +src/app/models/system_setting.py +src/app/models/system_setting_secret.py src/app/repositories/__init__.py src/app/repositories/employee.py src/app/repositories/reimbursement.py +src/app/repositories/settings.py src/app/schemas/__init__.py +src/app/schemas/auth.py src/app/schemas/bootstrap.py src/app/schemas/employee.py src/app/schemas/reimbursement.py +src/app/schemas/settings.py src/app/services/__init__.py +src/app/services/auth.py src/app/services/employee.py +src/app/services/employee_seed.py +src/app/services/model_connectivity.py src/app/services/reimbursement.py +src/app/services/settings.py src/x_financial_server.egg-info/PKG-INFO src/x_financial_server.egg-info/SOURCES.txt src/x_financial_server.egg-info/dependency_links.txt src/x_financial_server.egg-info/requires.txt src/x_financial_server.egg-info/top_level.txt -tests/test_imports.py \ No newline at end of file +tests/test_auth_service.py +tests/test_employee_service.py +tests/test_imports.py +tests/test_settings_persistence.py +tests/test_settings_service.py \ No newline at end of file diff --git a/start.sh b/start.sh index 3ba4fe8..154d94a 100755 --- a/start.sh +++ b/start.sh @@ -1,9 +1,18 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env sh +set -eu + +if (set -o pipefail) >/dev/null 2>&1; then + set -o pipefail +fi export MSYS_NO_PATHCONV=1 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="$0" +case "$SCRIPT_PATH" in + /*) ;; + *) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;; +esac +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)" ENV_FILE="$SCRIPT_DIR/.env" ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example" ADMIN_SECRET_FILE="$SCRIPT_DIR/server/.secrets/admin.json" @@ -14,9 +23,9 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -info() { echo -e "${GREEN}[INFO]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +info() { printf '%b\n' "${GREEN}[INFO]${NC} $*"; } +warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; } +error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; } if [ ! -f "$ENV_FILE" ]; then if [ -f "$ENV_EXAMPLE_FILE" ]; then @@ -27,15 +36,49 @@ if [ ! -f "$ENV_FILE" ]; then fi fi +ENV_OVERRIDE_WEB_HOST_SET=false +ENV_OVERRIDE_SERVER_HOST_SET=false + +if [ "${WEB_HOST+x}" = x ]; then + ENV_OVERRIDE_WEB_HOST_SET=true + ENV_OVERRIDE_WEB_HOST="$WEB_HOST" +fi + +if [ "${SERVER_HOST+x}" = x ]; then + ENV_OVERRIDE_SERVER_HOST_SET=true + ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" +fi + set -a . "$ENV_FILE" set +a +if [ "$ENV_OVERRIDE_WEB_HOST_SET" = true ]; then + WEB_HOST="$ENV_OVERRIDE_WEB_HOST" +fi + +if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then + SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" +fi + SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}" SETUP_COMPLETED="${SETUP_COMPLETED:-false}" APP_DEBUG="${APP_DEBUG:-true}" APP_ENV="${APP_ENV:-local}" SERVER_RELOAD="${SERVER_RELOAD:-}" +DEFAULT_SERVER_RELOAD="false" + +case "$APP_ENV" in + local|dev|development) + DEFAULT_SERVER_RELOAD="true" + ;; +esac + +if [ "$APP_DEBUG" = "true" ]; then + DEFAULT_SERVER_RELOAD="true" +fi + +EFFECTIVE_SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}" setup_ready() { [ "$SETUP_COMPLETED" = "true" ] && [ -f "$ADMIN_SECRET_FILE" ] @@ -75,16 +118,16 @@ server_probe_python() { } probe_server_health() { - local probe_url="${1:-$(server_probe_url)}" - local probe_python="" + _probe_url="${1:-$(server_probe_url)}" + _probe_python="" - if probe_python="$(server_probe_python)"; then - "$probe_python" -c "import json, sys, urllib.request; data = json.load(urllib.request.urlopen(sys.argv[1], timeout=2)); raise SystemExit(0 if data.get('status') == 'ok' else 1)" "$probe_url" >/dev/null 2>&1 + if _probe_python="$(server_probe_python)"; then + "$_probe_python" -c "import json, sys, urllib.request; data = json.load(urllib.request.urlopen(sys.argv[1], timeout=2)); raise SystemExit(0 if data.get('status') == 'ok' else 1)" "$_probe_url" >/dev/null 2>&1 return $? fi if command -v curl >/dev/null 2>&1; then - curl --silent --fail --max-time 2 "$probe_url" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' + curl --silent --fail --max-time 2 "$_probe_url" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' return $? fi @@ -92,16 +135,16 @@ probe_server_health() { } probe_server_smoke() { - local probe_url="${1:-$(server_smoke_url)}" - local probe_python="" + _probe_url="${1:-$(server_smoke_url)}" + _probe_python="" - if probe_python="$(server_probe_python)"; then - "$probe_python" -c "import sys, urllib.request; response = urllib.request.urlopen(sys.argv[1], timeout=3); raise SystemExit(0 if response.status == 200 else 1)" "$probe_url" >/dev/null 2>&1 + if _probe_python="$(server_probe_python)"; then + "$_probe_python" -c "import sys, urllib.request; response = urllib.request.urlopen(sys.argv[1], timeout=3); raise SystemExit(0 if response.status == 200 else 1)" "$_probe_url" >/dev/null 2>&1 return $? fi if command -v curl >/dev/null 2>&1; then - curl --silent --fail --max-time 3 "$probe_url" >/dev/null 2>&1 + curl --silent --fail --max-time 3 "$_probe_url" >/dev/null 2>&1 return $? fi @@ -109,10 +152,10 @@ probe_server_smoke() { } probe_server_ready() { - local health_url="${1:-$(server_probe_url)}" - local smoke_url="${2:-$(server_smoke_url)}" + _health_url="${1:-$(server_probe_url)}" + _smoke_url="${2:-$(server_smoke_url)}" - probe_server_health "$health_url" && probe_server_smoke "$smoke_url" + probe_server_health "$_health_url" && probe_server_smoke "$_smoke_url" } prepare_web() { @@ -154,10 +197,10 @@ start_setup_web() { } start_all() { - local server_pid="" - local started_server=false - local probe_url="" - local smoke_url="" + server_pid="" + started_server=false + probe_url="" + smoke_url="" prepare_server @@ -176,7 +219,7 @@ start_all() { if probe_server_ready "$probe_url" "$smoke_url"; then warn "FastAPI is already ready at $probe_url. Reusing the existing backend process." - if [ "$APP_DEBUG" = "true" ] && [ "$SERVER_RELOAD" != "true" ]; then + if [ "$APP_DEBUG" = "true" ] && [ "$EFFECTIVE_SERVER_RELOAD" != "true" ]; then warn "This backend may be stale because SERVER_RELOAD is disabled. If new API routes are missing, stop the old backend process and rerun ./start.sh." fi elif probe_server_health "$probe_url"; then @@ -192,8 +235,8 @@ start_all() { fi wait_for_server_ready() { - local attempt=1 - local max_attempts="$SERVER_STARTUP_TIMEOUT" + attempt=1 + max_attempts="$SERVER_STARTUP_TIMEOUT" info "Waiting for FastAPI readiness before starting the web frontend..." diff --git a/web/src/assets/styles/views/policies-view.css b/web/src/assets/styles/views/policies-view.css index f8efa87..ce8e6c0 100644 --- a/web/src/assets/styles/views/policies-view.css +++ b/web/src/assets/styles/views/policies-view.css @@ -315,44 +315,123 @@ th { .list-foot { display: grid; - grid-template-columns: auto auto 1fr auto; + grid-template-columns: 1fr auto 1fr; align-items: center; - gap: 10px; - color: #64748b; - font-size: 13px; -} - -.list-foot button { - min-height: 32px; - border: 1px solid #d7e0ea; - border-radius: 8px; - background: #fff; - color: #334155; - font-weight: 750; + gap: 16px; + margin-top: 8px; } .pager { display: inline-flex; + justify-content: center; gap: 6px; + padding: 4px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #f8fafc; } .pager button { width: 32px; + height: 32px; padding: 0; + border: 0; + border-radius: 9px; + background: transparent; + color: #334155; + font-size: 14px; + font-weight: 800; + transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + +.pager button:hover:not(.active) { + background: #fff; + color: #059669; + box-shadow: 0 1px 4px rgba(15, 23, 42, .08); } .pager button.active { - border-color: #059669; background: #059669; color: #fff; + box-shadow: 0 8px 16px rgba(5, 150, 105, .20); } -.list-foot input { - width: 42px; - height: 30px; +.list-foot .page-summary { + color: #64748b; + font-size: 14px; + font-weight: 650; +} + +.page-nav { + color: #64748b; +} + +.page-size-wrap { + position: relative; + justify-self: end; +} + +.page-size { + justify-self: end; + min-width: 112px; + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 14px; border: 1px solid #d7e0ea; - border-radius: 7px; - text-align: center; + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); + color: #334155; + font-size: 14px; + font-weight: 750; + white-space: nowrap; + transition: border-color 160ms ease, color 160ms ease; +} + +.page-size:hover { + border-color: rgba(16, 185, 129, .32); + color: #0f9f78; +} + +.page-size-dropdown { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + z-index: 40; + display: grid; + border: 1px solid #d7e0ea; + border-radius: 10px; + background: #fff; + box-shadow: 0 12px 32px rgba(15, 23, 42, .14); + overflow: hidden; +} + +.page-size-dropdown button { + height: 36px; + display: grid; + place-items: center; + border: 0; + border-radius: 0; + background: transparent; + color: #334155; + font-size: 13px; + font-weight: 750; + white-space: nowrap; + padding: 0 20px; + transition: background 120ms ease, color 120ms ease; +} + +.page-size-dropdown button:hover { + background: #f0fdf4; + color: #059669; +} + +.page-size-dropdown button.active { + background: #059669; + color: #fff; } .preview-column { @@ -640,4 +719,15 @@ th { .list-foot { grid-template-columns: 1fr; } + + .list-foot { + gap: 12px; + justify-items: stretch; + } + + .pager, + .page-size-wrap, + .page-size { + justify-self: stretch; + } } diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index f50888b..d57257a 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -27,26 +27,8 @@ const authIdleTimeoutMs = ? authIdleTimeoutMinutes * 60 * 1000 : 30 * 60 * 1000 -function resolveBrowserApiBaseUrl(state) { - const server = state?.server || {} - const configuredHost = String(server.host || '127.0.0.1').trim() - const port = Number(server.port || 8000) - const apiPrefix = String(import.meta.env.VITE_API_BASE_PREFIX || '/api/v1').replace(/\/$/, '') || '/api/v1' - - if (typeof window === 'undefined') { - return `http://${configuredHost}:${port}${apiPrefix}` - } - - const browserHost = window.location.hostname - const normalizedHost = configuredHost.toLowerCase() - const host = - normalizedHost === '0.0.0.0' || - normalizedHost === '::' || - (normalizedHost === '127.0.0.1' && browserHost && browserHost !== '127.0.0.1' && browserHost !== 'localhost') - ? browserHost - : configuredHost - - return `http://${host}:${port}${apiPrefix}` +function resolveBrowserApiBaseUrl() { + return '/api/v1' } let sessionRouter = null diff --git a/web/src/services/api.js b/web/src/services/api.js index e18a1cc..1685ac6 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -39,9 +39,11 @@ function readStoredApiBaseUrl() { return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '') } -let runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl( - readStoredApiBaseUrl() || import.meta.env.VITE_API_BASE_URL || '/api/v1' -) +let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1') + +if (typeof window !== 'undefined') { + window.localStorage.removeItem(API_BASE_STORAGE_KEY) +} export function setRuntimeApiBaseUrl(value) { runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value) diff --git a/web/src/views/PoliciesView.vue b/web/src/views/PoliciesView.vue index 5b98743..8b58888 100644 --- a/web/src/views/PoliciesView.vue +++ b/web/src/views/PoliciesView.vue @@ -63,7 +63,7 @@ diff --git a/web/src/views/scripts/PoliciesView.js b/web/src/views/scripts/PoliciesView.js index 256d199..0741de7 100644 --- a/web/src/views/scripts/PoliciesView.js +++ b/web/src/views/scripts/PoliciesView.js @@ -1,5 +1,7 @@ import { computed, ref } from 'vue' +import { watch } from 'vue' + export default { name: 'PoliciesView' , setup(props, { emit }) { @@ -230,6 +232,30 @@ export default { }) ) + const currentPage = ref(1) + const pageSize = ref(10) + const pageSizes = [10, 20, 50] + const pageSizeOpen = ref(false) + + const totalCount = computed(() => filteredDocuments.value.length) + const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value))) + + const visibleDocuments = computed(() => { + const start = (currentPage.value - 1) * pageSize.value + return filteredDocuments.value.slice(start, start + pageSize.value) + }) + + function changePageSize(size) { + pageSize.value = size + pageSizeOpen.value = false + currentPage.value = 1 + } + + watch(filteredDocuments, () => { + currentPage.value = 1 + pageSizeOpen.value = false + }) + return { folderSearch, activeFolder, @@ -237,7 +263,15 @@ export default { folders, documents, filteredFolders, - filteredDocuments + filteredDocuments, + currentPage, + pageSize, + pageSizes, + pageSizeOpen, + totalCount, + totalPages, + visibleDocuments, + changePageSize } } } diff --git a/web/vite.config.js b/web/vite.config.js index cf7506e..5008f5d 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -106,9 +106,36 @@ function parseEnv(text) { return result } +const envOverridePrefixes = ['APP_', 'WEB_', 'SERVER_', 'POSTGRES_', 'VITE_', 'LOG_'] +const envOverrideKeys = new Set([ + 'API_V1_PREFIX', + 'SETUP_COMPLETED', + 'COMPANY_NAME', + 'COMPANY_CODE', + 'ADMIN_EMAIL', + 'DATABASE_URL', + 'SQLALCHEMY_ECHO', + 'REDIS_URL', + 'CORS_ORIGINS' +]) + +function shouldOverlayEnvKey(key) { + return envOverrideKeys.has(key) || envOverridePrefixes.some((prefix) => key.startsWith(prefix)) +} + function readEnvState() { ensureEnvFile() - return parseEnv(fs.readFileSync(envFile, 'utf8')) + const state = parseEnv(fs.readFileSync(envFile, 'utf8')) + + for (const [key, value] of Object.entries(process.env)) { + if (!shouldOverlayEnvKey(key) || value == null || value === '') { + continue + } + + state[key] = String(value) + } + + return state } function readAdminSecret() { @@ -334,9 +361,7 @@ function buildCorsOrigins(payload) { function buildApiBaseUrl(payload, currentEnv) { const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1' - const host = resolveBrowserApiHost(payload.server_host, payload.web_host) - const port = String(payload.server_port || '').trim() - return `http://${host}:${port}${apiPrefix}` + return apiPrefix } function buildServerHealthUrl(env) { @@ -586,9 +611,21 @@ async function loadPgClient() { async function testDatabaseConnection(payload) { const Client = await loadPgClient() + const requestedHost = String(payload.postgres_host || '').trim() + const requestedHostNormalized = normalizeLoopbackHost(requestedHost) + const dockerPostgresHost = String(process.env.POSTGRES_HOST || '').trim() + const containerPostgresPort = Number(process.env.POSTGRES_PORT || 5432) + const shouldUseDockerPostgres = + dockerPostgresHost === 'postgres' && + ['127.0.0.1', 'localhost', '0.0.0.0', '::1', '::'].includes(requestedHostNormalized) + const effectiveHost = + shouldUseDockerPostgres ? 'postgres' : requestedHost + const effectivePort = + shouldUseDockerPostgres ? containerPostgresPort : Number(payload.postgres_port) + const client = new Client({ - host: String(payload.postgres_host || '').trim(), - port: Number(payload.postgres_port), + host: effectiveHost, + port: effectivePort, database: String(payload.postgres_db || '').trim(), user: String(payload.postgres_user || '').trim(), password: String(payload.postgres_password || ''), @@ -738,9 +775,21 @@ async function startBackendAndWait() { const stdout = fs.openSync(logFile, 'a') const stderr = fs.openSync(logFile, 'a') + const freshEnv = { ...process.env } + const envFileContent = fs.readFileSync(envFile, 'utf-8') + for (const line of envFileContent.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx < 0) continue + const key = trimmed.slice(0, eqIdx).trim() + const val = trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, '') + freshEnv[key] = val + } const child = spawn('bash', [path.join(rootDir, 'start.sh'), 'server'], { cwd: rootDir, detached: true, + env: freshEnv, stdio: ['ignore', stdout, stderr] }) @@ -980,6 +1029,12 @@ export default defineConfig({ envExampleFile, path.join(rootDir, 'server', 'logs', '**') ] + }, + proxy: { + '/api': { + target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`, + changeOrigin: true + } } }, plugins: [vue(), localSetupPlugin()] diff --git a/web/web_start.sh b/web/web_start.sh index 5218c02..7ce2e41 100755 --- a/web/web_start.sh +++ b/web/web_start.sh @@ -1,9 +1,18 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env sh +set -eu + +if (set -o pipefail) >/dev/null 2>&1; then + set -o pipefail +fi export MSYS_NO_PATHCONV=1 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_PATH="$0" +case "$SCRIPT_PATH" in + /*) ;; + *) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;; +esac +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)" cd "$SCRIPT_DIR" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT_ENV_FILE="$ROOT_DIR/.env" @@ -14,14 +23,45 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -info() { echo -e "${GREEN}[INFO]${NC} $*"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +info() { printf '%b\n' "${GREEN}[INFO]${NC} $*"; } +warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; } +error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; } if [ -f "$ROOT_ENV_FILE" ]; then + ENV_OVERRIDE_WEB_HOST_SET=false + ENV_OVERRIDE_SERVER_HOST_SET=false + ENV_OVERRIDE_POSTGRES_HOST_SET=false + + if [ "${WEB_HOST+x}" = x ]; then + ENV_OVERRIDE_WEB_HOST_SET=true + ENV_OVERRIDE_WEB_HOST="$WEB_HOST" + fi + + if [ "${SERVER_HOST+x}" = x ]; then + ENV_OVERRIDE_SERVER_HOST_SET=true + ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" + fi + + if [ "${POSTGRES_HOST+x}" = x ]; then + ENV_OVERRIDE_POSTGRES_HOST_SET=true + ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST" + fi + set -a . "$ROOT_ENV_FILE" set +a + + if [ "$ENV_OVERRIDE_WEB_HOST_SET" = true ]; then + WEB_HOST="$ENV_OVERRIDE_WEB_HOST" + fi + + if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then + SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" + fi + + if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then + POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST" + fi fi if [ "${X_FINANCIAL_FORCE_SETUP:-false}" = "true" ]; then @@ -66,26 +106,24 @@ windows_project_path() { wslpath -w "$SCRIPT_DIR" } +shell_quote_single() { + printf "%s" "$1" | sed "s/'/''/g" +} + run_windows_powershell() { - local command="$1" - powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$command" + powershell_command="$1" + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$powershell_command" } run_windows_npm_install() { - local win_path - local win_path_ps - win_path="$(windows_project_path)" - win_path_ps="${win_path//\'/\'\'}" + win_path_ps="$(shell_quote_single "$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//\'/\'\'}" + win_path_ps="$(shell_quote_single "$win_path")" exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$win_path_ps'; npm start -- --host $WEB_HOST --port $WEB_PORT" } @@ -96,11 +134,8 @@ dependencies_ready() { [ -f "node_modules/vue-router/package.json" ] || return 1 if use_windows_npm; then - local win_path - local win_path_ps - win_path="$(windows_project_path)" - win_path_ps="${win_path//\'/\'\'}" + win_path_ps="$(shell_quote_single "$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