feat: 完善知识库、策略预览与OnlyOffice集成,增强后端启动依赖检查
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
main:
|
main:
|
||||||
image: x-financial-dev:latest
|
image: x-financial-dev:latest
|
||||||
container_name: x-financial-main
|
container_name: x-financial-main
|
||||||
@@ -6,14 +6,14 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
onlyoffice:
|
onlyoffice:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
WEB_HOST: 0.0.0.0
|
WEB_HOST: 0.0.0.0
|
||||||
SERVER_HOST: 0.0.0.0
|
SERVER_HOST: 0.0.0.0
|
||||||
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
||||||
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}"
|
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}"
|
||||||
ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}"
|
ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}"
|
||||||
ONLYOFFICE_BACKEND_URL: "${ONLYOFFICE_BACKEND_URL:-http://main:${SERVER_PORT:-8000}}"
|
ONLYOFFICE_BACKEND_URL: "${ONLYOFFICE_BACKEND_URL:-http://main:${SERVER_PORT:-8000}}"
|
||||||
ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
|
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
|
||||||
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
||||||
@@ -38,25 +38,25 @@ services:
|
|||||||
chmod +x /app/start.sh /app/web/web_start.sh /app/server/server_start.sh &&
|
chmod +x /app/start.sh /app/web/web_start.sh /app/server/server_start.sh &&
|
||||||
cd /app &&
|
cd /app &&
|
||||||
./start.sh all
|
./start.sh all
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
start_period: 180s
|
start_period: 180s
|
||||||
|
|
||||||
onlyoffice:
|
onlyoffice:
|
||||||
image: onlyoffice/documentserver:latest
|
image: onlyoffice/documentserver:latest
|
||||||
container_name: x-financial-onlyoffice
|
container_name: x-financial-onlyoffice
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
JWT_ENABLED: "true"
|
JWT_ENABLED: "true"
|
||||||
JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||||
ports:
|
ports:
|
||||||
- "${ONLYOFFICE_PORT:-8082}:80"
|
- "${ONLYOFFICE_PORT:-8082}:80"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|||||||
134
docker/README.md
134
docker/README.md
@@ -1,67 +1,67 @@
|
|||||||
# Docker Compose
|
# Docker Compose
|
||||||
|
|
||||||
This project currently uses the Vite `__setup/*` middleware during the initial setup flow.
|
This project currently uses the Vite `__setup/*` middleware during the initial setup flow.
|
||||||
Because of that, the Docker deployment keeps the web frontend and FastAPI startup chain in
|
Because of that, the Docker deployment keeps the web frontend and FastAPI startup chain in
|
||||||
the same main container and runs the existing root `start.sh`.
|
the same main container and runs the existing root `start.sh`.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://<your-linux-host>:5173
|
http://<your-linux-host>:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
## Container Layout
|
## Container Layout
|
||||||
|
|
||||||
- `main`: web + FastAPI main container
|
- `main`: web + FastAPI main container
|
||||||
- `onlyoffice`: ONLYOFFICE Document Server
|
- `onlyoffice`: ONLYOFFICE Document Server
|
||||||
- `postgres`: PostgreSQL database container
|
- `postgres`: PostgreSQL database container
|
||||||
|
|
||||||
The project root is mounted directly into the main container:
|
The project root is mounted directly into the main container:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.:/app
|
.:/app
|
||||||
```
|
```
|
||||||
|
|
||||||
That means the container reads your existing `.env`, source code, `server/.secrets`, logs,
|
That means the container reads your existing `.env`, source code, `server/.secrets`, logs,
|
||||||
and generated dependency directories directly from the mapped project folder.
|
and generated dependency directories directly from the mapped project folder.
|
||||||
|
|
||||||
This is a `compose`-only setup. There is no custom `Dockerfile`.
|
This is a `compose`-only setup. There is no custom `Dockerfile`.
|
||||||
The tradeoff is that the `main` container installs the Python runtime packages it needs
|
The tradeoff is that the `main` container installs the Python runtime packages it needs
|
||||||
when it starts.
|
when it starts.
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
The PostgreSQL data directory is stored in the named volume `postgres_data`.
|
The PostgreSQL data directory is stored in the named volume `postgres_data`.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Most configuration should be maintained in the project root `.env`.
|
- Most configuration should be maintained in the project root `.env`.
|
||||||
- The first `docker compose up -d` does not require an existing `.env`; the compose file
|
- The first `docker compose up -d` does not require an existing `.env`; the compose file
|
||||||
uses built-in defaults for the PostgreSQL container and the main container database URL.
|
uses built-in defaults for the PostgreSQL container and the main container database URL.
|
||||||
- Docker Compose only overrides a few values that must differ inside containers:
|
- Docker Compose only overrides a few values that must differ inside containers:
|
||||||
- `WEB_HOST=0.0.0.0`
|
- `WEB_HOST=0.0.0.0`
|
||||||
- `SERVER_HOST=0.0.0.0`
|
- `SERVER_HOST=0.0.0.0`
|
||||||
- `POSTGRES_HOST=postgres`
|
- `POSTGRES_HOST=postgres`
|
||||||
- `POSTGRES_PORT=5432`
|
- `POSTGRES_PORT=5432`
|
||||||
- `DATABASE_URL=...@postgres:...`
|
- `DATABASE_URL=...@postgres:...`
|
||||||
- PostgreSQL is also published to the host by default as `127.0.0.1:55432`.
|
- PostgreSQL is also published to the host by default as `127.0.0.1:55432`.
|
||||||
- ONLYOFFICE is published to the host by default as `127.0.0.1:8082`.
|
- ONLYOFFICE is published to the host by default as `127.0.0.1:8082`.
|
||||||
- First boot with `SETUP_COMPLETED=false` starts the setup UI only.
|
- First boot with `SETUP_COMPLETED=false` starts the setup UI only.
|
||||||
- After you complete setup in the browser, the Vite setup bridge will start FastAPI in the
|
- After you complete setup in the browser, the Vite setup bridge will start FastAPI in the
|
||||||
same container using the saved runtime configuration.
|
same container using the saved runtime configuration.
|
||||||
- On later restarts, `start.sh` will detect the saved setup state and start both web and
|
- On later restarts, `start.sh` will detect the saved setup state and start both web and
|
||||||
server automatically.
|
server automatically.
|
||||||
- If you access the system from another machine, make sure `CORS_ORIGINS` in `.env` includes
|
- If you access the system from another machine, make sure `CORS_ORIGINS` in `.env` includes
|
||||||
the frontend address you actually use.
|
the frontend address you actually use.
|
||||||
- For Navicat or any host-side client, use `127.0.0.1:55432`.
|
- For Navicat or any host-side client, use `127.0.0.1:55432`.
|
||||||
- If you need to access ONLYOFFICE from another machine, override `ONLYOFFICE_PUBLIC_URL`
|
- If you need to access ONLYOFFICE from another machine, override `ONLYOFFICE_PUBLIC_URL`
|
||||||
so the browser can reach the document server address you actually expose.
|
so the browser can reach the document server address you actually expose.
|
||||||
- For the setup page, using `127.0.0.1` is acceptable in this Docker layout; the internal
|
- For the setup page, using `127.0.0.1` is acceptable in this Docker layout; the internal
|
||||||
test bridge will resolve that back to the Docker PostgreSQL service.
|
test bridge will resolve that back to the Docker PostgreSQL service.
|
||||||
|
|||||||
@@ -1,169 +1,169 @@
|
|||||||
# X-Financial 智能化财务系统:双层 Agent 架构设计与开发落地全景指南
|
# X-Financial 智能化财务系统:双层 Agent 架构设计与开发落地全景指南
|
||||||
|
|
||||||
> **核心设计理念:确定性与概率性的完美解耦**
|
> **核心设计理念:确定性与概率性的完美解耦**
|
||||||
>
|
>
|
||||||
> 在企业级财务系统中,“合规性”与“准确性”是不可妥协的底线。大语言模型(LLM)天生具有概率性(会产生幻觉),因此不能直接赋予其修改核心财务数据或放行审批的最高权限。
|
> 在企业级财务系统中,“合规性”与“准确性”是不可妥协的底线。大语言模型(LLM)天生具有概率性(会产生幻觉),因此不能直接赋予其修改核心财务数据或放行审批的最高权限。
|
||||||
>
|
>
|
||||||
> 本架构设计的核心,在于构建一个**“双层防线”**:
|
> 本架构设计的核心,在于构建一个**“双层防线”**:
|
||||||
> 1. **外层 Agent (自研流程大脑)**:提供 100% 的确定性。它是系统的执行者,严格按照预设流程和固化的规则行事,不具备“自我意识”,只负责“路由”、“拦截”和“记录”。
|
> 1. **外层 Agent (自研流程大脑)**:提供 100% 的确定性。它是系统的执行者,严格按照预设流程和固化的规则行事,不具备“自我意识”,只负责“路由”、“拦截”和“记录”。
|
||||||
> 2. **内层 Agent (Hermes 智囊核心)**:提供强大的概率性推理能力。它是系统的思考者,负责处理所有复杂、模糊、非结构化的任务(如阅读长文档、识别潜在风险),但它的输出**不能直接作用于业务**,而是转化为**规则配置**或**建议意见**,交由外层 Agent 或人类管理员执行。
|
> 2. **内层 Agent (Hermes 智囊核心)**:提供强大的概率性推理能力。它是系统的思考者,负责处理所有复杂、模糊、非结构化的任务(如阅读长文档、识别潜在风险),但它的输出**不能直接作用于业务**,而是转化为**规则配置**或**建议意见**,交由外层 Agent 或人类管理员执行。
|
||||||
>
|
>
|
||||||
> 这两层架构不是相互独立的两个系统,而是形成一个**“闭环”**:内层提炼规则,外层执行规则;外层收集数据,内层分析数据。这种深度协同,既保障了系统的安全性,又赋予了系统极高的智能化水平。
|
> 这两层架构不是相互独立的两个系统,而是形成一个**“闭环”**:内层提炼规则,外层执行规则;外层收集数据,内层分析数据。这种深度协同,既保障了系统的安全性,又赋予了系统极高的智能化水平。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、 系统架构图景与职责边界深度剖析
|
## 一、 系统架构图景与职责边界深度剖析
|
||||||
|
|
||||||
### 1. 外层 Agent (Outer Agent):流程与路由的绝对掌控者
|
### 1. 外层 Agent (Outer Agent):流程与路由的绝对掌控者
|
||||||
|
|
||||||
**本质:一个高度可配置的业务工作流引擎与意图分发器。**
|
**本质:一个高度可配置的业务工作流引擎与意图分发器。**
|
||||||
|
|
||||||
* **开发技术栈建议**:FastAPI (后端) + Vue3 (前端) + PostgreSQL (持久化) + Redis (可选,用于状态缓存)。
|
* **开发技术栈建议**:FastAPI (后端) + Vue3 (前端) + PostgreSQL (持久化) + Redis (可选,用于状态缓存)。
|
||||||
* **交互形态**:它直接面对用户。它可以是一个类似对话框的界面,但背后的逻辑是基于**状态机 (State Machine)** 驱动的。
|
* **交互形态**:它直接面对用户。它可以是一个类似对话框的界面,但背后的逻辑是基于**状态机 (State Machine)** 驱动的。
|
||||||
* **核心模块与职责 (What to do & How to do)**:
|
* **核心模块与职责 (What to do & How to do)**:
|
||||||
|
|
||||||
* **模块 1: 意图漏斗 (Intent Router)**
|
* **模块 1: 意图漏斗 (Intent Router)**
|
||||||
* **职责**:精准捕捉用户请求的第一诉求,并将其导向正确的处理管线。
|
* **职责**:精准捕捉用户请求的第一诉求,并将其导向正确的处理管线。
|
||||||
* **方法**:
|
* **方法**:
|
||||||
* *规则匹配优先*:使用简单的关键词或正则(例如:匹配到“报销”、“打车”字眼,直接激活报销向导)。
|
* *规则匹配优先*:使用简单的关键词或正则(例如:匹配到“报销”、“打车”字眼,直接激活报销向导)。
|
||||||
* *轻量级分类模型兜底*:对于模糊表述(如:“我上周去上海开会的钱怎么还没发?”),调用一个小参数的分类模型(或内层的快捷接口),将其分类为“状态查询”意图,并提取关键实体(如时间:上周,地点:上海)。
|
* *轻量级分类模型兜底*:对于模糊表述(如:“我上周去上海开会的钱怎么还没发?”),调用一个小参数的分类模型(或内层的快捷接口),将其分类为“状态查询”意图,并提取关键实体(如时间:上周,地点:上海)。
|
||||||
* **模块 2: 结构化状态机引擎 (State & Flow Controller)**
|
* **模块 2: 结构化状态机引擎 (State & Flow Controller)**
|
||||||
* **职责**:管理每一个业务对象(如一张报销单)的生命周期。从“草稿” -> “提交” -> “一级审批” -> “财务复核” -> “已打款”。
|
* **职责**:管理每一个业务对象(如一张报销单)的生命周期。从“草稿” -> “提交” -> “一级审批” -> “财务复核” -> “已打款”。
|
||||||
* **方法**:拒绝让大模型控制流程走向。流程流转必须基于代码逻辑中的条件判断(例如:如果金额 < 500,且员工级别为 M1,则跳过一级审批,直接进入财务复核)。外层 Agent 负责维护并推进这个状态。
|
* **方法**:拒绝让大模型控制流程走向。流程流转必须基于代码逻辑中的条件判断(例如:如果金额 < 500,且员工级别为 M1,则跳过一级审批,直接进入财务复核)。外层 Agent 负责维护并推进这个状态。
|
||||||
* **模块 3: 确定性规则执行器 (Rule Execution Engine)**
|
* **模块 3: 确定性规则执行器 (Rule Execution Engine)**
|
||||||
* **职责**:财务合规的第一道硬性防线。不讲道理,只看数据。
|
* **职责**:财务合规的第一道硬性防线。不讲道理,只看数据。
|
||||||
* **方法**:当用户提交报销数据时,该模块会查询本地的 `business_rules` 数据库表。如果用户提交的住宿费是 850,而数据库规则明确上限是 800,则立刻抛出“阻断型错误” (Blocking Error)。**此过程绝对禁止调用大模型进行实时推断。**
|
* **方法**:当用户提交报销数据时,该模块会查询本地的 `business_rules` 数据库表。如果用户提交的住宿费是 850,而数据库规则明确上限是 800,则立刻抛出“阻断型错误” (Blocking Error)。**此过程绝对禁止调用大模型进行实时推断。**
|
||||||
* **模块 4: 标准化 API 网关 (API Gateway & Handshake Layer)**
|
* **模块 4: 标准化 API 网关 (API Gateway & Handshake Layer)**
|
||||||
* **职责**:封装所有对外层系统(如 ERP、HR 系统)和对内层 Hermes 的通信接口。控制并发,记录调用日志。
|
* **职责**:封装所有对外层系统(如 ERP、HR 系统)和对内层 Hermes 的通信接口。控制并发,记录调用日志。
|
||||||
|
|
||||||
### 2. 内层 Agent (Hermes):非结构化信息的提炼者与深度思考者
|
### 2. 内层 Agent (Hermes):非结构化信息的提炼者与深度思考者
|
||||||
|
|
||||||
**本质:一个被严格隔离的智能计算引擎,专门处理人类擅长但传统代码难以处理的“软逻辑”。**
|
**本质:一个被严格隔离的智能计算引擎,专门处理人类擅长但传统代码难以处理的“软逻辑”。**
|
||||||
|
|
||||||
* **开发技术栈建议**:Hermes 框架 + 向量数据库 (如 Milvus/PGVector) + 强力 LLM (如 GPT-4 或开源大模型)。
|
* **开发技术栈建议**:Hermes 框架 + 向量数据库 (如 Milvus/PGVector) + 强力 LLM (如 GPT-4 或开源大模型)。
|
||||||
* **交互形态**:对用户不可见,只作为外层 Agent 的“后端服务”存在。
|
* **交互形态**:对用户不可见,只作为外层 Agent 的“后端服务”存在。
|
||||||
* **核心模块与职责 (What to do & How to do)**:
|
* **核心模块与职责 (What to do & How to do)**:
|
||||||
|
|
||||||
* **模块 1: 政策蒸馏器 (Policy Distiller) —— 解决“知行合一”的关键**
|
* **模块 1: 政策蒸馏器 (Policy Distiller) —— 解决“知行合一”的关键**
|
||||||
* **职责**:打破知识库(死文件)与业务流(活代码)之间的壁垒。
|
* **职责**:打破知识库(死文件)与业务流(活代码)之间的壁垒。
|
||||||
* **方法 (核心思路)**:
|
* **方法 (核心思路)**:
|
||||||
1. *触发*:管理员上传一份《差旅新规.pdf》。
|
1. *触发*:管理员上传一份《差旅新规.pdf》。
|
||||||
2. *解析*:Hermes 逐段阅读文档。
|
2. *解析*:Hermes 逐段阅读文档。
|
||||||
3. *提取*:使用精心设计的 **Few-Shot Prompt 链**,强制模型识别特定的“控制变量”。
|
3. *提取*:使用精心设计的 **Few-Shot Prompt 链**,强制模型识别特定的“控制变量”。
|
||||||
*(Prompt 示例: "你是一个专业的财务合规审计员。请阅读以下段落,如果包含任何关于费用上限、职级限制、审批层级的规定,请严格按照以下 JSON Schema 输出:{category, location, level_req, max_amount, is_hard_limit}。如果未找到,输出空。")*
|
*(Prompt 示例: "你是一个专业的财务合规审计员。请阅读以下段落,如果包含任何关于费用上限、职级限制、审批层级的规定,请严格按照以下 JSON Schema 输出:{category, location, level_req, max_amount, is_hard_limit}。如果未找到,输出空。")*
|
||||||
4. *回写*:Hermes 将提炼出的 JSON 结构转化为标准的 SQL Update 指令(或通过专用 API 接口),更新外层 Agent 依赖的 `business_rules` 表。
|
4. *回写*:Hermes 将提炼出的 JSON 结构转化为标准的 SQL Update 指令(或通过专用 API 接口),更新外层 Agent 依赖的 `business_rules` 表。
|
||||||
* **模块 2: 深度知识检索 (Deep RAG & Interpretation)**
|
* **模块 2: 深度知识检索 (Deep RAG & Interpretation)**
|
||||||
* **职责**:为用户提供复杂制度的个性化解读。
|
* **职责**:为用户提供复杂制度的个性化解读。
|
||||||
* **方法**:当外层 Agent 无法解答用户的合规疑问时(意图识别为“政策咨询”),外层将请求转发给 Hermes。Hermes 在向量库中检索相关段落,并结合用户当前的上下文(如:员工职级、出差地),生成一份连贯、人性化的解答。
|
* **方法**:当外层 Agent 无法解答用户的合规疑问时(意图识别为“政策咨询”),外层将请求转发给 Hermes。Hermes 在向量库中检索相关段落,并结合用户当前的上下文(如:员工职级、出差地),生成一份连贯、人性化的解答。
|
||||||
* **模块 3: 异步风险探针 (Asynchronous Risk Auditor)**
|
* **模块 3: 异步风险探针 (Asynchronous Risk Auditor)**
|
||||||
* **职责**:像“老会计”一样,在海量已发生或正在发生的业务数据中寻找蛛丝马迹。
|
* **职责**:像“老会计”一样,在海量已发生或正在发生的业务数据中寻找蛛丝马迹。
|
||||||
* **方法**:
|
* **方法**:
|
||||||
1. *定时任务*:每天凌晨启动。
|
1. *定时任务*:每天凌晨启动。
|
||||||
2. *数据聚合*:从外层数据库提取当天的报销流水(去除敏感个资)。
|
2. *数据聚合*:从外层数据库提取当天的报销流水(去除敏感个资)。
|
||||||
3. *模式识别*:通过特定的 Prompt(例如寻找“拆单报销”、“异常高频的出租车票”)。
|
3. *模式识别*:通过特定的 Prompt(例如寻找“拆单报销”、“异常高频的出租车票”)。
|
||||||
4. *生成报告*:生成结构化的风险预警报告,存入专用表,供管理员次日早晨审核,而不是直接去冻结员工账号。
|
4. *生成报告*:生成结构化的风险预警报告,存入专用表,供管理员次日早晨审核,而不是直接去冻结员工账号。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、 核心通信协议 (The Handshake):两层的握手与数据交互
|
## 二、 核心通信协议 (The Handshake):两层的握手与数据交互
|
||||||
|
|
||||||
双层架构的成败,取决于这两层能否顺畅地交换信息,且保证安全。我们需要定义清晰的接口协议。
|
双层架构的成败,取决于这两层能否顺畅地交换信息,且保证安全。我们需要定义清晰的接口协议。
|
||||||
|
|
||||||
### 1. 同步查询接口 (外 -> 内:求知与解惑)
|
### 1. 同步查询接口 (外 -> 内:求知与解惑)
|
||||||
|
|
||||||
当外层遇到处理不了的“软逻辑”时触发。
|
当外层遇到处理不了的“软逻辑”时触发。
|
||||||
|
|
||||||
* **Endpoint (示例)**: `POST /hermes/api/v1/consult`
|
* **Endpoint (示例)**: `POST /hermes/api/v1/consult`
|
||||||
* **外层 Request 结构**:
|
* **外层 Request 结构**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"context": {
|
"context": {
|
||||||
"user_id": "emp_1001",
|
"user_id": "emp_1001",
|
||||||
"current_task": "travel_reimbursement",
|
"current_task": "travel_reimbursement",
|
||||||
"form_data": {"city": "北京", "amount": 900}
|
"form_data": {"city": "北京", "amount": 900}
|
||||||
},
|
},
|
||||||
"query": "因为展会原因酒店全满,只能订900的,能报销吗?"
|
"query": "因为展会原因酒店全满,只能订900的,能报销吗?"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
* **内层 Hermes Response 结构**:
|
* **内层 Hermes Response 结构**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"interpretation": "根据《差旅管理办法》第15条,展会期间允许上浮 20%。您的标准是800,上浮后为960,可以报销。",
|
"interpretation": "根据《差旅管理办法》第15条,展会期间允许上浮 20%。您的标准是800,上浮后为960,可以报销。",
|
||||||
"action_recommendation": "require_special_approval", // 建议外层采取的动作
|
"action_recommendation": "require_special_approval", // 建议外层采取的动作
|
||||||
"citations": ["policy_doc_v2_page_4"]
|
"citations": ["policy_doc_v2_page_4"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 异步任务接口 (外 -> 内:派发耗时任务)
|
### 2. 异步任务接口 (外 -> 内:派发耗时任务)
|
||||||
|
|
||||||
例如请求生成长篇分析报告或进行全量风险巡检。
|
例如请求生成长篇分析报告或进行全量风险巡检。
|
||||||
|
|
||||||
* **流程**:
|
* **流程**:
|
||||||
1. 外层调用 `POST /hermes/api/v1/jobs/generate_report`。
|
1. 外层调用 `POST /hermes/api/v1/jobs/generate_report`。
|
||||||
2. 内层 Hermes 立即返回 `202 Accepted` 和一个 `job_id`。
|
2. 内层 Hermes 立即返回 `202 Accepted` 和一个 `job_id`。
|
||||||
3. 内层 Hermes 在后台慢慢算。
|
3. 内层 Hermes 在后台慢慢算。
|
||||||
4. 计算完成后,内层通过 Webhook 回调外层的通知接口,外层再通过系统消息通知用户“您的报告已就绪”。
|
4. 计算完成后,内层通过 Webhook 回调外层的通知接口,外层再通过系统消息通知用户“您的报告已就绪”。
|
||||||
|
|
||||||
### 3. 规则推送机制 (内 -> 外:自动化立法)
|
### 3. 规则推送机制 (内 -> 外:自动化立法)
|
||||||
|
|
||||||
这是最核心的逆向通信。内层提炼出的规则如何生效?
|
这是最核心的逆向通信。内层提炼出的规则如何生效?
|
||||||
|
|
||||||
* **流程**:
|
* **流程**:
|
||||||
1. Hermes 提炼出新规则。
|
1. Hermes 提炼出新规则。
|
||||||
2. Hermes 调用外层的特权 API (如 `POST /admin/api/rules/sync`),推送规则 payload。
|
2. Hermes 调用外层的特权 API (如 `POST /admin/api/rules/sync`),推送规则 payload。
|
||||||
3. 外层 Agent 收到后,执行数据库 `UPSERT` 操作更新 `business_rules` 表。
|
3. 外层 Agent 收到后,执行数据库 `UPSERT` 操作更新 `business_rules` 表。
|
||||||
4. *(可选但强烈建议)*:进入“待激活”状态,需要人类管理员在系统中点击“确认应用新规则”后,新规才正式生效。
|
4. *(可选但强烈建议)*:进入“待激活”状态,需要人类管理员在系统中点击“确认应用新规则”后,新规才正式生效。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、 分阶段开发落地全景计划 (Implementation Roadmap)
|
## 三、 分阶段开发落地全景计划 (Implementation Roadmap)
|
||||||
|
|
||||||
开发应当遵循“先基建后上层、先确定后智能”的原则。
|
开发应当遵循“先基建后上层、先确定后智能”的原则。
|
||||||
|
|
||||||
### Phase 1: 骨架搭建与基石铺设 (Foundation & Outer Shell)
|
### Phase 1: 骨架搭建与基石铺设 (Foundation & Outer Shell)
|
||||||
*目标:构建一个哪怕没有 AI 也能运转的硬核流程系统,确立两层隔离。*
|
*目标:构建一个哪怕没有 AI 也能运转的硬核流程系统,确立两层隔离。*
|
||||||
|
|
||||||
1. **架构拆分验证**:在服务器层面,确保 Outer Agent (FastAPI) 和 Inner Hermes 分别在独立的进程(或容器)中运行,仅通过 HTTP/gRPC 通信。
|
1. **架构拆分验证**:在服务器层面,确保 Outer Agent (FastAPI) 和 Inner Hermes 分别在独立的进程(或容器)中运行,仅通过 HTTP/gRPC 通信。
|
||||||
2. **动态规则引擎实现 (核心基建)**:
|
2. **动态规则引擎实现 (核心基建)**:
|
||||||
* 在 PostgreSQL 中设计 `business_rules` 表结构。必须支持高度扩展性(例如采用 `JSONB` 字段存储具体约束参数)。
|
* 在 PostgreSQL 中设计 `business_rules` 表结构。必须支持高度扩展性(例如采用 `JSONB` 字段存储具体约束参数)。
|
||||||
* 在外层 Agent 开发一个“规则校验服务 (Rule Validation Service)”,该服务能够在任何报销动作发生前,拦截并比对 `business_rules`。
|
* 在外层 Agent 开发一个“规则校验服务 (Rule Validation Service)”,该服务能够在任何报销动作发生前,拦截并比对 `business_rules`。
|
||||||
3. **标准化流程闭环**:开发一个完整的、基于硬规则驱动的差旅报销单据流转全流程(填单 -> 校验 -> 提交 -> 审批)。验证在“硬规则”下系统运转良好。
|
3. **标准化流程闭环**:开发一个完整的、基于硬规则驱动的差旅报销单据流转全流程(填单 -> 校验 -> 提交 -> 审批)。验证在“硬规则”下系统运转良好。
|
||||||
|
|
||||||
### Phase 2: 知识注入与基础问答 (Hermes RAG Integration)
|
### Phase 2: 知识注入与基础问答 (Hermes RAG Integration)
|
||||||
*目标:赋予系统“解答疑问”的能力。*
|
*目标:赋予系统“解答疑问”的能力。*
|
||||||
|
|
||||||
1. **内层基建**:配置 Hermes 环境,接入向量数据库。
|
1. **内层基建**:配置 Hermes 环境,接入向量数据库。
|
||||||
2. **文档清洗管道 (ETL pipeline)**:将现有的财务政策 PDF/Word 文档清洗、分块 (Chunking) 并向量化入库。
|
2. **文档清洗管道 (ETL pipeline)**:将现有的财务政策 PDF/Word 文档清洗、分块 (Chunking) 并向量化入库。
|
||||||
3. **问答桥接**:
|
3. **问答桥接**:
|
||||||
* 在外层前端 (Vue3) 提供一个“智能咨询”悬浮窗或独立页面。
|
* 在外层前端 (Vue3) 提供一个“智能咨询”悬浮窗或独立页面。
|
||||||
* 外层 Agent 接收问题,附带上用户的上下文(角色、权限),一并转发给内层 Hermes。
|
* 外层 Agent 接收问题,附带上用户的上下文(角色、权限),一并转发给内层 Hermes。
|
||||||
* 验证 Hermes 能够根据向量库的内容,给出带出处的准确回答。
|
* 验证 Hermes 能够根据向量库的内容,给出带出处的准确回答。
|
||||||
|
|
||||||
### Phase 3: 核心攻坚 —— 自动立法与双层联通 (Policy Distillation & Sync)
|
### Phase 3: 核心攻坚 —— 自动立法与双层联通 (Policy Distillation & Sync)
|
||||||
*目标:实现从“死文档”到“活规则”的自动化转化。*
|
*目标:实现从“死文档”到“活规则”的自动化转化。*
|
||||||
|
|
||||||
1. **蒸馏 Prompt 工程**:在 Hermes 中反复打磨“政策提炼”的 Prompt。针对你们公司常见的政策描述方式进行微调。
|
1. **蒸馏 Prompt 工程**:在 Hermes 中反复打磨“政策提炼”的 Prompt。针对你们公司常见的政策描述方式进行微调。
|
||||||
2. **结构化提取测试**:手动上传不同版本的政策文档,测试 Hermes 能否稳定、准确地输出 JSON 格式的规则参数。
|
2. **结构化提取测试**:手动上传不同版本的政策文档,测试 Hermes 能否稳定、准确地输出 JSON 格式的规则参数。
|
||||||
3. **闭环联调**:
|
3. **闭环联调**:
|
||||||
* 开发 Hermes 向外层推送规则的 API。
|
* 开发 Hermes 向外层推送规则的 API。
|
||||||
* 完成全链路测试:管理员界面上传新文档 -> Hermes 后台解析 -> 外层规则库自动更新 -> 前端即时生效新的金额限制。
|
* 完成全链路测试:管理员界面上传新文档 -> Hermes 后台解析 -> 外层规则库自动更新 -> 前端即时生效新的金额限制。
|
||||||
|
|
||||||
### Phase 4: 高阶进化 —— 异步审计与主动防御 (Proactive Risk Auditing)
|
### Phase 4: 高阶进化 —— 异步审计与主动防御 (Proactive Risk Auditing)
|
||||||
*目标:将系统从“被动响应”升级为“主动防护”。*
|
*目标:将系统从“被动响应”升级为“主动防护”。*
|
||||||
|
|
||||||
1. **数据安全隧道**:建立从外层业务库向内层 Hermes 传递“脱敏业务快照”的通道。
|
1. **数据安全隧道**:建立从外层业务库向内层 Hermes 传递“脱敏业务快照”的通道。
|
||||||
2. **风险模式定义**:梳理出 3-5 种典型的财务风险模式(如:异常聚集的餐饮发票、连续的单日高额交通费)。
|
2. **风险模式定义**:梳理出 3-5 种典型的财务风险模式(如:异常聚集的餐饮发票、连续的单日高额交通费)。
|
||||||
3. **Hermes 巡检任务**:编写定时任务逻辑,利用大模型的推理能力去比对这些模式和当天的业务快照数据。
|
3. **Hermes 巡检任务**:编写定时任务逻辑,利用大模型的推理能力去比对这些模式和当天的业务快照数据。
|
||||||
4. **风险看板**:在外层系统的管理后台开发“风险报告台”,展示 Hermes 生成的预警结果。
|
4. **风险看板**:在外层系统的管理后台开发“风险报告台”,展示 Hermes 生成的预警结果。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、 关键风险与防范策略总结
|
## 四、 关键风险与防范策略总结
|
||||||
|
|
||||||
1. **大模型幻觉污染规则库**:
|
1. **大模型幻觉污染规则库**:
|
||||||
* **防范**:Hermes 提炼的所有硬性规则(尤其是金额、审批级数),在写入外层正式库之前,必须增加一个**“人工审核 (Human-in-the-loop)”** 环节。系统提示“检测到政策更新,提炼出 5 条新规则,请管理员确认应用”。
|
* **防范**:Hermes 提炼的所有硬性规则(尤其是金额、审批级数),在写入外层正式库之前,必须增加一个**“人工审核 (Human-in-the-loop)”** 环节。系统提示“检测到政策更新,提炼出 5 条新规则,请管理员确认应用”。
|
||||||
2. **状态机混乱**:
|
2. **状态机混乱**:
|
||||||
* **防范**:外层 Agent 的流程控制代码必须使用强类型和严格的事务控制 (Transaction)。绝不允许任何组件(包括 AI)在不经过状态机合法校验的情况下直接修改数据库中的 `status` 字段。
|
* **防范**:外层 Agent 的流程控制代码必须使用强类型和严格的事务控制 (Transaction)。绝不允许任何组件(包括 AI)在不经过状态机合法校验的情况下直接修改数据库中的 `status` 字段。
|
||||||
3. **性能瓶颈**:
|
3. **性能瓶颈**:
|
||||||
* **防范**:所有外层必须做的事情(拦截、查询)必须在毫秒级完成。所有涉及调用 Hermes 的操作(问答、提炼、分析)全部采用异步设计或提供明确的 Loading 反馈。
|
* **防范**:所有外层必须做的事情(拦截、查询)必须在毫秒级完成。所有涉及调用 Hermes 的操作(问答、提炼、分析)全部采用异步设计或提供明确的 Loading 反馈。
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68", "wheel"]
|
requires = ["setuptools>=68", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "x-financial-server"
|
name = "x-financial-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Backend service for X-Financial reimbursement and approval platform."
|
description = "Backend service for X-Financial reimbursement and approval platform."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115.0,<1.0.0",
|
"fastapi>=0.115.0,<1.0.0",
|
||||||
"uvicorn[standard]>=0.30.0,<1.0.0",
|
"uvicorn[standard]>=0.30.0,<1.0.0",
|
||||||
"sqlalchemy>=2.0.36,<3.0.0",
|
"sqlalchemy>=2.0.36,<3.0.0",
|
||||||
"alembic>=1.14.0,<2.0.0",
|
"alembic>=1.14.0,<2.0.0",
|
||||||
"psycopg[binary]>=3.2.0,<4.0.0",
|
"psycopg[binary]>=3.2.0,<4.0.0",
|
||||||
"PyJWT>=2.9.0,<3.0.0",
|
"PyJWT>=2.9.0,<3.0.0",
|
||||||
"pydantic-settings>=2.6.0,<3.0.0",
|
"pydantic-settings>=2.6.0,<3.0.0",
|
||||||
"python-dotenv>=1.0.1,<2.0.0",
|
"python-dotenv>=1.0.1,<2.0.0",
|
||||||
"email-validator>=2.2.0,<3.0.0",
|
"email-validator>=2.2.0,<3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3.0,<9.0.0",
|
"pytest>=8.3.0,<9.0.0",
|
||||||
"httpx>=0.28.0,<1.0.0",
|
"httpx>=0.28.0,<1.0.0",
|
||||||
"ruff>=0.8.0,<1.0.0",
|
"ruff>=0.8.0,<1.0.0",
|
||||||
]
|
]
|
||||||
redis = [
|
redis = [
|
||||||
"redis>=5.2.0,<6.0.0",
|
"redis>=5.2.0,<6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "B", "UP"]
|
select = ["E", "F", "I", "B", "UP"]
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ fi
|
|||||||
ENV_OVERRIDE_SERVER_HOST_SET=false
|
ENV_OVERRIDE_SERVER_HOST_SET=false
|
||||||
ENV_OVERRIDE_POSTGRES_HOST_SET=false
|
ENV_OVERRIDE_POSTGRES_HOST_SET=false
|
||||||
ENV_OVERRIDE_DATABASE_URL_SET=false
|
ENV_OVERRIDE_DATABASE_URL_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=false
|
||||||
|
|
||||||
if [ "${SERVER_HOST+x}" = x ]; then
|
if [ "${SERVER_HOST+x}" = x ]; then
|
||||||
ENV_OVERRIDE_SERVER_HOST_SET=true
|
ENV_OVERRIDE_SERVER_HOST_SET=true
|
||||||
@@ -106,6 +110,26 @@ if [ "${DATABASE_URL+x}" = x ]; then
|
|||||||
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
|
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_PUBLIC_URL+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL="$ONLYOFFICE_PUBLIC_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_BACKEND_URL+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL="$ONLYOFFICE_BACKEND_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_JWT_SECRET+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET="$ONLYOFFICE_JWT_SECRET"
|
||||||
|
fi
|
||||||
|
|
||||||
set -a
|
set -a
|
||||||
. "$ROOT_ENV_FILE"
|
. "$ROOT_ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
@@ -122,6 +146,22 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
|
|||||||
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
|
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
|
||||||
|
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET" = true ]; then
|
||||||
|
ONLYOFFICE_PUBLIC_URL="$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET" = true ]; then
|
||||||
|
ONLYOFFICE_BACKEND_URL="$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then
|
||||||
|
ONLYOFFICE_JWT_SECRET="$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET"
|
||||||
|
fi
|
||||||
|
|
||||||
SERVER_HOST="${SERVER_HOST:-0.0.0.0}"
|
SERVER_HOST="${SERVER_HOST:-0.0.0.0}"
|
||||||
SERVER_PORT="${SERVER_PORT:-8000}"
|
SERVER_PORT="${SERVER_PORT:-8000}"
|
||||||
DEFAULT_SERVER_RELOAD="false"
|
DEFAULT_SERVER_RELOAD="false"
|
||||||
@@ -189,7 +229,7 @@ run_bootstrap_python() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies_ready() {
|
dependencies_ready() {
|
||||||
"$PYTHON_BIN" -c "import fastapi, uvicorn, sqlalchemy, alembic, pydantic_settings" >/dev/null 2>&1
|
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, psycopg, pydantic_settings, sqlalchemy, uvicorn" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
pip_ready() {
|
pip_ready() {
|
||||||
|
|||||||
@@ -1,62 +1,62 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, status
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Generator[Session, None, None]:
|
def get_db() -> Generator[Session, None, None]:
|
||||||
db = get_session_factory()()
|
db = get_session_factory()()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class CurrentUserContext:
|
class CurrentUserContext:
|
||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role_codes: list[str]
|
role_codes: list[str]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
x_auth_username: Annotated[str | None, Header()] = None,
|
x_auth_username: Annotated[str | None, Header()] = None,
|
||||||
x_auth_name: Annotated[str | None, Header()] = None,
|
x_auth_name: Annotated[str | None, Header()] = None,
|
||||||
x_auth_role_codes: Annotated[str | None, Header()] = None,
|
x_auth_role_codes: Annotated[str | None, Header()] = None,
|
||||||
x_auth_is_admin: Annotated[str | None, Header()] = None,
|
x_auth_is_admin: Annotated[str | None, Header()] = None,
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
||||||
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
username = (x_auth_username or "").strip()
|
username = (x_auth_username or "").strip()
|
||||||
name = (x_auth_name or username).strip()
|
name = (x_auth_name or username).strip()
|
||||||
|
|
||||||
if not username and not name:
|
if not username and not name:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="请先登录后再访问知识库。",
|
detail="请先登录后再访问知识库。",
|
||||||
)
|
)
|
||||||
|
|
||||||
return CurrentUserContext(
|
return CurrentUserContext(
|
||||||
username=username or name,
|
username=username or name,
|
||||||
name=name or username,
|
name=name or username,
|
||||||
role_codes=role_codes,
|
role_codes=role_codes,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_admin_user(
|
def require_admin_user(
|
||||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
if current_user.is_admin or "manager" in current_user.role_codes:
|
if current_user.is_admin or "manager" in current_user.role_codes:
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,124 +1,124 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext, get_current_user, require_admin_user
|
from app.api.deps import CurrentUserContext, get_current_user, require_admin_user
|
||||||
from app.schemas.knowledge import (
|
from app.schemas.knowledge import (
|
||||||
KnowledgeActionResponse,
|
KnowledgeActionResponse,
|
||||||
KnowledgeDocumentDetailRead,
|
KnowledgeDocumentDetailRead,
|
||||||
KnowledgeLibraryRead,
|
KnowledgeLibraryRead,
|
||||||
KnowledgeOnlyOfficeCallbackRead,
|
KnowledgeOnlyOfficeCallbackRead,
|
||||||
KnowledgeOnlyOfficeConfigRead,
|
KnowledgeOnlyOfficeConfigRead,
|
||||||
)
|
)
|
||||||
from app.services.knowledge import KnowledgeService
|
from app.services.knowledge import KnowledgeService
|
||||||
|
|
||||||
router = APIRouter(prefix="/knowledge")
|
router = APIRouter(prefix="/knowledge")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library", response_model=KnowledgeLibraryRead)
|
@router.get("/library", response_model=KnowledgeLibraryRead)
|
||||||
def get_knowledge_library(
|
def get_knowledge_library(
|
||||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
) -> KnowledgeLibraryRead:
|
) -> KnowledgeLibraryRead:
|
||||||
return KnowledgeService().list_library()
|
return KnowledgeService().list_library()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead)
|
@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead)
|
||||||
def get_knowledge_document(
|
def get_knowledge_document(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
) -> KnowledgeDocumentDetailRead:
|
) -> KnowledgeDocumentDetailRead:
|
||||||
try:
|
try:
|
||||||
return KnowledgeService().get_document_detail(document_id)
|
return KnowledgeService().get_document_detail(document_id)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead)
|
@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead)
|
||||||
def get_knowledge_document_onlyoffice_config(
|
def get_knowledge_document_onlyoffice_config(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
) -> KnowledgeOnlyOfficeConfigRead:
|
) -> KnowledgeOnlyOfficeConfigRead:
|
||||||
try:
|
try:
|
||||||
return KnowledgeService().build_onlyoffice_config(document_id, current_user)
|
return KnowledgeService().build_onlyoffice_config(document_id, current_user)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def upload_knowledge_document(
|
async def upload_knowledge_document(
|
||||||
request: Request,
|
request: Request,
|
||||||
folder: Annotated[str, Query(min_length=1)],
|
folder: Annotated[str, Query(min_length=1)],
|
||||||
filename: Annotated[str, Query(min_length=1)],
|
filename: Annotated[str, Query(min_length=1)],
|
||||||
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||||
) -> KnowledgeDocumentDetailRead:
|
) -> KnowledgeDocumentDetailRead:
|
||||||
content = await request.body()
|
content = await request.body()
|
||||||
try:
|
try:
|
||||||
return KnowledgeService().upload_document(folder, filename, content, current_user)
|
return KnowledgeService().upload_document(folder, filename, content, current_user)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse)
|
@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse)
|
||||||
def delete_knowledge_document(
|
def delete_knowledge_document(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||||
) -> KnowledgeActionResponse:
|
) -> KnowledgeActionResponse:
|
||||||
try:
|
try:
|
||||||
KnowledgeService().delete_document(document_id)
|
KnowledgeService().delete_document(document_id)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
|
|
||||||
return KnowledgeActionResponse(detail="知识库文件已删除。")
|
return KnowledgeActionResponse(detail="知识库文件已删除。")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/{document_id}/content")
|
@router.get("/documents/{document_id}/content")
|
||||||
def get_knowledge_document_content(
|
def get_knowledge_document_content(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline",
|
disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline",
|
||||||
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
|
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
try:
|
try:
|
||||||
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
|
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
|
|
||||||
_ = disposition
|
_ = disposition
|
||||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/{document_id}/onlyoffice/content")
|
@router.get("/documents/{document_id}/onlyoffice/content")
|
||||||
def get_knowledge_document_onlyoffice_content(
|
def get_knowledge_document_onlyoffice_content(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
access_token: Annotated[str, Query(min_length=1)],
|
access_token: Annotated[str, Query(min_length=1)],
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
try:
|
try:
|
||||||
service = KnowledgeService()
|
service = KnowledgeService()
|
||||||
service.validate_onlyoffice_access_token(document_id, access_token)
|
service.validate_onlyoffice_access_token(document_id, access_token)
|
||||||
file_path, media_type, filename = service.get_document_content(document_id)
|
file_path, media_type, filename = service.get_document_content(document_id)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead)
|
@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead)
|
||||||
async def handle_knowledge_document_onlyoffice_callback(
|
async def handle_knowledge_document_onlyoffice_callback(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> KnowledgeOnlyOfficeCallbackRead:
|
) -> KnowledgeOnlyOfficeCallbackRead:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
try:
|
try:
|
||||||
KnowledgeService().handle_onlyoffice_callback(document_id, payload)
|
KnowledgeService().handle_onlyoffice_callback(document_id, payload)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
return KnowledgeOnlyOfficeCallbackRead()
|
return KnowledgeOnlyOfficeCallbackRead()
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1.endpoints.auth import router as auth_router
|
from app.api.v1.endpoints.auth import router as auth_router
|
||||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||||
from app.api.v1.endpoints.employees import router as employees_router
|
from app.api.v1.endpoints.employees import router as employees_router
|
||||||
from app.api.v1.endpoints.health import router as health_router
|
from app.api.v1.endpoints.health import router as health_router
|
||||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||||
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
||||||
from app.api.v1.endpoints.settings import router as settings_router
|
from app.api.v1.endpoints.settings import router as settings_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(health_router, tags=["health"])
|
router.include_router(health_router, tags=["health"])
|
||||||
router.include_router(bootstrap_router, tags=["bootstrap"])
|
router.include_router(bootstrap_router, tags=["bootstrap"])
|
||||||
router.include_router(auth_router, tags=["auth"])
|
router.include_router(auth_router, tags=["auth"])
|
||||||
router.include_router(knowledge_router, tags=["knowledge"])
|
router.include_router(knowledge_router, tags=["knowledge"])
|
||||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||||
router.include_router(settings_router, tags=["settings"])
|
router.include_router(settings_router, tags=["settings"])
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import get_logger, setup_logging
|
from app.core.logging import get_logger, setup_logging
|
||||||
from app.middleware.logging import AccessLogMiddleware
|
from app.middleware.logging import AccessLogMiddleware
|
||||||
from app.services.employee import prepare_employee_directory
|
from app.services.employee import prepare_employee_directory
|
||||||
from app.services.knowledge import prepare_knowledge_library
|
from app.services.knowledge import prepare_knowledge_library
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
setup_logging(
|
setup_logging(
|
||||||
level=settings.log_level,
|
level=settings.log_level,
|
||||||
log_dir=settings.log_dir,
|
log_dir=settings.log_dir,
|
||||||
enable_file=settings.log_file_enabled,
|
enable_file=settings.log_file_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger("app.main")
|
logger = get_logger("app.main")
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
|
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
debug=settings.app_debug,
|
debug=settings.app_debug,
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(AccessLogMiddleware)
|
app.add_middleware(AccessLogMiddleware)
|
||||||
|
|
||||||
if settings.cors_origins:
|
if settings.cors_origins:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
@app.get("/", tags=["root"])
|
@app.get("/", tags=["root"])
|
||||||
def root() -> dict[str, str]:
|
def root() -> dict[str, str]:
|
||||||
return {"message": f"{settings.app_name} is running"}
|
return {"message": f"{settings.app_name} is running"}
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def _on_startup() -> None:
|
def _on_startup() -> None:
|
||||||
prepare_employee_directory()
|
prepare_employee_directory()
|
||||||
prepare_knowledge_library()
|
prepare_knowledge_library()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Server ready - host=%s port=%s prefix=%s",
|
"Server ready - host=%s port=%s prefix=%s",
|
||||||
settings.app_host,
|
settings.app_host,
|
||||||
settings.app_port,
|
settings.app_port,
|
||||||
settings.api_v1_prefix,
|
settings.api_v1_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__all__ = ["employee", "knowledge", "reimbursement"]
|
__all__ = ["employee", "knowledge", "reimbursement"]
|
||||||
|
|||||||
@@ -1,72 +1,72 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeFolderRead(BaseModel):
|
class KnowledgeFolderRead(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
count: int
|
count: int
|
||||||
icon: str = "mdi mdi-folder"
|
icon: str = "mdi mdi-folder"
|
||||||
|
|
||||||
|
|
||||||
class KnowledgePreviewStatRead(BaseModel):
|
class KnowledgePreviewStatRead(BaseModel):
|
||||||
label: str
|
label: str
|
||||||
value: str
|
value: str
|
||||||
|
|
||||||
|
|
||||||
class KnowledgePreviewBlockRead(BaseModel):
|
class KnowledgePreviewBlockRead(BaseModel):
|
||||||
heading: str
|
heading: str
|
||||||
lines: list[str] = Field(default_factory=list)
|
lines: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class KnowledgePreviewPageRead(BaseModel):
|
class KnowledgePreviewPageRead(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
subtitle: str
|
subtitle: str
|
||||||
stats: list[KnowledgePreviewStatRead] = Field(default_factory=list)
|
stats: list[KnowledgePreviewStatRead] = Field(default_factory=list)
|
||||||
blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list)
|
blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeDocumentRead(BaseModel):
|
class KnowledgeDocumentRead(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
folder: str
|
folder: str
|
||||||
tag: str
|
tag: str
|
||||||
time: str
|
time: str
|
||||||
version: str
|
version: str
|
||||||
state: str
|
state: str
|
||||||
stateTone: str
|
stateTone: str
|
||||||
owner: str
|
owner: str
|
||||||
icon: str
|
icon: str
|
||||||
fileType: str
|
fileType: str
|
||||||
fileTypeLabel: str
|
fileTypeLabel: str
|
||||||
summary: str
|
summary: str
|
||||||
mimeType: str
|
mimeType: str
|
||||||
extension: str
|
extension: str
|
||||||
sizeBytes: int
|
sizeBytes: int
|
||||||
canPreview: bool = False
|
canPreview: bool = False
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeDocumentDetailRead(KnowledgeDocumentRead):
|
class KnowledgeDocumentDetailRead(KnowledgeDocumentRead):
|
||||||
previewKind: str
|
previewKind: str
|
||||||
previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list)
|
previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeOnlyOfficeConfigRead(BaseModel):
|
class KnowledgeOnlyOfficeConfigRead(BaseModel):
|
||||||
documentServerUrl: str
|
documentServerUrl: str
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeOnlyOfficeCallbackRead(BaseModel):
|
class KnowledgeOnlyOfficeCallbackRead(BaseModel):
|
||||||
error: int = 0
|
error: int = 0
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeLibraryRead(BaseModel):
|
class KnowledgeLibraryRead(BaseModel):
|
||||||
folders: list[KnowledgeFolderRead] = Field(default_factory=list)
|
folders: list[KnowledgeFolderRead] = Field(default_factory=list)
|
||||||
documents: list[KnowledgeDocumentRead] = Field(default_factory=list)
|
documents: list[KnowledgeDocumentRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeActionResponse(BaseModel):
|
class KnowledgeActionResponse(BaseModel):
|
||||||
ok: bool = True
|
ok: bool = True
|
||||||
detail: str
|
detail: str
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,16 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"documents": [
|
"documents": [
|
||||||
{
|
{
|
||||||
"id": "fde293670eac4ae2b90a80eeb9f27b5b",
|
"id": "8af9350f0e02488aaf0df2001286b764",
|
||||||
"folder": "财务知识库",
|
"folder": "财务知识库",
|
||||||
"original_name": "差旅费季度报销258878.xlsx",
|
"original_name": "差旅费季度报销258878.xlsx",
|
||||||
"stored_name": "fde293670eac4ae2b90a80eeb9f27b5b__差旅费季度报销258878.xlsx",
|
"stored_name": "8af9350f0e02488aaf0df2001286b764__差旅费季度报销258878.xlsx",
|
||||||
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
"extension": "xlsx",
|
"extension": "xlsx",
|
||||||
"size_bytes": 11123,
|
"size_bytes": 11123,
|
||||||
"sha256": "ea02e59d3a22a4a02284172acce3fd4c6367a26f1a4fd196dc4f65afed1bd4c5",
|
"sha256": "ea02e59d3a22a4a02284172acce3fd4c6367a26f1a4fd196dc4f65afed1bd4c5",
|
||||||
"created_at": "2026-05-09T03:33:44.101489+00:00",
|
"created_at": "2026-05-09T05:46:24.699125+00:00",
|
||||||
"updated_at": "2026-05-09T03:33:44.101489+00:00",
|
"updated_at": "2026-05-09T05:46:24.699125+00:00",
|
||||||
"uploaded_by": "admin",
|
"uploaded_by": "admin",
|
||||||
"version_number": 1
|
"version_number": 1
|
||||||
}
|
}
|
||||||
|
|||||||
40
server/tests/test_server_start_dependencies.py
Normal file
40
server/tests/test_server_start_dependencies.py
Normal 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
|
||||||
40
start.sh
40
start.sh
@@ -38,6 +38,10 @@ fi
|
|||||||
|
|
||||||
ENV_OVERRIDE_WEB_HOST_SET=false
|
ENV_OVERRIDE_WEB_HOST_SET=false
|
||||||
ENV_OVERRIDE_SERVER_HOST_SET=false
|
ENV_OVERRIDE_SERVER_HOST_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=false
|
||||||
|
|
||||||
if [ "${WEB_HOST+x}" = x ]; then
|
if [ "${WEB_HOST+x}" = x ]; then
|
||||||
ENV_OVERRIDE_WEB_HOST_SET=true
|
ENV_OVERRIDE_WEB_HOST_SET=true
|
||||||
@@ -49,6 +53,26 @@ if [ "${SERVER_HOST+x}" = x ]; then
|
|||||||
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
|
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_PUBLIC_URL+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL="$ONLYOFFICE_PUBLIC_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_BACKEND_URL+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL="$ONLYOFFICE_BACKEND_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${ONLYOFFICE_JWT_SECRET+x}" = x ]; then
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET=true
|
||||||
|
ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET="$ONLYOFFICE_JWT_SECRET"
|
||||||
|
fi
|
||||||
|
|
||||||
set -a
|
set -a
|
||||||
. "$ENV_FILE"
|
. "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
@@ -61,6 +85,22 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
|
|||||||
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
|
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
|
||||||
|
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET" = true ]; then
|
||||||
|
ONLYOFFICE_PUBLIC_URL="$ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET" = true ]; then
|
||||||
|
ONLYOFFICE_BACKEND_URL="$ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then
|
||||||
|
ONLYOFFICE_JWT_SECRET="$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET"
|
||||||
|
fi
|
||||||
|
|
||||||
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
|
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
|
||||||
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
||||||
APP_DEBUG="${APP_DEBUG:-true}"
|
APP_DEBUG="${APP_DEBUG:-true}"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,157 +1,157 @@
|
|||||||
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
||||||
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
|
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
|
||||||
|
|
||||||
function readCurrentUserHeaders() {
|
function readCurrentUserHeaders() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
|
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(raw)
|
const payload = JSON.parse(raw)
|
||||||
const username = String(payload?.username || '').trim()
|
const username = String(payload?.username || '').trim()
|
||||||
const name = String(payload?.name || username).trim()
|
const name = String(payload?.name || username).trim()
|
||||||
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
||||||
const isAdmin = Boolean(payload?.isAdmin)
|
const isAdmin = Boolean(payload?.isAdmin)
|
||||||
|
|
||||||
if (!username && !name) {
|
if (!username && !name) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'x-auth-username': username,
|
'x-auth-username': username,
|
||||||
'x-auth-name': name,
|
'x-auth-name': name,
|
||||||
'x-auth-role-codes': roleCodes.join(','),
|
'x-auth-role-codes': roleCodes.join(','),
|
||||||
'x-auth-is-admin': String(isAdmin)
|
'x-auth-is-admin': String(isAdmin)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeApiBaseUrl(value) {
|
function normalizeApiBaseUrl(value) {
|
||||||
return String(value || '/api/v1').replace(/\/$/, '')
|
return String(value || '/api/v1').replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoopbackHost(hostname) {
|
function isLoopbackHost(hostname) {
|
||||||
const normalized = String(hostname || '').trim().toLowerCase()
|
const normalized = String(hostname || '').trim().toLowerCase()
|
||||||
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1'
|
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1'
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrowserReachableApiBaseUrl(value) {
|
function resolveBrowserReachableApiBaseUrl(value) {
|
||||||
const normalized = normalizeApiBaseUrl(value)
|
const normalized = normalizeApiBaseUrl(value)
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = new URL(normalized)
|
const apiUrl = new URL(normalized)
|
||||||
const browserHost = window.location.hostname
|
const browserHost = window.location.hostname
|
||||||
|
|
||||||
if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) {
|
if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) {
|
||||||
apiUrl.hostname = browserHost
|
apiUrl.hostname = browserHost
|
||||||
return normalizeApiBaseUrl(apiUrl.toString())
|
return normalizeApiBaseUrl(apiUrl.toString())
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStoredApiBaseUrl() {
|
function readStoredApiBaseUrl() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
|
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1')
|
let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1')
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(API_BASE_STORAGE_KEY)
|
window.localStorage.removeItem(API_BASE_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRuntimeApiBaseUrl(value) {
|
export function setRuntimeApiBaseUrl(value) {
|
||||||
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
|
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl)
|
window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRuntimeApiBaseUrl() {
|
export function getRuntimeApiBaseUrl() {
|
||||||
return runtimeApiBaseUrl
|
return runtimeApiBaseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(path) {
|
function buildUrl(path) {
|
||||||
if (!path.startsWith('/')) {
|
if (!path.startsWith('/')) {
|
||||||
return `${runtimeApiBaseUrl}/${path}`
|
return `${runtimeApiBaseUrl}/${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${runtimeApiBaseUrl}${path}`
|
return `${runtimeApiBaseUrl}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest(path, options = {}) {
|
export async function apiRequest(path, options = {}) {
|
||||||
const {
|
const {
|
||||||
contentType = 'application/json',
|
contentType = 'application/json',
|
||||||
responseType = 'json',
|
responseType = 'json',
|
||||||
headers: customHeaders,
|
headers: customHeaders,
|
||||||
...fetchOptions
|
...fetchOptions
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
...readCurrentUserHeaders(),
|
...readCurrentUserHeaders(),
|
||||||
...(customHeaders || {})
|
...(customHeaders || {})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
||||||
headers['Content-Type'] = contentType
|
headers['Content-Type'] = contentType
|
||||||
}
|
}
|
||||||
|
|
||||||
let response
|
let response
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(buildUrl(path), {
|
response = await fetch(buildUrl(path), {
|
||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
headers
|
headers
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
|
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseType === 'blob') {
|
if (responseType === 'blob') {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let payload = null
|
let payload = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
payload = await response.json()
|
payload = await response.json()
|
||||||
} catch {
|
} catch {
|
||||||
payload = null
|
payload = null
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
|
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob()
|
return response.blob()
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload = null
|
let payload = null
|
||||||
try {
|
try {
|
||||||
payload = await response.json()
|
payload = await response.json()
|
||||||
} catch {
|
} catch {
|
||||||
payload = null
|
payload = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
|
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
import { apiRequest } from './api.js'
|
import { apiRequest } from './api.js'
|
||||||
|
|
||||||
export function fetchKnowledgeLibrary() {
|
export function fetchKnowledgeLibrary() {
|
||||||
return apiRequest('/knowledge/library')
|
return apiRequest('/knowledge/library')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchKnowledgeDocument(documentId) {
|
export function fetchKnowledgeDocument(documentId) {
|
||||||
return apiRequest(`/knowledge/documents/${documentId}`)
|
return apiRequest(`/knowledge/documents/${documentId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchKnowledgeOnlyOfficeConfig(documentId) {
|
export function fetchKnowledgeOnlyOfficeConfig(documentId) {
|
||||||
return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`)
|
return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadKnowledgeDocument({ folder, file }) {
|
export function uploadKnowledgeDocument({ folder, file }) {
|
||||||
return apiRequest(
|
return apiRequest(
|
||||||
`/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`,
|
`/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: file,
|
body: file,
|
||||||
contentType: file.type || 'application/octet-stream'
|
contentType: file.type || 'application/octet-stream'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteKnowledgeDocument(documentId) {
|
export function deleteKnowledgeDocument(documentId) {
|
||||||
return apiRequest(`/knowledge/documents/${documentId}`, {
|
return apiRequest(`/knowledge/documents/${documentId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') {
|
export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') {
|
||||||
return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, {
|
return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
contentType: null
|
contentType: null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
const scriptPromises = new Map()
|
const scriptPromises = new Map()
|
||||||
|
|
||||||
function normalizeBaseUrl(value) {
|
function normalizeBaseUrl(value) {
|
||||||
return String(value || '').replace(/\/$/, '')
|
return String(value || '').replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOnlyOfficeScriptUrl(documentServerUrl) {
|
export function buildOnlyOfficeScriptUrl(documentServerUrl) {
|
||||||
return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js`
|
return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadOnlyOfficeApi(documentServerUrl) {
|
export function loadOnlyOfficeApi(documentServerUrl) {
|
||||||
const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl)
|
const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl)
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。'))
|
return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.DocsAPI?.DocEditor) {
|
if (window.DocsAPI?.DocEditor) {
|
||||||
return Promise.resolve(window.DocsAPI)
|
return Promise.resolve(window.DocsAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scriptPromises.has(scriptUrl)) {
|
if (scriptPromises.has(scriptUrl)) {
|
||||||
return scriptPromises.get(scriptUrl)
|
return scriptPromises.get(scriptUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
const existing = document.querySelector(`script[src="${scriptUrl}"]`)
|
const existing = document.querySelector(`script[src="${scriptUrl}"]`)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true })
|
existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true })
|
||||||
existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true })
|
existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script')
|
||||||
script.src = scriptUrl
|
script.src = scriptUrl
|
||||||
script.async = true
|
script.async = true
|
||||||
script.onload = () => resolve(window.DocsAPI)
|
script.onload = () => resolve(window.DocsAPI)
|
||||||
script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。'))
|
script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。'))
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script)
|
||||||
})
|
})
|
||||||
|
|
||||||
scriptPromises.set(scriptUrl, promise)
|
scriptPromises.set(scriptUrl, promise)
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,63 @@
|
|||||||
export const DEFAULT_APP_VIEW_ORDER = [
|
export const DEFAULT_APP_VIEW_ORDER = [
|
||||||
'overview',
|
'overview',
|
||||||
'workbench',
|
'workbench',
|
||||||
'requests',
|
'requests',
|
||||||
'approval',
|
'approval',
|
||||||
'chat',
|
'chat',
|
||||||
'policies',
|
'policies',
|
||||||
'audit',
|
'audit',
|
||||||
'employees',
|
'employees',
|
||||||
'settings'
|
'settings'
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies'])
|
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies'])
|
||||||
const VIEW_ROLE_RULES = {
|
const VIEW_ROLE_RULES = {
|
||||||
overview: ['finance', 'executive'],
|
overview: ['finance', 'executive'],
|
||||||
approval: ['approver'],
|
approval: ['approver'],
|
||||||
audit: ['auditor'],
|
audit: ['auditor'],
|
||||||
employees: ['manager'],
|
employees: ['manager'],
|
||||||
settings: ['manager']
|
settings: ['manager']
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedRoleCodes(user) {
|
function normalizedRoleCodes(user) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
|
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isManagerUser(user) {
|
export function isManagerUser(user) {
|
||||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAccessAppView(user, viewId) {
|
export function canAccessAppView(user, viewId) {
|
||||||
if (!viewId || !user) {
|
if (!viewId || !user) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isManagerUser(user)) {
|
if (isManagerUser(user)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
|
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredRoles = VIEW_ROLE_RULES[viewId] || []
|
const requiredRoles = VIEW_ROLE_RULES[viewId] || []
|
||||||
const roleCodes = normalizedRoleCodes(user)
|
const roleCodes = normalizedRoleCodes(user)
|
||||||
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
|
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAccessibleViewIds(user) {
|
export function getAccessibleViewIds(user) {
|
||||||
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
|
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterNavItemsByAccess(navItems, user) {
|
export function filterNavItemsByAccess(navItems, user) {
|
||||||
return navItems.filter((item) => canAccessAppView(user, item.id))
|
return navItems.filter((item) => canAccessAppView(user, item.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultAuthorizedRoute(user) {
|
export function resolveDefaultAuthorizedRoute(user) {
|
||||||
const firstVisibleView = getAccessibleViewIds(user)[0]
|
const firstVisibleView = getAccessibleViewIds(user)[0]
|
||||||
return { name: `app-${firstVisibleView || 'workbench'}` }
|
return { name: `app-${firstVisibleView || 'workbench'}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,200 +1,200 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<SidebarRail
|
<SidebarRail
|
||||||
:nav-items="filteredNavItems"
|
:nav-items="filteredNavItems"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:company-name="companyProfile.name"
|
:company-name="companyProfile.name"
|
||||||
:current-user="currentUser"
|
:current-user="currentUser"
|
||||||
@navigate="handleNavigate"
|
@navigate="handleNavigate"
|
||||||
@open-chat="handleOpenChat"
|
@open-chat="handleOpenChat"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
class="main"
|
class="main"
|
||||||
:class="{
|
:class="{
|
||||||
'chat-main': activeView === 'chat',
|
'chat-main': activeView === 'chat',
|
||||||
'overview-main': activeView === 'overview',
|
'overview-main': activeView === 'overview',
|
||||||
'workbench-main': activeView === 'workbench',
|
'workbench-main': activeView === 'workbench',
|
||||||
'requests-main': activeView === 'requests',
|
'requests-main': activeView === 'requests',
|
||||||
'approval-main': activeView === 'approval',
|
'approval-main': activeView === 'approval',
|
||||||
'policies-main': activeView === 'policies',
|
'policies-main': activeView === 'policies',
|
||||||
'audit-main': activeView === 'audit',
|
'audit-main': activeView === 'audit',
|
||||||
'employees-main': activeView === 'employees',
|
'employees-main': activeView === 'employees',
|
||||||
'settings-main': activeView === 'settings'
|
'settings-main': activeView === 'settings'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<TopBar
|
<TopBar
|
||||||
v-if="activeView !== 'settings'"
|
v-if="activeView !== 'settings'"
|
||||||
:current-view="topBarView"
|
:current-view="topBarView"
|
||||||
:search="search"
|
:search="search"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:ranges="ranges"
|
:ranges="ranges"
|
||||||
:active-range="activeRange"
|
:active-range="activeRange"
|
||||||
:employee-summary="employeeSummary"
|
:employee-summary="employeeSummary"
|
||||||
:knowledge-summary="knowledgeSummary"
|
:knowledge-summary="knowledgeSummary"
|
||||||
:custom-range="customRange"
|
:custom-range="customRange"
|
||||||
@update:search="search = $event"
|
@update:search="search = $event"
|
||||||
@update:active-range="activeRange = $event"
|
@update:active-range="activeRange = $event"
|
||||||
@update:custom-range="customRange = $event"
|
@update:custom-range="customRange = $event"
|
||||||
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
@batch-approve="toast('已批量通过 23 条审批任务。')"
|
||||||
@open-chat="handleOpenChat"
|
@open-chat="handleOpenChat"
|
||||||
@new-application="openTravelCreate"
|
@new-application="openTravelCreate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees' && activeView !== 'settings'"
|
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees' && activeView !== 'settings'"
|
||||||
:compact="activeView === 'overview'"
|
:compact="activeView === 'overview'"
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
:ranges="ranges"
|
:ranges="ranges"
|
||||||
:active-range="activeRange"
|
:active-range="activeRange"
|
||||||
@update:active-range="activeRange = $event"
|
@update:active-range="activeRange = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="workarea"
|
class="workarea"
|
||||||
:class="{
|
:class="{
|
||||||
'chat-workarea': activeView === 'chat',
|
'chat-workarea': activeView === 'chat',
|
||||||
'requests-workarea': activeView === 'requests',
|
'requests-workarea': activeView === 'requests',
|
||||||
'approval-workarea': activeView === 'approval',
|
'approval-workarea': activeView === 'approval',
|
||||||
'policies-workarea': activeView === 'policies',
|
'policies-workarea': activeView === 'policies',
|
||||||
'audit-workarea': activeView === 'audit',
|
'audit-workarea': activeView === 'audit',
|
||||||
'employees-workarea': activeView === 'employees',
|
'employees-workarea': activeView === 'employees',
|
||||||
'settings-workarea': activeView === 'settings'
|
'settings-workarea': activeView === 'settings'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<OverviewView
|
<OverviewView
|
||||||
v-if="activeView === 'overview'"
|
v-if="activeView === 'overview'"
|
||||||
:filtered-requests="filteredRequests"
|
:filtered-requests="filteredRequests"
|
||||||
@ask="handleOpenChat"
|
@ask="handleOpenChat"
|
||||||
@approve="handleApprove"
|
@approve="handleApprove"
|
||||||
@reject="handleReject"
|
@reject="handleReject"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PersonalWorkbenchView
|
<PersonalWorkbenchView
|
||||||
v-else-if="activeView === 'workbench'"
|
v-else-if="activeView === 'workbench'"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatView
|
<ChatView
|
||||||
v-else-if="activeView === 'chat'"
|
v-else-if="activeView === 'chat'"
|
||||||
:documents="filteredDocuments"
|
:documents="filteredDocuments"
|
||||||
:doc-search="docSearch"
|
:doc-search="docSearch"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:uploaded-files="uploadedFiles"
|
:uploaded-files="uploadedFiles"
|
||||||
:active-case="activeCase"
|
:active-case="activeCase"
|
||||||
:quick-prompts="travelPrompts"
|
:quick-prompts="travelPrompts"
|
||||||
:draft="draft"
|
:draft="draft"
|
||||||
:message-list="messageList"
|
:message-list="messageList"
|
||||||
@send="sendMessage"
|
@send="sendMessage"
|
||||||
@upload="handleUpload"
|
@upload="handleUpload"
|
||||||
@draft="draft = $event"
|
@draft="draft = $event"
|
||||||
@select-case="handleOpenChat"
|
@select-case="handleOpenChat"
|
||||||
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过。`)"
|
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过。`)"
|
||||||
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回。`)"
|
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回。`)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TravelRequestDetailView
|
<TravelRequestDetailView
|
||||||
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
|
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
|
||||||
:request="selectedTravelRequest"
|
:request="selectedTravelRequest"
|
||||||
@back-to-requests="closeRequestDetail"
|
@back-to-requests="closeRequestDetail"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RequestsView
|
<RequestsView
|
||||||
v-else-if="activeView === 'requests'"
|
v-else-if="activeView === 'requests'"
|
||||||
:filtered-requests="filteredRequests"
|
:filtered-requests="filteredRequests"
|
||||||
@ask="openRequestDetail"
|
@ask="openRequestDetail"
|
||||||
@approve="handleApprove"
|
@approve="handleApprove"
|
||||||
@reject="handleReject"
|
@reject="handleReject"
|
||||||
@create-request="openTravelCreate"
|
@create-request="openTravelCreate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||||
<AuditView v-else-if="activeView === 'audit'" />
|
<AuditView v-else-if="activeView === 'audit'" />
|
||||||
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
|
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
|
||||||
<SettingsView v-else />
|
<SettingsView v-else />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TravelReimbursementCreateView
|
<TravelReimbursementCreateView
|
||||||
v-if="smartEntryOpen"
|
v-if="smartEntryOpen"
|
||||||
:key="smartEntrySessionId"
|
:key="smartEntrySessionId"
|
||||||
:initial-prompt="smartEntryContext.prompt"
|
:initial-prompt="smartEntryContext.prompt"
|
||||||
:entry-source="smartEntryContext.source"
|
:entry-source="smartEntryContext.source"
|
||||||
:request-context="smartEntryContext.request"
|
:request-context="smartEntryContext.request"
|
||||||
@close="closeSmartEntry"
|
@close="closeSmartEntry"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||||
import TopBar from '../components/layout/TopBar.vue'
|
import TopBar from '../components/layout/TopBar.vue'
|
||||||
import FilterBar from '../components/layout/FilterBar.vue'
|
import FilterBar from '../components/layout/FilterBar.vue'
|
||||||
import OverviewView from './OverviewView.vue'
|
import OverviewView from './OverviewView.vue'
|
||||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||||
import ChatView from './ChatView.vue'
|
import ChatView from './ChatView.vue'
|
||||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||||
import RequestsView from './RequestsView.vue'
|
import RequestsView from './RequestsView.vue'
|
||||||
import ApprovalCenterView from './ApprovalCenterView.vue'
|
import ApprovalCenterView from './ApprovalCenterView.vue'
|
||||||
import PoliciesView from './PoliciesView.vue'
|
import PoliciesView from './PoliciesView.vue'
|
||||||
import AuditView from './AuditView.vue'
|
import AuditView from './AuditView.vue'
|
||||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||||
import SettingsView from './SettingsView.vue'
|
import SettingsView from './SettingsView.vue'
|
||||||
|
|
||||||
import { useAppShell } from '../composables/useAppShell.js'
|
import { useAppShell } from '../composables/useAppShell.js'
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||||
|
|
||||||
const employeeSummary = ref(null)
|
const employeeSummary = ref(null)
|
||||||
const knowledgeSummary = ref(null)
|
const knowledgeSummary = ref(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeCase,
|
activeCase,
|
||||||
activeRange,
|
activeRange,
|
||||||
activeView,
|
activeView,
|
||||||
closeRequestDetail,
|
closeRequestDetail,
|
||||||
closeSmartEntry,
|
closeSmartEntry,
|
||||||
customRange,
|
customRange,
|
||||||
detailMode,
|
detailMode,
|
||||||
docSearch,
|
docSearch,
|
||||||
draft,
|
draft,
|
||||||
filteredDocuments,
|
filteredDocuments,
|
||||||
filteredRequests,
|
filteredRequests,
|
||||||
filters,
|
filters,
|
||||||
handleApprove,
|
handleApprove,
|
||||||
handleNavigate,
|
handleNavigate,
|
||||||
handleOpenChat,
|
handleOpenChat,
|
||||||
handleReject,
|
handleReject,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
messageList,
|
messageList,
|
||||||
messages,
|
messages,
|
||||||
navItems,
|
navItems,
|
||||||
openRequestDetail,
|
openRequestDetail,
|
||||||
openSmartEntry,
|
openSmartEntry,
|
||||||
openTravelCreate,
|
openTravelCreate,
|
||||||
ranges,
|
ranges,
|
||||||
search,
|
search,
|
||||||
selectedTravelRequest,
|
selectedTravelRequest,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
smartEntryContext,
|
smartEntryContext,
|
||||||
smartEntryOpen,
|
smartEntryOpen,
|
||||||
smartEntrySessionId,
|
smartEntrySessionId,
|
||||||
toast,
|
toast,
|
||||||
topBarView,
|
topBarView,
|
||||||
travelPrompts,
|
travelPrompts,
|
||||||
uploadedFiles
|
uploadedFiles
|
||||||
} = useAppShell()
|
} = useAppShell()
|
||||||
|
|
||||||
const { companyProfile, currentUser, logout } = useSystemState()
|
const { companyProfile, currentUser, logout } = useSystemState()
|
||||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logout('manual')
|
logout('manual')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,199 +1,199 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="knowledge-page">
|
<section class="knowledge-page">
|
||||||
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
|
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
|
||||||
<section class="knowledge-main">
|
<section class="knowledge-main">
|
||||||
<article class="library-panel panel">
|
<article class="library-panel panel">
|
||||||
<header class="panel-title">
|
<header class="panel-title">
|
||||||
<div>
|
<div>
|
||||||
<h2>文档库 / 文件夹</h2>
|
<h2>文档库 / 文件夹</h2>
|
||||||
<p>默认展示文件列表,点击具体文件后可在右侧展开预览。</p>
|
<p>默认展示文件列表,点击具体文件后可在右侧展开预览。</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="file-search">
|
<label class="file-search">
|
||||||
<i class="mdi mdi-magnify"></i>
|
<i class="mdi mdi-magnify"></i>
|
||||||
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
|
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
|
||||||
</label>
|
</label>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="library-body">
|
<div class="library-body">
|
||||||
<aside class="folder-rail">
|
<aside class="folder-rail">
|
||||||
<nav class="folder-tree" aria-label="知识库文件夹">
|
<nav class="folder-tree" aria-label="知识库文件夹">
|
||||||
<button
|
<button
|
||||||
v-for="folder in filteredFolders"
|
v-for="folder in filteredFolders"
|
||||||
:key="folder.name"
|
:key="folder.name"
|
||||||
type="button"
|
type="button"
|
||||||
:class="{ active: activeFolder === folder.name }"
|
:class="{ active: activeFolder === folder.name }"
|
||||||
@click="activeFolder = folder.name"
|
@click="activeFolder = folder.name"
|
||||||
>
|
>
|
||||||
<i :class="folder.icon"></i>
|
<i :class="folder.icon"></i>
|
||||||
<span>{{ folder.name }}</span>
|
<span>{{ folder.name }}</span>
|
||||||
<b>{{ folder.count }}</b>
|
<b>{{ folder.count }}</b>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button class="new-folder-btn fixed" type="button" disabled>
|
<button class="new-folder-btn fixed" type="button" disabled>
|
||||||
<i class="mdi mdi-lock-outline"></i>
|
<i class="mdi mdi-lock-outline"></i>
|
||||||
<span>固定文件夹</span>
|
<span>固定文件夹</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="document-area">
|
<section class="document-area">
|
||||||
<div
|
<div
|
||||||
class="upload-zone"
|
class="upload-zone"
|
||||||
:class="{ disabled: !isAdmin, busy: uploading }"
|
:class="{ disabled: !isAdmin, busy: uploading }"
|
||||||
@click="triggerUpload"
|
@click="triggerUpload"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref="uploadInput"
|
ref="uploadInput"
|
||||||
class="upload-input"
|
class="upload-input"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
@change="handleFileInput"
|
@change="handleFileInput"
|
||||||
/>
|
/>
|
||||||
<i class="mdi mdi-cloud-upload"></i>
|
<i class="mdi mdi-cloud-upload"></i>
|
||||||
<strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
|
<strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
|
||||||
<span>{{ uploadHint }}</span>
|
<span>{{ uploadHint }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="doc-table-wrap">
|
<div class="doc-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>文件名称</th>
|
<th>文件名称</th>
|
||||||
<th>标签</th>
|
<th>标签</th>
|
||||||
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
|
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
|
||||||
<th>版本</th>
|
<th>版本</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>上传人</th>
|
<th>上传人</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="doc in visibleDocuments"
|
v-for="doc in visibleDocuments"
|
||||||
:key="doc.id"
|
:key="doc.id"
|
||||||
class="doc-row"
|
class="doc-row"
|
||||||
:class="{ selected: selectedDocument?.id === doc.id }"
|
:class="{ selected: selectedDocument?.id === doc.id }"
|
||||||
@click="selectDocument(doc.id)"
|
@click="selectDocument(doc.id)"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<span class="file-name">
|
<span class="file-name">
|
||||||
<i :class="doc.icon"></i>
|
<i :class="doc.icon"></i>
|
||||||
{{ doc.name }}
|
{{ doc.name }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="doc-tag">{{ doc.tag }}</span>
|
<span class="doc-tag">{{ doc.tag }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ doc.time }}</td>
|
<td>{{ doc.time }}</td>
|
||||||
<td>{{ doc.version }}</td>
|
<td>{{ doc.version }}</td>
|
||||||
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
|
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
|
||||||
<td>{{ doc.owner }}</td>
|
<td>{{ doc.owner }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions" @click.stop>
|
<div class="row-actions" @click.stop>
|
||||||
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
|
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
|
||||||
<i class="mdi mdi-download"></i>
|
<i class="mdi mdi-download"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="more-btn danger"
|
class="more-btn danger"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="deletingId === doc.id"
|
:disabled="deletingId === doc.id"
|
||||||
aria-label="删除文件"
|
aria-label="删除文件"
|
||||||
@click="handleDelete(doc)"
|
@click="handleDelete(doc)"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-delete-outline"></i>
|
<i class="mdi mdi-delete-outline"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!visibleDocuments.length">
|
<tr v-if="!visibleDocuments.length">
|
||||||
<td colspan="7" class="empty-row">
|
<td colspan="7" class="empty-row">
|
||||||
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
|
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="list-foot">
|
<footer class="list-foot">
|
||||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
||||||
<div class="pager" aria-label="分页">
|
<div class="pager" aria-label="分页">
|
||||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
||||||
<i class="mdi mdi-chevron-left"></i>
|
<i class="mdi mdi-chevron-left"></i>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="page in totalPages"
|
v-for="page in totalPages"
|
||||||
:key="page"
|
:key="page"
|
||||||
class="page-number"
|
class="page-number"
|
||||||
:class="{ active: currentPage === page }"
|
:class="{ active: currentPage === page }"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-current="currentPage === page ? 'page' : undefined"
|
:aria-current="currentPage === page ? 'page' : undefined"
|
||||||
@click="currentPage = page"
|
@click="currentPage = page"
|
||||||
>
|
>
|
||||||
{{ page }}
|
{{ page }}
|
||||||
</button>
|
</button>
|
||||||
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
|
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
|
||||||
<i class="mdi mdi-chevron-right"></i>
|
<i class="mdi mdi-chevron-right"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-size-wrap">
|
<div class="page-size-wrap">
|
||||||
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
|
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
|
||||||
{{ pageSize }} 条/页<i class="mdi mdi-chevron-down"></i>
|
{{ pageSize }} 条/页<i class="mdi mdi-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
|
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
|
||||||
<button
|
<button
|
||||||
v-for="size in pageSizes"
|
v-for="size in pageSizes"
|
||||||
:key="size"
|
:key="size"
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
:aria-selected="pageSize === size"
|
:aria-selected="pageSize === size"
|
||||||
:class="{ active: pageSize === size }"
|
:class="{ active: pageSize === size }"
|
||||||
@click="changePageSize(size)"
|
@click="changePageSize(size)"
|
||||||
>
|
>
|
||||||
{{ size }} 条/页
|
{{ size }} 条/页
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Transition name="preview-panel">
|
<Transition name="preview-panel">
|
||||||
<aside v-if="selectedDocument" class="preview-column">
|
<aside v-if="selectedDocument" class="preview-column">
|
||||||
<article class="preview-panel panel">
|
<article class="preview-panel panel">
|
||||||
<header class="preview-head">
|
<header class="preview-head">
|
||||||
<div class="preview-copy">
|
<div class="preview-copy">
|
||||||
<h2>{{ selectedDocument.name }}</h2>
|
<h2>{{ selectedDocument.name }}</h2>
|
||||||
<p class="preview-summary-line">
|
<p class="preview-summary-line">
|
||||||
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
|
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
|
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
|
||||||
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
|
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<button type="button" class="mini-action" @click="handleDownload(selectedDocument)">
|
<button type="button" class="mini-action" @click="handleDownload(selectedDocument)">
|
||||||
<i class="mdi mdi-download"></i>
|
<i class="mdi mdi-download"></i>
|
||||||
<span>下载</span>
|
<span>下载</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
|
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="preview-viewer">
|
<div class="preview-viewer">
|
||||||
<div v-if="previewLoading" class="preview-status">正在加载预览...</div>
|
<div v-if="previewLoading" class="preview-status">正在加载预览...</div>
|
||||||
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
|
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
|
||||||
<div v-else-if="selectedDocument.previewKind === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
|
<div v-else-if="previewMode === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
|
||||||
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
|
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedDocument.previewKind === 'image' && previewBlobUrl" class="preview-image-wrap">
|
<div v-else-if="previewMode === 'image' && previewBlobUrl" class="preview-image-wrap">
|
||||||
<img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" />
|
<img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="shouldUseOnlyOffice" class="onlyoffice-preview-wrap">
|
<div v-else-if="shouldUseOnlyOffice" class="onlyoffice-preview-wrap">
|
||||||
@@ -201,64 +201,64 @@
|
|||||||
<div v-else-if="onlyOfficeError" class="preview-status error">{{ onlyOfficeError }}</div>
|
<div v-else-if="onlyOfficeError" class="preview-status error">{{ onlyOfficeError }}</div>
|
||||||
<div v-else :id="onlyOfficeHostId" class="onlyoffice-preview-host"></div>
|
<div v-else :id="onlyOfficeHostId" class="onlyoffice-preview-host"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedDocument.previewKind === 'table'" class="excel-preview-wrap">
|
<div v-else-if="previewMode === 'table'" class="excel-preview-wrap">
|
||||||
<div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签">
|
<div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签">
|
||||||
<button
|
<button
|
||||||
v-for="(page, index) in selectedDocument.previewPages"
|
v-for="(page, index) in selectedDocument.previewPages"
|
||||||
:key="`${selectedDocument.id}-sheet-${index}`"
|
:key="`${selectedDocument.id}-sheet-${index}`"
|
||||||
type="button"
|
type="button"
|
||||||
class="excel-sheet-tab"
|
class="excel-sheet-tab"
|
||||||
:class="{ active: currentPreviewPageIndex === index }"
|
:class="{ active: currentPreviewPageIndex === index }"
|
||||||
:aria-selected="currentPreviewPageIndex === index"
|
:aria-selected="currentPreviewPageIndex === index"
|
||||||
@click="selectPreviewPage(index)"
|
@click="selectPreviewPage(index)"
|
||||||
>
|
>
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll">
|
<div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll">
|
||||||
<table class="excel-preview-table">
|
<table class="excel-preview-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th>
|
<th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`">
|
<tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`">
|
||||||
<td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
|
<td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="preview-status">当前表格暂未提取到可展示内容。</div>
|
<div v-else class="preview-status">当前表格暂未提取到可展示内容。</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="page-stage">
|
<div v-else class="page-stage">
|
||||||
<article
|
<article
|
||||||
v-for="(page, index) in selectedDocument.previewPages"
|
v-for="(page, index) in selectedDocument.previewPages"
|
||||||
:key="`${selectedDocument.id}-${index}`"
|
:key="`${selectedDocument.id}-${index}`"
|
||||||
class="page-sheet"
|
class="page-sheet"
|
||||||
:style="{ '--page-delay': `${index * 70}ms` }"
|
:style="{ '--page-delay': `${index * 70}ms` }"
|
||||||
>
|
>
|
||||||
<section class="page-content">
|
<section class="page-content">
|
||||||
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
|
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
|
||||||
<h3>{{ block.heading }}</h3>
|
<h3>{{ block.heading }}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="line in block.lines" :key="line">{{ line }}</li>
|
<li v-for="line in block.lines" :key="line">{{ line }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
|
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
|
||||||
当前文件暂未生成结构化预览,请下载后查看。
|
当前文件暂未生成结构化预览,请下载后查看。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</aside>
|
</aside>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./scripts/PoliciesView.js"></script>
|
<script src="./scripts/PoliciesView.js"></script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/views/policies-view.css"></style>
|
<style scoped src="../assets/styles/views/policies-view.css"></style>
|
||||||
|
|||||||
@@ -1,177 +1,183 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
import {
|
import {
|
||||||
deleteKnowledgeDocument,
|
deleteKnowledgeDocument,
|
||||||
fetchKnowledgeDocument,
|
fetchKnowledgeDocument,
|
||||||
fetchKnowledgeDocumentBlob,
|
fetchKnowledgeDocumentBlob,
|
||||||
fetchKnowledgeLibrary,
|
fetchKnowledgeLibrary,
|
||||||
fetchKnowledgeOnlyOfficeConfig,
|
fetchKnowledgeOnlyOfficeConfig,
|
||||||
uploadKnowledgeDocument
|
uploadKnowledgeDocument
|
||||||
} from '../../services/knowledge.js'
|
} from '../../services/knowledge.js'
|
||||||
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
|
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
|
||||||
import { isManagerUser } from '../../utils/accessControl.js'
|
import { isManagerUser } from '../../utils/accessControl.js'
|
||||||
import {
|
import {
|
||||||
buildExcelPreviewTable,
|
buildExcelPreviewTable,
|
||||||
buildPreviewMetaLine,
|
buildPreviewMetaLine,
|
||||||
buildPreviewSecondaryMetaLine
|
buildPreviewSecondaryMetaLine
|
||||||
} from './policiesPreviewFormatters.js'
|
} from './policiesPreviewFormatters.js'
|
||||||
|
import { canUseOnlyOfficePreview, resolveKnowledgePreviewMode } from './knowledgePreviewMode.js'
|
||||||
|
|
||||||
function triggerFileDownload(blob, filename) {
|
function triggerFileDownload(blob, filename) {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = url
|
anchor.href = url
|
||||||
anchor.download = filename
|
anchor.download = filename
|
||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx'])
|
export default {
|
||||||
|
name: 'PoliciesView',
|
||||||
function supportsOnlyOfficePreview(document) {
|
emits: ['summary-change'],
|
||||||
return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase())
|
setup(_, { emit }) {
|
||||||
}
|
const { currentUser } = useSystemState()
|
||||||
|
const { toast } = useToast()
|
||||||
export default {
|
|
||||||
name: 'PoliciesView',
|
const documentSearch = ref('')
|
||||||
emits: ['summary-change'],
|
const activeFolder = ref('差旅规范')
|
||||||
setup(_, { emit }) {
|
const folders = ref([])
|
||||||
const { currentUser } = useSystemState()
|
const documents = ref([])
|
||||||
const { toast } = useToast()
|
const selectedDocument = ref(null)
|
||||||
|
const pageSizeOpen = ref(false)
|
||||||
const documentSearch = ref('')
|
const currentPage = ref(1)
|
||||||
const activeFolder = ref('差旅规范')
|
const pageSize = ref(10)
|
||||||
const folders = ref([])
|
const pageSizes = [10, 20, 50]
|
||||||
const documents = ref([])
|
const loading = ref(false)
|
||||||
const selectedDocument = ref(null)
|
const uploadInput = ref(null)
|
||||||
const pageSizeOpen = ref(false)
|
const uploading = ref(false)
|
||||||
const currentPage = ref(1)
|
const deletingId = ref('')
|
||||||
const pageSize = ref(10)
|
const previewLoading = ref(false)
|
||||||
const pageSizes = [10, 20, 50]
|
|
||||||
const loading = ref(false)
|
|
||||||
const uploadInput = ref(null)
|
|
||||||
const uploading = ref(false)
|
|
||||||
const deletingId = ref('')
|
|
||||||
const previewLoading = ref(false)
|
|
||||||
const previewBlobUrl = ref('')
|
const previewBlobUrl = ref('')
|
||||||
const previewError = ref('')
|
const previewError = ref('')
|
||||||
const onlyOfficeLoading = ref(false)
|
const onlyOfficeLoading = ref(false)
|
||||||
const onlyOfficeError = ref('')
|
const onlyOfficeError = ref('')
|
||||||
|
const onlyOfficeAvailable = ref(false)
|
||||||
const onlyOfficeEditor = ref(null)
|
const onlyOfficeEditor = ref(null)
|
||||||
const onlyOfficeHostId = ref('knowledge-onlyoffice-preview')
|
const onlyOfficeHostId = ref('knowledge-onlyoffice-preview')
|
||||||
const currentPreviewPageIndex = ref(0)
|
const currentPreviewPageIndex = ref(0)
|
||||||
|
|
||||||
const isAdmin = computed(() => isManagerUser(currentUser.value))
|
const isAdmin = computed(() => isManagerUser(currentUser.value))
|
||||||
const uploadHint = computed(() =>
|
const uploadHint = computed(() =>
|
||||||
isAdmin.value
|
isAdmin.value
|
||||||
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
|
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
|
||||||
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
|
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredFolders = computed(() => folders.value)
|
const filteredFolders = computed(() => folders.value)
|
||||||
|
|
||||||
const filteredDocuments = computed(() => {
|
const filteredDocuments = computed(() => {
|
||||||
const key = documentSearch.value.trim()
|
const key = documentSearch.value.trim()
|
||||||
|
|
||||||
return documents.value.filter((doc) => {
|
return documents.value.filter((doc) => {
|
||||||
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
|
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
|
||||||
const matchesSearch = key ? doc.name.includes(key) : true
|
const matchesSearch = key ? doc.name.includes(key) : true
|
||||||
return inFolder && matchesSearch
|
return inFolder && matchesSearch
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCount = computed(() => filteredDocuments.value.length)
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||||
|
const visibleDocuments = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
return filteredDocuments.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
const activePreviewPage = computed(() => {
|
||||||
|
const pages = selectedDocument.value?.previewPages || []
|
||||||
|
return pages[currentPreviewPageIndex.value] || pages[0] || null
|
||||||
|
})
|
||||||
|
const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value))
|
||||||
|
const previewSecondaryMetaLine = computed(() =>
|
||||||
|
buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value)
|
||||||
|
)
|
||||||
|
const previewMode = computed(() =>
|
||||||
|
resolveKnowledgePreviewMode(selectedDocument.value, {
|
||||||
|
onlyOfficeAvailable: onlyOfficeAvailable.value
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const totalCount = computed(() => filteredDocuments.value.length)
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
|
||||||
const visibleDocuments = computed(() => {
|
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
|
||||||
return filteredDocuments.value.slice(start, start + pageSize.value)
|
|
||||||
})
|
|
||||||
const activePreviewPage = computed(() => {
|
|
||||||
const pages = selectedDocument.value?.previewPages || []
|
|
||||||
return pages[currentPreviewPageIndex.value] || pages[0] || null
|
|
||||||
})
|
|
||||||
const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value))
|
|
||||||
const previewSecondaryMetaLine = computed(() =>
|
|
||||||
buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value)
|
|
||||||
)
|
)
|
||||||
const shouldUseOnlyOffice = computed(() => supportsOnlyOfficePreview(selectedDocument.value))
|
const shouldUseOnlyOffice = computed(() => previewMode.value === 'onlyoffice')
|
||||||
const excelPreviewTable = computed(() =>
|
const excelPreviewTable = computed(() =>
|
||||||
selectedDocument.value?.previewKind === 'table'
|
selectedDocument.value?.previewKind === 'table'
|
||||||
? buildExcelPreviewTable(activePreviewPage.value)
|
? buildExcelPreviewTable(activePreviewPage.value)
|
||||||
: { headers: [], rows: [] }
|
: { headers: [], rows: [] }
|
||||||
)
|
)
|
||||||
|
|
||||||
function revokePreviewBlob() {
|
function revokePreviewBlob() {
|
||||||
if (previewBlobUrl.value) {
|
if (previewBlobUrl.value) {
|
||||||
URL.revokeObjectURL(previewBlobUrl.value)
|
URL.revokeObjectURL(previewBlobUrl.value)
|
||||||
previewBlobUrl.value = ''
|
previewBlobUrl.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyOnlyOfficeEditor() {
|
function destroyOnlyOfficeEditor() {
|
||||||
if (onlyOfficeEditor.value?.destroyEditor) {
|
if (onlyOfficeEditor.value?.destroyEditor) {
|
||||||
onlyOfficeEditor.value.destroyEditor()
|
onlyOfficeEditor.value.destroyEditor()
|
||||||
}
|
}
|
||||||
onlyOfficeEditor.value = null
|
onlyOfficeEditor.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mountOnlyOfficeEditor(documentId) {
|
async function mountOnlyOfficeEditor(documentId) {
|
||||||
onlyOfficeLoading.value = true
|
onlyOfficeLoading.value = true
|
||||||
onlyOfficeError.value = ''
|
onlyOfficeError.value = ''
|
||||||
|
onlyOfficeAvailable.value = false
|
||||||
destroyOnlyOfficeEditor()
|
destroyOnlyOfficeEditor()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
|
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
|
||||||
await loadOnlyOfficeApi(payload.documentServerUrl)
|
await loadOnlyOfficeApi(payload.documentServerUrl)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (!window.DocsAPI?.DocEditor) {
|
if (!window.DocsAPI?.DocEditor) {
|
||||||
throw new Error('ONLYOFFICE 编辑器未正确加载。')
|
throw new Error('ONLYOFFICE 编辑器未正确加载。')
|
||||||
}
|
}
|
||||||
|
|
||||||
onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}`
|
onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}`
|
||||||
await nextTick()
|
await nextTick()
|
||||||
onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config)
|
onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config)
|
||||||
|
onlyOfficeAvailable.value = true
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
|
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
onlyOfficeLoading.value = false
|
onlyOfficeLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLibrary(options = {}) {
|
async function loadLibrary(options = {}) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const payload = await fetchKnowledgeLibrary()
|
const payload = await fetchKnowledgeLibrary()
|
||||||
folders.value = payload.folders || []
|
folders.value = payload.folders || []
|
||||||
documents.value = payload.documents || []
|
documents.value = payload.documents || []
|
||||||
emit('summary-change', { totalDocuments: documents.value.length })
|
emit('summary-change', { totalDocuments: documents.value.length })
|
||||||
|
|
||||||
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value)
|
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value)
|
||||||
if (!activeExists) {
|
if (!activeExists) {
|
||||||
activeFolder.value = folders.value[0]?.name || ''
|
activeFolder.value = folders.value[0]?.name || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.preserveSelection && selectedDocument.value?.id) {
|
if (options.preserveSelection && selectedDocument.value?.id) {
|
||||||
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)
|
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
revokePreviewBlob()
|
revokePreviewBlob()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emit('summary-change', { totalDocuments: 0 })
|
emit('summary-change', { totalDocuments: 0 })
|
||||||
toast(error.message || '知识库加载失败。')
|
toast(error.message || '知识库加载失败。')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectDocument(documentId) {
|
async function selectDocument(documentId) {
|
||||||
previewLoading.value = true
|
previewLoading.value = true
|
||||||
previewError.value = ''
|
previewError.value = ''
|
||||||
onlyOfficeError.value = ''
|
onlyOfficeError.value = ''
|
||||||
|
onlyOfficeAvailable.value = false
|
||||||
revokePreviewBlob()
|
revokePreviewBlob()
|
||||||
destroyOnlyOfficeEditor()
|
destroyOnlyOfficeEditor()
|
||||||
|
|
||||||
@@ -180,182 +186,187 @@ export default {
|
|||||||
selectedDocument.value = payload
|
selectedDocument.value = payload
|
||||||
currentPreviewPageIndex.value = 0
|
currentPreviewPageIndex.value = 0
|
||||||
|
|
||||||
if (supportsOnlyOfficePreview(payload)) {
|
if (canUseOnlyOfficePreview(payload)) {
|
||||||
await mountOnlyOfficeEditor(documentId)
|
await mountOnlyOfficeEditor(documentId)
|
||||||
} else if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
|
}
|
||||||
|
|
||||||
|
if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
|
||||||
const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline')
|
const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline')
|
||||||
previewBlobUrl.value = URL.createObjectURL(blob)
|
previewBlobUrl.value = URL.createObjectURL(blob)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
previewError.value = error.message || '预览加载失败。'
|
previewError.value = error.message || '预览加载失败。'
|
||||||
toast(previewError.value)
|
toast(previewError.value)
|
||||||
} finally {
|
} finally {
|
||||||
previewLoading.value = false
|
previewLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload(document) {
|
async function handleDownload(document) {
|
||||||
try {
|
try {
|
||||||
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
|
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
|
||||||
triggerFileDownload(blob, document.name)
|
triggerFileDownload(blob, document.name)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message || '下载失败。')
|
toast(error.message || '下载失败。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerUpload() {
|
function triggerUpload() {
|
||||||
if (!isAdmin.value || uploading.value) {
|
if (!isAdmin.value || uploading.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploadInput.value?.click()
|
uploadInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFiles(fileList) {
|
async function uploadFiles(fileList) {
|
||||||
const files = Array.from(fileList || []).filter(Boolean)
|
const files = Array.from(fileList || []).filter(Boolean)
|
||||||
if (!files.length || !activeFolder.value || !isAdmin.value) {
|
if (!files.length || !activeFolder.value || !isAdmin.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
let latestDocumentId = ''
|
let latestDocumentId = ''
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
|
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
|
||||||
latestDocumentId = payload.id
|
latestDocumentId = payload.id
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadLibrary({ preserveSelection: true })
|
await loadLibrary({ preserveSelection: true })
|
||||||
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
|
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
|
||||||
|
|
||||||
if (latestDocumentId) {
|
if (latestDocumentId) {
|
||||||
await selectDocument(latestDocumentId)
|
await selectDocument(latestDocumentId)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message || '上传失败。')
|
toast(error.message || '上传失败。')
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
if (uploadInput.value) {
|
if (uploadInput.value) {
|
||||||
uploadInput.value.value = ''
|
uploadInput.value.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileInput(event) {
|
async function handleFileInput(event) {
|
||||||
await uploadFiles(event.target.files)
|
await uploadFiles(event.target.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDrop(event) {
|
async function handleDrop(event) {
|
||||||
if (!isAdmin.value) {
|
if (!isAdmin.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await uploadFiles(event.dataTransfer?.files)
|
await uploadFiles(event.dataTransfer?.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(document) {
|
async function handleDelete(document) {
|
||||||
if (!isAdmin.value || deletingId.value) {
|
if (!isAdmin.value || deletingId.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
|
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deletingId.value = document.id
|
deletingId.value = document.id
|
||||||
try {
|
try {
|
||||||
await deleteKnowledgeDocument(document.id)
|
await deleteKnowledgeDocument(document.id)
|
||||||
if (selectedDocument.value?.id === document.id) {
|
if (selectedDocument.value?.id === document.id) {
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
revokePreviewBlob()
|
revokePreviewBlob()
|
||||||
}
|
}
|
||||||
await loadLibrary()
|
await loadLibrary()
|
||||||
toast('知识库文件已删除。')
|
toast('知识库文件已删除。')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message || '删除失败。')
|
toast(error.message || '删除失败。')
|
||||||
} finally {
|
} finally {
|
||||||
deletingId.value = ''
|
deletingId.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePageSize(size) {
|
function changePageSize(size) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
pageSizeOpen.value = false
|
pageSizeOpen.value = false
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePreview() {
|
function closePreview() {
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
previewError.value = ''
|
previewError.value = ''
|
||||||
currentPreviewPageIndex.value = 0
|
currentPreviewPageIndex.value = 0
|
||||||
revokePreviewBlob()
|
revokePreviewBlob()
|
||||||
destroyOnlyOfficeEditor()
|
destroyOnlyOfficeEditor()
|
||||||
onlyOfficeError.value = ''
|
onlyOfficeError.value = ''
|
||||||
|
onlyOfficeAvailable.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPreviewPage(index) {
|
function selectPreviewPage(index) {
|
||||||
currentPreviewPageIndex.value = index
|
currentPreviewPageIndex.value = index
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(filteredDocuments, () => {
|
watch(filteredDocuments, () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
pageSizeOpen.value = false
|
pageSizeOpen.value = false
|
||||||
|
|
||||||
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
|
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
|
||||||
closePreview()
|
closePreview()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(activeFolder, () => {
|
watch(activeFolder, () => {
|
||||||
closePreview()
|
closePreview()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
revokePreviewBlob()
|
revokePreviewBlob()
|
||||||
|
destroyOnlyOfficeEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeFolder,
|
activeFolder,
|
||||||
activePreviewPage,
|
activePreviewPage,
|
||||||
changePageSize,
|
changePageSize,
|
||||||
closePreview,
|
closePreview,
|
||||||
excelPreviewTable,
|
excelPreviewTable,
|
||||||
currentPage,
|
currentPage,
|
||||||
currentPreviewPageIndex,
|
currentPreviewPageIndex,
|
||||||
deletingId,
|
deletingId,
|
||||||
documentSearch,
|
documentSearch,
|
||||||
filteredFolders,
|
filteredFolders,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
handleDrop,
|
handleDrop,
|
||||||
handleFileInput,
|
handleFileInput,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
loading,
|
loading,
|
||||||
pageSize,
|
pageSize,
|
||||||
pageSizeOpen,
|
pageSizeOpen,
|
||||||
pageSizes,
|
pageSizes,
|
||||||
onlyOfficeError,
|
onlyOfficeError,
|
||||||
onlyOfficeHostId,
|
onlyOfficeHostId,
|
||||||
onlyOfficeLoading,
|
onlyOfficeLoading,
|
||||||
|
previewMode,
|
||||||
previewMetaLine,
|
previewMetaLine,
|
||||||
previewSecondaryMetaLine,
|
previewSecondaryMetaLine,
|
||||||
previewBlobUrl,
|
previewBlobUrl,
|
||||||
previewError,
|
previewError,
|
||||||
previewLoading,
|
previewLoading,
|
||||||
shouldUseOnlyOffice,
|
shouldUseOnlyOffice,
|
||||||
selectDocument,
|
selectDocument,
|
||||||
selectPreviewPage,
|
selectPreviewPage,
|
||||||
selectedDocument,
|
selectedDocument,
|
||||||
totalCount,
|
totalCount,
|
||||||
totalPages,
|
totalPages,
|
||||||
triggerUpload,
|
triggerUpload,
|
||||||
uploadHint,
|
uploadHint,
|
||||||
uploadInput,
|
uploadInput,
|
||||||
uploading,
|
uploading,
|
||||||
visibleDocuments
|
visibleDocuments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
web/src/views/scripts/knowledgePreviewMode.js
Normal file
21
web/src/views/scripts/knowledgePreviewMode.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,65 +1,65 @@
|
|||||||
function splitPreviewRow(line) {
|
function splitPreviewRow(line) {
|
||||||
return String(line || '')
|
return String(line || '')
|
||||||
.split('|')
|
.split('|')
|
||||||
.map((cell) => cell.trim())
|
.map((cell) => cell.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPreviewMetaLine(document) {
|
export function buildPreviewMetaLine(document) {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return [document.summary, document.time].filter(Boolean)
|
return [document.summary, document.time].filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPreviewSecondaryMetaLine(document, page = null) {
|
export function buildPreviewSecondaryMetaLine(document, page = null) {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null)
|
const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null)
|
||||||
if (!activePage) {
|
if (!activePage) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = []
|
const parts = []
|
||||||
|
|
||||||
if (activePage.subtitle) {
|
if (activePage.subtitle) {
|
||||||
parts.push(activePage.subtitle)
|
parts.push(activePage.subtitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.previewKind === 'table') {
|
if (document.previewKind === 'table') {
|
||||||
for (const item of activePage.stats || []) {
|
for (const item of activePage.stats || []) {
|
||||||
if (!item?.label || !item?.value || item.label === '文件大小') {
|
if (!item?.label || !item?.value || item.label === '文件大小') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts.push(`${item.label} ${item.value}`)
|
parts.push(`${item.label} ${item.value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildExcelPreviewTable(page) {
|
export function buildExcelPreviewTable(page) {
|
||||||
const rawRows = (page?.blocks || [])
|
const rawRows = (page?.blocks || [])
|
||||||
.flatMap((block) => block.lines || [])
|
.flatMap((block) => block.lines || [])
|
||||||
.map(splitPreviewRow)
|
.map(splitPreviewRow)
|
||||||
.filter((row) => row.length > 0 && row.some((cell) => cell !== ''))
|
.filter((row) => row.length > 0 && row.some((cell) => cell !== ''))
|
||||||
|
|
||||||
if (!rawRows.length) {
|
if (!rawRows.length) {
|
||||||
return { headers: [], rows: [] }
|
return { headers: [], rows: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0)
|
const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0)
|
||||||
const normalizedRows = rawRows.map((row) =>
|
const normalizedRows = rawRows.map((row) =>
|
||||||
Array.from({ length: columnCount }, (_, index) => row[index] ?? '')
|
Array.from({ length: columnCount }, (_, index) => row[index] ?? '')
|
||||||
)
|
)
|
||||||
|
|
||||||
const [headerRow, ...bodyRows] = normalizedRows
|
const [headerRow, ...bodyRows] = normalizedRows
|
||||||
const headers = headerRow.map((cell, index) => cell || `列 ${index + 1}`)
|
const headers = headerRow.map((cell, index) => cell || `列 ${index + 1}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headers,
|
headers,
|
||||||
rows: bodyRows
|
rows: bodyRows
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import { apiRequest } from '../src/services/api.js'
|
import { apiRequest } from '../src/services/api.js'
|
||||||
|
|
||||||
async function testUsesCustomContentTypeHeader() {
|
async function testUsesCustomContentTypeHeader() {
|
||||||
let capturedOptions = null
|
let capturedOptions = null
|
||||||
|
|
||||||
global.fetch = async (_url, options) => {
|
global.fetch = async (_url, options) => {
|
||||||
capturedOptions = options
|
capturedOptions = options
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
async json() {
|
async json() {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiRequest('/knowledge/documents', {
|
await apiRequest('/knowledge/documents', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'payload',
|
body: 'payload',
|
||||||
contentType: 'application/octet-stream'
|
contentType: 'application/octet-stream'
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream')
|
assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testSupportsBlobResponses() {
|
async function testSupportsBlobResponses() {
|
||||||
const blob = new Blob(['preview'])
|
const blob = new Blob(['preview'])
|
||||||
|
|
||||||
global.fetch = async () => ({
|
global.fetch = async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
async blob() {
|
async blob() {
|
||||||
return blob
|
return blob
|
||||||
},
|
},
|
||||||
async json() {
|
async json() {
|
||||||
throw new Error('json parser should not be used for blob responses')
|
throw new Error('json parser should not be used for blob responses')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await apiRequest('/knowledge/documents/demo/content', {
|
const payload = await apiRequest('/knowledge/documents/demo/content', {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
contentType: null
|
contentType: null
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(payload, blob)
|
assert.equal(payload, blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testInjectsAuthenticatedUserHeaders() {
|
async function testInjectsAuthenticatedUserHeaders() {
|
||||||
const sessionStorage = new Map([
|
const sessionStorage = new Map([
|
||||||
[
|
[
|
||||||
'x-financial-auth-user',
|
'x-financial-auth-user',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
name: '系统管理员',
|
name: '系统管理员',
|
||||||
roleCodes: ['manager'],
|
roleCodes: ['manager'],
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
])
|
])
|
||||||
|
|
||||||
global.window = {
|
global.window = {
|
||||||
sessionStorage: {
|
sessionStorage: {
|
||||||
getItem(key) {
|
getItem(key) {
|
||||||
return sessionStorage.get(key) ?? null
|
return sessionStorage.get(key) ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let capturedOptions = null
|
let capturedOptions = null
|
||||||
|
|
||||||
global.fetch = async (_url, options) => {
|
global.fetch = async (_url, options) => {
|
||||||
capturedOptions = options
|
capturedOptions = options
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
async json() {
|
async json() {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiRequest('/knowledge/library')
|
await apiRequest('/knowledge/library')
|
||||||
|
|
||||||
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
|
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
|
||||||
assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员')
|
assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员')
|
||||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
||||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
await testUsesCustomContentTypeHeader()
|
await testUsesCustomContentTypeHeader()
|
||||||
await testSupportsBlobResponses()
|
await testSupportsBlobResponses()
|
||||||
await testInjectsAuthenticatedUserHeaders()
|
await testInjectsAuthenticatedUserHeaders()
|
||||||
console.log('api-request tests passed')
|
console.log('api-request tests passed')
|
||||||
}
|
}
|
||||||
|
|
||||||
run().catch((error) => {
|
run().catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
39
web/tests/knowledge-preview-mode.test.mjs
Normal file
39
web/tests/knowledge-preview-mode.test.mjs
Normal 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()
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js'
|
import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js'
|
||||||
|
|
||||||
function run() {
|
function run() {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'),
|
buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'),
|
||||||
'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js'
|
'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js'
|
||||||
)
|
)
|
||||||
console.log('onlyoffice service tests passed')
|
console.log('onlyoffice service tests passed')
|
||||||
}
|
}
|
||||||
|
|
||||||
run()
|
run()
|
||||||
|
|||||||
@@ -1,69 +1,69 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildExcelPreviewTable,
|
buildExcelPreviewTable,
|
||||||
buildPreviewMetaLine,
|
buildPreviewMetaLine,
|
||||||
buildPreviewSecondaryMetaLine
|
buildPreviewSecondaryMetaLine
|
||||||
} from '../src/views/scripts/policiesPreviewFormatters.js'
|
} from '../src/views/scripts/policiesPreviewFormatters.js'
|
||||||
|
|
||||||
function testBuildPreviewMetaLineUsesRealDocumentFields() {
|
function testBuildPreviewMetaLineUsesRealDocumentFields() {
|
||||||
const document = {
|
const document = {
|
||||||
summary: '财务知识库 · XLSX · 10.9 KB',
|
summary: '财务知识库 · XLSX · 10.9 KB',
|
||||||
time: '2026-05-09 12:30'
|
time: '2026-05-09 12:30'
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30'])
|
assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30'])
|
||||||
}
|
}
|
||||||
|
|
||||||
function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() {
|
function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() {
|
||||||
const document = {
|
const document = {
|
||||||
previewKind: 'table',
|
previewKind: 'table',
|
||||||
previewPages: [
|
previewPages: [
|
||||||
{
|
{
|
||||||
subtitle: '表格内容预览',
|
subtitle: '表格内容预览',
|
||||||
stats: [
|
stats: [
|
||||||
{ label: '工作表数量', value: '4' },
|
{ label: '工作表数量', value: '4' },
|
||||||
{ label: '预览行数', value: '7' },
|
{ label: '预览行数', value: '7' },
|
||||||
{ label: '文件大小', value: '10.9 KB' }
|
{ label: '文件大小', value: '10.9 KB' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
subtitle: '第二页签预览',
|
subtitle: '第二页签预览',
|
||||||
stats: [
|
stats: [
|
||||||
{ label: '工作表数量', value: '4' },
|
{ label: '工作表数量', value: '4' },
|
||||||
{ label: '预览行数', value: '3' }
|
{ label: '预览行数', value: '3' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7'])
|
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7'])
|
||||||
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3'])
|
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3'])
|
||||||
}
|
}
|
||||||
|
|
||||||
function testBuildExcelPreviewTableParsesHeaderAndRows() {
|
function testBuildExcelPreviewTableParsesHeaderAndRows() {
|
||||||
const page = {
|
const page = {
|
||||||
blocks: [
|
blocks: [
|
||||||
{ heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] },
|
{ heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] },
|
||||||
{ heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] },
|
{ heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] },
|
||||||
{ heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] }
|
{ heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.deepEqual(buildExcelPreviewTable(page), {
|
assert.deepEqual(buildExcelPreviewTable(page), {
|
||||||
headers: ['日期', '部门', '金额', '备注'],
|
headers: ['日期', '部门', '金额', '备注'],
|
||||||
rows: [
|
rows: [
|
||||||
['2026-05-01', '财务部', '300', '差旅'],
|
['2026-05-01', '财务部', '300', '差旅'],
|
||||||
['2026-05-02', '行政部', '120', '']
|
['2026-05-02', '行政部', '120', '']
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function run() {
|
function run() {
|
||||||
testBuildPreviewMetaLineUsesRealDocumentFields()
|
testBuildPreviewMetaLineUsesRealDocumentFields()
|
||||||
testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats()
|
testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats()
|
||||||
testBuildExcelPreviewTableParsesHeaderAndRows()
|
testBuildExcelPreviewTableParsesHeaderAndRows()
|
||||||
console.log('policies preview formatter tests passed')
|
console.log('policies preview formatter tests passed')
|
||||||
}
|
}
|
||||||
|
|
||||||
run()
|
run()
|
||||||
|
|||||||
1970
web/vite.config.js
1970
web/vite.config.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user