diff --git a/docker-compose.yml b/docker-compose.yml index a140403..cf3e8f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -services: +services: main: image: x-financial-dev:latest container_name: x-financial-main @@ -6,14 +6,14 @@ services: depends_on: onlyoffice: condition: service_started - environment: - WEB_HOST: 0.0.0.0 - SERVER_HOST: 0.0.0.0 - SERVER_VENV_DIR: /tmp/x-financial-server-venv - ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}" - ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}" - ONLYOFFICE_BACKEND_URL: "${ONLYOFFICE_BACKEND_URL:-http://main:${SERVER_PORT:-8000}}" - ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}" + environment: + WEB_HOST: 0.0.0.0 + SERVER_HOST: 0.0.0.0 + SERVER_VENV_DIR: /tmp/x-financial-server-venv + ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}" + ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}" + ONLYOFFICE_BACKEND_URL: "${ONLYOFFICE_BACKEND_URL:-http://main:${SERVER_PORT:-8000}}" + ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}" ports: - "${WEB_PORT:-5173}:${WEB_PORT:-5173}" - "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}" @@ -38,25 +38,25 @@ services: chmod +x /app/start.sh /app/web/web_start.sh /app/server/server_start.sh && cd /app && ./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 - - onlyoffice: - image: onlyoffice/documentserver:latest - container_name: x-financial-onlyoffice - restart: unless-stopped - environment: - JWT_ENABLED: "true" - JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}" - ports: - - "${ONLYOFFICE_PORT:-8082}:80" - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"] - interval: 15s - timeout: 5s - retries: 10 - start_period: 60s + 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 + + onlyoffice: + image: onlyoffice/documentserver:latest + container_name: x-financial-onlyoffice + restart: unless-stopped + environment: + JWT_ENABLED: "true" + JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}" + ports: + - "${ONLYOFFICE_PORT:-8082}:80" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 60s diff --git a/docker/README.md b/docker/README.md index 9abd576..44417fe 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,67 +1,67 @@ -# 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 -- `onlyoffice`: ONLYOFFICE Document Server -- `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`. -- ONLYOFFICE is published to the host by default as `127.0.0.1:8082`. -- 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`. -- If you need to access ONLYOFFICE from another machine, override `ONLYOFFICE_PUBLIC_URL` - so the browser can reach the document server address you actually expose. -- 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. +# 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 +- `onlyoffice`: ONLYOFFICE Document Server +- `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`. +- ONLYOFFICE is published to the host by default as `127.0.0.1:8082`. +- 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`. +- If you need to access ONLYOFFICE from another machine, override `ONLYOFFICE_PUBLIC_URL` + so the browser can reach the document server address you actually expose. +- 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/development/plan/ai_agent_dual_layer_arch.md b/document/development/plan/ai_agent_dual_layer_arch.md index c262a97..fbcabfa 100644 --- a/document/development/plan/ai_agent_dual_layer_arch.md +++ b/document/development/plan/ai_agent_dual_layer_arch.md @@ -1,169 +1,169 @@ -# X-Financial 智能化财务系统:双层 Agent 架构设计与开发落地全景指南 - -> **核心设计理念:确定性与概率性的完美解耦** -> -> 在企业级财务系统中,“合规性”与“准确性”是不可妥协的底线。大语言模型(LLM)天生具有概率性(会产生幻觉),因此不能直接赋予其修改核心财务数据或放行审批的最高权限。 -> -> 本架构设计的核心,在于构建一个**“双层防线”**: -> 1. **外层 Agent (自研流程大脑)**:提供 100% 的确定性。它是系统的执行者,严格按照预设流程和固化的规则行事,不具备“自我意识”,只负责“路由”、“拦截”和“记录”。 -> 2. **内层 Agent (Hermes 智囊核心)**:提供强大的概率性推理能力。它是系统的思考者,负责处理所有复杂、模糊、非结构化的任务(如阅读长文档、识别潜在风险),但它的输出**不能直接作用于业务**,而是转化为**规则配置**或**建议意见**,交由外层 Agent 或人类管理员执行。 -> -> 这两层架构不是相互独立的两个系统,而是形成一个**“闭环”**:内层提炼规则,外层执行规则;外层收集数据,内层分析数据。这种深度协同,既保障了系统的安全性,又赋予了系统极高的智能化水平。 - ---- - -## 一、 系统架构图景与职责边界深度剖析 - -### 1. 外层 Agent (Outer Agent):流程与路由的绝对掌控者 - -**本质:一个高度可配置的业务工作流引擎与意图分发器。** - -* **开发技术栈建议**:FastAPI (后端) + Vue3 (前端) + PostgreSQL (持久化) + Redis (可选,用于状态缓存)。 -* **交互形态**:它直接面对用户。它可以是一个类似对话框的界面,但背后的逻辑是基于**状态机 (State Machine)** 驱动的。 -* **核心模块与职责 (What to do & How to do)**: - - * **模块 1: 意图漏斗 (Intent Router)** - * **职责**:精准捕捉用户请求的第一诉求,并将其导向正确的处理管线。 - * **方法**: - * *规则匹配优先*:使用简单的关键词或正则(例如:匹配到“报销”、“打车”字眼,直接激活报销向导)。 - * *轻量级分类模型兜底*:对于模糊表述(如:“我上周去上海开会的钱怎么还没发?”),调用一个小参数的分类模型(或内层的快捷接口),将其分类为“状态查询”意图,并提取关键实体(如时间:上周,地点:上海)。 - * **模块 2: 结构化状态机引擎 (State & Flow Controller)** - * **职责**:管理每一个业务对象(如一张报销单)的生命周期。从“草稿” -> “提交” -> “一级审批” -> “财务复核” -> “已打款”。 - * **方法**:拒绝让大模型控制流程走向。流程流转必须基于代码逻辑中的条件判断(例如:如果金额 < 500,且员工级别为 M1,则跳过一级审批,直接进入财务复核)。外层 Agent 负责维护并推进这个状态。 - * **模块 3: 确定性规则执行器 (Rule Execution Engine)** - * **职责**:财务合规的第一道硬性防线。不讲道理,只看数据。 - * **方法**:当用户提交报销数据时,该模块会查询本地的 `business_rules` 数据库表。如果用户提交的住宿费是 850,而数据库规则明确上限是 800,则立刻抛出“阻断型错误” (Blocking Error)。**此过程绝对禁止调用大模型进行实时推断。** - * **模块 4: 标准化 API 网关 (API Gateway & Handshake Layer)** - * **职责**:封装所有对外层系统(如 ERP、HR 系统)和对内层 Hermes 的通信接口。控制并发,记录调用日志。 - -### 2. 内层 Agent (Hermes):非结构化信息的提炼者与深度思考者 - -**本质:一个被严格隔离的智能计算引擎,专门处理人类擅长但传统代码难以处理的“软逻辑”。** - -* **开发技术栈建议**:Hermes 框架 + 向量数据库 (如 Milvus/PGVector) + 强力 LLM (如 GPT-4 或开源大模型)。 -* **交互形态**:对用户不可见,只作为外层 Agent 的“后端服务”存在。 -* **核心模块与职责 (What to do & How to do)**: - - * **模块 1: 政策蒸馏器 (Policy Distiller) —— 解决“知行合一”的关键** - * **职责**:打破知识库(死文件)与业务流(活代码)之间的壁垒。 - * **方法 (核心思路)**: - 1. *触发*:管理员上传一份《差旅新规.pdf》。 - 2. *解析*:Hermes 逐段阅读文档。 - 3. *提取*:使用精心设计的 **Few-Shot Prompt 链**,强制模型识别特定的“控制变量”。 - *(Prompt 示例: "你是一个专业的财务合规审计员。请阅读以下段落,如果包含任何关于费用上限、职级限制、审批层级的规定,请严格按照以下 JSON Schema 输出:{category, location, level_req, max_amount, is_hard_limit}。如果未找到,输出空。")* - 4. *回写*:Hermes 将提炼出的 JSON 结构转化为标准的 SQL Update 指令(或通过专用 API 接口),更新外层 Agent 依赖的 `business_rules` 表。 - * **模块 2: 深度知识检索 (Deep RAG & Interpretation)** - * **职责**:为用户提供复杂制度的个性化解读。 - * **方法**:当外层 Agent 无法解答用户的合规疑问时(意图识别为“政策咨询”),外层将请求转发给 Hermes。Hermes 在向量库中检索相关段落,并结合用户当前的上下文(如:员工职级、出差地),生成一份连贯、人性化的解答。 - * **模块 3: 异步风险探针 (Asynchronous Risk Auditor)** - * **职责**:像“老会计”一样,在海量已发生或正在发生的业务数据中寻找蛛丝马迹。 - * **方法**: - 1. *定时任务*:每天凌晨启动。 - 2. *数据聚合*:从外层数据库提取当天的报销流水(去除敏感个资)。 - 3. *模式识别*:通过特定的 Prompt(例如寻找“拆单报销”、“异常高频的出租车票”)。 - 4. *生成报告*:生成结构化的风险预警报告,存入专用表,供管理员次日早晨审核,而不是直接去冻结员工账号。 - ---- - -## 二、 核心通信协议 (The Handshake):两层的握手与数据交互 - -双层架构的成败,取决于这两层能否顺畅地交换信息,且保证安全。我们需要定义清晰的接口协议。 - -### 1. 同步查询接口 (外 -> 内:求知与解惑) - -当外层遇到处理不了的“软逻辑”时触发。 - -* **Endpoint (示例)**: `POST /hermes/api/v1/consult` -* **外层 Request 结构**: - ```json - { - "context": { - "user_id": "emp_1001", - "current_task": "travel_reimbursement", - "form_data": {"city": "北京", "amount": 900} - }, - "query": "因为展会原因酒店全满,只能订900的,能报销吗?" - } - ``` -* **内层 Hermes Response 结构**: - ```json - { - "status": "success", - "interpretation": "根据《差旅管理办法》第15条,展会期间允许上浮 20%。您的标准是800,上浮后为960,可以报销。", - "action_recommendation": "require_special_approval", // 建议外层采取的动作 - "citations": ["policy_doc_v2_page_4"] - } - ``` - -### 2. 异步任务接口 (外 -> 内:派发耗时任务) - -例如请求生成长篇分析报告或进行全量风险巡检。 - -* **流程**: - 1. 外层调用 `POST /hermes/api/v1/jobs/generate_report`。 - 2. 内层 Hermes 立即返回 `202 Accepted` 和一个 `job_id`。 - 3. 内层 Hermes 在后台慢慢算。 - 4. 计算完成后,内层通过 Webhook 回调外层的通知接口,外层再通过系统消息通知用户“您的报告已就绪”。 - -### 3. 规则推送机制 (内 -> 外:自动化立法) - -这是最核心的逆向通信。内层提炼出的规则如何生效? - -* **流程**: - 1. Hermes 提炼出新规则。 - 2. Hermes 调用外层的特权 API (如 `POST /admin/api/rules/sync`),推送规则 payload。 - 3. 外层 Agent 收到后,执行数据库 `UPSERT` 操作更新 `business_rules` 表。 - 4. *(可选但强烈建议)*:进入“待激活”状态,需要人类管理员在系统中点击“确认应用新规则”后,新规才正式生效。 - ---- - -## 三、 分阶段开发落地全景计划 (Implementation Roadmap) - -开发应当遵循“先基建后上层、先确定后智能”的原则。 - -### Phase 1: 骨架搭建与基石铺设 (Foundation & Outer Shell) -*目标:构建一个哪怕没有 AI 也能运转的硬核流程系统,确立两层隔离。* - -1. **架构拆分验证**:在服务器层面,确保 Outer Agent (FastAPI) 和 Inner Hermes 分别在独立的进程(或容器)中运行,仅通过 HTTP/gRPC 通信。 -2. **动态规则引擎实现 (核心基建)**: - * 在 PostgreSQL 中设计 `business_rules` 表结构。必须支持高度扩展性(例如采用 `JSONB` 字段存储具体约束参数)。 - * 在外层 Agent 开发一个“规则校验服务 (Rule Validation Service)”,该服务能够在任何报销动作发生前,拦截并比对 `business_rules`。 -3. **标准化流程闭环**:开发一个完整的、基于硬规则驱动的差旅报销单据流转全流程(填单 -> 校验 -> 提交 -> 审批)。验证在“硬规则”下系统运转良好。 - -### Phase 2: 知识注入与基础问答 (Hermes RAG Integration) -*目标:赋予系统“解答疑问”的能力。* - -1. **内层基建**:配置 Hermes 环境,接入向量数据库。 -2. **文档清洗管道 (ETL pipeline)**:将现有的财务政策 PDF/Word 文档清洗、分块 (Chunking) 并向量化入库。 -3. **问答桥接**: - * 在外层前端 (Vue3) 提供一个“智能咨询”悬浮窗或独立页面。 - * 外层 Agent 接收问题,附带上用户的上下文(角色、权限),一并转发给内层 Hermes。 - * 验证 Hermes 能够根据向量库的内容,给出带出处的准确回答。 - -### Phase 3: 核心攻坚 —— 自动立法与双层联通 (Policy Distillation & Sync) -*目标:实现从“死文档”到“活规则”的自动化转化。* - -1. **蒸馏 Prompt 工程**:在 Hermes 中反复打磨“政策提炼”的 Prompt。针对你们公司常见的政策描述方式进行微调。 -2. **结构化提取测试**:手动上传不同版本的政策文档,测试 Hermes 能否稳定、准确地输出 JSON 格式的规则参数。 -3. **闭环联调**: - * 开发 Hermes 向外层推送规则的 API。 - * 完成全链路测试:管理员界面上传新文档 -> Hermes 后台解析 -> 外层规则库自动更新 -> 前端即时生效新的金额限制。 - -### Phase 4: 高阶进化 —— 异步审计与主动防御 (Proactive Risk Auditing) -*目标:将系统从“被动响应”升级为“主动防护”。* - -1. **数据安全隧道**:建立从外层业务库向内层 Hermes 传递“脱敏业务快照”的通道。 -2. **风险模式定义**:梳理出 3-5 种典型的财务风险模式(如:异常聚集的餐饮发票、连续的单日高额交通费)。 -3. **Hermes 巡检任务**:编写定时任务逻辑,利用大模型的推理能力去比对这些模式和当天的业务快照数据。 -4. **风险看板**:在外层系统的管理后台开发“风险报告台”,展示 Hermes 生成的预警结果。 - ---- - -## 四、 关键风险与防范策略总结 - -1. **大模型幻觉污染规则库**: - * **防范**:Hermes 提炼的所有硬性规则(尤其是金额、审批级数),在写入外层正式库之前,必须增加一个**“人工审核 (Human-in-the-loop)”** 环节。系统提示“检测到政策更新,提炼出 5 条新规则,请管理员确认应用”。 -2. **状态机混乱**: - * **防范**:外层 Agent 的流程控制代码必须使用强类型和严格的事务控制 (Transaction)。绝不允许任何组件(包括 AI)在不经过状态机合法校验的情况下直接修改数据库中的 `status` 字段。 -3. **性能瓶颈**: - * **防范**:所有外层必须做的事情(拦截、查询)必须在毫秒级完成。所有涉及调用 Hermes 的操作(问答、提炼、分析)全部采用异步设计或提供明确的 Loading 反馈。 +# X-Financial 智能化财务系统:双层 Agent 架构设计与开发落地全景指南 + +> **核心设计理念:确定性与概率性的完美解耦** +> +> 在企业级财务系统中,“合规性”与“准确性”是不可妥协的底线。大语言模型(LLM)天生具有概率性(会产生幻觉),因此不能直接赋予其修改核心财务数据或放行审批的最高权限。 +> +> 本架构设计的核心,在于构建一个**“双层防线”**: +> 1. **外层 Agent (自研流程大脑)**:提供 100% 的确定性。它是系统的执行者,严格按照预设流程和固化的规则行事,不具备“自我意识”,只负责“路由”、“拦截”和“记录”。 +> 2. **内层 Agent (Hermes 智囊核心)**:提供强大的概率性推理能力。它是系统的思考者,负责处理所有复杂、模糊、非结构化的任务(如阅读长文档、识别潜在风险),但它的输出**不能直接作用于业务**,而是转化为**规则配置**或**建议意见**,交由外层 Agent 或人类管理员执行。 +> +> 这两层架构不是相互独立的两个系统,而是形成一个**“闭环”**:内层提炼规则,外层执行规则;外层收集数据,内层分析数据。这种深度协同,既保障了系统的安全性,又赋予了系统极高的智能化水平。 + +--- + +## 一、 系统架构图景与职责边界深度剖析 + +### 1. 外层 Agent (Outer Agent):流程与路由的绝对掌控者 + +**本质:一个高度可配置的业务工作流引擎与意图分发器。** + +* **开发技术栈建议**:FastAPI (后端) + Vue3 (前端) + PostgreSQL (持久化) + Redis (可选,用于状态缓存)。 +* **交互形态**:它直接面对用户。它可以是一个类似对话框的界面,但背后的逻辑是基于**状态机 (State Machine)** 驱动的。 +* **核心模块与职责 (What to do & How to do)**: + + * **模块 1: 意图漏斗 (Intent Router)** + * **职责**:精准捕捉用户请求的第一诉求,并将其导向正确的处理管线。 + * **方法**: + * *规则匹配优先*:使用简单的关键词或正则(例如:匹配到“报销”、“打车”字眼,直接激活报销向导)。 + * *轻量级分类模型兜底*:对于模糊表述(如:“我上周去上海开会的钱怎么还没发?”),调用一个小参数的分类模型(或内层的快捷接口),将其分类为“状态查询”意图,并提取关键实体(如时间:上周,地点:上海)。 + * **模块 2: 结构化状态机引擎 (State & Flow Controller)** + * **职责**:管理每一个业务对象(如一张报销单)的生命周期。从“草稿” -> “提交” -> “一级审批” -> “财务复核” -> “已打款”。 + * **方法**:拒绝让大模型控制流程走向。流程流转必须基于代码逻辑中的条件判断(例如:如果金额 < 500,且员工级别为 M1,则跳过一级审批,直接进入财务复核)。外层 Agent 负责维护并推进这个状态。 + * **模块 3: 确定性规则执行器 (Rule Execution Engine)** + * **职责**:财务合规的第一道硬性防线。不讲道理,只看数据。 + * **方法**:当用户提交报销数据时,该模块会查询本地的 `business_rules` 数据库表。如果用户提交的住宿费是 850,而数据库规则明确上限是 800,则立刻抛出“阻断型错误” (Blocking Error)。**此过程绝对禁止调用大模型进行实时推断。** + * **模块 4: 标准化 API 网关 (API Gateway & Handshake Layer)** + * **职责**:封装所有对外层系统(如 ERP、HR 系统)和对内层 Hermes 的通信接口。控制并发,记录调用日志。 + +### 2. 内层 Agent (Hermes):非结构化信息的提炼者与深度思考者 + +**本质:一个被严格隔离的智能计算引擎,专门处理人类擅长但传统代码难以处理的“软逻辑”。** + +* **开发技术栈建议**:Hermes 框架 + 向量数据库 (如 Milvus/PGVector) + 强力 LLM (如 GPT-4 或开源大模型)。 +* **交互形态**:对用户不可见,只作为外层 Agent 的“后端服务”存在。 +* **核心模块与职责 (What to do & How to do)**: + + * **模块 1: 政策蒸馏器 (Policy Distiller) —— 解决“知行合一”的关键** + * **职责**:打破知识库(死文件)与业务流(活代码)之间的壁垒。 + * **方法 (核心思路)**: + 1. *触发*:管理员上传一份《差旅新规.pdf》。 + 2. *解析*:Hermes 逐段阅读文档。 + 3. *提取*:使用精心设计的 **Few-Shot Prompt 链**,强制模型识别特定的“控制变量”。 + *(Prompt 示例: "你是一个专业的财务合规审计员。请阅读以下段落,如果包含任何关于费用上限、职级限制、审批层级的规定,请严格按照以下 JSON Schema 输出:{category, location, level_req, max_amount, is_hard_limit}。如果未找到,输出空。")* + 4. *回写*:Hermes 将提炼出的 JSON 结构转化为标准的 SQL Update 指令(或通过专用 API 接口),更新外层 Agent 依赖的 `business_rules` 表。 + * **模块 2: 深度知识检索 (Deep RAG & Interpretation)** + * **职责**:为用户提供复杂制度的个性化解读。 + * **方法**:当外层 Agent 无法解答用户的合规疑问时(意图识别为“政策咨询”),外层将请求转发给 Hermes。Hermes 在向量库中检索相关段落,并结合用户当前的上下文(如:员工职级、出差地),生成一份连贯、人性化的解答。 + * **模块 3: 异步风险探针 (Asynchronous Risk Auditor)** + * **职责**:像“老会计”一样,在海量已发生或正在发生的业务数据中寻找蛛丝马迹。 + * **方法**: + 1. *定时任务*:每天凌晨启动。 + 2. *数据聚合*:从外层数据库提取当天的报销流水(去除敏感个资)。 + 3. *模式识别*:通过特定的 Prompt(例如寻找“拆单报销”、“异常高频的出租车票”)。 + 4. *生成报告*:生成结构化的风险预警报告,存入专用表,供管理员次日早晨审核,而不是直接去冻结员工账号。 + +--- + +## 二、 核心通信协议 (The Handshake):两层的握手与数据交互 + +双层架构的成败,取决于这两层能否顺畅地交换信息,且保证安全。我们需要定义清晰的接口协议。 + +### 1. 同步查询接口 (外 -> 内:求知与解惑) + +当外层遇到处理不了的“软逻辑”时触发。 + +* **Endpoint (示例)**: `POST /hermes/api/v1/consult` +* **外层 Request 结构**: + ```json + { + "context": { + "user_id": "emp_1001", + "current_task": "travel_reimbursement", + "form_data": {"city": "北京", "amount": 900} + }, + "query": "因为展会原因酒店全满,只能订900的,能报销吗?" + } + ``` +* **内层 Hermes Response 结构**: + ```json + { + "status": "success", + "interpretation": "根据《差旅管理办法》第15条,展会期间允许上浮 20%。您的标准是800,上浮后为960,可以报销。", + "action_recommendation": "require_special_approval", // 建议外层采取的动作 + "citations": ["policy_doc_v2_page_4"] + } + ``` + +### 2. 异步任务接口 (外 -> 内:派发耗时任务) + +例如请求生成长篇分析报告或进行全量风险巡检。 + +* **流程**: + 1. 外层调用 `POST /hermes/api/v1/jobs/generate_report`。 + 2. 内层 Hermes 立即返回 `202 Accepted` 和一个 `job_id`。 + 3. 内层 Hermes 在后台慢慢算。 + 4. 计算完成后,内层通过 Webhook 回调外层的通知接口,外层再通过系统消息通知用户“您的报告已就绪”。 + +### 3. 规则推送机制 (内 -> 外:自动化立法) + +这是最核心的逆向通信。内层提炼出的规则如何生效? + +* **流程**: + 1. Hermes 提炼出新规则。 + 2. Hermes 调用外层的特权 API (如 `POST /admin/api/rules/sync`),推送规则 payload。 + 3. 外层 Agent 收到后,执行数据库 `UPSERT` 操作更新 `business_rules` 表。 + 4. *(可选但强烈建议)*:进入“待激活”状态,需要人类管理员在系统中点击“确认应用新规则”后,新规才正式生效。 + +--- + +## 三、 分阶段开发落地全景计划 (Implementation Roadmap) + +开发应当遵循“先基建后上层、先确定后智能”的原则。 + +### Phase 1: 骨架搭建与基石铺设 (Foundation & Outer Shell) +*目标:构建一个哪怕没有 AI 也能运转的硬核流程系统,确立两层隔离。* + +1. **架构拆分验证**:在服务器层面,确保 Outer Agent (FastAPI) 和 Inner Hermes 分别在独立的进程(或容器)中运行,仅通过 HTTP/gRPC 通信。 +2. **动态规则引擎实现 (核心基建)**: + * 在 PostgreSQL 中设计 `business_rules` 表结构。必须支持高度扩展性(例如采用 `JSONB` 字段存储具体约束参数)。 + * 在外层 Agent 开发一个“规则校验服务 (Rule Validation Service)”,该服务能够在任何报销动作发生前,拦截并比对 `business_rules`。 +3. **标准化流程闭环**:开发一个完整的、基于硬规则驱动的差旅报销单据流转全流程(填单 -> 校验 -> 提交 -> 审批)。验证在“硬规则”下系统运转良好。 + +### Phase 2: 知识注入与基础问答 (Hermes RAG Integration) +*目标:赋予系统“解答疑问”的能力。* + +1. **内层基建**:配置 Hermes 环境,接入向量数据库。 +2. **文档清洗管道 (ETL pipeline)**:将现有的财务政策 PDF/Word 文档清洗、分块 (Chunking) 并向量化入库。 +3. **问答桥接**: + * 在外层前端 (Vue3) 提供一个“智能咨询”悬浮窗或独立页面。 + * 外层 Agent 接收问题,附带上用户的上下文(角色、权限),一并转发给内层 Hermes。 + * 验证 Hermes 能够根据向量库的内容,给出带出处的准确回答。 + +### Phase 3: 核心攻坚 —— 自动立法与双层联通 (Policy Distillation & Sync) +*目标:实现从“死文档”到“活规则”的自动化转化。* + +1. **蒸馏 Prompt 工程**:在 Hermes 中反复打磨“政策提炼”的 Prompt。针对你们公司常见的政策描述方式进行微调。 +2. **结构化提取测试**:手动上传不同版本的政策文档,测试 Hermes 能否稳定、准确地输出 JSON 格式的规则参数。 +3. **闭环联调**: + * 开发 Hermes 向外层推送规则的 API。 + * 完成全链路测试:管理员界面上传新文档 -> Hermes 后台解析 -> 外层规则库自动更新 -> 前端即时生效新的金额限制。 + +### Phase 4: 高阶进化 —— 异步审计与主动防御 (Proactive Risk Auditing) +*目标:将系统从“被动响应”升级为“主动防护”。* + +1. **数据安全隧道**:建立从外层业务库向内层 Hermes 传递“脱敏业务快照”的通道。 +2. **风险模式定义**:梳理出 3-5 种典型的财务风险模式(如:异常聚集的餐饮发票、连续的单日高额交通费)。 +3. **Hermes 巡检任务**:编写定时任务逻辑,利用大模型的推理能力去比对这些模式和当天的业务快照数据。 +4. **风险看板**:在外层系统的管理后台开发“风险报告台”,展示 Hermes 生成的预警结果。 + +--- + +## 四、 关键风险与防范策略总结 + +1. **大模型幻觉污染规则库**: + * **防范**:Hermes 提炼的所有硬性规则(尤其是金额、审批级数),在写入外层正式库之前,必须增加一个**“人工审核 (Human-in-the-loop)”** 环节。系统提示“检测到政策更新,提炼出 5 条新规则,请管理员确认应用”。 +2. **状态机混乱**: + * **防范**:外层 Agent 的流程控制代码必须使用强类型和严格的事务控制 (Transaction)。绝不允许任何组件(包括 AI)在不经过状态机合法校验的情况下直接修改数据库中的 `status` 字段。 +3. **性能瓶颈**: + * **防范**:所有外层必须做的事情(拦截、查询)必须在毫秒级完成。所有涉及调用 Hermes 的操作(问答、提炼、分析)全部采用异步设计或提供明确的 Loading 反馈。 diff --git a/server/pyproject.toml b/server/pyproject.toml index ec3e763..0fcb229 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -1,48 +1,48 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "x-financial-server" -version = "0.1.0" -description = "Backend service for X-Financial reimbursement and approval platform." -readme = "README.md" -requires-python = ">=3.11" -dependencies = [ - "fastapi>=0.115.0,<1.0.0", - "uvicorn[standard]>=0.30.0,<1.0.0", - "sqlalchemy>=2.0.36,<3.0.0", - "alembic>=1.14.0,<2.0.0", - "psycopg[binary]>=3.2.0,<4.0.0", - "PyJWT>=2.9.0,<3.0.0", - "pydantic-settings>=2.6.0,<3.0.0", - "python-dotenv>=1.0.1,<2.0.0", - "email-validator>=2.2.0,<3.0.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.3.0,<9.0.0", - "httpx>=0.28.0,<1.0.0", - "ruff>=0.8.0,<1.0.0", -] -redis = [ - "redis>=5.2.0,<6.0.0", -] - -[tool.setuptools] -package-dir = {"" = "src"} - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.pytest.ini_options] -pythonpath = ["src"] -testpaths = ["tests"] - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint] -select = ["E", "F", "I", "B", "UP"] +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "x-financial-server" +version = "0.1.0" +description = "Backend service for X-Financial reimbursement and approval platform." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0,<1.0.0", + "uvicorn[standard]>=0.30.0,<1.0.0", + "sqlalchemy>=2.0.36,<3.0.0", + "alembic>=1.14.0,<2.0.0", + "psycopg[binary]>=3.2.0,<4.0.0", + "PyJWT>=2.9.0,<3.0.0", + "pydantic-settings>=2.6.0,<3.0.0", + "python-dotenv>=1.0.1,<2.0.0", + "email-validator>=2.2.0,<3.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0,<9.0.0", + "httpx>=0.28.0,<1.0.0", + "ruff>=0.8.0,<1.0.0", +] +redis = [ + "redis>=5.2.0,<6.0.0", +] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] diff --git a/server/server_start.sh b/server/server_start.sh index 87b0ef1..cc29713 100755 --- a/server/server_start.sh +++ b/server/server_start.sh @@ -90,6 +90,10 @@ fi ENV_OVERRIDE_SERVER_HOST_SET=false ENV_OVERRIDE_POSTGRES_HOST_SET=false ENV_OVERRIDE_DATABASE_URL_SET=false +ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false +ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false +ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false +ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=false if [ "${SERVER_HOST+x}" = x ]; then ENV_OVERRIDE_SERVER_HOST_SET=true @@ -106,6 +110,26 @@ if [ "${DATABASE_URL+x}" = x ]; then ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL" fi +if [ "${ONLYOFFICE_ENABLED+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true + ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED" +fi + +if [ "${ONLYOFFICE_PUBLIC_URL+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=true + ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL="$ONLYOFFICE_PUBLIC_URL" +fi + +if [ "${ONLYOFFICE_BACKEND_URL+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=true + ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL="$ONLYOFFICE_BACKEND_URL" +fi + +if [ "${ONLYOFFICE_JWT_SECRET+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=true + ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET="$ONLYOFFICE_JWT_SECRET" +fi + set -a . "$ROOT_ENV_FILE" set +a @@ -122,6 +146,22 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL" fi +if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then + ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED" +fi + +if [ "$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET" = true ]; then + ONLYOFFICE_PUBLIC_URL="$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL" +fi + +if [ "$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET" = true ]; then + ONLYOFFICE_BACKEND_URL="$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL" +fi + +if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then + ONLYOFFICE_JWT_SECRET="$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET" +fi + SERVER_HOST="${SERVER_HOST:-0.0.0.0}" SERVER_PORT="${SERVER_PORT:-8000}" DEFAULT_SERVER_RELOAD="false" @@ -189,7 +229,7 @@ run_bootstrap_python() { } dependencies_ready() { - "$PYTHON_BIN" -c "import fastapi, uvicorn, sqlalchemy, alembic, pydantic_settings" >/dev/null 2>&1 + "$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, psycopg, pydantic_settings, sqlalchemy, uvicorn" >/dev/null 2>&1 } pip_ready() { diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index e73c07a..957a24f 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -1,62 +1,62 @@ -from collections.abc import Generator -from dataclasses import dataclass -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, status -from sqlalchemy.orm import Session - -from app.db.session import get_session_factory - - -def get_db() -> Generator[Session, None, None]: - db = get_session_factory()() - try: - yield db - finally: - db.close() - - -@dataclass(slots=True) -class CurrentUserContext: - username: str - name: str - role_codes: list[str] - is_admin: bool - - -def get_current_user( - x_auth_username: Annotated[str | None, Header()] = None, - x_auth_name: Annotated[str | None, Header()] = None, - x_auth_role_codes: Annotated[str | None, Header()] = None, - x_auth_is_admin: Annotated[str | None, Header()] = None, -) -> CurrentUserContext: - role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()] - is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"} - - username = (x_auth_username or "").strip() - name = (x_auth_name or username).strip() - - if not username and not name: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="请先登录后再访问知识库。", - ) - - return CurrentUserContext( - username=username or name, - name=name or username, - role_codes=role_codes, - is_admin=is_admin, - ) - - -def require_admin_user( - current_user: Annotated[CurrentUserContext, Depends(get_current_user)], -) -> CurrentUserContext: - if current_user.is_admin or "manager" in current_user.role_codes: - return current_user - - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="只有管理员可以上传、删除或修改知识库文件。", - ) +from collections.abc import Generator +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_session_factory + + +def get_db() -> Generator[Session, None, None]: + db = get_session_factory()() + try: + yield db + finally: + db.close() + + +@dataclass(slots=True) +class CurrentUserContext: + username: str + name: str + role_codes: list[str] + is_admin: bool + + +def get_current_user( + x_auth_username: Annotated[str | None, Header()] = None, + x_auth_name: Annotated[str | None, Header()] = None, + x_auth_role_codes: Annotated[str | None, Header()] = None, + x_auth_is_admin: Annotated[str | None, Header()] = None, +) -> CurrentUserContext: + role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()] + is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"} + + username = (x_auth_username or "").strip() + name = (x_auth_name or username).strip() + + if not username and not name: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录后再访问知识库。", + ) + + return CurrentUserContext( + username=username or name, + name=name or username, + role_codes=role_codes, + is_admin=is_admin, + ) + + +def require_admin_user( + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> CurrentUserContext: + if current_user.is_admin or "manager" in current_user.role_codes: + return current_user + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有管理员可以上传、删除或修改知识库文件。", + ) diff --git a/server/src/app/api/v1/endpoints/knowledge.py b/server/src/app/api/v1/endpoints/knowledge.py index a0ffcd0..41e4b96 100644 --- a/server/src/app/api/v1/endpoints/knowledge.py +++ b/server/src/app/api/v1/endpoints/knowledge.py @@ -1,124 +1,124 @@ -from __future__ import annotations - -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from fastapi.responses import FileResponse - -from app.api.deps import CurrentUserContext, get_current_user, require_admin_user -from app.schemas.knowledge import ( - KnowledgeActionResponse, - KnowledgeDocumentDetailRead, - KnowledgeLibraryRead, - KnowledgeOnlyOfficeCallbackRead, - KnowledgeOnlyOfficeConfigRead, -) -from app.services.knowledge import KnowledgeService - -router = APIRouter(prefix="/knowledge") - - -@router.get("/library", response_model=KnowledgeLibraryRead) -def get_knowledge_library( - _: Annotated[CurrentUserContext, Depends(get_current_user)], -) -> KnowledgeLibraryRead: - return KnowledgeService().list_library() - - -@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead) -def get_knowledge_document( - document_id: str, - _: Annotated[CurrentUserContext, Depends(get_current_user)], -) -> KnowledgeDocumentDetailRead: - try: - return KnowledgeService().get_document_detail(document_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc - - -@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead) -def get_knowledge_document_onlyoffice_config( - document_id: str, - current_user: Annotated[CurrentUserContext, Depends(get_current_user)], -) -> KnowledgeOnlyOfficeConfigRead: - try: - return KnowledgeService().build_onlyoffice_config(document_id, current_user) - except FileNotFoundError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - - -@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED) -async def upload_knowledge_document( - request: Request, - folder: Annotated[str, Query(min_length=1)], - filename: Annotated[str, Query(min_length=1)], - current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], -) -> KnowledgeDocumentDetailRead: - content = await request.body() - try: - return KnowledgeService().upload_document(folder, filename, content, current_user) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - - -@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse) -def delete_knowledge_document( - document_id: str, - _: Annotated[CurrentUserContext, Depends(require_admin_user)], -) -> KnowledgeActionResponse: - try: - KnowledgeService().delete_document(document_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc - - return KnowledgeActionResponse(detail="知识库文件已删除。") - - -@router.get("/documents/{document_id}/content") -def get_knowledge_document_content( - document_id: str, - disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline", - _: Annotated[CurrentUserContext, Depends(get_current_user)] = None, -) -> FileResponse: - try: - file_path, media_type, filename = KnowledgeService().get_document_content(document_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc - - _ = disposition - return FileResponse(file_path, media_type=media_type, filename=filename) - - -@router.get("/documents/{document_id}/onlyoffice/content") -def get_knowledge_document_onlyoffice_content( - document_id: str, - access_token: Annotated[str, Query(min_length=1)], -) -> FileResponse: - try: - service = KnowledgeService() - service.validate_onlyoffice_access_token(document_id, access_token) - file_path, media_type, filename = service.get_document_content(document_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc - - return FileResponse(file_path, media_type=media_type, filename=filename) - - -@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead) -async def handle_knowledge_document_onlyoffice_callback( - document_id: str, - request: Request, -) -> KnowledgeOnlyOfficeCallbackRead: - payload = await request.json() - try: - KnowledgeService().handle_onlyoffice_callback(document_id, payload) - except FileNotFoundError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - - return KnowledgeOnlyOfficeCallbackRead() +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import FileResponse + +from app.api.deps import CurrentUserContext, get_current_user, require_admin_user +from app.schemas.knowledge import ( + KnowledgeActionResponse, + KnowledgeDocumentDetailRead, + KnowledgeLibraryRead, + KnowledgeOnlyOfficeCallbackRead, + KnowledgeOnlyOfficeConfigRead, +) +from app.services.knowledge import KnowledgeService + +router = APIRouter(prefix="/knowledge") + + +@router.get("/library", response_model=KnowledgeLibraryRead) +def get_knowledge_library( + _: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> KnowledgeLibraryRead: + return KnowledgeService().list_library() + + +@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead) +def get_knowledge_document( + document_id: str, + _: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> KnowledgeDocumentDetailRead: + try: + return KnowledgeService().get_document_detail(document_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc + + +@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead) +def get_knowledge_document_onlyoffice_config( + document_id: str, + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> KnowledgeOnlyOfficeConfigRead: + try: + return KnowledgeService().build_onlyoffice_config(document_id, current_user) + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED) +async def upload_knowledge_document( + request: Request, + folder: Annotated[str, Query(min_length=1)], + filename: Annotated[str, Query(min_length=1)], + current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], +) -> KnowledgeDocumentDetailRead: + content = await request.body() + try: + return KnowledgeService().upload_document(folder, filename, content, current_user) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse) +def delete_knowledge_document( + document_id: str, + _: Annotated[CurrentUserContext, Depends(require_admin_user)], +) -> KnowledgeActionResponse: + try: + KnowledgeService().delete_document(document_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc + + return KnowledgeActionResponse(detail="知识库文件已删除。") + + +@router.get("/documents/{document_id}/content") +def get_knowledge_document_content( + document_id: str, + disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline", + _: Annotated[CurrentUserContext, Depends(get_current_user)] = None, +) -> FileResponse: + try: + file_path, media_type, filename = KnowledgeService().get_document_content(document_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc + + _ = disposition + return FileResponse(file_path, media_type=media_type, filename=filename) + + +@router.get("/documents/{document_id}/onlyoffice/content") +def get_knowledge_document_onlyoffice_content( + document_id: str, + access_token: Annotated[str, Query(min_length=1)], +) -> FileResponse: + try: + service = KnowledgeService() + service.validate_onlyoffice_access_token(document_id, access_token) + file_path, media_type, filename = service.get_document_content(document_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + + return FileResponse(file_path, media_type=media_type, filename=filename) + + +@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead) +async def handle_knowledge_document_onlyoffice_callback( + document_id: str, + request: Request, +) -> KnowledgeOnlyOfficeCallbackRead: + payload = await request.json() + try: + KnowledgeService().handle_onlyoffice_callback(document_id, payload) + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + return KnowledgeOnlyOfficeCallbackRead() diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 51ae53c..565dd68 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -1,18 +1,18 @@ -from fastapi import APIRouter - -from app.api.v1.endpoints.auth import router as auth_router -from app.api.v1.endpoints.bootstrap import router as bootstrap_router -from app.api.v1.endpoints.employees import router as employees_router -from app.api.v1.endpoints.health import router as health_router -from app.api.v1.endpoints.knowledge import router as knowledge_router -from app.api.v1.endpoints.reimbursements import router as reimbursements_router -from app.api.v1.endpoints.settings import router as settings_router - -router = APIRouter() -router.include_router(health_router, tags=["health"]) -router.include_router(bootstrap_router, tags=["bootstrap"]) -router.include_router(auth_router, tags=["auth"]) -router.include_router(knowledge_router, tags=["knowledge"]) -router.include_router(employees_router, prefix="/employees", tags=["employees"]) -router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) -router.include_router(settings_router, tags=["settings"]) +from fastapi import APIRouter + +from app.api.v1.endpoints.auth import router as auth_router +from app.api.v1.endpoints.bootstrap import router as bootstrap_router +from app.api.v1.endpoints.employees import router as employees_router +from app.api.v1.endpoints.health import router as health_router +from app.api.v1.endpoints.knowledge import router as knowledge_router +from app.api.v1.endpoints.reimbursements import router as reimbursements_router +from app.api.v1.endpoints.settings import router as settings_router + +router = APIRouter() +router.include_router(health_router, tags=["health"]) +router.include_router(bootstrap_router, tags=["bootstrap"]) +router.include_router(auth_router, tags=["auth"]) +router.include_router(knowledge_router, tags=["knowledge"]) +router.include_router(employees_router, prefix="/employees", tags=["employees"]) +router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) +router.include_router(settings_router, tags=["settings"]) diff --git a/server/src/app/main.py b/server/src/app/main.py index 44398ef..45f91c5 100644 --- a/server/src/app/main.py +++ b/server/src/app/main.py @@ -1,65 +1,65 @@ -from __future__ import annotations - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from app.api.router import api_router -from app.core.config import get_settings -from app.core.logging import get_logger, setup_logging -from app.middleware.logging import AccessLogMiddleware -from app.services.employee import prepare_employee_directory -from app.services.knowledge import prepare_knowledge_library - - -def create_app() -> FastAPI: - settings = get_settings() - - setup_logging( - level=settings.log_level, - log_dir=settings.log_dir, - enable_file=settings.log_file_enabled, - ) - - logger = get_logger("app.main") - logger.info( - "Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug - ) - - app = FastAPI( - title=settings.app_name, - debug=settings.app_debug, - version="0.1.0", - ) - - app.add_middleware(AccessLogMiddleware) - - if settings.cors_origins: - app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - app.include_router(api_router, prefix=settings.api_v1_prefix) - - @app.get("/", tags=["root"]) - def root() -> dict[str, str]: - return {"message": f"{settings.app_name} is running"} - - @app.on_event("startup") - def _on_startup() -> None: - prepare_employee_directory() - prepare_knowledge_library() - logger.info( - "Server ready - host=%s port=%s prefix=%s", - settings.app_host, - settings.app_port, - settings.api_v1_prefix, - ) - - return app - - -app = create_app() +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.router import api_router +from app.core.config import get_settings +from app.core.logging import get_logger, setup_logging +from app.middleware.logging import AccessLogMiddleware +from app.services.employee import prepare_employee_directory +from app.services.knowledge import prepare_knowledge_library + + +def create_app() -> FastAPI: + settings = get_settings() + + setup_logging( + level=settings.log_level, + log_dir=settings.log_dir, + enable_file=settings.log_file_enabled, + ) + + logger = get_logger("app.main") + logger.info( + "Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug + ) + + app = FastAPI( + title=settings.app_name, + debug=settings.app_debug, + version="0.1.0", + ) + + app.add_middleware(AccessLogMiddleware) + + if settings.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(api_router, prefix=settings.api_v1_prefix) + + @app.get("/", tags=["root"]) + def root() -> dict[str, str]: + return {"message": f"{settings.app_name} is running"} + + @app.on_event("startup") + def _on_startup() -> None: + prepare_employee_directory() + prepare_knowledge_library() + logger.info( + "Server ready - host=%s port=%s prefix=%s", + settings.app_host, + settings.app_port, + settings.api_v1_prefix, + ) + + return app + + +app = create_app() diff --git a/server/src/app/schemas/__init__.py b/server/src/app/schemas/__init__.py index 12a7ac8..4292843 100644 --- a/server/src/app/schemas/__init__.py +++ b/server/src/app/schemas/__init__.py @@ -1 +1 @@ -__all__ = ["employee", "knowledge", "reimbursement"] +__all__ = ["employee", "knowledge", "reimbursement"] diff --git a/server/src/app/schemas/knowledge.py b/server/src/app/schemas/knowledge.py index 99b2fea..d92bbe3 100644 --- a/server/src/app/schemas/knowledge.py +++ b/server/src/app/schemas/knowledge.py @@ -1,72 +1,72 @@ -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, Field - - -class KnowledgeFolderRead(BaseModel): - name: str - count: int - icon: str = "mdi mdi-folder" - - -class KnowledgePreviewStatRead(BaseModel): - label: str - value: str - - -class KnowledgePreviewBlockRead(BaseModel): - heading: str - lines: list[str] = Field(default_factory=list) - - -class KnowledgePreviewPageRead(BaseModel): - title: str - subtitle: str - stats: list[KnowledgePreviewStatRead] = Field(default_factory=list) - blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list) - - -class KnowledgeDocumentRead(BaseModel): - id: str - name: str - folder: str - tag: str - time: str - version: str - state: str - stateTone: str - owner: str - icon: str - fileType: str - fileTypeLabel: str - summary: str - mimeType: str - extension: str - sizeBytes: int - canPreview: bool = False - - -class KnowledgeDocumentDetailRead(KnowledgeDocumentRead): - previewKind: str - previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list) - - -class KnowledgeOnlyOfficeConfigRead(BaseModel): - documentServerUrl: str - config: dict[str, Any] = Field(default_factory=dict) - - -class KnowledgeOnlyOfficeCallbackRead(BaseModel): - error: int = 0 - - -class KnowledgeLibraryRead(BaseModel): - folders: list[KnowledgeFolderRead] = Field(default_factory=list) - documents: list[KnowledgeDocumentRead] = Field(default_factory=list) - - -class KnowledgeActionResponse(BaseModel): - ok: bool = True - detail: str +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class KnowledgeFolderRead(BaseModel): + name: str + count: int + icon: str = "mdi mdi-folder" + + +class KnowledgePreviewStatRead(BaseModel): + label: str + value: str + + +class KnowledgePreviewBlockRead(BaseModel): + heading: str + lines: list[str] = Field(default_factory=list) + + +class KnowledgePreviewPageRead(BaseModel): + title: str + subtitle: str + stats: list[KnowledgePreviewStatRead] = Field(default_factory=list) + blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list) + + +class KnowledgeDocumentRead(BaseModel): + id: str + name: str + folder: str + tag: str + time: str + version: str + state: str + stateTone: str + owner: str + icon: str + fileType: str + fileTypeLabel: str + summary: str + mimeType: str + extension: str + sizeBytes: int + canPreview: bool = False + + +class KnowledgeDocumentDetailRead(KnowledgeDocumentRead): + previewKind: str + previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list) + + +class KnowledgeOnlyOfficeConfigRead(BaseModel): + documentServerUrl: str + config: dict[str, Any] = Field(default_factory=dict) + + +class KnowledgeOnlyOfficeCallbackRead(BaseModel): + error: int = 0 + + +class KnowledgeLibraryRead(BaseModel): + folders: list[KnowledgeFolderRead] = Field(default_factory=list) + documents: list[KnowledgeDocumentRead] = Field(default_factory=list) + + +class KnowledgeActionResponse(BaseModel): + ok: bool = True + detail: str diff --git a/server/src/app/services/knowledge.py b/server/src/app/services/knowledge.py index 967e327..fbcd5f4 100644 --- a/server/src/app/services/knowledge.py +++ b/server/src/app/services/knowledge.py @@ -1,860 +1,860 @@ -from __future__ import annotations - -import hashlib -import json -import mimetypes -import re -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Any -from urllib.request import Request, urlopen -from uuid import uuid4 -from xml.etree import ElementTree -from zipfile import BadZipFile, ZipFile - -import jwt - -from app.api.deps import CurrentUserContext -from app.core.config import get_settings -from app.core.logging import get_logger -from app.schemas.knowledge import ( - KnowledgeDocumentDetailRead, - KnowledgeDocumentRead, - KnowledgeFolderRead, - KnowledgeLibraryRead, - KnowledgeOnlyOfficeConfigRead, - KnowledgePreviewBlockRead, - KnowledgePreviewPageRead, - KnowledgePreviewStatRead, -) - -logger = get_logger("app.services.knowledge") - -FIXED_KNOWLEDGE_FOLDERS = [ - "财务知识库", - "制度政策", - "报销制度", - "差旅规范", - "发票管理", - "税务合规", - "预算管理", - "财务共享", - "培训资料", - "常见问答", -] - -ICON_BY_TYPE = { - "pdf": "mdi mdi-file-document-outline-pdf pdf", - "word": "mdi mdi-file-document-outline-word word", - "excel": "mdi mdi-file-document-outline-excel excel", - "ppt": "mdi mdi-file-powerpoint-box ppt", - "image": "mdi mdi-file-image-outline image", - "text": "mdi mdi-file-document-outline text", - "archive": "mdi mdi-folder-zip-outline archive", - "binary": "mdi mdi-file-outline", -} - -TEXT_EXTENSIONS = {"txt", "md", "csv", "json", "xml", "yml", "yaml", "log"} -WORD_EXTENSIONS = {"doc", "docx"} -EXCEL_EXTENSIONS = {"xls", "xlsx", "csv"} -PPT_EXTENSIONS = {"ppt", "pptx"} -IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"} -ARCHIVE_EXTENSIONS = {"zip", "rar", "7z"} -STRUCTURED_PREVIEW_EXTENSIONS = {"docx", "xlsx", "pptx"} | TEXT_EXTENSIONS -INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS -ONLYOFFICE_EDITABLE_EXTENSIONS = {"docx", "xlsx", "pptx"} - - -@dataclass(slots=True) -class OnlyOfficeCallbackPayload: - status: int - download_url: str - users: list[str] - - -def prepare_knowledge_library() -> None: - KnowledgeService().ensure_library_ready() - - -class KnowledgeService: - def __init__(self, storage_root: Path | None = None) -> None: - settings = get_settings() - self.storage_root = Path(storage_root or settings.resolved_storage_root_dir) - self.library_root = self.storage_root / "knowledge" - self.index_path = self.library_root / ".index.json" - - def ensure_library_ready(self) -> None: - self.library_root.mkdir(parents=True, exist_ok=True) - for folder_name in FIXED_KNOWLEDGE_FOLDERS: - (self.library_root / folder_name).mkdir(parents=True, exist_ok=True) - - if not self.index_path.exists(): - self._save_index({"version": 1, "documents": []}) - - index = self._load_index() - if self._reconcile_index(index): - self._save_index(index) - - def list_library(self) -> KnowledgeLibraryRead: - documents = self._load_documents() - folders = [ - KnowledgeFolderRead( - name=folder_name, - count=sum(1 for item in documents if item.folder == folder_name), - icon="mdi mdi-folder-open" if folder_name == "差旅规范" else "mdi mdi-folder", - ) - for folder_name in FIXED_KNOWLEDGE_FOLDERS - ] - return KnowledgeLibraryRead(folders=folders, documents=documents) - - def get_document_detail(self, document_id: str) -> KnowledgeDocumentDetailRead: - self.ensure_library_ready() - index = self._load_index() - entry = self._require_entry(index, document_id) - preview_kind, preview_pages = self._build_preview(entry) - document = self._serialize_document(entry) - return KnowledgeDocumentDetailRead( - **document.model_dump(), - previewKind=preview_kind, - previewPages=preview_pages, - ) - - def upload_document( - self, - folder: str, - filename: str, - content: bytes, - current_user: CurrentUserContext, - ) -> KnowledgeDocumentDetailRead: - self.ensure_library_ready() - normalized_folder = self._normalize_folder(folder) - normalized_name = self._normalize_filename(filename) - - if not content: - raise ValueError("上传文件不能为空。") - - index = self._load_index() - existing_entry = next( - ( - item - for item in index["documents"] - if item["folder"] == normalized_folder - and item["original_name"].lower() == normalized_name.lower() - ), - None, - ) - - document_id = existing_entry["id"] if existing_entry else uuid4().hex - stored_name = f"{document_id}__{normalized_name}" - target_path = self.library_root / normalized_folder / stored_name - - if existing_entry is not None and existing_entry["stored_name"] != stored_name: - old_path = self.library_root / existing_entry["folder"] / existing_entry["stored_name"] - if old_path.exists(): - old_path.unlink() - - target_path.write_bytes(content) - - now = datetime.now(UTC).isoformat() - mime_type = mimetypes.guess_type(normalized_name)[0] or "application/octet-stream" - checksum = hashlib.sha256(content).hexdigest() - extension = self._extract_extension(normalized_name) - - if existing_entry is None: - entry = { - "id": document_id, - "folder": normalized_folder, - "original_name": normalized_name, - "stored_name": stored_name, - "mime_type": mime_type, - "extension": extension, - "size_bytes": len(content), - "sha256": checksum, - "created_at": now, - "updated_at": now, - "uploaded_by": current_user.name, - "version_number": 1, - } - index["documents"].append(entry) - logger.info( - "Knowledge document uploaded id=%s folder=%s filename=%s by=%s", - document_id, - normalized_folder, - normalized_name, - current_user.name, - ) - else: - existing_entry.update( - { - "stored_name": stored_name, - "mime_type": mime_type, - "extension": extension, - "size_bytes": len(content), - "sha256": checksum, - "updated_at": now, - "uploaded_by": current_user.name, - "version_number": int(existing_entry.get("version_number", 1)) + 1, - } - ) - entry = existing_entry - logger.info( - "Knowledge document updated id=%s folder=%s filename=%s by=%s", - document_id, - normalized_folder, - normalized_name, - current_user.name, - ) - - self._save_index(index) - return self.get_document_detail(document_id) - - def delete_document(self, document_id: str) -> None: - self.ensure_library_ready() - index = self._load_index() - entry = self._require_entry(index, document_id) - file_path = self._resolve_document_path(entry) - if file_path.exists(): - file_path.unlink() - - index["documents"] = [item for item in index["documents"] if item["id"] != document_id] - self._save_index(index) - logger.info("Knowledge document deleted id=%s filename=%s", document_id, entry["original_name"]) - - def get_document_content(self, document_id: str) -> tuple[Path, str, str]: - self.ensure_library_ready() - index = self._load_index() - entry = self._require_entry(index, document_id) - file_path = self._resolve_document_path(entry) - - if not file_path.exists(): - raise FileNotFoundError(entry["original_name"]) - - return file_path, entry["mime_type"], entry["original_name"] - - def build_onlyoffice_config( - self, - document_id: str, - current_user: CurrentUserContext, - ) -> KnowledgeOnlyOfficeConfigRead: - self.ensure_library_ready() - settings = get_settings() - if not settings.onlyoffice_enabled: - raise ValueError("ONLYOFFICE 预览未启用。") - if not settings.onlyoffice_public_url or not settings.onlyoffice_backend_url: - raise ValueError("ONLYOFFICE 地址配置不完整。") - if not settings.onlyoffice_jwt_secret: - raise ValueError("ONLYOFFICE JWT 密钥未配置。") - - index = self._load_index() - entry = self._require_entry(index, document_id) - extension = self._extract_extension(entry["original_name"]) - if extension not in ONLYOFFICE_EDITABLE_EXTENSIONS: - raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。") - - document_type = self._resolve_onlyoffice_document_type(extension) - backend_base_url = settings.onlyoffice_backend_url.rstrip("/") - public_url = settings.onlyoffice_public_url.rstrip("/") - access_token = self._build_onlyoffice_access_token(document_id) - document_url = ( - f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/content" - f"?access_token={access_token}" - ) - callback_url = ( - f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/callback" - ) - can_edit = current_user.is_admin or "manager" in current_user.role_codes - document_key = self._build_onlyoffice_document_key(entry) - - config: dict[str, Any] = { - "documentType": document_type, - "document": { - "fileType": extension, - "key": document_key, - "title": entry["original_name"], - "url": document_url, - "permissions": { - "download": True, - "edit": can_edit, - "print": True, - "copy": True, - }, - }, - "editorConfig": { - "mode": "edit" if can_edit else "view", - "lang": "zh-CN", - "callbackUrl": callback_url, - "user": { - "id": current_user.username, - "name": current_user.name, - }, - "customization": { - "compactHeader": True, - "compactToolbar": True, - "toolbarNoTabs": False, - "autosave": can_edit, - "forcesave": can_edit, - }, - }, - "width": "100%", - "height": "100%", - } - config["token"] = jwt.encode(config, settings.onlyoffice_jwt_secret, algorithm="HS256") - - return KnowledgeOnlyOfficeConfigRead( - documentServerUrl=public_url, - config=config, - ) - - def validate_onlyoffice_access_token(self, document_id: str, access_token: str) -> None: - settings = get_settings() - try: - payload = jwt.decode( - access_token, - settings.onlyoffice_jwt_secret, - algorithms=["HS256"], - ) - except jwt.PyJWTError as exc: - raise ValueError("ONLYOFFICE 文件访问令牌无效。") from exc - - if payload.get("scope") != "onlyoffice-content" or payload.get("document_id") != document_id: - raise ValueError("ONLYOFFICE 文件访问令牌无效。") - - def handle_onlyoffice_callback(self, document_id: str, payload: dict[str, Any]) -> None: - self.ensure_library_ready() - callback = self._parse_onlyoffice_callback(payload) - if callback.status not in {2, 6} or not callback.download_url: - return - - logger.info( - "ONLYOFFICE callback received id=%s status=%s users=%s", - document_id, - callback.status, - ",".join(callback.users) if callback.users else "-", - ) - - request = Request(callback.download_url, headers={"User-Agent": "x-financial-onlyoffice"}) - with urlopen(request, timeout=30) as response: # noqa: S310 - content = response.read() - - actor_name = callback.users[0] if callback.users else "ONLYOFFICE" - self._replace_document_content(document_id, content, actor_name=actor_name) - - def _load_documents(self) -> list[KnowledgeDocumentRead]: - self.ensure_library_ready() - index = self._load_index() - self._reconcile_index(index) - self._save_index(index) - - documents = [self._serialize_document(entry) for entry in index["documents"]] - return sorted(documents, key=lambda item: item.time, reverse=True) - - def _serialize_document(self, entry: dict[str, Any]) -> KnowledgeDocumentRead: - extension = entry.get("extension") or self._extract_extension(entry["original_name"]) - file_type = self._resolve_file_type(extension) - size_bytes = int(entry.get("size_bytes") or 0) - updated_at = self._format_time(entry.get("updated_at") or entry.get("created_at")) - - return KnowledgeDocumentRead( - id=entry["id"], - name=entry["original_name"], - folder=entry["folder"], - tag=f"{entry['folder']} / {extension.upper() or 'FILE'}", - time=updated_at, - version=f"v{int(entry.get('version_number', 1))}.0", - state="已发布", - stateTone="success", - owner=entry.get("uploaded_by") or "系统导入", - icon=ICON_BY_TYPE.get(file_type, ICON_BY_TYPE["binary"]), - fileType=file_type, - fileTypeLabel=self._resolve_file_type_label(file_type), - summary=f"{entry['folder']} · {extension.upper() or 'FILE'} · {self._format_size(size_bytes)}", - mimeType=entry.get("mime_type") or "application/octet-stream", - extension=extension, - sizeBytes=size_bytes, - canPreview=self._can_preview(extension), - ) - - def _build_preview( - self, entry: dict[str, Any] - ) -> tuple[str, list[KnowledgePreviewPageRead]]: - extension = self._extract_extension(entry["original_name"]) - file_path = self._resolve_document_path(entry) - - if extension == "pdf": - return "pdf", [] - - if extension in IMAGE_EXTENSIONS: - return "image", [] - - if extension in TEXT_EXTENSIONS: - text = self._read_text_preview(file_path) - return "text", [self._build_text_preview_page(entry, text)] - - if extension == "docx": - text = self._extract_docx_text(file_path) - return "text", [self._build_text_preview_page(entry, text)] - - if extension == "xlsx": - return "table", self._build_xlsx_preview_pages(entry, file_path) - - if extension == "pptx": - return "slides", self._build_pptx_preview_pages(entry, file_path) - - return ( - "unsupported", - [ - KnowledgePreviewPageRead( - title=entry["original_name"], - subtitle="当前格式暂不支持在线解析预览。", - stats=[ - KnowledgePreviewStatRead(label="文件格式", value=extension.upper() or "FILE"), - KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])), - KnowledgePreviewStatRead(label="建议操作", value="下载后查看"), - ], - blocks=[ - KnowledgePreviewBlockRead( - heading="预览说明", - lines=[ - "当前系统已支持该文件的上传、下载和权限控制。", - "如需在线预览,可后续接入专门的文档转换服务。", - ], - ) - ], - ) - ], - ) - - def _build_text_preview_page( - self, entry: dict[str, Any], text: str - ) -> KnowledgePreviewPageRead: - lines = [line.strip() for line in text.splitlines() if line.strip()] - if not lines: - lines = ["文件内容为空,或当前文档未提取到可展示文本。"] - - groups = [lines[index : index + 8] for index in range(0, min(len(lines), 24), 8)] - blocks = [ - KnowledgePreviewBlockRead(heading=f"内容片段 {index + 1}", lines=group) - for index, group in enumerate(groups) - ] - - return KnowledgePreviewPageRead( - title=entry["original_name"], - subtitle="文本提取预览", - stats=[ - KnowledgePreviewStatRead(label="文件格式", value=entry["extension"].upper() or "TEXT"), - KnowledgePreviewStatRead(label="可见行数", value=str(len(lines))), - KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])), - ], - blocks=blocks, - ) - - def _build_xlsx_preview_pages( - self, entry: dict[str, Any], file_path: Path - ) -> list[KnowledgePreviewPageRead]: - sheets = self._extract_xlsx_sheets(file_path) - if not sheets: - sheets = [("Sheet 1", [["未提取到表格内容。"]])] - - preview_pages: list[KnowledgePreviewPageRead] = [] - sheet_count = len(sheets) - for sheet_name, rows in sheets[:8]: - visible_rows = rows[:12] if rows else [["未提取到表格内容。"]] - blocks = [ - KnowledgePreviewBlockRead( - heading=f"第 {index + 1} 行", - lines=[" | ".join((cell or "") for cell in row)], - ) - for index, row in enumerate(visible_rows) - ] - - preview_pages.append( - KnowledgePreviewPageRead( - title=sheet_name, - subtitle="表格内容预览", - stats=[ - KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)), - KnowledgePreviewStatRead(label="预览行数", value=str(len(visible_rows))), - KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])), - ], - blocks=blocks, - ) - ) - - return preview_pages - - def _build_pptx_preview_pages( - self, entry: dict[str, Any], file_path: Path - ) -> list[KnowledgePreviewPageRead]: - slides = self._extract_pptx_slides(file_path) - if not slides: - slides = [["未提取到幻灯片文本。"]] - - pages: list[KnowledgePreviewPageRead] = [] - for index, slide_lines in enumerate(slides[:8]): - pages.append( - KnowledgePreviewPageRead( - title=entry["original_name"], - subtitle=f"幻灯片 {index + 1}", - stats=[ - KnowledgePreviewStatRead(label="页码", value=str(index + 1)), - KnowledgePreviewStatRead(label="文本条数", value=str(len(slide_lines))), - KnowledgePreviewStatRead(label="文件格式", value="PPTX"), - ], - blocks=[ - KnowledgePreviewBlockRead( - heading="幻灯片内容", - lines=slide_lines or ["该页未提取到文本内容。"], - ) - ], - ) - ) - - return pages - - def _load_index(self) -> dict[str, Any]: - try: - payload = json.loads(self.index_path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError): - payload = {"version": 1, "documents": []} - payload.setdefault("documents", []) - return payload - - def _save_index(self, index: dict[str, Any]) -> None: - self.index_path.write_text( - json.dumps(index, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - - def _reconcile_index(self, index: dict[str, Any]) -> bool: - changed = False - documents = index.setdefault("documents", []) - known_by_stored = { - (item["folder"], item["stored_name"]): item - for item in documents - if item.get("folder") and item.get("stored_name") - } - - existing_items: list[dict[str, Any]] = [] - for item in documents: - file_path = self._resolve_document_path(item) - if file_path.exists(): - item["size_bytes"] = file_path.stat().st_size - item["extension"] = self._extract_extension(item["original_name"]) - item["mime_type"] = item.get("mime_type") or ( - mimetypes.guess_type(item["original_name"])[0] or "application/octet-stream" - ) - existing_items.append(item) - else: - changed = True - - for folder_name in FIXED_KNOWLEDGE_FOLDERS: - folder_path = self.library_root / folder_name - for file_path in folder_path.iterdir(): - if not file_path.is_file() or file_path.name.startswith("."): - continue - - key = (folder_name, file_path.name) - if key in known_by_stored: - continue - - document_id, original_name = self._parse_stored_name(file_path.name) - stat = file_path.stat() - existing_items.append( - { - "id": document_id, - "folder": folder_name, - "original_name": original_name, - "stored_name": file_path.name, - "mime_type": mimetypes.guess_type(original_name)[0] - or "application/octet-stream", - "extension": self._extract_extension(original_name), - "size_bytes": stat.st_size, - "sha256": "", - "created_at": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(), - "updated_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(), - "uploaded_by": "系统导入", - "version_number": 1, - } - ) - changed = True - - if changed or len(existing_items) != len(documents): - index["documents"] = existing_items - return True - return False - - def _require_entry(self, index: dict[str, Any], document_id: str) -> dict[str, Any]: - for entry in index["documents"]: - if entry["id"] == document_id: - return entry - raise FileNotFoundError(document_id) - - def _resolve_document_path(self, entry: dict[str, Any]) -> Path: - return self.library_root / entry["folder"] / entry["stored_name"] - - def _replace_document_content(self, document_id: str, content: bytes, actor_name: str) -> KnowledgeDocumentDetailRead: - index = self._load_index() - entry = self._require_entry(index, document_id) - current_user = CurrentUserContext( - username="onlyoffice", - name=actor_name or "ONLYOFFICE", - role_codes=["manager"], - is_admin=True, - ) - return self.upload_document( - folder=entry["folder"], - filename=entry["original_name"], - content=content, - current_user=current_user, - ) - - @staticmethod - def _parse_onlyoffice_callback(payload: dict[str, Any]) -> OnlyOfficeCallbackPayload: - status = int(payload.get("status") or 0) - download_url = str(payload.get("url") or "").strip() - users = [str(item).strip() for item in payload.get("users") or [] if str(item).strip()] - return OnlyOfficeCallbackPayload(status=status, download_url=download_url, users=users) - - @staticmethod - def _normalize_filename(filename: str) -> str: - normalized = Path(str(filename or "").strip()).name.strip() - normalized = normalized.replace("/", "_").replace("\\", "_") - if not normalized: - raise ValueError("文件名不能为空。") - return normalized - - @staticmethod - def _normalize_folder(folder: str) -> str: - normalized = str(folder or "").strip() - if normalized not in FIXED_KNOWLEDGE_FOLDERS: - raise ValueError("只能上传到预设知识库文件夹。") - return normalized - - @staticmethod - def _extract_extension(filename: str) -> str: - suffix = Path(filename).suffix.lower().lstrip(".") - return suffix - - @staticmethod - def _build_onlyoffice_document_key(entry: dict[str, Any]) -> str: - version = int(entry.get("version_number", 1)) - checksum = str(entry.get("sha256") or "")[:12] - return f"{entry['id']}-v{version}-{checksum or 'nochecksum'}" - - def _build_onlyoffice_access_token(self, document_id: str) -> str: - settings = get_settings() - payload = { - "scope": "onlyoffice-content", - "document_id": document_id, - } - return jwt.encode(payload, settings.onlyoffice_jwt_secret, algorithm="HS256") - - @staticmethod - def _resolve_onlyoffice_document_type(extension: str) -> str: - if extension in WORD_EXTENSIONS: - return "word" - if extension in EXCEL_EXTENSIONS: - return "cell" - if extension in PPT_EXTENSIONS: - return "slide" - raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。") - - @staticmethod - def _parse_stored_name(stored_name: str) -> tuple[str, str]: - if "__" not in stored_name: - return uuid4().hex, stored_name - document_id, original_name = stored_name.split("__", 1) - return document_id or uuid4().hex, original_name or stored_name - - @staticmethod - def _format_time(value: str | None) -> str: - if not value: - return "" - try: - parsed = datetime.fromisoformat(value) - except ValueError: - return value - return parsed.astimezone(UTC).strftime("%Y-%m-%d %H:%M") - - @staticmethod - def _format_size(size_bytes: int) -> str: - if size_bytes < 1024: - return f"{size_bytes} B" - if size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - return f"{size_bytes / (1024 * 1024):.1f} MB" - - @staticmethod - def _resolve_file_type(extension: str) -> str: - if extension == "pdf": - return "pdf" - if extension in WORD_EXTENSIONS: - return "word" - if extension in EXCEL_EXTENSIONS: - return "excel" - if extension in PPT_EXTENSIONS: - return "ppt" - if extension in IMAGE_EXTENSIONS: - return "image" - if extension in TEXT_EXTENSIONS: - return "text" - if extension in ARCHIVE_EXTENSIONS: - return "archive" - return "binary" - - @staticmethod - def _resolve_file_type_label(file_type: str) -> str: - mapping = { - "pdf": "PDF 预览", - "word": "Word 预览", - "excel": "Excel 预览", - "ppt": "PPT 预览", - "image": "图片预览", - "text": "文本预览", - "archive": "压缩包", - "binary": "文件预览", - } - return mapping.get(file_type, "文件预览") - - @staticmethod - def _can_preview(extension: str) -> bool: - return extension in INLINE_PREVIEW_EXTENSIONS or extension in STRUCTURED_PREVIEW_EXTENSIONS - - @staticmethod - def _read_text_preview(file_path: Path) -> str: - encodings = ("utf-8", "utf-8-sig", "gbk") - for encoding in encodings: - try: - return file_path.read_text(encoding=encoding) - except UnicodeDecodeError: - continue - return "当前文本文件编码暂不支持在线解析。" - - @staticmethod - def _extract_docx_text(file_path: Path) -> str: - try: - with ZipFile(file_path) as archive: - xml_content = archive.read("word/document.xml") - except (BadZipFile, KeyError): - return "当前 Word 文件解析失败。" - - root = ElementTree.fromstring(xml_content) - texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] - return "\n".join(texts) - - @staticmethod - def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]: - try: - with ZipFile(file_path) as archive: - shared_strings: list[str] = [] - if "xl/sharedStrings.xml" in archive.namelist(): - shared_root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml")) - shared_strings = [ - "".join(node.itertext()).strip() - for node in shared_root.iter() - if node.tag.endswith("}si") - ] - - sheet_files = sorted( - name - for name in archive.namelist() - if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name) - ) - if not sheet_files: - return [] - - relationship_targets: dict[str, str] = {} - if "xl/_rels/workbook.xml.rels" in archive.namelist(): - rel_root = ElementTree.fromstring(archive.read("xl/_rels/workbook.xml.rels")) - for node in rel_root.iter(): - if not node.tag.endswith("Relationship"): - continue - rel_id = node.attrib.get("Id") - target = node.attrib.get("Target") - if not rel_id or not target: - continue - normalized = target.lstrip("/") - if not normalized.startswith("xl/"): - normalized = f"xl/{normalized.lstrip('./')}" - relationship_targets[rel_id] = normalized - - ordered_sheets: list[tuple[str, str]] = [] - if "xl/workbook.xml" in archive.namelist(): - workbook_root = ElementTree.fromstring(archive.read("xl/workbook.xml")) - for index, node in enumerate(workbook_root.iter()): - if not node.tag.endswith("sheet"): - continue - sheet_name = node.attrib.get("name") or f"Sheet {index + 1}" - relationship_id = next( - (value for key, value in node.attrib.items() if key.endswith("}id")), - None, - ) - target = relationship_targets.get(relationship_id or "") - if target: - ordered_sheets.append((sheet_name, target)) - - if not ordered_sheets: - ordered_sheets = [ - (f"Sheet {index + 1}", sheet_file) - for index, sheet_file in enumerate(sheet_files) - ] - - preview_sheets: list[tuple[str, list[list[str]]]] = [] - for sheet_name, target in ordered_sheets: - if target not in archive.namelist(): - continue - - sheet_root = ElementTree.fromstring(archive.read(target)) - rows: list[list[str]] = [] - for row in sheet_root.iter(): - if not row.tag.endswith("}row"): - continue - row_values: list[str] = [] - for cell in row: - if not cell.tag.endswith("}c"): - continue - cell_type = cell.attrib.get("t") - value_node = next((item for item in cell if item.tag.endswith("}v")), None) - - if cell_type == "inlineStr": - text_node = next((item for item in cell.iter() if item.tag.endswith("}t")), None) - row_values.append((text_node.text or "").strip() if text_node is not None else "") - continue - - if value_node is None or value_node.text is None: - row_values.append("") - continue - - raw_value = value_node.text.strip() - if cell_type == "s" and raw_value.isdigit(): - index = int(raw_value) - row_values.append(shared_strings[index] if index < len(shared_strings) else raw_value) - else: - row_values.append(raw_value) - if row_values: - rows.append(row_values) - - preview_sheets.append((sheet_name, rows)) - - return preview_sheets - except (BadZipFile, ElementTree.ParseError, KeyError, ValueError): - return [] - - @staticmethod - def _extract_pptx_slides(file_path: Path) -> list[list[str]]: - try: - with ZipFile(file_path) as archive: - slide_names = sorted( - name - for name in archive.namelist() - if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) - ) - slides: list[list[str]] = [] - for slide_name in slide_names: - root = ElementTree.fromstring(archive.read(slide_name)) - texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] - slides.append(texts) - return slides - except (BadZipFile, ElementTree.ParseError, KeyError): - return [] +from __future__ import annotations + +import hashlib +import json +import mimetypes +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib.request import Request, urlopen +from uuid import uuid4 +from xml.etree import ElementTree +from zipfile import BadZipFile, ZipFile + +import jwt + +from app.api.deps import CurrentUserContext +from app.core.config import get_settings +from app.core.logging import get_logger +from app.schemas.knowledge import ( + KnowledgeDocumentDetailRead, + KnowledgeDocumentRead, + KnowledgeFolderRead, + KnowledgeLibraryRead, + KnowledgeOnlyOfficeConfigRead, + KnowledgePreviewBlockRead, + KnowledgePreviewPageRead, + KnowledgePreviewStatRead, +) + +logger = get_logger("app.services.knowledge") + +FIXED_KNOWLEDGE_FOLDERS = [ + "财务知识库", + "制度政策", + "报销制度", + "差旅规范", + "发票管理", + "税务合规", + "预算管理", + "财务共享", + "培训资料", + "常见问答", +] + +ICON_BY_TYPE = { + "pdf": "mdi mdi-file-document-outline-pdf pdf", + "word": "mdi mdi-file-document-outline-word word", + "excel": "mdi mdi-file-document-outline-excel excel", + "ppt": "mdi mdi-file-powerpoint-box ppt", + "image": "mdi mdi-file-image-outline image", + "text": "mdi mdi-file-document-outline text", + "archive": "mdi mdi-folder-zip-outline archive", + "binary": "mdi mdi-file-outline", +} + +TEXT_EXTENSIONS = {"txt", "md", "csv", "json", "xml", "yml", "yaml", "log"} +WORD_EXTENSIONS = {"doc", "docx"} +EXCEL_EXTENSIONS = {"xls", "xlsx", "csv"} +PPT_EXTENSIONS = {"ppt", "pptx"} +IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"} +ARCHIVE_EXTENSIONS = {"zip", "rar", "7z"} +STRUCTURED_PREVIEW_EXTENSIONS = {"docx", "xlsx", "pptx"} | TEXT_EXTENSIONS +INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS +ONLYOFFICE_EDITABLE_EXTENSIONS = {"docx", "xlsx", "pptx"} + + +@dataclass(slots=True) +class OnlyOfficeCallbackPayload: + status: int + download_url: str + users: list[str] + + +def prepare_knowledge_library() -> None: + KnowledgeService().ensure_library_ready() + + +class KnowledgeService: + def __init__(self, storage_root: Path | None = None) -> None: + settings = get_settings() + self.storage_root = Path(storage_root or settings.resolved_storage_root_dir) + self.library_root = self.storage_root / "knowledge" + self.index_path = self.library_root / ".index.json" + + def ensure_library_ready(self) -> None: + self.library_root.mkdir(parents=True, exist_ok=True) + for folder_name in FIXED_KNOWLEDGE_FOLDERS: + (self.library_root / folder_name).mkdir(parents=True, exist_ok=True) + + if not self.index_path.exists(): + self._save_index({"version": 1, "documents": []}) + + index = self._load_index() + if self._reconcile_index(index): + self._save_index(index) + + def list_library(self) -> KnowledgeLibraryRead: + documents = self._load_documents() + folders = [ + KnowledgeFolderRead( + name=folder_name, + count=sum(1 for item in documents if item.folder == folder_name), + icon="mdi mdi-folder-open" if folder_name == "差旅规范" else "mdi mdi-folder", + ) + for folder_name in FIXED_KNOWLEDGE_FOLDERS + ] + return KnowledgeLibraryRead(folders=folders, documents=documents) + + def get_document_detail(self, document_id: str) -> KnowledgeDocumentDetailRead: + self.ensure_library_ready() + index = self._load_index() + entry = self._require_entry(index, document_id) + preview_kind, preview_pages = self._build_preview(entry) + document = self._serialize_document(entry) + return KnowledgeDocumentDetailRead( + **document.model_dump(), + previewKind=preview_kind, + previewPages=preview_pages, + ) + + def upload_document( + self, + folder: str, + filename: str, + content: bytes, + current_user: CurrentUserContext, + ) -> KnowledgeDocumentDetailRead: + self.ensure_library_ready() + normalized_folder = self._normalize_folder(folder) + normalized_name = self._normalize_filename(filename) + + if not content: + raise ValueError("上传文件不能为空。") + + index = self._load_index() + existing_entry = next( + ( + item + for item in index["documents"] + if item["folder"] == normalized_folder + and item["original_name"].lower() == normalized_name.lower() + ), + None, + ) + + document_id = existing_entry["id"] if existing_entry else uuid4().hex + stored_name = f"{document_id}__{normalized_name}" + target_path = self.library_root / normalized_folder / stored_name + + if existing_entry is not None and existing_entry["stored_name"] != stored_name: + old_path = self.library_root / existing_entry["folder"] / existing_entry["stored_name"] + if old_path.exists(): + old_path.unlink() + + target_path.write_bytes(content) + + now = datetime.now(UTC).isoformat() + mime_type = mimetypes.guess_type(normalized_name)[0] or "application/octet-stream" + checksum = hashlib.sha256(content).hexdigest() + extension = self._extract_extension(normalized_name) + + if existing_entry is None: + entry = { + "id": document_id, + "folder": normalized_folder, + "original_name": normalized_name, + "stored_name": stored_name, + "mime_type": mime_type, + "extension": extension, + "size_bytes": len(content), + "sha256": checksum, + "created_at": now, + "updated_at": now, + "uploaded_by": current_user.name, + "version_number": 1, + } + index["documents"].append(entry) + logger.info( + "Knowledge document uploaded id=%s folder=%s filename=%s by=%s", + document_id, + normalized_folder, + normalized_name, + current_user.name, + ) + else: + existing_entry.update( + { + "stored_name": stored_name, + "mime_type": mime_type, + "extension": extension, + "size_bytes": len(content), + "sha256": checksum, + "updated_at": now, + "uploaded_by": current_user.name, + "version_number": int(existing_entry.get("version_number", 1)) + 1, + } + ) + entry = existing_entry + logger.info( + "Knowledge document updated id=%s folder=%s filename=%s by=%s", + document_id, + normalized_folder, + normalized_name, + current_user.name, + ) + + self._save_index(index) + return self.get_document_detail(document_id) + + def delete_document(self, document_id: str) -> None: + self.ensure_library_ready() + index = self._load_index() + entry = self._require_entry(index, document_id) + file_path = self._resolve_document_path(entry) + if file_path.exists(): + file_path.unlink() + + index["documents"] = [item for item in index["documents"] if item["id"] != document_id] + self._save_index(index) + logger.info("Knowledge document deleted id=%s filename=%s", document_id, entry["original_name"]) + + def get_document_content(self, document_id: str) -> tuple[Path, str, str]: + self.ensure_library_ready() + index = self._load_index() + entry = self._require_entry(index, document_id) + file_path = self._resolve_document_path(entry) + + if not file_path.exists(): + raise FileNotFoundError(entry["original_name"]) + + return file_path, entry["mime_type"], entry["original_name"] + + def build_onlyoffice_config( + self, + document_id: str, + current_user: CurrentUserContext, + ) -> KnowledgeOnlyOfficeConfigRead: + self.ensure_library_ready() + settings = get_settings() + if not settings.onlyoffice_enabled: + raise ValueError("ONLYOFFICE 预览未启用。") + if not settings.onlyoffice_public_url or not settings.onlyoffice_backend_url: + raise ValueError("ONLYOFFICE 地址配置不完整。") + if not settings.onlyoffice_jwt_secret: + raise ValueError("ONLYOFFICE JWT 密钥未配置。") + + index = self._load_index() + entry = self._require_entry(index, document_id) + extension = self._extract_extension(entry["original_name"]) + if extension not in ONLYOFFICE_EDITABLE_EXTENSIONS: + raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。") + + document_type = self._resolve_onlyoffice_document_type(extension) + backend_base_url = settings.onlyoffice_backend_url.rstrip("/") + public_url = settings.onlyoffice_public_url.rstrip("/") + access_token = self._build_onlyoffice_access_token(document_id) + document_url = ( + f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/content" + f"?access_token={access_token}" + ) + callback_url = ( + f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/callback" + ) + can_edit = current_user.is_admin or "manager" in current_user.role_codes + document_key = self._build_onlyoffice_document_key(entry) + + config: dict[str, Any] = { + "documentType": document_type, + "document": { + "fileType": extension, + "key": document_key, + "title": entry["original_name"], + "url": document_url, + "permissions": { + "download": True, + "edit": can_edit, + "print": True, + "copy": True, + }, + }, + "editorConfig": { + "mode": "edit" if can_edit else "view", + "lang": "zh-CN", + "callbackUrl": callback_url, + "user": { + "id": current_user.username, + "name": current_user.name, + }, + "customization": { + "compactHeader": True, + "compactToolbar": True, + "toolbarNoTabs": False, + "autosave": can_edit, + "forcesave": can_edit, + }, + }, + "width": "100%", + "height": "100%", + } + config["token"] = jwt.encode(config, settings.onlyoffice_jwt_secret, algorithm="HS256") + + return KnowledgeOnlyOfficeConfigRead( + documentServerUrl=public_url, + config=config, + ) + + def validate_onlyoffice_access_token(self, document_id: str, access_token: str) -> None: + settings = get_settings() + try: + payload = jwt.decode( + access_token, + settings.onlyoffice_jwt_secret, + algorithms=["HS256"], + ) + except jwt.PyJWTError as exc: + raise ValueError("ONLYOFFICE 文件访问令牌无效。") from exc + + if payload.get("scope") != "onlyoffice-content" or payload.get("document_id") != document_id: + raise ValueError("ONLYOFFICE 文件访问令牌无效。") + + def handle_onlyoffice_callback(self, document_id: str, payload: dict[str, Any]) -> None: + self.ensure_library_ready() + callback = self._parse_onlyoffice_callback(payload) + if callback.status not in {2, 6} or not callback.download_url: + return + + logger.info( + "ONLYOFFICE callback received id=%s status=%s users=%s", + document_id, + callback.status, + ",".join(callback.users) if callback.users else "-", + ) + + request = Request(callback.download_url, headers={"User-Agent": "x-financial-onlyoffice"}) + with urlopen(request, timeout=30) as response: # noqa: S310 + content = response.read() + + actor_name = callback.users[0] if callback.users else "ONLYOFFICE" + self._replace_document_content(document_id, content, actor_name=actor_name) + + def _load_documents(self) -> list[KnowledgeDocumentRead]: + self.ensure_library_ready() + index = self._load_index() + self._reconcile_index(index) + self._save_index(index) + + documents = [self._serialize_document(entry) for entry in index["documents"]] + return sorted(documents, key=lambda item: item.time, reverse=True) + + def _serialize_document(self, entry: dict[str, Any]) -> KnowledgeDocumentRead: + extension = entry.get("extension") or self._extract_extension(entry["original_name"]) + file_type = self._resolve_file_type(extension) + size_bytes = int(entry.get("size_bytes") or 0) + updated_at = self._format_time(entry.get("updated_at") or entry.get("created_at")) + + return KnowledgeDocumentRead( + id=entry["id"], + name=entry["original_name"], + folder=entry["folder"], + tag=f"{entry['folder']} / {extension.upper() or 'FILE'}", + time=updated_at, + version=f"v{int(entry.get('version_number', 1))}.0", + state="已发布", + stateTone="success", + owner=entry.get("uploaded_by") or "系统导入", + icon=ICON_BY_TYPE.get(file_type, ICON_BY_TYPE["binary"]), + fileType=file_type, + fileTypeLabel=self._resolve_file_type_label(file_type), + summary=f"{entry['folder']} · {extension.upper() or 'FILE'} · {self._format_size(size_bytes)}", + mimeType=entry.get("mime_type") or "application/octet-stream", + extension=extension, + sizeBytes=size_bytes, + canPreview=self._can_preview(extension), + ) + + def _build_preview( + self, entry: dict[str, Any] + ) -> tuple[str, list[KnowledgePreviewPageRead]]: + extension = self._extract_extension(entry["original_name"]) + file_path = self._resolve_document_path(entry) + + if extension == "pdf": + return "pdf", [] + + if extension in IMAGE_EXTENSIONS: + return "image", [] + + if extension in TEXT_EXTENSIONS: + text = self._read_text_preview(file_path) + return "text", [self._build_text_preview_page(entry, text)] + + if extension == "docx": + text = self._extract_docx_text(file_path) + return "text", [self._build_text_preview_page(entry, text)] + + if extension == "xlsx": + return "table", self._build_xlsx_preview_pages(entry, file_path) + + if extension == "pptx": + return "slides", self._build_pptx_preview_pages(entry, file_path) + + return ( + "unsupported", + [ + KnowledgePreviewPageRead( + title=entry["original_name"], + subtitle="当前格式暂不支持在线解析预览。", + stats=[ + KnowledgePreviewStatRead(label="文件格式", value=extension.upper() or "FILE"), + KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])), + KnowledgePreviewStatRead(label="建议操作", value="下载后查看"), + ], + blocks=[ + KnowledgePreviewBlockRead( + heading="预览说明", + lines=[ + "当前系统已支持该文件的上传、下载和权限控制。", + "如需在线预览,可后续接入专门的文档转换服务。", + ], + ) + ], + ) + ], + ) + + def _build_text_preview_page( + self, entry: dict[str, Any], text: str + ) -> KnowledgePreviewPageRead: + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + lines = ["文件内容为空,或当前文档未提取到可展示文本。"] + + groups = [lines[index : index + 8] for index in range(0, min(len(lines), 24), 8)] + blocks = [ + KnowledgePreviewBlockRead(heading=f"内容片段 {index + 1}", lines=group) + for index, group in enumerate(groups) + ] + + return KnowledgePreviewPageRead( + title=entry["original_name"], + subtitle="文本提取预览", + stats=[ + KnowledgePreviewStatRead(label="文件格式", value=entry["extension"].upper() or "TEXT"), + KnowledgePreviewStatRead(label="可见行数", value=str(len(lines))), + KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])), + ], + blocks=blocks, + ) + + def _build_xlsx_preview_pages( + self, entry: dict[str, Any], file_path: Path + ) -> list[KnowledgePreviewPageRead]: + sheets = self._extract_xlsx_sheets(file_path) + if not sheets: + sheets = [("Sheet 1", [["未提取到表格内容。"]])] + + preview_pages: list[KnowledgePreviewPageRead] = [] + sheet_count = len(sheets) + for sheet_name, rows in sheets[:8]: + visible_rows = rows[:12] if rows else [["未提取到表格内容。"]] + blocks = [ + KnowledgePreviewBlockRead( + heading=f"第 {index + 1} 行", + lines=[" | ".join((cell or "") for cell in row)], + ) + for index, row in enumerate(visible_rows) + ] + + preview_pages.append( + KnowledgePreviewPageRead( + title=sheet_name, + subtitle="表格内容预览", + stats=[ + KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)), + KnowledgePreviewStatRead(label="预览行数", value=str(len(visible_rows))), + KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])), + ], + blocks=blocks, + ) + ) + + return preview_pages + + def _build_pptx_preview_pages( + self, entry: dict[str, Any], file_path: Path + ) -> list[KnowledgePreviewPageRead]: + slides = self._extract_pptx_slides(file_path) + if not slides: + slides = [["未提取到幻灯片文本。"]] + + pages: list[KnowledgePreviewPageRead] = [] + for index, slide_lines in enumerate(slides[:8]): + pages.append( + KnowledgePreviewPageRead( + title=entry["original_name"], + subtitle=f"幻灯片 {index + 1}", + stats=[ + KnowledgePreviewStatRead(label="页码", value=str(index + 1)), + KnowledgePreviewStatRead(label="文本条数", value=str(len(slide_lines))), + KnowledgePreviewStatRead(label="文件格式", value="PPTX"), + ], + blocks=[ + KnowledgePreviewBlockRead( + heading="幻灯片内容", + lines=slide_lines or ["该页未提取到文本内容。"], + ) + ], + ) + ) + + return pages + + def _load_index(self) -> dict[str, Any]: + try: + payload = json.loads(self.index_path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + payload = {"version": 1, "documents": []} + payload.setdefault("documents", []) + return payload + + def _save_index(self, index: dict[str, Any]) -> None: + self.index_path.write_text( + json.dumps(index, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _reconcile_index(self, index: dict[str, Any]) -> bool: + changed = False + documents = index.setdefault("documents", []) + known_by_stored = { + (item["folder"], item["stored_name"]): item + for item in documents + if item.get("folder") and item.get("stored_name") + } + + existing_items: list[dict[str, Any]] = [] + for item in documents: + file_path = self._resolve_document_path(item) + if file_path.exists(): + item["size_bytes"] = file_path.stat().st_size + item["extension"] = self._extract_extension(item["original_name"]) + item["mime_type"] = item.get("mime_type") or ( + mimetypes.guess_type(item["original_name"])[0] or "application/octet-stream" + ) + existing_items.append(item) + else: + changed = True + + for folder_name in FIXED_KNOWLEDGE_FOLDERS: + folder_path = self.library_root / folder_name + for file_path in folder_path.iterdir(): + if not file_path.is_file() or file_path.name.startswith("."): + continue + + key = (folder_name, file_path.name) + if key in known_by_stored: + continue + + document_id, original_name = self._parse_stored_name(file_path.name) + stat = file_path.stat() + existing_items.append( + { + "id": document_id, + "folder": folder_name, + "original_name": original_name, + "stored_name": file_path.name, + "mime_type": mimetypes.guess_type(original_name)[0] + or "application/octet-stream", + "extension": self._extract_extension(original_name), + "size_bytes": stat.st_size, + "sha256": "", + "created_at": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(), + "updated_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(), + "uploaded_by": "系统导入", + "version_number": 1, + } + ) + changed = True + + if changed or len(existing_items) != len(documents): + index["documents"] = existing_items + return True + return False + + def _require_entry(self, index: dict[str, Any], document_id: str) -> dict[str, Any]: + for entry in index["documents"]: + if entry["id"] == document_id: + return entry + raise FileNotFoundError(document_id) + + def _resolve_document_path(self, entry: dict[str, Any]) -> Path: + return self.library_root / entry["folder"] / entry["stored_name"] + + def _replace_document_content(self, document_id: str, content: bytes, actor_name: str) -> KnowledgeDocumentDetailRead: + index = self._load_index() + entry = self._require_entry(index, document_id) + current_user = CurrentUserContext( + username="onlyoffice", + name=actor_name or "ONLYOFFICE", + role_codes=["manager"], + is_admin=True, + ) + return self.upload_document( + folder=entry["folder"], + filename=entry["original_name"], + content=content, + current_user=current_user, + ) + + @staticmethod + def _parse_onlyoffice_callback(payload: dict[str, Any]) -> OnlyOfficeCallbackPayload: + status = int(payload.get("status") or 0) + download_url = str(payload.get("url") or "").strip() + users = [str(item).strip() for item in payload.get("users") or [] if str(item).strip()] + return OnlyOfficeCallbackPayload(status=status, download_url=download_url, users=users) + + @staticmethod + def _normalize_filename(filename: str) -> str: + normalized = Path(str(filename or "").strip()).name.strip() + normalized = normalized.replace("/", "_").replace("\\", "_") + if not normalized: + raise ValueError("文件名不能为空。") + return normalized + + @staticmethod + def _normalize_folder(folder: str) -> str: + normalized = str(folder or "").strip() + if normalized not in FIXED_KNOWLEDGE_FOLDERS: + raise ValueError("只能上传到预设知识库文件夹。") + return normalized + + @staticmethod + def _extract_extension(filename: str) -> str: + suffix = Path(filename).suffix.lower().lstrip(".") + return suffix + + @staticmethod + def _build_onlyoffice_document_key(entry: dict[str, Any]) -> str: + version = int(entry.get("version_number", 1)) + checksum = str(entry.get("sha256") or "")[:12] + return f"{entry['id']}-v{version}-{checksum or 'nochecksum'}" + + def _build_onlyoffice_access_token(self, document_id: str) -> str: + settings = get_settings() + payload = { + "scope": "onlyoffice-content", + "document_id": document_id, + } + return jwt.encode(payload, settings.onlyoffice_jwt_secret, algorithm="HS256") + + @staticmethod + def _resolve_onlyoffice_document_type(extension: str) -> str: + if extension in WORD_EXTENSIONS: + return "word" + if extension in EXCEL_EXTENSIONS: + return "cell" + if extension in PPT_EXTENSIONS: + return "slide" + raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。") + + @staticmethod + def _parse_stored_name(stored_name: str) -> tuple[str, str]: + if "__" not in stored_name: + return uuid4().hex, stored_name + document_id, original_name = stored_name.split("__", 1) + return document_id or uuid4().hex, original_name or stored_name + + @staticmethod + def _format_time(value: str | None) -> str: + if not value: + return "" + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return value + return parsed.astimezone(UTC).strftime("%Y-%m-%d %H:%M") + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + return f"{size_bytes / (1024 * 1024):.1f} MB" + + @staticmethod + def _resolve_file_type(extension: str) -> str: + if extension == "pdf": + return "pdf" + if extension in WORD_EXTENSIONS: + return "word" + if extension in EXCEL_EXTENSIONS: + return "excel" + if extension in PPT_EXTENSIONS: + return "ppt" + if extension in IMAGE_EXTENSIONS: + return "image" + if extension in TEXT_EXTENSIONS: + return "text" + if extension in ARCHIVE_EXTENSIONS: + return "archive" + return "binary" + + @staticmethod + def _resolve_file_type_label(file_type: str) -> str: + mapping = { + "pdf": "PDF 预览", + "word": "Word 预览", + "excel": "Excel 预览", + "ppt": "PPT 预览", + "image": "图片预览", + "text": "文本预览", + "archive": "压缩包", + "binary": "文件预览", + } + return mapping.get(file_type, "文件预览") + + @staticmethod + def _can_preview(extension: str) -> bool: + return extension in INLINE_PREVIEW_EXTENSIONS or extension in STRUCTURED_PREVIEW_EXTENSIONS + + @staticmethod + def _read_text_preview(file_path: Path) -> str: + encodings = ("utf-8", "utf-8-sig", "gbk") + for encoding in encodings: + try: + return file_path.read_text(encoding=encoding) + except UnicodeDecodeError: + continue + return "当前文本文件编码暂不支持在线解析。" + + @staticmethod + def _extract_docx_text(file_path: Path) -> str: + try: + with ZipFile(file_path) as archive: + xml_content = archive.read("word/document.xml") + except (BadZipFile, KeyError): + return "当前 Word 文件解析失败。" + + root = ElementTree.fromstring(xml_content) + texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] + return "\n".join(texts) + + @staticmethod + def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]: + try: + with ZipFile(file_path) as archive: + shared_strings: list[str] = [] + if "xl/sharedStrings.xml" in archive.namelist(): + shared_root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml")) + shared_strings = [ + "".join(node.itertext()).strip() + for node in shared_root.iter() + if node.tag.endswith("}si") + ] + + sheet_files = sorted( + name + for name in archive.namelist() + if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name) + ) + if not sheet_files: + return [] + + relationship_targets: dict[str, str] = {} + if "xl/_rels/workbook.xml.rels" in archive.namelist(): + rel_root = ElementTree.fromstring(archive.read("xl/_rels/workbook.xml.rels")) + for node in rel_root.iter(): + if not node.tag.endswith("Relationship"): + continue + rel_id = node.attrib.get("Id") + target = node.attrib.get("Target") + if not rel_id or not target: + continue + normalized = target.lstrip("/") + if not normalized.startswith("xl/"): + normalized = f"xl/{normalized.lstrip('./')}" + relationship_targets[rel_id] = normalized + + ordered_sheets: list[tuple[str, str]] = [] + if "xl/workbook.xml" in archive.namelist(): + workbook_root = ElementTree.fromstring(archive.read("xl/workbook.xml")) + for index, node in enumerate(workbook_root.iter()): + if not node.tag.endswith("sheet"): + continue + sheet_name = node.attrib.get("name") or f"Sheet {index + 1}" + relationship_id = next( + (value for key, value in node.attrib.items() if key.endswith("}id")), + None, + ) + target = relationship_targets.get(relationship_id or "") + if target: + ordered_sheets.append((sheet_name, target)) + + if not ordered_sheets: + ordered_sheets = [ + (f"Sheet {index + 1}", sheet_file) + for index, sheet_file in enumerate(sheet_files) + ] + + preview_sheets: list[tuple[str, list[list[str]]]] = [] + for sheet_name, target in ordered_sheets: + if target not in archive.namelist(): + continue + + sheet_root = ElementTree.fromstring(archive.read(target)) + rows: list[list[str]] = [] + for row in sheet_root.iter(): + if not row.tag.endswith("}row"): + continue + row_values: list[str] = [] + for cell in row: + if not cell.tag.endswith("}c"): + continue + cell_type = cell.attrib.get("t") + value_node = next((item for item in cell if item.tag.endswith("}v")), None) + + if cell_type == "inlineStr": + text_node = next((item for item in cell.iter() if item.tag.endswith("}t")), None) + row_values.append((text_node.text or "").strip() if text_node is not None else "") + continue + + if value_node is None or value_node.text is None: + row_values.append("") + continue + + raw_value = value_node.text.strip() + if cell_type == "s" and raw_value.isdigit(): + index = int(raw_value) + row_values.append(shared_strings[index] if index < len(shared_strings) else raw_value) + else: + row_values.append(raw_value) + if row_values: + rows.append(row_values) + + preview_sheets.append((sheet_name, rows)) + + return preview_sheets + except (BadZipFile, ElementTree.ParseError, KeyError, ValueError): + return [] + + @staticmethod + def _extract_pptx_slides(file_path: Path) -> list[list[str]]: + try: + with ZipFile(file_path) as archive: + slide_names = sorted( + name + for name in archive.namelist() + if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) + ) + slides: list[list[str]] = [] + for slide_name in slide_names: + root = ElementTree.fromstring(archive.read(slide_name)) + texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text] + slides.append(texts) + return slides + except (BadZipFile, ElementTree.ParseError, KeyError): + return [] diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index aea83b8..7f72f04 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -2,16 +2,16 @@ "version": 1, "documents": [ { - "id": "fde293670eac4ae2b90a80eeb9f27b5b", + "id": "8af9350f0e02488aaf0df2001286b764", "folder": "财务知识库", "original_name": "差旅费季度报销258878.xlsx", - "stored_name": "fde293670eac4ae2b90a80eeb9f27b5b__差旅费季度报销258878.xlsx", + "stored_name": "8af9350f0e02488aaf0df2001286b764__差旅费季度报销258878.xlsx", "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "extension": "xlsx", "size_bytes": 11123, "sha256": "ea02e59d3a22a4a02284172acce3fd4c6367a26f1a4fd196dc4f65afed1bd4c5", - "created_at": "2026-05-09T03:33:44.101489+00:00", - "updated_at": "2026-05-09T03:33:44.101489+00:00", + "created_at": "2026-05-09T05:46:24.699125+00:00", + "updated_at": "2026-05-09T05:46:24.699125+00:00", "uploaded_by": "admin", "version_number": 1 } diff --git a/server/tests/test_server_start_dependencies.py b/server/tests/test_server_start_dependencies.py new file mode 100644 index 0000000..6a201b0 --- /dev/null +++ b/server/tests/test_server_start_dependencies.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path +import os +import stat +import subprocess + + +def test_dependencies_ready_fails_when_jwt_is_missing(tmp_path: Path) -> None: + fake_python = tmp_path / "fake-python.sh" + fake_python.write_text( + """#!/usr/bin/env bash +if [ "$1" = "-c" ]; then + case "$2" in + *jwt*) exit 1 ;; + *) exit 0 ;; + esac +fi +exit 0 +""", + encoding="utf-8", + ) + fake_python.chmod(fake_python.stat().st_mode | stat.S_IEXEC) + + script_path = Path(__file__).resolve().parents[1] / "server_start.sh" + script_prefix = script_path.read_text(encoding="utf-8").split('case "$MODE" in', 1)[0] + command = f"""{script_prefix} +PYTHON_BIN="{fake_python}" +dependencies_ready +""" + result = subprocess.run( + ["bash", "-c", command], + capture_output=True, + text=True, + env={**os.environ, "MODE": "test"}, + cwd=script_path.parent, + check=False, + ) + + assert result.returncode != 0 diff --git a/start.sh b/start.sh index 154d94a..c7679e7 100755 --- a/start.sh +++ b/start.sh @@ -38,6 +38,10 @@ fi ENV_OVERRIDE_WEB_HOST_SET=false ENV_OVERRIDE_SERVER_HOST_SET=false +ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false +ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false +ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false +ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=false if [ "${WEB_HOST+x}" = x ]; then ENV_OVERRIDE_WEB_HOST_SET=true @@ -49,6 +53,26 @@ if [ "${SERVER_HOST+x}" = x ]; then ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" fi +if [ "${ONLYOFFICE_ENABLED+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true + ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED" +fi + +if [ "${ONLYOFFICE_PUBLIC_URL+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=true + ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL="$ONLYOFFICE_PUBLIC_URL" +fi + +if [ "${ONLYOFFICE_BACKEND_URL+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=true + ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL="$ONLYOFFICE_BACKEND_URL" +fi + +if [ "${ONLYOFFICE_JWT_SECRET+x}" = x ]; then + ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=true + ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET="$ONLYOFFICE_JWT_SECRET" +fi + set -a . "$ENV_FILE" set +a @@ -61,6 +85,22 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" fi +if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then + ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED" +fi + +if [ "$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET" = true ]; then + ONLYOFFICE_PUBLIC_URL="$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL" +fi + +if [ "$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET" = true ]; then + ONLYOFFICE_BACKEND_URL="$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL" +fi + +if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then + ONLYOFFICE_JWT_SECRET="$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET" +fi + SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}" SETUP_COMPLETED="${SETUP_COMPLETED:-false}" APP_DEBUG="${APP_DEBUG:-true}" diff --git a/web/src/assets/styles/views/policies-view.css b/web/src/assets/styles/views/policies-view.css index a65e883..9a9b62d 100644 --- a/web/src/assets/styles/views/policies-view.css +++ b/web/src/assets/styles/views/policies-view.css @@ -1,880 +1,880 @@ -.knowledge-page { - height: 100%; - min-height: 0; - overflow: hidden; - animation: fadeUp 220ms var(--ease) both; -} - -.knowledge-grid { - height: 100%; - min-height: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) 0; - gap: 0; - transition: grid-template-columns 320ms var(--ease), gap 320ms var(--ease); -} - -.knowledge-grid.has-preview { - grid-template-columns: minmax(560px, 1fr) minmax(420px, 0.82fr); - gap: 16px; -} - -.knowledge-main, -.preview-column { - min-width: 0; - min-height: 0; -} - -.knowledge-main { - overflow: hidden; -} - -.library-panel { - height: 100%; - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - padding: 16px 18px; - overflow: hidden; -} - -.panel-title { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.panel-title h2, -.preview-head h2 { - color: #0f172a; - font-size: 16px; - font-weight: 850; -} - -.panel-title p, -.preview-head p { - margin-top: 6px; - color: #64748b; - font-size: 13px; - line-height: 1.5; -} - -.file-search { - width: min(320px, 100%); - min-height: 36px; - display: inline-flex; - align-items: center; - gap: 8px; - padding: 0 12px; - border: 1px solid #d7e0ea; - border-radius: 10px; - background: #fff; - color: #64748b; - transition: border-color 180ms ease, box-shadow 180ms ease; -} - -.file-search:focus-within { - border-color: #60a5fa; - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14); -} - -.file-search input { - width: 100%; - min-width: 0; - border: 0; - color: #0f172a; - font-size: 13px; - background: transparent; -} - -.file-search input:focus { - outline: none; -} - -.library-body { - min-height: 0; - display: grid; - grid-template-columns: 180px minmax(0, 1fr); - gap: 14px; - margin-top: 16px; -} - -.folder-rail { - min-height: 0; - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - gap: 12px; - border-right: 1px solid #edf2f7; - padding-right: 12px; -} - -.folder-tree { - min-height: 0; - display: grid; - align-content: start; - gap: 6px; - overflow-y: auto; -} - -.folder-tree button { - min-height: 34px; - display: grid; - grid-template-columns: 18px minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - padding: 0 9px; - border: 0; - border-radius: 7px; - background: transparent; - color: #334155; - font-size: 13px; - text-align: left; -} - -.folder-tree button.active { - background: #dcfce7; - color: #059669; - font-weight: 850; -} - -.folder-tree b { - min-width: 24px; - height: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - background: #f1f5f9; - color: #64748b; - font-size: 11px; -} - -.new-folder-btn { - min-height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border: 1px solid rgba(16, 185, 129, .28); - border-radius: 8px; - background: #f0fdf4; - color: #059669; - font-size: 13px; - font-weight: 850; -} - -.new-folder-btn.fixed { - border-color: rgba(148, 163, 184, 0.3); - background: #f8fafc; - color: #64748b; -} - -.document-area { - min-width: 0; - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - gap: 12px; -} - -.upload-input { - display: none; -} - -.upload-zone { - min-height: 112px; - display: grid; - place-items: center; - align-content: center; - gap: 8px; - border: 1px dashed #93c5fd; - border-radius: 10px; - background: #f8fbff; - color: #334155; - text-align: center; - cursor: pointer; - transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease; -} - -.upload-zone:hover { - border-color: #60a5fa; - background: #f3f8ff; -} - -.upload-zone.disabled { - cursor: default; - border-color: #cbd5e1; - background: #f8fafc; -} - -.upload-zone.busy { - opacity: 0.72; -} - -.upload-zone i { - color: #2563eb; - font-size: 31px; -} - -.upload-zone strong { - font-size: 13px; - font-weight: 850; -} - -.upload-zone span { - color: #64748b; - font-size: 12px; -} - -.doc-table-wrap { - min-height: 0; - overflow: auto; -} - -table { - width: 100%; - min-width: 690px; - border-collapse: collapse; -} - -th, -td { - padding: 12px 10px; - border-bottom: 1px solid #edf2f7; - color: #24324a; - font-size: 12px; - line-height: 1.35; - text-align: left; - vertical-align: middle; -} - -th { - background: #f7fafc; - color: #64748b; - font-weight: 800; - white-space: nowrap; -} - -.doc-row { - cursor: pointer; - transition: background 180ms ease, box-shadow 180ms ease; -} - -.doc-row:hover { - background: #f8fbff; -} - -.doc-row.selected { - background: linear-gradient(90deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.04)); - box-shadow: inset 3px 0 0 #10b981; -} - -.file-name { - display: inline-flex; - align-items: center; - gap: 7px; - font-weight: 750; - white-space: nowrap; -} - -.file-name .pdf, -.viewer-filetype.pdf { color: #ef4444; } -.file-name .word, -.viewer-filetype.word { color: #2563eb; } -.file-name .excel, -.viewer-filetype.excel { color: #10b981; } - -.doc-tag { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 7px; - border-radius: 6px; - background: #f1f5f9; - color: #64748b; - font-size: 11px; - font-weight: 750; -} - -.state-tag { - min-height: 22px; - display: inline-flex; - align-items: center; - padding: 0 8px; - border-radius: 6px; - font-size: 11px; - font-weight: 800; - white-space: nowrap; -} - -.state-tag.success { - background: #dcfce7; - color: #059669; -} - -.state-tag.warning { - background: #ffedd5; - color: #f97316; -} - -.more-btn { - width: 32px; - height: 32px; - display: grid; - place-items: center; - border: 0; - border-radius: 8px; - background: transparent; - color: #2563eb; -} - -.more-btn.danger { - color: #dc2626; -} - -.more-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.row-actions { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.empty-row { - color: #64748b; - text-align: center; -} - -.list-foot { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - 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 { - background: #059669; - color: #fff; - box-shadow: 0 8px 16px rgba(5, 150, 105, .20); -} - -.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: 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 { - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.preview-panel { - height: 100%; - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - padding: 20px 22px; - overflow: hidden; -} - -.preview-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - padding-bottom: 16px; - border-bottom: 1px solid #edf2f7; -} - -.preview-copy { - min-width: 0; -} - -.preview-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.mini-action, -.icon-action, -.viewer-toolbar-actions button { - border: 1px solid #d7e0ea; - border-radius: 8px; - background: #fff; - color: #334155; -} - -.mini-action { - min-height: 34px; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 12px; - font-size: 12px; - font-weight: 800; -} - -.icon-action { - width: 34px; - height: 34px; - display: grid; - place-items: center; -} - -.preview-summary-line { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - margin-top: 8px; - color: #64748b; - font-size: 13px; - line-height: 1.6; -} - -.preview-secondary-line { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - margin-top: 12px; - padding: 10px 12px; - border-radius: 10px; - background: #1e293b; - color: #e2e8f0; - font-size: 12px; - line-height: 1.5; -} - -.preview-viewer { - min-height: 0; - margin-top: 18px; -} - -.preview-status { - display: grid; - place-items: center; - min-height: 180px; - padding: 24px; - border: 1px dashed #cbd5e1; - border-radius: 14px; - background: #f8fafc; - color: #64748b; - font-size: 13px; - font-weight: 700; - text-align: center; -} - -.preview-status.error { - border-color: #fecaca; - background: #fef2f2; - color: #dc2626; -} - -.preview-embed-wrap, -.preview-image-wrap { - min-height: 0; - overflow: hidden; - border: 1px solid #edf2f7; - border-radius: 12px; - background: #fff; -} - -.preview-embed { - width: 100%; - height: 100%; - min-height: 560px; - border: 0; -} - -.preview-image-wrap { - display: grid; - place-items: center; - padding: 20px; -} - -.preview-image { - max-width: 100%; - max-height: 70vh; - object-fit: contain; -} - -.onlyoffice-preview-wrap { - min-height: 0; - overflow: hidden; - border: 1px solid #dbe4ee; - border-radius: 12px; - background: #fff; -} - -.onlyoffice-preview-host { - width: 100%; - min-height: 720px; -} - -.excel-preview-wrap { - min-height: 0; - overflow: hidden; - border: 1px solid #dbe4ee; - border-radius: 12px; - background: #fff; -} - -.excel-sheet-tabs { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid #e2e8f0; - background: #f8fafc; - overflow-x: auto; -} - -.excel-sheet-tab { - min-height: 34px; - padding: 0 12px; - border: 1px solid #d7e0ea; - border-radius: 8px; - background: #fff; - color: #475569; - font-size: 12px; - font-weight: 700; - white-space: nowrap; -} - -.excel-sheet-tab.active { - border-color: #93c5fd; - background: #dbeafe; - color: #1d4ed8; -} - -.excel-preview-scroll { - min-height: 0; - overflow: auto; -} - -.excel-preview-table { - width: 100%; - min-width: 640px; - border-collapse: separate; - border-spacing: 0; -} - -.excel-preview-table th, -.excel-preview-table td { - padding: 10px 12px; - border-right: 1px solid #e2e8f0; - border-bottom: 1px solid #e2e8f0; - text-align: left; - vertical-align: top; - white-space: nowrap; - font-size: 13px; - line-height: 1.5; -} - -.excel-preview-table th { - position: sticky; - top: 0; - z-index: 1; - background: #e8f0fe; - color: #0f172a; - font-weight: 800; -} - -.excel-preview-table td { - color: #334155; - background: #fff; -} - -.excel-preview-table tbody tr:nth-child(even) td { - background: #f8fafc; -} - -.excel-preview-table tr > *:last-child { - border-right: 0; -} - -.excel-preview-table tbody tr:last-child td { - border-bottom: 0; -} - -.page-stage { - min-height: 0; - overflow: auto; - display: grid; - gap: 20px; - padding-right: 6px; -} - -.page-sheet { - display: grid; - gap: 18px; - padding: 0 0 20px; - border-bottom: 1px solid #edf2f7; - background: transparent; - box-shadow: none; - animation: previewSheetIn 360ms var(--ease) both; - animation-delay: var(--page-delay, 0ms); -} - -.page-sheet:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.page-title { - display: flex; - align-items: flex-start; - justify-content: flex-start; - gap: 12px; -} - -.page-title strong { - color: #0f172a; - font-size: 16px; - font-weight: 850; -} - -.page-title span { - display: block; - margin-top: 6px; - color: #64748b; - font-size: 13px; - font-weight: 600; -} - -.summary-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.summary-item { - display: grid; - gap: 4px; - padding: 0 0 10px; - border-bottom: 1px solid #f1f5f9; - background: transparent; -} - -.summary-item span { - color: #64748b; - font-size: 12px; - font-weight: 600; -} - -.summary-item strong { - color: #0f172a; - font-size: 14px; - font-weight: 800; -} - -.page-content { - display: grid; - gap: 16px; -} - -.content-block { - padding: 0; - border-radius: 0; - background: transparent; - border: 0; -} - -.content-block h3 { - margin: 0 0 10px; - color: #0f172a; - font-size: 14px; - font-weight: 850; -} - -.content-block ul { - display: grid; - gap: 10px; - margin: 0; - padding-left: 20px; - color: #475569; - font-size: 13px; - line-height: 1.75; -} - -.preview-panel-enter-active, -.preview-panel-leave-active { - transition: opacity 240ms ease, transform 320ms var(--ease); -} - -.preview-panel-enter-from, -.preview-panel-leave-to { - opacity: 0; - transform: translateX(24px) scale(0.98); -} - -@keyframes previewSheetIn { - from { - opacity: 0; - transform: translateY(14px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 1320px) { - .knowledge-grid.has-preview { - grid-template-columns: minmax(0, 1fr) minmax(360px, 0.78fr); - } -} - -@media (max-width: 1080px) { - .knowledge-grid, - .knowledge-grid.has-preview { - grid-template-columns: 1fr; - gap: 16px; - overflow-y: auto; - } - - .library-body { - grid-template-columns: 1fr; - } - - .folder-rail { - border-right: 0; - border-bottom: 1px solid #edf2f7; - padding: 0 0 12px; - } -} - -@media (max-width: 760px) { - .panel-title, - .preview-head, - .viewer-toolbar { - flex-direction: column; - align-items: stretch; - } - - .summary-grid, - .list-foot { - grid-template-columns: 1fr; - } - - .list-foot { - gap: 12px; - justify-items: stretch; - } - - .pager, - .page-size-wrap, - .page-size { - justify-self: stretch; - } -} +.knowledge-page { + height: 100%; + min-height: 0; + overflow: hidden; + animation: fadeUp 220ms var(--ease) both; +} + +.knowledge-grid { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 0; + gap: 0; + transition: grid-template-columns 320ms var(--ease), gap 320ms var(--ease); +} + +.knowledge-grid.has-preview { + grid-template-columns: minmax(560px, 1fr) minmax(420px, 0.82fr); + gap: 16px; +} + +.knowledge-main, +.preview-column { + min-width: 0; + min-height: 0; +} + +.knowledge-main { + overflow: hidden; +} + +.library-panel { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + padding: 16px 18px; + overflow: hidden; +} + +.panel-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.panel-title h2, +.preview-head h2 { + color: #0f172a; + font-size: 16px; + font-weight: 850; +} + +.panel-title p, +.preview-head p { + margin-top: 6px; + color: #64748b; + font-size: 13px; + line-height: 1.5; +} + +.file-search { + width: min(320px, 100%); + min-height: 36px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid #d7e0ea; + border-radius: 10px; + background: #fff; + color: #64748b; + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.file-search:focus-within { + border-color: #60a5fa; + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14); +} + +.file-search input { + width: 100%; + min-width: 0; + border: 0; + color: #0f172a; + font-size: 13px; + background: transparent; +} + +.file-search input:focus { + outline: none; +} + +.library-body { + min-height: 0; + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 14px; + margin-top: 16px; +} + +.folder-rail { + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 12px; + border-right: 1px solid #edf2f7; + padding-right: 12px; +} + +.folder-tree { + min-height: 0; + display: grid; + align-content: start; + gap: 6px; + overflow-y: auto; +} + +.folder-tree button { + min-height: 34px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 0 9px; + border: 0; + border-radius: 7px; + background: transparent; + color: #334155; + font-size: 13px; + text-align: left; +} + +.folder-tree button.active { + background: #dcfce7; + color: #059669; + font-weight: 850; +} + +.folder-tree b { + min-width: 24px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: #f1f5f9; + color: #64748b; + font-size: 11px; +} + +.new-folder-btn { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid rgba(16, 185, 129, .28); + border-radius: 8px; + background: #f0fdf4; + color: #059669; + font-size: 13px; + font-weight: 850; +} + +.new-folder-btn.fixed { + border-color: rgba(148, 163, 184, 0.3); + background: #f8fafc; + color: #64748b; +} + +.document-area { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; +} + +.upload-input { + display: none; +} + +.upload-zone { + min-height: 112px; + display: grid; + place-items: center; + align-content: center; + gap: 8px; + border: 1px dashed #93c5fd; + border-radius: 10px; + background: #f8fbff; + color: #334155; + text-align: center; + cursor: pointer; + transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease; +} + +.upload-zone:hover { + border-color: #60a5fa; + background: #f3f8ff; +} + +.upload-zone.disabled { + cursor: default; + border-color: #cbd5e1; + background: #f8fafc; +} + +.upload-zone.busy { + opacity: 0.72; +} + +.upload-zone i { + color: #2563eb; + font-size: 31px; +} + +.upload-zone strong { + font-size: 13px; + font-weight: 850; +} + +.upload-zone span { + color: #64748b; + font-size: 12px; +} + +.doc-table-wrap { + min-height: 0; + overflow: auto; +} + +table { + width: 100%; + min-width: 690px; + border-collapse: collapse; +} + +th, +td { + padding: 12px 10px; + border-bottom: 1px solid #edf2f7; + color: #24324a; + font-size: 12px; + line-height: 1.35; + text-align: left; + vertical-align: middle; +} + +th { + background: #f7fafc; + color: #64748b; + font-weight: 800; + white-space: nowrap; +} + +.doc-row { + cursor: pointer; + transition: background 180ms ease, box-shadow 180ms ease; +} + +.doc-row:hover { + background: #f8fbff; +} + +.doc-row.selected { + background: linear-gradient(90deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.04)); + box-shadow: inset 3px 0 0 #10b981; +} + +.file-name { + display: inline-flex; + align-items: center; + gap: 7px; + font-weight: 750; + white-space: nowrap; +} + +.file-name .pdf, +.viewer-filetype.pdf { color: #ef4444; } +.file-name .word, +.viewer-filetype.word { color: #2563eb; } +.file-name .excel, +.viewer-filetype.excel { color: #10b981; } + +.doc-tag { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border-radius: 6px; + background: #f1f5f9; + color: #64748b; + font-size: 11px; + font-weight: 750; +} + +.state-tag { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.state-tag.success { + background: #dcfce7; + color: #059669; +} + +.state-tag.warning { + background: #ffedd5; + color: #f97316; +} + +.more-btn { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #2563eb; +} + +.more-btn.danger { + color: #dc2626; +} + +.more-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.row-actions { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.empty-row { + color: #64748b; + text-align: center; +} + +.list-foot { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + 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 { + background: #059669; + color: #fff; + box-shadow: 0 8px 16px rgba(5, 150, 105, .20); +} + +.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: 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 { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.preview-panel { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + padding: 20px 22px; + overflow: hidden; +} + +.preview-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding-bottom: 16px; + border-bottom: 1px solid #edf2f7; +} + +.preview-copy { + min-width: 0; +} + +.preview-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.mini-action, +.icon-action, +.viewer-toolbar-actions button { + border: 1px solid #d7e0ea; + border-radius: 8px; + background: #fff; + color: #334155; +} + +.mini-action { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 12px; + font-size: 12px; + font-weight: 800; +} + +.icon-action { + width: 34px; + height: 34px; + display: grid; + place-items: center; +} + +.preview-summary-line { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 8px; + color: #64748b; + font-size: 13px; + line-height: 1.6; +} + +.preview-secondary-line { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 12px; + padding: 10px 12px; + border-radius: 10px; + background: #1e293b; + color: #e2e8f0; + font-size: 12px; + line-height: 1.5; +} + +.preview-viewer { + min-height: 0; + margin-top: 18px; +} + +.preview-status { + display: grid; + place-items: center; + min-height: 180px; + padding: 24px; + border: 1px dashed #cbd5e1; + border-radius: 14px; + background: #f8fafc; + color: #64748b; + font-size: 13px; + font-weight: 700; + text-align: center; +} + +.preview-status.error { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; +} + +.preview-embed-wrap, +.preview-image-wrap { + min-height: 0; + overflow: hidden; + border: 1px solid #edf2f7; + border-radius: 12px; + background: #fff; +} + +.preview-embed { + width: 100%; + height: 100%; + min-height: 560px; + border: 0; +} + +.preview-image-wrap { + display: grid; + place-items: center; + padding: 20px; +} + +.preview-image { + max-width: 100%; + max-height: 70vh; + object-fit: contain; +} + +.onlyoffice-preview-wrap { + min-height: 0; + overflow: hidden; + border: 1px solid #dbe4ee; + border-radius: 12px; + background: #fff; +} + +.onlyoffice-preview-host { + width: 100%; + min-height: 720px; +} + +.excel-preview-wrap { + min-height: 0; + overflow: hidden; + border: 1px solid #dbe4ee; + border-radius: 12px; + background: #fff; +} + +.excel-sheet-tabs { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + overflow-x: auto; +} + +.excel-sheet-tab { + min-height: 34px; + padding: 0 12px; + border: 1px solid #d7e0ea; + border-radius: 8px; + background: #fff; + color: #475569; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.excel-sheet-tab.active { + border-color: #93c5fd; + background: #dbeafe; + color: #1d4ed8; +} + +.excel-preview-scroll { + min-height: 0; + overflow: auto; +} + +.excel-preview-table { + width: 100%; + min-width: 640px; + border-collapse: separate; + border-spacing: 0; +} + +.excel-preview-table th, +.excel-preview-table td { + padding: 10px 12px; + border-right: 1px solid #e2e8f0; + border-bottom: 1px solid #e2e8f0; + text-align: left; + vertical-align: top; + white-space: nowrap; + font-size: 13px; + line-height: 1.5; +} + +.excel-preview-table th { + position: sticky; + top: 0; + z-index: 1; + background: #e8f0fe; + color: #0f172a; + font-weight: 800; +} + +.excel-preview-table td { + color: #334155; + background: #fff; +} + +.excel-preview-table tbody tr:nth-child(even) td { + background: #f8fafc; +} + +.excel-preview-table tr > *:last-child { + border-right: 0; +} + +.excel-preview-table tbody tr:last-child td { + border-bottom: 0; +} + +.page-stage { + min-height: 0; + overflow: auto; + display: grid; + gap: 20px; + padding-right: 6px; +} + +.page-sheet { + display: grid; + gap: 18px; + padding: 0 0 20px; + border-bottom: 1px solid #edf2f7; + background: transparent; + box-shadow: none; + animation: previewSheetIn 360ms var(--ease) both; + animation-delay: var(--page-delay, 0ms); +} + +.page-sheet:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.page-title { + display: flex; + align-items: flex-start; + justify-content: flex-start; + gap: 12px; +} + +.page-title strong { + color: #0f172a; + font-size: 16px; + font-weight: 850; +} + +.page-title span { + display: block; + margin-top: 6px; + color: #64748b; + font-size: 13px; + font-weight: 600; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.summary-item { + display: grid; + gap: 4px; + padding: 0 0 10px; + border-bottom: 1px solid #f1f5f9; + background: transparent; +} + +.summary-item span { + color: #64748b; + font-size: 12px; + font-weight: 600; +} + +.summary-item strong { + color: #0f172a; + font-size: 14px; + font-weight: 800; +} + +.page-content { + display: grid; + gap: 16px; +} + +.content-block { + padding: 0; + border-radius: 0; + background: transparent; + border: 0; +} + +.content-block h3 { + margin: 0 0 10px; + color: #0f172a; + font-size: 14px; + font-weight: 850; +} + +.content-block ul { + display: grid; + gap: 10px; + margin: 0; + padding-left: 20px; + color: #475569; + font-size: 13px; + line-height: 1.75; +} + +.preview-panel-enter-active, +.preview-panel-leave-active { + transition: opacity 240ms ease, transform 320ms var(--ease); +} + +.preview-panel-enter-from, +.preview-panel-leave-to { + opacity: 0; + transform: translateX(24px) scale(0.98); +} + +@keyframes previewSheetIn { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1320px) { + .knowledge-grid.has-preview { + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.78fr); + } +} + +@media (max-width: 1080px) { + .knowledge-grid, + .knowledge-grid.has-preview { + grid-template-columns: 1fr; + gap: 16px; + overflow-y: auto; + } + + .library-body { + grid-template-columns: 1fr; + } + + .folder-rail { + border-right: 0; + border-bottom: 1px solid #edf2f7; + padding: 0 0 12px; + } +} + +@media (max-width: 760px) { + .panel-title, + .preview-head, + .viewer-toolbar { + flex-direction: column; + align-items: stretch; + } + + .summary-grid, + .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 d57257a..3f650db 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -1,673 +1,673 @@ -import { computed, ref } from 'vue' - -import { - fetchBootstrapBackendStatus, - fetchBootstrapState, - saveBootstrapConfig, - startBootstrapBackend, - testBootstrapDatabase, - testBootstrapRuntime -} from '../services/bootstrap.js' -import { login as loginByAccount } from '../services/auth.js' -import { setRuntimeApiBaseUrl } from '../services/api.js' -import { checkBackendHealth } from './useBackendHealth.js' -import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' -import { useToast } from './useToast.js' - -const AUTH_STORAGE_KEY = 'x-financial-authenticated' -const AUTH_USERNAME_KEY = 'x-financial-auth-username' -const AUTH_USER_KEY = 'x-financial-auth-user' -const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity' -const DEFAULT_USER_NAME = '系统管理员' -const DEFAULT_USER_ROLE = '管理员' -const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange'] -const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30) -const authIdleTimeoutMs = - Number.isFinite(authIdleTimeoutMinutes) && authIdleTimeoutMinutes > 0 - ? authIdleTimeoutMinutes * 60 * 1000 - : 30 * 60 * 1000 - -function resolveBrowserApiBaseUrl() { - return '/api/v1' -} - -let sessionRouter = null -let sessionTimeoutHandle = 0 -let sessionMonitoringInstalled = false -let lastActivityWriteAt = 0 - -function readClientBootstrapState() { - const env = import.meta.env - - return { - initialized: String(env.VITE_SETUP_COMPLETED || '').toLowerCase() === 'true', - company: { - name: env.VITE_COMPANY_NAME || '', - code: env.VITE_COMPANY_CODE || '', - admin_email: env.VITE_ADMIN_EMAIL || '' - }, - web: { - host: env.VITE_WEB_HOST || '0.0.0.0', - port: Number(env.VITE_WEB_PORT || 5173) - }, - server: { - host: env.VITE_SERVER_HOST || '0.0.0.0', - port: Number(env.VITE_SERVER_PORT || 8000) - }, - database: { - driver: 'postgresql', - host: env.VITE_POSTGRES_HOST || '127.0.0.1', - port: Number(env.VITE_POSTGRES_PORT || 5432), - name: env.VITE_POSTGRES_DB || 'x_financial', - username: env.VITE_POSTGRES_USER || 'postgres', - password_configured: false - }, - redis: { - enabled: Boolean(env.VITE_REDIS_URL), - url: env.VITE_REDIS_URL || '' - } - } -} - -function readAuthState() { - if (typeof window === 'undefined') { - return false - } - - return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true' -} - -function readStoredUsername() { - if (typeof window === 'undefined') { - return '' - } - - return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || '' -} - -function buildAnonymousUser() { - return { - username: '', - name: '', - role: '', - roleCodes: [], - email: '', - avatar: '', - isAdmin: false - } -} - -function buildLegacyAdminUser(username = '') { - const normalized = String(username || '').trim() - const name = normalized || DEFAULT_USER_NAME - - return { - username: normalized, - name, - role: DEFAULT_USER_ROLE, - roleCodes: ['manager'], - email: '', - avatar: name.slice(0, 1).toUpperCase(), - isAdmin: true - } -} - -function readStoredUser() { - if (typeof window === 'undefined') { - return buildAnonymousUser() - } - - const raw = window.sessionStorage.getItem(AUTH_USER_KEY) - - if (raw) { - try { - const payload = JSON.parse(raw) - if (payload && typeof payload === 'object') { - const username = String(payload.username || '').trim() - const name = String(payload.name || username || DEFAULT_USER_NAME).trim() - const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : [] - - return { - username, - name, - role: String(payload.role || DEFAULT_USER_ROLE), - roleCodes, - email: String(payload.email || ''), - avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), - isAdmin: Boolean(payload.isAdmin) - } - } - } catch { - return buildLegacyAdminUser(readStoredUsername()) - } - } - - const legacyUsername = readStoredUsername() - return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser() -} - -function readLastActivityAt() { - if (typeof window === 'undefined') { - return 0 - } - - return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0) -} - -function isSessionExpired(now = Date.now()) { - if (!readAuthState()) { - return false - } - - const lastActivityAt = readLastActivityAt() - - if (!lastActivityAt) { - return true - } - - return now - lastActivityAt > authIdleTimeoutMs -} - -function persistAuthState(value, user = null) { - if (typeof window === 'undefined') { - return - } - - if (value) { - window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true') - const normalizedUser = user || buildAnonymousUser() - window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim()) - window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) - return - } - - window.sessionStorage.removeItem(AUTH_STORAGE_KEY) - window.sessionStorage.removeItem(AUTH_USERNAME_KEY) - window.sessionStorage.removeItem(AUTH_USER_KEY) - window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY) -} - -function clearSessionTimeout() { - if (typeof window === 'undefined' || !sessionTimeoutHandle) { - return - } - - window.clearTimeout(sessionTimeoutHandle) - sessionTimeoutHandle = 0 -} - -function redirectToLogin() { - if (sessionRouter?.currentRoute?.value?.name === 'login') { - return - } - - if (sessionRouter) { - sessionRouter.replace({ name: 'login' }) - return - } - - if (typeof window !== 'undefined' && window.location.pathname !== '/login') { - window.location.assign('/login') - } -} - -function scheduleSessionTimeout() { - clearSessionTimeout() - - if (typeof window === 'undefined' || !readAuthState()) { - return - } - - const lastActivityAt = readLastActivityAt() - - if (!lastActivityAt) { - return - } - - const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt) - - if (remaining <= 0) { - logout('timeout', { notify: true }) - return - } - - sessionTimeoutHandle = window.setTimeout(() => { - logout('timeout', { notify: true }) - }, remaining) -} - -function touchAuthActivity(force = false) { - if (typeof window === 'undefined' || !readAuthState()) { - return - } - - const now = Date.now() - - if (!force && now - lastActivityWriteAt < 1000) { - scheduleSessionTimeout() - return - } - - window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now)) - lastActivityWriteAt = now - scheduleSessionTimeout() -} - -function handleSessionActivity(event) { - if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') { - return - } - - touchAuthActivity() -} - -function installSessionMonitoring() { - if (sessionMonitoringInstalled || typeof window === 'undefined') { - return - } - - sessionMonitoringInstalled = true - SESSION_ACTIVITY_EVENTS.forEach((eventName) => { - window.addEventListener(eventName, handleSessionActivity, { passive: true }) - }) -} - -function syncAuthSession(options = {}) { - const shouldNotify = Boolean(options.notify) - - if (!readAuthState()) { - loggedIn.value = false - currentUser.value = buildAnonymousUser() - clearSessionTimeout() - return false - } - - if (isSessionExpired()) { - logout('timeout', { notify: shouldNotify, redirect: false }) - return false - } - - loggedIn.value = true - currentUser.value = readStoredUser() - scheduleSessionTimeout() - return true -} - -function reconcileEntryRoute(router) { - const target = resolveEntryRoute() - const current = router.currentRoute.value - - if (!current.name || current.name === 'root' || current.name === 'setup' || target.name === 'setup') { - router.replace(target) - } -} - -export function installSessionNavigation(router) { - sessionRouter = router - installSessionMonitoring() - - if (readAuthState() && !isSessionExpired()) { - scheduleSessionTimeout() - } - - fetchBootstrapState() - .then((state) => { - applyBootstrapState(state) - setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) - router.isReady().then(() => reconcileEntryRoute(router)) - }) - .catch(() => { - router.isReady().then(() => { - if (!isInitialized.value && router.currentRoute.value.name !== 'setup') { - router.replace({ name: 'setup' }) - } - }) - }) -} - -const bootstrapState = ref(readClientBootstrapState()) -const setupSubmitting = ref(false) -const setupError = ref('') -const setupProgressMessage = ref('') -const setupStartupVisible = ref(false) -const setupStartupSteps = ref([]) -const setupStartupLog = ref('') -const setupCountdownSeconds = ref(0) -const runtimeTesting = ref(false) -const databaseTesting = ref(false) -const runtimeTestPassed = ref(false) -const databaseTestPassed = ref(false) -const runtimeTestMessage = ref('') -const databaseTestMessage = ref('') -const loginSubmitting = ref(false) -const loginError = ref('') -const loggedIn = ref(readAuthState() && !isSessionExpired()) -const currentUser = ref(readStoredUser()) - -if (!loggedIn.value && readAuthState()) { - persistAuthState(false) -} - -const { toast } = useToast() - -const companyProfile = computed(() => ({ - name: bootstrapState.value.company?.name || '', - code: bootstrapState.value.company?.code || '', - adminEmail: bootstrapState.value.company?.admin_email || '' -})) - -function updateCompanyProfilePreview(payload = {}) { - const currentCompany = bootstrapState.value.company || {} - - bootstrapState.value = { - ...bootstrapState.value, - company: { - ...currentCompany, - ...(payload.name !== undefined ? { name: payload.name } : {}), - ...(payload.code !== undefined ? { code: payload.code } : {}), - ...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {}) - } - } -} - -const isInitialized = computed(() => Boolean(bootstrapState.value.initialized)) - -function applyBootstrapState(state) { - bootstrapState.value = state - - if (!state.initialized) { - logout('reset', { redirect: false }) - } -} - -function clearSetupRuntimeState() { - runtimeTesting.value = false - databaseTesting.value = false - runtimeTestPassed.value = false - databaseTestPassed.value = false - runtimeTestMessage.value = '' - databaseTestMessage.value = '' - setupError.value = '' - setupProgressMessage.value = '' - setupStartupVisible.value = false - setupStartupSteps.value = [] - setupStartupLog.value = '' - setupCountdownSeconds.value = 0 -} - -function resetFromClientEnv() { - applyBootstrapState(readClientBootstrapState()) - clearSetupRuntimeState() - loginError.value = '' - currentUser.value = readStoredUser() -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function applyBackendStartupStatus(status) { - setupStartupVisible.value = true - setupStartupSteps.value = Array.isArray(status?.steps) ? status.steps : [] - setupStartupLog.value = status?.logTail || '' - setupProgressMessage.value = status?.detail || setupProgressMessage.value -} - -async function waitForBackendStartup() { - const started = await startBootstrapBackend() - applyBackendStartupStatus(started) - - while (true) { - const status = await fetchBootstrapBackendStatus() - applyBackendStartupStatus(status) - - if (status?.completed) { - return status - } - - if (status?.failed) { - throw new Error(status.detail || 'FastAPI 后端启动失败。') - } - - await sleep(1000) - } -} - -async function runLoginCountdown() { - for (let second = 5; second > 0; second -= 1) { - setupCountdownSeconds.value = second - setupProgressMessage.value = `配置成功,${second} 秒后进入登录页...` - await sleep(1000) - } -} - -async function handleSetupSubmit(payload) { - if (!runtimeTestPassed.value) { - setupError.value = '请先完成运行端口检测。' - toast(setupError.value) - return false - } - - if (!databaseTestPassed.value) { - setupError.value = '请先完成数据库连接检测。' - toast(setupError.value) - return false - } - - setupSubmitting.value = true - setupError.value = '' - setupProgressMessage.value = '正在写入初始化配置...' - setupStartupVisible.value = true - setupStartupSteps.value = [ - { id: 'config', label: '第一步:写入初始化配置', status: 'running', detail: '正在保存企业、管理员、端口和数据库配置。' }, - { id: 'deps', label: '第二步:安装/检查后端虚拟环境', status: 'pending', detail: '等待后端启动任务开始。' }, - { id: 'server', label: '第三步:启动 FastAPI 服务', status: 'pending', detail: '等待启动 uvicorn。' }, - { id: 'health', label: '第四步:检测后端健康状态', status: 'pending', detail: '等待 /api/v1/health。' }, - { id: 'done', label: '第五步:配置完成', status: 'pending', detail: '后端就绪后进入登录页。' } - ] - setupStartupLog.value = '' - setupCountdownSeconds.value = 0 - - try { - const state = await saveBootstrapConfig(payload) - setupStartupSteps.value = setupStartupSteps.value.map((step) => - step.id === 'config' - ? { ...step, status: 'success', detail: '初始化配置已写入。' } - : step - ) - setupProgressMessage.value = '配置已写入,正在启动 FastAPI 后端...' - await waitForBackendStartup() - setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) - setupProgressMessage.value = '后端已启动,正在检测服务连通性...' - - const backendReady = await checkBackendHealth({ force: true }) - - if (!backendReady) { - throw new Error('FastAPI 后端已启动,但浏览器暂时无法连接后端接口。请确认 Server Host 使用 0.0.0.0,且防火墙允许访问后端端口。') - } - - setupStartupSteps.value = setupStartupSteps.value.map((step) => - step.id === 'done' - ? { ...step, status: 'success', detail: '配置成功,准备进入登录页。' } - : step - ) - setupProgressMessage.value = '配置成功,准备进入登录页...' - applyBootstrapState(state) - toast('初始化完成,后端已启动。') - await runLoginCountdown() - return true - } catch (error) { - setupError.value = error.message || '初始化配置写入或后端启动失败,请稍后重试。' - setupStartupVisible.value = true - setupStartupSteps.value = setupStartupSteps.value.map((step) => - step.id === 'done' - ? { ...step, status: 'error', detail: setupError.value } - : step - ) - toast(setupError.value) - return false - } finally { - setupSubmitting.value = false - } -} - -async function handleRuntimeTest(payload) { - runtimeTesting.value = true - runtimeTestMessage.value = '' - setupError.value = '' - - try { - const result = await testBootstrapRuntime(payload) - runtimeTestPassed.value = true - runtimeTestMessage.value = result.detail || '端口占用检测通过。' - toast(runtimeTestMessage.value) - } catch (error) { - runtimeTestPassed.value = false - runtimeTestMessage.value = error.message || '端口占用检测失败。' - toast(runtimeTestMessage.value) - } finally { - runtimeTesting.value = false - } -} - -async function handleDatabaseTest(payload) { - databaseTesting.value = true - databaseTestMessage.value = '' - setupError.value = '' - - try { - const result = await testBootstrapDatabase(payload) - databaseTestPassed.value = true - databaseTestMessage.value = result.detail || '数据库连接检测通过。' - toast(databaseTestMessage.value) - } catch (error) { - databaseTestPassed.value = false - databaseTestMessage.value = error.message || '数据库连接检测失败。' - toast(databaseTestMessage.value) - } finally { - databaseTesting.value = false - } -} - -function handleRuntimeDirty() { - runtimeTestPassed.value = false - runtimeTestMessage.value = '' - - if (setupError.value === '请先完成运行端口检测。') { - setupError.value = '' - } -} - -function handleDatabaseDirty() { - databaseTestPassed.value = false - databaseTestMessage.value = '' - - if (setupError.value === '请先完成数据库连接检测。') { - setupError.value = '' - } -} - -async function handleLogin(credentials) { - loginSubmitting.value = true - loginError.value = '' - - try { - const response = await loginByAccount({ - username: credentials.username, - password: credentials.password - }) - - const user = response?.user || buildAnonymousUser() - loggedIn.value = true - persistAuthState(true, user) - currentUser.value = user - touchAuthActivity(true) - return true - } catch (error) { - logout('invalid', { redirect: false }) - loginError.value = error.message || '登录失败,请检查账号和密码。' - toast(loginError.value) - return false - } finally { - loginSubmitting.value = false - } -} - -function logout(reason = 'manual', options = {}) { - const notify = options.notify ?? reason === 'timeout' - const redirect = options.redirect ?? reason !== 'invalid' - - loggedIn.value = false - persistAuthState(false) - currentUser.value = buildAnonymousUser() - clearSessionTimeout() - - if (notify) { - toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。') - } - - if (redirect) { - redirectToLogin() - } -} - -function handleRecoverPassword() { - toast('请联系系统管理员重置账号密码。') -} - -function handleSsoLogin() { - toast('SSO 登录暂未启用。') -} - -function resolveEntryRoute() { - loggedIn.value = syncAuthSession() - currentUser.value = readStoredUser() - - if (!isInitialized.value) { - return { name: 'setup' } - } - - if (!loggedIn.value) { - return { name: 'login' } - } - - return resolveDefaultAuthorizedRoute(currentUser.value) -} - -export function useSystemState() { - return { - bootstrapState, - companyProfile, - currentUser, - databaseTestMessage, - databaseTestPassed, - databaseTesting, - handleDatabaseDirty, - handleDatabaseTest, - handleLogin, - handleRecoverPassword, - handleRuntimeDirty, - handleRuntimeTest, - handleSetupSubmit, - handleSsoLogin, - isInitialized, - loggedIn, - loginError, - loginSubmitting, - logout, - resetFromClientEnv, - resolveEntryRoute, - runtimeTestMessage, - runtimeTestPassed, - runtimeTesting, - setupError, - setupCountdownSeconds, - setupProgressMessage, - setupStartupLog, - setupStartupSteps, - setupStartupVisible, - setupSubmitting, - syncAuthSession, - updateCompanyProfilePreview - } -} +import { computed, ref } from 'vue' + +import { + fetchBootstrapBackendStatus, + fetchBootstrapState, + saveBootstrapConfig, + startBootstrapBackend, + testBootstrapDatabase, + testBootstrapRuntime +} from '../services/bootstrap.js' +import { login as loginByAccount } from '../services/auth.js' +import { setRuntimeApiBaseUrl } from '../services/api.js' +import { checkBackendHealth } from './useBackendHealth.js' +import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' +import { useToast } from './useToast.js' + +const AUTH_STORAGE_KEY = 'x-financial-authenticated' +const AUTH_USERNAME_KEY = 'x-financial-auth-username' +const AUTH_USER_KEY = 'x-financial-auth-user' +const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity' +const DEFAULT_USER_NAME = '系统管理员' +const DEFAULT_USER_ROLE = '管理员' +const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange'] +const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30) +const authIdleTimeoutMs = + Number.isFinite(authIdleTimeoutMinutes) && authIdleTimeoutMinutes > 0 + ? authIdleTimeoutMinutes * 60 * 1000 + : 30 * 60 * 1000 + +function resolveBrowserApiBaseUrl() { + return '/api/v1' +} + +let sessionRouter = null +let sessionTimeoutHandle = 0 +let sessionMonitoringInstalled = false +let lastActivityWriteAt = 0 + +function readClientBootstrapState() { + const env = import.meta.env + + return { + initialized: String(env.VITE_SETUP_COMPLETED || '').toLowerCase() === 'true', + company: { + name: env.VITE_COMPANY_NAME || '', + code: env.VITE_COMPANY_CODE || '', + admin_email: env.VITE_ADMIN_EMAIL || '' + }, + web: { + host: env.VITE_WEB_HOST || '0.0.0.0', + port: Number(env.VITE_WEB_PORT || 5173) + }, + server: { + host: env.VITE_SERVER_HOST || '0.0.0.0', + port: Number(env.VITE_SERVER_PORT || 8000) + }, + database: { + driver: 'postgresql', + host: env.VITE_POSTGRES_HOST || '127.0.0.1', + port: Number(env.VITE_POSTGRES_PORT || 5432), + name: env.VITE_POSTGRES_DB || 'x_financial', + username: env.VITE_POSTGRES_USER || 'postgres', + password_configured: false + }, + redis: { + enabled: Boolean(env.VITE_REDIS_URL), + url: env.VITE_REDIS_URL || '' + } + } +} + +function readAuthState() { + if (typeof window === 'undefined') { + return false + } + + return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true' +} + +function readStoredUsername() { + if (typeof window === 'undefined') { + return '' + } + + return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || '' +} + +function buildAnonymousUser() { + return { + username: '', + name: '', + role: '', + roleCodes: [], + email: '', + avatar: '', + isAdmin: false + } +} + +function buildLegacyAdminUser(username = '') { + const normalized = String(username || '').trim() + const name = normalized || DEFAULT_USER_NAME + + return { + username: normalized, + name, + role: DEFAULT_USER_ROLE, + roleCodes: ['manager'], + email: '', + avatar: name.slice(0, 1).toUpperCase(), + isAdmin: true + } +} + +function readStoredUser() { + if (typeof window === 'undefined') { + return buildAnonymousUser() + } + + const raw = window.sessionStorage.getItem(AUTH_USER_KEY) + + if (raw) { + try { + const payload = JSON.parse(raw) + if (payload && typeof payload === 'object') { + const username = String(payload.username || '').trim() + const name = String(payload.name || username || DEFAULT_USER_NAME).trim() + const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : [] + + return { + username, + name, + role: String(payload.role || DEFAULT_USER_ROLE), + roleCodes, + email: String(payload.email || ''), + avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), + isAdmin: Boolean(payload.isAdmin) + } + } + } catch { + return buildLegacyAdminUser(readStoredUsername()) + } + } + + const legacyUsername = readStoredUsername() + return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser() +} + +function readLastActivityAt() { + if (typeof window === 'undefined') { + return 0 + } + + return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0) +} + +function isSessionExpired(now = Date.now()) { + if (!readAuthState()) { + return false + } + + const lastActivityAt = readLastActivityAt() + + if (!lastActivityAt) { + return true + } + + return now - lastActivityAt > authIdleTimeoutMs +} + +function persistAuthState(value, user = null) { + if (typeof window === 'undefined') { + return + } + + if (value) { + window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true') + const normalizedUser = user || buildAnonymousUser() + window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim()) + window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) + return + } + + window.sessionStorage.removeItem(AUTH_STORAGE_KEY) + window.sessionStorage.removeItem(AUTH_USERNAME_KEY) + window.sessionStorage.removeItem(AUTH_USER_KEY) + window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY) +} + +function clearSessionTimeout() { + if (typeof window === 'undefined' || !sessionTimeoutHandle) { + return + } + + window.clearTimeout(sessionTimeoutHandle) + sessionTimeoutHandle = 0 +} + +function redirectToLogin() { + if (sessionRouter?.currentRoute?.value?.name === 'login') { + return + } + + if (sessionRouter) { + sessionRouter.replace({ name: 'login' }) + return + } + + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + window.location.assign('/login') + } +} + +function scheduleSessionTimeout() { + clearSessionTimeout() + + if (typeof window === 'undefined' || !readAuthState()) { + return + } + + const lastActivityAt = readLastActivityAt() + + if (!lastActivityAt) { + return + } + + const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt) + + if (remaining <= 0) { + logout('timeout', { notify: true }) + return + } + + sessionTimeoutHandle = window.setTimeout(() => { + logout('timeout', { notify: true }) + }, remaining) +} + +function touchAuthActivity(force = false) { + if (typeof window === 'undefined' || !readAuthState()) { + return + } + + const now = Date.now() + + if (!force && now - lastActivityWriteAt < 1000) { + scheduleSessionTimeout() + return + } + + window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now)) + lastActivityWriteAt = now + scheduleSessionTimeout() +} + +function handleSessionActivity(event) { + if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') { + return + } + + touchAuthActivity() +} + +function installSessionMonitoring() { + if (sessionMonitoringInstalled || typeof window === 'undefined') { + return + } + + sessionMonitoringInstalled = true + SESSION_ACTIVITY_EVENTS.forEach((eventName) => { + window.addEventListener(eventName, handleSessionActivity, { passive: true }) + }) +} + +function syncAuthSession(options = {}) { + const shouldNotify = Boolean(options.notify) + + if (!readAuthState()) { + loggedIn.value = false + currentUser.value = buildAnonymousUser() + clearSessionTimeout() + return false + } + + if (isSessionExpired()) { + logout('timeout', { notify: shouldNotify, redirect: false }) + return false + } + + loggedIn.value = true + currentUser.value = readStoredUser() + scheduleSessionTimeout() + return true +} + +function reconcileEntryRoute(router) { + const target = resolveEntryRoute() + const current = router.currentRoute.value + + if (!current.name || current.name === 'root' || current.name === 'setup' || target.name === 'setup') { + router.replace(target) + } +} + +export function installSessionNavigation(router) { + sessionRouter = router + installSessionMonitoring() + + if (readAuthState() && !isSessionExpired()) { + scheduleSessionTimeout() + } + + fetchBootstrapState() + .then((state) => { + applyBootstrapState(state) + setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) + router.isReady().then(() => reconcileEntryRoute(router)) + }) + .catch(() => { + router.isReady().then(() => { + if (!isInitialized.value && router.currentRoute.value.name !== 'setup') { + router.replace({ name: 'setup' }) + } + }) + }) +} + +const bootstrapState = ref(readClientBootstrapState()) +const setupSubmitting = ref(false) +const setupError = ref('') +const setupProgressMessage = ref('') +const setupStartupVisible = ref(false) +const setupStartupSteps = ref([]) +const setupStartupLog = ref('') +const setupCountdownSeconds = ref(0) +const runtimeTesting = ref(false) +const databaseTesting = ref(false) +const runtimeTestPassed = ref(false) +const databaseTestPassed = ref(false) +const runtimeTestMessage = ref('') +const databaseTestMessage = ref('') +const loginSubmitting = ref(false) +const loginError = ref('') +const loggedIn = ref(readAuthState() && !isSessionExpired()) +const currentUser = ref(readStoredUser()) + +if (!loggedIn.value && readAuthState()) { + persistAuthState(false) +} + +const { toast } = useToast() + +const companyProfile = computed(() => ({ + name: bootstrapState.value.company?.name || '', + code: bootstrapState.value.company?.code || '', + adminEmail: bootstrapState.value.company?.admin_email || '' +})) + +function updateCompanyProfilePreview(payload = {}) { + const currentCompany = bootstrapState.value.company || {} + + bootstrapState.value = { + ...bootstrapState.value, + company: { + ...currentCompany, + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.code !== undefined ? { code: payload.code } : {}), + ...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {}) + } + } +} + +const isInitialized = computed(() => Boolean(bootstrapState.value.initialized)) + +function applyBootstrapState(state) { + bootstrapState.value = state + + if (!state.initialized) { + logout('reset', { redirect: false }) + } +} + +function clearSetupRuntimeState() { + runtimeTesting.value = false + databaseTesting.value = false + runtimeTestPassed.value = false + databaseTestPassed.value = false + runtimeTestMessage.value = '' + databaseTestMessage.value = '' + setupError.value = '' + setupProgressMessage.value = '' + setupStartupVisible.value = false + setupStartupSteps.value = [] + setupStartupLog.value = '' + setupCountdownSeconds.value = 0 +} + +function resetFromClientEnv() { + applyBootstrapState(readClientBootstrapState()) + clearSetupRuntimeState() + loginError.value = '' + currentUser.value = readStoredUser() +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function applyBackendStartupStatus(status) { + setupStartupVisible.value = true + setupStartupSteps.value = Array.isArray(status?.steps) ? status.steps : [] + setupStartupLog.value = status?.logTail || '' + setupProgressMessage.value = status?.detail || setupProgressMessage.value +} + +async function waitForBackendStartup() { + const started = await startBootstrapBackend() + applyBackendStartupStatus(started) + + while (true) { + const status = await fetchBootstrapBackendStatus() + applyBackendStartupStatus(status) + + if (status?.completed) { + return status + } + + if (status?.failed) { + throw new Error(status.detail || 'FastAPI 后端启动失败。') + } + + await sleep(1000) + } +} + +async function runLoginCountdown() { + for (let second = 5; second > 0; second -= 1) { + setupCountdownSeconds.value = second + setupProgressMessage.value = `配置成功,${second} 秒后进入登录页...` + await sleep(1000) + } +} + +async function handleSetupSubmit(payload) { + if (!runtimeTestPassed.value) { + setupError.value = '请先完成运行端口检测。' + toast(setupError.value) + return false + } + + if (!databaseTestPassed.value) { + setupError.value = '请先完成数据库连接检测。' + toast(setupError.value) + return false + } + + setupSubmitting.value = true + setupError.value = '' + setupProgressMessage.value = '正在写入初始化配置...' + setupStartupVisible.value = true + setupStartupSteps.value = [ + { id: 'config', label: '第一步:写入初始化配置', status: 'running', detail: '正在保存企业、管理员、端口和数据库配置。' }, + { id: 'deps', label: '第二步:安装/检查后端虚拟环境', status: 'pending', detail: '等待后端启动任务开始。' }, + { id: 'server', label: '第三步:启动 FastAPI 服务', status: 'pending', detail: '等待启动 uvicorn。' }, + { id: 'health', label: '第四步:检测后端健康状态', status: 'pending', detail: '等待 /api/v1/health。' }, + { id: 'done', label: '第五步:配置完成', status: 'pending', detail: '后端就绪后进入登录页。' } + ] + setupStartupLog.value = '' + setupCountdownSeconds.value = 0 + + try { + const state = await saveBootstrapConfig(payload) + setupStartupSteps.value = setupStartupSteps.value.map((step) => + step.id === 'config' + ? { ...step, status: 'success', detail: '初始化配置已写入。' } + : step + ) + setupProgressMessage.value = '配置已写入,正在启动 FastAPI 后端...' + await waitForBackendStartup() + setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) + setupProgressMessage.value = '后端已启动,正在检测服务连通性...' + + const backendReady = await checkBackendHealth({ force: true }) + + if (!backendReady) { + throw new Error('FastAPI 后端已启动,但浏览器暂时无法连接后端接口。请确认 Server Host 使用 0.0.0.0,且防火墙允许访问后端端口。') + } + + setupStartupSteps.value = setupStartupSteps.value.map((step) => + step.id === 'done' + ? { ...step, status: 'success', detail: '配置成功,准备进入登录页。' } + : step + ) + setupProgressMessage.value = '配置成功,准备进入登录页...' + applyBootstrapState(state) + toast('初始化完成,后端已启动。') + await runLoginCountdown() + return true + } catch (error) { + setupError.value = error.message || '初始化配置写入或后端启动失败,请稍后重试。' + setupStartupVisible.value = true + setupStartupSteps.value = setupStartupSteps.value.map((step) => + step.id === 'done' + ? { ...step, status: 'error', detail: setupError.value } + : step + ) + toast(setupError.value) + return false + } finally { + setupSubmitting.value = false + } +} + +async function handleRuntimeTest(payload) { + runtimeTesting.value = true + runtimeTestMessage.value = '' + setupError.value = '' + + try { + const result = await testBootstrapRuntime(payload) + runtimeTestPassed.value = true + runtimeTestMessage.value = result.detail || '端口占用检测通过。' + toast(runtimeTestMessage.value) + } catch (error) { + runtimeTestPassed.value = false + runtimeTestMessage.value = error.message || '端口占用检测失败。' + toast(runtimeTestMessage.value) + } finally { + runtimeTesting.value = false + } +} + +async function handleDatabaseTest(payload) { + databaseTesting.value = true + databaseTestMessage.value = '' + setupError.value = '' + + try { + const result = await testBootstrapDatabase(payload) + databaseTestPassed.value = true + databaseTestMessage.value = result.detail || '数据库连接检测通过。' + toast(databaseTestMessage.value) + } catch (error) { + databaseTestPassed.value = false + databaseTestMessage.value = error.message || '数据库连接检测失败。' + toast(databaseTestMessage.value) + } finally { + databaseTesting.value = false + } +} + +function handleRuntimeDirty() { + runtimeTestPassed.value = false + runtimeTestMessage.value = '' + + if (setupError.value === '请先完成运行端口检测。') { + setupError.value = '' + } +} + +function handleDatabaseDirty() { + databaseTestPassed.value = false + databaseTestMessage.value = '' + + if (setupError.value === '请先完成数据库连接检测。') { + setupError.value = '' + } +} + +async function handleLogin(credentials) { + loginSubmitting.value = true + loginError.value = '' + + try { + const response = await loginByAccount({ + username: credentials.username, + password: credentials.password + }) + + const user = response?.user || buildAnonymousUser() + loggedIn.value = true + persistAuthState(true, user) + currentUser.value = user + touchAuthActivity(true) + return true + } catch (error) { + logout('invalid', { redirect: false }) + loginError.value = error.message || '登录失败,请检查账号和密码。' + toast(loginError.value) + return false + } finally { + loginSubmitting.value = false + } +} + +function logout(reason = 'manual', options = {}) { + const notify = options.notify ?? reason === 'timeout' + const redirect = options.redirect ?? reason !== 'invalid' + + loggedIn.value = false + persistAuthState(false) + currentUser.value = buildAnonymousUser() + clearSessionTimeout() + + if (notify) { + toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。') + } + + if (redirect) { + redirectToLogin() + } +} + +function handleRecoverPassword() { + toast('请联系系统管理员重置账号密码。') +} + +function handleSsoLogin() { + toast('SSO 登录暂未启用。') +} + +function resolveEntryRoute() { + loggedIn.value = syncAuthSession() + currentUser.value = readStoredUser() + + if (!isInitialized.value) { + return { name: 'setup' } + } + + if (!loggedIn.value) { + return { name: 'login' } + } + + return resolveDefaultAuthorizedRoute(currentUser.value) +} + +export function useSystemState() { + return { + bootstrapState, + companyProfile, + currentUser, + databaseTestMessage, + databaseTestPassed, + databaseTesting, + handleDatabaseDirty, + handleDatabaseTest, + handleLogin, + handleRecoverPassword, + handleRuntimeDirty, + handleRuntimeTest, + handleSetupSubmit, + handleSsoLogin, + isInitialized, + loggedIn, + loginError, + loginSubmitting, + logout, + resetFromClientEnv, + resolveEntryRoute, + runtimeTestMessage, + runtimeTestPassed, + runtimeTesting, + setupError, + setupCountdownSeconds, + setupProgressMessage, + setupStartupLog, + setupStartupSteps, + setupStartupVisible, + setupSubmitting, + syncAuthSession, + updateCompanyProfilePreview + } +} diff --git a/web/src/services/api.js b/web/src/services/api.js index 363c344..037b6bb 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -1,157 +1,157 @@ -const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' -const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' - -function readCurrentUserHeaders() { - if (typeof window === 'undefined') { - return {} - } - - const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY) - if (!raw) { - return {} - } - - try { - const payload = JSON.parse(raw) - const username = String(payload?.username || '').trim() - const name = String(payload?.name || username).trim() - const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : [] - const isAdmin = Boolean(payload?.isAdmin) - - if (!username && !name) { - return {} - } - - return { - 'x-auth-username': username, - 'x-auth-name': name, - 'x-auth-role-codes': roleCodes.join(','), - 'x-auth-is-admin': String(isAdmin) - } - } catch { - return {} - } -} - -function normalizeApiBaseUrl(value) { - return String(value || '/api/v1').replace(/\/$/, '') -} - -function isLoopbackHost(hostname) { - const normalized = String(hostname || '').trim().toLowerCase() - return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1' -} - -function resolveBrowserReachableApiBaseUrl(value) { - const normalized = normalizeApiBaseUrl(value) - - if (typeof window === 'undefined') { - return normalized - } - - try { - const apiUrl = new URL(normalized) - const browserHost = window.location.hostname - - if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) { - apiUrl.hostname = browserHost - return normalizeApiBaseUrl(apiUrl.toString()) - } - } catch { - return normalized - } - - return normalized -} - -function readStoredApiBaseUrl() { - if (typeof window === 'undefined') { - return '' - } - - return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '') -} - -let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1') - -if (typeof window !== 'undefined') { - window.localStorage.removeItem(API_BASE_STORAGE_KEY) -} - -export function setRuntimeApiBaseUrl(value) { - runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value) - - if (typeof window !== 'undefined') { - window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl) - } -} - -export function getRuntimeApiBaseUrl() { - return runtimeApiBaseUrl -} - -function buildUrl(path) { - if (!path.startsWith('/')) { - return `${runtimeApiBaseUrl}/${path}` - } - - return `${runtimeApiBaseUrl}${path}` -} - -export async function apiRequest(path, options = {}) { - const { - contentType = 'application/json', - responseType = 'json', - headers: customHeaders, - ...fetchOptions - } = options - - const headers = { - ...readCurrentUserHeaders(), - ...(customHeaders || {}) - } - - if (contentType !== null && typeof headers['Content-Type'] === 'undefined') { - headers['Content-Type'] = contentType - } - - let response - - try { - response = await fetch(buildUrl(path), { - ...fetchOptions, - headers - }) - } catch { - throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。') - } - - if (responseType === 'blob') { - if (!response.ok) { - let payload = null - - try { - payload = await response.json() - } catch { - payload = null - } - - throw new Error(payload?.detail || '接口请求失败,请稍后重试。') - } - - return response.blob() - } - - let payload = null - try { - payload = await response.json() - } catch { - payload = null - } - - if (!response.ok) { - throw new Error(payload?.detail || '接口请求失败,请稍后重试。') - } - - return payload -} +const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' +const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' + +function readCurrentUserHeaders() { + if (typeof window === 'undefined') { + return {} + } + + const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY) + if (!raw) { + return {} + } + + try { + const payload = JSON.parse(raw) + const username = String(payload?.username || '').trim() + const name = String(payload?.name || username).trim() + const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : [] + const isAdmin = Boolean(payload?.isAdmin) + + if (!username && !name) { + return {} + } + + return { + 'x-auth-username': username, + 'x-auth-name': name, + 'x-auth-role-codes': roleCodes.join(','), + 'x-auth-is-admin': String(isAdmin) + } + } catch { + return {} + } +} + +function normalizeApiBaseUrl(value) { + return String(value || '/api/v1').replace(/\/$/, '') +} + +function isLoopbackHost(hostname) { + const normalized = String(hostname || '').trim().toLowerCase() + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1' +} + +function resolveBrowserReachableApiBaseUrl(value) { + const normalized = normalizeApiBaseUrl(value) + + if (typeof window === 'undefined') { + return normalized + } + + try { + const apiUrl = new URL(normalized) + const browserHost = window.location.hostname + + if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) { + apiUrl.hostname = browserHost + return normalizeApiBaseUrl(apiUrl.toString()) + } + } catch { + return normalized + } + + return normalized +} + +function readStoredApiBaseUrl() { + if (typeof window === 'undefined') { + return '' + } + + return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '') +} + +let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1') + +if (typeof window !== 'undefined') { + window.localStorage.removeItem(API_BASE_STORAGE_KEY) +} + +export function setRuntimeApiBaseUrl(value) { + runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value) + + if (typeof window !== 'undefined') { + window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl) + } +} + +export function getRuntimeApiBaseUrl() { + return runtimeApiBaseUrl +} + +function buildUrl(path) { + if (!path.startsWith('/')) { + return `${runtimeApiBaseUrl}/${path}` + } + + return `${runtimeApiBaseUrl}${path}` +} + +export async function apiRequest(path, options = {}) { + const { + contentType = 'application/json', + responseType = 'json', + headers: customHeaders, + ...fetchOptions + } = options + + const headers = { + ...readCurrentUserHeaders(), + ...(customHeaders || {}) + } + + if (contentType !== null && typeof headers['Content-Type'] === 'undefined') { + headers['Content-Type'] = contentType + } + + let response + + try { + response = await fetch(buildUrl(path), { + ...fetchOptions, + headers + }) + } catch { + throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。') + } + + if (responseType === 'blob') { + if (!response.ok) { + let payload = null + + try { + payload = await response.json() + } catch { + payload = null + } + + throw new Error(payload?.detail || '接口请求失败,请稍后重试。') + } + + return response.blob() + } + + let payload = null + try { + payload = await response.json() + } catch { + payload = null + } + + if (!response.ok) { + throw new Error(payload?.detail || '接口请求失败,请稍后重试。') + } + + return payload +} diff --git a/web/src/services/knowledge.js b/web/src/services/knowledge.js index 82e4a69..906456c 100644 --- a/web/src/services/knowledge.js +++ b/web/src/services/knowledge.js @@ -1,37 +1,37 @@ -import { apiRequest } from './api.js' - -export function fetchKnowledgeLibrary() { - return apiRequest('/knowledge/library') -} - -export function fetchKnowledgeDocument(documentId) { - return apiRequest(`/knowledge/documents/${documentId}`) -} - -export function fetchKnowledgeOnlyOfficeConfig(documentId) { - return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`) -} - -export function uploadKnowledgeDocument({ folder, file }) { - return apiRequest( - `/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`, - { - method: 'POST', - body: file, - contentType: file.type || 'application/octet-stream' - } - ) -} - -export function deleteKnowledgeDocument(documentId) { - return apiRequest(`/knowledge/documents/${documentId}`, { - method: 'DELETE' - }) -} - -export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') { - return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, { - responseType: 'blob', - contentType: null - }) -} +import { apiRequest } from './api.js' + +export function fetchKnowledgeLibrary() { + return apiRequest('/knowledge/library') +} + +export function fetchKnowledgeDocument(documentId) { + return apiRequest(`/knowledge/documents/${documentId}`) +} + +export function fetchKnowledgeOnlyOfficeConfig(documentId) { + return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`) +} + +export function uploadKnowledgeDocument({ folder, file }) { + return apiRequest( + `/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`, + { + method: 'POST', + body: file, + contentType: file.type || 'application/octet-stream' + } + ) +} + +export function deleteKnowledgeDocument(documentId) { + return apiRequest(`/knowledge/documents/${documentId}`, { + method: 'DELETE' + }) +} + +export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') { + return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, { + responseType: 'blob', + contentType: null + }) +} diff --git a/web/src/services/onlyoffice.js b/web/src/services/onlyoffice.js index b4e4d6b..e4c6e0d 100644 --- a/web/src/services/onlyoffice.js +++ b/web/src/services/onlyoffice.js @@ -1,43 +1,43 @@ -const scriptPromises = new Map() - -function normalizeBaseUrl(value) { - return String(value || '').replace(/\/$/, '') -} - -export function buildOnlyOfficeScriptUrl(documentServerUrl) { - return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js` -} - -export function loadOnlyOfficeApi(documentServerUrl) { - const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl) - if (typeof window === 'undefined') { - return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。')) - } - - if (window.DocsAPI?.DocEditor) { - return Promise.resolve(window.DocsAPI) - } - - if (scriptPromises.has(scriptUrl)) { - return scriptPromises.get(scriptUrl) - } - - const promise = new Promise((resolve, reject) => { - const existing = document.querySelector(`script[src="${scriptUrl}"]`) - if (existing) { - existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true }) - existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true }) - return - } - - const script = document.createElement('script') - script.src = scriptUrl - script.async = true - script.onload = () => resolve(window.DocsAPI) - script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。')) - document.head.appendChild(script) - }) - - scriptPromises.set(scriptUrl, promise) - return promise -} +const scriptPromises = new Map() + +function normalizeBaseUrl(value) { + return String(value || '').replace(/\/$/, '') +} + +export function buildOnlyOfficeScriptUrl(documentServerUrl) { + return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js` +} + +export function loadOnlyOfficeApi(documentServerUrl) { + const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl) + if (typeof window === 'undefined') { + return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。')) + } + + if (window.DocsAPI?.DocEditor) { + return Promise.resolve(window.DocsAPI) + } + + if (scriptPromises.has(scriptUrl)) { + return scriptPromises.get(scriptUrl) + } + + const promise = new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${scriptUrl}"]`) + if (existing) { + existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true }) + existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true }) + return + } + + const script = document.createElement('script') + script.src = scriptUrl + script.async = true + script.onload = () => resolve(window.DocsAPI) + script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。')) + document.head.appendChild(script) + }) + + scriptPromises.set(scriptUrl, promise) + return promise +} diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index d90aa17..16c1986 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -1,63 +1,63 @@ -export const DEFAULT_APP_VIEW_ORDER = [ - 'overview', - 'workbench', - 'requests', - 'approval', - 'chat', - 'policies', - 'audit', - 'employees', - 'settings' -] - -const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies']) -const VIEW_ROLE_RULES = { - overview: ['finance', 'executive'], - approval: ['approver'], - audit: ['auditor'], - employees: ['manager'], - settings: ['manager'] -} - -function normalizedRoleCodes(user) { - if (!user) { - return [] - } - - return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : [] -} - -export function isManagerUser(user) { - return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') -} - -export function canAccessAppView(user, viewId) { - if (!viewId || !user) { - return false - } - - if (isManagerUser(user)) { - return true - } - - if (ALWAYS_VISIBLE_VIEWS.has(viewId)) { - return true - } - - const requiredRoles = VIEW_ROLE_RULES[viewId] || [] - const roleCodes = normalizedRoleCodes(user) - return requiredRoles.some((roleCode) => roleCodes.includes(roleCode)) -} - -export function getAccessibleViewIds(user) { - return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId)) -} - -export function filterNavItemsByAccess(navItems, user) { - return navItems.filter((item) => canAccessAppView(user, item.id)) -} - -export function resolveDefaultAuthorizedRoute(user) { - const firstVisibleView = getAccessibleViewIds(user)[0] - return { name: `app-${firstVisibleView || 'workbench'}` } -} +export const DEFAULT_APP_VIEW_ORDER = [ + 'overview', + 'workbench', + 'requests', + 'approval', + 'chat', + 'policies', + 'audit', + 'employees', + 'settings' +] + +const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies']) +const VIEW_ROLE_RULES = { + overview: ['finance', 'executive'], + approval: ['approver'], + audit: ['auditor'], + employees: ['manager'], + settings: ['manager'] +} + +function normalizedRoleCodes(user) { + if (!user) { + return [] + } + + return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : [] +} + +export function isManagerUser(user) { + return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') +} + +export function canAccessAppView(user, viewId) { + if (!viewId || !user) { + return false + } + + if (isManagerUser(user)) { + return true + } + + if (ALWAYS_VISIBLE_VIEWS.has(viewId)) { + return true + } + + const requiredRoles = VIEW_ROLE_RULES[viewId] || [] + const roleCodes = normalizedRoleCodes(user) + return requiredRoles.some((roleCode) => roleCodes.includes(roleCode)) +} + +export function getAccessibleViewIds(user) { + return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId)) +} + +export function filterNavItemsByAccess(navItems, user) { + return navItems.filter((item) => canAccessAppView(user, item.id)) +} + +export function resolveDefaultAuthorizedRoute(user) { + const firstVisibleView = getAccessibleViewIds(user)[0] + return { name: `app-${firstVisibleView || 'workbench'}` } +} diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 7f4e701..f6cf2fc 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -1,200 +1,200 @@ - - - + + + diff --git a/web/src/views/PoliciesView.vue b/web/src/views/PoliciesView.vue index 08dc1e9..7a15ff1 100644 --- a/web/src/views/PoliciesView.vue +++ b/web/src/views/PoliciesView.vue @@ -1,199 +1,199 @@ - + + + + diff --git a/web/src/views/scripts/PoliciesView.js b/web/src/views/scripts/PoliciesView.js index 0eb04b4..36f3d61 100644 --- a/web/src/views/scripts/PoliciesView.js +++ b/web/src/views/scripts/PoliciesView.js @@ -1,177 +1,183 @@ -import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' - -import { useSystemState } from '../../composables/useSystemState.js' -import { useToast } from '../../composables/useToast.js' -import { - deleteKnowledgeDocument, - fetchKnowledgeDocument, - fetchKnowledgeDocumentBlob, - fetchKnowledgeLibrary, - fetchKnowledgeOnlyOfficeConfig, - uploadKnowledgeDocument -} from '../../services/knowledge.js' -import { loadOnlyOfficeApi } from '../../services/onlyoffice.js' -import { isManagerUser } from '../../utils/accessControl.js' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' + +import { useSystemState } from '../../composables/useSystemState.js' +import { useToast } from '../../composables/useToast.js' +import { + deleteKnowledgeDocument, + fetchKnowledgeDocument, + fetchKnowledgeDocumentBlob, + fetchKnowledgeLibrary, + fetchKnowledgeOnlyOfficeConfig, + uploadKnowledgeDocument +} from '../../services/knowledge.js' +import { loadOnlyOfficeApi } from '../../services/onlyoffice.js' +import { isManagerUser } from '../../utils/accessControl.js' import { buildExcelPreviewTable, buildPreviewMetaLine, buildPreviewSecondaryMetaLine } from './policiesPreviewFormatters.js' +import { canUseOnlyOfficePreview, resolveKnowledgePreviewMode } from './knowledgePreviewMode.js' function triggerFileDownload(blob, filename) { const url = URL.createObjectURL(blob) -const anchor = document.createElement('a') + const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() URL.revokeObjectURL(url) } - -const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx']) - -function supportsOnlyOfficePreview(document) { - return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase()) -} - -export default { - name: 'PoliciesView', - emits: ['summary-change'], - setup(_, { emit }) { - const { currentUser } = useSystemState() - const { toast } = useToast() - - const documentSearch = ref('') - const activeFolder = ref('差旅规范') - const folders = ref([]) - const documents = ref([]) - const selectedDocument = ref(null) - const pageSizeOpen = ref(false) - const currentPage = ref(1) - const pageSize = ref(10) - const pageSizes = [10, 20, 50] - const loading = ref(false) - const uploadInput = ref(null) - const uploading = ref(false) - const deletingId = ref('') - const previewLoading = ref(false) + +export default { + name: 'PoliciesView', + emits: ['summary-change'], + setup(_, { emit }) { + const { currentUser } = useSystemState() + const { toast } = useToast() + + const documentSearch = ref('') + const activeFolder = ref('差旅规范') + const folders = ref([]) + const documents = ref([]) + const selectedDocument = ref(null) + const pageSizeOpen = ref(false) + const currentPage = ref(1) + const pageSize = ref(10) + const pageSizes = [10, 20, 50] + const loading = ref(false) + const uploadInput = ref(null) + const uploading = ref(false) + const deletingId = ref('') + const previewLoading = ref(false) const previewBlobUrl = ref('') const previewError = ref('') const onlyOfficeLoading = ref(false) const onlyOfficeError = ref('') + const onlyOfficeAvailable = ref(false) const onlyOfficeEditor = ref(null) const onlyOfficeHostId = ref('knowledge-onlyoffice-preview') const currentPreviewPageIndex = ref(0) - - const isAdmin = computed(() => isManagerUser(currentUser.value)) - const uploadHint = computed(() => - isAdmin.value - ? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本' - : '当前账号只有查阅权限,上传、删除和修改仅管理员可用' - ) - - const filteredFolders = computed(() => folders.value) - - const filteredDocuments = computed(() => { - const key = documentSearch.value.trim() - - return documents.value.filter((doc) => { - const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true - const matchesSearch = key ? doc.name.includes(key) : true - return inFolder && matchesSearch + + const isAdmin = computed(() => isManagerUser(currentUser.value)) + const uploadHint = computed(() => + isAdmin.value + ? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本' + : '当前账号只有查阅权限,上传、删除和修改仅管理员可用' + ) + + const filteredFolders = computed(() => folders.value) + + const filteredDocuments = computed(() => { + const key = documentSearch.value.trim() + + return documents.value.filter((doc) => { + const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true + const matchesSearch = key ? doc.name.includes(key) : true + return inFolder && matchesSearch + }) + }) + + 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) + }) + const activePreviewPage = computed(() => { + const pages = selectedDocument.value?.previewPages || [] + return pages[currentPreviewPageIndex.value] || pages[0] || null + }) + const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value)) + const previewSecondaryMetaLine = computed(() => + buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value) + ) + const previewMode = computed(() => + resolveKnowledgePreviewMode(selectedDocument.value, { + onlyOfficeAvailable: onlyOfficeAvailable.value }) - }) - - 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) - }) - const activePreviewPage = computed(() => { - const pages = selectedDocument.value?.previewPages || [] - return pages[currentPreviewPageIndex.value] || pages[0] || null - }) - const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value)) - const previewSecondaryMetaLine = computed(() => - buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value) ) - const shouldUseOnlyOffice = computed(() => supportsOnlyOfficePreview(selectedDocument.value)) + const shouldUseOnlyOffice = computed(() => previewMode.value === 'onlyoffice') const excelPreviewTable = computed(() => selectedDocument.value?.previewKind === 'table' ? buildExcelPreviewTable(activePreviewPage.value) : { headers: [], rows: [] } ) - - function revokePreviewBlob() { - if (previewBlobUrl.value) { - URL.revokeObjectURL(previewBlobUrl.value) - previewBlobUrl.value = '' - } - } - - function destroyOnlyOfficeEditor() { - if (onlyOfficeEditor.value?.destroyEditor) { - onlyOfficeEditor.value.destroyEditor() - } - onlyOfficeEditor.value = null - } - + + function revokePreviewBlob() { + if (previewBlobUrl.value) { + URL.revokeObjectURL(previewBlobUrl.value) + previewBlobUrl.value = '' + } + } + + function destroyOnlyOfficeEditor() { + if (onlyOfficeEditor.value?.destroyEditor) { + onlyOfficeEditor.value.destroyEditor() + } + onlyOfficeEditor.value = null + } + async function mountOnlyOfficeEditor(documentId) { onlyOfficeLoading.value = true onlyOfficeError.value = '' + onlyOfficeAvailable.value = false destroyOnlyOfficeEditor() try { const payload = await fetchKnowledgeOnlyOfficeConfig(documentId) await loadOnlyOfficeApi(payload.documentServerUrl) - await nextTick() - - if (!window.DocsAPI?.DocEditor) { - throw new Error('ONLYOFFICE 编辑器未正确加载。') - } + await nextTick() + + if (!window.DocsAPI?.DocEditor) { + throw new Error('ONLYOFFICE 编辑器未正确加载。') + } onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}` await nextTick() onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config) + onlyOfficeAvailable.value = true + return true } catch (error) { onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。' + return false } finally { onlyOfficeLoading.value = false } } - - async function loadLibrary(options = {}) { - loading.value = true - try { - const payload = await fetchKnowledgeLibrary() - folders.value = payload.folders || [] - documents.value = payload.documents || [] - emit('summary-change', { totalDocuments: documents.value.length }) - - const activeExists = folders.value.some((folder) => folder.name === activeFolder.value) - if (!activeExists) { - activeFolder.value = folders.value[0]?.name || '' - } - - if (options.preserveSelection && selectedDocument.value?.id) { - const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id) - if (!exists) { - selectedDocument.value = null - revokePreviewBlob() - } - } - } catch (error) { - emit('summary-change', { totalDocuments: 0 }) - toast(error.message || '知识库加载失败。') - } finally { - loading.value = false - } - } - - async function selectDocument(documentId) { - previewLoading.value = true + + async function loadLibrary(options = {}) { + loading.value = true + try { + const payload = await fetchKnowledgeLibrary() + folders.value = payload.folders || [] + documents.value = payload.documents || [] + emit('summary-change', { totalDocuments: documents.value.length }) + + const activeExists = folders.value.some((folder) => folder.name === activeFolder.value) + if (!activeExists) { + activeFolder.value = folders.value[0]?.name || '' + } + + if (options.preserveSelection && selectedDocument.value?.id) { + const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id) + if (!exists) { + selectedDocument.value = null + revokePreviewBlob() + } + } + } catch (error) { + emit('summary-change', { totalDocuments: 0 }) + toast(error.message || '知识库加载失败。') + } finally { + loading.value = false + } + } + + async function selectDocument(documentId) { + previewLoading.value = true previewError.value = '' onlyOfficeError.value = '' + onlyOfficeAvailable.value = false revokePreviewBlob() destroyOnlyOfficeEditor() @@ -180,182 +186,187 @@ export default { selectedDocument.value = payload currentPreviewPageIndex.value = 0 - if (supportsOnlyOfficePreview(payload)) { + if (canUseOnlyOfficePreview(payload)) { await mountOnlyOfficeEditor(documentId) - } else if (payload.previewKind === 'pdf' || payload.previewKind === 'image') { + } + + if (payload.previewKind === 'pdf' || payload.previewKind === 'image') { const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline') previewBlobUrl.value = URL.createObjectURL(blob) } } catch (error) { previewError.value = error.message || '预览加载失败。' - toast(previewError.value) - } finally { - previewLoading.value = false - } - } - - async function handleDownload(document) { - try { - const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment') - triggerFileDownload(blob, document.name) - } catch (error) { - toast(error.message || '下载失败。') - } - } - - function triggerUpload() { - if (!isAdmin.value || uploading.value) { - return - } - uploadInput.value?.click() - } - - async function uploadFiles(fileList) { - const files = Array.from(fileList || []).filter(Boolean) - if (!files.length || !activeFolder.value || !isAdmin.value) { - return - } - - uploading.value = true - try { - let latestDocumentId = '' - for (const file of files) { - const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file }) - latestDocumentId = payload.id - } - - await loadLibrary({ preserveSelection: true }) - toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。') - - if (latestDocumentId) { - await selectDocument(latestDocumentId) - } - } catch (error) { - toast(error.message || '上传失败。') - } finally { - uploading.value = false - if (uploadInput.value) { - uploadInput.value.value = '' - } - } - } - - async function handleFileInput(event) { - await uploadFiles(event.target.files) - } - - async function handleDrop(event) { - if (!isAdmin.value) { - return - } - await uploadFiles(event.dataTransfer?.files) - } - - async function handleDelete(document) { - if (!isAdmin.value || deletingId.value) { - return - } - - const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`) - if (!confirmed) { - return - } - - deletingId.value = document.id - try { - await deleteKnowledgeDocument(document.id) - if (selectedDocument.value?.id === document.id) { - selectedDocument.value = null - revokePreviewBlob() - } - await loadLibrary() - toast('知识库文件已删除。') - } catch (error) { - toast(error.message || '删除失败。') - } finally { - deletingId.value = '' - } - } - - function changePageSize(size) { - pageSize.value = size - pageSizeOpen.value = false - currentPage.value = 1 - } - - function closePreview() { - selectedDocument.value = null - previewError.value = '' - currentPreviewPageIndex.value = 0 + toast(previewError.value) + } finally { + previewLoading.value = false + } + } + + async function handleDownload(document) { + try { + const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment') + triggerFileDownload(blob, document.name) + } catch (error) { + toast(error.message || '下载失败。') + } + } + + function triggerUpload() { + if (!isAdmin.value || uploading.value) { + return + } + uploadInput.value?.click() + } + + async function uploadFiles(fileList) { + const files = Array.from(fileList || []).filter(Boolean) + if (!files.length || !activeFolder.value || !isAdmin.value) { + return + } + + uploading.value = true + try { + let latestDocumentId = '' + for (const file of files) { + const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file }) + latestDocumentId = payload.id + } + + await loadLibrary({ preserveSelection: true }) + toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。') + + if (latestDocumentId) { + await selectDocument(latestDocumentId) + } + } catch (error) { + toast(error.message || '上传失败。') + } finally { + uploading.value = false + if (uploadInput.value) { + uploadInput.value.value = '' + } + } + } + + async function handleFileInput(event) { + await uploadFiles(event.target.files) + } + + async function handleDrop(event) { + if (!isAdmin.value) { + return + } + await uploadFiles(event.dataTransfer?.files) + } + + async function handleDelete(document) { + if (!isAdmin.value || deletingId.value) { + return + } + + const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`) + if (!confirmed) { + return + } + + deletingId.value = document.id + try { + await deleteKnowledgeDocument(document.id) + if (selectedDocument.value?.id === document.id) { + selectedDocument.value = null + revokePreviewBlob() + } + await loadLibrary() + toast('知识库文件已删除。') + } catch (error) { + toast(error.message || '删除失败。') + } finally { + deletingId.value = '' + } + } + + function changePageSize(size) { + pageSize.value = size + pageSizeOpen.value = false + currentPage.value = 1 + } + + function closePreview() { + selectedDocument.value = null + previewError.value = '' + currentPreviewPageIndex.value = 0 revokePreviewBlob() destroyOnlyOfficeEditor() onlyOfficeError.value = '' + onlyOfficeAvailable.value = false } - - function selectPreviewPage(index) { - currentPreviewPageIndex.value = index - } - - watch(filteredDocuments, () => { - currentPage.value = 1 - pageSizeOpen.value = false - - if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) { - closePreview() - } - }) - - watch(activeFolder, () => { - closePreview() - }) - - onMounted(() => { - loadLibrary() - }) - + + function selectPreviewPage(index) { + currentPreviewPageIndex.value = index + } + + watch(filteredDocuments, () => { + currentPage.value = 1 + pageSizeOpen.value = false + + if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) { + closePreview() + } + }) + + watch(activeFolder, () => { + closePreview() + }) + + onMounted(() => { + loadLibrary() + }) + onBeforeUnmount(() => { revokePreviewBlob() + destroyOnlyOfficeEditor() }) - - return { - activeFolder, - activePreviewPage, - changePageSize, - closePreview, - excelPreviewTable, - currentPage, - currentPreviewPageIndex, - deletingId, - documentSearch, - filteredFolders, - handleDelete, - handleDownload, - handleDrop, - handleFileInput, - isAdmin, - loading, - pageSize, + + return { + activeFolder, + activePreviewPage, + changePageSize, + closePreview, + excelPreviewTable, + currentPage, + currentPreviewPageIndex, + deletingId, + documentSearch, + filteredFolders, + handleDelete, + handleDownload, + handleDrop, + handleFileInput, + isAdmin, + loading, + pageSize, pageSizeOpen, pageSizes, onlyOfficeError, onlyOfficeHostId, onlyOfficeLoading, + previewMode, previewMetaLine, previewSecondaryMetaLine, previewBlobUrl, - previewError, - previewLoading, - shouldUseOnlyOffice, - selectDocument, - selectPreviewPage, - selectedDocument, - totalCount, - totalPages, - triggerUpload, - uploadHint, - uploadInput, - uploading, - visibleDocuments - } - } -} + previewError, + previewLoading, + shouldUseOnlyOffice, + selectDocument, + selectPreviewPage, + selectedDocument, + totalCount, + totalPages, + triggerUpload, + uploadHint, + uploadInput, + uploading, + visibleDocuments + } + } +} diff --git a/web/src/views/scripts/knowledgePreviewMode.js b/web/src/views/scripts/knowledgePreviewMode.js new file mode 100644 index 0000000..843fc89 --- /dev/null +++ b/web/src/views/scripts/knowledgePreviewMode.js @@ -0,0 +1,21 @@ +const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx']) + +function supportsOnlyOfficePreview(document) { + return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase()) +} + +export function resolveKnowledgePreviewMode(document, options = {}) { + if (!document) { + return 'none' + } + + if (supportsOnlyOfficePreview(document) && options.onlyOfficeAvailable) { + return 'onlyoffice' + } + + return document.previewKind || 'unsupported' +} + +export function canUseOnlyOfficePreview(document) { + return supportsOnlyOfficePreview(document) +} diff --git a/web/src/views/scripts/policiesPreviewFormatters.js b/web/src/views/scripts/policiesPreviewFormatters.js index e161d09..734c583 100644 --- a/web/src/views/scripts/policiesPreviewFormatters.js +++ b/web/src/views/scripts/policiesPreviewFormatters.js @@ -1,65 +1,65 @@ -function splitPreviewRow(line) { - return String(line || '') - .split('|') - .map((cell) => cell.trim()) -} - -export function buildPreviewMetaLine(document) { - if (!document) { - return [] - } - - return [document.summary, document.time].filter(Boolean) -} - -export function buildPreviewSecondaryMetaLine(document, page = null) { - if (!document) { - return [] - } - - const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null) - if (!activePage) { - return [] - } - - const parts = [] - - if (activePage.subtitle) { - parts.push(activePage.subtitle) - } - - if (document.previewKind === 'table') { - for (const item of activePage.stats || []) { - if (!item?.label || !item?.value || item.label === '文件大小') { - continue - } - parts.push(`${item.label} ${item.value}`) - } - } - - return parts -} - -export function buildExcelPreviewTable(page) { - const rawRows = (page?.blocks || []) - .flatMap((block) => block.lines || []) - .map(splitPreviewRow) - .filter((row) => row.length > 0 && row.some((cell) => cell !== '')) - - if (!rawRows.length) { - return { headers: [], rows: [] } - } - - const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0) - const normalizedRows = rawRows.map((row) => - Array.from({ length: columnCount }, (_, index) => row[index] ?? '') - ) - - const [headerRow, ...bodyRows] = normalizedRows - const headers = headerRow.map((cell, index) => cell || `列 ${index + 1}`) - - return { - headers, - rows: bodyRows - } -} +function splitPreviewRow(line) { + return String(line || '') + .split('|') + .map((cell) => cell.trim()) +} + +export function buildPreviewMetaLine(document) { + if (!document) { + return [] + } + + return [document.summary, document.time].filter(Boolean) +} + +export function buildPreviewSecondaryMetaLine(document, page = null) { + if (!document) { + return [] + } + + const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null) + if (!activePage) { + return [] + } + + const parts = [] + + if (activePage.subtitle) { + parts.push(activePage.subtitle) + } + + if (document.previewKind === 'table') { + for (const item of activePage.stats || []) { + if (!item?.label || !item?.value || item.label === '文件大小') { + continue + } + parts.push(`${item.label} ${item.value}`) + } + } + + return parts +} + +export function buildExcelPreviewTable(page) { + const rawRows = (page?.blocks || []) + .flatMap((block) => block.lines || []) + .map(splitPreviewRow) + .filter((row) => row.length > 0 && row.some((cell) => cell !== '')) + + if (!rawRows.length) { + return { headers: [], rows: [] } + } + + const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0) + const normalizedRows = rawRows.map((row) => + Array.from({ length: columnCount }, (_, index) => row[index] ?? '') + ) + + const [headerRow, ...bodyRows] = normalizedRows + const headers = headerRow.map((cell, index) => cell || `列 ${index + 1}`) + + return { + headers, + rows: bodyRows + } +} diff --git a/web/tests/api-request.test.mjs b/web/tests/api-request.test.mjs index 2b4efca..699d7a5 100644 --- a/web/tests/api-request.test.mjs +++ b/web/tests/api-request.test.mjs @@ -1,99 +1,99 @@ -import assert from 'node:assert/strict' - -import { apiRequest } from '../src/services/api.js' - -async function testUsesCustomContentTypeHeader() { - let capturedOptions = null - - global.fetch = async (_url, options) => { - capturedOptions = options - return { - ok: true, - async json() { - return { ok: true } - } - } - } - - await apiRequest('/knowledge/documents', { - method: 'POST', - body: 'payload', - contentType: 'application/octet-stream' - }) - - assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream') -} - -async function testSupportsBlobResponses() { - const blob = new Blob(['preview']) - - global.fetch = async () => ({ - ok: true, - async blob() { - return blob - }, - async json() { - throw new Error('json parser should not be used for blob responses') - } - }) - - const payload = await apiRequest('/knowledge/documents/demo/content', { - responseType: 'blob', - contentType: null - }) - - assert.equal(payload, blob) -} - -async function testInjectsAuthenticatedUserHeaders() { - const sessionStorage = new Map([ - [ - 'x-financial-auth-user', - JSON.stringify({ - username: 'admin', - name: '系统管理员', - roleCodes: ['manager'], - isAdmin: true - }) - ] - ]) - - global.window = { - sessionStorage: { - getItem(key) { - return sessionStorage.get(key) ?? null - } - } - } - - let capturedOptions = null - - global.fetch = async (_url, options) => { - capturedOptions = options - return { - ok: true, - async json() { - return { ok: true } - } - } - } - - await apiRequest('/knowledge/library') - - assert.equal(capturedOptions.headers['x-auth-username'], 'admin') - assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员') - assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager') - assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true') -} - -async function run() { - await testUsesCustomContentTypeHeader() - await testSupportsBlobResponses() - await testInjectsAuthenticatedUserHeaders() - console.log('api-request tests passed') -} - -run().catch((error) => { - console.error(error) - process.exit(1) -}) +import assert from 'node:assert/strict' + +import { apiRequest } from '../src/services/api.js' + +async function testUsesCustomContentTypeHeader() { + let capturedOptions = null + + global.fetch = async (_url, options) => { + capturedOptions = options + return { + ok: true, + async json() { + return { ok: true } + } + } + } + + await apiRequest('/knowledge/documents', { + method: 'POST', + body: 'payload', + contentType: 'application/octet-stream' + }) + + assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream') +} + +async function testSupportsBlobResponses() { + const blob = new Blob(['preview']) + + global.fetch = async () => ({ + ok: true, + async blob() { + return blob + }, + async json() { + throw new Error('json parser should not be used for blob responses') + } + }) + + const payload = await apiRequest('/knowledge/documents/demo/content', { + responseType: 'blob', + contentType: null + }) + + assert.equal(payload, blob) +} + +async function testInjectsAuthenticatedUserHeaders() { + const sessionStorage = new Map([ + [ + 'x-financial-auth-user', + JSON.stringify({ + username: 'admin', + name: '系统管理员', + roleCodes: ['manager'], + isAdmin: true + }) + ] + ]) + + global.window = { + sessionStorage: { + getItem(key) { + return sessionStorage.get(key) ?? null + } + } + } + + let capturedOptions = null + + global.fetch = async (_url, options) => { + capturedOptions = options + return { + ok: true, + async json() { + return { ok: true } + } + } + } + + await apiRequest('/knowledge/library') + + assert.equal(capturedOptions.headers['x-auth-username'], 'admin') + assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员') + assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager') + assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true') +} + +async function run() { + await testUsesCustomContentTypeHeader() + await testSupportsBlobResponses() + await testInjectsAuthenticatedUserHeaders() + console.log('api-request tests passed') +} + +run().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/web/tests/knowledge-preview-mode.test.mjs b/web/tests/knowledge-preview-mode.test.mjs new file mode 100644 index 0000000..f68e2c2 --- /dev/null +++ b/web/tests/knowledge-preview-mode.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' + +import { resolveKnowledgePreviewMode } from '../src/views/scripts/knowledgePreviewMode.js' + +function testPrefersOnlyOfficeForSupportedOfficeFileWhenAvailable() { + const document = { + extension: 'xlsx', + previewKind: 'table' + } + + assert.equal(resolveKnowledgePreviewMode(document, { onlyOfficeAvailable: true }), 'onlyoffice') +} + +function testFallsBackToStructuredPreviewForOfficeFileWhenOnlyOfficeUnavailable() { + const document = { + extension: 'xlsx', + previewKind: 'table' + } + + assert.equal(resolveKnowledgePreviewMode(document, { onlyOfficeAvailable: false }), 'table') +} + +function testUsesPreviewKindForNonOnlyOfficeFile() { + const document = { + extension: 'pdf', + previewKind: 'pdf' + } + + assert.equal(resolveKnowledgePreviewMode(document, { onlyOfficeAvailable: false }), 'pdf') +} + +function run() { + testPrefersOnlyOfficeForSupportedOfficeFileWhenAvailable() + testFallsBackToStructuredPreviewForOfficeFileWhenOnlyOfficeUnavailable() + testUsesPreviewKindForNonOnlyOfficeFile() + console.log('knowledge preview mode tests passed') +} + +run() diff --git a/web/tests/onlyoffice-service.test.mjs b/web/tests/onlyoffice-service.test.mjs index 1bdc314..41bb31c 100644 --- a/web/tests/onlyoffice-service.test.mjs +++ b/web/tests/onlyoffice-service.test.mjs @@ -1,13 +1,13 @@ -import assert from 'node:assert/strict' - -import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js' - -function run() { - assert.equal( - buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'), - 'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js' - ) - console.log('onlyoffice service tests passed') -} - -run() +import assert from 'node:assert/strict' + +import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js' + +function run() { + assert.equal( + buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'), + 'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js' + ) + console.log('onlyoffice service tests passed') +} + +run() diff --git a/web/tests/policies-preview-formatters.test.mjs b/web/tests/policies-preview-formatters.test.mjs index 70312b3..708f711 100644 --- a/web/tests/policies-preview-formatters.test.mjs +++ b/web/tests/policies-preview-formatters.test.mjs @@ -1,69 +1,69 @@ -import assert from 'node:assert/strict' - -import { - buildExcelPreviewTable, - buildPreviewMetaLine, - buildPreviewSecondaryMetaLine -} from '../src/views/scripts/policiesPreviewFormatters.js' - -function testBuildPreviewMetaLineUsesRealDocumentFields() { - const document = { - summary: '财务知识库 · XLSX · 10.9 KB', - time: '2026-05-09 12:30' - } - - assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30']) -} - -function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() { - const document = { - previewKind: 'table', - previewPages: [ - { - subtitle: '表格内容预览', - stats: [ - { label: '工作表数量', value: '4' }, - { label: '预览行数', value: '7' }, - { label: '文件大小', value: '10.9 KB' } - ] - }, - { - subtitle: '第二页签预览', - stats: [ - { label: '工作表数量', value: '4' }, - { label: '预览行数', value: '3' } - ] - } - ] - } - - assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7']) - assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3']) -} - -function testBuildExcelPreviewTableParsesHeaderAndRows() { - const page = { - blocks: [ - { heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] }, - { heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] }, - { heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] } - ] - } - - assert.deepEqual(buildExcelPreviewTable(page), { - headers: ['日期', '部门', '金额', '备注'], - rows: [ - ['2026-05-01', '财务部', '300', '差旅'], - ['2026-05-02', '行政部', '120', ''] - ] - }) -} - -function run() { - testBuildPreviewMetaLineUsesRealDocumentFields() - testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() - testBuildExcelPreviewTableParsesHeaderAndRows() - console.log('policies preview formatter tests passed') -} - -run() +import assert from 'node:assert/strict' + +import { + buildExcelPreviewTable, + buildPreviewMetaLine, + buildPreviewSecondaryMetaLine +} from '../src/views/scripts/policiesPreviewFormatters.js' + +function testBuildPreviewMetaLineUsesRealDocumentFields() { + const document = { + summary: '财务知识库 · XLSX · 10.9 KB', + time: '2026-05-09 12:30' + } + + assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30']) +} + +function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() { + const document = { + previewKind: 'table', + previewPages: [ + { + subtitle: '表格内容预览', + stats: [ + { label: '工作表数量', value: '4' }, + { label: '预览行数', value: '7' }, + { label: '文件大小', value: '10.9 KB' } + ] + }, + { + subtitle: '第二页签预览', + stats: [ + { label: '工作表数量', value: '4' }, + { label: '预览行数', value: '3' } + ] + } + ] + } + + assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7']) + assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3']) +} + +function testBuildExcelPreviewTableParsesHeaderAndRows() { + const page = { + blocks: [ + { heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] }, + { heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] }, + { heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] } + ] + } + + assert.deepEqual(buildExcelPreviewTable(page), { + headers: ['日期', '部门', '金额', '备注'], + rows: [ + ['2026-05-01', '财务部', '300', '差旅'], + ['2026-05-02', '行政部', '120', ''] + ] + }) +} + +function run() { + testBuildPreviewMetaLineUsesRealDocumentFields() + testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() + testBuildExcelPreviewTableParsesHeaderAndRows() + console.log('policies preview formatter tests passed') +} + +run() diff --git a/web/vite.config.js b/web/vite.config.js index 5008f5d..b6e2d9a 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,108 +1,108 @@ -import { spawn } from 'node:child_process' -import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' -import fs from 'node:fs' -import net from 'node:net' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootDir = path.resolve(__dirname, '..') -const envFile = path.join(rootDir, '.env') -const envExampleFile = path.join(rootDir, '.env.example') -const adminSecretDir = path.join(rootDir, 'server', '.secrets') -const adminSecretFile = path.join(adminSecretDir, 'admin.json') -const adminScryptOptions = { N: 16384, r: 8, p: 1 } -const adminScryptKeyLength = 64 -let backendStartPromise = null -let backendStartState = createBackendStartState() - -function createBackendStartState() { - return { - running: false, - completed: false, - failed: false, - detail: '', - logTail: '', - steps: [ - { id: 'config', label: '第一步:读取初始化配置', status: 'pending', detail: '等待配置写入完成。' }, - { id: 'deps', label: '第二步:安装/检查后端虚拟环境', status: 'pending', detail: '等待执行 server/server_start.sh deps。' }, - { id: 'server', label: '第三步:启动 FastAPI 服务', status: 'pending', detail: '等待启动 uvicorn。' }, - { id: 'health', label: '第四步:检测后端健康状态', status: 'pending', detail: '等待 /api/v1/health 返回正常。' }, - { id: 'done', label: '第五步:配置完成', status: 'pending', detail: '后端就绪后进入登录页。' } - ] - } -} - -function cloneBackendStartState() { - return { - ...backendStartState, - steps: backendStartState.steps.map((step) => ({ ...step })) - } -} - -function updateBackendStep(id, status, detail = '') { - backendStartState.steps = backendStartState.steps.map((step) => { - if (step.id !== id) { - return step - } - - return { - ...step, - status, - detail: detail || step.detail - } - }) -} - -function ensureEnvFile() { - if (fs.existsSync(envFile)) { - return - } - - if (fs.existsSync(envExampleFile)) { - fs.copyFileSync(envExampleFile, envFile) - return - } - - fs.writeFileSync(envFile, '', 'utf8') -} - -function ensureAdminSecretDir() { - fs.mkdirSync(adminSecretDir, { recursive: true }) -} - +import { spawn } from 'node:child_process' +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' +import fs from 'node:fs' +import net from 'node:net' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const envFile = path.join(rootDir, '.env') +const envExampleFile = path.join(rootDir, '.env.example') +const adminSecretDir = path.join(rootDir, 'server', '.secrets') +const adminSecretFile = path.join(adminSecretDir, 'admin.json') +const adminScryptOptions = { N: 16384, r: 8, p: 1 } +const adminScryptKeyLength = 64 +let backendStartPromise = null +let backendStartState = createBackendStartState() + +function createBackendStartState() { + return { + running: false, + completed: false, + failed: false, + detail: '', + logTail: '', + steps: [ + { id: 'config', label: '第一步:读取初始化配置', status: 'pending', detail: '等待配置写入完成。' }, + { id: 'deps', label: '第二步:安装/检查后端虚拟环境', status: 'pending', detail: '等待执行 server/server_start.sh deps。' }, + { id: 'server', label: '第三步:启动 FastAPI 服务', status: 'pending', detail: '等待启动 uvicorn。' }, + { id: 'health', label: '第四步:检测后端健康状态', status: 'pending', detail: '等待 /api/v1/health 返回正常。' }, + { id: 'done', label: '第五步:配置完成', status: 'pending', detail: '后端就绪后进入登录页。' } + ] + } +} + +function cloneBackendStartState() { + return { + ...backendStartState, + steps: backendStartState.steps.map((step) => ({ ...step })) + } +} + +function updateBackendStep(id, status, detail = '') { + backendStartState.steps = backendStartState.steps.map((step) => { + if (step.id !== id) { + return step + } + + return { + ...step, + status, + detail: detail || step.detail + } + }) +} + +function ensureEnvFile() { + if (fs.existsSync(envFile)) { + return + } + + if (fs.existsSync(envExampleFile)) { + fs.copyFileSync(envExampleFile, envFile) + return + } + + fs.writeFileSync(envFile, '', 'utf8') +} + +function ensureAdminSecretDir() { + fs.mkdirSync(adminSecretDir, { recursive: true }) +} + function parseEnv(text) { const result = {} - - for (const line of text.split(/\r?\n/u)) { - const trimmed = line.trim() - - if (!trimmed || trimmed.startsWith('#')) { - continue - } - - const separatorIndex = trimmed.indexOf('=') - - if (separatorIndex === -1) { - continue - } - - const key = trimmed.slice(0, separatorIndex).trim() - let value = trimmed.slice(separatorIndex + 1).trim() - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1) - } - - result[key] = value - } - + + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim() + + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const separatorIndex = trimmed.indexOf('=') + + if (separatorIndex === -1) { + continue + } + + const key = trimmed.slice(0, separatorIndex).trim() + let value = trimmed.slice(separatorIndex + 1).trim() + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + result[key] = value + } + return result } @@ -137,478 +137,478 @@ function readEnvState() { return state } - -function readAdminSecret() { - if (!fs.existsSync(adminSecretFile)) { - return null - } - - try { - const payload = JSON.parse(fs.readFileSync(adminSecretFile, 'utf8')) - - if ( - payload && - payload.algorithm === 'scrypt' && - typeof payload.username === 'string' && - typeof payload.salt === 'string' && - typeof payload.derived_key === 'string' - ) { - return payload - } - } catch { - return null - } - - return null -} - -function hashAdminPassword(password, salt, keyLength = adminScryptKeyLength, options = adminScryptOptions) { - return scryptSync(password, Buffer.from(salt, 'hex'), keyLength, options) -} - -function persistAdminCredentials(payload) { - ensureAdminSecretDir() - - const existing = readAdminSecret() - const salt = randomBytes(16).toString('hex') - const now = new Date().toISOString() - const derivedKey = hashAdminPassword(String(payload.admin_password || ''), salt) - const record = { - version: 1, - algorithm: 'scrypt', - username: String(payload.admin_username || '').trim(), - salt, - derived_key: derivedKey.toString('hex'), - key_length: adminScryptKeyLength, - ...adminScryptOptions, - created_at: existing?.created_at || now, - updated_at: now - } - - fs.writeFileSync(adminSecretFile, `${JSON.stringify(record, null, 2)}\n`, { - encoding: 'utf8', - mode: 0o600 - }) -} - -function verifyAdminCredentials(username, password) { - const record = readAdminSecret() - - if (!record) { - throw new Error('管理员账号尚未初始化,请先完成初始化配置。') - } - - if (record.username !== String(username || '').trim()) { - return false - } - - const derivedKey = hashAdminPassword( - String(password || ''), - record.salt, - Number(record.key_length || adminScryptKeyLength), - { - N: Number(record.N || adminScryptOptions.N), - r: Number(record.r || adminScryptOptions.r), - p: Number(record.p || adminScryptOptions.p) - } - ) - const storedKey = Buffer.from(record.derived_key, 'hex') - - if (storedKey.length !== derivedKey.length) { - return false - } - - return timingSafeEqual(storedKey, derivedKey) -} - -function normalizeLoopbackHost(host) { - const normalized = String(host || '').trim().toLowerCase() - - if (normalized === 'localhost' || normalized === '::1') { - return '127.0.0.1' - } - - if (normalized === '::') { - return '0.0.0.0' - } - - return normalized -} - -function resolveClientHost(host) { - const normalizedHost = normalizeLoopbackHost(host) - - if (!normalizedHost || normalizedHost === '0.0.0.0') { - return '127.0.0.1' - } - - return String(host || '').trim() -} - -function resolveBrowserApiHost(serverHost, webHost) { - const normalizedServerHost = normalizeLoopbackHost(serverHost) - const normalizedWebHost = normalizeLoopbackHost(webHost) - - if ( - (normalizedServerHost === '0.0.0.0' || normalizedServerHost === '127.0.0.1') && - normalizedWebHost && - normalizedWebHost !== '0.0.0.0' && - normalizedWebHost !== '127.0.0.1' - ) { - return String(webHost || '').trim() - } - - if (normalizedServerHost === '0.0.0.0') { - return '127.0.0.1' - } - - return String(serverHost || '').trim() -} - -function hostsConflict(left, right) { - const normalizedLeft = normalizeLoopbackHost(left) - const normalizedRight = normalizeLoopbackHost(right) - - if (!normalizedLeft || !normalizedRight) { - return false - } - - if (normalizedLeft === normalizedRight) { - return true - } - - return normalizedLeft === '0.0.0.0' || normalizedRight === '0.0.0.0' -} - -function serializeEnvValue(value) { - const stringValue = value == null ? '' : String(value) - - if (stringValue === '') { - return '' - } - - if (/^[A-Za-z0-9_./:-]+$/u.test(stringValue)) { - return stringValue - } - - return `'${stringValue.replace(/'/gu, `'\\''`)}'` -} - -function updateEnvFile(updates) { - ensureEnvFile() - - let content = fs.readFileSync(envFile, 'utf8') - const existingLines = content ? content.split(/\r?\n/u) : [] - const remainingKeys = new Set(Object.keys(updates)) - const nextLines = existingLines.map((line) => { - const trimmed = line.trim() - - if (!trimmed || trimmed.startsWith('#')) { - return line - } - - const separatorIndex = line.indexOf('=') - - if (separatorIndex === -1) { - return line - } - - const key = line.slice(0, separatorIndex).trim() - - if (!remainingKeys.has(key)) { - return line - } - - remainingKeys.delete(key) - return `${key}=${serializeEnvValue(updates[key])}` - }) - - for (const key of remainingKeys) { - nextLines.push(`${key}=${serializeEnvValue(updates[key])}`) - } - - content = `${nextLines.join('\n').replace(/\n+$/u, '')}\n` - fs.writeFileSync(envFile, content, 'utf8') -} - -function buildDatabaseUrl(payload) { - const username = encodeURIComponent(payload.postgres_user) - const password = encodeURIComponent(payload.postgres_password) - return `postgresql+psycopg://${username}:${password}@${payload.postgres_host}:${payload.postgres_port}/${payload.postgres_db}` -} - -function buildCorsOrigins(payload) { - const webHost = String(payload.web_host || '').trim() - const webPort = String(payload.web_port || '').trim() - const origins = new Set() - const normalizedHost = normalizeLoopbackHost(webHost) - - if (normalizedHost === '0.0.0.0') { - origins.add(`http://127.0.0.1:${webPort}`) - origins.add(`http://localhost:${webPort}`) - origins.add(`http://0.0.0.0:${webPort}`) - } else { - origins.add(`http://${webHost}:${webPort}`) - - if (normalizedHost === '127.0.0.1') { - origins.add(`http://127.0.0.1:${webPort}`) - origins.add(`http://localhost:${webPort}`) - } - } - - return JSON.stringify([...origins]) -} - -function buildApiBaseUrl(payload, currentEnv) { - const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1' - return apiPrefix -} - -function buildServerHealthUrl(env) { - const apiPrefix = env.API_V1_PREFIX || '/api/v1' - const host = resolveClientHost(env.SERVER_HOST || '127.0.0.1') - const port = String(env.SERVER_PORT || 8000).trim() - return `http://${host}:${port}${apiPrefix}/health` -} - -function buildBrowserReachableServerHealthUrl(env) { - const apiPrefix = env.API_V1_PREFIX || '/api/v1' - const serverHost = String(env.SERVER_HOST || '127.0.0.1').trim() - const webHost = String(env.WEB_HOST || '').trim() - const host = resolveBrowserApiHost(serverHost, webHost) - const port = String(env.SERVER_PORT || 8000).trim() - return `http://${host}:${port}${apiPrefix}/health` -} - -function buildClientEnvUpdates(payload, apiBaseUrl) { - return { - VITE_SETUP_COMPLETED: 'true', - VITE_COMPANY_NAME: String(payload.company_name || '').trim(), - VITE_COMPANY_CODE: String(payload.company_code || '').trim(), - VITE_ADMIN_EMAIL: String(payload.admin_email || '').trim(), - VITE_WEB_HOST: String(payload.web_host || '').trim(), - VITE_WEB_PORT: String(payload.web_port || '').trim(), - VITE_SERVER_HOST: String(payload.server_host || '').trim(), - VITE_SERVER_PORT: String(payload.server_port || '').trim(), - VITE_POSTGRES_HOST: String(payload.postgres_host || '').trim(), - VITE_POSTGRES_PORT: String(payload.postgres_port || '').trim(), - VITE_POSTGRES_DB: String(payload.postgres_db || '').trim(), - VITE_POSTGRES_USER: String(payload.postgres_user || '').trim(), - VITE_REDIS_URL: String(payload.redis_url || '').trim(), - VITE_API_BASE_URL: apiBaseUrl - } -} - -function normalizeState(env) { - const adminConfigured = Boolean(readAdminSecret()) - - return { - initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured, - company: { - name: env.COMPANY_NAME || '', - code: env.COMPANY_CODE || '', - admin_email: env.ADMIN_EMAIL || '' - }, - admin: { - configured: adminConfigured - }, - web: { - host: env.WEB_HOST || '0.0.0.0', - port: Number(env.WEB_PORT || 5173) - }, - server: { - host: env.SERVER_HOST || '0.0.0.0', - port: Number(env.SERVER_PORT || 8000) - }, - database: { - driver: 'postgresql', - host: env.POSTGRES_HOST || '127.0.0.1', - port: Number(env.POSTGRES_PORT || 5432), - name: env.POSTGRES_DB || 'x_financial', - username: env.POSTGRES_USER || 'postgres', - password_configured: Boolean(env.POSTGRES_PASSWORD) - }, - redis: { - enabled: Boolean(env.REDIS_URL), - url: env.REDIS_URL || '' - } - } -} - -async function readJsonBody(req) { - const chunks = [] - - for await (const chunk of req) { - chunks.push(chunk) - } - - const raw = Buffer.concat(chunks).toString('utf8') - return raw ? JSON.parse(raw) : {} -} - -function sendJson(res, statusCode, payload) { - res.statusCode = statusCode - res.setHeader('Content-Type', 'application/json; charset=utf-8') - res.end(JSON.stringify(payload)) -} - -function validateRuntimePayload(payload) { - const fields = [ - ['server_host', 'Server Host'] - ] - - for (const [field, label] of fields) { - if (!String(payload[field] ?? '').trim()) { - return `请填写 ${label}。` - } - } - - const portFields = [ - ['server_port', 'Server Port'] - ] - - for (const [field, label] of portFields) { - const value = Number(payload[field]) - - if (!Number.isInteger(value) || value < 1 || value > 65535) { - return `${label} 必须在 1 到 65535 之间。` - } - } - - return '' -} - -function resolveRuntimePayload(payload, currentEnv) { - const webHost = String(payload.web_host || currentEnv.WEB_HOST || '0.0.0.0').trim() - const serverHost = String(payload.server_host || currentEnv.SERVER_HOST || '0.0.0.0').trim() - const normalizedWebHost = normalizeLoopbackHost(webHost) - const normalizedServerHost = normalizeLoopbackHost(serverHost) - - return { - ...payload, - web_host: webHost, - web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5173), - server_host: - normalizedWebHost && - normalizedWebHost !== '127.0.0.1' && - normalizedWebHost !== '0.0.0.0' && - normalizedServerHost === '127.0.0.1' - ? '0.0.0.0' - : serverHost - } -} - -function validateDatabasePayload(payload) { - const fields = [ - ['postgres_host', 'PostgreSQL Host'], - ['postgres_db', '数据库名称'], - ['postgres_user', '数据库用户'] - ] - - for (const [field, label] of fields) { - if (!String(payload[field] ?? '').trim()) { - return `请填写 ${label}。` - } - } - - const port = Number(payload.postgres_port) - - if (!Number.isInteger(port) || port < 1 || port > 65535) { - return 'PostgreSQL Port 必须在 1 到 65535 之间。' - } - - if (!String(payload.postgres_password || '').length) { - return '请填写数据库密码。' - } - - return '' -} - -function validateIdentityPayload(payload) { - const companyName = String(payload.company_name || '').trim() - const adminEmail = String(payload.admin_email || '').trim() - const adminUsername = String(payload.admin_username || '').trim() - const adminPassword = String(payload.admin_password || '') - const adminPasswordConfirm = String(payload.admin_password_confirm || '') - - if (companyName.length < 2) { - return '企业名称至少 2 个字符。' - } - - if (!adminEmail) { - return '请填写管理员邮箱。' - } - - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(adminEmail)) { - return '管理员邮箱格式不正确。' - } - - if (adminUsername.length < 4) { - return '管理员账号至少 4 位。' - } - - if (!/^[A-Za-z0-9._@-]+$/u.test(adminUsername)) { - return '管理员账号仅允许字母、数字、点、下划线、中划线和 @。' - } - - if (adminPassword.length < 5) { - return '管理员密码当前至少 5 位。' - } - - if (adminPassword !== adminPasswordConfirm) { - return '两次输入的管理员密码不一致。' - } - - return '' -} - -function validateSetupPayload(payload) { - return validateIdentityPayload(payload) || validateRuntimePayload(payload) || validateDatabasePayload(payload) -} - -async function assertPortAvailable(host, port) { - await new Promise((resolve, reject) => { - const tester = net.createServer() - - tester.once('error', (error) => { - tester.close() - reject(error) - }) - - tester.once('listening', () => { - tester.close(() => resolve()) - }) - - tester.listen(port, host) - }) -} - -async function testRuntimePorts(payload) { - const webPort = Number(payload.web_port) - const serverPort = Number(payload.server_port) - const webHost = String(payload.web_host || '').trim() - const serverHost = String(payload.server_host || '').trim() - - if (webPort === serverPort && hostsConflict(webHost, serverHost)) { - throw new Error('Web 与 Server 不能使用同一个主机与端口组合。') - } - - try { - await assertPortAvailable(serverHost, serverPort) - } catch { - throw new Error(`Server 端口 ${serverHost}:${serverPort} 已被占用。`) - } -} - -async function loadPgClient() { - try { - const module = await import('pg') - return module.Client - } catch { - throw new Error('缺少 Node 侧 PostgreSQL 驱动 pg(web/node_modules/pg)。请先执行 bash start.sh,或进入 web 目录执行 npm install。') - } -} - + +function readAdminSecret() { + if (!fs.existsSync(adminSecretFile)) { + return null + } + + try { + const payload = JSON.parse(fs.readFileSync(adminSecretFile, 'utf8')) + + if ( + payload && + payload.algorithm === 'scrypt' && + typeof payload.username === 'string' && + typeof payload.salt === 'string' && + typeof payload.derived_key === 'string' + ) { + return payload + } + } catch { + return null + } + + return null +} + +function hashAdminPassword(password, salt, keyLength = adminScryptKeyLength, options = adminScryptOptions) { + return scryptSync(password, Buffer.from(salt, 'hex'), keyLength, options) +} + +function persistAdminCredentials(payload) { + ensureAdminSecretDir() + + const existing = readAdminSecret() + const salt = randomBytes(16).toString('hex') + const now = new Date().toISOString() + const derivedKey = hashAdminPassword(String(payload.admin_password || ''), salt) + const record = { + version: 1, + algorithm: 'scrypt', + username: String(payload.admin_username || '').trim(), + salt, + derived_key: derivedKey.toString('hex'), + key_length: adminScryptKeyLength, + ...adminScryptOptions, + created_at: existing?.created_at || now, + updated_at: now + } + + fs.writeFileSync(adminSecretFile, `${JSON.stringify(record, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600 + }) +} + +function verifyAdminCredentials(username, password) { + const record = readAdminSecret() + + if (!record) { + throw new Error('管理员账号尚未初始化,请先完成初始化配置。') + } + + if (record.username !== String(username || '').trim()) { + return false + } + + const derivedKey = hashAdminPassword( + String(password || ''), + record.salt, + Number(record.key_length || adminScryptKeyLength), + { + N: Number(record.N || adminScryptOptions.N), + r: Number(record.r || adminScryptOptions.r), + p: Number(record.p || adminScryptOptions.p) + } + ) + const storedKey = Buffer.from(record.derived_key, 'hex') + + if (storedKey.length !== derivedKey.length) { + return false + } + + return timingSafeEqual(storedKey, derivedKey) +} + +function normalizeLoopbackHost(host) { + const normalized = String(host || '').trim().toLowerCase() + + if (normalized === 'localhost' || normalized === '::1') { + return '127.0.0.1' + } + + if (normalized === '::') { + return '0.0.0.0' + } + + return normalized +} + +function resolveClientHost(host) { + const normalizedHost = normalizeLoopbackHost(host) + + if (!normalizedHost || normalizedHost === '0.0.0.0') { + return '127.0.0.1' + } + + return String(host || '').trim() +} + +function resolveBrowserApiHost(serverHost, webHost) { + const normalizedServerHost = normalizeLoopbackHost(serverHost) + const normalizedWebHost = normalizeLoopbackHost(webHost) + + if ( + (normalizedServerHost === '0.0.0.0' || normalizedServerHost === '127.0.0.1') && + normalizedWebHost && + normalizedWebHost !== '0.0.0.0' && + normalizedWebHost !== '127.0.0.1' + ) { + return String(webHost || '').trim() + } + + if (normalizedServerHost === '0.0.0.0') { + return '127.0.0.1' + } + + return String(serverHost || '').trim() +} + +function hostsConflict(left, right) { + const normalizedLeft = normalizeLoopbackHost(left) + const normalizedRight = normalizeLoopbackHost(right) + + if (!normalizedLeft || !normalizedRight) { + return false + } + + if (normalizedLeft === normalizedRight) { + return true + } + + return normalizedLeft === '0.0.0.0' || normalizedRight === '0.0.0.0' +} + +function serializeEnvValue(value) { + const stringValue = value == null ? '' : String(value) + + if (stringValue === '') { + return '' + } + + if (/^[A-Za-z0-9_./:-]+$/u.test(stringValue)) { + return stringValue + } + + return `'${stringValue.replace(/'/gu, `'\\''`)}'` +} + +function updateEnvFile(updates) { + ensureEnvFile() + + let content = fs.readFileSync(envFile, 'utf8') + const existingLines = content ? content.split(/\r?\n/u) : [] + const remainingKeys = new Set(Object.keys(updates)) + const nextLines = existingLines.map((line) => { + const trimmed = line.trim() + + if (!trimmed || trimmed.startsWith('#')) { + return line + } + + const separatorIndex = line.indexOf('=') + + if (separatorIndex === -1) { + return line + } + + const key = line.slice(0, separatorIndex).trim() + + if (!remainingKeys.has(key)) { + return line + } + + remainingKeys.delete(key) + return `${key}=${serializeEnvValue(updates[key])}` + }) + + for (const key of remainingKeys) { + nextLines.push(`${key}=${serializeEnvValue(updates[key])}`) + } + + content = `${nextLines.join('\n').replace(/\n+$/u, '')}\n` + fs.writeFileSync(envFile, content, 'utf8') +} + +function buildDatabaseUrl(payload) { + const username = encodeURIComponent(payload.postgres_user) + const password = encodeURIComponent(payload.postgres_password) + return `postgresql+psycopg://${username}:${password}@${payload.postgres_host}:${payload.postgres_port}/${payload.postgres_db}` +} + +function buildCorsOrigins(payload) { + const webHost = String(payload.web_host || '').trim() + const webPort = String(payload.web_port || '').trim() + const origins = new Set() + const normalizedHost = normalizeLoopbackHost(webHost) + + if (normalizedHost === '0.0.0.0') { + origins.add(`http://127.0.0.1:${webPort}`) + origins.add(`http://localhost:${webPort}`) + origins.add(`http://0.0.0.0:${webPort}`) + } else { + origins.add(`http://${webHost}:${webPort}`) + + if (normalizedHost === '127.0.0.1') { + origins.add(`http://127.0.0.1:${webPort}`) + origins.add(`http://localhost:${webPort}`) + } + } + + return JSON.stringify([...origins]) +} + +function buildApiBaseUrl(payload, currentEnv) { + const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1' + return apiPrefix +} + +function buildServerHealthUrl(env) { + const apiPrefix = env.API_V1_PREFIX || '/api/v1' + const host = resolveClientHost(env.SERVER_HOST || '127.0.0.1') + const port = String(env.SERVER_PORT || 8000).trim() + return `http://${host}:${port}${apiPrefix}/health` +} + +function buildBrowserReachableServerHealthUrl(env) { + const apiPrefix = env.API_V1_PREFIX || '/api/v1' + const serverHost = String(env.SERVER_HOST || '127.0.0.1').trim() + const webHost = String(env.WEB_HOST || '').trim() + const host = resolveBrowserApiHost(serverHost, webHost) + const port = String(env.SERVER_PORT || 8000).trim() + return `http://${host}:${port}${apiPrefix}/health` +} + +function buildClientEnvUpdates(payload, apiBaseUrl) { + return { + VITE_SETUP_COMPLETED: 'true', + VITE_COMPANY_NAME: String(payload.company_name || '').trim(), + VITE_COMPANY_CODE: String(payload.company_code || '').trim(), + VITE_ADMIN_EMAIL: String(payload.admin_email || '').trim(), + VITE_WEB_HOST: String(payload.web_host || '').trim(), + VITE_WEB_PORT: String(payload.web_port || '').trim(), + VITE_SERVER_HOST: String(payload.server_host || '').trim(), + VITE_SERVER_PORT: String(payload.server_port || '').trim(), + VITE_POSTGRES_HOST: String(payload.postgres_host || '').trim(), + VITE_POSTGRES_PORT: String(payload.postgres_port || '').trim(), + VITE_POSTGRES_DB: String(payload.postgres_db || '').trim(), + VITE_POSTGRES_USER: String(payload.postgres_user || '').trim(), + VITE_REDIS_URL: String(payload.redis_url || '').trim(), + VITE_API_BASE_URL: apiBaseUrl + } +} + +function normalizeState(env) { + const adminConfigured = Boolean(readAdminSecret()) + + return { + initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured, + company: { + name: env.COMPANY_NAME || '', + code: env.COMPANY_CODE || '', + admin_email: env.ADMIN_EMAIL || '' + }, + admin: { + configured: adminConfigured + }, + web: { + host: env.WEB_HOST || '0.0.0.0', + port: Number(env.WEB_PORT || 5173) + }, + server: { + host: env.SERVER_HOST || '0.0.0.0', + port: Number(env.SERVER_PORT || 8000) + }, + database: { + driver: 'postgresql', + host: env.POSTGRES_HOST || '127.0.0.1', + port: Number(env.POSTGRES_PORT || 5432), + name: env.POSTGRES_DB || 'x_financial', + username: env.POSTGRES_USER || 'postgres', + password_configured: Boolean(env.POSTGRES_PASSWORD) + }, + redis: { + enabled: Boolean(env.REDIS_URL), + url: env.REDIS_URL || '' + } + } +} + +async function readJsonBody(req) { + const chunks = [] + + for await (const chunk of req) { + chunks.push(chunk) + } + + const raw = Buffer.concat(chunks).toString('utf8') + return raw ? JSON.parse(raw) : {} +} + +function sendJson(res, statusCode, payload) { + res.statusCode = statusCode + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify(payload)) +} + +function validateRuntimePayload(payload) { + const fields = [ + ['server_host', 'Server Host'] + ] + + for (const [field, label] of fields) { + if (!String(payload[field] ?? '').trim()) { + return `请填写 ${label}。` + } + } + + const portFields = [ + ['server_port', 'Server Port'] + ] + + for (const [field, label] of portFields) { + const value = Number(payload[field]) + + if (!Number.isInteger(value) || value < 1 || value > 65535) { + return `${label} 必须在 1 到 65535 之间。` + } + } + + return '' +} + +function resolveRuntimePayload(payload, currentEnv) { + const webHost = String(payload.web_host || currentEnv.WEB_HOST || '0.0.0.0').trim() + const serverHost = String(payload.server_host || currentEnv.SERVER_HOST || '0.0.0.0').trim() + const normalizedWebHost = normalizeLoopbackHost(webHost) + const normalizedServerHost = normalizeLoopbackHost(serverHost) + + return { + ...payload, + web_host: webHost, + web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5173), + server_host: + normalizedWebHost && + normalizedWebHost !== '127.0.0.1' && + normalizedWebHost !== '0.0.0.0' && + normalizedServerHost === '127.0.0.1' + ? '0.0.0.0' + : serverHost + } +} + +function validateDatabasePayload(payload) { + const fields = [ + ['postgres_host', 'PostgreSQL Host'], + ['postgres_db', '数据库名称'], + ['postgres_user', '数据库用户'] + ] + + for (const [field, label] of fields) { + if (!String(payload[field] ?? '').trim()) { + return `请填写 ${label}。` + } + } + + const port = Number(payload.postgres_port) + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return 'PostgreSQL Port 必须在 1 到 65535 之间。' + } + + if (!String(payload.postgres_password || '').length) { + return '请填写数据库密码。' + } + + return '' +} + +function validateIdentityPayload(payload) { + const companyName = String(payload.company_name || '').trim() + const adminEmail = String(payload.admin_email || '').trim() + const adminUsername = String(payload.admin_username || '').trim() + const adminPassword = String(payload.admin_password || '') + const adminPasswordConfirm = String(payload.admin_password_confirm || '') + + if (companyName.length < 2) { + return '企业名称至少 2 个字符。' + } + + if (!adminEmail) { + return '请填写管理员邮箱。' + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(adminEmail)) { + return '管理员邮箱格式不正确。' + } + + if (adminUsername.length < 4) { + return '管理员账号至少 4 位。' + } + + if (!/^[A-Za-z0-9._@-]+$/u.test(adminUsername)) { + return '管理员账号仅允许字母、数字、点、下划线、中划线和 @。' + } + + if (adminPassword.length < 5) { + return '管理员密码当前至少 5 位。' + } + + if (adminPassword !== adminPasswordConfirm) { + return '两次输入的管理员密码不一致。' + } + + return '' +} + +function validateSetupPayload(payload) { + return validateIdentityPayload(payload) || validateRuntimePayload(payload) || validateDatabasePayload(payload) +} + +async function assertPortAvailable(host, port) { + await new Promise((resolve, reject) => { + const tester = net.createServer() + + tester.once('error', (error) => { + tester.close() + reject(error) + }) + + tester.once('listening', () => { + tester.close(() => resolve()) + }) + + tester.listen(port, host) + }) +} + +async function testRuntimePorts(payload) { + const webPort = Number(payload.web_port) + const serverPort = Number(payload.server_port) + const webHost = String(payload.web_host || '').trim() + const serverHost = String(payload.server_host || '').trim() + + if (webPort === serverPort && hostsConflict(webHost, serverHost)) { + throw new Error('Web 与 Server 不能使用同一个主机与端口组合。') + } + + try { + await assertPortAvailable(serverHost, serverPort) + } catch { + throw new Error(`Server 端口 ${serverHost}:${serverPort} 已被占用。`) + } +} + +async function loadPgClient() { + try { + const module = await import('pg') + return module.Client + } catch { + throw new Error('缺少 Node 侧 PostgreSQL 驱动 pg(web/node_modules/pg)。请先执行 bash start.sh,或进入 web 目录执行 npm install。') + } +} + async function testDatabaseConnection(payload) { const Client = await loadPgClient() const requestedHost = String(payload.postgres_host || '').trim() @@ -629,413 +629,413 @@ async function testDatabaseConnection(payload) { database: String(payload.postgres_db || '').trim(), user: String(payload.postgres_user || '').trim(), password: String(payload.postgres_password || ''), - connectionTimeoutMillis: 5000 - }) - - try { - await client.connect() - await client.query('SELECT 1') - } finally { - await client.end().catch(() => {}) - } -} - -async function probeBackendHealth(env) { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 2000) - - try { - const response = await fetch(buildServerHealthUrl(env), { - signal: controller.signal - }) - - if (!response.ok) { - return false - } - - const payload = await response.json().catch(() => null) - return payload?.status === 'ok' - } catch { - return false - } finally { - clearTimeout(timeout) - } -} - -async function probeBrowserReachableBackendHealth(env) { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 2000) - - try { - const response = await fetch(buildBrowserReachableServerHealthUrl(env), { - signal: controller.signal - }) - - if (!response.ok) { - return false - } - - const payload = await response.json().catch(() => null) - return payload?.status === 'ok' - } catch { - return false - } finally { - clearTimeout(timeout) - } -} - -async function waitForBackendReady(env) { - const timeoutSeconds = Number(env.SERVER_STARTUP_TIMEOUT || 300) - const maxAttempts = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 300 - let localOnlyAttempts = 0 - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const localReady = await probeBackendHealth(env) - const browserReady = await probeBrowserReachableBackendHealth(env) - - if (localReady && browserReady) { - return { - ok: true, - detail: 'FastAPI 后端已启动。' - } - } - - if (localReady && !browserReady) { - localOnlyAttempts += 1 - - if (localOnlyAttempts >= 5) { - throw new Error( - 'FastAPI 仅在本机地址可用,浏览器访问地址不可达。通常是旧后端仍以 127.0.0.1 启动并占用端口,请停止旧后端后重新完成初始化。' - ) - } - } else { - localOnlyAttempts = 0 - } - - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - - throw new Error(`FastAPI 未在 ${maxAttempts}s 内完成启动,请查看 server/logs/bootstrap-backend.log。`) -} - -function readBackendLogTail(logFile) { - if (!fs.existsSync(logFile)) { - return '' - } - - const content = fs.readFileSync(logFile, 'utf8') - const lines = content.trimEnd().split(/\r?\n/u) - return lines.slice(-30).join('\n') -} - -function completeBackendStartup(detail) { - backendStartState.running = false - backendStartState.completed = true - backendStartState.failed = false - backendStartState.detail = detail - updateBackendStep('config', 'success', '初始化配置已写入。') - updateBackendStep('deps', 'success', '后端依赖和虚拟环境检查完成。') - updateBackendStep('server', 'success', 'FastAPI 进程已启动。') - updateBackendStep('health', 'success', '健康检查通过。') - updateBackendStep('done', 'success', '配置成功,准备进入登录页。') -} - -function failBackendStartup(error, logFile) { - backendStartState.running = false - backendStartState.completed = false - backendStartState.failed = true - backendStartState.detail = error instanceof Error ? error.message : 'FastAPI 后端启动失败。' - backendStartState.logTail = readBackendLogTail(logFile) - updateBackendStep('done', 'error', backendStartState.detail) -} - -async function startBackendAndWait() { - const env = readEnvState() - const logDir = path.join(rootDir, 'server', 'logs') - const logFile = path.join(logDir, 'bootstrap-backend.log') - - if ((await probeBackendHealth(env)) && (await probeBrowserReachableBackendHealth(env))) { - backendStartState = createBackendStartState() - completeBackendStartup('FastAPI 后端已就绪。') - backendStartState.logTail = readBackendLogTail(logFile) - return cloneBackendStartState() - } - - if (!backendStartPromise) { - backendStartState = createBackendStartState() - backendStartState.running = true - backendStartState.detail = '正在启动 FastAPI 后端。' - updateBackendStep('config', 'success', '初始化配置已写入。') - updateBackendStep('deps', 'running', '正在创建/检查虚拟环境并安装依赖。') - updateBackendStep('server', 'pending', '等待依赖检查完成后启动。') - updateBackendStep('health', 'pending', '等待 FastAPI 响应。') - - backendStartPromise = (async () => { - fs.mkdirSync(logDir, { recursive: true }) - - 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] - }) - - child.unref() - updateBackendStep('server', 'running', '后端启动命令已提交,等待 uvicorn 监听端口。') - updateBackendStep('health', 'running', '正在轮询 /api/v1/health。') - - try { - await waitForBackendReady(env) - completeBackendStartup('FastAPI 后端已启动。') - } catch (error) { - failBackendStartup(error, logFile) - } finally { - backendStartState.logTail = readBackendLogTail(logFile) - } - - return cloneBackendStartState() - })().finally(() => { - backendStartPromise = null - }) - } - - backendStartState.logTail = readBackendLogTail(logFile) - return cloneBackendStartState() -} - -function localSetupPlugin() { - return { - name: 'local-setup-api', - configureServer(server) { - server.watcher.unwatch(envFile) - server.watcher.unwatch(envExampleFile) - server.watcher.unwatch(path.join(rootDir, 'server', 'logs')) - - server.middlewares.use('/__setup/auth/login', async (req, res) => { - try { - if (req.method !== 'POST') { - sendJson(res, 405, { detail: 'Method not allowed' }) - return - } - - const payload = await readJsonBody(req) - const username = String(payload.username || '').trim() - const password = String(payload.password || '') - - if (!username || !password) { - sendJson(res, 400, { detail: '请输入管理员账号和密码。' }) - return - } - - const passed = verifyAdminCredentials(username, password) - - if (!passed) { - sendJson(res, 401, { detail: '管理员账号或密码错误。' }) - return - } - - sendJson(res, 200, { - ok: true, - detail: '登录成功。', - user: { - username - } - }) - } catch (error) { - sendJson(res, 500, { - detail: error instanceof Error ? error.message : '管理员登录校验失败。' - }) - } - }) - - server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => { - try { - if (req.method !== 'PUT') { - sendJson(res, 405, { detail: 'Method not allowed' }) - return - } - - const payload = resolveRuntimePayload(await readJsonBody(req), readEnvState()) - const validationError = validateRuntimePayload(payload) - - if (validationError) { - sendJson(res, 400, { detail: validationError }) - return - } - - try { - await testRuntimePorts(payload) - sendJson(res, 200, { ok: true, detail: 'Server 端口占用检测通过。' }) - } catch (error) { - sendJson(res, 400, { - ok: false, - detail: error instanceof Error ? error.message : '端口占用检测失败。' - }) - } - } catch (error) { - sendJson(res, 500, { - detail: error instanceof Error ? error.message : '运行端口检测服务异常。' - }) - } - }) - - server.middlewares.use('/__setup/bootstrap/database', async (req, res) => { - try { - if (req.method !== 'PUT') { - sendJson(res, 405, { detail: 'Method not allowed' }) - return - } - - const payload = await readJsonBody(req) - const validationError = validateDatabasePayload(payload) - - if (validationError) { - sendJson(res, 400, { detail: validationError }) - return - } - - try { - await testDatabaseConnection(payload) - sendJson(res, 200, { ok: true, detail: '数据库连接检测通过。' }) - } catch (error) { - sendJson(res, 400, { - ok: false, - detail: error instanceof Error ? error.message : '数据库连接检测失败。' - }) - } - } catch (error) { - sendJson(res, 500, { - detail: error instanceof Error ? error.message : '数据库检测服务异常。' - }) - } - }) - - server.middlewares.use('/__setup/bootstrap/backend', async (req, res) => { - try { - if (req.method === 'GET') { - const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log') - backendStartState.logTail = readBackendLogTail(logFile) - sendJson(res, 200, cloneBackendStartState()) - return - } - - if (req.method !== 'POST') { - sendJson(res, 405, { detail: 'Method not allowed' }) - return - } - - try { - const result = await startBackendAndWait() - sendJson(res, 200, result) - } catch (error) { - sendJson(res, 500, { - ok: false, - detail: error instanceof Error ? error.message : 'FastAPI 后端启动失败。' - }) - } - } catch (error) { - sendJson(res, 500, { - detail: error instanceof Error ? error.message : '后端启动桥接服务异常。' - }) - } - }) - - server.middlewares.use('/__setup/bootstrap', async (req, res) => { - try { - if (req.method === 'GET') { - sendJson(res, 200, normalizeState(readEnvState())) - return - } - - if (req.method !== 'POST') { - sendJson(res, 405, { detail: 'Method not allowed' }) - return - } - - const currentEnv = readEnvState() - const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv) - const validationError = validateSetupPayload(payload) - - if (validationError) { - sendJson(res, 400, { detail: validationError }) - return - } - - try { - await testRuntimePorts(payload) - await testDatabaseConnection(payload) - } catch (error) { - sendJson(res, 400, { - detail: error instanceof Error ? error.message : '初始化校验失败。' - }) - return - } - - persistAdminCredentials(payload) - - const apiBaseUrl = buildApiBaseUrl(payload, currentEnv) - - updateEnvFile({ - SETUP_COMPLETED: 'true', - COMPANY_NAME: String(payload.company_name || '').trim(), - COMPANY_CODE: String(payload.company_code || '').trim(), - ADMIN_EMAIL: String(payload.admin_email || '').trim(), - WEB_HOST: String(payload.web_host || '').trim(), - WEB_PORT: String(payload.web_port || '').trim(), - SERVER_HOST: String(payload.server_host || '').trim(), - SERVER_PORT: String(payload.server_port || '').trim(), - POSTGRES_HOST: String(payload.postgres_host || '').trim(), - POSTGRES_PORT: String(payload.postgres_port || '').trim(), - POSTGRES_DB: String(payload.postgres_db || '').trim(), - POSTGRES_USER: String(payload.postgres_user || '').trim(), - POSTGRES_PASSWORD: String(payload.postgres_password || ''), - DATABASE_URL: buildDatabaseUrl(payload), - REDIS_URL: String(payload.redis_url || '').trim(), - CORS_ORIGINS: buildCorsOrigins(payload), - VITE_API_BASE_URL: apiBaseUrl, - ...buildClientEnvUpdates(payload, apiBaseUrl) - }) - - sendJson(res, 201, normalizeState(readEnvState())) - } catch (error) { - sendJson(res, 500, { - detail: error instanceof Error ? error.message : '初始化写入失败。' - }) - } - }) - } - } -} - -export default defineConfig({ - envDir: '..', - server: { - watch: { - ignored: [ - envFile, - envExampleFile, - path.join(rootDir, 'server', 'logs', '**') - ] - }, - proxy: { - '/api': { - target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`, - changeOrigin: true - } - } - }, - plugins: [vue(), localSetupPlugin()] -}) + connectionTimeoutMillis: 5000 + }) + + try { + await client.connect() + await client.query('SELECT 1') + } finally { + await client.end().catch(() => {}) + } +} + +async function probeBackendHealth(env) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + try { + const response = await fetch(buildServerHealthUrl(env), { + signal: controller.signal + }) + + if (!response.ok) { + return false + } + + const payload = await response.json().catch(() => null) + return payload?.status === 'ok' + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +async function probeBrowserReachableBackendHealth(env) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + try { + const response = await fetch(buildBrowserReachableServerHealthUrl(env), { + signal: controller.signal + }) + + if (!response.ok) { + return false + } + + const payload = await response.json().catch(() => null) + return payload?.status === 'ok' + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +async function waitForBackendReady(env) { + const timeoutSeconds = Number(env.SERVER_STARTUP_TIMEOUT || 300) + const maxAttempts = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 300 + let localOnlyAttempts = 0 + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const localReady = await probeBackendHealth(env) + const browserReady = await probeBrowserReachableBackendHealth(env) + + if (localReady && browserReady) { + return { + ok: true, + detail: 'FastAPI 后端已启动。' + } + } + + if (localReady && !browserReady) { + localOnlyAttempts += 1 + + if (localOnlyAttempts >= 5) { + throw new Error( + 'FastAPI 仅在本机地址可用,浏览器访问地址不可达。通常是旧后端仍以 127.0.0.1 启动并占用端口,请停止旧后端后重新完成初始化。' + ) + } + } else { + localOnlyAttempts = 0 + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + throw new Error(`FastAPI 未在 ${maxAttempts}s 内完成启动,请查看 server/logs/bootstrap-backend.log。`) +} + +function readBackendLogTail(logFile) { + if (!fs.existsSync(logFile)) { + return '' + } + + const content = fs.readFileSync(logFile, 'utf8') + const lines = content.trimEnd().split(/\r?\n/u) + return lines.slice(-30).join('\n') +} + +function completeBackendStartup(detail) { + backendStartState.running = false + backendStartState.completed = true + backendStartState.failed = false + backendStartState.detail = detail + updateBackendStep('config', 'success', '初始化配置已写入。') + updateBackendStep('deps', 'success', '后端依赖和虚拟环境检查完成。') + updateBackendStep('server', 'success', 'FastAPI 进程已启动。') + updateBackendStep('health', 'success', '健康检查通过。') + updateBackendStep('done', 'success', '配置成功,准备进入登录页。') +} + +function failBackendStartup(error, logFile) { + backendStartState.running = false + backendStartState.completed = false + backendStartState.failed = true + backendStartState.detail = error instanceof Error ? error.message : 'FastAPI 后端启动失败。' + backendStartState.logTail = readBackendLogTail(logFile) + updateBackendStep('done', 'error', backendStartState.detail) +} + +async function startBackendAndWait() { + const env = readEnvState() + const logDir = path.join(rootDir, 'server', 'logs') + const logFile = path.join(logDir, 'bootstrap-backend.log') + + if ((await probeBackendHealth(env)) && (await probeBrowserReachableBackendHealth(env))) { + backendStartState = createBackendStartState() + completeBackendStartup('FastAPI 后端已就绪。') + backendStartState.logTail = readBackendLogTail(logFile) + return cloneBackendStartState() + } + + if (!backendStartPromise) { + backendStartState = createBackendStartState() + backendStartState.running = true + backendStartState.detail = '正在启动 FastAPI 后端。' + updateBackendStep('config', 'success', '初始化配置已写入。') + updateBackendStep('deps', 'running', '正在创建/检查虚拟环境并安装依赖。') + updateBackendStep('server', 'pending', '等待依赖检查完成后启动。') + updateBackendStep('health', 'pending', '等待 FastAPI 响应。') + + backendStartPromise = (async () => { + fs.mkdirSync(logDir, { recursive: true }) + + 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] + }) + + child.unref() + updateBackendStep('server', 'running', '后端启动命令已提交,等待 uvicorn 监听端口。') + updateBackendStep('health', 'running', '正在轮询 /api/v1/health。') + + try { + await waitForBackendReady(env) + completeBackendStartup('FastAPI 后端已启动。') + } catch (error) { + failBackendStartup(error, logFile) + } finally { + backendStartState.logTail = readBackendLogTail(logFile) + } + + return cloneBackendStartState() + })().finally(() => { + backendStartPromise = null + }) + } + + backendStartState.logTail = readBackendLogTail(logFile) + return cloneBackendStartState() +} + +function localSetupPlugin() { + return { + name: 'local-setup-api', + configureServer(server) { + server.watcher.unwatch(envFile) + server.watcher.unwatch(envExampleFile) + server.watcher.unwatch(path.join(rootDir, 'server', 'logs')) + + server.middlewares.use('/__setup/auth/login', async (req, res) => { + try { + if (req.method !== 'POST') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + const payload = await readJsonBody(req) + const username = String(payload.username || '').trim() + const password = String(payload.password || '') + + if (!username || !password) { + sendJson(res, 400, { detail: '请输入管理员账号和密码。' }) + return + } + + const passed = verifyAdminCredentials(username, password) + + if (!passed) { + sendJson(res, 401, { detail: '管理员账号或密码错误。' }) + return + } + + sendJson(res, 200, { + ok: true, + detail: '登录成功。', + user: { + username + } + }) + } catch (error) { + sendJson(res, 500, { + detail: error instanceof Error ? error.message : '管理员登录校验失败。' + }) + } + }) + + server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => { + try { + if (req.method !== 'PUT') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + const payload = resolveRuntimePayload(await readJsonBody(req), readEnvState()) + const validationError = validateRuntimePayload(payload) + + if (validationError) { + sendJson(res, 400, { detail: validationError }) + return + } + + try { + await testRuntimePorts(payload) + sendJson(res, 200, { ok: true, detail: 'Server 端口占用检测通过。' }) + } catch (error) { + sendJson(res, 400, { + ok: false, + detail: error instanceof Error ? error.message : '端口占用检测失败。' + }) + } + } catch (error) { + sendJson(res, 500, { + detail: error instanceof Error ? error.message : '运行端口检测服务异常。' + }) + } + }) + + server.middlewares.use('/__setup/bootstrap/database', async (req, res) => { + try { + if (req.method !== 'PUT') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + const payload = await readJsonBody(req) + const validationError = validateDatabasePayload(payload) + + if (validationError) { + sendJson(res, 400, { detail: validationError }) + return + } + + try { + await testDatabaseConnection(payload) + sendJson(res, 200, { ok: true, detail: '数据库连接检测通过。' }) + } catch (error) { + sendJson(res, 400, { + ok: false, + detail: error instanceof Error ? error.message : '数据库连接检测失败。' + }) + } + } catch (error) { + sendJson(res, 500, { + detail: error instanceof Error ? error.message : '数据库检测服务异常。' + }) + } + }) + + server.middlewares.use('/__setup/bootstrap/backend', async (req, res) => { + try { + if (req.method === 'GET') { + const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log') + backendStartState.logTail = readBackendLogTail(logFile) + sendJson(res, 200, cloneBackendStartState()) + return + } + + if (req.method !== 'POST') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + try { + const result = await startBackendAndWait() + sendJson(res, 200, result) + } catch (error) { + sendJson(res, 500, { + ok: false, + detail: error instanceof Error ? error.message : 'FastAPI 后端启动失败。' + }) + } + } catch (error) { + sendJson(res, 500, { + detail: error instanceof Error ? error.message : '后端启动桥接服务异常。' + }) + } + }) + + server.middlewares.use('/__setup/bootstrap', async (req, res) => { + try { + if (req.method === 'GET') { + sendJson(res, 200, normalizeState(readEnvState())) + return + } + + if (req.method !== 'POST') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + const currentEnv = readEnvState() + const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv) + const validationError = validateSetupPayload(payload) + + if (validationError) { + sendJson(res, 400, { detail: validationError }) + return + } + + try { + await testRuntimePorts(payload) + await testDatabaseConnection(payload) + } catch (error) { + sendJson(res, 400, { + detail: error instanceof Error ? error.message : '初始化校验失败。' + }) + return + } + + persistAdminCredentials(payload) + + const apiBaseUrl = buildApiBaseUrl(payload, currentEnv) + + updateEnvFile({ + SETUP_COMPLETED: 'true', + COMPANY_NAME: String(payload.company_name || '').trim(), + COMPANY_CODE: String(payload.company_code || '').trim(), + ADMIN_EMAIL: String(payload.admin_email || '').trim(), + WEB_HOST: String(payload.web_host || '').trim(), + WEB_PORT: String(payload.web_port || '').trim(), + SERVER_HOST: String(payload.server_host || '').trim(), + SERVER_PORT: String(payload.server_port || '').trim(), + POSTGRES_HOST: String(payload.postgres_host || '').trim(), + POSTGRES_PORT: String(payload.postgres_port || '').trim(), + POSTGRES_DB: String(payload.postgres_db || '').trim(), + POSTGRES_USER: String(payload.postgres_user || '').trim(), + POSTGRES_PASSWORD: String(payload.postgres_password || ''), + DATABASE_URL: buildDatabaseUrl(payload), + REDIS_URL: String(payload.redis_url || '').trim(), + CORS_ORIGINS: buildCorsOrigins(payload), + VITE_API_BASE_URL: apiBaseUrl, + ...buildClientEnvUpdates(payload, apiBaseUrl) + }) + + sendJson(res, 201, normalizeState(readEnvState())) + } catch (error) { + sendJson(res, 500, { + detail: error instanceof Error ? error.message : '初始化写入失败。' + }) + } + }) + } + } +} + +export default defineConfig({ + envDir: '..', + server: { + watch: { + ignored: [ + envFile, + envExampleFile, + path.join(rootDir, 'server', 'logs', '**') + ] + }, + proxy: { + '/api': { + target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`, + changeOrigin: true + } + } + }, + plugins: [vue(), localSetupPlugin()] +})