feat: 完善知识库、策略预览与OnlyOffice集成,增强后端启动依赖检查

This commit is contained in:
caoxiaozhu
2026-05-09 05:59:46 +00:00
parent 1d3ac5c2e0
commit d9133193e8
31 changed files with 5534 additions and 5343 deletions

View File

@@ -1,4 +1,4 @@
services: services:
main: main:
image: x-financial-dev:latest image: x-financial-dev:latest
container_name: x-financial-main container_name: x-financial-main
@@ -6,14 +6,14 @@ services:
depends_on: depends_on:
onlyoffice: onlyoffice:
condition: service_started condition: service_started
environment: environment:
WEB_HOST: 0.0.0.0 WEB_HOST: 0.0.0.0
SERVER_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0
SERVER_VENV_DIR: /tmp/x-financial-server-venv SERVER_VENV_DIR: /tmp/x-financial-server-venv
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}" ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}"
ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}" 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_BACKEND_URL: "${ONLYOFFICE_BACKEND_URL:-http://main:${SERVER_PORT:-8000}}"
ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}" ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
ports: ports:
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}" - "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}" - "${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 && chmod +x /app/start.sh /app/web/web_start.sh /app/server/server_start.sh &&
cd /app && cd /app &&
./start.sh all ./start.sh all
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"] test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 180s start_period: 180s
onlyoffice: onlyoffice:
image: onlyoffice/documentserver:latest image: onlyoffice/documentserver:latest
container_name: x-financial-onlyoffice container_name: x-financial-onlyoffice
restart: unless-stopped restart: unless-stopped
environment: environment:
JWT_ENABLED: "true" JWT_ENABLED: "true"
JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}" JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
ports: ports:
- "${ONLYOFFICE_PORT:-8082}:80" - "${ONLYOFFICE_PORT:-8082}:80"
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"] test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 60s start_period: 60s

View File

