fix: 修复 Docker 部署 API 地址与数据库连接问题

This commit is contained in:
2026-05-09 09:29:34 +08:00
parent 86568660a4
commit c2315f68dc
15 changed files with 665 additions and 119 deletions

View File

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

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
*.sh text eol=lf
.env text eol=lf
.env.example text eol=lf

55
docker-compose.yml Normal file
View File

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

63
docker/README.md Normal file
View File

@@ -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://<your-linux-host>: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.

View File

@@ -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 输入体验,聚焦时自动清除遮罩显示,并提示已从数据库加载密钥

View File

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

View File

@@ -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
tests/test_auth_service.py
tests/test_employee_service.py
tests/test_imports.py
tests/test_settings_persistence.py
tests/test_settings_service.py

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,7 @@
</thead>
<tbody>
<tr
v-for="doc in filteredDocuments"
v-for="doc in visibleDocuments"
:key="doc.name"
class="doc-row"
:class="{ selected: selectedDocument?.name === doc.name }"
@@ -93,6 +93,7 @@
</div>
<footer class="list-foot">
<template v-if="false">
<span> {{ filteredDocuments.length }} </span>
<button type="button">10/ <i class="mdi mdi-chevron-down"></i></button>
<div class="pager" aria-label="分页">
@@ -102,6 +103,45 @@
<button type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
</div>
<label>前往 <input value="1" aria-label="页码" /> </label>
</template>
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} /<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</section>
</div>

View File

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

View File

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

View File

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