feat: 完善系统配置、安全增强与知识库功能
- .env.example: API基础路径改为相对路径 /api/v1,支持代理转发 - README.md: 完善项目结构与启动说明文档 - docker-compose.yml: 新增Docker编排配置,支持容器化部署 - docker/: 新增Docker部署相关文档与配置 - server_start.sh: 重构启动脚本,添加容器环境检测、隔离虚拟环境路径、环境变量覆盖机制 - deps.py: 完善API依赖注入,增强权限验证逻辑 - admin_secret.py: 优化管理员密钥加密存储与验证 - config.py: 扩展配置管理,支持多环境变量绑定 - security.py: 增强安全模块,完善加密与认证机制 - db/base.py: 优化数据库基础架构与连接管理 - main.py: 更新应用入口,整合新模块路由 - models/: 完善系统模型配置,支持模型设置持久化 - repositories/settings.py: 优化设置仓储层,增强数据持久化 - services/settings.py: 重构设置服务,精简代码结构 - router.py: 更新API路由配置 - endpoints/knowledge.py: 新增知识库API端点 - schemas/knowledge.py: 新增知识库数据模型 - services/knowledge.py: 新增知识库业务逻辑 - storage/knowledge/.index.json: 知识库索引存储 - api.js: 完善API服务层,增强错误处理 - bootstrap.js: 优化前端初始化与引导流程 - useSetupView.js / useSystemState.js: 重构组合式函数 - TopBar.vue: 优化顶部导航栏组件 - SettingsView.vue: 重构设置页面UI,增强用户体验 - SetupView.vue / SetupRouteView.vue: 完善引导流程页面 - PoliciesView.vue: 优化策略视图组件 - vite.config.js: 更新Vite构建配置 - web_start.sh: 完善前端启动脚本 - views/scripts/: 优化各业务视图JS逻辑 - settings-view.css: 重构设置页面样式 - setup-view.css: 完善引导页样式 - policies-view.css: 优化策略页样式 - test_auth_service.py: 完善认证服务测试 - test_settings_persistence.py: 增强设置持久化测试 - document/: 新增开发文档与工作日志
This commit is contained in:
90
README.md
90
README.md
@@ -1,45 +1,45 @@
|
|||||||
# X-Financial
|
# X-Financial
|
||||||
|
|
||||||
项目结构已按前后端拆开:
|
项目结构已按前后端拆开:
|
||||||
|
|
||||||
- `web/`:前端工程(当前 Vue + Vite 项目)
|
- `web/`:前端工程(当前 Vue + Vite 项目)
|
||||||
- `server/`:后端工程目录
|
- `server/`:后端工程目录
|
||||||
- `docs/`:方案和阶段文档
|
- `docs/`:方案和阶段文档
|
||||||
- `UI/`:界面参考稿
|
- `UI/`:界面参考稿
|
||||||
- `document/`:业务文档
|
- `document/`:业务文档
|
||||||
|
|
||||||
根目录统一环境变量:
|
根目录统一环境变量:
|
||||||
|
|
||||||
- `.env`
|
- `.env`
|
||||||
- `.env.example`
|
- `.env.example`
|
||||||
|
|
||||||
这里集中维护:
|
这里集中维护:
|
||||||
|
|
||||||
- 前端启动端口
|
- 前端启动端口
|
||||||
- 后端启动端口
|
- 后端启动端口
|
||||||
- PostgreSQL 连接参数
|
- PostgreSQL 连接参数
|
||||||
- `DATABASE_URL`
|
- `DATABASE_URL`
|
||||||
- `REDIS_URL`
|
- `REDIS_URL`
|
||||||
|
|
||||||
从根目录统一启动:
|
从根目录统一启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./start.sh
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
可选模式:
|
可选模式:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./start.sh web
|
./start.sh web
|
||||||
./start.sh server
|
./start.sh server
|
||||||
./start.sh all
|
./start.sh all
|
||||||
```
|
```
|
||||||
|
|
||||||
根目录 `start.sh` 是统一编排入口;前端和后端的子启动脚本分别是 `web/web_start.sh` 与 `server/server_start.sh`。
|
根目录 `start.sh` 是统一编排入口;前端和后端的子启动脚本分别是 `web/web_start.sh` 与 `server/server_start.sh`。
|
||||||
|
|
||||||
手动进入前端目录:
|
手动进入前端目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|||||||
169
document/development/plan/ai_agent_dual_layer_arch.md
Normal file
169
document/development/plan/ai_agent_dual_layer_arch.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# X-Financial 智能化财务系统:双层 Agent 架构设计与开发落地全景指南
|
||||||
|
|
||||||
|
> **核心设计理念:确定性与概率性的完美解耦**
|
||||||
|
>
|
||||||
|
> 在企业级财务系统中,“合规性”与“准确性”是不可妥协的底线。大语言模型(LLM)天生具有概率性(会产生幻觉),因此不能直接赋予其修改核心财务数据或放行审批的最高权限。
|
||||||
|
>
|
||||||
|
> 本架构设计的核心,在于构建一个**“双层防线”**:
|
||||||
|
> 1. **外层 Agent (自研流程大脑)**:提供 100% 的确定性。它是系统的执行者,严格按照预设流程和固化的规则行事,不具备“自我意识”,只负责“路由”、“拦截”和“记录”。
|
||||||
|
> 2. **内层 Agent (Hermes 智囊核心)**:提供强大的概率性推理能力。它是系统的思考者,负责处理所有复杂、模糊、非结构化的任务(如阅读长文档、识别潜在风险),但它的输出**不能直接作用于业务**,而是转化为**规则配置**或**建议意见**,交由外层 Agent 或人类管理员执行。
|
||||||
|
>
|
||||||
|
> 这两层架构不是相互独立的两个系统,而是形成一个**“闭环”**:内层提炼规则,外层执行规则;外层收集数据,内层分析数据。这种深度协同,既保障了系统的安全性,又赋予了系统极高的智能化水平。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、 系统架构图景与职责边界深度剖析
|
||||||
|
|
||||||
|
### 1. 外层 Agent (Outer Agent):流程与路由的绝对掌控者
|
||||||
|
|
||||||
|
**本质:一个高度可配置的业务工作流引擎与意图分发器。**
|
||||||
|
|
||||||
|
* **开发技术栈建议**:FastAPI (后端) + Vue3 (前端) + PostgreSQL (持久化) + Redis (可选,用于状态缓存)。
|
||||||
|
* **交互形态**:它直接面对用户。它可以是一个类似对话框的界面,但背后的逻辑是基于**状态机 (State Machine)** 驱动的。
|
||||||
|
* **核心模块与职责 (What to do & How to do)**:
|
||||||
|
|
||||||
|
* **模块 1: 意图漏斗 (Intent Router)**
|
||||||
|
* **职责**:精准捕捉用户请求的第一诉求,并将其导向正确的处理管线。
|
||||||
|
* **方法**:
|
||||||
|
* *规则匹配优先*:使用简单的关键词或正则(例如:匹配到“报销”、“打车”字眼,直接激活报销向导)。
|
||||||
|
* *轻量级分类模型兜底*:对于模糊表述(如:“我上周去上海开会的钱怎么还没发?”),调用一个小参数的分类模型(或内层的快捷接口),将其分类为“状态查询”意图,并提取关键实体(如时间:上周,地点:上海)。
|
||||||
|
* **模块 2: 结构化状态机引擎 (State & Flow Controller)**
|
||||||
|
* **职责**:管理每一个业务对象(如一张报销单)的生命周期。从“草稿” -> “提交” -> “一级审批” -> “财务复核” -> “已打款”。
|
||||||
|
* **方法**:拒绝让大模型控制流程走向。流程流转必须基于代码逻辑中的条件判断(例如:如果金额 < 500,且员工级别为 M1,则跳过一级审批,直接进入财务复核)。外层 Agent 负责维护并推进这个状态。
|
||||||
|
* **模块 3: 确定性规则执行器 (Rule Execution Engine)**
|
||||||
|
* **职责**:财务合规的第一道硬性防线。不讲道理,只看数据。
|
||||||
|
* **方法**:当用户提交报销数据时,该模块会查询本地的 `business_rules` 数据库表。如果用户提交的住宿费是 850,而数据库规则明确上限是 800,则立刻抛出“阻断型错误” (Blocking Error)。**此过程绝对禁止调用大模型进行实时推断。**
|
||||||
|
* **模块 4: 标准化 API 网关 (API Gateway & Handshake Layer)**
|
||||||
|
* **职责**:封装所有对外层系统(如 ERP、HR 系统)和对内层 Hermes 的通信接口。控制并发,记录调用日志。
|
||||||
|
|
||||||
|
### 2. 内层 Agent (Hermes):非结构化信息的提炼者与深度思考者
|
||||||
|
|
||||||
|
**本质:一个被严格隔离的智能计算引擎,专门处理人类擅长但传统代码难以处理的“软逻辑”。**
|
||||||
|
|
||||||
|
* **开发技术栈建议**:Hermes 框架 + 向量数据库 (如 Milvus/PGVector) + 强力 LLM (如 GPT-4 或开源大模型)。
|
||||||
|
* **交互形态**:对用户不可见,只作为外层 Agent 的“后端服务”存在。
|
||||||
|
* **核心模块与职责 (What to do & How to do)**:
|
||||||
|
|
||||||
|
* **模块 1: 政策蒸馏器 (Policy Distiller) —— 解决“知行合一”的关键**
|
||||||
|
* **职责**:打破知识库(死文件)与业务流(活代码)之间的壁垒。
|
||||||
|
* **方法 (核心思路)**:
|
||||||
|
1. *触发*:管理员上传一份《差旅新规.pdf》。
|
||||||
|
2. *解析*:Hermes 逐段阅读文档。
|
||||||
|
3. *提取*:使用精心设计的 **Few-Shot Prompt 链**,强制模型识别特定的“控制变量”。
|
||||||
|
*(Prompt 示例: "你是一个专业的财务合规审计员。请阅读以下段落,如果包含任何关于费用上限、职级限制、审批层级的规定,请严格按照以下 JSON Schema 输出:{category, location, level_req, max_amount, is_hard_limit}。如果未找到,输出空。")*
|
||||||
|
4. *回写*:Hermes 将提炼出的 JSON 结构转化为标准的 SQL Update 指令(或通过专用 API 接口),更新外层 Agent 依赖的 `business_rules` 表。
|
||||||
|
* **模块 2: 深度知识检索 (Deep RAG & Interpretation)**
|
||||||
|
* **职责**:为用户提供复杂制度的个性化解读。
|
||||||
|
* **方法**:当外层 Agent 无法解答用户的合规疑问时(意图识别为“政策咨询”),外层将请求转发给 Hermes。Hermes 在向量库中检索相关段落,并结合用户当前的上下文(如:员工职级、出差地),生成一份连贯、人性化的解答。
|
||||||
|
* **模块 3: 异步风险探针 (Asynchronous Risk Auditor)**
|
||||||
|
* **职责**:像“老会计”一样,在海量已发生或正在发生的业务数据中寻找蛛丝马迹。
|
||||||
|
* **方法**:
|
||||||
|
1. *定时任务*:每天凌晨启动。
|
||||||
|
2. *数据聚合*:从外层数据库提取当天的报销流水(去除敏感个资)。
|
||||||
|
3. *模式识别*:通过特定的 Prompt(例如寻找“拆单报销”、“异常高频的出租车票”)。
|
||||||
|
4. *生成报告*:生成结构化的风险预警报告,存入专用表,供管理员次日早晨审核,而不是直接去冻结员工账号。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、 核心通信协议 (The Handshake):两层的握手与数据交互
|
||||||
|
|
||||||
|
双层架构的成败,取决于这两层能否顺畅地交换信息,且保证安全。我们需要定义清晰的接口协议。
|
||||||
|
|
||||||
|
### 1. 同步查询接口 (外 -> 内:求知与解惑)
|
||||||
|
|
||||||
|
当外层遇到处理不了的“软逻辑”时触发。
|
||||||
|
|
||||||
|
* **Endpoint (示例)**: `POST /hermes/api/v1/consult`
|
||||||
|
* **外层 Request 结构**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"context": {
|
||||||
|
"user_id": "emp_1001",
|
||||||
|
"current_task": "travel_reimbursement",
|
||||||
|
"form_data": {"city": "北京", "amount": 900}
|
||||||
|
},
|
||||||
|
"query": "因为展会原因酒店全满,只能订900的,能报销吗?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **内层 Hermes Response 结构**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"interpretation": "根据《差旅管理办法》第15条,展会期间允许上浮 20%。您的标准是800,上浮后为960,可以报销。",
|
||||||
|
"action_recommendation": "require_special_approval", // 建议外层采取的动作
|
||||||
|
"citations": ["policy_doc_v2_page_4"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 异步任务接口 (外 -> 内:派发耗时任务)
|
||||||
|
|
||||||
|
例如请求生成长篇分析报告或进行全量风险巡检。
|
||||||
|
|
||||||
|
* **流程**:
|
||||||
|
1. 外层调用 `POST /hermes/api/v1/jobs/generate_report`。
|
||||||
|
2. 内层 Hermes 立即返回 `202 Accepted` 和一个 `job_id`。
|
||||||
|
3. 内层 Hermes 在后台慢慢算。
|
||||||
|
4. 计算完成后,内层通过 Webhook 回调外层的通知接口,外层再通过系统消息通知用户“您的报告已就绪”。
|
||||||
|
|
||||||
|
### 3. 规则推送机制 (内 -> 外:自动化立法)
|
||||||
|
|
||||||
|
这是最核心的逆向通信。内层提炼出的规则如何生效?
|
||||||
|
|
||||||
|
* **流程**:
|
||||||
|
1. Hermes 提炼出新规则。
|
||||||
|
2. Hermes 调用外层的特权 API (如 `POST /admin/api/rules/sync`),推送规则 payload。
|
||||||
|
3. 外层 Agent 收到后,执行数据库 `UPSERT` 操作更新 `business_rules` 表。
|
||||||
|
4. *(可选但强烈建议)*:进入“待激活”状态,需要人类管理员在系统中点击“确认应用新规则”后,新规才正式生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、 分阶段开发落地全景计划 (Implementation Roadmap)
|
||||||
|
|
||||||
|
开发应当遵循“先基建后上层、先确定后智能”的原则。
|
||||||
|
|
||||||
|
### Phase 1: 骨架搭建与基石铺设 (Foundation & Outer Shell)
|
||||||
|
*目标:构建一个哪怕没有 AI 也能运转的硬核流程系统,确立两层隔离。*
|
||||||
|
|
||||||
|
1. **架构拆分验证**:在服务器层面,确保 Outer Agent (FastAPI) 和 Inner Hermes 分别在独立的进程(或容器)中运行,仅通过 HTTP/gRPC 通信。
|
||||||
|
2. **动态规则引擎实现 (核心基建)**:
|
||||||
|
* 在 PostgreSQL 中设计 `business_rules` 表结构。必须支持高度扩展性(例如采用 `JSONB` 字段存储具体约束参数)。
|
||||||
|
* 在外层 Agent 开发一个“规则校验服务 (Rule Validation Service)”,该服务能够在任何报销动作发生前,拦截并比对 `business_rules`。
|
||||||
|
3. **标准化流程闭环**:开发一个完整的、基于硬规则驱动的差旅报销单据流转全流程(填单 -> 校验 -> 提交 -> 审批)。验证在“硬规则”下系统运转良好。
|
||||||
|
|
||||||
|
### Phase 2: 知识注入与基础问答 (Hermes RAG Integration)
|
||||||
|
*目标:赋予系统“解答疑问”的能力。*
|
||||||
|
|
||||||
|
1. **内层基建**:配置 Hermes 环境,接入向量数据库。
|
||||||
|
2. **文档清洗管道 (ETL pipeline)**:将现有的财务政策 PDF/Word 文档清洗、分块 (Chunking) 并向量化入库。
|
||||||
|
3. **问答桥接**:
|
||||||
|
* 在外层前端 (Vue3) 提供一个“智能咨询”悬浮窗或独立页面。
|
||||||
|
* 外层 Agent 接收问题,附带上用户的上下文(角色、权限),一并转发给内层 Hermes。
|
||||||
|
* 验证 Hermes 能够根据向量库的内容,给出带出处的准确回答。
|
||||||
|
|
||||||
|
### Phase 3: 核心攻坚 —— 自动立法与双层联通 (Policy Distillation & Sync)
|
||||||
|
*目标:实现从“死文档”到“活规则”的自动化转化。*
|
||||||
|
|
||||||
|
1. **蒸馏 Prompt 工程**:在 Hermes 中反复打磨“政策提炼”的 Prompt。针对你们公司常见的政策描述方式进行微调。
|
||||||
|
2. **结构化提取测试**:手动上传不同版本的政策文档,测试 Hermes 能否稳定、准确地输出 JSON 格式的规则参数。
|
||||||
|
3. **闭环联调**:
|
||||||
|
* 开发 Hermes 向外层推送规则的 API。
|
||||||
|
* 完成全链路测试:管理员界面上传新文档 -> Hermes 后台解析 -> 外层规则库自动更新 -> 前端即时生效新的金额限制。
|
||||||
|
|
||||||
|
### Phase 4: 高阶进化 —— 异步审计与主动防御 (Proactive Risk Auditing)
|
||||||
|
*目标:将系统从“被动响应”升级为“主动防护”。*
|
||||||
|
|
||||||
|
1. **数据安全隧道**:建立从外层业务库向内层 Hermes 传递“脱敏业务快照”的通道。
|
||||||
|
2. **风险模式定义**:梳理出 3-5 种典型的财务风险模式(如:异常聚集的餐饮发票、连续的单日高额交通费)。
|
||||||
|
3. **Hermes 巡检任务**:编写定时任务逻辑,利用大模型的推理能力去比对这些模式和当天的业务快照数据。
|
||||||
|
4. **风险看板**:在外层系统的管理后台开发“风险报告台”,展示 Hermes 生成的预警结果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、 关键风险与防范策略总结
|
||||||
|
|
||||||
|
1. **大模型幻觉污染规则库**:
|
||||||
|
* **防范**:Hermes 提炼的所有硬性规则(尤其是金额、审批级数),在写入外层正式库之前,必须增加一个**“人工审核 (Human-in-the-loop)”** 环节。系统提示“检测到政策更新,提炼出 5 条新规则,请管理员确认应用”。
|
||||||
|
2. **状态机混乱**:
|
||||||
|
* **防范**:外层 Agent 的流程控制代码必须使用强类型和严格的事务控制 (Transaction)。绝不允许任何组件(包括 AI)在不经过状态机合法校验的情况下直接修改数据库中的 `status` 字段。
|
||||||
|
3. **性能瓶颈**:
|
||||||
|
* **防范**:所有外层必须做的事情(拦截、查询)必须在毫秒级完成。所有涉及调用 Hermes 的操作(问答、提炼、分析)全部采用异步设计或提供明确的 Loading 反馈。
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
@@ -11,3 +14,49 @@ def get_db() -> Generator[Session, None, None]:
|
|||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CurrentUserContext:
|
||||||
|
username: str
|
||||||
|
name: str
|
||||||
|
role_codes: list[str]
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
x_auth_username: Annotated[str | None, Header()] = None,
|
||||||
|
x_auth_name: Annotated[str | None, Header()] = None,
|
||||||
|
x_auth_role_codes: Annotated[str | None, Header()] = None,
|
||||||
|
x_auth_is_admin: Annotated[str | None, Header()] = None,
|
||||||
|
) -> CurrentUserContext:
|
||||||
|
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
||||||
|
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
username = (x_auth_username or "").strip()
|
||||||
|
name = (x_auth_name or username).strip()
|
||||||
|
|
||||||
|
if not username and not name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="请先登录后再访问知识库。",
|
||||||
|
)
|
||||||
|
|
||||||
|
return CurrentUserContext(
|
||||||
|
username=username or name,
|
||||||
|
name=name or username,
|
||||||
|
role_codes=role_codes,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_user(
|
||||||
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
|
) -> CurrentUserContext:
|
||||||
|
if current_user.is_admin or "manager" in current_user.role_codes:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||||
|
)
|
||||||
|
|||||||
76
server/src/app/api/v1/endpoints/knowledge.py
Normal file
76
server/src/app/api/v1/endpoints/knowledge.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext, get_current_user, require_admin_user
|
||||||
|
from app.schemas.knowledge import (
|
||||||
|
KnowledgeActionResponse,
|
||||||
|
KnowledgeDocumentDetailRead,
|
||||||
|
KnowledgeLibraryRead,
|
||||||
|
)
|
||||||
|
from app.services.knowledge import KnowledgeService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/knowledge")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library", response_model=KnowledgeLibraryRead)
|
||||||
|
def get_knowledge_library(
|
||||||
|
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
|
) -> KnowledgeLibraryRead:
|
||||||
|
return KnowledgeService().list_library()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}", response_model=KnowledgeDocumentDetailRead)
|
||||||
|
def get_knowledge_document(
|
||||||
|
document_id: str,
|
||||||
|
_: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
|
) -> KnowledgeDocumentDetailRead:
|
||||||
|
try:
|
||||||
|
return KnowledgeService().get_document_detail(document_id)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_knowledge_document(
|
||||||
|
request: Request,
|
||||||
|
folder: Annotated[str, Query(min_length=1)],
|
||||||
|
filename: Annotated[str, Query(min_length=1)],
|
||||||
|
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||||
|
) -> KnowledgeDocumentDetailRead:
|
||||||
|
content = await request.body()
|
||||||
|
try:
|
||||||
|
return KnowledgeService().upload_document(folder, filename, content, current_user)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/documents/{document_id}", response_model=KnowledgeActionResponse)
|
||||||
|
def delete_knowledge_document(
|
||||||
|
document_id: str,
|
||||||
|
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||||||
|
) -> KnowledgeActionResponse:
|
||||||
|
try:
|
||||||
|
KnowledgeService().delete_document(document_id)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
|
|
||||||
|
return KnowledgeActionResponse(detail="知识库文件已删除。")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}/content")
|
||||||
|
def get_knowledge_document_content(
|
||||||
|
document_id: str,
|
||||||
|
disposition: Annotated[str, Query(pattern="^(inline|attachment)$")] = "inline",
|
||||||
|
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
|
||||||
|
) -> FileResponse:
|
||||||
|
try:
|
||||||
|
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||||
|
|
||||||
|
_ = disposition
|
||||||
|
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||||
@@ -4,6 +4,7 @@ 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.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
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ 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(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,63 +1,63 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.config import SERVER_DIR
|
from app.core.config import SERVER_DIR
|
||||||
|
|
||||||
ADMIN_SECRET_FILE = SERVER_DIR / ".secrets" / "admin.json"
|
ADMIN_SECRET_FILE = SERVER_DIR / ".secrets" / "admin.json"
|
||||||
|
|
||||||
|
|
||||||
def read_admin_secret() -> dict[str, object] | None:
|
def read_admin_secret() -> dict[str, object] | None:
|
||||||
if not ADMIN_SECRET_FILE.exists():
|
if not ADMIN_SECRET_FILE.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(ADMIN_SECRET_FILE.read_text(encoding="utf-8"))
|
payload = json.loads(ADMIN_SECRET_FILE.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
payload
|
payload
|
||||||
and payload.get("algorithm") == "scrypt"
|
and payload.get("algorithm") == "scrypt"
|
||||||
and isinstance(payload.get("username"), str)
|
and isinstance(payload.get("username"), str)
|
||||||
and isinstance(payload.get("salt"), str)
|
and isinstance(payload.get("salt"), str)
|
||||||
and isinstance(payload.get("derived_key"), str)
|
and isinstance(payload.get("derived_key"), str)
|
||||||
):
|
):
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def verify_admin_secret(password: str, record: dict[str, object]) -> bool:
|
def verify_admin_secret(password: str, record: dict[str, object]) -> bool:
|
||||||
try:
|
try:
|
||||||
salt = bytes.fromhex(str(record["salt"]))
|
salt = bytes.fromhex(str(record["salt"]))
|
||||||
stored_key = bytes.fromhex(str(record["derived_key"]))
|
stored_key = bytes.fromhex(str(record["derived_key"]))
|
||||||
key_length = int(record.get("key_length", 64))
|
key_length = int(record.get("key_length", 64))
|
||||||
n_value = int(record.get("N", 16384))
|
n_value = int(record.get("N", 16384))
|
||||||
r_value = int(record.get("r", 8))
|
r_value = int(record.get("r", 8))
|
||||||
p_value = int(record.get("p", 1))
|
p_value = int(record.get("p", 1))
|
||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
derived_key = hashlib.scrypt(
|
derived_key = hashlib.scrypt(
|
||||||
password.encode("utf-8"),
|
password.encode("utf-8"),
|
||||||
salt=salt,
|
salt=salt,
|
||||||
n=n_value,
|
n=n_value,
|
||||||
r=r_value,
|
r=r_value,
|
||||||
p=p_value,
|
p=p_value,
|
||||||
dklen=key_length,
|
dklen=key_length,
|
||||||
)
|
)
|
||||||
return secrets.compare_digest(derived_key, stored_key)
|
return secrets.compare_digest(derived_key, stored_key)
|
||||||
|
|
||||||
|
|
||||||
def legacy_admin_secret_to_password_hash(record: dict[str, object]) -> str:
|
def legacy_admin_secret_to_password_hash(record: dict[str, object]) -> str:
|
||||||
salt = str(record["salt"])
|
salt = str(record["salt"])
|
||||||
derived_key = str(record["derived_key"])
|
derived_key = str(record["derived_key"])
|
||||||
key_length = int(record.get("key_length", 64))
|
key_length = int(record.get("key_length", 64))
|
||||||
n_value = int(record.get("N", 16384))
|
n_value = int(record.get("N", 16384))
|
||||||
r_value = int(record.get("r", 8))
|
r_value = int(record.get("r", 8))
|
||||||
p_value = int(record.get("p", 1))
|
p_value = int(record.get("p", 1))
|
||||||
return f"scrypt${n_value}${r_value}${p_value}${key_length}${salt}${derived_key}"
|
return f"scrypt${n_value}${r_value}${p_value}${key_length}${salt}${derived_key}"
|
||||||
|
|||||||
@@ -1,76 +1,84 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from os import environ
|
from os import environ
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
SERVER_DIR = Path(__file__).resolve().parents[3]
|
SERVER_DIR = Path(__file__).resolve().parents[3]
|
||||||
ROOT_DIR = SERVER_DIR.parent
|
ROOT_DIR = SERVER_DIR.parent
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=(ROOT_DIR / ".env", SERVER_DIR / ".env"),
|
env_file=(ROOT_DIR / ".env", SERVER_DIR / ".env"),
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
|
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
|
||||||
app_env: str = Field(default="local", alias="APP_ENV")
|
app_env: str = Field(default="local", alias="APP_ENV")
|
||||||
app_debug: bool = Field(default=True, alias="APP_DEBUG")
|
app_debug: bool = Field(default=True, alias="APP_DEBUG")
|
||||||
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
|
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
|
||||||
|
|
||||||
company_name: str = Field(default="", alias="COMPANY_NAME")
|
company_name: str = Field(default="", alias="COMPANY_NAME")
|
||||||
company_code: str = Field(default="", alias="COMPANY_CODE")
|
company_code: str = Field(default="", alias="COMPANY_CODE")
|
||||||
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
|
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
|
||||||
|
|
||||||
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
|
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
|
||||||
web_port: int = Field(default=5173, alias="WEB_PORT")
|
web_port: int = Field(default=5173, alias="WEB_PORT")
|
||||||
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
|
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
|
||||||
app_port: int = Field(default=8000, alias="SERVER_PORT")
|
app_port: int = Field(default=8000, alias="SERVER_PORT")
|
||||||
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
|
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
|
||||||
|
|
||||||
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
|
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
|
||||||
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
||||||
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
|
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
|
||||||
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
|
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
|
||||||
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
|
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
|
||||||
|
|
||||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||||
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
|
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
|
||||||
|
|
||||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||||
vite_api_base_url: str = Field(
|
vite_api_base_url: str = Field(
|
||||||
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
|
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||||
log_dir: str = Field(default="logs", alias="LOG_DIR")
|
log_dir: str = Field(default="logs", alias="LOG_DIR")
|
||||||
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
||||||
|
storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolved_database_url(self) -> str:
|
def resolved_database_url(self) -> str:
|
||||||
if self.database_url:
|
if self.database_url:
|
||||||
return self.database_url
|
return self.database_url
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
|
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
|
||||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
@lru_cache
|
def resolved_storage_root_dir(self) -> Path:
|
||||||
def get_settings() -> Settings:
|
path = Path(self.storage_root_dir)
|
||||||
return Settings()
|
if not path.is_absolute():
|
||||||
|
path = SERVER_DIR / path
|
||||||
|
return path.resolve()
|
||||||
def refresh_settings(updated_values: dict[str, str]) -> Settings:
|
|
||||||
for key, value in updated_values.items():
|
|
||||||
environ[key] = value
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
get_settings.cache_clear()
|
return Settings()
|
||||||
return get_settings()
|
|
||||||
|
|
||||||
|
def refresh_settings(updated_values: dict[str, str]) -> Settings:
|
||||||
|
for key, value in updated_values.items():
|
||||||
|
environ[key] = value
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
return get_settings()
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||||
|
|
||||||
PBKDF2_ALGORITHM = "sha256"
|
PBKDF2_ALGORITHM = "sha256"
|
||||||
PBKDF2_ITERATIONS = 120_000
|
PBKDF2_ITERATIONS = 120_000
|
||||||
SALT_BYTES = 16
|
SALT_BYTES = 16
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
salt = secrets.token_bytes(SALT_BYTES)
|
salt = secrets.token_bytes(SALT_BYTES)
|
||||||
digest = hashlib.pbkdf2_hmac(
|
digest = hashlib.pbkdf2_hmac(
|
||||||
PBKDF2_ALGORITHM,
|
PBKDF2_ALGORITHM,
|
||||||
password.encode("utf-8"),
|
password.encode("utf-8"),
|
||||||
salt,
|
salt,
|
||||||
PBKDF2_ITERATIONS,
|
PBKDF2_ITERATIONS,
|
||||||
)
|
)
|
||||||
encoded_salt = urlsafe_b64encode(salt).decode("utf-8")
|
encoded_salt = urlsafe_b64encode(salt).decode("utf-8")
|
||||||
encoded_digest = urlsafe_b64encode(digest).decode("utf-8")
|
encoded_digest = urlsafe_b64encode(digest).decode("utf-8")
|
||||||
return f"pbkdf2_{PBKDF2_ALGORITHM}${PBKDF2_ITERATIONS}${encoded_salt}${encoded_digest}"
|
return f"pbkdf2_{PBKDF2_ALGORITHM}${PBKDF2_ITERATIONS}${encoded_salt}${encoded_digest}"
|
||||||
|
|
||||||
|
|
||||||
def verify_password(password: str, password_hash: str) -> bool:
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
if password_hash.startswith("scrypt$"):
|
if password_hash.startswith("scrypt$"):
|
||||||
return verify_scrypt_password(password, password_hash)
|
return verify_scrypt_password(password, password_hash)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3)
|
scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if scheme != f"pbkdf2_{PBKDF2_ALGORITHM}":
|
if scheme != f"pbkdf2_{PBKDF2_ALGORITHM}":
|
||||||
return False
|
return False
|
||||||
|
|
||||||
salt = urlsafe_b64decode(encoded_salt.encode("utf-8"))
|
salt = urlsafe_b64decode(encoded_salt.encode("utf-8"))
|
||||||
expected_digest = urlsafe_b64decode(encoded_digest.encode("utf-8"))
|
expected_digest = urlsafe_b64decode(encoded_digest.encode("utf-8"))
|
||||||
computed_digest = hashlib.pbkdf2_hmac(
|
computed_digest = hashlib.pbkdf2_hmac(
|
||||||
PBKDF2_ALGORITHM,
|
PBKDF2_ALGORITHM,
|
||||||
password.encode("utf-8"),
|
password.encode("utf-8"),
|
||||||
salt,
|
salt,
|
||||||
int(iterations),
|
int(iterations),
|
||||||
)
|
)
|
||||||
return secrets.compare_digest(computed_digest, expected_digest)
|
return secrets.compare_digest(computed_digest, expected_digest)
|
||||||
|
|
||||||
|
|
||||||
def verify_scrypt_password(password: str, password_hash: str) -> bool:
|
def verify_scrypt_password(password: str, password_hash: str) -> bool:
|
||||||
try:
|
try:
|
||||||
scheme, n_value, r_value, p_value, key_length, salt_hex, derived_key_hex = password_hash.split("$", 6)
|
scheme, n_value, r_value, p_value, key_length, salt_hex, derived_key_hex = password_hash.split("$", 6)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if scheme != "scrypt":
|
if scheme != "scrypt":
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
salt = bytes.fromhex(salt_hex)
|
salt = bytes.fromhex(salt_hex)
|
||||||
expected_key = bytes.fromhex(derived_key_hex)
|
expected_key = bytes.fromhex(derived_key_hex)
|
||||||
derived_key = hashlib.scrypt(
|
derived_key = hashlib.scrypt(
|
||||||
password.encode("utf-8"),
|
password.encode("utf-8"),
|
||||||
salt=salt,
|
salt=salt,
|
||||||
n=int(n_value),
|
n=int(n_value),
|
||||||
r=int(r_value),
|
r=int(r_value),
|
||||||
p=int(p_value),
|
p=int(p_value),
|
||||||
dklen=int(key_length),
|
dklen=int(key_length),
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return secrets.compare_digest(derived_key, expected_key)
|
return secrets.compare_digest(derived_key, expected_key)
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
from app.models.approval import ApprovalRecord
|
from app.models.approval import ApprovalRecord
|
||||||
from app.models.employee_change_log import EmployeeChangeLog
|
from app.models.employee_change_log import EmployeeChangeLog
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.models.system_model_setting import SystemModelSetting
|
from app.models.system_model_setting import SystemModelSetting
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.system_setting_secret import SystemSettingSecret
|
from app.models.system_setting_secret import SystemSettingSecret
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ApprovalRecord",
|
"ApprovalRecord",
|
||||||
"Employee",
|
"Employee",
|
||||||
"EmployeeChangeLog",
|
"EmployeeChangeLog",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"ReimbursementRequest",
|
"ReimbursementRequest",
|
||||||
"Role",
|
"Role",
|
||||||
"SystemModelSetting",
|
"SystemModelSetting",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"SystemSettingSecret",
|
"SystemSettingSecret",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -50,6 +51,7 @@ def create_app() -> FastAPI:
|
|||||||
@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()
|
||||||
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,
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
from app.models.approval import ApprovalRecord
|
from app.models.approval import ApprovalRecord
|
||||||
from app.models.employee_change_log import EmployeeChangeLog
|
from app.models.employee_change_log import EmployeeChangeLog
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.models.system_model_setting import SystemModelSetting
|
from app.models.system_model_setting import SystemModelSetting
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.system_setting_secret import SystemSettingSecret
|
from app.models.system_setting_secret import SystemSettingSecret
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApprovalRecord",
|
"ApprovalRecord",
|
||||||
"Employee",
|
"Employee",
|
||||||
"EmployeeChangeLog",
|
"EmployeeChangeLog",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"ReimbursementRequest",
|
"ReimbursementRequest",
|
||||||
"Role",
|
"Role",
|
||||||
"SystemModelSetting",
|
"SystemModelSetting",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"SystemSettingSecret",
|
"SystemSettingSecret",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
class SystemModelSetting(Base):
|
class SystemModelSetting(Base):
|
||||||
__tablename__ = "system_model_settings"
|
__tablename__ = "system_model_settings"
|
||||||
|
|
||||||
slot: Mapped[str] = mapped_column(String(32), primary_key=True)
|
slot: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
provider: Mapped[str] = mapped_column(String(64), default="")
|
provider: Mapped[str] = mapped_column(String(64), default="")
|
||||||
model_name: Mapped[str] = mapped_column(String(255), default="")
|
model_name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
endpoint: Mapped[str] = mapped_column(String(512), default="")
|
endpoint: Mapped[str] = mapped_column(String(512), default="")
|
||||||
capability: Mapped[str] = mapped_column(String(32), default="chat")
|
capability: Mapped[str] = mapped_column(String(32), default="chat")
|
||||||
priority: Mapped[int] = mapped_column(Integer, default=0)
|
priority: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
api_key_encrypted: Mapped[str] = mapped_column(Text, default="")
|
api_key_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
onupdate=func.now(),
|
onupdate=func.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.system_model_setting import SystemModelSetting
|
from app.models.system_model_setting import SystemModelSetting
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.system_setting_secret import SystemSettingSecret
|
from app.models.system_setting_secret import SystemSettingSecret
|
||||||
|
|
||||||
SETTINGS_ROW_ID = "default"
|
SETTINGS_ROW_ID = "default"
|
||||||
|
|
||||||
|
|
||||||
class SettingsRepository:
|
class SettingsRepository:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def get_settings(self) -> SystemSetting | None:
|
def get_settings(self) -> SystemSetting | None:
|
||||||
stmt = select(SystemSetting).where(SystemSetting.id == SETTINGS_ROW_ID)
|
stmt = select(SystemSetting).where(SystemSetting.id == SETTINGS_ROW_ID)
|
||||||
return self.db.execute(stmt).scalars().first()
|
return self.db.execute(stmt).scalars().first()
|
||||||
|
|
||||||
def get_secrets(self) -> SystemSettingSecret | None:
|
def get_secrets(self) -> SystemSettingSecret | None:
|
||||||
stmt = select(SystemSettingSecret).where(SystemSettingSecret.id == SETTINGS_ROW_ID)
|
stmt = select(SystemSettingSecret).where(SystemSettingSecret.id == SETTINGS_ROW_ID)
|
||||||
return self.db.execute(stmt).scalars().first()
|
return self.db.execute(stmt).scalars().first()
|
||||||
|
|
||||||
def get_model_settings(self) -> list[SystemModelSetting]:
|
def get_model_settings(self) -> list[SystemModelSetting]:
|
||||||
stmt = select(SystemModelSetting)
|
stmt = select(SystemModelSetting)
|
||||||
return list(self.db.execute(stmt).scalars().all())
|
return list(self.db.execute(stmt).scalars().all())
|
||||||
|
|
||||||
def get_model_setting(self, slot: str) -> SystemModelSetting | None:
|
def get_model_setting(self, slot: str) -> SystemModelSetting | None:
|
||||||
stmt = select(SystemModelSetting).where(SystemModelSetting.slot == slot)
|
stmt = select(SystemModelSetting).where(SystemModelSetting.slot == slot)
|
||||||
return self.db.execute(stmt).scalars().first()
|
return self.db.execute(stmt).scalars().first()
|
||||||
|
|
||||||
def save_settings(self, settings: SystemSetting) -> SystemSetting:
|
def save_settings(self, settings: SystemSetting) -> SystemSetting:
|
||||||
self.db.add(settings)
|
self.db.add(settings)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(settings)
|
self.db.refresh(settings)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
def save_secrets(self, secrets: SystemSettingSecret) -> SystemSettingSecret:
|
def save_secrets(self, secrets: SystemSettingSecret) -> SystemSettingSecret:
|
||||||
self.db.add(secrets)
|
self.db.add(secrets)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(secrets)
|
self.db.refresh(secrets)
|
||||||
return secrets
|
return secrets
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__all__ = ["employee", "reimbursement"]
|
__all__ = ["employee", "knowledge", "reimbursement"]
|
||||||
|
|||||||
61
server/src/app/schemas/knowledge.py
Normal file
61
server/src/app/schemas/knowledge.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFolderRead(BaseModel):
|
||||||
|
name: str
|
||||||
|
count: int
|
||||||
|
icon: str = "mdi mdi-folder"
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgePreviewStatRead(BaseModel):
|
||||||
|
label: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgePreviewBlockRead(BaseModel):
|
||||||
|
heading: str
|
||||||
|
lines: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgePreviewPageRead(BaseModel):
|
||||||
|
title: str
|
||||||
|
subtitle: str
|
||||||
|
stats: list[KnowledgePreviewStatRead] = Field(default_factory=list)
|
||||||
|
blocks: list[KnowledgePreviewBlockRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeDocumentRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
folder: str
|
||||||
|
tag: str
|
||||||
|
time: str
|
||||||
|
version: str
|
||||||
|
state: str
|
||||||
|
stateTone: str
|
||||||
|
owner: str
|
||||||
|
icon: str
|
||||||
|
fileType: str
|
||||||
|
fileTypeLabel: str
|
||||||
|
summary: str
|
||||||
|
mimeType: str
|
||||||
|
extension: str
|
||||||
|
sizeBytes: int
|
||||||
|
canPreview: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeDocumentDetailRead(KnowledgeDocumentRead):
|
||||||
|
previewKind: str
|
||||||
|
previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeLibraryRead(BaseModel):
|
||||||
|
folders: list[KnowledgeFolderRead] = Field(default_factory=list)
|
||||||
|
documents: list[KnowledgeDocumentRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeActionResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
detail: str
|
||||||
634
server/src/app/services/knowledge.py
Normal file
634
server/src/app/services/knowledge.py
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
from zipfile import BadZipFile, ZipFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.schemas.knowledge import (
|
||||||
|
KnowledgeDocumentDetailRead,
|
||||||
|
KnowledgeDocumentRead,
|
||||||
|
KnowledgeFolderRead,
|
||||||
|
KnowledgeLibraryRead,
|
||||||
|
KnowledgePreviewBlockRead,
|
||||||
|
KnowledgePreviewPageRead,
|
||||||
|
KnowledgePreviewStatRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger("app.services.knowledge")
|
||||||
|
|
||||||
|
FIXED_KNOWLEDGE_FOLDERS = [
|
||||||
|
"财务知识库",
|
||||||
|
"制度政策",
|
||||||
|
"报销制度",
|
||||||
|
"差旅规范",
|
||||||
|
"发票管理",
|
||||||
|
"税务合规",
|
||||||
|
"预算管理",
|
||||||
|
"财务共享",
|
||||||
|
"培训资料",
|
||||||
|
"常见问答",
|
||||||
|
]
|
||||||
|
|
||||||
|
ICON_BY_TYPE = {
|
||||||
|
"pdf": "mdi mdi-file-document-outline-pdf pdf",
|
||||||
|
"word": "mdi mdi-file-document-outline-word word",
|
||||||
|
"excel": "mdi mdi-file-document-outline-excel excel",
|
||||||
|
"ppt": "mdi mdi-file-powerpoint-box ppt",
|
||||||
|
"image": "mdi mdi-file-image-outline image",
|
||||||
|
"text": "mdi mdi-file-document-outline text",
|
||||||
|
"archive": "mdi mdi-folder-zip-outline archive",
|
||||||
|
"binary": "mdi mdi-file-outline",
|
||||||
|
}
|
||||||
|
|
||||||
|
TEXT_EXTENSIONS = {"txt", "md", "csv", "json", "xml", "yml", "yaml", "log"}
|
||||||
|
WORD_EXTENSIONS = {"doc", "docx"}
|
||||||
|
EXCEL_EXTENSIONS = {"xls", "xlsx", "csv"}
|
||||||
|
PPT_EXTENSIONS = {"ppt", "pptx"}
|
||||||
|
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"}
|
||||||
|
ARCHIVE_EXTENSIONS = {"zip", "rar", "7z"}
|
||||||
|
STRUCTURED_PREVIEW_EXTENSIONS = {"docx", "xlsx", "pptx"} | TEXT_EXTENSIONS
|
||||||
|
INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_knowledge_library() -> None:
|
||||||
|
KnowledgeService().ensure_library_ready()
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeService:
|
||||||
|
def __init__(self, storage_root: Path | None = None) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.storage_root = Path(storage_root or settings.resolved_storage_root_dir)
|
||||||
|
self.library_root = self.storage_root / "knowledge"
|
||||||
|
self.index_path = self.library_root / ".index.json"
|
||||||
|
|
||||||
|
def ensure_library_ready(self) -> None:
|
||||||
|
self.library_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
for folder_name in FIXED_KNOWLEDGE_FOLDERS:
|
||||||
|
(self.library_root / folder_name).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not self.index_path.exists():
|
||||||
|
self._save_index({"version": 1, "documents": []})
|
||||||
|
|
||||||
|
index = self._load_index()
|
||||||
|
if self._reconcile_index(index):
|
||||||
|
self._save_index(index)
|
||||||
|
|
||||||
|
def list_library(self) -> KnowledgeLibraryRead:
|
||||||
|
documents = self._load_documents()
|
||||||
|
folders = [
|
||||||
|
KnowledgeFolderRead(
|
||||||
|
name=folder_name,
|
||||||
|
count=sum(1 for item in documents if item.folder == folder_name),
|
||||||
|
icon="mdi mdi-folder-open" if folder_name == "差旅规范" else "mdi mdi-folder",
|
||||||
|
)
|
||||||
|
for folder_name in FIXED_KNOWLEDGE_FOLDERS
|
||||||
|
]
|
||||||
|
return KnowledgeLibraryRead(folders=folders, documents=documents)
|
||||||
|
|
||||||
|
def get_document_detail(self, document_id: str) -> KnowledgeDocumentDetailRead:
|
||||||
|
self.ensure_library_ready()
|
||||||
|
index = self._load_index()
|
||||||
|
entry = self._require_entry(index, document_id)
|
||||||
|
preview_kind, preview_pages = self._build_preview(entry)
|
||||||
|
document = self._serialize_document(entry)
|
||||||
|
return KnowledgeDocumentDetailRead(
|
||||||
|
**document.model_dump(),
|
||||||
|
previewKind=preview_kind,
|
||||||
|
previewPages=preview_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_document(
|
||||||
|
self,
|
||||||
|
folder: str,
|
||||||
|
filename: str,
|
||||||
|
content: bytes,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> KnowledgeDocumentDetailRead:
|
||||||
|
self.ensure_library_ready()
|
||||||
|
normalized_folder = self._normalize_folder(folder)
|
||||||
|
normalized_name = self._normalize_filename(filename)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise ValueError("上传文件不能为空。")
|
||||||
|
|
||||||
|
index = self._load_index()
|
||||||
|
existing_entry = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in index["documents"]
|
||||||
|
if item["folder"] == normalized_folder
|
||||||
|
and item["original_name"].lower() == normalized_name.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
document_id = existing_entry["id"] if existing_entry else uuid4().hex
|
||||||
|
stored_name = f"{document_id}__{normalized_name}"
|
||||||
|
target_path = self.library_root / normalized_folder / stored_name
|
||||||
|
|
||||||
|
if existing_entry is not None and existing_entry["stored_name"] != stored_name:
|
||||||
|
old_path = self.library_root / existing_entry["folder"] / existing_entry["stored_name"]
|
||||||
|
if old_path.exists():
|
||||||
|
old_path.unlink()
|
||||||
|
|
||||||
|
target_path.write_bytes(content)
|
||||||
|
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
mime_type = mimetypes.guess_type(normalized_name)[0] or "application/octet-stream"
|
||||||
|
checksum = hashlib.sha256(content).hexdigest()
|
||||||
|
extension = self._extract_extension(normalized_name)
|
||||||
|
|
||||||
|
if existing_entry is None:
|
||||||
|
entry = {
|
||||||
|
"id": document_id,
|
||||||
|
"folder": normalized_folder,
|
||||||
|
"original_name": normalized_name,
|
||||||
|
"stored_name": stored_name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"extension": extension,
|
||||||
|
"size_bytes": len(content),
|
||||||
|
"sha256": checksum,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"uploaded_by": current_user.name,
|
||||||
|
"version_number": 1,
|
||||||
|
}
|
||||||
|
index["documents"].append(entry)
|
||||||
|
logger.info(
|
||||||
|
"Knowledge document uploaded id=%s folder=%s filename=%s by=%s",
|
||||||
|
document_id,
|
||||||
|
normalized_folder,
|
||||||
|
normalized_name,
|
||||||
|
current_user.name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
existing_entry.update(
|
||||||
|
{
|
||||||
|
"stored_name": stored_name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"extension": extension,
|
||||||
|
"size_bytes": len(content),
|
||||||
|
"sha256": checksum,
|
||||||
|
"updated_at": now,
|
||||||
|
"uploaded_by": current_user.name,
|
||||||
|
"version_number": int(existing_entry.get("version_number", 1)) + 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entry = existing_entry
|
||||||
|
logger.info(
|
||||||
|
"Knowledge document updated id=%s folder=%s filename=%s by=%s",
|
||||||
|
document_id,
|
||||||
|
normalized_folder,
|
||||||
|
normalized_name,
|
||||||
|
current_user.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._save_index(index)
|
||||||
|
return self.get_document_detail(document_id)
|
||||||
|
|
||||||
|
def delete_document(self, document_id: str) -> None:
|
||||||
|
self.ensure_library_ready()
|
||||||
|
index = self._load_index()
|
||||||
|
entry = self._require_entry(index, document_id)
|
||||||
|
file_path = self._resolve_document_path(entry)
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
|
||||||
|
index["documents"] = [item for item in index["documents"] if item["id"] != document_id]
|
||||||
|
self._save_index(index)
|
||||||
|
logger.info("Knowledge document deleted id=%s filename=%s", document_id, entry["original_name"])
|
||||||
|
|
||||||
|
def get_document_content(self, document_id: str) -> tuple[Path, str, str]:
|
||||||
|
self.ensure_library_ready()
|
||||||
|
index = self._load_index()
|
||||||
|
entry = self._require_entry(index, document_id)
|
||||||
|
file_path = self._resolve_document_path(entry)
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise FileNotFoundError(entry["original_name"])
|
||||||
|
|
||||||
|
return file_path, entry["mime_type"], entry["original_name"]
|
||||||
|
|
||||||
|
def _load_documents(self) -> list[KnowledgeDocumentRead]:
|
||||||
|
self.ensure_library_ready()
|
||||||
|
index = self._load_index()
|
||||||
|
self._reconcile_index(index)
|
||||||
|
self._save_index(index)
|
||||||
|
|
||||||
|
documents = [self._serialize_document(entry) for entry in index["documents"]]
|
||||||
|
return sorted(documents, key=lambda item: item.time, reverse=True)
|
||||||
|
|
||||||
|
def _serialize_document(self, entry: dict[str, Any]) -> KnowledgeDocumentRead:
|
||||||
|
extension = entry.get("extension") or self._extract_extension(entry["original_name"])
|
||||||
|
file_type = self._resolve_file_type(extension)
|
||||||
|
size_bytes = int(entry.get("size_bytes") or 0)
|
||||||
|
updated_at = self._format_time(entry.get("updated_at") or entry.get("created_at"))
|
||||||
|
|
||||||
|
return KnowledgeDocumentRead(
|
||||||
|
id=entry["id"],
|
||||||
|
name=entry["original_name"],
|
||||||
|
folder=entry["folder"],
|
||||||
|
tag=f"{entry['folder']} / {extension.upper() or 'FILE'}",
|
||||||
|
time=updated_at,
|
||||||
|
version=f"v{int(entry.get('version_number', 1))}.0",
|
||||||
|
state="已发布",
|
||||||
|
stateTone="success",
|
||||||
|
owner=entry.get("uploaded_by") or "系统导入",
|
||||||
|
icon=ICON_BY_TYPE.get(file_type, ICON_BY_TYPE["binary"]),
|
||||||
|
fileType=file_type,
|
||||||
|
fileTypeLabel=self._resolve_file_type_label(file_type),
|
||||||
|
summary=f"{entry['folder']} · {extension.upper() or 'FILE'} · {self._format_size(size_bytes)}",
|
||||||
|
mimeType=entry.get("mime_type") or "application/octet-stream",
|
||||||
|
extension=extension,
|
||||||
|
sizeBytes=size_bytes,
|
||||||
|
canPreview=self._can_preview(extension),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_preview(
|
||||||
|
self, entry: dict[str, Any]
|
||||||
|
) -> tuple[str, list[KnowledgePreviewPageRead]]:
|
||||||
|
extension = self._extract_extension(entry["original_name"])
|
||||||
|
file_path = self._resolve_document_path(entry)
|
||||||
|
|
||||||
|
if extension == "pdf":
|
||||||
|
return "pdf", []
|
||||||
|
|
||||||
|
if extension in IMAGE_EXTENSIONS:
|
||||||
|
return "image", []
|
||||||
|
|
||||||
|
if extension in TEXT_EXTENSIONS:
|
||||||
|
text = self._read_text_preview(file_path)
|
||||||
|
return "text", [self._build_text_preview_page(entry, text)]
|
||||||
|
|
||||||
|
if extension == "docx":
|
||||||
|
text = self._extract_docx_text(file_path)
|
||||||
|
return "text", [self._build_text_preview_page(entry, text)]
|
||||||
|
|
||||||
|
if extension == "xlsx":
|
||||||
|
return "table", [self._build_xlsx_preview_page(entry, file_path)]
|
||||||
|
|
||||||
|
if extension == "pptx":
|
||||||
|
return "slides", self._build_pptx_preview_pages(entry, file_path)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"unsupported",
|
||||||
|
[
|
||||||
|
KnowledgePreviewPageRead(
|
||||||
|
title=entry["original_name"],
|
||||||
|
subtitle="当前格式暂不支持在线解析预览。",
|
||||||
|
stats=[
|
||||||
|
KnowledgePreviewStatRead(label="文件格式", value=extension.upper() or "FILE"),
|
||||||
|
KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])),
|
||||||
|
KnowledgePreviewStatRead(label="建议操作", value="下载后查看"),
|
||||||
|
],
|
||||||
|
blocks=[
|
||||||
|
KnowledgePreviewBlockRead(
|
||||||
|
heading="预览说明",
|
||||||
|
lines=[
|
||||||
|
"当前系统已支持该文件的上传、下载和权限控制。",
|
||||||
|
"如需在线预览,可后续接入专门的文档转换服务。",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_text_preview_page(
|
||||||
|
self, entry: dict[str, Any], text: str
|
||||||
|
) -> KnowledgePreviewPageRead:
|
||||||
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||||
|
if not lines:
|
||||||
|
lines = ["文件内容为空,或当前文档未提取到可展示文本。"]
|
||||||
|
|
||||||
|
groups = [lines[index : index + 8] for index in range(0, min(len(lines), 24), 8)]
|
||||||
|
blocks = [
|
||||||
|
KnowledgePreviewBlockRead(heading=f"内容片段 {index + 1}", lines=group)
|
||||||
|
for index, group in enumerate(groups)
|
||||||
|
]
|
||||||
|
|
||||||
|
return KnowledgePreviewPageRead(
|
||||||
|
title=entry["original_name"],
|
||||||
|
subtitle="文本提取预览",
|
||||||
|
stats=[
|
||||||
|
KnowledgePreviewStatRead(label="文件格式", value=entry["extension"].upper() or "TEXT"),
|
||||||
|
KnowledgePreviewStatRead(label="可见行数", value=str(len(lines))),
|
||||||
|
KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])),
|
||||||
|
],
|
||||||
|
blocks=blocks,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_xlsx_preview_page(
|
||||||
|
self, entry: dict[str, Any], file_path: Path
|
||||||
|
) -> KnowledgePreviewPageRead:
|
||||||
|
rows, sheet_count = self._extract_xlsx_rows(file_path)
|
||||||
|
if not rows:
|
||||||
|
rows = [["未提取到表格内容。"]]
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
KnowledgePreviewBlockRead(
|
||||||
|
heading=f"第 {index + 1} 行",
|
||||||
|
lines=[" | ".join(cell for cell in row if cell) or "(空行)"],
|
||||||
|
)
|
||||||
|
for index, row in enumerate(rows[:12])
|
||||||
|
]
|
||||||
|
|
||||||
|
return KnowledgePreviewPageRead(
|
||||||
|
title=entry["original_name"],
|
||||||
|
subtitle="表格内容预览",
|
||||||
|
stats=[
|
||||||
|
KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)),
|
||||||
|
KnowledgePreviewStatRead(label="预览行数", value=str(min(len(rows), 12))),
|
||||||
|
KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])),
|
||||||
|
],
|
||||||
|
blocks=blocks,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_pptx_preview_pages(
|
||||||
|
self, entry: dict[str, Any], file_path: Path
|
||||||
|
) -> list[KnowledgePreviewPageRead]:
|
||||||
|
slides = self._extract_pptx_slides(file_path)
|
||||||
|
if not slides:
|
||||||
|
slides = [["未提取到幻灯片文本。"]]
|
||||||
|
|
||||||
|
pages: list[KnowledgePreviewPageRead] = []
|
||||||
|
for index, slide_lines in enumerate(slides[:8]):
|
||||||
|
pages.append(
|
||||||
|
KnowledgePreviewPageRead(
|
||||||
|
title=entry["original_name"],
|
||||||
|
subtitle=f"幻灯片 {index + 1}",
|
||||||
|
stats=[
|
||||||
|
KnowledgePreviewStatRead(label="页码", value=str(index + 1)),
|
||||||
|
KnowledgePreviewStatRead(label="文本条数", value=str(len(slide_lines))),
|
||||||
|
KnowledgePreviewStatRead(label="文件格式", value="PPTX"),
|
||||||
|
],
|
||||||
|
blocks=[
|
||||||
|
KnowledgePreviewBlockRead(
|
||||||
|
heading="幻灯片内容",
|
||||||
|
lines=slide_lines or ["该页未提取到文本内容。"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
def _load_index(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.index_path.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
payload = {"version": 1, "documents": []}
|
||||||
|
payload.setdefault("documents", [])
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _save_index(self, index: dict[str, Any]) -> None:
|
||||||
|
self.index_path.write_text(
|
||||||
|
json.dumps(index, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reconcile_index(self, index: dict[str, Any]) -> bool:
|
||||||
|
changed = False
|
||||||
|
documents = index.setdefault("documents", [])
|
||||||
|
known_by_stored = {
|
||||||
|
(item["folder"], item["stored_name"]): item
|
||||||
|
for item in documents
|
||||||
|
if item.get("folder") and item.get("stored_name")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing_items: list[dict[str, Any]] = []
|
||||||
|
for item in documents:
|
||||||
|
file_path = self._resolve_document_path(item)
|
||||||
|
if file_path.exists():
|
||||||
|
item["size_bytes"] = file_path.stat().st_size
|
||||||
|
item["extension"] = self._extract_extension(item["original_name"])
|
||||||
|
item["mime_type"] = item.get("mime_type") or (
|
||||||
|
mimetypes.guess_type(item["original_name"])[0] or "application/octet-stream"
|
||||||
|
)
|
||||||
|
existing_items.append(item)
|
||||||
|
else:
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
for folder_name in FIXED_KNOWLEDGE_FOLDERS:
|
||||||
|
folder_path = self.library_root / folder_name
|
||||||
|
for file_path in folder_path.iterdir():
|
||||||
|
if not file_path.is_file() or file_path.name.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (folder_name, file_path.name)
|
||||||
|
if key in known_by_stored:
|
||||||
|
continue
|
||||||
|
|
||||||
|
document_id, original_name = self._parse_stored_name(file_path.name)
|
||||||
|
stat = file_path.stat()
|
||||||
|
existing_items.append(
|
||||||
|
{
|
||||||
|
"id": document_id,
|
||||||
|
"folder": folder_name,
|
||||||
|
"original_name": original_name,
|
||||||
|
"stored_name": file_path.name,
|
||||||
|
"mime_type": mimetypes.guess_type(original_name)[0]
|
||||||
|
or "application/octet-stream",
|
||||||
|
"extension": self._extract_extension(original_name),
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"sha256": "",
|
||||||
|
"created_at": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
|
||||||
|
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||||
|
"uploaded_by": "系统导入",
|
||||||
|
"version_number": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed or len(existing_items) != len(documents):
|
||||||
|
index["documents"] = existing_items
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _require_entry(self, index: dict[str, Any], document_id: str) -> dict[str, Any]:
|
||||||
|
for entry in index["documents"]:
|
||||||
|
if entry["id"] == document_id:
|
||||||
|
return entry
|
||||||
|
raise FileNotFoundError(document_id)
|
||||||
|
|
||||||
|
def _resolve_document_path(self, entry: dict[str, Any]) -> Path:
|
||||||
|
return self.library_root / entry["folder"] / entry["stored_name"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_filename(filename: str) -> str:
|
||||||
|
normalized = Path(str(filename or "").strip()).name.strip()
|
||||||
|
normalized = normalized.replace("/", "_").replace("\\", "_")
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("文件名不能为空。")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_folder(folder: str) -> str:
|
||||||
|
normalized = str(folder or "").strip()
|
||||||
|
if normalized not in FIXED_KNOWLEDGE_FOLDERS:
|
||||||
|
raise ValueError("只能上传到预设知识库文件夹。")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_extension(filename: str) -> str:
|
||||||
|
suffix = Path(filename).suffix.lower().lstrip(".")
|
||||||
|
return suffix
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_stored_name(stored_name: str) -> tuple[str, str]:
|
||||||
|
if "__" not in stored_name:
|
||||||
|
return uuid4().hex, stored_name
|
||||||
|
document_id, original_name = stored_name.split("__", 1)
|
||||||
|
return document_id or uuid4().hex, original_name or stored_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_time(value: str | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
return parsed.astimezone(UTC).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_size(size_bytes: int) -> str:
|
||||||
|
if size_bytes < 1024:
|
||||||
|
return f"{size_bytes} B"
|
||||||
|
if size_bytes < 1024 * 1024:
|
||||||
|
return f"{size_bytes / 1024:.1f} KB"
|
||||||
|
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_file_type(extension: str) -> str:
|
||||||
|
if extension == "pdf":
|
||||||
|
return "pdf"
|
||||||
|
if extension in WORD_EXTENSIONS:
|
||||||
|
return "word"
|
||||||
|
if extension in EXCEL_EXTENSIONS:
|
||||||
|
return "excel"
|
||||||
|
if extension in PPT_EXTENSIONS:
|
||||||
|
return "ppt"
|
||||||
|
if extension in IMAGE_EXTENSIONS:
|
||||||
|
return "image"
|
||||||
|
if extension in TEXT_EXTENSIONS:
|
||||||
|
return "text"
|
||||||
|
if extension in ARCHIVE_EXTENSIONS:
|
||||||
|
return "archive"
|
||||||
|
return "binary"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_file_type_label(file_type: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"pdf": "PDF 预览",
|
||||||
|
"word": "Word 预览",
|
||||||
|
"excel": "Excel 预览",
|
||||||
|
"ppt": "PPT 预览",
|
||||||
|
"image": "图片预览",
|
||||||
|
"text": "文本预览",
|
||||||
|
"archive": "压缩包",
|
||||||
|
"binary": "文件预览",
|
||||||
|
}
|
||||||
|
return mapping.get(file_type, "文件预览")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _can_preview(extension: str) -> bool:
|
||||||
|
return extension in INLINE_PREVIEW_EXTENSIONS or extension in STRUCTURED_PREVIEW_EXTENSIONS
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_text_preview(file_path: Path) -> str:
|
||||||
|
encodings = ("utf-8", "utf-8-sig", "gbk")
|
||||||
|
for encoding in encodings:
|
||||||
|
try:
|
||||||
|
return file_path.read_text(encoding=encoding)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return "当前文本文件编码暂不支持在线解析。"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_docx_text(file_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
with ZipFile(file_path) as archive:
|
||||||
|
xml_content = archive.read("word/document.xml")
|
||||||
|
except (BadZipFile, KeyError):
|
||||||
|
return "当前 Word 文件解析失败。"
|
||||||
|
|
||||||
|
root = ElementTree.fromstring(xml_content)
|
||||||
|
texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text]
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_xlsx_rows(file_path: Path) -> tuple[list[list[str]], int]:
|
||||||
|
try:
|
||||||
|
with ZipFile(file_path) as archive:
|
||||||
|
shared_strings: list[str] = []
|
||||||
|
if "xl/sharedStrings.xml" in archive.namelist():
|
||||||
|
shared_root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml"))
|
||||||
|
shared_strings = [
|
||||||
|
"".join(node.itertext()).strip()
|
||||||
|
for node in shared_root.iter()
|
||||||
|
if node.tag.endswith("}si")
|
||||||
|
]
|
||||||
|
|
||||||
|
sheet_names = sorted(
|
||||||
|
name
|
||||||
|
for name in archive.namelist()
|
||||||
|
if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)
|
||||||
|
)
|
||||||
|
if not sheet_names:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
first_sheet = ElementTree.fromstring(archive.read(sheet_names[0]))
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
for row in first_sheet.iter():
|
||||||
|
if not row.tag.endswith("}row"):
|
||||||
|
continue
|
||||||
|
row_values: list[str] = []
|
||||||
|
for cell in row:
|
||||||
|
if not cell.tag.endswith("}c"):
|
||||||
|
continue
|
||||||
|
cell_type = cell.attrib.get("t")
|
||||||
|
value_node = next((item for item in cell if item.tag.endswith("}v")), None)
|
||||||
|
if value_node is None or value_node.text is None:
|
||||||
|
row_values.append("")
|
||||||
|
continue
|
||||||
|
raw_value = value_node.text.strip()
|
||||||
|
if cell_type == "s" and raw_value.isdigit():
|
||||||
|
index = int(raw_value)
|
||||||
|
row_values.append(shared_strings[index] if index < len(shared_strings) else raw_value)
|
||||||
|
else:
|
||||||
|
row_values.append(raw_value)
|
||||||
|
if row_values:
|
||||||
|
rows.append(row_values)
|
||||||
|
|
||||||
|
return rows, len(sheet_names)
|
||||||
|
except (BadZipFile, ElementTree.ParseError, KeyError, ValueError):
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_pptx_slides(file_path: Path) -> list[list[str]]:
|
||||||
|
try:
|
||||||
|
with ZipFile(file_path) as archive:
|
||||||
|
slide_names = sorted(
|
||||||
|
name
|
||||||
|
for name in archive.namelist()
|
||||||
|
if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
|
||||||
|
)
|
||||||
|
slides: list[list[str]] = []
|
||||||
|
for slide_name in slide_names:
|
||||||
|
root = ElementTree.fromstring(archive.read(slide_name))
|
||||||
|
texts = [node.text.strip() for node in root.iter() if node.tag.endswith("}t") and node.text]
|
||||||
|
slides.append(texts)
|
||||||
|
return slides
|
||||||
|
except (BadZipFile, ElementTree.ParseError, KeyError):
|
||||||
|
return []
|
||||||
File diff suppressed because it is too large
Load Diff
4
server/storage/knowledge/.index.json
Normal file
4
server/storage/knowledge/.index.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"documents": []
|
||||||
|
}
|
||||||
@@ -1,70 +1,70 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.schemas.auth import LoginRequest
|
from app.schemas.auth import LoginRequest
|
||||||
from app.schemas.settings import SettingsWrite
|
from app.schemas.settings import SettingsWrite
|
||||||
from app.services.auth import AuthService
|
from app.services.auth import AuthService
|
||||||
from app.services.employee import EmployeeService
|
from app.services.employee import EmployeeService
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
|
|
||||||
|
|
||||||
def build_session() -> Session:
|
def build_session() -> Session:
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite+pysqlite:///:memory:",
|
"sqlite+pysqlite:///:memory:",
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
)
|
)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
return session_factory()
|
return session_factory()
|
||||||
|
|
||||||
|
|
||||||
def test_employee_can_login_with_seed_default_password() -> None:
|
def test_employee_can_login_with_seed_default_password() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
employee = EmployeeService(db).list_employees()[0]
|
employee = EmployeeService(db).list_employees()[0]
|
||||||
result = AuthService(db).login(
|
result = AuthService(db).login(
|
||||||
LoginRequest(username=employee.email, password="123456")
|
LoginRequest(username=employee.email, password="123456")
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert result.user.username == employee.email
|
assert result.user.username == employee.email
|
||||||
assert result.user.name == employee.name
|
assert result.user.name == employee.name
|
||||||
assert result.user.roleCodes
|
assert result.user.roleCodes
|
||||||
assert result.user.isAdmin is False
|
assert result.user.isAdmin is False
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_login_with_database_password() -> None:
|
def test_admin_can_login_with_database_password() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
settings_service = SettingsService(db)
|
settings_service = SettingsService(db)
|
||||||
payload = settings_service.get_settings_snapshot().model_dump()
|
payload = settings_service.get_settings_snapshot().model_dump()
|
||||||
payload["adminForm"]["adminAccount"] = "superadmin"
|
payload["adminForm"]["adminAccount"] = "superadmin"
|
||||||
payload["adminForm"]["newPassword"] = "admin123"
|
payload["adminForm"]["newPassword"] = "admin123"
|
||||||
payload["adminForm"]["confirmPassword"] = "admin123"
|
payload["adminForm"]["confirmPassword"] = "admin123"
|
||||||
settings_service.save_settings_snapshot(SettingsWrite(**payload))
|
settings_service.save_settings_snapshot(SettingsWrite(**payload))
|
||||||
|
|
||||||
result = AuthService(db).login(
|
result = AuthService(db).login(
|
||||||
LoginRequest(username="superadmin", password="admin123")
|
LoginRequest(username="superadmin", password="admin123")
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert result.user.username == "superadmin"
|
assert result.user.username == "superadmin"
|
||||||
assert result.user.isAdmin is True
|
assert result.user.isAdmin is True
|
||||||
assert result.user.roleCodes == ["manager"]
|
assert result.user.roleCodes == ["manager"]
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_employee_cannot_login() -> None:
|
def test_disabled_employee_cannot_login() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = EmployeeService(db)
|
service = EmployeeService(db)
|
||||||
employee = service.list_employees()[0]
|
employee = service.list_employees()[0]
|
||||||
service.disable_employee(employee.id)
|
service.disable_employee(employee.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
AuthService(db).login(LoginRequest(username=employee.email, password="123456"))
|
AuthService(db).login(LoginRequest(username=employee.email, password="123456"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
assert "账号或密码错误" in str(exc)
|
assert "账号或密码错误" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("disabled employee login should be rejected")
|
raise AssertionError("disabled employee login should be rejected")
|
||||||
|
|||||||
@@ -1,132 +1,132 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from app.core import admin_secret
|
from app.core import admin_secret
|
||||||
from app.core import secret_box
|
from app.core import secret_box
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.system_model_setting import SystemModelSetting
|
from app.models.system_model_setting import SystemModelSetting
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.system_setting_secret import SystemSettingSecret
|
from app.models.system_setting_secret import SystemSettingSecret
|
||||||
from app.schemas.settings import SettingsWrite
|
from app.schemas.settings import SettingsWrite
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
|
|
||||||
|
|
||||||
def build_session(db_file: Path) -> Session:
|
def build_session(db_file: Path) -> Session:
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
f"sqlite+pysqlite:///{db_file.as_posix()}",
|
f"sqlite+pysqlite:///{db_file.as_posix()}",
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
)
|
)
|
||||||
SystemSetting.__table__.create(bind=engine)
|
SystemSetting.__table__.create(bind=engine)
|
||||||
SystemSettingSecret.__table__.create(bind=engine)
|
SystemSettingSecret.__table__.create(bind=engine)
|
||||||
SystemModelSetting.__table__.create(bind=engine)
|
SystemModelSetting.__table__.create(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
return session_factory()
|
return session_factory()
|
||||||
|
|
||||||
|
|
||||||
def build_temp_secret_dir() -> Path:
|
def build_temp_secret_dir() -> Path:
|
||||||
return Path(tempfile.mkdtemp(prefix="xf-settings-test-"))
|
return Path(tempfile.mkdtemp(prefix="xf-settings-test-"))
|
||||||
|
|
||||||
|
|
||||||
def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> None:
|
def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> None:
|
||||||
temp_dir = build_temp_secret_dir()
|
temp_dir = build_temp_secret_dir()
|
||||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||||
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
with build_session(temp_dir / "settings.db") as db:
|
with build_session(temp_dir / "settings.db") as db:
|
||||||
service = SettingsService(db)
|
service = SettingsService(db)
|
||||||
initial_snapshot = service.get_settings_snapshot()
|
initial_snapshot = service.get_settings_snapshot()
|
||||||
payload = initial_snapshot.model_dump()
|
payload = initial_snapshot.model_dump()
|
||||||
|
|
||||||
payload["companyForm"]["companyName"] = "YGSOFT"
|
payload["companyForm"]["companyName"] = "YGSOFT"
|
||||||
payload["companyForm"]["displayName"] = "云广软件"
|
payload["companyForm"]["displayName"] = "云广软件"
|
||||||
payload["adminForm"]["adminAccount"] = "admin-root"
|
payload["adminForm"]["adminAccount"] = "admin-root"
|
||||||
payload["adminForm"]["adminEmail"] = "admin@example.com"
|
payload["adminForm"]["adminEmail"] = "admin@example.com"
|
||||||
payload["adminForm"]["newPassword"] = "54321"
|
payload["adminForm"]["newPassword"] = "54321"
|
||||||
payload["adminForm"]["confirmPassword"] = "54321"
|
payload["adminForm"]["confirmPassword"] = "54321"
|
||||||
payload["llmForm"]["mainModel"] = "glm-4.5"
|
payload["llmForm"]["mainModel"] = "glm-4.5"
|
||||||
payload["llmForm"]["mainApiKey"] = "main-secret"
|
payload["llmForm"]["mainApiKey"] = "main-secret"
|
||||||
payload["mailForm"]["password"] = "smtp-secret"
|
payload["mailForm"]["password"] = "smtp-secret"
|
||||||
|
|
||||||
saved_snapshot = service.save_settings_snapshot(SettingsWrite(**payload))
|
saved_snapshot = service.save_settings_snapshot(SettingsWrite(**payload))
|
||||||
|
|
||||||
assert saved_snapshot.companyForm.companyName == "YGSOFT"
|
assert saved_snapshot.companyForm.companyName == "YGSOFT"
|
||||||
assert saved_snapshot.companyForm.displayName == "云广软件"
|
assert saved_snapshot.companyForm.displayName == "云广软件"
|
||||||
assert saved_snapshot.llmForm.mainModel == "glm-4.5"
|
assert saved_snapshot.llmForm.mainModel == "glm-4.5"
|
||||||
assert saved_snapshot.llmForm.mainApiKey == ""
|
assert saved_snapshot.llmForm.mainApiKey == ""
|
||||||
assert saved_snapshot.llmForm.mainApiKeyConfigured is True
|
assert saved_snapshot.llmForm.mainApiKeyConfigured is True
|
||||||
assert saved_snapshot.mailForm.password == ""
|
assert saved_snapshot.mailForm.password == ""
|
||||||
assert saved_snapshot.mailForm.passwordConfigured is True
|
assert saved_snapshot.mailForm.passwordConfigured is True
|
||||||
assert saved_snapshot.adminForm.newPassword == ""
|
assert saved_snapshot.adminForm.newPassword == ""
|
||||||
assert saved_snapshot.adminForm.adminPasswordConfigured is True
|
assert saved_snapshot.adminForm.adminPasswordConfigured is True
|
||||||
|
|
||||||
model_row = db.get(SystemModelSetting, "main")
|
model_row = db.get(SystemModelSetting, "main")
|
||||||
assert model_row is not None
|
assert model_row is not None
|
||||||
assert model_row.model_name == "glm-4.5"
|
assert model_row.model_name == "glm-4.5"
|
||||||
assert model_row.api_key_encrypted
|
assert model_row.api_key_encrypted
|
||||||
|
|
||||||
assert service.load_saved_model_api_key("main") == "main-secret"
|
assert service.load_saved_model_api_key("main") == "main-secret"
|
||||||
assert service.verify_admin_login("admin-root", "54321") is not None
|
assert service.verify_admin_login("admin-root", "54321") is not None
|
||||||
assert service.verify_admin_login("admin@example.com", "54321") is not None
|
assert service.verify_admin_login("admin@example.com", "54321") is not None
|
||||||
|
|
||||||
|
|
||||||
def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
|
def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
|
||||||
temp_dir = build_temp_secret_dir()
|
temp_dir = build_temp_secret_dir()
|
||||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||||
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
with build_session(temp_dir / "settings.db") as db:
|
with build_session(temp_dir / "settings.db") as db:
|
||||||
service = SettingsService(db)
|
service = SettingsService(db)
|
||||||
first_payload = service.get_settings_snapshot().model_dump()
|
first_payload = service.get_settings_snapshot().model_dump()
|
||||||
first_payload["llmForm"]["mainApiKey"] = "persisted-key"
|
first_payload["llmForm"]["mainApiKey"] = "persisted-key"
|
||||||
service.save_settings_snapshot(SettingsWrite(**first_payload))
|
service.save_settings_snapshot(SettingsWrite(**first_payload))
|
||||||
|
|
||||||
second_payload = service.get_settings_snapshot().model_dump()
|
second_payload = service.get_settings_snapshot().model_dump()
|
||||||
second_payload["llmForm"]["mainApiKey"] = ""
|
second_payload["llmForm"]["mainApiKey"] = ""
|
||||||
service.save_settings_snapshot(SettingsWrite(**second_payload))
|
service.save_settings_snapshot(SettingsWrite(**second_payload))
|
||||||
|
|
||||||
assert service.load_saved_model_api_key("main") == "persisted-key"
|
assert service.load_saved_model_api_key("main") == "persisted-key"
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None:
|
def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None:
|
||||||
temp_dir = build_temp_secret_dir()
|
temp_dir = build_temp_secret_dir()
|
||||||
admin_file = temp_dir / "admin.json"
|
admin_file = temp_dir / "admin.json"
|
||||||
monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", admin_file)
|
monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", admin_file)
|
||||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||||
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
password = "setup-secret"
|
password = "setup-secret"
|
||||||
salt = secrets.token_bytes(16)
|
salt = secrets.token_bytes(16)
|
||||||
derived_key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64)
|
derived_key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64)
|
||||||
admin_file.write_text(
|
admin_file.write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"algorithm": "scrypt",
|
"algorithm": "scrypt",
|
||||||
"username": "setup-admin",
|
"username": "setup-admin",
|
||||||
"salt": salt.hex(),
|
"salt": salt.hex(),
|
||||||
"derived_key": derived_key.hex(),
|
"derived_key": derived_key.hex(),
|
||||||
"key_length": 64,
|
"key_length": 64,
|
||||||
"N": 16384,
|
"N": 16384,
|
||||||
"r": 8,
|
"r": 8,
|
||||||
"p": 1,
|
"p": 1,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
with build_session(temp_dir / "settings.db") as db:
|
with build_session(temp_dir / "settings.db") as db:
|
||||||
service = SettingsService(db)
|
service = SettingsService(db)
|
||||||
snapshot = service.get_settings_snapshot()
|
snapshot = service.get_settings_snapshot()
|
||||||
secrets_row = db.get(SystemSettingSecret, "default")
|
secrets_row = db.get(SystemSettingSecret, "default")
|
||||||
|
|
||||||
assert snapshot.adminForm.adminPasswordConfigured is True
|
assert snapshot.adminForm.adminPasswordConfigured is True
|
||||||
assert secrets_row is not None
|
assert secrets_row is not None
|
||||||
assert secrets_row.admin_password_hash.startswith("scrypt$")
|
assert secrets_row.admin_password_hash.startswith("scrypt$")
|
||||||
assert service.verify_admin_login("setup-admin", password) is not None
|
assert service.verify_admin_login("setup-admin", password) is not None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
4094
web/package-lock.json
generated
4094
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "x-financial-reimbursement-admin",
|
"name": "x-financial-reimbursement-admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite --host 0.0.0.0",
|
"start": "vite --host 0.0.0.0",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primevue/themes": "^4.5.4",
|
"@primevue/themes": "^4.5.4",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vueuse/motion": "^3.0.3",
|
"@vueuse/motion": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.5.5",
|
"primevue": "^4.5.5",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -833,5 +833,5 @@ const policyItems = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,403 +1,403 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
function readCurrentWebEndpoint(initialState) {
|
function readCurrentWebEndpoint(initialState) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return {
|
return {
|
||||||
host: initialState?.web?.host || '0.0.0.0',
|
host: initialState?.web?.host || '0.0.0.0',
|
||||||
port: Number(initialState?.web?.port || 5173)
|
port: Number(initialState?.web?.port || 5173)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackPort = Number(initialState?.web?.port || 5173)
|
const fallbackPort = Number(initialState?.web?.port || 5173)
|
||||||
const port = Number(window.location.port || fallbackPort)
|
const port = Number(window.location.port || fallbackPort)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host: window.location.hostname || initialState?.web?.host || '0.0.0.0',
|
host: window.location.hostname || initialState?.web?.host || '0.0.0.0',
|
||||||
port: Number.isInteger(port) && port > 0 ? port : fallbackPort
|
port: Number.isInteger(port) && port > 0 ? port : fallbackPort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldExposeServerHost() {
|
function shouldExposeServerHost() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = String(window.location.hostname || '').toLowerCase()
|
const host = String(window.location.hostname || '').toLowerCase()
|
||||||
return Boolean(host && host !== '127.0.0.1' && host !== 'localhost' && host !== '::1')
|
return Boolean(host && host !== '127.0.0.1' && host !== 'localhost' && host !== '::1')
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInitialServerHost(initialState) {
|
function resolveInitialServerHost(initialState) {
|
||||||
const host = String(initialState?.server?.host || '0.0.0.0').trim()
|
const host = String(initialState?.server?.host || '0.0.0.0').trim()
|
||||||
const normalized = host.toLowerCase()
|
const normalized = host.toLowerCase()
|
||||||
|
|
||||||
if (shouldExposeServerHost() && (normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1')) {
|
if (shouldExposeServerHost() && (normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1')) {
|
||||||
return '0.0.0.0'
|
return '0.0.0.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
return host || '0.0.0.0'
|
return host || '0.0.0.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
function createForm(initialState) {
|
function createForm(initialState) {
|
||||||
const currentWeb = readCurrentWebEndpoint(initialState)
|
const currentWeb = readCurrentWebEndpoint(initialState)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company_name: initialState?.company?.name || '',
|
company_name: initialState?.company?.name || '',
|
||||||
company_code: initialState?.company?.code || '',
|
company_code: initialState?.company?.code || '',
|
||||||
admin_email: initialState?.company?.admin_email || '',
|
admin_email: initialState?.company?.admin_email || '',
|
||||||
admin_username: '',
|
admin_username: '',
|
||||||
admin_password: '',
|
admin_password: '',
|
||||||
admin_password_confirm: '',
|
admin_password_confirm: '',
|
||||||
web_host: currentWeb.host,
|
web_host: currentWeb.host,
|
||||||
web_port: currentWeb.port,
|
web_port: currentWeb.port,
|
||||||
server_host: resolveInitialServerHost(initialState),
|
server_host: resolveInitialServerHost(initialState),
|
||||||
server_port: initialState?.server?.port || 8000,
|
server_port: initialState?.server?.port || 8000,
|
||||||
postgres_host: initialState?.database?.host || '127.0.0.1',
|
postgres_host: initialState?.database?.host || '127.0.0.1',
|
||||||
postgres_port: initialState?.database?.port || 5432,
|
postgres_port: initialState?.database?.port || 5432,
|
||||||
postgres_db: initialState?.database?.name || 'x_financial',
|
postgres_db: initialState?.database?.name || 'x_financial',
|
||||||
postgres_user: initialState?.database?.username || 'postgres',
|
postgres_user: initialState?.database?.username || 'postgres',
|
||||||
postgres_password: '',
|
postgres_password: '',
|
||||||
redis_url: initialState?.redis?.url || ''
|
redis_url: initialState?.redis?.url || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(form) {
|
function buildPayload(form) {
|
||||||
const currentWeb = readCurrentWebEndpoint({
|
const currentWeb = readCurrentWebEndpoint({
|
||||||
web: {
|
web: {
|
||||||
host: form.web_host,
|
host: form.web_host,
|
||||||
port: form.web_port
|
port: form.web_port
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company_name: form.company_name.trim(),
|
company_name: form.company_name.trim(),
|
||||||
company_code: form.company_code.trim(),
|
company_code: form.company_code.trim(),
|
||||||
admin_email: form.admin_email.trim(),
|
admin_email: form.admin_email.trim(),
|
||||||
admin_username: form.admin_username.trim(),
|
admin_username: form.admin_username.trim(),
|
||||||
admin_password: String(form.admin_password || ''),
|
admin_password: String(form.admin_password || ''),
|
||||||
admin_password_confirm: String(form.admin_password_confirm || ''),
|
admin_password_confirm: String(form.admin_password_confirm || ''),
|
||||||
web_host: currentWeb.host,
|
web_host: currentWeb.host,
|
||||||
web_port: currentWeb.port,
|
web_port: currentWeb.port,
|
||||||
server_host: shouldExposeServerHost() && ['127.0.0.1', 'localhost', '::1'].includes(form.server_host.trim().toLowerCase())
|
server_host: shouldExposeServerHost() && ['127.0.0.1', 'localhost', '::1'].includes(form.server_host.trim().toLowerCase())
|
||||||
? '0.0.0.0'
|
? '0.0.0.0'
|
||||||
: form.server_host.trim(),
|
: form.server_host.trim(),
|
||||||
server_port: Number(form.server_port),
|
server_port: Number(form.server_port),
|
||||||
postgres_host: form.postgres_host.trim(),
|
postgres_host: form.postgres_host.trim(),
|
||||||
postgres_port: Number(form.postgres_port),
|
postgres_port: Number(form.postgres_port),
|
||||||
postgres_db: form.postgres_db.trim(),
|
postgres_db: form.postgres_db.trim(),
|
||||||
postgres_user: form.postgres_user.trim(),
|
postgres_user: form.postgres_user.trim(),
|
||||||
postgres_password: String(form.postgres_password || ''),
|
postgres_password: String(form.postgres_password || ''),
|
||||||
redis_url: form.redis_url.trim()
|
redis_url: form.redis_url.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRuntimeFingerprint(form) {
|
function buildRuntimeFingerprint(form) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
server_host: form.server_host.trim(),
|
server_host: form.server_host.trim(),
|
||||||
server_port: String(form.server_port).trim()
|
server_port: String(form.server_port).trim()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDatabaseFingerprint(form) {
|
function buildDatabaseFingerprint(form) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
postgres_host: form.postgres_host.trim(),
|
postgres_host: form.postgres_host.trim(),
|
||||||
postgres_port: String(form.postgres_port).trim(),
|
postgres_port: String(form.postgres_port).trim(),
|
||||||
postgres_db: form.postgres_db.trim(),
|
postgres_db: form.postgres_db.trim(),
|
||||||
postgres_user: form.postgres_user.trim(),
|
postgres_user: form.postgres_user.trim(),
|
||||||
postgres_password: String(form.postgres_password || ''),
|
postgres_password: String(form.postgres_password || ''),
|
||||||
redis_url: form.redis_url.trim()
|
redis_url: form.redis_url.trim()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEmail(value) {
|
function isEmail(value) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(String(value || '').trim())
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(String(value || '').trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetupView(props, emit) {
|
export function useSetupView(props, emit) {
|
||||||
const form = reactive(createForm(props.initialState))
|
const form = reactive(createForm(props.initialState))
|
||||||
const activeSection = ref('company')
|
const activeSection = ref('company')
|
||||||
let syncingFromProps = false
|
let syncingFromProps = false
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.initialState,
|
() => props.initialState,
|
||||||
(state) => {
|
(state) => {
|
||||||
syncingFromProps = true
|
syncingFromProps = true
|
||||||
Object.assign(form, createForm(state))
|
Object.assign(form, createForm(state))
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
syncingFromProps = false
|
syncingFromProps = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => buildRuntimeFingerprint(form),
|
() => buildRuntimeFingerprint(form),
|
||||||
(_value, oldValue) => {
|
(_value, oldValue) => {
|
||||||
if (oldValue !== undefined && !syncingFromProps) {
|
if (oldValue !== undefined && !syncingFromProps) {
|
||||||
emit('runtime-dirty')
|
emit('runtime-dirty')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => buildDatabaseFingerprint(form),
|
() => buildDatabaseFingerprint(form),
|
||||||
(_value, oldValue) => {
|
(_value, oldValue) => {
|
||||||
if (oldValue !== undefined && !syncingFromProps) {
|
if (oldValue !== undefined && !syncingFromProps) {
|
||||||
emit('database-dirty')
|
emit('database-dirty')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const companyReady = computed(() => form.company_name.trim().length >= 2)
|
const companyReady = computed(() => form.company_name.trim().length >= 2)
|
||||||
const adminReady = computed(() => {
|
const adminReady = computed(() => {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
isEmail(form.admin_email) &&
|
isEmail(form.admin_email) &&
|
||||||
form.admin_username.trim().length >= 4 &&
|
form.admin_username.trim().length >= 4 &&
|
||||||
String(form.admin_password || '').length >= 5 &&
|
String(form.admin_password || '').length >= 5 &&
|
||||||
form.admin_password === form.admin_password_confirm
|
form.admin_password === form.admin_password_confirm
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const runtimeInputsReady = computed(() => {
|
const runtimeInputsReady = computed(() => {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
form.server_host.trim() &&
|
form.server_host.trim() &&
|
||||||
String(form.server_port).trim()
|
String(form.server_port).trim()
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const databaseInputsReady = computed(() => {
|
const databaseInputsReady = computed(() => {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
form.postgres_host.trim() &&
|
form.postgres_host.trim() &&
|
||||||
String(form.postgres_port).trim() &&
|
String(form.postgres_port).trim() &&
|
||||||
form.postgres_db.trim() &&
|
form.postgres_db.trim() &&
|
||||||
form.postgres_user.trim() &&
|
form.postgres_user.trim() &&
|
||||||
String(form.postgres_password || '').length > 0
|
String(form.postgres_password || '').length > 0
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const runtimeReady = computed(() => runtimeInputsReady.value && props.runtimeTestPassed)
|
const runtimeReady = computed(() => runtimeInputsReady.value && props.runtimeTestPassed)
|
||||||
const databaseReady = computed(() => databaseInputsReady.value && props.databaseTestPassed)
|
const databaseReady = computed(() => databaseInputsReady.value && props.databaseTestPassed)
|
||||||
const finalReady = computed(() => companyReady.value && adminReady.value && runtimeReady.value && databaseReady.value)
|
const finalReady = computed(() => companyReady.value && adminReady.value && runtimeReady.value && databaseReady.value)
|
||||||
|
|
||||||
const sections = computed(() => [
|
const sections = computed(() => [
|
||||||
{
|
{
|
||||||
id: 'company',
|
id: 'company',
|
||||||
index: '01',
|
index: '01',
|
||||||
title: '企业信息',
|
title: '企业信息',
|
||||||
desc: '填写企业名称与识别编码。',
|
desc: '填写企业名称与识别编码。',
|
||||||
complete: companyReady.value
|
complete: companyReady.value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'admin',
|
id: 'admin',
|
||||||
index: '02',
|
index: '02',
|
||||||
title: '管理员安全',
|
title: '管理员安全',
|
||||||
desc: '配置管理员邮箱、账号与密码。',
|
desc: '配置管理员邮箱、账号与密码。',
|
||||||
complete: adminReady.value
|
complete: adminReady.value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'runtime',
|
id: 'runtime',
|
||||||
index: '03',
|
index: '03',
|
||||||
title: '运行端口',
|
title: '运行端口',
|
||||||
desc: 'Web 端口跟随当前启动实例,只检测后端端口占用。',
|
desc: 'Web 端口跟随当前启动实例,只检测后端端口占用。',
|
||||||
complete: runtimeReady.value
|
complete: runtimeReady.value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'database',
|
id: 'database',
|
||||||
index: '04',
|
index: '04',
|
||||||
title: '数据库',
|
title: '数据库',
|
||||||
desc: '检测 PostgreSQL 连接,Redis 暂时可选。',
|
desc: '检测 PostgreSQL 连接,Redis 暂时可选。',
|
||||||
complete: databaseReady.value
|
complete: databaseReady.value
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeStep = computed(() => sections.value.find((section) => section.id === activeSection.value) || sections.value[0])
|
const activeStep = computed(() => sections.value.find((section) => section.id === activeSection.value) || sections.value[0])
|
||||||
const completionCount = computed(() => sections.value.filter((section) => section.complete).length)
|
const completionCount = computed(() => sections.value.filter((section) => section.complete).length)
|
||||||
|
|
||||||
const runtimeEndpoints = computed(() => [
|
const runtimeEndpoints = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Web 当前访问',
|
label: 'Web 当前访问',
|
||||||
value: `${form.web_host}:${form.web_port}`
|
value: `${form.web_host}:${form.web_port}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Server 待启动',
|
label: 'Server 待启动',
|
||||||
value: `${form.server_host}:${form.server_port}`
|
value: `${form.server_host}:${form.server_port}`
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const currentTestMessage = computed(() => {
|
const currentTestMessage = computed(() => {
|
||||||
if (activeSection.value === 'runtime') {
|
if (activeSection.value === 'runtime') {
|
||||||
return props.runtimeTestMessage
|
return props.runtimeTestMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'database') {
|
if (activeSection.value === 'database') {
|
||||||
return props.databaseTestMessage
|
return props.databaseTestMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentTestPassed = computed(() => {
|
const currentTestPassed = computed(() => {
|
||||||
if (activeSection.value === 'runtime') {
|
if (activeSection.value === 'runtime') {
|
||||||
return props.runtimeTestPassed
|
return props.runtimeTestPassed
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'database') {
|
if (activeSection.value === 'database') {
|
||||||
return props.databaseTestPassed
|
return props.databaseTestPassed
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
const showTestAction = computed(() => ['runtime', 'database'].includes(activeSection.value))
|
const showTestAction = computed(() => ['runtime', 'database'].includes(activeSection.value))
|
||||||
const testButtonLabel = computed(() => {
|
const testButtonLabel = computed(() => {
|
||||||
if (activeSection.value === 'runtime') {
|
if (activeSection.value === 'runtime') {
|
||||||
return props.runtimeTesting ? '检测中...' : '检测端口占用'
|
return props.runtimeTesting ? '检测中...' : '检测端口占用'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'database') {
|
if (activeSection.value === 'database') {
|
||||||
return props.databaseTesting ? '检测中...' : '检测数据库连接'
|
return props.databaseTesting ? '检测中...' : '检测数据库连接'
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
const testButtonIcon = computed(() => {
|
const testButtonIcon = computed(() => {
|
||||||
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
|
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
|
||||||
return 'pi pi-spin pi-spinner'
|
return 'pi pi-spin pi-spinner'
|
||||||
}
|
}
|
||||||
|
|
||||||
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
|
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
|
||||||
})
|
})
|
||||||
|
|
||||||
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||||
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||||
const canTest = computed(() => {
|
const canTest = computed(() => {
|
||||||
if (activeSection.value === 'runtime') {
|
if (activeSection.value === 'runtime') {
|
||||||
return canRuntimeTest.value
|
return canRuntimeTest.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'database') {
|
if (activeSection.value === 'database') {
|
||||||
return canDatabaseTest.value
|
return canDatabaseTest.value
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitHint = computed(() => {
|
const submitHint = computed(() => {
|
||||||
if (activeSection.value === 'admin') {
|
if (activeSection.value === 'admin') {
|
||||||
if (!form.admin_email.trim() && !form.admin_username.trim() && !String(form.admin_password || '').length) {
|
if (!form.admin_email.trim() && !form.admin_username.trim() && !String(form.admin_password || '').length) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.admin_email.trim()) {
|
if (!form.admin_email.trim()) {
|
||||||
return '请填写管理员邮箱。'
|
return '请填写管理员邮箱。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEmail(form.admin_email)) {
|
if (!isEmail(form.admin_email)) {
|
||||||
return '管理员邮箱格式不正确。'
|
return '管理员邮箱格式不正确。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.admin_username.trim() && form.admin_username.trim().length < 4) {
|
if (form.admin_username.trim() && form.admin_username.trim().length < 4) {
|
||||||
return '管理员账号至少 4 位。'
|
return '管理员账号至少 4 位。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (String(form.admin_password || '').length > 0 && String(form.admin_password || '').length < 5) {
|
if (String(form.admin_password || '').length > 0 && String(form.admin_password || '').length < 5) {
|
||||||
return '管理员密码当前至少 5 位。'
|
return '管理员密码当前至少 5 位。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
String(form.admin_password_confirm || '').length > 0 &&
|
String(form.admin_password_confirm || '').length > 0 &&
|
||||||
form.admin_password !== form.admin_password_confirm
|
form.admin_password !== form.admin_password_confirm
|
||||||
) {
|
) {
|
||||||
return '两次输入的管理员密码不一致。'
|
return '两次输入的管理员密码不一致。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'runtime') {
|
if (activeSection.value === 'runtime') {
|
||||||
if (!runtimeInputsReady.value) {
|
if (!runtimeInputsReady.value) {
|
||||||
return '请先填写 Server 的主机和端口。'
|
return '请先填写 Server 的主机和端口。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.runtimeTestPassed) {
|
if (!props.runtimeTestPassed) {
|
||||||
return '请先完成端口占用检测。'
|
return '请先完成端口占用检测。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'database') {
|
if (activeSection.value === 'database') {
|
||||||
if (!databaseInputsReady.value) {
|
if (!databaseInputsReady.value) {
|
||||||
return '请先填写 PostgreSQL 连接信息。'
|
return '请先填写 PostgreSQL 连接信息。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.databaseTestPassed) {
|
if (!props.databaseTestPassed) {
|
||||||
return '请先完成数据库连接检测。'
|
return '请先完成数据库连接检测。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'company') {
|
if (activeSection.value === 'company') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!companyReady.value) {
|
if (!companyReady.value) {
|
||||||
return '请先完成企业信息。'
|
return '请先完成企业信息。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminReady.value) {
|
if (!adminReady.value) {
|
||||||
return '请先完成管理员安全配置。'
|
return '请先完成管理员安全配置。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runtimeReady.value) {
|
if (!runtimeReady.value) {
|
||||||
return '请先完成运行端口检测。'
|
return '请先完成运行端口检测。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!databaseReady.value) {
|
if (!databaseReady.value) {
|
||||||
return '请先完成数据库连接检测。'
|
return '请先完成数据库连接检测。'
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function goToSection(id) {
|
function goToSection(id) {
|
||||||
activeSection.value = id
|
activeSection.value = id
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitForm() {
|
function submitForm() {
|
||||||
if (!finalReady.value || props.submitting) {
|
if (!finalReady.value || props.submitting) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('submit', buildPayload(form))
|
emit('submit', buildPayload(form))
|
||||||
}
|
}
|
||||||
|
|
||||||
function testSetup() {
|
function testSetup() {
|
||||||
if (!canTest.value) {
|
if (!canTest.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildPayload(form)
|
const payload = buildPayload(form)
|
||||||
|
|
||||||
if (activeSection.value === 'runtime') {
|
if (activeSection.value === 'runtime') {
|
||||||
emit('runtime-test', payload)
|
emit('runtime-test', payload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value === 'database') {
|
if (activeSection.value === 'database') {
|
||||||
emit('database-test', payload)
|
emit('database-test', payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSection,
|
activeSection,
|
||||||
activeStep,
|
activeStep,
|
||||||
canSubmit: finalReady,
|
canSubmit: finalReady,
|
||||||
canTest,
|
canTest,
|
||||||
completionCount,
|
completionCount,
|
||||||
currentTestMessage,
|
currentTestMessage,
|
||||||
currentTestPassed,
|
currentTestPassed,
|
||||||
form,
|
form,
|
||||||
goToSection,
|
goToSection,
|
||||||
runtimeEndpoints,
|
runtimeEndpoints,
|
||||||
sections,
|
sections,
|
||||||
showTestAction,
|
showTestAction,
|
||||||
submitForm,
|
submitForm,
|
||||||
submitHint,
|
submitHint,
|
||||||
testButtonIcon,
|
testButtonIcon,
|
||||||
testButtonLabel,
|
testButtonLabel,
|
||||||
testSetup
|
testSetup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,4 +206,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
web/src/services/bootstrap.js
vendored
176
web/src/services/bootstrap.js
vendored
@@ -1,88 +1,88 @@
|
|||||||
const SETUP_API_BASE = '/__setup'
|
const SETUP_API_BASE = '/__setup'
|
||||||
|
|
||||||
function formatValidationErrors(detail) {
|
function formatValidationErrors(detail) {
|
||||||
if (!Array.isArray(detail)) {
|
if (!Array.isArray(detail)) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return detail
|
return detail
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const field = Array.isArray(item.loc) ? item.loc[item.loc.length - 1] : 'field'
|
const field = Array.isArray(item.loc) ? item.loc[item.loc.length - 1] : 'field'
|
||||||
return `${field}: ${item.msg}`
|
return `${field}: ${item.msg}`
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
let response
|
let response
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${SETUP_API_BASE}${path}`, {
|
response = await fetch(`${SETUP_API_BASE}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options.headers || {})
|
...(options.headers || {})
|
||||||
},
|
},
|
||||||
...options
|
...options
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('无法连接初始化服务,请确认本地配置桥已启动。')
|
throw new Error('无法连接初始化服务,请确认本地配置桥已启动。')
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = null
|
let data = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
} catch {
|
} catch {
|
||||||
data = null
|
data = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const validationMessage = formatValidationErrors(data?.detail)
|
const validationMessage = formatValidationErrors(data?.detail)
|
||||||
const message = validationMessage || data?.detail || '初始化请求失败,请稍后重试。'
|
const message = validationMessage || data?.detail || '初始化请求失败,请稍后重试。'
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchBootstrapState() {
|
export function fetchBootstrapState() {
|
||||||
return request('/bootstrap')
|
return request('/bootstrap')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveBootstrapConfig(payload) {
|
export function saveBootstrapConfig(payload) {
|
||||||
return request('/bootstrap', {
|
return request('/bootstrap', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startBootstrapBackend() {
|
export function startBootstrapBackend() {
|
||||||
return request('/bootstrap/backend', {
|
return request('/bootstrap/backend', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchBootstrapBackendStatus() {
|
export function fetchBootstrapBackendStatus() {
|
||||||
return request('/bootstrap/backend')
|
return request('/bootstrap/backend')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function testBootstrapRuntime(payload) {
|
export function testBootstrapRuntime(payload) {
|
||||||
return request('/bootstrap/runtime', {
|
return request('/bootstrap/runtime', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function testBootstrapDatabase(payload) {
|
export function testBootstrapDatabase(payload) {
|
||||||
return request('/bootstrap/database', {
|
return request('/bootstrap/database', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loginBootstrapAdmin(payload) {
|
export function loginBootstrapAdmin(payload) {
|
||||||
return request('/auth/login', {
|
return request('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
33
web/src/services/knowledge.js
Normal file
33
web/src/services/knowledge.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { apiRequest } from './api.js'
|
||||||
|
|
||||||
|
export function fetchKnowledgeLibrary() {
|
||||||
|
return apiRequest('/knowledge/library')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchKnowledgeDocument(documentId) {
|
||||||
|
return apiRequest(`/knowledge/documents/${documentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadKnowledgeDocument({ folder, file }) {
|
||||||
|
return apiRequest(
|
||||||
|
`/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: file,
|
||||||
|
contentType: file.type || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteKnowledgeDocument(documentId) {
|
||||||
|
return apiRequest(`/knowledge/documents/${documentId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') {
|
||||||
|
return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
contentType: null
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,11 +10,10 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
|||||||
'settings'
|
'settings'
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat'])
|
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'],
|
||||||
policies: ['manager'],
|
|
||||||
audit: ['auditor'],
|
audit: ['auditor'],
|
||||||
employees: ['manager'],
|
employees: ['manager'],
|
||||||
settings: ['manager']
|
settings: ['manager']
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<SetupView
|
<SetupView
|
||||||
:initial-state="bootstrapState || {}"
|
:initial-state="bootstrapState || {}"
|
||||||
:submitting="setupSubmitting"
|
:submitting="setupSubmitting"
|
||||||
:runtime-testing="runtimeTesting"
|
:runtime-testing="runtimeTesting"
|
||||||
:database-testing="databaseTesting"
|
:database-testing="databaseTesting"
|
||||||
:runtime-test-passed="runtimeTestPassed"
|
:runtime-test-passed="runtimeTestPassed"
|
||||||
:database-test-passed="databaseTestPassed"
|
:database-test-passed="databaseTestPassed"
|
||||||
:runtime-test-message="runtimeTestMessage"
|
:runtime-test-message="runtimeTestMessage"
|
||||||
:database-test-message="databaseTestMessage"
|
:database-test-message="databaseTestMessage"
|
||||||
:error-message="setupError"
|
:error-message="setupError"
|
||||||
:startup-countdown-seconds="setupCountdownSeconds"
|
:startup-countdown-seconds="setupCountdownSeconds"
|
||||||
:startup-log="setupStartupLog"
|
:startup-log="setupStartupLog"
|
||||||
:startup-steps="setupStartupSteps"
|
:startup-steps="setupStartupSteps"
|
||||||
:startup-visible="setupStartupVisible"
|
:startup-visible="setupStartupVisible"
|
||||||
:progress-message="setupProgressMessage"
|
:progress-message="setupProgressMessage"
|
||||||
@submit="submitSetup"
|
@submit="submitSetup"
|
||||||
@runtime-test="handleRuntimeTest"
|
@runtime-test="handleRuntimeTest"
|
||||||
@database-test="handleDatabaseTest"
|
@database-test="handleDatabaseTest"
|
||||||
@runtime-dirty="handleRuntimeDirty"
|
@runtime-dirty="handleRuntimeDirty"
|
||||||
@database-dirty="handleDatabaseDirty"
|
@database-dirty="handleDatabaseDirty"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
import SetupView from './SetupView.vue'
|
import SetupView from './SetupView.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const {
|
const {
|
||||||
bootstrapState,
|
bootstrapState,
|
||||||
databaseTestMessage,
|
databaseTestMessage,
|
||||||
databaseTestPassed,
|
databaseTestPassed,
|
||||||
databaseTesting,
|
databaseTesting,
|
||||||
handleDatabaseDirty,
|
handleDatabaseDirty,
|
||||||
handleDatabaseTest,
|
handleDatabaseTest,
|
||||||
handleRuntimeDirty,
|
handleRuntimeDirty,
|
||||||
handleRuntimeTest,
|
handleRuntimeTest,
|
||||||
handleSetupSubmit,
|
handleSetupSubmit,
|
||||||
runtimeTestMessage,
|
runtimeTestMessage,
|
||||||
runtimeTestPassed,
|
runtimeTestPassed,
|
||||||
runtimeTesting,
|
runtimeTesting,
|
||||||
setupCountdownSeconds,
|
setupCountdownSeconds,
|
||||||
setupError,
|
setupError,
|
||||||
setupProgressMessage,
|
setupProgressMessage,
|
||||||
setupStartupLog,
|
setupStartupLog,
|
||||||
setupStartupSteps,
|
setupStartupSteps,
|
||||||
setupStartupVisible,
|
setupStartupVisible,
|
||||||
setupSubmitting
|
setupSubmitting
|
||||||
} = useSystemState()
|
} = useSystemState()
|
||||||
|
|
||||||
async function submitSetup(payload) {
|
async function submitSetup(payload) {
|
||||||
const completed = await handleSetupSubmit(payload)
|
const completed = await handleSetupSubmit(payload)
|
||||||
|
|
||||||
if (completed) {
|
if (completed) {
|
||||||
router.replace({ name: 'login' })
|
router.replace({ name: 'login' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,376 +1,376 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="setup-page">
|
<main class="setup-page">
|
||||||
<aside class="setup-context">
|
<aside class="setup-context">
|
||||||
<div class="setup-brand">
|
<div class="setup-brand">
|
||||||
<div class="setup-brand-mark" aria-hidden="true">
|
<div class="setup-brand-mark" aria-hidden="true">
|
||||||
<span class="setup-brand-ring"></span>
|
<span class="setup-brand-ring"></span>
|
||||||
<span class="setup-brand-core">XF</span>
|
<span class="setup-brand-core">XF</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="setup-kicker">INITIAL SETUP</p>
|
<p class="setup-kicker">INITIAL SETUP</p>
|
||||||
<h1>初始化配置</h1>
|
<h1>初始化配置</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setup-lead">
|
<p class="setup-lead">
|
||||||
先完成 4 个必要步骤,再进入主登录界面。扩展服务当前不参与初始化完成条件。
|
先完成 4 个必要步骤,再进入主登录界面。扩展服务当前不参与初始化完成条件。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<nav class="setup-nav" aria-label="初始化步骤">
|
<nav class="setup-nav" aria-label="初始化步骤">
|
||||||
<button
|
<button
|
||||||
v-for="section in sections"
|
v-for="section in sections"
|
||||||
:key="section.id"
|
:key="section.id"
|
||||||
class="setup-nav-item"
|
class="setup-nav-item"
|
||||||
:class="{ 'is-active': activeSection === section.id, 'is-complete': section.complete }"
|
:class="{ 'is-active': activeSection === section.id, 'is-complete': section.complete }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="goToSection(section.id)"
|
@click="goToSection(section.id)"
|
||||||
>
|
>
|
||||||
<span class="setup-nav-index">{{ section.index }}</span>
|
<span class="setup-nav-index">{{ section.index }}</span>
|
||||||
<span class="setup-nav-copy">
|
<span class="setup-nav-copy">
|
||||||
<strong>{{ section.title }}</strong>
|
<strong>{{ section.title }}</strong>
|
||||||
<small>{{ section.desc }}</small>
|
<small>{{ section.desc }}</small>
|
||||||
</span>
|
</span>
|
||||||
<i v-if="section.complete" class="pi pi-check setup-nav-check"></i>
|
<i v-if="section.complete" class="pi pi-check setup-nav-check"></i>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="setup-progress">
|
<div class="setup-progress">
|
||||||
<strong>{{ completionCount }} / {{ sections.length }} 已完成</strong>
|
<strong>{{ completionCount }} / {{ sections.length }} 已完成</strong>
|
||||||
<p>企业信息、管理员安全、运行端口、数据库连接都通过后,左下角会自动出现完成初始化按钮。</p>
|
<p>企业信息、管理员安全、运行端口、数据库连接都通过后,左下角会自动出现完成初始化按钮。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canSubmit" class="setup-complete">
|
<div v-if="canSubmit" class="setup-complete">
|
||||||
<p>所有必要步骤已通过检测,可以写入配置并进入登录界面。</p>
|
<p>所有必要步骤已通过检测,可以写入配置并进入登录界面。</p>
|
||||||
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
|
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
|
||||||
<i :class="['pi', submitting ? 'pi-spin pi-spinner' : 'pi-check']"></i>
|
<i :class="['pi', submitting ? 'pi-spin pi-spinner' : 'pi-check']"></i>
|
||||||
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
|
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<p v-if="progressMessage" class="setup-complete-progress">
|
<p v-if="progressMessage" class="setup-complete-progress">
|
||||||
<i class="pi pi-spin pi-spinner"></i>
|
<i class="pi pi-spin pi-spinner"></i>
|
||||||
<span>{{ progressMessage }}</span>
|
<span>{{ progressMessage }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="setup-panel">
|
<section class="setup-panel">
|
||||||
<header class="setup-panel-head">
|
<header class="setup-panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="setup-kicker setup-kicker-light">{{ activeStep.index }}</p>
|
<p class="setup-kicker setup-kicker-light">{{ activeStep.index }}</p>
|
||||||
<h2>{{ activeStep.title }}</h2>
|
<h2>{{ activeStep.title }}</h2>
|
||||||
<p class="setup-panel-desc">{{ activeStep.desc }}</p>
|
<p class="setup-panel-desc">{{ activeStep.desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="setup-chip" :class="{ 'is-success': activeStep.complete }">
|
<span class="setup-chip" :class="{ 'is-success': activeStep.complete }">
|
||||||
{{ activeStep.complete ? '已完成' : '待配置' }}
|
{{ activeStep.complete ? '已完成' : '待配置' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="setup-form">
|
<div class="setup-form">
|
||||||
<section v-if="activeSection === 'company'" class="setup-stage">
|
<section v-if="activeSection === 'company'" class="setup-stage">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>企业基础信息</h3>
|
<h3>企业基础信息</h3>
|
||||||
<p>这里仅保留企业名称与企业编码,不放管理员邮箱。</p>
|
<p>这里仅保留企业名称与企业编码,不放管理员邮箱。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-grid field-grid-2">
|
<div class="field-grid field-grid-2">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>企业名称</span>
|
<span>企业名称</span>
|
||||||
<input v-model.trim="form.company_name" type="text" placeholder="请输入企业名称" required />
|
<input v-model.trim="form.company_name" type="text" placeholder="请输入企业名称" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>企业编码</span>
|
<span>企业编码</span>
|
||||||
<input v-model.trim="form.company_code" type="text" placeholder="例如 FIN" />
|
<input v-model.trim="form.company_code" type="text" placeholder="例如 FIN" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeSection === 'admin'" class="setup-stage">
|
<section v-else-if="activeSection === 'admin'" class="setup-stage">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>管理员安全</h3>
|
<h3>管理员安全</h3>
|
||||||
<p>管理员邮箱、账号和密码在这里配置。密码不会写入 `.env`,只会保存哈希后的密文。</p>
|
<p>管理员邮箱、账号和密码在这里配置。密码不会写入 `.env`,只会保存哈希后的密文。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-grid field-grid-2">
|
<div class="field-grid field-grid-2">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>管理员邮箱</span>
|
<span>管理员邮箱</span>
|
||||||
<input v-model.trim="form.admin_email" type="email" placeholder="admin@company.com" />
|
<input v-model.trim="form.admin_email" type="email" placeholder="admin@company.com" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>管理员账号</span>
|
<span>管理员账号</span>
|
||||||
<input v-model.trim="form.admin_username" type="text" placeholder="例如 superadmin" required />
|
<input v-model.trim="form.admin_username" type="text" placeholder="例如 superadmin" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>管理员密码</span>
|
<span>管理员密码</span>
|
||||||
<input
|
<input
|
||||||
v-model="form.admin_password"
|
v-model="form.admin_password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入管理员密码"
|
placeholder="请输入管理员密码"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>确认密码</span>
|
<span>确认密码</span>
|
||||||
<input
|
<input
|
||||||
v-model="form.admin_password_confirm"
|
v-model="form.admin_password_confirm"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请再次输入管理员密码"
|
placeholder="请再次输入管理员密码"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="field-group-note">管理员密码当前暂定至少 5 位。</p>
|
<p class="field-group-note">管理员密码当前暂定至少 5 位。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeSection === 'runtime'" class="setup-stage">
|
<section v-else-if="activeSection === 'runtime'" class="setup-stage">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>运行端口配置</h3>
|
<h3>运行端口配置</h3>
|
||||||
<p>Web 地址由当前已启动的前端实例自动确定,这一步只需要配置并检测后端端口。</p>
|
<p>Web 地址由当前已启动的前端实例自动确定,这一步只需要配置并检测后端端口。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-grid field-grid-2">
|
<div class="field-grid field-grid-2">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Server Host</span>
|
<span>Server Host</span>
|
||||||
<input v-model.trim="form.server_host" type="text" placeholder="0.0.0.0" required />
|
<input v-model.trim="form.server_host" type="text" placeholder="0.0.0.0" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Server Port</span>
|
<span>Server Port</span>
|
||||||
<input v-model.number="form.server_port" type="number" min="1" max="65535" required />
|
<input v-model.number="form.server_port" type="number" min="1" max="65535" required />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-runtime">
|
<div class="setup-runtime">
|
||||||
<article v-for="item in runtimeEndpoints" :key="item.label">
|
<article v-for="item in runtimeEndpoints" :key="item.label">
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
<strong>{{ item.value }}</strong>
|
<strong>{{ item.value }}</strong>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="setup-stage">
|
<section v-else class="setup-stage">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>数据库连接</h3>
|
<h3>数据库连接</h3>
|
||||||
<p>这里检测 PostgreSQL 连接。Redis 作为扩展服务暂时可选,不影响完成初始化。</p>
|
<p>这里检测 PostgreSQL 连接。Redis 作为扩展服务暂时可选,不影响完成初始化。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-grid field-grid-2">
|
<div class="field-grid field-grid-2">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>PostgreSQL Host</span>
|
<span>PostgreSQL Host</span>
|
||||||
<input v-model.trim="form.postgres_host" type="text" placeholder="127.0.0.1" required />
|
<input v-model.trim="form.postgres_host" type="text" placeholder="127.0.0.1" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>PostgreSQL Port</span>
|
<span>PostgreSQL Port</span>
|
||||||
<input v-model.number="form.postgres_port" type="number" min="1" max="65535" required />
|
<input v-model.number="form.postgres_port" type="number" min="1" max="65535" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>数据库名称</span>
|
<span>数据库名称</span>
|
||||||
<input v-model.trim="form.postgres_db" type="text" placeholder="x_financial" required />
|
<input v-model.trim="form.postgres_db" type="text" placeholder="x_financial" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>数据库用户</span>
|
<span>数据库用户</span>
|
||||||
<input v-model.trim="form.postgres_user" type="text" placeholder="postgres" required />
|
<input v-model.trim="form.postgres_user" type="text" placeholder="postgres" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-span-2">
|
<label class="field field-span-2">
|
||||||
<span>数据库密码</span>
|
<span>数据库密码</span>
|
||||||
<input
|
<input
|
||||||
v-model="form.postgres_password"
|
v-model="form.postgres_password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入数据库密码"
|
placeholder="请输入数据库密码"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="optional-block">
|
<div class="optional-block">
|
||||||
<div class="optional-block-head">
|
<div class="optional-block-head">
|
||||||
<strong>扩展服务</strong>
|
<strong>扩展服务</strong>
|
||||||
<span>可选</span>
|
<span>可选</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Redis URL</span>
|
<span>Redis URL</span>
|
||||||
<input v-model.trim="form.redis_url" type="text" placeholder="redis://127.0.0.1:6379/0" />
|
<input v-model.trim="form.redis_url" type="text" placeholder="redis://127.0.0.1:6379/0" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p v-if="currentTestMessage" :class="['setup-status', currentTestPassed ? 'is-success' : 'is-danger']">
|
<p v-if="currentTestMessage" :class="['setup-status', currentTestPassed ? 'is-success' : 'is-danger']">
|
||||||
{{ currentTestMessage }}
|
{{ currentTestMessage }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="setup-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="setup-error">{{ errorMessage }}</p>
|
||||||
<p v-if="submitHint" class="setup-gate">{{ submitHint }}</p>
|
<p v-if="submitHint" class="setup-gate">{{ submitHint }}</p>
|
||||||
|
|
||||||
<footer class="setup-actions">
|
<footer class="setup-actions">
|
||||||
<div class="setup-actions-right">
|
<div class="setup-actions-right">
|
||||||
<button
|
<button
|
||||||
v-if="showTestAction"
|
v-if="showTestAction"
|
||||||
class="secondary-btn secondary-btn-strong"
|
class="secondary-btn secondary-btn-strong"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canTest"
|
:disabled="!canTest"
|
||||||
@click="testSetup"
|
@click="testSetup"
|
||||||
>
|
>
|
||||||
<i :class="testButtonIcon"></i>
|
<i :class="testButtonIcon"></i>
|
||||||
<span>{{ testButtonLabel }}</span>
|
<span>{{ testButtonLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div v-if="startupVisible" class="setup-modal-backdrop" role="alertdialog" aria-modal="true">
|
<div v-if="startupVisible" class="setup-modal-backdrop" role="alertdialog" aria-modal="true">
|
||||||
<section class="setup-startup-modal" aria-label="后端启动进度">
|
<section class="setup-startup-modal" aria-label="后端启动进度">
|
||||||
<header class="setup-startup-head">
|
<header class="setup-startup-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="setup-kicker setup-kicker-light">BACKEND STARTUP</p>
|
<p class="setup-kicker setup-kicker-light">BACKEND STARTUP</p>
|
||||||
<h2>正在完成系统启动</h2>
|
<h2>正在完成系统启动</h2>
|
||||||
<span>{{ progressMessage || '正在准备后端服务...' }}</span>
|
<span>{{ progressMessage || '正在准备后端服务...' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-startup-spinner" aria-hidden="true">
|
<div class="setup-startup-spinner" aria-hidden="true">
|
||||||
<i v-if="!startupCountdownSeconds" class="pi pi-spin pi-spinner"></i>
|
<i v-if="!startupCountdownSeconds" class="pi pi-spin pi-spinner"></i>
|
||||||
<strong v-else>{{ startupCountdownSeconds }}</strong>
|
<strong v-else>{{ startupCountdownSeconds }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="setup-startup-body">
|
<div class="setup-startup-body">
|
||||||
<ol class="setup-startup-steps">
|
<ol class="setup-startup-steps">
|
||||||
<li
|
<li
|
||||||
v-for="step in startupSteps"
|
v-for="step in startupSteps"
|
||||||
:key="step.id"
|
:key="step.id"
|
||||||
:class="['setup-startup-step', `is-${step.status || 'pending'}`]"
|
:class="['setup-startup-step', `is-${step.status || 'pending'}`]"
|
||||||
>
|
>
|
||||||
<i :class="startupStepIcon(step.status)"></i>
|
<i :class="startupStepIcon(step.status)"></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ step.label }}</strong>
|
<strong>{{ step.label }}</strong>
|
||||||
<span>{{ step.detail }}</span>
|
<span>{{ step.detail }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<section class="setup-startup-console" aria-label="后端启动日志">
|
<section class="setup-startup-console" aria-label="后端启动日志">
|
||||||
<div class="setup-startup-console-head">
|
<div class="setup-startup-console-head">
|
||||||
<strong>执行日志</strong>
|
<strong>执行日志</strong>
|
||||||
<span>server/logs/bootstrap-backend.log</span>
|
<span>server/logs/bootstrap-backend.log</span>
|
||||||
</div>
|
</div>
|
||||||
<pre class="setup-startup-log">{{ startupLog || '等待后端启动输出...' }}</pre>
|
<pre class="setup-startup-log">{{ startupLog || '等待后端启动输出...' }}</pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useSetupView } from '../composables/useSetupView.js'
|
import { useSetupView } from '../composables/useSetupView.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialState: {
|
initialState: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
},
|
},
|
||||||
submitting: {
|
submitting: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
runtimeTesting: {
|
runtimeTesting: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
databaseTesting: {
|
databaseTesting: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
runtimeTestPassed: {
|
runtimeTestPassed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
databaseTestPassed: {
|
databaseTestPassed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
runtimeTestMessage: {
|
runtimeTestMessage: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
databaseTestMessage: {
|
databaseTestMessage: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
errorMessage: {
|
errorMessage: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
progressMessage: {
|
progressMessage: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
startupCountdownSeconds: {
|
startupCountdownSeconds: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
startupLog: {
|
startupLog: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
startupSteps: {
|
startupSteps: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
startupVisible: {
|
startupVisible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'runtime-test', 'database-test', 'runtime-dirty', 'database-dirty'])
|
const emit = defineEmits(['submit', 'runtime-test', 'database-test', 'runtime-dirty', 'database-dirty'])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeSection,
|
activeSection,
|
||||||
activeStep,
|
activeStep,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
canTest,
|
canTest,
|
||||||
completionCount,
|
completionCount,
|
||||||
currentTestMessage,
|
currentTestMessage,
|
||||||
currentTestPassed,
|
currentTestPassed,
|
||||||
form,
|
form,
|
||||||
goToSection,
|
goToSection,
|
||||||
runtimeEndpoints,
|
runtimeEndpoints,
|
||||||
sections,
|
sections,
|
||||||
showTestAction,
|
showTestAction,
|
||||||
submitForm,
|
submitForm,
|
||||||
submitHint,
|
submitHint,
|
||||||
testButtonIcon,
|
testButtonIcon,
|
||||||
testButtonLabel,
|
testButtonLabel,
|
||||||
testSetup
|
testSetup
|
||||||
} = useSetupView(props, emit)
|
} = useSetupView(props, emit)
|
||||||
|
|
||||||
function startupStepIcon(status) {
|
function startupStepIcon(status) {
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return 'pi pi-check-circle'
|
return 'pi pi-check-circle'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return 'pi pi-times-circle'
|
return 'pi pi-times-circle'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'running') {
|
if (status === 'running') {
|
||||||
return 'pi pi-spin pi-spinner'
|
return 'pi pi-spin pi-spinner'
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'pi pi-circle'
|
return 'pi pi-circle'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/views/setup-view.css"></style>
|
<style scoped src="../assets/styles/views/setup-view.css"></style>
|
||||||
|
|||||||
@@ -281,4 +281,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,4 +246,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,4 +98,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,4 +39,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,4 +127,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -440,4 +440,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -448,4 +448,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user