@@ -1,67 +1,67 @@
# Docker Compose # Docker Compose
This project currently uses the Vite `__setup/*` middleware during the initial setup flow. 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 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`. the same main container and runs the existing root `start.sh`.
## Start ## Start
```bash ```bash
cp .env.example .env cp .env.example .env
docker compose up -d docker compose up -d
``` ```
Open: Open:
```text ```text
http://<your-linux-host>:5173 http://<your-linux-host>:5173
``` ```
## Container Layout ## Container Layout
- `main`: web + FastAPI main container - `main`: web + FastAPI main container
- `onlyoffice`: ONLYOFFICE Document Server - `onlyoffice`: ONLYOFFICE Document Server
- `postgres`: PostgreSQL database container - `postgres`: PostgreSQL database container
The project root is mounted directly into the main container: The project root is mounted directly into the main container:
```text ```text
.:/app .:/app
``` ```
That means the container reads your existing `.env`, source code, `server/.secrets`, logs, That means the container reads your existing `.env`, source code, `server/.secrets`, logs,
and generated dependency directories directly from the mapped project folder. and generated dependency directories directly from the mapped project folder.
This is a `compose`-only setup. There is no custom `Dockerfile`. 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 The tradeoff is that the `main` container installs the Python runtime packages it needs
when it starts. when it starts.
## Persistence ## Persistence
The PostgreSQL data directory is stored in the named volume `postgres_data`. The PostgreSQL data directory is stored in the named volume `postgres_data`.
## Notes ## Notes
- Most configuration should be maintained in the project root `.env`. - 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 - 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. 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: - Docker Compose only overrides a few values that must differ inside containers:
- `WEB_HOST=0.0.0.0` - `WEB_HOST=0.0.0.0`
- `SERVER_HOST=0.0.0.0` - `SERVER_HOST=0.0.0.0`
- `POSTGRES_HOST=postgres` - `POSTGRES_HOST=postgres`
- `POSTGRES_PORT=5432` - `POSTGRES_PORT=5432`
- `DATABASE_URL=...@postgres:...` - `DATABASE_URL=...@postgres:...`
- PostgreSQL is also published to the host by default as `127.0.0.1:55432`. - 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`. - 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. - 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 - After you complete setup in the browser, the Vite setup bridge will start FastAPI in the
same container using the saved runtime configuration. same container using the saved runtime configuration.
- On later restarts, `start.sh` will detect the saved setup state and start both web and - On later restarts, `start.sh` will detect the saved setup state and start both web and
server automatically. server automatically.
- If you access the system from another machine, make sure `CORS_ORIGINS` in `.env` includes - If you access the system from another machine, make sure `CORS_ORIGINS` in `.env` includes
the frontend address you actually use. the frontend address you actually use.
- For Navicat or any host-side client, use `127.0.0.1:55432`. - 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` - 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. 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 - 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. test bridge will resolve that back to the Docker PostgreSQL service.

View File

@@ -1,169 +1,169 @@
# X-Financial 智能化财务系统:双层 Agent 架构设计与开发落地全景指南 # X-Financial 智能化财务系统:双层 Agent 架构设计与开发落地全景指南
> **核心设计理念:确定性与概率性的完美解耦** > **核心设计理念:确定性与概率性的完美解耦**
> >
> 在企业级财务系统中“合规性”与“准确性”是不可妥协的底线。大语言模型LLM天生具有概率性会产生幻觉因此不能直接赋予其修改核心财务数据或放行审批的最高权限。 > 在企业级财务系统中“合规性”与“准确性”是不可妥协的底线。大语言模型LLM天生具有概率性会产生幻觉因此不能直接赋予其修改核心财务数据或放行审批的最高权限。
> >
> 本架构设计的核心,在于构建一个**“双层防线”** > 本架构设计的核心,在于构建一个**“双层防线”**
> 1. **外层 Agent (自研流程大脑)**:提供 100% 的确定性。它是系统的执行者,严格按照预设流程和固化的规则行事,不具备“自我意识”,只负责“路由”、“拦截”和“记录”。 > 1. **外层 Agent (自研流程大脑)**:提供 100% 的确定性。它是系统的执行者,严格按照预设流程和固化的规则行事,不具备“自我意识”,只负责“路由”、“拦截”和“记录”。
> 2. **内层 Agent (Hermes 智囊核心)**:提供强大的概率性推理能力。它是系统的思考者,负责处理所有复杂、模糊、非结构化的任务(如阅读长文档、识别潜在风险),但它的输出**不能直接作用于业务**,而是转化为**规则配置**或**建议意见**,交由外层 Agent 或人类管理员执行。 > 2. **内层 Agent (Hermes 智囊核心)**:提供强大的概率性推理能力。它是系统的思考者,负责处理所有复杂、模糊、非结构化的任务(如阅读长文档、识别潜在风险),但它的输出**不能直接作用于业务**,而是转化为**规则配置**或**建议意见**,交由外层 Agent 或人类管理员执行。
> >
> 这两层架构不是相互独立的两个系统,而是形成一个**“闭环”**:内层提炼规则,外层执行规则;外层收集数据,内层分析数据。这种深度协同,既保障了系统的安全性,又赋予了系统极高的智能化水平。 > 这两层架构不是相互独立的两个系统,而是形成一个**“闭环”**:内层提炼规则,外层执行规则;外层收集数据,内层分析数据。这种深度协同,既保障了系统的安全性,又赋予了系统极高的智能化水平。
--- ---
## 一、 系统架构图景与职责边界深度剖析 ## 一、 系统架构图景与职责边界深度剖析
### 1. 外层 Agent (Outer Agent):流程与路由的绝对掌控者 ### 1. 外层 Agent (Outer Agent):流程与路由的绝对掌控者
**本质:一个高度可配置的业务工作流引擎与意图分发器。** **本质:一个高度可配置的业务工作流引擎与意图分发器。**
* **开发技术栈建议**FastAPI (后端) + Vue3 (前端) + PostgreSQL (持久化) + Redis (可选,用于状态缓存)。 * **开发技术栈建议**FastAPI (后端) + Vue3 (前端) + PostgreSQL (持久化) + Redis (可选,用于状态缓存)。
* **交互形态**:它直接面对用户。它可以是一个类似对话框的界面,但背后的逻辑是基于**状态机 (State Machine)** 驱动的。 * **交互形态**:它直接面对用户。它可以是一个类似对话框的界面,但背后的逻辑是基于**状态机 (State Machine)** 驱动的。
* **核心模块与职责 (What to do & How to do)** * **核心模块与职责 (What to do & How to do)**
* **模块 1: 意图漏斗 (Intent Router)** * **模块 1: 意图漏斗 (Intent Router)**
* **职责**:精准捕捉用户请求的第一诉求,并将其导向正确的处理管线。 * **职责**:精准捕捉用户请求的第一诉求,并将其导向正确的处理管线。
* **方法** * **方法**
* *规则匹配优先*:使用简单的关键词或正则(例如:匹配到“报销”、“打车”字眼,直接激活报销向导)。 * *规则匹配优先*:使用简单的关键词或正则(例如:匹配到“报销”、“打车”字眼,直接激活报销向导)。
* *轻量级分类模型兜底*:对于模糊表述(如:“我上周去上海开会的钱怎么还没发?”),调用一个小参数的分类模型(或内层的快捷接口),将其分类为“状态查询”意图,并提取关键实体(如时间:上周,地点:上海)。 * *轻量级分类模型兜底*:对于模糊表述(如:“我上周去上海开会的钱怎么还没发?”),调用一个小参数的分类模型(或内层的快捷接口),将其分类为“状态查询”意图,并提取关键实体(如时间:上周,地点:上海)。
* **模块 2: 结构化状态机引擎 (State & Flow Controller)** * **模块 2: 结构化状态机引擎 (State & Flow Controller)**
* **职责**:管理每一个业务对象(如一张报销单)的生命周期。从“草稿” -> “提交” -> “一级审批” -> “财务复核” -> “已打款”。 * **职责**:管理每一个业务对象(如一张报销单)的生命周期。从“草稿” -> “提交” -> “一级审批” -> “财务复核” -> “已打款”。
* **方法**:拒绝让大模型控制流程走向。流程流转必须基于代码逻辑中的条件判断(例如:如果金额 < 500且员工级别为 M1则跳过一级审批直接进入财务复核。外层 Agent 负责维护并推进这个状态。 * **方法**:拒绝让大模型控制流程走向。流程流转必须基于代码逻辑中的条件判断(例如:如果金额 < 500且员工级别为 M1则跳过一级审批直接进入财务复核。外层 Agent 负责维护并推进这个状态。
* **模块 3: 确定性规则执行器 (Rule Execution Engine)** * **模块 3: 确定性规则执行器 (Rule Execution Engine)**
* **职责**:财务合规的第一道硬性防线。不讲道理,只看数据。 * **职责**:财务合规的第一道硬性防线。不讲道理,只看数据。
* **方法**:当用户提交报销数据时,该模块会查询本地的 `business_rules` 数据库表。如果用户提交的住宿费是 850而数据库规则明确上限是 800则立刻抛出“阻断型错误” (Blocking Error)。**此过程绝对禁止调用大模型进行实时推断。** * **方法**:当用户提交报销数据时,该模块会查询本地的 `business_rules` 数据库表。如果用户提交的住宿费是 850而数据库规则明确上限是 800则立刻抛出“阻断型错误” (Blocking Error)。**此过程绝对禁止调用大模型进行实时推断。**
* **模块 4: 标准化 API 网关 (API Gateway & Handshake Layer)** * **模块 4: 标准化 API 网关 (API Gateway & Handshake Layer)**
* **职责**:封装所有对外层系统(如 ERP、HR 系统)和对内层 Hermes 的通信接口。控制并发,记录调用日志。 * **职责**:封装所有对外层系统(如 ERP、HR 系统)和对内层 Hermes 的通信接口。控制并发,记录调用日志。
### 2. 内层 Agent (Hermes):非结构化信息的提炼者与深度思考者 ### 2. 内层 Agent (Hermes):非结构化信息的提炼者与深度思考者
**本质:一个被严格隔离的智能计算引擎,专门处理人类擅长但传统代码难以处理的“软逻辑”。** **本质:一个被严格隔离的智能计算引擎,专门处理人类擅长但传统代码难以处理的“软逻辑”。**
* **开发技术栈建议**Hermes 框架 + 向量数据库 (如 Milvus/PGVector) + 强力 LLM (如 GPT-4 或开源大模型)。 * **开发技术栈建议**Hermes 框架 + 向量数据库 (如 Milvus/PGVector) + 强力 LLM (如 GPT-4 或开源大模型)。
* **交互形态**:对用户不可见,只作为外层 Agent 的“后端服务”存在。 * **交互形态**:对用户不可见,只作为外层 Agent 的“后端服务”存在。
* **核心模块与职责 (What to do & How to do)** * **核心模块与职责 (What to do & How to do)**
* **模块 1: 政策蒸馏器 (Policy Distiller) —— 解决“知行合一”的关键** * **模块 1: 政策蒸馏器 (Policy Distiller) —— 解决“知行合一”的关键**
* **职责**:打破知识库(死文件)与业务流(活代码)之间的壁垒。 * **职责**:打破知识库(死文件)与业务流(活代码)之间的壁垒。
* **方法 (核心思路)** * **方法 (核心思路)**
1. *触发*:管理员上传一份《差旅新规.pdf》。 1. *触发*:管理员上传一份《差旅新规.pdf》。
2. *解析*Hermes 逐段阅读文档。 2. *解析*Hermes 逐段阅读文档。
3. *提取*:使用精心设计的 **Few-Shot Prompt 链**,强制模型识别特定的“控制变量”。 3. *提取*:使用精心设计的 **Few-Shot Prompt 链**,强制模型识别特定的“控制变量”。
*(Prompt 示例: "你是一个专业的财务合规审计员。请阅读以下段落,如果包含任何关于费用上限、职级限制、审批层级的规定,请严格按照以下 JSON Schema 输出:{category, location, level_req, max_amount, is_hard_limit}。如果未找到,输出空。")* *(Prompt 示例: "你是一个专业的财务合规审计员。请阅读以下段落,如果包含任何关于费用上限、职级限制、审批层级的规定,请严格按照以下 JSON Schema 输出:{category, location, level_req, max_amount, is_hard_limit}。如果未找到,输出空。")*
4. *回写*Hermes 将提炼出的 JSON 结构转化为标准的 SQL Update 指令(或通过专用 API 接口),更新外层 Agent 依赖的 `business_rules` 表。 4. *回写*Hermes 将提炼出的 JSON 结构转化为标准的 SQL Update 指令(或通过专用 API 接口),更新外层 Agent 依赖的 `business_rules` 表。
* **模块 2: 深度知识检索 (Deep RAG & Interpretation)** * **模块 2: 深度知识检索 (Deep RAG & Interpretation)**
* **职责**:为用户提供复杂制度的个性化解读。 * **职责**:为用户提供复杂制度的个性化解读。
* **方法**:当外层 Agent 无法解答用户的合规疑问时(意图识别为“政策咨询”),外层将请求转发给 Hermes。Hermes 在向量库中检索相关段落,并结合用户当前的上下文(如:员工职级、出差地),生成一份连贯、人性化的解答。 * **方法**:当外层 Agent 无法解答用户的合规疑问时(意图识别为“政策咨询”),外层将请求转发给 Hermes。Hermes 在向量库中检索相关段落,并结合用户当前的上下文(如:员工职级、出差地),生成一份连贯、人性化的解答。
* **模块 3: 异步风险探针 (Asynchronous Risk Auditor)** * **模块 3: 异步风险探针 (Asynchronous Risk Auditor)**
* **职责**:像“老会计”一样,在海量已发生或正在发生的业务数据中寻找蛛丝马迹。 * **职责**:像“老会计”一样,在海量已发生或正在发生的业务数据中寻找蛛丝马迹。
* **方法** * **方法**
1. *定时任务*:每天凌晨启动。 1. *定时任务*:每天凌晨启动。
2. *数据聚合*:从外层数据库提取当天的报销流水(去除敏感个资)。 2. *数据聚合*:从外层数据库提取当天的报销流水(去除敏感个资)。
3. *模式识别*:通过特定的 Prompt例如寻找“拆单报销”、“异常高频的出租车票” 3. *模式识别*:通过特定的 Prompt例如寻找“拆单报销”、“异常高频的出租车票”
4. *生成报告*:生成结构化的风险预警报告,存入专用表,供管理员次日早晨审核,而不是直接去冻结员工账号。 4. *生成报告*:生成结构化的风险预警报告,存入专用表,供管理员次日早晨审核,而不是直接去冻结员工账号。
--- ---
## 二、 核心通信协议 (The Handshake):两层的握手与数据交互 ## 二、 核心通信协议 (The Handshake):两层的握手与数据交互
双层架构的成败,取决于这两层能否顺畅地交换信息,且保证安全。我们需要定义清晰的接口协议。 双层架构的成败,取决于这两层能否顺畅地交换信息,且保证安全。我们需要定义清晰的接口协议。
### 1. 同步查询接口 (外 -> 内:求知与解惑) ### 1. 同步查询接口 (外 -> 内:求知与解惑)
当外层遇到处理不了的“软逻辑”时触发。 当外层遇到处理不了的“软逻辑”时触发。
* **Endpoint (示例)**: `POST /hermes/api/v1/consult` * **Endpoint (示例)**: `POST /hermes/api/v1/consult`
* **外层 Request 结构**: * **外层 Request 结构**:
```json ```json
{ {
"context": { "context": {
"user_id": "emp_1001", "user_id": "emp_1001",
"current_task": "travel_reimbursement", "current_task": "travel_reimbursement",
"form_data": {"city": "北京", "amount": 900} "form_data": {"city": "北京", "amount": 900}
}, },
"query": "因为展会原因酒店全满只能订900的能报销吗" "query": "因为展会原因酒店全满只能订900的能报销吗"
} }
``` ```
* **内层 Hermes Response 结构**: * **内层 Hermes Response 结构**:
```json ```json
{ {
"status": "success", "status": "success",
"interpretation": "根据《差旅管理办法》第15条展会期间允许上浮 20%。您的标准是800上浮后为960可以报销。", "interpretation": "根据《差旅管理办法》第15条展会期间允许上浮 20%。您的标准是800上浮后为960可以报销。",
"action_recommendation": "require_special_approval", // 建议外层采取的动作 "action_recommendation": "require_special_approval", // 建议外层采取的动作
"citations": ["policy_doc_v2_page_4"] "citations": ["policy_doc_v2_page_4"]
} }
``` ```
### 2. 异步任务接口 (外 -> 内:派发耗时任务) ### 2. 异步任务接口 (外 -> 内:派发耗时任务)
例如请求生成长篇分析报告或进行全量风险巡检。 例如请求生成长篇分析报告或进行全量风险巡检。
* **流程** * **流程**
1. 外层调用 `POST /hermes/api/v1/jobs/generate_report`。 1. 外层调用 `POST /hermes/api/v1/jobs/generate_report`。
2. 内层 Hermes 立即返回 `202 Accepted` 和一个 `job_id`。 2. 内层 Hermes 立即返回 `202 Accepted` 和一个 `job_id`。
3. 内层 Hermes 在后台慢慢算。 3. 内层 Hermes 在后台慢慢算。
4. 计算完成后,内层通过 Webhook 回调外层的通知接口,外层再通过系统消息通知用户“您的报告已就绪”。 4. 计算完成后,内层通过 Webhook 回调外层的通知接口,外层再通过系统消息通知用户“您的报告已就绪”。
### 3. 规则推送机制 (内 -> 外:自动化立法) ### 3. 规则推送机制 (内 -> 外:自动化立法)
这是最核心的逆向通信。内层提炼出的规则如何生效? 这是最核心的逆向通信。内层提炼出的规则如何生效?
* **流程** * **流程**
1. Hermes 提炼出新规则。 1. Hermes 提炼出新规则。
2. Hermes 调用外层的特权 API (如 `POST /admin/api/rules/sync`),推送规则 payload。 2. Hermes 调用外层的特权 API (如 `POST /admin/api/rules/sync`),推送规则 payload。
3. 外层 Agent 收到后,执行数据库 `UPSERT` 操作更新 `business_rules` 表。 3. 外层 Agent 收到后,执行数据库 `UPSERT` 操作更新 `business_rules` 表。
4. *(可选但强烈建议)*:进入“待激活”状态,需要人类管理员在系统中点击“确认应用新规则”后,新规才正式生效。 4. *(可选但强烈建议)*:进入“待激活”状态,需要人类管理员在系统中点击“确认应用新规则”后,新规才正式生效。
--- ---
## 三、 分阶段开发落地全景计划 (Implementation Roadmap) ## 三、 分阶段开发落地全景计划 (Implementation Roadmap)
开发应当遵循“先基建后上层、先确定后智能”的原则。 开发应当遵循“先基建后上层、先确定后智能”的原则。
### Phase 1: 骨架搭建与基石铺设 (Foundation & Outer Shell) ### Phase 1: 骨架搭建与基石铺设 (Foundation & Outer Shell)
*目标:构建一个哪怕没有 AI 也能运转的硬核流程系统,确立两层隔离。* *目标:构建一个哪怕没有 AI 也能运转的硬核流程系统,确立两层隔离。*
1. **架构拆分验证**:在服务器层面,确保 Outer Agent (FastAPI) 和 Inner Hermes 分别在独立的进程(或容器)中运行,仅通过 HTTP/gRPC 通信。 1. **架构拆分验证**:在服务器层面,确保 Outer Agent (FastAPI) 和 Inner Hermes 分别在独立的进程(或容器)中运行,仅通过 HTTP/gRPC 通信。
2. **动态规则引擎实现 (核心基建)** 2. **动态规则引擎实现 (核心基建)**
* 在 PostgreSQL 中设计 `business_rules` 表结构。必须支持高度扩展性(例如采用 `JSONB` 字段存储具体约束参数)。 * 在 PostgreSQL 中设计 `business_rules` 表结构。必须支持高度扩展性(例如采用 `JSONB` 字段存储具体约束参数)。
* 在外层 Agent 开发一个“规则校验服务 (Rule Validation Service)”,该服务能够在任何报销动作发生前,拦截并比对 `business_rules`。 * 在外层 Agent 开发一个“规则校验服务 (Rule Validation Service)”,该服务能够在任何报销动作发生前,拦截并比对 `business_rules`。
3. **标准化流程闭环**:开发一个完整的、基于硬规则驱动的差旅报销单据流转全流程(填单 -> 校验 -> 提交 -> 审批)。验证在“硬规则”下系统运转良好。 3. **标准化流程闭环**:开发一个完整的、基于硬规则驱动的差旅报销单据流转全流程(填单 -> 校验 -> 提交 -> 审批)。验证在“硬规则”下系统运转良好。
### Phase 2: 知识注入与基础问答 (Hermes RAG Integration) ### Phase 2: 知识注入与基础问答 (Hermes RAG Integration)
*目标:赋予系统“解答疑问”的能力。* *目标:赋予系统“解答疑问”的能力。*
1. **内层基建**:配置 Hermes 环境,接入向量数据库。 1. **内层基建**:配置 Hermes 环境,接入向量数据库。
2. **文档清洗管道 (ETL pipeline)**:将现有的财务政策 PDF/Word 文档清洗、分块 (Chunking) 并向量化入库。 2. **文档清洗管道 (ETL pipeline)**:将现有的财务政策 PDF/Word 文档清洗、分块 (Chunking) 并向量化入库。
3. **问答桥接** 3. **问答桥接**
* 在外层前端 (Vue3) 提供一个“智能咨询”悬浮窗或独立页面。 * 在外层前端 (Vue3) 提供一个“智能咨询”悬浮窗或独立页面。
* 外层 Agent 接收问题,附带上用户的上下文(角色、权限),一并转发给内层 Hermes。 * 外层 Agent 接收问题,附带上用户的上下文(角色、权限),一并转发给内层 Hermes。
* 验证 Hermes 能够根据向量库的内容,给出带出处的准确回答。 * 验证 Hermes 能够根据向量库的内容,给出带出处的准确回答。
### Phase 3: 核心攻坚 —— 自动立法与双层联通 (Policy Distillation & Sync) ### Phase 3: 核心攻坚 —— 自动立法与双层联通 (Policy Distillation & Sync)
*目标:实现从“死文档”到“活规则”的自动化转化。* *目标:实现从“死文档”到“活规则”的自动化转化。*
1. **蒸馏 Prompt 工程**:在 Hermes 中反复打磨“政策提炼”的 Prompt。针对你们公司常见的政策描述方式进行微调。 1. **蒸馏 Prompt 工程**:在 Hermes 中反复打磨“政策提炼”的 Prompt。针对你们公司常见的政策描述方式进行微调。
2. **结构化提取测试**:手动上传不同版本的政策文档,测试 Hermes 能否稳定、准确地输出 JSON 格式的规则参数。 2. **结构化提取测试**:手动上传不同版本的政策文档,测试 Hermes 能否稳定、准确地输出 JSON 格式的规则参数。
3. **闭环联调** 3. **闭环联调**
* 开发 Hermes 向外层推送规则的 API。 * 开发 Hermes 向外层推送规则的 API。
* 完成全链路测试:管理员界面上传新文档 -> Hermes 后台解析 -> 外层规则库自动更新 -> 前端即时生效新的金额限制。 * 完成全链路测试:管理员界面上传新文档 -> Hermes 后台解析 -> 外层规则库自动更新 -> 前端即时生效新的金额限制。
### Phase 4: 高阶进化 —— 异步审计与主动防御 (Proactive Risk Auditing) ### Phase 4: 高阶进化 —— 异步审计与主动防御 (Proactive Risk Auditing)
*目标:将系统从“被动响应”升级为“主动防护”。* *目标:将系统从“被动响应”升级为“主动防护”。*
1. **数据安全隧道**:建立从外层业务库向内层 Hermes 传递“脱敏业务快照”的通道。 1. **数据安全隧道**:建立从外层业务库向内层 Hermes 传递“脱敏业务快照”的通道。
2. **风险模式定义**:梳理出 3-5 种典型的财务风险模式(如:异常聚集的餐饮发票、连续的单日高额交通费)。 2. **风险模式定义**:梳理出 3-5 种典型的财务风险模式(如:异常聚集的餐饮发票、连续的单日高额交通费)。
3. **Hermes 巡检任务**:编写定时任务逻辑,利用大模型的推理能力去比对这些模式和当天的业务快照数据。 3. **Hermes 巡检任务**:编写定时任务逻辑,利用大模型的推理能力去比对这些模式和当天的业务快照数据。
4. **风险看板**:在外层系统的管理后台开发“风险报告台”,展示 Hermes 生成的预警结果。 4. **风险看板**:在外层系统的管理后台开发“风险报告台”,展示 Hermes 生成的预警结果。
--- ---
## 四、 关键风险与防范策略总结 ## 四、 关键风险与防范策略总结
1. **大模型幻觉污染规则库** 1. **大模型幻觉污染规则库**
* **防范**Hermes 提炼的所有硬性规则(尤其是金额、审批级数),在写入外层正式库之前,必须增加一个**“人工审核 (Human-in-the-loop)”** 环节。系统提示“检测到政策更新,提炼出 5 条新规则,请管理员确认应用”。 * **防范**Hermes 提炼的所有硬性规则(尤其是金额、审批级数),在写入外层正式库之前,必须增加一个**“人工审核 (Human-in-the-loop)”** 环节。系统提示“检测到政策更新,提炼出 5 条新规则,请管理员确认应用”。
2. **状态机混乱** 2. **状态机混乱**
* **防范**:外层 Agent 的流程控制代码必须使用强类型和严格的事务控制 (Transaction)。绝不允许任何组件(包括 AI在不经过状态机合法校验的情况下直接修改数据库中的 `status` 字段。 * **防范**:外层 Agent 的流程控制代码必须使用强类型和严格的事务控制 (Transaction)。绝不允许任何组件(包括 AI在不经过状态机合法校验的情况下直接修改数据库中的 `status` 字段。
3. **性能瓶颈** 3. **性能瓶颈**
* **防范**:所有外层必须做的事情(拦截、查询)必须在毫秒级完成。所有涉及调用 Hermes 的操作(问答、提炼、分析)全部采用异步设计或提供明确的 Loading 反馈。 * **防范**:所有外层必须做的事情(拦截、查询)必须在毫秒级完成。所有涉及调用 Hermes 的操作(问答、提炼、分析)全部采用异步设计或提供明确的 Loading 反馈。

View File

@@ -1,48 +1,48 @@
[build-system] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "x-financial-server" name = "x-financial-server"
version = "0.1.0" version = "0.1.0"
description = "Backend service for X-Financial reimbursement and approval platform." description = "Backend service for X-Financial reimbursement and approval platform."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115.0,<1.0.0", "fastapi>=0.115.0,<1.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0", "uvicorn[standard]>=0.30.0,<1.0.0",
"sqlalchemy>=2.0.36,<3.0.0", "sqlalchemy>=2.0.36,<3.0.0",
"alembic>=1.14.0,<2.0.0", "alembic>=1.14.0,<2.0.0",
"psycopg[binary]>=3.2.0,<4.0.0", "psycopg[binary]>=3.2.0,<4.0.0",
"PyJWT>=2.9.0,<3.0.0", "PyJWT>=2.9.0,<3.0.0",
"pydantic-settings>=2.6.0,<3.0.0", "pydantic-settings>=2.6.0,<3.0.0",
"python-dotenv>=1.0.1,<2.0.0", "python-dotenv>=1.0.1,<2.0.0",
"email-validator>=2.2.0,<3.0.0", "email-validator>=2.2.0,<3.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.3.0,<9.0.0", "pytest>=8.3.0,<9.0.0",
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
"ruff>=0.8.0,<1.0.0", "ruff>=0.8.0,<1.0.0",
] ]
redis = [ redis = [
"redis>=5.2.0,<6.0.0", "redis>=5.2.0,<6.0.0",
] ]
[tool.setuptools] [tool.setuptools]
package-dir = {"" = "src"} package-dir = {"" = "src"}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]
testpaths = ["tests"] testpaths = ["tests"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py311" target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"] select = ["E", "F", "I", "B", "UP"]

View File

@@ -90,6 +90,10 @@ fi
ENV_OVERRIDE_SERVER_HOST_SET=false ENV_OVERRIDE_SERVER_HOST_SET=false
ENV_OVERRIDE_POSTGRES_HOST_SET=false ENV_OVERRIDE_POSTGRES_HOST_SET=false
ENV_OVERRIDE_DATABASE_URL_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 if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST_SET=true ENV_OVERRIDE_SERVER_HOST_SET=true
@@ -106,6 +110,26 @@ if [ "${DATABASE_URL+x}" = x ]; then
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL" ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
fi 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 set -a
. "$ROOT_ENV_FILE" . "$ROOT_ENV_FILE"
set +a set +a
@@ -122,6 +146,22 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL" DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
fi 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_HOST="${SERVER_HOST:-0.0.0.0}"
SERVER_PORT="${SERVER_PORT:-8000}" SERVER_PORT="${SERVER_PORT:-8000}"
DEFAULT_SERVER_RELOAD="false" DEFAULT_SERVER_RELOAD="false"
@@ -189,7 +229,7 @@ run_bootstrap_python() {
} }
dependencies_ready() { 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() { pip_ready() {

View File

@@ -1,62 +1,62 @@
from collections.abc import Generator from collections.abc import Generator
from dataclasses import dataclass from dataclasses import dataclass
from typing import Annotated from typing import Annotated
from fastapi import Depends, Header, HTTPException, status from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.session import get_session_factory from app.db.session import get_session_factory
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
db = get_session_factory()() db = get_session_factory()()
try: try:
yield db yield db
finally: finally:
db.close() db.close()
@dataclass(slots=True) @dataclass(slots=True)
class CurrentUserContext: class CurrentUserContext:
username: str username: str
name: str name: str
role_codes: list[str] role_codes: list[str]
is_admin: bool is_admin: bool
def get_current_user( def get_current_user(
x_auth_username: Annotated[str | None, Header()] = None, x_auth_username: Annotated[str | None, Header()] = None,
x_auth_name: Annotated[str | None, Header()] = None, x_auth_name: Annotated[str | None, Header()] = None,
x_auth_role_codes: Annotated[str | None, Header()] = None, x_auth_role_codes: Annotated[str | None, Header()] = None,
x_auth_is_admin: Annotated[str | None, Header()] = None, x_auth_is_admin: Annotated[str | None, Header()] = None,
) -> CurrentUserContext: ) -> CurrentUserContext:
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()] 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"} is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
username = (x_auth_username or "").strip() username = (x_auth_username or "").strip()
name = (x_auth_name or username).strip() name = (x_auth_name or username).strip()
if not username and not name: if not username and not name:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录后再访问知识库。", detail="请先登录后再访问知识库。",
) )
return CurrentUserContext( return CurrentUserContext(
username=username or name, username=username or name,
name=name or username, name=name or username,
role_codes=role_codes, role_codes=role_codes,
is_admin=is_admin, is_admin=is_admin,
) )
def require_admin_user( def require_admin_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)], current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext: ) -> CurrentUserContext:
if current_user.is_admin or "manager" in current_user.role_codes: if current_user.is_admin or "manager" in current_user.role_codes:
return current_user return current_user
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员可以上传、删除或修改知识库文件。", detail="只有管理员可以上传、删除或修改知识库文件。",
) )

View File

@@ -1,124 +1,124 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.api.deps import CurrentUserContext, get_current_user, require_admin_user from app.api.deps import CurrentUserContext, get_current_user, require_admin_user
from app.schemas.knowledge import ( from app.schemas.knowledge import (
KnowledgeActionResponse, KnowledgeActionResponse,
KnowledgeDocumentDetailRead, KnowledgeDocumentDetailRead,
KnowledgeLibraryRead, KnowledgeLibraryRead,
KnowledgeOnlyOfficeCallbackRead, KnowledgeOnlyOfficeCallbackRead,
KnowledgeOnlyOfficeConfigRead, KnowledgeOnlyOfficeConfigRead,
) )
from app.services.knowledge import KnowledgeService from app.services.knowledge import KnowledgeService
router = APIRouter(prefix="/knowledge") router = APIRouter(prefix="/knowledge")
@router.get("/library", response_model=KnowledgeLibraryRead) @router.get("/library", response_model=KnowledgeLibraryRead)
def get_knowledge_library( def get_knowledge_library(
_: Annotated[CurrentUserContext, Depends(get_current_user)], _: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> KnowledgeLibraryRead: ) -> KnowledgeLibraryRead:
return KnowledgeService().list_library() return KnowledgeService().list_library()
@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead) @router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead)
def get_knowledge_document( def get_knowledge_document(
document_id: str, document_id: str,
_: Annotated[CurrentUserContext, Depends(get_current_user)], _: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> KnowledgeDocumentDetailRead: ) -> KnowledgeDocumentDetailRead:
try: try:
return KnowledgeService().get_document_detail(document_id) return KnowledgeService().get_document_detail(document_id)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead) @router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead)
def get_knowledge_document_onlyoffice_config( def get_knowledge_document_onlyoffice_config(
document_id: str, document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)], current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> KnowledgeOnlyOfficeConfigRead: ) -> KnowledgeOnlyOfficeConfigRead:
try: try:
return KnowledgeService().build_onlyoffice_config(document_id, current_user) return KnowledgeService().build_onlyoffice_config(document_id, current_user)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from 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) @router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED)
async def upload_knowledge_document( async def upload_knowledge_document(
request: Request, request: Request,
folder: Annotated[str, Query(min_length=1)], folder: Annotated[str, Query(min_length=1)],
filename: Annotated[str, Query(min_length=1)], filename: Annotated[str, Query(min_length=1)],
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeDocumentDetailRead: ) -> KnowledgeDocumentDetailRead:
content = await request.body() content = await request.body()
try: try:
return KnowledgeService().upload_document(folder, filename, content, current_user) return KnowledgeService().upload_document(folder, filename, content, current_user)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse) @router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse)
def delete_knowledge_document( def delete_knowledge_document(
document_id: str, document_id: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)], _: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeActionResponse: ) -> KnowledgeActionResponse:
try: try:
KnowledgeService().delete_document(document_id) KnowledgeService().delete_document(document_id)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
return KnowledgeActionResponse(detail="知识库文件已删除。") return KnowledgeActionResponse(detail="知识库文件已删除。")
@router.get("/documents/{document_id}/content") @router.get("/documents/{document_id}/content")
def get_knowledge_document_content( def get_knowledge_document_content(
document_id: str, document_id: str,
disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline", disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline",
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None, _: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
) -> FileResponse: ) -> FileResponse:
try: try:
file_path, media_type, filename = KnowledgeService().get_document_content(document_id) file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
_ = disposition _ = disposition
return FileResponse(file_path, media_type=media_type, filename=filename) return FileResponse(file_path, media_type=media_type, filename=filename)
@router.get("/documents/{document_id}/onlyoffice/content") @router.get("/documents/{document_id}/onlyoffice/content")
def get_knowledge_document_onlyoffice_content( def get_knowledge_document_onlyoffice_content(
document_id: str, document_id: str,
access_token: Annotated[str, Query(min_length=1)], access_token: Annotated[str, Query(min_length=1)],
) -> FileResponse: ) -> FileResponse:
try: try:
service = KnowledgeService() service = KnowledgeService()
service.validate_onlyoffice_access_token(document_id, access_token) service.validate_onlyoffice_access_token(document_id, access_token)
file_path, media_type, filename = service.get_document_content(document_id) file_path, media_type, filename = service.get_document_content(document_id)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return FileResponse(file_path, media_type=media_type, filename=filename) return FileResponse(file_path, media_type=media_type, filename=filename)
@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead) @router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead)
async def handle_knowledge_document_onlyoffice_callback( async def handle_knowledge_document_onlyoffice_callback(
document_id: str, document_id: str,
request: Request, request: Request,
) -> KnowledgeOnlyOfficeCallbackRead: ) -> KnowledgeOnlyOfficeCallbackRead:
payload = await request.json() payload = await request.json()
try: try:
KnowledgeService().handle_onlyoffice_callback(document_id, payload) KnowledgeService().handle_onlyoffice_callback(document_id, payload)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return KnowledgeOnlyOfficeCallbackRead() return KnowledgeOnlyOfficeCallbackRead()

View File

@@ -1,18 +1,18 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints.auth import router as auth_router 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.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.employees import router as employees_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.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_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.reimbursements import router as reimbursements_router
from app.api.v1.endpoints.settings import router as settings_router from app.api.v1.endpoints.settings import router as settings_router
router = APIRouter() router = APIRouter()
router.include_router(health_router, tags=["health"]) router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"]) router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(auth_router, tags=["auth"]) router.include_router(auth_router, tags=["auth"])
router.include_router(knowledge_router, tags=["knowledge"]) router.include_router(knowledge_router, tags=["knowledge"])
router.include_router(employees_router, prefix="/employees", tags=["employees"]) router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(settings_router, tags=["settings"]) router.include_router(settings_router, tags=["settings"])

View File

@@ -1,65 +1,65 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router from app.api.router import api_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
from app.middleware.logging import AccessLogMiddleware from app.middleware.logging import AccessLogMiddleware
from app.services.employee import prepare_employee_directory from app.services.employee import prepare_employee_directory
from app.services.knowledge import prepare_knowledge_library from app.services.knowledge import prepare_knowledge_library
def create_app() -> FastAPI: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
setup_logging( setup_logging(
level=settings.log_level, level=settings.log_level,
log_dir=settings.log_dir, log_dir=settings.log_dir,
enable_file=settings.log_file_enabled, enable_file=settings.log_file_enabled,
) )
logger = get_logger("app.main") logger = get_logger("app.main")
logger.info( logger.info(
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug "Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
) )
app = FastAPI( app = FastAPI(
title=settings.app_name, title=settings.app_name,
debug=settings.app_debug, debug=settings.app_debug,
version="0.1.0", version="0.1.0",
) )
app.add_middleware(AccessLogMiddleware) app.add_middleware(AccessLogMiddleware)
if settings.cors_origins: if settings.cors_origins:
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(api_router, prefix=settings.api_v1_prefix) app.include_router(api_router, prefix=settings.api_v1_prefix)
@app.get("/", tags=["root"]) @app.get("/", tags=["root"])
def root() -> dict[str, str]: def root() -> dict[str, str]:
return {"message": f"{settings.app_name} is running"} return {"message": f"{settings.app_name} is running"}
@app.on_event("startup") @app.on_event("startup")
def _on_startup() -> None: def _on_startup() -> None:
prepare_employee_directory() prepare_employee_directory()
prepare_knowledge_library() prepare_knowledge_library()
logger.info( logger.info(
"Server ready - host=%s port=%s prefix=%s", "Server ready - host=%s port=%s prefix=%s",
settings.app_host, settings.app_host,
settings.app_port, settings.app_port,
settings.api_v1_prefix, settings.api_v1_prefix,
) )
return app return app
app = create_app() app = create_app()

View File

@@ -1 +1 @@
__all__ = ["employee", "knowledge", "reimbursement"] __all__ = ["employee", "knowledge", "reimbursement"]

View File

@@ -1,72 +1,72 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class KnowledgeFolderRead(BaseModel): class KnowledgeFolderRead(BaseModel):
name: str name: str
count: int count: int
icon: str = "mdi mdi-folder" icon: str = "mdi mdi-folder"
class KnowledgePreviewStatRead(BaseModel): class KnowledgePreviewStatRead(BaseModel):
label: str label: str
value: str value: str
class KnowledgePreviewBlockRead(BaseModel): class KnowledgePreviewBlockRead(BaseModel):
heading: str heading: str
lines: list[str] = Field(default_factory=list) lines: list[str] = Field(default_factory=list)
class KnowledgePreviewPageRead(BaseModel): class KnowledgePreviewPageRead(BaseModel):
title: str title: str
subtitle: str subtitle: str
stats: list[KnowledgePreviewStatRead] = Field(default_factory=list) stats: list[KnowledgePreviewStatRead] = Field(default_factory=list)
blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list) blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list)
class KnowledgeDocumentRead(BaseModel): class KnowledgeDocumentRead(BaseModel):
id: str id: str
name: str name: str
folder: str folder: str
tag: str tag: str
time: str time: str
version: str version: str
state: str state: str
stateTone: str stateTone: str
owner: str owner: str
icon: str icon: str
fileType: str fileType: str
fileTypeLabel: str fileTypeLabel: str
summary: str summary: str
mimeType: str mimeType: str
extension: str extension: str
sizeBytes: int sizeBytes: int
canPreview: bool = False canPreview: bool = False
class KnowledgeDocumentDetailRead(KnowledgeDocumentRead): class KnowledgeDocumentDetailRead(KnowledgeDocumentRead):
previewKind: str previewKind: str
previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list) previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list)
class KnowledgeOnlyOfficeConfigRead(BaseModel): class KnowledgeOnlyOfficeConfigRead(BaseModel):
documentServerUrl: str documentServerUrl: str
config: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict)
class KnowledgeOnlyOfficeCallbackRead(BaseModel): class KnowledgeOnlyOfficeCallbackRead(BaseModel):
error: int = 0 error: int = 0
class KnowledgeLibraryRead(BaseModel): class KnowledgeLibraryRead(BaseModel):
folders: list[KnowledgeFolderRead] = Field(default_factory=list) folders: list[KnowledgeFolderRead] = Field(default_factory=list)
documents: list[KnowledgeDocumentRead] = Field(default_factory=list) documents: list[KnowledgeDocumentRead] = Field(default_factory=list)
class KnowledgeActionResponse(BaseModel): class KnowledgeActionResponse(BaseModel):
ok: bool = True ok: bool = True
detail: str detail: str

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,16 @@
"version": 1, "version": 1,
"documents": [ "documents": [
{ {
"id": "fde293670eac4ae2b90a80eeb9f27b5b", "id": "8af9350f0e02488aaf0df2001286b764",
"folder": "财务知识库", "folder": "财务知识库",
"original_name": "差旅费季度报销258878.xlsx", "original_name": "差旅费季度报销258878.xlsx",
"stored_name": "fde293670eac4ae2b90a80eeb9f27b5b__差旅费季度报销258878.xlsx", "stored_name": "8af9350f0e02488aaf0df2001286b764__差旅费季度报销258878.xlsx",
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"extension": "xlsx", "extension": "xlsx",
"size_bytes": 11123, "size_bytes": 11123,
"sha256": "ea02e59d3a22a4a02284172acce3fd4c6367a26f1a4fd196dc4f65afed1bd4c5", "sha256": "ea02e59d3a22a4a02284172acce3fd4c6367a26f1a4fd196dc4f65afed1bd4c5",
"created_at": "2026-05-09T03:33:44.101489+00:00", "created_at": "2026-05-09T05:46:24.699125+00:00",
"updated_at": "2026-05-09T03:33:44.101489+00:00", "updated_at": "2026-05-09T05:46:24.699125+00:00",
"uploaded_by": "admin", "uploaded_by": "admin",
"version_number": 1 "version_number": 1
} }

View File

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

View File

@@ -38,6 +38,10 @@ fi
ENV_OVERRIDE_WEB_HOST_SET=false ENV_OVERRIDE_WEB_HOST_SET=false
ENV_OVERRIDE_SERVER_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 if [ "${WEB_HOST+x}" = x ]; then
ENV_OVERRIDE_WEB_HOST_SET=true ENV_OVERRIDE_WEB_HOST_SET=true
@@ -49,6 +53,26 @@ if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
fi 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 set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
@@ -61,6 +85,22 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
fi 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}" SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
SETUP_COMPLETED="${SETUP_COMPLETED:-false}" SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
APP_DEBUG="${APP_DEBUG:-true}" APP_DEBUG="${APP_DEBUG:-true}"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,157 +1,157 @@
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
function readCurrentUserHeaders() { function readCurrentUserHeaders() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return {} return {}
} }
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY) const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
if (!raw) { if (!raw) {
return {} return {}
} }
try { try {
const payload = JSON.parse(raw) const payload = JSON.parse(raw)
const username = String(payload?.username || '').trim() const username = String(payload?.username || '').trim()
const name = String(payload?.name || username).trim() const name = String(payload?.name || username).trim()
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : [] const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
const isAdmin = Boolean(payload?.isAdmin) const isAdmin = Boolean(payload?.isAdmin)
if (!username && !name) { if (!username && !name) {
return {} return {}
} }
return { return {
'x-auth-username': username, 'x-auth-username': username,
'x-auth-name': name, 'x-auth-name': name,
'x-auth-role-codes': roleCodes.join(','), 'x-auth-role-codes': roleCodes.join(','),
'x-auth-is-admin': String(isAdmin) 'x-auth-is-admin': String(isAdmin)
} }
} catch { } catch {
return {} return {}
} }
} }
function normalizeApiBaseUrl(value) { function normalizeApiBaseUrl(value) {
return String(value || '/api/v1').replace(/\/$/, '') return String(value || '/api/v1').replace(/\/$/, '')
} }
function isLoopbackHost(hostname) { function isLoopbackHost(hostname) {
const normalized = String(hostname || '').trim().toLowerCase() const normalized = String(hostname || '').trim().toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1' return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1'
} }
function resolveBrowserReachableApiBaseUrl(value) { function resolveBrowserReachableApiBaseUrl(value) {
const normalized = normalizeApiBaseUrl(value) const normalized = normalizeApiBaseUrl(value)
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return normalized return normalized
} }
try { try {
const apiUrl = new URL(normalized) const apiUrl = new URL(normalized)
const browserHost = window.location.hostname const browserHost = window.location.hostname
if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) { if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) {
apiUrl.hostname = browserHost apiUrl.hostname = browserHost
return normalizeApiBaseUrl(apiUrl.toString()) return normalizeApiBaseUrl(apiUrl.toString())
} }
} catch { } catch {
return normalized return normalized
} }
return normalized return normalized
} }
function readStoredApiBaseUrl() { function readStoredApiBaseUrl() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return '' return ''
} }
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '') return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
} }
let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1') let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1')
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.removeItem(API_BASE_STORAGE_KEY) window.localStorage.removeItem(API_BASE_STORAGE_KEY)
} }
export function setRuntimeApiBaseUrl(value) { export function setRuntimeApiBaseUrl(value) {
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value) runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl) window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl)
} }
} }
export function getRuntimeApiBaseUrl() { export function getRuntimeApiBaseUrl() {
return runtimeApiBaseUrl return runtimeApiBaseUrl
} }
function buildUrl(path) { function buildUrl(path) {
if (!path.startsWith('/')) { if (!path.startsWith('/')) {
return `${runtimeApiBaseUrl}/${path}` return `${runtimeApiBaseUrl}/${path}`
} }
return `${runtimeApiBaseUrl}${path}` return `${runtimeApiBaseUrl}${path}`
} }
export async function apiRequest(path, options = {}) { export async function apiRequest(path, options = {}) {
const { const {
contentType = 'application/json', contentType = 'application/json',
responseType = 'json', responseType = 'json',
headers: customHeaders, headers: customHeaders,
...fetchOptions ...fetchOptions
} = options } = options
const headers = { const headers = {
...readCurrentUserHeaders(), ...readCurrentUserHeaders(),
...(customHeaders || {}) ...(customHeaders || {})
} }
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') { if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
headers['Content-Type'] = contentType headers['Content-Type'] = contentType
} }
let response let response
try { try {
response = await fetch(buildUrl(path), { response = await fetch(buildUrl(path), {
...fetchOptions, ...fetchOptions,
headers headers
}) })
} catch { } catch {
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。') throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
} }
if (responseType === 'blob') { if (responseType === 'blob') {
if (!response.ok) { if (!response.ok) {
let payload = null let payload = null
try { try {
payload = await response.json() payload = await response.json()
} catch { } catch {
payload = null payload = null
} }
throw new Error(payload?.detail || '接口请求失败,请稍后重试。') throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
} }
return response.blob() return response.blob()
} }
let payload = null let payload = null
try { try {
payload = await response.json() payload = await response.json()
} catch { } catch {
payload = null payload = null
} }
if (!response.ok) { if (!response.ok) {
throw new Error(payload?.detail || '接口请求失败,请稍后重试。') throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
} }
return payload return payload
} }

View File

@@ -1,37 +1,37 @@
import { apiRequest } from './api.js' import { apiRequest } from './api.js'
export function fetchKnowledgeLibrary() { export function fetchKnowledgeLibrary() {
return apiRequest('/knowledge/library') return apiRequest('/knowledge/library')
} }
export function fetchKnowledgeDocument(documentId) { export function fetchKnowledgeDocument(documentId) {
return apiRequest(`/knowledge/documents/${documentId}`) return apiRequest(`/knowledge/documents/${documentId}`)
} }
export function fetchKnowledgeOnlyOfficeConfig(documentId) { export function fetchKnowledgeOnlyOfficeConfig(documentId) {
return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`) return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`)
} }
export function uploadKnowledgeDocument({ folder, file }) { export function uploadKnowledgeDocument({ folder, file }) {
return apiRequest( return apiRequest(
`/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`, `/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`,
{ {
method: 'POST', method: 'POST',
body: file, body: file,
contentType: file.type || 'application/octet-stream' contentType: file.type || 'application/octet-stream'
} }
) )
} }
export function deleteKnowledgeDocument(documentId) { export function deleteKnowledgeDocument(documentId) {
return apiRequest(`/knowledge/documents/${documentId}`, { return apiRequest(`/knowledge/documents/${documentId}`, {
method: 'DELETE' method: 'DELETE'
}) })
} }
export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') { export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') {
return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, { return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, {
responseType: 'blob', responseType: 'blob',
contentType: null contentType: null
}) })
} }

View File

@@ -1,43 +1,43 @@
const scriptPromises = new Map() const scriptPromises = new Map()
function normalizeBaseUrl(value) { function normalizeBaseUrl(value) {
return String(value || '').replace(/\/$/, '') return String(value || '').replace(/\/$/, '')
} }
export function buildOnlyOfficeScriptUrl(documentServerUrl) { export function buildOnlyOfficeScriptUrl(documentServerUrl) {
return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js` return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js`
} }
export function loadOnlyOfficeApi(documentServerUrl) { export function loadOnlyOfficeApi(documentServerUrl) {
const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl) const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl)
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。')) return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。'))
} }
if (window.DocsAPI?.DocEditor) { if (window.DocsAPI?.DocEditor) {
return Promise.resolve(window.DocsAPI) return Promise.resolve(window.DocsAPI)
} }
if (scriptPromises.has(scriptUrl)) { if (scriptPromises.has(scriptUrl)) {
return scriptPromises.get(scriptUrl) return scriptPromises.get(scriptUrl)
} }
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${scriptUrl}"]`) const existing = document.querySelector(`script[src="${scriptUrl}"]`)
if (existing) { if (existing) {
existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true }) existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true })
existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true }) existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true })
return return
} }
const script = document.createElement('script') const script = document.createElement('script')
script.src = scriptUrl script.src = scriptUrl
script.async = true script.async = true
script.onload = () => resolve(window.DocsAPI) script.onload = () => resolve(window.DocsAPI)
script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。')) script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。'))
document.head.appendChild(script) document.head.appendChild(script)
}) })
scriptPromises.set(scriptUrl, promise) scriptPromises.set(scriptUrl, promise)
return promise return promise
} }

