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:
caoxiaozhu
2026-05-09 03:04:09 +00:00
parent c2315f68dc
commit 619281afc3
43 changed files with 9337 additions and 8300 deletions

View File

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

View 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 反馈。

View File

@@ -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="只有管理员可以上传、删除或修改知识库文件。",
)

View 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)

View File

@@ -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"])

View File

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

View File

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

View File

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

View File

@@ -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",
] ]

View File

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

View File

@@ -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",
] ]

View File

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

View File

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

View File

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

View 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

View 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

View File

@@ -0,0 +1,4 @@
{
"version": 1,
"documents": []
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -833,5 +833,5 @@ const policyItems = [
} }
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -206,4 +206,4 @@ export default {
} }
} }
} }

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -281,4 +281,4 @@ export default {
} }
} }
} }

View File

@@ -246,4 +246,4 @@ export default {
} }
} }
} }

View File

@@ -98,4 +98,4 @@ export default {
} }
} }
} }

View File

@@ -39,4 +39,4 @@ export default {
} }
} }
} }

View File

@@ -127,4 +127,4 @@ export default {
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -440,4 +440,4 @@ export default {
} }
} }
} }

View File

@@ -448,4 +448,4 @@ export default {
} }
} }
} }