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

View File

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

View File

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

View File

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

View File

@@ -90,6 +90,10 @@ fi
ENV_OVERRIDE_SERVER_HOST_SET=false
ENV_OVERRIDE_POSTGRES_HOST_SET=false
ENV_OVERRIDE_DATABASE_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=false
if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST_SET=true
@@ -106,6 +110,26 @@ if [ "${DATABASE_URL+x}" = x ]; then
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
fi
if [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
fi
if [ "${ONLYOFFICE_PUBLIC_URL+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=true
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL="$ONLYOFFICE_PUBLIC_URL"
fi
if [ "${ONLYOFFICE_BACKEND_URL+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=true
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL="$ONLYOFFICE_BACKEND_URL"
fi
if [ "${ONLYOFFICE_JWT_SECRET+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=true
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET="$ONLYOFFICE_JWT_SECRET"
fi
set -a
. "$ROOT_ENV_FILE"
set +a
@@ -122,6 +146,22 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET" = true ]; then
ONLYOFFICE_PUBLIC_URL="$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET" = true ]; then
ONLYOFFICE_BACKEND_URL="$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then
ONLYOFFICE_JWT_SECRET="$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET"
fi
SERVER_HOST="${SERVER_HOST:-0.0.0.0}"
SERVER_PORT="${SERVER_PORT:-8000}"
DEFAULT_SERVER_RELOAD="false"
@@ -189,7 +229,7 @@ run_bootstrap_python() {
}
dependencies_ready() {
"$PYTHON_BIN" -c "import fastapi, uvicorn, sqlalchemy, alembic, pydantic_settings" >/dev/null 2>&1
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, psycopg, pydantic_settings, sqlalchemy, uvicorn" >/dev/null 2>&1
}
pip_ready() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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_SERVER_HOST_SET=false
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=false
if [ "${WEB_HOST+x}" = x ]; then
ENV_OVERRIDE_WEB_HOST_SET=true
@@ -49,6 +53,26 @@ if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
fi
if [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
fi
if [ "${ONLYOFFICE_PUBLIC_URL+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=true
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL="$ONLYOFFICE_PUBLIC_URL"
fi
if [ "${ONLYOFFICE_BACKEND_URL+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=true
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL="$ONLYOFFICE_BACKEND_URL"
fi
if [ "${ONLYOFFICE_JWT_SECRET+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=true
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET="$ONLYOFFICE_JWT_SECRET"
fi
set -a
. "$ENV_FILE"
set +a
@@ -61,6 +85,22 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET" = true ]; then
ONLYOFFICE_PUBLIC_URL="$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET" = true ]; then
ONLYOFFICE_BACKEND_URL="$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then
ONLYOFFICE_JWT_SECRET="$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET"
fi
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
APP_DEBUG="${APP_DEBUG:-true}"

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,199 +1,199 @@
<template>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div>
<label class="file-search">
<i class="mdi mdi-magnify"></i>
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
</label>
</header>
<div class="library-body">
<aside class="folder-rail">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<button class="new-folder-btn fixed" type="button" disabled>
<i class="mdi mdi-lock-outline"></i>
<span>固定文件夹</span>
</button>
</aside>
<section class="document-area">
<div
class="upload-zone"
:class="{ disabled: !isAdmin, busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<table>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
<td>
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td>
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td>
<td>
<div class="row-actions" @click.stop>
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
<i class="mdi mdi-download"></i>
</button>
<button
v-if="isAdmin"
class="more-btn danger"
type="button"
:disabled="deletingId === doc.id"
aria-label="删除文件"
@click="handleDelete(doc)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
</div>
</td>
</tr>
<tr v-if="!visibleDocuments.length">
<td colspan="7" class="empty-row">
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
</td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} /<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</section>
</div>
</article>
</section>
<Transition name="preview-panel">
<aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel">
<header class="preview-head">
<div class="preview-copy">
<h2>{{ selectedDocument.name }}</h2>
<p class="preview-summary-line">
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
</p>
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
</div>
</div>
<div class="preview-actions">
<button type="button" class="mini-action" @click="handleDownload(selectedDocument)">
<i class="mdi mdi-download"></i>
<span>下载</span>
</button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-viewer">
<div v-if="previewLoading" class="preview-status">正在加载预览...</div>
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
<div v-else-if="selectedDocument.previewKind === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
<template>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div>
<label class="file-search">
<i class="mdi mdi-magnify"></i>
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
</label>
</header>
<div class="library-body">
<aside class="folder-rail">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<button class="new-folder-btn fixed" type="button" disabled>
<i class="mdi mdi-lock-outline"></i>
<span>固定文件夹</span>
</button>
</aside>
<section class="document-area">
<div
class="upload-zone"
:class="{ disabled: !isAdmin, busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<table>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
<td>
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td>
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td>
<td>
<div class="row-actions" @click.stop>
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
<i class="mdi mdi-download"></i>
</button>
<button
v-if="isAdmin"
class="more-btn danger"
type="button"
:disabled="deletingId === doc.id"
aria-label="删除文件"
@click="handleDelete(doc)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
</div>
</td>
</tr>
<tr v-if="!visibleDocuments.length">
<td colspan="7" class="empty-row">
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
</td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} /<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</section>
</div>
</article>
</section>
<Transition name="preview-panel">
<aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel">
<header class="preview-head">
<div class="preview-copy">
<h2>{{ selectedDocument.name }}</h2>
<p class="preview-summary-line">
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
</p>
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
</div>
</div>
<div class="preview-actions">
<button type="button" class="mini-action" @click="handleDownload(selectedDocument)">
<i class="mdi mdi-download"></i>
<span>下载</span>
</button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-viewer">
<div v-if="previewLoading" class="preview-status">正在加载预览...</div>
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
<div v-else-if="previewMode === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
</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" />
</div>
<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 :id="onlyOfficeHostId" class="onlyoffice-preview-host"></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 工作表页签">
<button
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.id}-sheet-${index}`"
type="button"
class="excel-sheet-tab"
:class="{ active: currentPreviewPageIndex === index }"
:aria-selected="currentPreviewPageIndex === index"
@click="selectPreviewPage(index)"
>
{{ page.title }}
</button>
</div>
<div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll">
<table class="excel-preview-table">
<thead>
<tr>
<th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="preview-status">当前表格暂未提取到可展示内容</div>
</div>
<div v-else class="page-stage">
<article
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.id}-${index}`"
class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }"
>
<section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3>
<ul>
<li v-for="line in block.lines" :key="line">{{ line }}</li>
</ul>
</div>
</section>
</article>
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
当前文件暂未生成结构化预览请下载后查看
</div>
</div>
</div>
</article>
</aside>
</Transition>
</div>
</section>
</template>
<script src="./scripts/PoliciesView.js"></script>
<style scoped src="../assets/styles/views/policies-view.css"></style>
:key="`${selectedDocument.id}-sheet-${index}`"
type="button"
class="excel-sheet-tab"
:class="{ active: currentPreviewPageIndex === index }"
:aria-selected="currentPreviewPageIndex === index"
@click="selectPreviewPage(index)"
>
{{ page.title }}
</button>
</div>
<div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll">
<table class="excel-preview-table">
<thead>
<tr>
<th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="preview-status">当前表格暂未提取到可展示内容</div>
</div>
<div v-else class="page-stage">
<article
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.id}-${index}`"
class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }"
>
<section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3>
<ul>
<li v-for="line in block.lines" :key="line">{{ line }}</li>
</ul>
</div>
</section>
</article>
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
当前文件暂未生成结构化预览请下载后查看
</div>
</div>
</div>
</article>
</aside>
</Transition>
</div>
</section>
</template>
<script src="./scripts/PoliciesView.js"></script>
<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 { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
deleteKnowledgeDocument,
fetchKnowledgeDocument,
fetchKnowledgeDocumentBlob,
fetchKnowledgeLibrary,
fetchKnowledgeOnlyOfficeConfig,
uploadKnowledgeDocument
} from '../../services/knowledge.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isManagerUser } from '../../utils/accessControl.js'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
deleteKnowledgeDocument,
fetchKnowledgeDocument,
fetchKnowledgeDocumentBlob,
fetchKnowledgeLibrary,
fetchKnowledgeOnlyOfficeConfig,
uploadKnowledgeDocument
} from '../../services/knowledge.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isManagerUser } from '../../utils/accessControl.js'
import {
buildExcelPreviewTable,
buildPreviewMetaLine,
buildPreviewSecondaryMetaLine
} from './policiesPreviewFormatters.js'
import { canUseOnlyOfficePreview, resolveKnowledgePreviewMode } from './knowledgePreviewMode.js'
function triggerFileDownload(blob, filename) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx'])
function supportsOnlyOfficePreview(document) {
return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase())
}
export default {
name: 'PoliciesView',
emits: ['summary-change'],
setup(_, { emit }) {
const { currentUser } = useSystemState()
const { toast } = useToast()
const documentSearch = ref('')
const activeFolder = ref('差旅规范')
const folders = ref([])
const documents = ref([])
const selectedDocument = ref(null)
const pageSizeOpen = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const loading = ref(false)
const uploadInput = ref(null)
const uploading = ref(false)
const deletingId = ref('')
const previewLoading = ref(false)
export default {
name: 'PoliciesView',
emits: ['summary-change'],
setup(_, { emit }) {
const { currentUser } = useSystemState()
const { toast } = useToast()
const documentSearch = ref('')
const activeFolder = ref('差旅规范')
const folders = ref([])
const documents = ref([])
const selectedDocument = ref(null)
const pageSizeOpen = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const loading = ref(false)
const uploadInput = ref(null)
const uploading = ref(false)
const deletingId = ref('')
const previewLoading = ref(false)
const previewBlobUrl = ref('')
const previewError = ref('')
const onlyOfficeLoading = ref(false)
const onlyOfficeError = ref('')
const onlyOfficeAvailable = ref(false)
const onlyOfficeEditor = ref(null)
const onlyOfficeHostId = ref('knowledge-onlyoffice-preview')
const currentPreviewPageIndex = ref(0)
const isAdmin = computed(() => isManagerUser(currentUser.value))
const uploadHint = computed(() =>
isAdmin.value
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
)
const filteredFolders = computed(() => folders.value)
const filteredDocuments = computed(() => {
const key = documentSearch.value.trim()
return documents.value.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
const matchesSearch = key ? doc.name.includes(key) : true
return inFolder && matchesSearch
const isAdmin = computed(() => isManagerUser(currentUser.value))
const uploadHint = computed(() =>
isAdmin.value
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
)
const filteredFolders = computed(() => folders.value)
const filteredDocuments = computed(() => {
const key = documentSearch.value.trim()
return documents.value.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
const matchesSearch = key ? doc.name.includes(key) : true
return inFolder && matchesSearch
})
})
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleDocuments = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDocuments.value.slice(start, start + pageSize.value)
})
const activePreviewPage = computed(() => {
const pages = selectedDocument.value?.previewPages || []
return pages[currentPreviewPageIndex.value] || pages[0] || null
})
const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value))
const previewSecondaryMetaLine = computed(() =>
buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value)
)
const previewMode = computed(() =>
resolveKnowledgePreviewMode(selectedDocument.value, {
onlyOfficeAvailable: onlyOfficeAvailable.value
})
})
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleDocuments = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDocuments.value.slice(start, start + pageSize.value)
})
const activePreviewPage = computed(() => {
const pages = selectedDocument.value?.previewPages || []
return pages[currentPreviewPageIndex.value] || pages[0] || null
})
const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value))
const previewSecondaryMetaLine = computed(() =>
buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value)
)
const shouldUseOnlyOffice = computed(() => supportsOnlyOfficePreview(selectedDocument.value))
const shouldUseOnlyOffice = computed(() => previewMode.value === 'onlyoffice')
const excelPreviewTable = computed(() =>
selectedDocument.value?.previewKind === 'table'
? buildExcelPreviewTable(activePreviewPage.value)
: { headers: [], rows: [] }
)
function revokePreviewBlob() {
if (previewBlobUrl.value) {
URL.revokeObjectURL(previewBlobUrl.value)
previewBlobUrl.value = ''
}
}
function destroyOnlyOfficeEditor() {
if (onlyOfficeEditor.value?.destroyEditor) {
onlyOfficeEditor.value.destroyEditor()
}
onlyOfficeEditor.value = null
}
function revokePreviewBlob() {
if (previewBlobUrl.value) {
URL.revokeObjectURL(previewBlobUrl.value)
previewBlobUrl.value = ''
}
}
function destroyOnlyOfficeEditor() {
if (onlyOfficeEditor.value?.destroyEditor) {
onlyOfficeEditor.value.destroyEditor()
}
onlyOfficeEditor.value = null
}
async function mountOnlyOfficeEditor(documentId) {
onlyOfficeLoading.value = true
onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
destroyOnlyOfficeEditor()
try {
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
await loadOnlyOfficeApi(payload.documentServerUrl)
await nextTick()
if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。')
}
await nextTick()
if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。')
}
onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}`
await nextTick()
onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config)
onlyOfficeAvailable.value = true
return true
} catch (error) {
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
return false
} finally {
onlyOfficeLoading.value = false
}
}
async function loadLibrary(options = {}) {
loading.value = true
try {
const payload = await fetchKnowledgeLibrary()
folders.value = payload.folders || []
documents.value = payload.documents || []
emit('summary-change', { totalDocuments: documents.value.length })
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value)
if (!activeExists) {
activeFolder.value = folders.value[0]?.name || ''
}
if (options.preserveSelection && selectedDocument.value?.id) {
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)
if (!exists) {
selectedDocument.value = null
revokePreviewBlob()
}
}
} catch (error) {
emit('summary-change', { totalDocuments: 0 })
toast(error.message || '知识库加载失败。')
} finally {
loading.value = false
}
}
async function selectDocument(documentId) {
previewLoading.value = true
async function loadLibrary(options = {}) {
loading.value = true
try {
const payload = await fetchKnowledgeLibrary()
folders.value = payload.folders || []
documents.value = payload.documents || []
emit('summary-change', { totalDocuments: documents.value.length })
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value)
if (!activeExists) {
activeFolder.value = folders.value[0]?.name || ''
}
if (options.preserveSelection && selectedDocument.value?.id) {
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)
if (!exists) {
selectedDocument.value = null
revokePreviewBlob()
}
}
} catch (error) {
emit('summary-change', { totalDocuments: 0 })
toast(error.message || '知识库加载失败。')
} finally {
loading.value = false
}
}
async function selectDocument(documentId) {
previewLoading.value = true
previewError.value = ''
onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
revokePreviewBlob()
destroyOnlyOfficeEditor()
@@ -180,182 +186,187 @@ export default {
selectedDocument.value = payload
currentPreviewPageIndex.value = 0
if (supportsOnlyOfficePreview(payload)) {
if (canUseOnlyOfficePreview(payload)) {
await mountOnlyOfficeEditor(documentId)
} else if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
}
if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline')
previewBlobUrl.value = URL.createObjectURL(blob)
}
} catch (error) {
previewError.value = error.message || '预览加载失败。'
toast(previewError.value)
} finally {
previewLoading.value = false
}
}
async function handleDownload(document) {
try {
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
triggerFileDownload(blob, document.name)
} catch (error) {
toast(error.message || '下载失败。')
}
}
function triggerUpload() {
if (!isAdmin.value || uploading.value) {
return
}
uploadInput.value?.click()
}
async function uploadFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean)
if (!files.length || !activeFolder.value || !isAdmin.value) {
return
}
uploading.value = true
try {
let latestDocumentId = ''
for (const file of files) {
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
latestDocumentId = payload.id
}
await loadLibrary({ preserveSelection: true })
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
if (latestDocumentId) {
await selectDocument(latestDocumentId)
}
} catch (error) {
toast(error.message || '上传失败。')
} finally {
uploading.value = false
if (uploadInput.value) {
uploadInput.value.value = ''
}
}
}
async function handleFileInput(event) {
await uploadFiles(event.target.files)
}
async function handleDrop(event) {
if (!isAdmin.value) {
return
}
await uploadFiles(event.dataTransfer?.files)
}
async function handleDelete(document) {
if (!isAdmin.value || deletingId.value) {
return
}
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
if (!confirmed) {
return
}
deletingId.value = document.id
try {
await deleteKnowledgeDocument(document.id)
if (selectedDocument.value?.id === document.id) {
selectedDocument.value = null
revokePreviewBlob()
}
await loadLibrary()
toast('知识库文件已删除。')
} catch (error) {
toast(error.message || '删除失败。')
} finally {
deletingId.value = ''
}
}
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
function closePreview() {
selectedDocument.value = null
previewError.value = ''
currentPreviewPageIndex.value = 0
toast(previewError.value)
} finally {
previewLoading.value = false
}
}
async function handleDownload(document) {
try {
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
triggerFileDownload(blob, document.name)
} catch (error) {
toast(error.message || '下载失败。')
}
}
function triggerUpload() {
if (!isAdmin.value || uploading.value) {
return
}
uploadInput.value?.click()
}
async function uploadFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean)
if (!files.length || !activeFolder.value || !isAdmin.value) {
return
}
uploading.value = true
try {
let latestDocumentId = ''
for (const file of files) {
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
latestDocumentId = payload.id
}
await loadLibrary({ preserveSelection: true })
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
if (latestDocumentId) {
await selectDocument(latestDocumentId)
}
} catch (error) {
toast(error.message || '上传失败。')
} finally {
uploading.value = false
if (uploadInput.value) {
uploadInput.value.value = ''
}
}
}
async function handleFileInput(event) {
await uploadFiles(event.target.files)
}
async function handleDrop(event) {
if (!isAdmin.value) {
return
}
await uploadFiles(event.dataTransfer?.files)
}
async function handleDelete(document) {
if (!isAdmin.value || deletingId.value) {
return
}
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
if (!confirmed) {
return
}
deletingId.value = document.id
try {
await deleteKnowledgeDocument(document.id)
if (selectedDocument.value?.id === document.id) {
selectedDocument.value = null
revokePreviewBlob()
}
await loadLibrary()
toast('知识库文件已删除。')
} catch (error) {
toast(error.message || '删除失败。')
} finally {
deletingId.value = ''
}
}
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
function closePreview() {
selectedDocument.value = null
previewError.value = ''
currentPreviewPageIndex.value = 0
revokePreviewBlob()
destroyOnlyOfficeEditor()
onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
}
function selectPreviewPage(index) {
currentPreviewPageIndex.value = index
}
watch(filteredDocuments, () => {
currentPage.value = 1
pageSizeOpen.value = false
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
closePreview()
}
})
watch(activeFolder, () => {
closePreview()
})
onMounted(() => {
loadLibrary()
})
function selectPreviewPage(index) {
currentPreviewPageIndex.value = index
}
watch(filteredDocuments, () => {
currentPage.value = 1
pageSizeOpen.value = false
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
closePreview()
}
})
watch(activeFolder, () => {
closePreview()
})
onMounted(() => {
loadLibrary()
})
onBeforeUnmount(() => {
revokePreviewBlob()
destroyOnlyOfficeEditor()
})
return {
activeFolder,
activePreviewPage,
changePageSize,
closePreview,
excelPreviewTable,
currentPage,
currentPreviewPageIndex,
deletingId,
documentSearch,
filteredFolders,
handleDelete,
handleDownload,
handleDrop,
handleFileInput,
isAdmin,
loading,
pageSize,
return {
activeFolder,
activePreviewPage,
changePageSize,
closePreview,
excelPreviewTable,
currentPage,
currentPreviewPageIndex,
deletingId,
documentSearch,
filteredFolders,
handleDelete,
handleDownload,
handleDrop,
handleFileInput,
isAdmin,
loading,
pageSize,
pageSizeOpen,
pageSizes,
onlyOfficeError,
onlyOfficeHostId,
onlyOfficeLoading,
previewMode,
previewMetaLine,
previewSecondaryMetaLine,
previewBlobUrl,
previewError,
previewLoading,
shouldUseOnlyOffice,
selectDocument,
selectPreviewPage,
selectedDocument,
totalCount,
totalPages,
triggerUpload,
uploadHint,
uploadInput,
uploading,
visibleDocuments
}
}
}
previewError,
previewLoading,
shouldUseOnlyOffice,
selectDocument,
selectPreviewPage,
selectedDocument,
totalCount,
totalPages,
triggerUpload,
uploadHint,
uploadInput,
uploading,
visibleDocuments
}
}
}

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

View File

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

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 { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js'
function run() {
assert.equal(
buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'),
'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js'
)
console.log('onlyoffice service tests passed')
}
run()
import assert from 'node:assert/strict'
import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js'
function run() {
assert.equal(
buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'),
'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js'
)
console.log('onlyoffice service tests passed')
}
run()

View File

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

File diff suppressed because it is too large Load Diff