View File

@@ -1,63 +1,63 @@
export const DEFAULT_APP_VIEW_ORDER = [ export const DEFAULT_APP_VIEW_ORDER = [
'overview', 'overview',
'workbench', 'workbench',
'requests', 'requests',
'approval', 'approval',
'chat', 'chat',
'policies', 'policies',
'audit', 'audit',
'employees', 'employees',
'settings' 'settings'
] ]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies']) const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies'])
const VIEW_ROLE_RULES = { const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'], overview: ['finance', 'executive'],
approval: ['approver'], approval: ['approver'],
audit: ['auditor'], audit: ['auditor'],
employees: ['manager'], employees: ['manager'],
settings: ['manager'] settings: ['manager']
} }
function normalizedRoleCodes(user) { function normalizedRoleCodes(user) {
if (!user) { if (!user) {
return [] return []
} }
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : [] return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
} }
export function isManagerUser(user) { export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
} }
export function canAccessAppView(user, viewId) { export function canAccessAppView(user, viewId) {
if (!viewId || !user) { if (!viewId || !user) {
return false return false
} }
if (isManagerUser(user)) { if (isManagerUser(user)) {
return true return true
} }
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) { if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true return true
} }
const requiredRoles = VIEW_ROLE_RULES[viewId] || [] const requiredRoles = VIEW_ROLE_RULES[viewId] || []
const roleCodes = normalizedRoleCodes(user) const roleCodes = normalizedRoleCodes(user)
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode)) return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
} }
export function getAccessibleViewIds(user) { export function getAccessibleViewIds(user) {
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId)) return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
} }
export function filterNavItemsByAccess(navItems, user) { export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id)) return navItems.filter((item) => canAccessAppView(user, item.id))
} }
export function resolveDefaultAuthorizedRoute(user) { export function resolveDefaultAuthorizedRoute(user) {
const firstVisibleView = getAccessibleViewIds(user)[0] const firstVisibleView = getAccessibleViewIds(user)[0]
return { name: `app-${firstVisibleView || 'workbench'}` } return { name: `app-${firstVisibleView || 'workbench'}` }
} }

