fix: 修复 Docker 部署 API 地址与数据库连接问题
This commit is contained in:
@@ -24,7 +24,7 @@ VITE_SERVER_HOST=0.0.0.0
|
|||||||
VITE_SERVER_PORT=8000
|
VITE_SERVER_PORT=8000
|
||||||
SERVER_STARTUP_TIMEOUT=300
|
SERVER_STARTUP_TIMEOUT=300
|
||||||
SERVER_BLOCKING_STARTUP_TIMEOUT=12
|
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
|
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
|
||||||
|
|
||||||
POSTGRES_HOST=127.0.0.1
|
POSTGRES_HOST=127.0.0.1
|
||||||
|
|||||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
.env text eol=lf
|
||||||
|
.env.example text eol=lf
|
||||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal 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
63
docker/README.md
Normal 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.
|
||||||
30
document/work-log/2026-05-08.md
Normal file
30
document/work-log/2026-05-08.md
Normal 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 输入体验,聚焦时自动清除遮罩显示,并提示已从数据库加载密钥
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env sh
|
||||||
set -euo pipefail
|
set -eu
|
||||||
|
|
||||||
|
if (set -o pipefail) >/dev/null 2>&1; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
export MSYS_NO_PATHCONV=1
|
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"
|
cd "$SCRIPT_DIR"
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
ROOT_ENV_FILE="$ROOT_DIR/.env"
|
ROOT_ENV_FILE="$ROOT_DIR/.env"
|
||||||
ROOT_ENV_EXAMPLE_FILE="$ROOT_DIR/.env.example"
|
ROOT_ENV_EXAMPLE_FILE="$ROOT_DIR/.env.example"
|
||||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
|
||||||
MODE="${1:-start}"
|
MODE="${1:-start}"
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -16,9 +24,59 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
info() { printf '%b\n' "${GREEN}[INFO]${NC} $*"; }
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; }
|
||||||
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
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_FILE" ]; then
|
||||||
if [ -f "$ROOT_ENV_EXAMPLE_FILE" ]; then
|
if [ -f "$ROOT_ENV_EXAMPLE_FILE" ]; then
|
||||||
@@ -29,10 +87,41 @@ if [ ! -f "$ROOT_ENV_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
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
|
set -a
|
||||||
. "$ROOT_ENV_FILE"
|
. "$ROOT_ENV_FILE"
|
||||||
set +a
|
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_HOST="${SERVER_HOST:-0.0.0.0}"
|
||||||
SERVER_PORT="${SERVER_PORT:-8000}"
|
SERVER_PORT="${SERVER_PORT:-8000}"
|
||||||
DEFAULT_SERVER_RELOAD="false"
|
DEFAULT_SERVER_RELOAD="false"
|
||||||
@@ -49,17 +138,6 @@ fi
|
|||||||
|
|
||||||
SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}"
|
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() {
|
needs_windows_python() {
|
||||||
is_msys || is_wsl
|
is_msys || is_wsl
|
||||||
}
|
}
|
||||||
@@ -119,11 +197,18 @@ pip_ready() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create_venv() {
|
create_venv() {
|
||||||
info "Creating virtual environment at ./server/.venv"
|
info "Creating virtual environment at $VENV_DISPLAY_PATH"
|
||||||
if [ -d "$VENV_DIR" ]; then
|
if [ -d "$VENV_DIR" ]; then
|
||||||
rm -rf "$VENV_DIR"
|
rm -rf "$VENV_DIR"
|
||||||
fi
|
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
|
if ! PYTHON_BIN="$(venv_python_path)"; then
|
||||||
error "Virtual environment was not created successfully."
|
error "Virtual environment was not created successfully."
|
||||||
@@ -155,10 +240,13 @@ ensure_pip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_python_bootstrap() {
|
ensure_python_bootstrap() {
|
||||||
|
PYTHON_BOOTSTRAP_IS_WINDOWS="false"
|
||||||
|
|
||||||
if needs_windows_python; then
|
if needs_windows_python; then
|
||||||
if find_windows_python >/dev/null 2>&1; then
|
if find_windows_python >/dev/null 2>&1; then
|
||||||
PYTHON_BOOTSTRAP="$(find_windows_python)"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -179,6 +267,10 @@ ensure_python_bootstrap() {
|
|||||||
ensure_dependencies() {
|
ensure_dependencies() {
|
||||||
ensure_python_bootstrap
|
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
|
if ! PYTHON_BIN="$(venv_python_path)"; then
|
||||||
warn "Python virtual environment not found"
|
warn "Python virtual environment not found"
|
||||||
create_venv
|
create_venv
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ src/app/api/router.py
|
|||||||
src/app/api/v1/__init__.py
|
src/app/api/v1/__init__.py
|
||||||
src/app/api/v1/router.py
|
src/app/api/v1/router.py
|
||||||
src/app/api/v1/endpoints/__init__.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/bootstrap.py
|
||||||
src/app/api/v1/endpoints/employees.py
|
src/app/api/v1/endpoints/employees.py
|
||||||
src/app/api/v1/endpoints/health.py
|
src/app/api/v1/endpoints/health.py
|
||||||
src/app/api/v1/endpoints/reimbursements.py
|
src/app/api/v1/endpoints/reimbursements.py
|
||||||
|
src/app/api/v1/endpoints/settings.py
|
||||||
src/app/core/__init__.py
|
src/app/core/__init__.py
|
||||||
|
src/app/core/admin_secret.py
|
||||||
src/app/core/bootstrap.py
|
src/app/core/bootstrap.py
|
||||||
src/app/core/config.py
|
src/app/core/config.py
|
||||||
src/app/core/logging.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/__init__.py
|
||||||
src/app/db/base.py
|
src/app/db/base.py
|
||||||
src/app/db/base_class.py
|
src/app/db/base_class.py
|
||||||
@@ -25,20 +30,37 @@ src/app/middleware/logging.py
|
|||||||
src/app/models/__init__.py
|
src/app/models/__init__.py
|
||||||
src/app/models/approval.py
|
src/app/models/approval.py
|
||||||
src/app/models/employee.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/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/__init__.py
|
||||||
src/app/repositories/employee.py
|
src/app/repositories/employee.py
|
||||||
src/app/repositories/reimbursement.py
|
src/app/repositories/reimbursement.py
|
||||||
|
src/app/repositories/settings.py
|
||||||
src/app/schemas/__init__.py
|
src/app/schemas/__init__.py
|
||||||
|
src/app/schemas/auth.py
|
||||||
src/app/schemas/bootstrap.py
|
src/app/schemas/bootstrap.py
|
||||||
src/app/schemas/employee.py
|
src/app/schemas/employee.py
|
||||||
src/app/schemas/reimbursement.py
|
src/app/schemas/reimbursement.py
|
||||||
|
src/app/schemas/settings.py
|
||||||
src/app/services/__init__.py
|
src/app/services/__init__.py
|
||||||
|
src/app/services/auth.py
|
||||||
src/app/services/employee.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/reimbursement.py
|
||||||
|
src/app/services/settings.py
|
||||||
src/x_financial_server.egg-info/PKG-INFO
|
src/x_financial_server.egg-info/PKG-INFO
|
||||||
src/x_financial_server.egg-info/SOURCES.txt
|
src/x_financial_server.egg-info/SOURCES.txt
|
||||||
src/x_financial_server.egg-info/dependency_links.txt
|
src/x_financial_server.egg-info/dependency_links.txt
|
||||||
src/x_financial_server.egg-info/requires.txt
|
src/x_financial_server.egg-info/requires.txt
|
||||||
src/x_financial_server.egg-info/top_level.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
|
||||||
95
start.sh
95
start.sh
@@ -1,9 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env sh
|
||||||
set -euo pipefail
|
set -eu
|
||||||
|
|
||||||
|
if (set -o pipefail) >/dev/null 2>&1; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
export MSYS_NO_PATHCONV=1
|
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_FILE="$SCRIPT_DIR/.env"
|
||||||
ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example"
|
ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example"
|
||||||
ADMIN_SECRET_FILE="$SCRIPT_DIR/server/.secrets/admin.json"
|
ADMIN_SECRET_FILE="$SCRIPT_DIR/server/.secrets/admin.json"
|
||||||
@@ -14,9 +23,9 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
info() { printf '%b\n' "${GREEN}[INFO]${NC} $*"; }
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; }
|
||||||
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
if [ -f "$ENV_EXAMPLE_FILE" ]; then
|
if [ -f "$ENV_EXAMPLE_FILE" ]; then
|
||||||
@@ -27,15 +36,49 @@ if [ ! -f "$ENV_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
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
|
set -a
|
||||||
. "$ENV_FILE"
|
. "$ENV_FILE"
|
||||||
set +a
|
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}"
|
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
|
||||||
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
||||||
APP_DEBUG="${APP_DEBUG:-true}"
|
APP_DEBUG="${APP_DEBUG:-true}"
|
||||||
APP_ENV="${APP_ENV:-local}"
|
APP_ENV="${APP_ENV:-local}"
|
||||||
SERVER_RELOAD="${SERVER_RELOAD:-}"
|
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_ready() {
|
||||||
[ "$SETUP_COMPLETED" = "true" ] && [ -f "$ADMIN_SECRET_FILE" ]
|
[ "$SETUP_COMPLETED" = "true" ] && [ -f "$ADMIN_SECRET_FILE" ]
|
||||||
@@ -75,16 +118,16 @@ server_probe_python() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
probe_server_health() {
|
probe_server_health() {
|
||||||
local probe_url="${1:-$(server_probe_url)}"
|
_probe_url="${1:-$(server_probe_url)}"
|
||||||
local probe_python=""
|
_probe_python=""
|
||||||
|
|
||||||
if probe_python="$(server_probe_python)"; then
|
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
|
"$_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 $?
|
return $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
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 $?
|
return $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -92,16 +135,16 @@ probe_server_health() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
probe_server_smoke() {
|
probe_server_smoke() {
|
||||||
local probe_url="${1:-$(server_smoke_url)}"
|
_probe_url="${1:-$(server_smoke_url)}"
|
||||||
local probe_python=""
|
_probe_python=""
|
||||||
|
|
||||||
if probe_python="$(server_probe_python)"; then
|
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
|
"$_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 $?
|
return $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
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 $?
|
return $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -109,10 +152,10 @@ probe_server_smoke() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
probe_server_ready() {
|
probe_server_ready() {
|
||||||
local health_url="${1:-$(server_probe_url)}"
|
_health_url="${1:-$(server_probe_url)}"
|
||||||
local smoke_url="${2:-$(server_smoke_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() {
|
prepare_web() {
|
||||||
@@ -154,10 +197,10 @@ start_setup_web() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start_all() {
|
start_all() {
|
||||||
local server_pid=""
|
server_pid=""
|
||||||
local started_server=false
|
started_server=false
|
||||||
local probe_url=""
|
probe_url=""
|
||||||
local smoke_url=""
|
smoke_url=""
|
||||||
|
|
||||||
prepare_server
|
prepare_server
|
||||||
|
|
||||||
@@ -176,7 +219,7 @@ start_all() {
|
|||||||
|
|
||||||
if probe_server_ready "$probe_url" "$smoke_url"; then
|
if probe_server_ready "$probe_url" "$smoke_url"; then
|
||||||
warn "FastAPI is already ready at $probe_url. Reusing the existing backend process."
|
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."
|
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
|
fi
|
||||||
elif probe_server_health "$probe_url"; then
|
elif probe_server_health "$probe_url"; then
|
||||||
@@ -192,8 +235,8 @@ start_all() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
wait_for_server_ready() {
|
wait_for_server_ready() {
|
||||||
local attempt=1
|
attempt=1
|
||||||
local max_attempts="$SERVER_STARTUP_TIMEOUT"
|
max_attempts="$SERVER_STARTUP_TIMEOUT"
|
||||||
|
|
||||||
info "Waiting for FastAPI readiness before starting the web frontend..."
|
info "Waiting for FastAPI readiness before starting the web frontend..."
|
||||||
|
|
||||||
|
|||||||
@@ -315,44 +315,123 @@ th {
|
|||||||
|
|
||||||
.list-foot {
|
.list-foot {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto auto 1fr auto;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 16px;
|
||||||
color: #64748b;
|
margin-top: 8px;
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-foot button {
|
|
||||||
min-height: 32px;
|
|
||||||
border: 1px solid #d7e0ea;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
color: #334155;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pager {
|
.pager {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pager button {
|
.pager button {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
padding: 0;
|
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 {
|
.pager button.active {
|
||||||
border-color: #059669;
|
|
||||||
background: #059669;
|
background: #059669;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-foot input {
|
.list-foot .page-summary {
|
||||||
width: 42px;
|
color: #64748b;
|
||||||
height: 30px;
|
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: 1px solid #d7e0ea;
|
||||||
border-radius: 7px;
|
border-radius: 10px;
|
||||||
text-align: center;
|
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 {
|
.preview-column {
|
||||||
@@ -640,4 +719,15 @@ th {
|
|||||||
.list-foot {
|
.list-foot {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-foot {
|
||||||
|
gap: 12px;
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager,
|
||||||
|
.page-size-wrap,
|
||||||
|
.page-size {
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,26 +27,8 @@ const authIdleTimeoutMs =
|
|||||||
? authIdleTimeoutMinutes * 60 * 1000
|
? authIdleTimeoutMinutes * 60 * 1000
|
||||||
: 30 * 60 * 1000
|
: 30 * 60 * 1000
|
||||||
|
|
||||||
function resolveBrowserApiBaseUrl(state) {
|
function resolveBrowserApiBaseUrl() {
|
||||||
const server = state?.server || {}
|
return '/api/v1'
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessionRouter = null
|
let sessionRouter = null
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ function readStoredApiBaseUrl() {
|
|||||||
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
|
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(
|
let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1')
|
||||||
readStoredApiBaseUrl() || import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
|
||||||
)
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.removeItem(API_BASE_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
export function setRuntimeApiBaseUrl(value) {
|
export function setRuntimeApiBaseUrl(value) {
|
||||||
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
|
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="doc in filteredDocuments"
|
v-for="doc in visibleDocuments"
|
||||||
:key="doc.name"
|
:key="doc.name"
|
||||||
class="doc-row"
|
class="doc-row"
|
||||||
:class="{ selected: selectedDocument?.name === doc.name }"
|
:class="{ selected: selectedDocument?.name === doc.name }"
|
||||||
@@ -93,6 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="list-foot">
|
<footer class="list-foot">
|
||||||
|
<template v-if="false">
|
||||||
<span>共 {{ filteredDocuments.length }} 条</span>
|
<span>共 {{ filteredDocuments.length }} 条</span>
|
||||||
<button type="button">10条/页 <i class="mdi mdi-chevron-down"></i></button>
|
<button type="button">10条/页 <i class="mdi mdi-chevron-down"></i></button>
|
||||||
<div class="pager" aria-label="分页">
|
<div class="pager" aria-label="分页">
|
||||||
@@ -102,6 +103,45 @@
|
|||||||
<button type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
|
<button type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<label>前往 <input value="1" aria-label="页码" /> 页</label>
|
<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>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PoliciesView' ,
|
name: 'PoliciesView' ,
|
||||||
setup(props, { emit }) {
|
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 {
|
return {
|
||||||
folderSearch,
|
folderSearch,
|
||||||
activeFolder,
|
activeFolder,
|
||||||
@@ -237,7 +263,15 @@ export default {
|
|||||||
folders,
|
folders,
|
||||||
documents,
|
documents,
|
||||||
filteredFolders,
|
filteredFolders,
|
||||||
filteredDocuments
|
filteredDocuments,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
pageSizes,
|
||||||
|
pageSizeOpen,
|
||||||
|
totalCount,
|
||||||
|
totalPages,
|
||||||
|
visibleDocuments,
|
||||||
|
changePageSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,9 +106,36 @@ function parseEnv(text) {
|
|||||||
return result
|
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() {
|
function readEnvState() {
|
||||||
ensureEnvFile()
|
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() {
|
function readAdminSecret() {
|
||||||
@@ -334,9 +361,7 @@ function buildCorsOrigins(payload) {
|
|||||||
|
|
||||||
function buildApiBaseUrl(payload, currentEnv) {
|
function buildApiBaseUrl(payload, currentEnv) {
|
||||||
const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1'
|
const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1'
|
||||||
const host = resolveBrowserApiHost(payload.server_host, payload.web_host)
|
return apiPrefix
|
||||||
const port = String(payload.server_port || '').trim()
|
|
||||||
return `http://${host}:${port}${apiPrefix}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildServerHealthUrl(env) {
|
function buildServerHealthUrl(env) {
|
||||||
@@ -586,9 +611,21 @@ async function loadPgClient() {
|
|||||||
|
|
||||||
async function testDatabaseConnection(payload) {
|
async function testDatabaseConnection(payload) {
|
||||||
const Client = await loadPgClient()
|
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({
|
const client = new Client({
|
||||||
host: String(payload.postgres_host || '').trim(),
|
host: effectiveHost,
|
||||||
port: Number(payload.postgres_port),
|
port: effectivePort,
|
||||||
database: String(payload.postgres_db || '').trim(),
|
database: String(payload.postgres_db || '').trim(),
|
||||||
user: String(payload.postgres_user || '').trim(),
|
user: String(payload.postgres_user || '').trim(),
|
||||||
password: String(payload.postgres_password || ''),
|
password: String(payload.postgres_password || ''),
|
||||||
@@ -738,9 +775,21 @@ async function startBackendAndWait() {
|
|||||||
|
|
||||||
const stdout = fs.openSync(logFile, 'a')
|
const stdout = fs.openSync(logFile, 'a')
|
||||||
const stderr = 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'], {
|
const child = spawn('bash', [path.join(rootDir, 'start.sh'), 'server'], {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
detached: true,
|
detached: true,
|
||||||
|
env: freshEnv,
|
||||||
stdio: ['ignore', stdout, stderr]
|
stdio: ['ignore', stdout, stderr]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -980,6 +1029,12 @@ export default defineConfig({
|
|||||||
envExampleFile,
|
envExampleFile,
|
||||||
path.join(rootDir, 'server', 'logs', '**')
|
path.join(rootDir, 'server', 'logs', '**')
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`,
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [vue(), localSetupPlugin()]
|
plugins: [vue(), localSetupPlugin()]
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env sh
|
||||||
set -euo pipefail
|
set -eu
|
||||||
|
|
||||||
|
if (set -o pipefail) >/dev/null 2>&1; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
export MSYS_NO_PATHCONV=1
|
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"
|
cd "$SCRIPT_DIR"
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
ROOT_ENV_FILE="$ROOT_DIR/.env"
|
ROOT_ENV_FILE="$ROOT_DIR/.env"
|
||||||
@@ -14,14 +23,45 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
info() { printf '%b\n' "${GREEN}[INFO]${NC} $*"; }
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
warn() { printf '%b\n' "${YELLOW}[WARN]${NC} $*"; }
|
||||||
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
error() { printf '%b\n' "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
if [ -f "$ROOT_ENV_FILE" ]; then
|
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
|
set -a
|
||||||
. "$ROOT_ENV_FILE"
|
. "$ROOT_ENV_FILE"
|
||||||
set +a
|
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
|
fi
|
||||||
|
|
||||||
if [ "${X_FINANCIAL_FORCE_SETUP:-false}" = "true" ]; then
|
if [ "${X_FINANCIAL_FORCE_SETUP:-false}" = "true" ]; then
|
||||||
@@ -66,26 +106,24 @@ windows_project_path() {
|
|||||||
wslpath -w "$SCRIPT_DIR"
|
wslpath -w "$SCRIPT_DIR"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shell_quote_single() {
|
||||||
|
printf "%s" "$1" | sed "s/'/''/g"
|
||||||
|
}
|
||||||
|
|
||||||
run_windows_powershell() {
|
run_windows_powershell() {
|
||||||
local command="$1"
|
powershell_command="$1"
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$command"
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$powershell_command"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_windows_npm_install() {
|
run_windows_npm_install() {
|
||||||
local win_path
|
|
||||||
local win_path_ps
|
|
||||||
|
|
||||||
win_path="$(windows_project_path)"
|
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_powershell "Set-Location -LiteralPath '$win_path_ps'; npm install"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_windows_npm_start() {
|
run_windows_npm_start() {
|
||||||
local win_path
|
|
||||||
local win_path_ps
|
|
||||||
|
|
||||||
win_path="$(windows_project_path)"
|
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"
|
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
|
[ -f "node_modules/vue-router/package.json" ] || return 1
|
||||||
|
|
||||||
if use_windows_npm; then
|
if use_windows_npm; then
|
||||||
local win_path
|
|
||||||
local win_path_ps
|
|
||||||
|
|
||||||
win_path="$(windows_project_path)"
|
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
|
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 $?
|
return $?
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user