View File

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

View File

@@ -1,199 +1,199 @@
<template> <template>
<section class="knowledge-page"> <section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }"> <div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
<section class="knowledge-main"> <section class="knowledge-main">
<article class="library-panel panel"> <article class="library-panel panel">
<header class="panel-title"> <header class="panel-title">
<div> <div>
<h2>文档库 / 文件夹</h2> <h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p> <p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div> </div>
<label class="file-search"> <label class="file-search">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" /> <input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
</label> </label>
</header> </header>
<div class="library-body"> <div class="library-body">
<aside class="folder-rail"> <aside class="folder-rail">
<nav class="folder-tree" aria-label="知识库文件夹"> <nav class="folder-tree" aria-label="知识库文件夹">
<button <button
v-for="folder in filteredFolders" v-for="folder in filteredFolders"
:key="folder.name" :key="folder.name"
type="button" type="button"
:class="{ active: activeFolder === folder.name }" :class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name" @click="activeFolder = folder.name"
> >
<i :class="folder.icon"></i> <i :class="folder.icon"></i>
<span>{{ folder.name }}</span> <span>{{ folder.name }}</span>
<b>{{ folder.count }}</b> <b>{{ folder.count }}</b>
</button> </button>
</nav> </nav>
<button class="new-folder-btn fixed" type="button" disabled> <button class="new-folder-btn fixed" type="button" disabled>
<i class="mdi mdi-lock-outline"></i> <i class="mdi mdi-lock-outline"></i>
<span>固定文件夹</span> <span>固定文件夹</span>
</button> </button>
</aside> </aside>
<section class="document-area"> <section class="document-area">
<div <div
class="upload-zone" class="upload-zone"
:class="{ disabled: !isAdmin, busy: uploading }" :class="{ disabled: !isAdmin, busy: uploading }"
@click="triggerUpload" @click="triggerUpload"
@dragover.prevent @dragover.prevent
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
> >
<input <input
ref="uploadInput" ref="uploadInput"
class="upload-input" class="upload-input"
type="file" type="file"
multiple multiple
@change="handleFileInput" @change="handleFileInput"
/> />
<i class="mdi mdi-cloud-upload"></i> <i class="mdi mdi-cloud-upload"></i>
<strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong> <strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
<span>{{ uploadHint }}</span> <span>{{ uploadHint }}</span>
</div> </div>
<div class="doc-table-wrap"> <div class="doc-table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>文件名称</th> <th>文件名称</th>
<th>标签</th> <th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th> <th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th> <th>版本</th>
<th>状态</th> <th>状态</th>
<th>上传人</th> <th>上传人</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="doc in visibleDocuments" v-for="doc in visibleDocuments"
:key="doc.id" :key="doc.id"
class="doc-row" class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }" :class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)" @click="selectDocument(doc.id)"
> >
<td> <td>
<span class="file-name"> <span class="file-name">
<i :class="doc.icon"></i> <i :class="doc.icon"></i>
{{ doc.name }} {{ doc.name }}
</span> </span>
</td> </td>
<td> <td>
<span class="doc-tag">{{ doc.tag }}</span> <span class="doc-tag">{{ doc.tag }}</span>
</td> </td>
<td>{{ doc.time }}</td> <td>{{ doc.time }}</td>
<td>{{ doc.version }}</td> <td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td> <td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td> <td>{{ doc.owner }}</td>
<td> <td>
<div class="row-actions" @click.stop> <div class="row-actions" @click.stop>
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)"> <button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
<i class="mdi mdi-download"></i> <i class="mdi mdi-download"></i>
</button> </button>
<button <button
v-if="isAdmin" v-if="isAdmin"
class="more-btn danger" class="more-btn danger"
type="button" type="button"
:disabled="deletingId === doc.id" :disabled="deletingId === doc.id"
aria-label="删除文件" aria-label="删除文件"
@click="handleDelete(doc)" @click="handleDelete(doc)"
> >
<i class="mdi mdi-delete-outline"></i> <i class="mdi mdi-delete-outline"></i>
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="!visibleDocuments.length"> <tr v-if="!visibleDocuments.length">
<td colspan="7" class="empty-row"> <td colspan="7" class="empty-row">
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }} {{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<footer class="list-foot"> <footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span> <span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页"> <div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--"> <button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i> <i class="mdi mdi-chevron-left"></i>
</button> </button>
<button <button
v-for="page in totalPages" v-for="page in totalPages"
:key="page" :key="page"
class="page-number" class="page-number"
:class="{ active: currentPage === page }" :class="{ active: currentPage === page }"
type="button" type="button"
:aria-current="currentPage === page ? 'page' : undefined" :aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page" @click="currentPage = page"
> >
{{ page }} {{ page }}
</button> </button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++"> <button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i> <i class="mdi mdi-chevron-right"></i>
</button> </button>
</div> </div>
<div class="page-size-wrap"> <div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen"> <button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} /<i class="mdi mdi-chevron-down"></i> {{ pageSize }} /<i class="mdi mdi-chevron-down"></i>
</button> </button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox"> <div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button <button
v-for="size in pageSizes" v-for="size in pageSizes"
:key="size" :key="size"
type="button" type="button"
role="option" role="option"
:aria-selected="pageSize === size" :aria-selected="pageSize === size"
:class="{ active: pageSize === size }" :class="{ active: pageSize === size }"
@click="changePageSize(size)" @click="changePageSize(size)"
> >
{{ size }} / {{ size }} /
</button> </button>
</div> </div>
</div> </div>
</footer> </footer>
</section> </section>
</div> </div>
</article> </article>
</section> </section>
<Transition name="preview-panel"> <Transition name="preview-panel">
<aside v-if="selectedDocument" class="preview-column"> <aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel"> <article class="preview-panel panel">
<header class="preview-head"> <header class="preview-head">
<div class="preview-copy"> <div class="preview-copy">
<h2>{{ selectedDocument.name }}</h2> <h2>{{ selectedDocument.name }}</h2>
<p class="preview-summary-line"> <p class="preview-summary-line">
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span> <span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
</p> </p>
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line"> <div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span> <span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
</div> </div>
</div> </div>
<div class="preview-actions"> <div class="preview-actions">
<button type="button" class="mini-action" @click="handleDownload(selectedDocument)"> <button type="button" class="mini-action" @click="handleDownload(selectedDocument)">
<i class="mdi mdi-download"></i> <i class="mdi mdi-download"></i>
<span>下载</span> <span>下载</span>
</button> </button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview"> <button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
</button> </button>
</div> </div>
</header> </header>
<div class="preview-viewer"> <div class="preview-viewer">
<div v-if="previewLoading" class="preview-status">正在加载预览...</div> <div v-if="previewLoading" class="preview-status">正在加载预览...</div>
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div> <div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
<div v-else-if="selectedDocument.previewKind === 'pdf' && previewBlobUrl" class="preview-embed-wrap"> <div v-else-if="previewMode === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe> <iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
</div> </div>
<div v-else-if="selectedDocument.previewKind === 'image' && previewBlobUrl" class="preview-image-wrap"> <div v-else-if="previewMode === 'image' && previewBlobUrl" class="preview-image-wrap">
<img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" /> <img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" />
</div> </div>
<div v-else-if="shouldUseOnlyOffice" class="onlyoffice-preview-wrap"> <div v-else-if="shouldUseOnlyOffice" class="onlyoffice-preview-wrap">
@@ -201,64 +201,64 @@
<div v-else-if="onlyOfficeError" class="preview-status error">{{ onlyOfficeError }}</div> <div v-else-if="onlyOfficeError" class="preview-status error">{{ onlyOfficeError }}</div>
<div v-else :id="onlyOfficeHostId" class="onlyoffice-preview-host"></div> <div v-else :id="onlyOfficeHostId" class="onlyoffice-preview-host"></div>
</div> </div>
<div v-else-if="selectedDocument.previewKind === 'table'" class="excel-preview-wrap"> <div v-else-if="previewMode === 'table'" class="excel-preview-wrap">
<div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签"> <div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签">
<button <button
v-for="(page, index) in selectedDocument.previewPages" v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.id}-sheet-${index}`" :key="`${selectedDocument.id}-sheet-${index}`"
type="button" type="button"
class="excel-sheet-tab" class="excel-sheet-tab"
:class="{ active: currentPreviewPageIndex === index }" :class="{ active: currentPreviewPageIndex === index }"
:aria-selected="currentPreviewPageIndex === index" :aria-selected="currentPreviewPageIndex === index"
@click="selectPreviewPage(index)" @click="selectPreviewPage(index)"
> >
{{ page.title }} {{ page.title }}
</button> </button>
</div> </div>
<div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll"> <div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll">
<table class="excel-preview-table"> <table class="excel-preview-table">
<thead> <thead>
<tr> <tr>
<th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th> <th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`"> <tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td> <td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-else class="preview-status">当前表格暂未提取到可展示内容</div> <div v-else class="preview-status">当前表格暂未提取到可展示内容</div>
</div> </div>
<div v-else class="page-stage"> <div v-else class="page-stage">
<article <article
v-for="(page, index) in selectedDocument.previewPages" v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.id}-${index}`" :key="`${selectedDocument.id}-${index}`"
class="page-sheet" class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }" :style="{ '--page-delay': `${index * 70}ms` }"
> >
<section class="page-content"> <section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block"> <div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3> <h3>{{ block.heading }}</h3>
<ul> <ul>
<li v-for="line in block.lines" :key="line">{{ line }}</li> <li v-for="line in block.lines" :key="line">{{ line }}</li>
</ul> </ul>
</div> </div>
</section> </section>
</article> </article>
<div v-if="!selectedDocument.previewPages.length" class="preview-status"> <div v-if="!selectedDocument.previewPages.length" class="preview-status">
当前文件暂未生成结构化预览请下载后查看 当前文件暂未生成结构化预览请下载后查看
</div> </div>
</div> </div>
</div> </div>
</article> </article>
</aside> </aside>
</Transition> </Transition>
</div> </div>
</section> </section>
</template> </template>
<script src="./scripts/PoliciesView.js"></script> <script src="./scripts/PoliciesView.js"></script>
<style scoped src="../assets/styles/views/policies-view.css"></style> <style scoped src="../assets/styles/views/policies-view.css"></style>

View File

@@ -1,177 +1,183 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
import { import {
deleteKnowledgeDocument, deleteKnowledgeDocument,
fetchKnowledgeDocument, fetchKnowledgeDocument,
fetchKnowledgeDocumentBlob, fetchKnowledgeDocumentBlob,
fetchKnowledgeLibrary, fetchKnowledgeLibrary,
fetchKnowledgeOnlyOfficeConfig, fetchKnowledgeOnlyOfficeConfig,
uploadKnowledgeDocument uploadKnowledgeDocument
} from '../../services/knowledge.js' } from '../../services/knowledge.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js' import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isManagerUser } from '../../utils/accessControl.js' import { isManagerUser } from '../../utils/accessControl.js'
import { import {
buildExcelPreviewTable, buildExcelPreviewTable,
buildPreviewMetaLine, buildPreviewMetaLine,
buildPreviewSecondaryMetaLine buildPreviewSecondaryMetaLine
} from './policiesPreviewFormatters.js' } from './policiesPreviewFormatters.js'
import { canUseOnlyOfficePreview, resolveKnowledgePreviewMode } from './knowledgePreviewMode.js'
function triggerFileDownload(blob, filename) { function triggerFileDownload(blob, filename) {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const anchor = document.createElement('a') const anchor = document.createElement('a')
anchor.href = url anchor.href = url
anchor.download = filename anchor.download = filename
anchor.click() anchor.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx']) export default {
name: 'PoliciesView',
function supportsOnlyOfficePreview(document) { emits: ['summary-change'],
return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase()) setup(_, { emit }) {
} const { currentUser } = useSystemState()
const { toast } = useToast()
export default {
name: 'PoliciesView', const documentSearch = ref('')
emits: ['summary-change'], const activeFolder = ref('差旅规范')
setup(_, { emit }) { const folders = ref([])
const { currentUser } = useSystemState() const documents = ref([])
const { toast } = useToast() const selectedDocument = ref(null)
const pageSizeOpen = ref(false)
const documentSearch = ref('') const currentPage = ref(1)
const activeFolder = ref('差旅规范') const pageSize = ref(10)
const folders = ref([]) const pageSizes = [10, 20, 50]
const documents = ref([]) const loading = ref(false)
const selectedDocument = ref(null) const uploadInput = ref(null)
const pageSizeOpen = ref(false) const uploading = ref(false)
const currentPage = ref(1) const deletingId = ref('')
const pageSize = ref(10) const previewLoading = ref(false)
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 previewBlobUrl = ref('')
const previewError = ref('') const previewError = ref('')
const onlyOfficeLoading = ref(false) const onlyOfficeLoading = ref(false)
const onlyOfficeError = ref('') const onlyOfficeError = ref('')
const onlyOfficeAvailable = ref(false)
const onlyOfficeEditor = ref(null) const onlyOfficeEditor = ref(null)
const onlyOfficeHostId = ref('knowledge-onlyoffice-preview') const onlyOfficeHostId = ref('knowledge-onlyoffice-preview')
const currentPreviewPageIndex = ref(0) const currentPreviewPageIndex = ref(0)
const isAdmin = computed(() => isManagerUser(currentUser.value)) const isAdmin = computed(() => isManagerUser(currentUser.value))
const uploadHint = computed(() => const uploadHint = computed(() =>
isAdmin.value isAdmin.value
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本' ? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用' : '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
) )
const filteredFolders = computed(() => folders.value) const filteredFolders = computed(() => folders.value)
const filteredDocuments = computed(() => { const filteredDocuments = computed(() => {
const key = documentSearch.value.trim() const key = documentSearch.value.trim()
return documents.value.filter((doc) => { return documents.value.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
const matchesSearch = key ? doc.name.includes(key) : true const matchesSearch = key ? doc.name.includes(key) : true
return inFolder && matchesSearch 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(() => const excelPreviewTable = computed(() =>
selectedDocument.value?.previewKind === 'table' selectedDocument.value?.previewKind === 'table'
? buildExcelPreviewTable(activePreviewPage.value) ? buildExcelPreviewTable(activePreviewPage.value)
: { headers: [], rows: [] } : { headers: [], rows: [] }
) )
function revokePreviewBlob() { function revokePreviewBlob() {
if (previewBlobUrl.value) { if (previewBlobUrl.value) {
URL.revokeObjectURL(previewBlobUrl.value) URL.revokeObjectURL(previewBlobUrl.value)
previewBlobUrl.value = '' previewBlobUrl.value = ''
} }
} }
function destroyOnlyOfficeEditor() { function destroyOnlyOfficeEditor() {
if (onlyOfficeEditor.value?.destroyEditor) { if (onlyOfficeEditor.value?.destroyEditor) {
onlyOfficeEditor.value.destroyEditor() onlyOfficeEditor.value.destroyEditor()
} }
onlyOfficeEditor.value = null onlyOfficeEditor.value = null
} }
async function mountOnlyOfficeEditor(documentId) { async function mountOnlyOfficeEditor(documentId) {
onlyOfficeLoading.value = true onlyOfficeLoading.value = true
onlyOfficeError.value = '' onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
destroyOnlyOfficeEditor() destroyOnlyOfficeEditor()
try { try {
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId) const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
await loadOnlyOfficeApi(payload.documentServerUrl) await loadOnlyOfficeApi(payload.documentServerUrl)
await nextTick() await nextTick()
if (!window.DocsAPI?.DocEditor) { if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。') throw new Error('ONLYOFFICE 编辑器未正确加载。')
} }
onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}` onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}`
await nextTick() await nextTick()
onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config) onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config)
onlyOfficeAvailable.value = true
return true
} catch (error) { } catch (error) {
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。' onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
return false
} finally { } finally {
onlyOfficeLoading.value = false onlyOfficeLoading.value = false
} }
} }
async function loadLibrary(options = {}) { async function loadLibrary(options = {}) {
loading.value = true loading.value = true
try { try {
const payload = await fetchKnowledgeLibrary() const payload = await fetchKnowledgeLibrary()
folders.value = payload.folders || [] folders.value = payload.folders || []
documents.value = payload.documents || [] documents.value = payload.documents || []
emit('summary-change', { totalDocuments: documents.value.length }) emit('summary-change', { totalDocuments: documents.value.length })
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value) const activeExists = folders.value.some((folder) => folder.name === activeFolder.value)
if (!activeExists) { if (!activeExists) {
activeFolder.value = folders.value[0]?.name || '' activeFolder.value = folders.value[0]?.name || ''
} }
if (options.preserveSelection && selectedDocument.value?.id) { if (options.preserveSelection && selectedDocument.value?.id) {
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id) const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)
if (!exists) { if (!exists) {
selectedDocument.value = null selectedDocument.value = null
revokePreviewBlob() revokePreviewBlob()
} }
} }
} catch (error) { } catch (error) {
emit('summary-change', { totalDocuments: 0 }) emit('summary-change', { totalDocuments: 0 })
toast(error.message || '知识库加载失败。') toast(error.message || '知识库加载失败。')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function selectDocument(documentId) { async function selectDocument(documentId) {
previewLoading.value = true previewLoading.value = true
previewError.value = '' previewError.value = ''
onlyOfficeError.value = '' onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
revokePreviewBlob() revokePreviewBlob()
destroyOnlyOfficeEditor() destroyOnlyOfficeEditor()
@@ -180,182 +186,187 @@ export default {
selectedDocument.value = payload selectedDocument.value = payload
currentPreviewPageIndex.value = 0 currentPreviewPageIndex.value = 0
if (supportsOnlyOfficePreview(payload)) { if (canUseOnlyOfficePreview(payload)) {
await mountOnlyOfficeEditor(documentId) await mountOnlyOfficeEditor(documentId)
} else if (payload.previewKind === 'pdf' || payload.previewKind === 'image') { }
if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline') const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline')
previewBlobUrl.value = URL.createObjectURL(blob) previewBlobUrl.value = URL.createObjectURL(blob)
} }
} catch (error) { } catch (error) {
previewError.value = error.message || '预览加载失败。' previewError.value = error.message || '预览加载失败。'
toast(previewError.value) toast(previewError.value)
} finally { } finally {
previewLoading.value = false previewLoading.value = false
} }
} }
async function handleDownload(document) { async function handleDownload(document) {
try { try {
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment') const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
triggerFileDownload(blob, document.name) triggerFileDownload(blob, document.name)
} catch (error) { } catch (error) {
toast(error.message || '下载失败。') toast(error.message || '下载失败。')
} }
} }
function triggerUpload() { function triggerUpload() {
if (!isAdmin.value || uploading.value) { if (!isAdmin.value || uploading.value) {
return return
} }
uploadInput.value?.click() uploadInput.value?.click()
} }
async function uploadFiles(fileList) { async function uploadFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean) const files = Array.from(fileList || []).filter(Boolean)
if (!files.length || !activeFolder.value || !isAdmin.value) { if (!files.length || !activeFolder.value || !isAdmin.value) {
return return
} }
uploading.value = true uploading.value = true
try { try {
let latestDocumentId = '' let latestDocumentId = ''
for (const file of files) { for (const file of files) {
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file }) const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
latestDocumentId = payload.id latestDocumentId = payload.id
} }
await loadLibrary({ preserveSelection: true }) await loadLibrary({ preserveSelection: true })
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。') toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
if (latestDocumentId) { if (latestDocumentId) {
await selectDocument(latestDocumentId) await selectDocument(latestDocumentId)
} }
} catch (error) { } catch (error) {
toast(error.message || '上传失败。') toast(error.message || '上传失败。')
} finally { } finally {
uploading.value = false uploading.value = false
if (uploadInput.value) { if (uploadInput.value) {
uploadInput.value.value = '' uploadInput.value.value = ''
} }
} }
} }
async function handleFileInput(event) { async function handleFileInput(event) {
await uploadFiles(event.target.files) await uploadFiles(event.target.files)
} }
async function handleDrop(event) { async function handleDrop(event) {
if (!isAdmin.value) { if (!isAdmin.value) {
return return
} }
await uploadFiles(event.dataTransfer?.files) await uploadFiles(event.dataTransfer?.files)
} }
async function handleDelete(document) { async function handleDelete(document) {
if (!isAdmin.value || deletingId.value) { if (!isAdmin.value || deletingId.value) {
return return
} }
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`) const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
if (!confirmed) { if (!confirmed) {
return return
} }
deletingId.value = document.id deletingId.value = document.id
try { try {
await deleteKnowledgeDocument(document.id) await deleteKnowledgeDocument(document.id)
if (selectedDocument.value?.id === document.id) { if (selectedDocument.value?.id === document.id) {
selectedDocument.value = null selectedDocument.value = null
revokePreviewBlob() revokePreviewBlob()
} }
await loadLibrary() await loadLibrary()
toast('知识库文件已删除。') toast('知识库文件已删除。')
} catch (error) { } catch (error) {
toast(error.message || '删除失败。') toast(error.message || '删除失败。')
} finally { } finally {
deletingId.value = '' deletingId.value = ''
} }
} }
function changePageSize(size) { function changePageSize(size) {
pageSize.value = size pageSize.value = size
pageSizeOpen.value = false pageSizeOpen.value = false
currentPage.value = 1 currentPage.value = 1
} }
function closePreview() { function closePreview() {
selectedDocument.value = null selectedDocument.value = null
previewError.value = '' previewError.value = ''
currentPreviewPageIndex.value = 0 currentPreviewPageIndex.value = 0
revokePreviewBlob() revokePreviewBlob()
destroyOnlyOfficeEditor() destroyOnlyOfficeEditor()
onlyOfficeError.value = '' onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
} }
function selectPreviewPage(index) { function selectPreviewPage(index) {
currentPreviewPageIndex.value = index currentPreviewPageIndex.value = index
} }
watch(filteredDocuments, () => { watch(filteredDocuments, () => {
currentPage.value = 1 currentPage.value = 1
pageSizeOpen.value = false pageSizeOpen.value = false
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) { if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
closePreview() closePreview()
} }
}) })
watch(activeFolder, () => { watch(activeFolder, () => {
closePreview() closePreview()
}) })
onMounted(() => { onMounted(() => {
loadLibrary() loadLibrary()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
revokePreviewBlob() revokePreviewBlob()
destroyOnlyOfficeEditor()
}) })
return { return {
activeFolder, activeFolder,
activePreviewPage, activePreviewPage,
changePageSize, changePageSize,
closePreview, closePreview,
excelPreviewTable, excelPreviewTable,
currentPage, currentPage,
currentPreviewPageIndex, currentPreviewPageIndex,
deletingId, deletingId,
documentSearch, documentSearch,
filteredFolders, filteredFolders,
handleDelete, handleDelete,
handleDownload, handleDownload,
handleDrop, handleDrop,
handleFileInput, handleFileInput,
isAdmin, isAdmin,
loading, loading,
pageSize, pageSize,
pageSizeOpen, pageSizeOpen,
pageSizes, pageSizes,
onlyOfficeError, onlyOfficeError,
onlyOfficeHostId, onlyOfficeHostId,
onlyOfficeLoading, onlyOfficeLoading,
previewMode,
previewMetaLine, previewMetaLine,
previewSecondaryMetaLine, previewSecondaryMetaLine,
previewBlobUrl, previewBlobUrl,
previewError, previewError,
previewLoading, previewLoading,
shouldUseOnlyOffice, shouldUseOnlyOffice,
selectDocument, selectDocument,
selectPreviewPage, selectPreviewPage,
selectedDocument, selectedDocument,
totalCount, totalCount,
totalPages, totalPages,
triggerUpload, triggerUpload,
uploadHint, uploadHint,
uploadInput, uploadInput,
uploading, uploading,
visibleDocuments visibleDocuments
} }
} }
} }

View File

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

View File

@@ -1,65 +1,65 @@
function splitPreviewRow(line) { function splitPreviewRow(line) {
return String(line || '') return String(line || '')
.split('|') .split('|')
.map((cell) => cell.trim()) .map((cell) => cell.trim())
} }
export function buildPreviewMetaLine(document) { export function buildPreviewMetaLine(document) {
if (!document) { if (!document) {
return [] return []
} }
return [document.summary, document.time].filter(Boolean) return [document.summary, document.time].filter(Boolean)
} }
export function buildPreviewSecondaryMetaLine(document, page = null) { export function buildPreviewSecondaryMetaLine(document, page = null) {
if (!document) { if (!document) {
return [] return []
} }
const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null) const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null)
if (!activePage) { if (!activePage) {
return [] return []
} }
const parts = [] const parts = []
if (activePage.subtitle) { if (activePage.subtitle) {
parts.push(activePage.subtitle) parts.push(activePage.subtitle)
} }
if (document.previewKind === 'table') { if (document.previewKind === 'table') {
for (const item of activePage.stats || []) { for (const item of activePage.stats || []) {
if (!item?.label || !item?.value || item.label === '文件大小') { if (!item?.label || !item?.value || item.label === '文件大小') {
continue continue
} }
parts.push(`${item.label} ${item.value}`) parts.push(`${item.label} ${item.value}`)
} }
} }
return parts return parts
} }
export function buildExcelPreviewTable(page) { export function buildExcelPreviewTable(page) {
const rawRows = (page?.blocks || []) const rawRows = (page?.blocks || [])
.flatMap((block) => block.lines || []) .flatMap((block) => block.lines || [])
.map(splitPreviewRow) .map(splitPreviewRow)
.filter((row) => row.length > 0 && row.some((cell) => cell !== '')) .filter((row) => row.length > 0 && row.some((cell) => cell !== ''))
if (!rawRows.length) { if (!rawRows.length) {
return { headers: [], rows: [] } return { headers: [], rows: [] }
} }
const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0) const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0)
const normalizedRows = rawRows.map((row) => const normalizedRows = rawRows.map((row) =>
Array.from({ length: columnCount }, (_, index) => row[index] ?? '') Array.from({ length: columnCount }, (_, index) => row[index] ?? '')
) )
const [headerRow, ...bodyRows] = normalizedRows const [headerRow, ...bodyRows] = normalizedRows
const headers = headerRow.map((cell, index) => cell || `${index + 1}`) const headers = headerRow.map((cell, index) => cell || `${index + 1}`)
return { return {
headers, headers,
rows: bodyRows rows: bodyRows
} }
} }

View File

@@ -1,99 +1,99 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { apiRequest } from '../src/services/api.js' import { apiRequest } from '../src/services/api.js'
async function testUsesCustomContentTypeHeader() { async function testUsesCustomContentTypeHeader() {
let capturedOptions = null let capturedOptions = null
global.fetch = async (_url, options) => { global.fetch = async (_url, options) => {
capturedOptions = options capturedOptions = options
return { return {
ok: true, ok: true,
async json() { async json() {
return { ok: true } return { ok: true }
} }
} }
} }
await apiRequest('/knowledge/documents', { await apiRequest('/knowledge/documents', {
method: 'POST', method: 'POST',
body: 'payload', body: 'payload',
contentType: 'application/octet-stream' contentType: 'application/octet-stream'
}) })
assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream') assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream')
} }
async function testSupportsBlobResponses() { async function testSupportsBlobResponses() {
const blob = new Blob(['preview']) const blob = new Blob(['preview'])
global.fetch = async () => ({ global.fetch = async () => ({
ok: true, ok: true,
async blob() { async blob() {
return blob return blob
}, },
async json() { async json() {
throw new Error('json parser should not be used for blob responses') throw new Error('json parser should not be used for blob responses')
} }
}) })
const payload = await apiRequest('/knowledge/documents/demo/content', { const payload = await apiRequest('/knowledge/documents/demo/content', {
responseType: 'blob', responseType: 'blob',
contentType: null contentType: null
}) })
assert.equal(payload, blob) assert.equal(payload, blob)
} }
async function testInjectsAuthenticatedUserHeaders() { async function testInjectsAuthenticatedUserHeaders() {
const sessionStorage = new Map([ const sessionStorage = new Map([
[ [
'x-financial-auth-user', 'x-financial-auth-user',
JSON.stringify({ JSON.stringify({
username: 'admin', username: 'admin',
name: '系统管理员', name: '系统管理员',
roleCodes: ['manager'], roleCodes: ['manager'],
isAdmin: true isAdmin: true
}) })
] ]
]) ])
global.window = { global.window = {
sessionStorage: { sessionStorage: {
getItem(key) { getItem(key) {
return sessionStorage.get(key) ?? null return sessionStorage.get(key) ?? null
} }
} }
} }
let capturedOptions = null let capturedOptions = null
global.fetch = async (_url, options) => { global.fetch = async (_url, options) => {
capturedOptions = options capturedOptions = options
return { return {
ok: true, ok: true,
async json() { async json() {
return { ok: true } return { ok: true }
} }
} }
} }
await apiRequest('/knowledge/library') await apiRequest('/knowledge/library')
assert.equal(capturedOptions.headers['x-auth-username'], 'admin') assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员') assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员')
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager') assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true') assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
} }
async function run() { async function run() {
await testUsesCustomContentTypeHeader() await testUsesCustomContentTypeHeader()
await testSupportsBlobResponses() await testSupportsBlobResponses()
await testInjectsAuthenticatedUserHeaders() await testInjectsAuthenticatedUserHeaders()
console.log('api-request tests passed') console.log('api-request tests passed')
} }
run().catch((error) => { run().catch((error) => {
console.error(error) console.error(error)
process.exit(1) process.exit(1)
}) })

View File

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

View File

@@ -1,13 +1,13 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js' import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js'
function run() { function run() {
assert.equal( assert.equal(
buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'), buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'),
'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js' 'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js'
) )
console.log('onlyoffice service tests passed') console.log('onlyoffice service tests passed')
} }
run() run()

View File

@@ -1,69 +1,69 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { import {
buildExcelPreviewTable, buildExcelPreviewTable,
buildPreviewMetaLine, buildPreviewMetaLine,
buildPreviewSecondaryMetaLine buildPreviewSecondaryMetaLine
} from '../src/views/scripts/policiesPreviewFormatters.js' } from '../src/views/scripts/policiesPreviewFormatters.js'
function testBuildPreviewMetaLineUsesRealDocumentFields() { function testBuildPreviewMetaLineUsesRealDocumentFields() {
const document = { const document = {
summary: '财务知识库 · XLSX · 10.9 KB', summary: '财务知识库 · XLSX · 10.9 KB',
time: '2026-05-09 12:30' time: '2026-05-09 12:30'
} }
assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30']) assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30'])
} }
function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() { function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() {
const document = { const document = {
previewKind: 'table', previewKind: 'table',
previewPages: [ previewPages: [
{ {
subtitle: '表格内容预览', subtitle: '表格内容预览',
stats: [ stats: [
{ label: '工作表数量', value: '4' }, { label: '工作表数量', value: '4' },
{ label: '预览行数', value: '7' }, { label: '预览行数', value: '7' },
{ label: '文件大小', value: '10.9 KB' } { label: '文件大小', value: '10.9 KB' }
] ]
}, },
{ {
subtitle: '第二页签预览', subtitle: '第二页签预览',
stats: [ stats: [
{ label: '工作表数量', value: '4' }, { label: '工作表数量', value: '4' },
{ label: '预览行数', value: '3' } { label: '预览行数', value: '3' }
] ]
} }
] ]
} }
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7']) assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7'])
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3']) assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3'])
} }
function testBuildExcelPreviewTableParsesHeaderAndRows() { function testBuildExcelPreviewTableParsesHeaderAndRows() {
const page = { const page = {
blocks: [ blocks: [
{ heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] }, { heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] },
{ heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] }, { heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] },
{ heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] } { heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] }
] ]
} }
assert.deepEqual(buildExcelPreviewTable(page), { assert.deepEqual(buildExcelPreviewTable(page), {
headers: ['日期', '部门', '金额', '备注'], headers: ['日期', '部门', '金额', '备注'],
rows: [ rows: [
['2026-05-01', '财务部', '300', '差旅'], ['2026-05-01', '财务部', '300', '差旅'],
['2026-05-02', '行政部', '120', ''] ['2026-05-02', '行政部', '120', '']
] ]
}) })
} }
function run() { function run() {
testBuildPreviewMetaLineUsesRealDocumentFields() testBuildPreviewMetaLineUsesRealDocumentFields()
testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats()
testBuildExcelPreviewTableParsesHeaderAndRows() testBuildExcelPreviewTableParsesHeaderAndRows()
console.log('policies preview formatter tests passed') console.log('policies preview formatter tests passed')
} }
run() run()

File diff suppressed because it is too large Load Diff