W07 统一身份接入与工作台框架:统一身份接入、HTTP 路由骨架与工作台前端框架。
按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
This commit is contained in:
parent
367d01db21
commit
1cbda23880
1
src/muse/接入/__init__.py
Normal file
1
src/muse/接入/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""身份与协议接入;业务行为由所属模块维护。"""
|
||||
1
src/muse/接入/cli/__init__.py
Normal file
1
src/muse/接入/cli/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""本地具名命令接入。"""
|
||||
225
src/muse/接入/cli/入口.py
Normal file
225
src/muse/接入/cli/入口.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""Python 命令入口。
|
||||
|
||||
安装后 ``python -m muse`` 与 CLI(W07)使用一致的参数、退出码和业务结果。
|
||||
已实现子命令:自检(W02)、迁移(W03,显式维护命令,不在启动时自动执行)。
|
||||
未实现或未注入的能力明确失败,不返回伪成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from muse.共享.错误 import Muse错误
|
||||
from muse.启动 import 构建, 自检
|
||||
from muse.配置 import 读取配置
|
||||
|
||||
_退出码_成功 = 0
|
||||
_退出码_用法错误 = 2
|
||||
_退出码_业务失败 = 1
|
||||
|
||||
|
||||
def _解析参数(argv: list[str] | None) -> argparse.Namespace:
|
||||
解析器 = argparse.ArgumentParser(
|
||||
prog="python -m muse",
|
||||
description="Muse 应用命令入口",
|
||||
)
|
||||
子命令 = 解析器.add_subparsers(dest="命令", required=True)
|
||||
|
||||
自检解析 = 子命令.add_parser("自检", help="报告装配状态;不连接外部服务")
|
||||
自检解析.add_argument("配置", help="应用配置 TOML 路径")
|
||||
|
||||
迁移解析 = 子命令.add_parser("迁移", help="执行数据库迁移(显式维护命令)")
|
||||
迁移解析.add_argument("配置", help="应用配置 TOML 路径")
|
||||
迁移解析.add_argument("--目标版本", type=int, default=None, help="执行到该版本为止(默认全部)")
|
||||
服务解析 = 子命令.add_parser("服务", help="启动已配置的作者工作台")
|
||||
服务解析.add_argument("配置", help="应用配置 TOML 路径")
|
||||
任务解析 = 子命令.add_parser("任务", help="查看、暂停、取消或恢复持久任务")
|
||||
任务解析.add_argument("配置", help="作者配置 TOML 路径")
|
||||
任务解析.add_argument("动作", choices=["列表", "查看", "事件", "控制"])
|
||||
任务解析.add_argument("目标", nargs="?", help="任务 ID;控制时为具名请求 JSON 文件")
|
||||
任务解析.add_argument("--游标", type=int, default=0)
|
||||
结构解析 = 子命令.add_parser("结构", help="发布内置种子或查看已发布的固定结构")
|
||||
结构解析.add_argument("配置", help="应用配置 TOML 路径")
|
||||
结构解析.add_argument("动作", choices=["发布内置", "查看", "维护"])
|
||||
结构解析.add_argument("目标", nargs="?", help="结构身份")
|
||||
结构解析.add_argument("--版本", type=int, default=1)
|
||||
作品解析 = 子命令.add_parser("作品", help="管理作品档案、章节和人工正文版本")
|
||||
作品解析.add_argument("配置", help="作者配置 TOML 路径")
|
||||
作品解析.add_argument(
|
||||
"动作",
|
||||
choices=[
|
||||
"结构",
|
||||
"查看",
|
||||
"目录",
|
||||
"节点目录",
|
||||
"保存节点",
|
||||
"进度",
|
||||
"正文",
|
||||
"保存档案",
|
||||
"新增章节",
|
||||
"重排章节",
|
||||
"保存正文",
|
||||
"回退正文",
|
||||
],
|
||||
)
|
||||
作品解析.add_argument("目标", help="查询身份或写入请求 JSON 文件")
|
||||
作品解析.add_argument("--结构", default="work_core")
|
||||
作品解析.add_argument("--版本", type=int, default=1)
|
||||
作品解析.add_argument("--分支", default="main")
|
||||
审阅解析 = 子命令.add_parser("审阅", help="比较候选、修改候选并提交作者决定")
|
||||
审阅解析.add_argument("配置", help="作者配置TOML路径")
|
||||
审阅解析.add_argument("动作", choices=["列表", "查看", "建立候选", "修改候选", "打开", "决定"])
|
||||
审阅解析.add_argument("目标", help="查询身份或操作请求JSON文件")
|
||||
审阅解析.add_argument("--分支", default="main")
|
||||
探索解析 = 子命令.add_parser("探索", help="保存私人想法与检查最终整理稿")
|
||||
探索解析.add_argument("配置", help="作者配置TOML路径")
|
||||
探索解析.add_argument("动作", choices=["列表", "查看", "保存", "检查", "选定", "查看选定"])
|
||||
探索解析.add_argument("目标", nargs="?", help="稿件身份或保存请求JSON文件")
|
||||
探索解析.add_argument("--预期哈希")
|
||||
规划解析 = 子命令.add_parser("规划", help="保存规划候选、查看版本并执行明确作者决定")
|
||||
规划解析.add_argument("配置")
|
||||
规划解析.add_argument(
|
||||
"动作",
|
||||
choices=[
|
||||
"结构",
|
||||
"列表",
|
||||
"查看",
|
||||
"创建候选",
|
||||
"编辑候选",
|
||||
"查看候选",
|
||||
"打开审阅",
|
||||
"决定",
|
||||
"读取细纲",
|
||||
],
|
||||
)
|
||||
规划解析.add_argument("目标")
|
||||
规划解析.add_argument("--结构", default="outline")
|
||||
规划解析.add_argument("--版本", type=int, default=1)
|
||||
规划解析.add_argument("--章节")
|
||||
配置解析 = 子命令.add_parser("配置", help="保存或查看运行配置草案;不自动验证启用")
|
||||
配置解析.add_argument("配置", help="应用连接配置TOML路径")
|
||||
配置解析.add_argument("动作", choices=["保存", "查看"])
|
||||
配置解析.add_argument("配置ID")
|
||||
配置解析.add_argument("版本")
|
||||
配置解析.add_argument("--内容", help="运行配置草案TOML路径")
|
||||
管理解析 = 子命令.add_parser("管理", help="管理当前配置的受控暂存;不访问其他根")
|
||||
管理解析.add_argument("配置", help="明确的应用连接配置")
|
||||
管理解析.add_argument("动作", choices=["清理无租约暂存", "查看清理回执"])
|
||||
管理解析.add_argument("对象ID", nargs="?")
|
||||
return 解析器.parse_args(argv)
|
||||
|
||||
|
||||
def _运行迁移(参数: argparse.Namespace) -> int:
|
||||
from muse.基础设施.数据库.迁移 import 迁移目标库
|
||||
|
||||
已执行 = 迁移目标库(读取配置(参数.配置), 目标版本=参数.目标版本)
|
||||
if 已执行:
|
||||
for 项 in 已执行:
|
||||
print(f"已应用 V{项.版本:04d} {项.文件名}")
|
||||
else:
|
||||
print("无待执行迁移(账本已是最新)")
|
||||
return _退出码_成功
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
参数 = _解析参数(argv)
|
||||
try:
|
||||
if 参数.命令 == "规划":
|
||||
from muse.接入.cli.作品命令 import 运行规划命令
|
||||
|
||||
结果 = 运行规划命令(
|
||||
构建(读取配置(参数.配置)),
|
||||
参数.动作,
|
||||
参数.目标,
|
||||
结构ID=参数.结构,
|
||||
版本=参数.版本,
|
||||
章节ID=参数.章节,
|
||||
)
|
||||
print(json.dumps(结果, ensure_ascii=False))
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "探索":
|
||||
from muse.接入.cli.作品命令 import 运行探索命令
|
||||
|
||||
结果 = 运行探索命令(
|
||||
构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 预期哈希=参数.预期哈希
|
||||
)
|
||||
print(json.dumps(结果, ensure_ascii=False))
|
||||
return (
|
||||
_退出码_业务失败
|
||||
if isinstance(结果, dict) and 结果.get("valid") is False
|
||||
else _退出码_成功
|
||||
)
|
||||
if 参数.命令 == "审阅":
|
||||
from muse.接入.cli.审阅命令 import 运行审阅命令
|
||||
|
||||
结果 = 运行审阅命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 分支=参数.分支)
|
||||
print(json.dumps(结果, ensure_ascii=False))
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "管理":
|
||||
from muse.接入.cli.管理命令 import 运行管理命令
|
||||
|
||||
结果 = 运行管理命令(构建(读取配置(参数.配置)), 参数.动作, 参数.对象ID)
|
||||
print(json.dumps(结果, ensure_ascii=False))
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "配置":
|
||||
from muse.接入.cli.管理命令 import 运行配置命令
|
||||
|
||||
结果 = 运行配置命令(
|
||||
构建(读取配置(参数.配置)), 参数.动作, 参数.配置ID, 参数.版本, 参数.内容
|
||||
)
|
||||
print(json.dumps(结果, ensure_ascii=False))
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "作品":
|
||||
from muse.接入.cli.作品命令 import 运行作品命令
|
||||
|
||||
结果 = 运行作品命令(
|
||||
构建(读取配置(参数.配置)),
|
||||
参数.动作,
|
||||
参数.目标,
|
||||
结构ID=参数.结构,
|
||||
版本=参数.版本,
|
||||
分支=参数.分支,
|
||||
)
|
||||
print(json.dumps(结果, ensure_ascii=False))
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "结构":
|
||||
from muse.接入.cli.结构命令 import 运行结构命令
|
||||
|
||||
结果 = 运行结构命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 参数.版本)
|
||||
print(json.dumps(结果, ensure_ascii=False))
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "任务":
|
||||
from muse.接入.cli.任务命令 import 运行任务命令
|
||||
|
||||
结果 = 运行任务命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 参数.游标)
|
||||
print(json.dumps(结果, ensure_ascii=False, default=str))
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "自检":
|
||||
装配 = 构建(读取配置(参数.配置))
|
||||
for 键, 值 in 自检(装配).items():
|
||||
print(f"{键}: {值}")
|
||||
return _退出码_成功
|
||||
if 参数.命令 == "迁移":
|
||||
return _运行迁移(参数)
|
||||
if 参数.命令 == "服务":
|
||||
import uvicorn
|
||||
|
||||
from muse.基础设施.可观测性 import 配置日志
|
||||
from muse.接入.http.应用 import 创建应用
|
||||
|
||||
配置 = 读取配置(参数.配置)
|
||||
应用 = 创建应用(配置)
|
||||
配置日志()
|
||||
assert 配置.HTTP is not None
|
||||
uvicorn.run(应用, host=配置.HTTP.监听地址, port=配置.HTTP.端口, access_log=False)
|
||||
return _退出码_成功
|
||||
except Muse错误 as exc:
|
||||
print(json.dumps(exc.呈现(), ensure_ascii=False), file=sys.stderr)
|
||||
return _退出码_业务失败
|
||||
return _退出码_用法错误
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
src/muse/接入/http/__init__.py
Normal file
1
src/muse/接入/http/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""单作者 HTTP 接入。"""
|
||||
95
src/muse/接入/http/事件订阅.py
Normal file
95
src/muse/接入/http/事件订阅.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""SSE 与有限事件页共用 PostgreSQL 持久化游标。"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from muse.任务运行.接口 import 事件续接
|
||||
from muse.接入.http.作者会话 import 作者依赖
|
||||
from muse.接入.http.依赖 import 取得任务服务, 读取作者任务
|
||||
|
||||
路由 = APIRouter(prefix="/api/v1/tasks", tags=["任务事件"])
|
||||
|
||||
|
||||
class 事件响应(BaseModel):
|
||||
event_id: str
|
||||
sequence: int
|
||||
event_type: str
|
||||
occurred_at: datetime
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class 事件页(BaseModel):
|
||||
items: list[事件响应]
|
||||
reset_required: bool
|
||||
first_sequence: int
|
||||
last_sequence: int
|
||||
|
||||
|
||||
def 呈现事件(结果: 事件续接) -> 事件页:
|
||||
return 事件页(
|
||||
items=[
|
||||
事件响应(
|
||||
event_id=项.事件ID,
|
||||
sequence=项.序号,
|
||||
event_type=项.类型.value,
|
||||
occurred_at=项.发生时间,
|
||||
payload=项.载荷,
|
||||
)
|
||||
for 项 in 结果.事件
|
||||
],
|
||||
reset_required=结果.需要快照,
|
||||
first_sequence=结果.可用首序号,
|
||||
last_sequence=结果.最后序号,
|
||||
)
|
||||
|
||||
|
||||
@路由.get("/{task_id}/events", response_model=事件页, operation_id="read_task_events")
|
||||
def 读取事件(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
作者: 作者依赖,
|
||||
cursor: int = Query(0, ge=0),
|
||||
work_id: str | None = None,
|
||||
) -> 事件页:
|
||||
读取作者任务(request, task_id, work_id)
|
||||
return 呈现事件(取得任务服务(request).续接事件(task_id, cursor))
|
||||
|
||||
|
||||
@路由.get("/{task_id}/events/stream", operation_id="stream_task_events")
|
||||
async def 订阅事件(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
作者: 作者依赖,
|
||||
cursor: int = Query(0, ge=0),
|
||||
work_id: str | None = None,
|
||||
last_event_id: int | None = Header(None, ge=0),
|
||||
):
|
||||
await run_in_threadpool(读取作者任务, request, task_id, work_id)
|
||||
服务 = 取得任务服务(request)
|
||||
|
||||
async def 数据流():
|
||||
游标 = max(cursor, last_event_id or 0)
|
||||
while not await request.is_disconnected():
|
||||
结果 = await run_in_threadpool(服务.续接事件, task_id, 游标)
|
||||
页 = 呈现事件(结果)
|
||||
if 页.reset_required:
|
||||
yield f"event: reset\ndata: {页.model_dump_json()}\n\n"
|
||||
return
|
||||
for 事件 in 页.items:
|
||||
yield f"id: {事件.sequence}\nevent: task-event\ndata: {事件.model_dump_json()}\n\n"
|
||||
游标 = 事件.sequence
|
||||
if not 页.items:
|
||||
yield ": keep-alive\n\n"
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return StreamingResponse(
|
||||
数据流(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
53
src/muse/接入/http/作者会话.py
Normal file
53
src/muse/接入/http/作者会话.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""作者凭据只用于建立服务端签名会话,不接受客户端自报身份。"""
|
||||
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr
|
||||
|
||||
from muse.配置 import 服务配置
|
||||
|
||||
|
||||
class 登录请求(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
password: SecretStr
|
||||
|
||||
|
||||
class 会话响应(BaseModel):
|
||||
author_id: str
|
||||
|
||||
|
||||
def 要求作者(request: Request) -> str:
|
||||
配置: 服务配置 = request.app.state.服务配置
|
||||
if request.session.get("author_id") != 配置.作者ID:
|
||||
raise HTTPException(401, "请先连接作者工作台")
|
||||
return 配置.作者ID
|
||||
|
||||
|
||||
作者依赖 = Annotated[str, Depends(要求作者)]
|
||||
|
||||
|
||||
def 会话路由() -> APIRouter:
|
||||
路由 = APIRouter(prefix="/api/v1/session", tags=["作者会话"])
|
||||
|
||||
@路由.post("", response_model=会话响应, operation_id="open_session")
|
||||
def 登录(请求: 登录请求, request: Request) -> 会话响应:
|
||||
配置: 服务配置 = request.app.state.服务配置
|
||||
口令: str = request.app.state.口令
|
||||
if not secrets.compare_digest(请求.password.get_secret_value().encode(), 口令.encode()):
|
||||
raise HTTPException(401, "访问口令不正确")
|
||||
request.session.clear()
|
||||
request.session["author_id"] = 配置.作者ID
|
||||
return 会话响应(author_id=配置.作者ID)
|
||||
|
||||
@路由.get("", response_model=会话响应, operation_id="read_session")
|
||||
def 读取(作者: 作者依赖) -> 会话响应:
|
||||
return 会话响应(author_id=作者)
|
||||
|
||||
@路由.delete("", operation_id="close_session")
|
||||
def 退出(request: Request, 作者: 作者依赖) -> dict[str, bool]:
|
||||
request.session.clear()
|
||||
return {"closed": True}
|
||||
|
||||
return 路由
|
||||
18
src/muse/接入/http/依赖.py
Normal file
18
src/muse/接入/http/依赖.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""从已认证请求和应用装配取得任务能力,不接受客户端作者身份。"""
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from muse.任务运行.接口 import 任务快照, 任务服务
|
||||
from muse.接入.http.作者会话 import 要求作者
|
||||
|
||||
|
||||
def 取得任务服务(request: Request) -> 任务服务:
|
||||
return request.app.state.装配.任务运行
|
||||
|
||||
|
||||
def 读取作者任务(request: Request, 任务ID: str, 作品ID: str | None = None) -> 任务快照:
|
||||
作者 = 要求作者(request)
|
||||
任务 = 取得任务服务(request).读取任务(任务ID)
|
||||
if 任务.作者 != 作者 or (作品ID is not None and 任务.冻结输入.get("作品ID") != 作品ID):
|
||||
raise HTTPException(403, "任务不属于当前作者或作品")
|
||||
return 任务
|
||||
25
src/muse/接入/http/健康检查.py
Normal file
25
src/muse/接入/http/健康检查.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""分别报告进程存活和依赖就绪,不推断业务任务成功。"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from muse.共享.错误 import Muse错误, 资源缺失错误
|
||||
from muse.资源加载 import 核对资源
|
||||
|
||||
路由 = APIRouter(tags=["系统状态"])
|
||||
|
||||
|
||||
@路由.get("/health", operation_id="read_health")
|
||||
def 存活() -> dict[str, str]:
|
||||
return {"status": "alive"}
|
||||
|
||||
|
||||
@路由.get("/ready", operation_id="read_readiness")
|
||||
def 就绪(request: Request) -> JSONResponse:
|
||||
try:
|
||||
request.app.state.装配.要求数据库().检查连接()
|
||||
if 核对资源()["发布身份"] != request.app.state.装配.配置.资源发布身份:
|
||||
raise 资源缺失错误("应用配置与资源发布身份不一致")
|
||||
except Muse错误 as exc:
|
||||
return JSONResponse(status_code=503, content={"status": "unavailable", "code": exc.错误码})
|
||||
return JSONResponse({"status": "ready"})
|
||||
94
src/muse/接入/http/应用.py
Normal file
94
src/muse/接入/http/应用.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""装配 HTTP 生命周期、单作者会话、来源校验与具名业务路由。"""
|
||||
|
||||
import hashlib
|
||||
from contextlib import asynccontextmanager
|
||||
from time import monotonic
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from muse.共享.错误 import 配置错误
|
||||
from muse.启动 import 构建
|
||||
from muse.基础设施.凭据读取 import 读取凭据
|
||||
from muse.基础设施.可观测性 import 记录运行
|
||||
from muse.接入.http.事件订阅 import 路由 as 事件路由
|
||||
from muse.接入.http.作者会话 import 会话路由
|
||||
from muse.接入.http.健康检查 import 路由 as 健康路由
|
||||
from muse.接入.http.路由.任务运行 import 路由 as 任务路由
|
||||
from muse.接入.http.路由.作品规划 import 路由 as 作品路由
|
||||
from muse.接入.http.路由.作者审阅 import 路由 as 审阅路由
|
||||
from muse.接入.http.路由.元数据 import 路由 as 元数据路由
|
||||
from muse.接入.http.路由.故事世界 import 路由 as 世界路由
|
||||
from muse.接入.http.路由.正文写作 import 路由 as 正文路由
|
||||
from muse.接入.http.错误响应 import 协议错误, 安装错误响应
|
||||
from muse.接入.http.静态资源 import 挂载工作台
|
||||
from muse.配置 import 应用配置
|
||||
|
||||
|
||||
def 创建应用(配置: 应用配置) -> FastAPI:
|
||||
服务 = 配置.HTTP
|
||||
if 服务 is None:
|
||||
raise 配置错误("启动工作台需要明确的 HTTP 与作者口令配置")
|
||||
口令 = 读取凭据("受控存储", 服务.口令文件)
|
||||
装配 = 构建(配置)
|
||||
|
||||
@asynccontextmanager
|
||||
async def 生命周期(app: FastAPI):
|
||||
app.state.装配 = 装配
|
||||
app.state.服务配置 = 服务
|
||||
yield
|
||||
|
||||
应用 = FastAPI(
|
||||
title="Muse 作者工作台", version="0.1.0", lifespan=生命周期, docs_url=None, redoc_url=None
|
||||
)
|
||||
应用.state.口令 = 口令
|
||||
应用.state.装配 = 装配
|
||||
应用.state.服务配置 = 服务
|
||||
应用.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=hashlib.sha256(("muse-session:" + 口令).encode()).hexdigest(),
|
||||
session_cookie="muse_session",
|
||||
same_site="strict",
|
||||
max_age=12 * 3600,
|
||||
https_only=服务.公开地址.startswith("https://"),
|
||||
)
|
||||
|
||||
@应用.middleware("http")
|
||||
async def 写入来源(request: Request, call_next):
|
||||
开始 = monotonic()
|
||||
请求ID = str(uuid4())
|
||||
request.state.request_id = 请求ID
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
if request.headers.get("origin") not in 服务.允许来源:
|
||||
return 协议错误("SCOPE_DENIED", "写入请求的来源不在允许范围", 403)
|
||||
响应 = await call_next(request)
|
||||
响应.headers["X-Request-ID"] = 请求ID
|
||||
响应.headers["X-Content-Type-Options"] = "nosniff"
|
||||
记录运行("http.completed", 请求ID=请求ID, 状态=响应.status_code, 耗时秒=monotonic() - 开始)
|
||||
return 响应
|
||||
|
||||
安装错误响应(应用)
|
||||
注册API(应用)
|
||||
挂载工作台(应用)
|
||||
return 应用
|
||||
|
||||
|
||||
def 注册API(应用: FastAPI) -> None:
|
||||
"""运行和合同生成共享同一组路由;不创建连接或加载凭据。"""
|
||||
应用.include_router(任务路由)
|
||||
应用.include_router(元数据路由)
|
||||
应用.include_router(作品路由)
|
||||
应用.include_router(正文路由)
|
||||
应用.include_router(审阅路由)
|
||||
应用.include_router(世界路由)
|
||||
应用.include_router(事件路由)
|
||||
应用.include_router(健康路由)
|
||||
应用.include_router(会话路由())
|
||||
|
||||
|
||||
def 导出合同() -> dict:
|
||||
"""生成静态外壳,动态作品结构由 S04 运行时提供。"""
|
||||
应用 = FastAPI(title="Muse 作者工作台", version="0.1.0")
|
||||
注册API(应用)
|
||||
return 应用.openapi()
|
||||
1
src/muse/接入/http/路由/__init__.py
Normal file
1
src/muse/接入/http/路由/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""具名业务 HTTP 路由。"""
|
||||
57
src/muse/接入/http/错误响应.py
Normal file
57
src/muse/接入/http/错误响应.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""统一错误外壳;验证错误不回显请求值。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from muse.共享.错误 import Muse错误
|
||||
from muse.接入.主会话 import 参数合同错误
|
||||
|
||||
|
||||
def 错误内容(错误: Muse错误, *, target_ref: str | None = None) -> dict[str, Any]:
|
||||
return {**错误.呈现(), "target_ref": target_ref}
|
||||
|
||||
|
||||
def 协议错误(code: str, message: str, status: int, details: Any = None) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"code": code,
|
||||
"message": message,
|
||||
"target_ref": None,
|
||||
"retryable": False,
|
||||
"details": details or {},
|
||||
"actions": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def 安装错误响应(应用: FastAPI) -> None:
|
||||
@应用.exception_handler(Muse错误)
|
||||
async def 业务错误(request: Request, exc: Muse错误) -> JSONResponse:
|
||||
code = exc.错误码
|
||||
if code in {"SCOPE_DENIED", "MUSE_PURPOSE_VIOLATION"}:
|
||||
status = 403
|
||||
elif code.endswith("NOT_FOUND"):
|
||||
status = 404
|
||||
elif code.endswith(("STALE", "CONFLICT", "MISMATCH")):
|
||||
status = 409
|
||||
elif exc.可重试:
|
||||
status = 503
|
||||
else:
|
||||
status = 422
|
||||
return JSONResponse(status_code=status, content=错误内容(exc))
|
||||
|
||||
@应用.exception_handler(RequestValidationError)
|
||||
async def 参数错误(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(status_code=422, content=参数合同错误(exc.errors()).呈现())
|
||||
|
||||
@应用.exception_handler(HTTPException)
|
||||
async def 访问错误(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
code = {401: "UNAUTHENTICATED", 403: "SCOPE_DENIED", 404: "NOT_FOUND"}.get(
|
||||
exc.status_code, "HTTP_ERROR"
|
||||
)
|
||||
return 协议错误(code, str(exc.detail), exc.status_code)
|
||||
25
src/muse/接入/http/静态资源.py
Normal file
25
src/muse/接入/http/静态资源.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""只提供已发布工作台资源,SPA 路由不接管 API 错误。"""
|
||||
|
||||
import mimetypes
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
|
||||
from muse.共享.错误 import 资源缺失错误
|
||||
from muse.资源加载 import 打开资源
|
||||
|
||||
|
||||
def 挂载工作台(应用: FastAPI) -> None:
|
||||
@应用.get("/{path:path}", include_in_schema=False)
|
||||
def 工作台文件(path: str) -> Response:
|
||||
if path.startswith("api/"):
|
||||
raise HTTPException(404, "接口不存在")
|
||||
名称 = path if PurePosixPath(path).suffix else "index.html"
|
||||
try:
|
||||
数据 = 打开资源(f"工作台/{名称}")
|
||||
except 资源缺失错误:
|
||||
raise HTTPException(404, "工作台资源不存在,请核对安装包") from None
|
||||
缓存 = "no-cache" if 名称 == "index.html" else "public, max-age=31536000, immutable"
|
||||
return Response(
|
||||
数据, media_type=mimetypes.guess_type(名称)[0], headers={"Cache-Control": 缓存}
|
||||
)
|
||||
58
src/muse/接入/主会话.py
Normal file
58
src/muse/接入/主会话.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""主会话、HTTP 和 CLI 共用的具名任务控制输入与转交。"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from muse.任务运行.接口 import 任务服务, 任务状态
|
||||
from muse.元数据.接口 import 有效结构, 生成结构合同
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
|
||||
def 参数合同错误(字段错误, *, 前缀: tuple[str, ...] = ()) -> Muse错误:
|
||||
错误 = Muse错误(
|
||||
"请求字段不符合接口合同",
|
||||
上下文={"fields": [{"field": [*前缀, *e["loc"]], "type": e["type"]} for e in 字段错误]},
|
||||
)
|
||||
错误.错误码 = "INVALID_REQUEST"
|
||||
return 错误
|
||||
|
||||
|
||||
class 结构读取结果(BaseModel):
|
||||
schema_id: str
|
||||
schema_version: int
|
||||
type_id: str
|
||||
effective_schema_hash: str
|
||||
contract: dict
|
||||
|
||||
|
||||
def 呈现结构(结构: 有效结构) -> 结构读取结果:
|
||||
return 结构读取结果(
|
||||
schema_id=结构.基础.schema_id,
|
||||
schema_version=结构.基础.schema_version,
|
||||
type_id=结构.基础.type_id,
|
||||
effective_schema_hash=结构.effective_schema_hash,
|
||||
contract=生成结构合同(结构),
|
||||
)
|
||||
|
||||
|
||||
class 任务控制请求(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
command_id: str = Field(min_length=1)
|
||||
target_ref: str = Field(min_length=1)
|
||||
expected_state: 任务状态
|
||||
action: Literal["暂停", "取消", "恢复"]
|
||||
|
||||
|
||||
class 任务控制回执(BaseModel):
|
||||
command_id: str
|
||||
task_id: str
|
||||
state: 任务状态
|
||||
last_sequence: int
|
||||
|
||||
|
||||
def 执行任务控制(服务: 任务服务, 作者: str, 请求: 任务控制请求) -> 任务控制回执:
|
||||
回执 = 服务.控制任务(
|
||||
请求.target_ref, 作者, 请求.expected_state, 请求.action, 命令ID=请求.command_id
|
||||
)
|
||||
return 任务控制回执.model_validate(回执)
|
||||
189
tests/集成/test_HTTP任务接入.py
Normal file
189
tests/集成/test_HTTP任务接入.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""真实 ASGI、CLI 与隔离 PostgreSQL 共用任务合同;不使用仓储替身。"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muse.任务运行.接口 import 任务服务, 任务请求, 步骤处理器, 步骤结果, 步骤计划
|
||||
from muse.共享.调用身份 import 内容用途, 用途
|
||||
from muse.接入.http.应用 import 创建应用
|
||||
from muse.编排.接口 import 流程定义, 流程服务, 流程登记
|
||||
from muse.配置 import 应用配置, 服务配置
|
||||
|
||||
pytestmark = pytest.mark.数据库
|
||||
|
||||
|
||||
def test_具名CLI按字段传参不依赖JSON排列__793c2a(任务接入环境, tmp_path: Path) -> None:
|
||||
服务, 身份, _, 配置文件 = 任务接入环境
|
||||
请求文件 = tmp_path / "作者决定.json"
|
||||
请求文件.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "取消",
|
||||
"expected_state": "queued",
|
||||
"target_ref": 身份,
|
||||
"command_id": "作者决定-1",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
进程 = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "任务", str(配置文件), "控制", str(请求文件)],
|
||||
cwd="/",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
assert 进程.returncode == 0, 进程.stderr
|
||||
assert json.loads(进程.stdout)["command_id"] == "作者决定-1"
|
||||
assert 服务.读取任务(身份).状态.value == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"请求文本,额外参数,退出码",
|
||||
[
|
||||
("{}", [], 1),
|
||||
('["x", 1, null]', [], 1),
|
||||
("{}", ["--stdin"], 2),
|
||||
("not json", [], 1),
|
||||
('{"a": 1}', [], 1),
|
||||
('{"sql": "CREATE TABLE t (id int)"}', [], 1),
|
||||
('{"sql": "DELETE FROM t"}', [], 1),
|
||||
('{"sql": "ALTER TABLE t ADD COLUMN x int", "params": []}', [], 1),
|
||||
('{"sql": "DELETE FROM t", "params": []}', [], 1),
|
||||
('{"sql": "UPDATE t SET x=1 WHERE id=1"}', [], 1),
|
||||
('{"sql": "DELETE FROM t WHERE id=%s", "params": [1]}', [], 1),
|
||||
],
|
||||
ids=[
|
||||
"empty",
|
||||
"typed-array",
|
||||
"competing-input",
|
||||
"invalid-json",
|
||||
"unknown-object",
|
||||
"create-sql",
|
||||
"bare-delete",
|
||||
"alter-params",
|
||||
"delete-params",
|
||||
"update-where",
|
||||
"delete-where",
|
||||
],
|
||||
)
|
||||
def test_具名CLI拒绝未登记输入且任务不变__a71002(
|
||||
任务接入环境,
|
||||
tmp_path: Path,
|
||||
请求文本: str,
|
||||
额外参数: list[str],
|
||||
退出码: int,
|
||||
) -> None:
|
||||
"""旧通用 SQL 入口已退出;只接受具名命令合同,不能以 WHERE 或参数列表换取写能力。"""
|
||||
服务, 身份, _, 配置文件 = 任务接入环境
|
||||
请求文件 = tmp_path / "请求.json"
|
||||
请求文件.write_text(请求文本)
|
||||
进程 = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "任务", str(配置文件), "控制", str(请求文件), *额外参数],
|
||||
cwd="/",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
assert 进程.returncode == 退出码, 进程.stdout
|
||||
if 退出码 == 1:
|
||||
assert json.loads(进程.stderr)["code"] == "INVALID_REQUEST"
|
||||
assert 服务.读取任务(身份).状态.value == "queued"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def 任务接入环境(应用测试库, tmp_path: Path):
|
||||
registry = 流程登记()
|
||||
registry.登记处理器(步骤处理器("synthetic", "1", lambda _: 步骤结果({"ok": True}), "v1", "v1"))
|
||||
registry.登记类型("synthetic", 必需保护=())
|
||||
service = 任务服务(应用测试库[用途.生产], registry)
|
||||
flow = 流程服务(service, registry)
|
||||
flow.发布(流程定义("synthetic", "1", (步骤计划("合成步骤", "synthetic", "1"),)))
|
||||
req = 任务请求(
|
||||
"合成任务",
|
||||
"create",
|
||||
"author-local",
|
||||
用途.生产,
|
||||
内容用途.抽取,
|
||||
{},
|
||||
"v1",
|
||||
"test",
|
||||
{
|
||||
"source_scope": {},
|
||||
"schema_versions": {},
|
||||
"authorization": "synthetic",
|
||||
"budget": {},
|
||||
"stop_conditions": [],
|
||||
},
|
||||
作品ID="work-A",
|
||||
)
|
||||
task_id = flow.发起(req, "synthetic", "1")
|
||||
token = tmp_path / "口令.txt"
|
||||
token.write_text("synthetic-password")
|
||||
config = 应用配置(
|
||||
应用测试库[用途.生产].引用,
|
||||
"test",
|
||||
HTTP=服务配置(str(token), 公开地址="http://testserver", 允许来源=("http://testserver",)),
|
||||
)
|
||||
file = tmp_path / "应用.toml"
|
||||
file.write_text(f"""["数据库"]
|
||||
"取值方式" = "受控存储"
|
||||
"位置" = {json.dumps(str(tmp_path / "production.txt"))}
|
||||
["资源"]
|
||||
"发布身份" = "test"
|
||||
[HTTP]
|
||||
"口令文件" = {json.dumps(str(token))}
|
||||
""")
|
||||
return service, task_id, config, file
|
||||
|
||||
|
||||
def test_HTTP与CLI控制回执和拒绝保持一致__a71001(任务接入环境, tmp_path: Path) -> None:
|
||||
service, task_id, config, file = 任务接入环境
|
||||
with TestClient(创建应用(config)) as client:
|
||||
assert client.get(f"/api/v1/tasks/{task_id}").status_code == 401
|
||||
client.headers["Origin"] = "http://testserver"
|
||||
assert (
|
||||
client.post("/api/v1/session", json={"password": "synthetic-password"}).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
client.get("/api/v1/tasks", params={"work_id": "work-A"}).json()[0]["task_id"]
|
||||
== task_id
|
||||
)
|
||||
assert (
|
||||
client.get(f"/api/v1/tasks/{task_id}", params={"work_id": "work-B"}).status_code == 403
|
||||
)
|
||||
body = {
|
||||
"command_id": "cancel-once",
|
||||
"target_ref": task_id,
|
||||
"expected_state": "queued",
|
||||
"action": "取消",
|
||||
}
|
||||
response = client.post(f"/api/v1/tasks/{task_id}/controls", json=body)
|
||||
assert response.status_code == 200
|
||||
request = tmp_path / "请求.json"
|
||||
request.write_text(json.dumps(body))
|
||||
args = [sys.executable, "-m", "muse", "任务", str(file), "控制", str(request)]
|
||||
cli = subprocess.run(args, cwd="/", capture_output=True, text=True, timeout=20)
|
||||
assert cli.returncode == 0, cli.stderr
|
||||
assert json.loads(cli.stdout) == response.json()
|
||||
body["action"] = "暂停"
|
||||
request.write_text(json.dumps(body))
|
||||
response = client.post(f"/api/v1/tasks/{task_id}/controls", json=body)
|
||||
cli = subprocess.run(args, cwd="/", capture_output=True, text=True, timeout=20)
|
||||
assert response.status_code == 409 and cli.returncode == 1
|
||||
assert response.json() == json.loads(cli.stderr)
|
||||
events = client.get(f"/api/v1/tasks/{task_id}/events").json()
|
||||
assert [event["event_type"] for event in events["items"]] == [
|
||||
"task.created",
|
||||
"task.cancelled",
|
||||
]
|
||||
assert (
|
||||
client.get(f"/api/v1/tasks/{task_id}/events", params={"cursor": 2}).json()["items"]
|
||||
== []
|
||||
)
|
||||
14
web/src/main.tsx
Normal file
14
web/src/main.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Ui应用 } from "./应用/应用";
|
||||
import { Ui依赖装配 } from "./应用/依赖装配";
|
||||
import "./界面/主题.css";
|
||||
import "./界面/排版.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Ui依赖装配>
|
||||
<Ui应用 />
|
||||
</Ui依赖装配>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
1
web/src/功能/任务运行/index.ts
Normal file
1
web/src/功能/任务运行/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Ui任务列表 } from "./任务列表";
|
||||
148
web/src/功能/任务运行/任务列表.tsx
Normal file
148
web/src/功能/任务运行/任务列表.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { 客户端 } from "../../接口/生成/客户端";
|
||||
import { Ui反馈状态 } from "../../界面/反馈状态";
|
||||
import { useMuse作品会话 } from "../../应用/作品会话";
|
||||
import { 查询键 } from "../../接口/查询键";
|
||||
import { 状态 } from "./数据";
|
||||
import { Ui任务详情 } from "./任务详情";
|
||||
|
||||
export function Ui任务列表() {
|
||||
const work = useMuse作品会话();
|
||||
const allTasks = useQuery({
|
||||
queryKey: ["任务总览"],
|
||||
queryFn: async ({ signal }) =>
|
||||
(await 客户端.GET("/api/v1/tasks", { signal })).data!,
|
||||
});
|
||||
const works = [
|
||||
...new Set(
|
||||
(allTasks.data ?? []).flatMap((item) =>
|
||||
item.work_id ? [item.work_id] : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
const list = useQuery({
|
||||
queryKey: 查询键(work.workId, "任务列表"),
|
||||
queryFn: async ({ signal }) =>
|
||||
(
|
||||
await 客户端.GET("/api/v1/tasks", {
|
||||
params: { query: { work_id: work.workId ?? undefined } },
|
||||
signal,
|
||||
})
|
||||
).data!,
|
||||
});
|
||||
const [input, setInput] = useState("");
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const task = useQuery({
|
||||
queryKey: 查询键(work.workId, taskId),
|
||||
enabled: !!taskId,
|
||||
queryFn: async ({ signal }) =>
|
||||
(
|
||||
await 客户端.GET("/api/v1/tasks/{task_id}", {
|
||||
params: {
|
||||
path: { task_id: taskId },
|
||||
query: { work_id: work.workId ?? undefined },
|
||||
},
|
||||
signal,
|
||||
})
|
||||
).data!,
|
||||
});
|
||||
function find(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setTaskId(input.trim());
|
||||
}
|
||||
return (
|
||||
<main className="content">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">创作进程</p>
|
||||
<h1>每一步,都可以接续</h1>
|
||||
<p className="muted">查看已发起的任务,跟进进度与处理结果。</p>
|
||||
</div>
|
||||
<span className="page-number">01 / 任务记录</span>
|
||||
</div>
|
||||
<div className="scope-filter">
|
||||
<label htmlFor="work-scope">作品范围</label>
|
||||
<select
|
||||
id="work-scope"
|
||||
value={work.workId ?? ""}
|
||||
onChange={(event) => {
|
||||
work.change(event.target.value || null);
|
||||
setTaskId("");
|
||||
setInput("");
|
||||
}}
|
||||
>
|
||||
<option value="">全部作品</option>
|
||||
{works.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<form className="task-search" onSubmit={find}>
|
||||
<label htmlFor="task-id">任务编号</label>
|
||||
<div>
|
||||
<input
|
||||
id="task-id"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="粘贴已有任务编号"
|
||||
required
|
||||
/>
|
||||
<button className="primary">查看任务</button>
|
||||
</div>
|
||||
</form>
|
||||
{!taskId ? (
|
||||
<section className="recent-tasks">
|
||||
<div className="panel-heading">
|
||||
<h2>最近任务</h2>
|
||||
<span className="muted">{list.data?.length ?? 0} 条记录</span>
|
||||
</div>
|
||||
{list.isPending ? (
|
||||
<Ui反馈状态 message="正在读取任务记录…" />
|
||||
) : list.error ? (
|
||||
<Ui反馈状态 message={list.error.message} error />
|
||||
) : list.data?.length ? (
|
||||
<ul>
|
||||
{list.data.map((item) => (
|
||||
<li key={item.task_id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTaskId(item.task_id);
|
||||
setInput(item.task_id);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{item.task_type}</strong>
|
||||
<small>
|
||||
{item.work_id ?? "工作台任务"} · {item.steps.length}{" "}
|
||||
个步骤
|
||||
</small>
|
||||
</span>
|
||||
<span className="state-pill">{状态[item.state]}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<h2>这里会保留每一步进度</h2>
|
||||
<p>当前范围还没有任务记录。</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : task.isPending ? (
|
||||
<Ui反馈状态 message="正在读取任务…" />
|
||||
) : task.error ? (
|
||||
<Ui反馈状态 error message={task.error.message} />
|
||||
) : task.data ? (
|
||||
<Ui任务详情
|
||||
key={task.data.task_id}
|
||||
task={task.data}
|
||||
scope={work.workId}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
79
web/src/功能/任务运行/任务详情.tsx
Normal file
79
web/src/功能/任务运行/任务详情.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Ui恢复控制 } from "./恢复控制";
|
||||
import { 订阅任务事件 } from "../../接口/事件连接";
|
||||
import { 查询键 } from "../../接口/查询键";
|
||||
import { Ui反馈状态 } from "../../界面/反馈状态";
|
||||
import { 状态, 事件名, type 任务, type 任务事件 } from "./数据";
|
||||
|
||||
export function Ui任务详情({
|
||||
task,
|
||||
scope,
|
||||
}: {
|
||||
task: 任务;
|
||||
scope: string | null;
|
||||
}) {
|
||||
const cache = useQueryClient();
|
||||
const [events, setEvents] = useState<任务事件[]>([]);
|
||||
const [notice, setNotice] = useState("");
|
||||
useEffect(
|
||||
() =>
|
||||
订阅任务事件(task.task_id, {
|
||||
收到: (事件) => {
|
||||
setEvents((旧) => [...旧, 事件].slice(-40));
|
||||
setNotice("");
|
||||
void cache.invalidateQueries({
|
||||
queryKey: 查询键(scope, task.task_id),
|
||||
});
|
||||
},
|
||||
缺口: () => setNotice("较早的事件已归档,请重新读取任务进度。"),
|
||||
重连: () => setNotice("连接正在恢复,任务会继续保留。"),
|
||||
}),
|
||||
[task.task_id, scope, cache],
|
||||
);
|
||||
return (
|
||||
<div className="task-columns">
|
||||
<section className="paper-panel">
|
||||
<div className="panel-heading">
|
||||
<h2>任务进度</h2>
|
||||
<span className="state-pill">{状态[task.state]}</span>
|
||||
</div>
|
||||
<p className="mono muted">{task.task_id}</p>
|
||||
<Ui恢复控制 task={task} />
|
||||
<ol className="step-list">
|
||||
{task.steps.map((step, index) => (
|
||||
<li key={step.step_id}>
|
||||
<span className="step-number">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{step.step_id}</strong>
|
||||
<span>{状态[step.state] ?? step.state}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
<section className="event-panel">
|
||||
<div className="panel-heading">
|
||||
<h2>过程记录</h2>
|
||||
<span className="muted">实时更新</span>
|
||||
</div>
|
||||
{notice && <Ui反馈状态 message={notice} />}
|
||||
<ol className="event-list" aria-live="polite">
|
||||
{events.map((event) => (
|
||||
<li key={event.event_id}>
|
||||
<time>
|
||||
{new Date(event.occurred_at).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</time>
|
||||
<span>{事件名[event.event_type] ?? "进度更新"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
web/src/功能/任务运行/恢复控制.tsx
Normal file
65
web/src/功能/任务运行/恢复控制.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { 客户端 } from "../../接口/生成/客户端";
|
||||
import { Ui反馈状态 } from "../../界面/反馈状态";
|
||||
import type { 任务 } from "./数据";
|
||||
|
||||
export function Ui恢复控制({ task }: { task: 任务 }) {
|
||||
const cache = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [controlError, setControlError] = useState("");
|
||||
const pending = useRef<{
|
||||
action: "暂停" | "取消" | "恢复";
|
||||
id: string;
|
||||
} | null>(null);
|
||||
async function 控制(action: "暂停" | "取消" | "恢复") {
|
||||
if (pending.current?.action !== action)
|
||||
pending.current = { action, id: crypto.randomUUID() };
|
||||
setBusy(true);
|
||||
setControlError("");
|
||||
try {
|
||||
await 客户端.POST("/api/v1/tasks/{task_id}/controls", {
|
||||
params: { path: { task_id: task.task_id } },
|
||||
body: {
|
||||
command_id: pending.current.id,
|
||||
target_ref: task.task_id,
|
||||
expected_state: task.state,
|
||||
action,
|
||||
},
|
||||
});
|
||||
pending.current = null;
|
||||
await cache.invalidateQueries();
|
||||
} catch (e) {
|
||||
setControlError(e instanceof Error ? e.message : "控制请求未完成。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{(["queued", "running"] as string[]).includes(task.state) && (
|
||||
<div className="task-actions">
|
||||
<button disabled={busy} onClick={() => 控制("暂停")}>
|
||||
暂停任务
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => 控制("取消")}>
|
||||
取消任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(["paused", "failed", "reconciling"] as string[]).includes(
|
||||
task.state,
|
||||
) && (
|
||||
<div className="task-actions">
|
||||
<button disabled={busy} onClick={() => 控制("恢复")}>
|
||||
核对并恢复
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => 控制("取消")}>
|
||||
取消任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{controlError && <Ui反馈状态 message={controlError} error />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
web/src/功能/任务运行/数据.ts
Normal file
29
web/src/功能/任务运行/数据.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { operations } from "../../接口/生成/类型";
|
||||
|
||||
export type 任务 =
|
||||
operations["read_task"]["responses"][200]["content"]["application/json"];
|
||||
export type { 任务事件 } from "../../接口/事件连接";
|
||||
export const 状态: Record<string, string> = {
|
||||
queued: "等待运行",
|
||||
running: "正在进行",
|
||||
paused: "已暂停",
|
||||
reconciling: "等待核对",
|
||||
failed: "未能完成",
|
||||
cancelled: "已取消",
|
||||
completed: "已完成",
|
||||
pending: "待开始",
|
||||
unknown: "等待核对",
|
||||
};
|
||||
export const 事件名: Record<string, string> = {
|
||||
"task.created": "任务已建立",
|
||||
"step.started": "开始处理",
|
||||
"step.completed": "步骤完成",
|
||||
"task.completed": "任务完成",
|
||||
"task.cancelled": "任务已取消",
|
||||
"task.paused": "任务已暂停",
|
||||
"task.resumed": "继续处理",
|
||||
"step.failed": "步骤未完成",
|
||||
"text.delta": "正文更新",
|
||||
"step.checkpoint": "进度已保存",
|
||||
"call.unknown": "等待结果核对",
|
||||
};
|
||||
13
web/src/应用/作品会话.ts
Normal file
13
web/src/应用/作品会话.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/** 缓存身份和取消共用 React Query,避免维护第二套请求代次。 */
|
||||
export function useMuse作品会话() {
|
||||
const [workId, setWorkId] = useState<string | null>(null);
|
||||
const cache = useQueryClient();
|
||||
function change(next: string | null) {
|
||||
void cache.cancelQueries({ queryKey: ["作品", workId] });
|
||||
setWorkId(next);
|
||||
}
|
||||
return { workId, change };
|
||||
}
|
||||
9
web/src/应用/作者会话.ts
Normal file
9
web/src/应用/作者会话.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { 客户端 } from "../接口/生成/客户端";
|
||||
|
||||
export function useMuse作者会话() {
|
||||
return useQuery({
|
||||
queryKey: ["作者会话"],
|
||||
queryFn: async () => (await 客户端.GET("/api/v1/session")).data!,
|
||||
});
|
||||
}
|
||||
14
web/src/应用/依赖装配.tsx
Normal file
14
web/src/应用/依赖装配.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type PropsWithChildren } from "react";
|
||||
|
||||
export function Ui依赖装配({ children }: PropsWithChildren) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, refetchOnWindowFocus: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
62
web/src/应用/工作台布局.tsx
Normal file
62
web/src/应用/工作台布局.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useState, type PropsWithChildren } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { 客户端 } from "../接口/生成/客户端";
|
||||
import { Ui反馈状态 } from "../界面/反馈状态";
|
||||
|
||||
export function Ui工作台布局({ children }: PropsWithChildren) {
|
||||
const cache = useQueryClient();
|
||||
const [disconnectError, setDisconnectError] = useState("");
|
||||
async function 断开() {
|
||||
try {
|
||||
await 客户端.DELETE("/api/v1/session");
|
||||
await cache.cancelQueries();
|
||||
cache.setQueryData(["作者会话"], null);
|
||||
cache.removeQueries({
|
||||
predicate: (query) => query.queryKey[0] !== "作者会话",
|
||||
});
|
||||
} catch (e) {
|
||||
setDisconnectError(e instanceof Error ? e.message : "断开请求未完成。");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="workbench">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark small">M</span>
|
||||
<div>
|
||||
<strong>Muse</strong>
|
||||
<span>创作工作台</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="nav-caption">工作空间</p>
|
||||
<nav>
|
||||
<NavLink to="/works">
|
||||
我的作品<span>01</span>
|
||||
</NavLink>
|
||||
<NavLink to="/tasks">
|
||||
任务记录<span>02</span>
|
||||
</NavLink>
|
||||
<NavLink to="/system">
|
||||
连接状态<span>03</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-note">
|
||||
<span className="status-dot" />
|
||||
作者会话已连接<p>关掉页面后,任务仍会保留。</p>
|
||||
</div>
|
||||
<button className="text-button" onClick={断开}>
|
||||
断开会话
|
||||
</button>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<span>我的工作台</span>
|
||||
<span className="muted">每一步,都有记录</span>
|
||||
</header>
|
||||
{disconnectError && <Ui反馈状态 error message={disconnectError} />}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
web/src/应用/应用.tsx
Normal file
96
web/src/应用/应用.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { 客户端 } from "../接口/生成/客户端";
|
||||
import { 接口错误 } from "../接口/传输";
|
||||
import { Ui反馈状态 } from "../界面/反馈状态";
|
||||
import { useMuse作者会话 } from "./作者会话";
|
||||
import { Ui工作台布局 } from "./工作台布局";
|
||||
import { Ui页面路由 } from "./路由";
|
||||
|
||||
function Ui连接表单() {
|
||||
const cache = useQueryClient();
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function 连接(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await 客户端.POST("/api/v1/session", {
|
||||
body: { password },
|
||||
});
|
||||
setPassword("");
|
||||
cache.setQueryData(["作者会话"], result.data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "连接未完成。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="connect-page">
|
||||
<div className="connect-intro">
|
||||
<span className="brand-mark">M</span>
|
||||
<p className="eyebrow">MUSE · 创作工作台</p>
|
||||
<h1>
|
||||
把故事
|
||||
<br />
|
||||
接着写下去。
|
||||
</h1>
|
||||
<p>
|
||||
整理想法,守住每一次选择。
|
||||
<br />
|
||||
从你的工作台继续。
|
||||
</p>
|
||||
</div>
|
||||
<form className="connect-form" onSubmit={连接}>
|
||||
<p className="eyebrow">作者会话</p>
|
||||
<h2>连接工作台</h2>
|
||||
<p className="muted">使用本机配置的访问口令。</p>
|
||||
<label htmlFor="password">访问口令</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <Ui反馈状态 message={error} error />}
|
||||
<button className="primary" disabled={busy}>
|
||||
{busy ? "连接中…" : "进入工作台"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function Ui应用() {
|
||||
const session = useMuse作者会话();
|
||||
if (session.isPending)
|
||||
return (
|
||||
<div className="loading-page">
|
||||
<Ui反馈状态 message="正在连接工作台…" />
|
||||
</div>
|
||||
);
|
||||
if (
|
||||
session.error &&
|
||||
!(session.error instanceof 接口错误 && session.error.status === 401)
|
||||
)
|
||||
return (
|
||||
<div className="loading-page">
|
||||
<Ui反馈状态 message={session.error.message} error />
|
||||
<button onClick={() => session.refetch()}>重新连接</button>
|
||||
</div>
|
||||
);
|
||||
if (!session.data) return <Ui连接表单 />;
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Ui工作台布局>
|
||||
<Ui页面路由 />
|
||||
</Ui工作台布局>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
59
web/src/应用/路由.tsx
Normal file
59
web/src/应用/路由.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Ui任务列表 } from "../功能/任务运行";
|
||||
import {
|
||||
Ui作品首页,
|
||||
Ui作品档案,
|
||||
Ui探索工作区,
|
||||
Ui规划工作区,
|
||||
} from "../功能/作品规划";
|
||||
import { Ui候选审阅页, Ui正文页 } from "../功能/正文写作";
|
||||
import { 客户端 } from "../接口/生成/客户端";
|
||||
|
||||
export function Ui页面路由() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/works" element={<Ui作品首页 />} />
|
||||
<Route path="/explorations/:draft_id?" element={<Ui探索工作区 />} />
|
||||
<Route path="/works/:work_id/profile" element={<Ui作品档案 />} />
|
||||
<Route path="/works/:work_id/plans" element={<Ui规划工作区 />} />
|
||||
<Route path="/works/:work_id/write/:chapter_id?" element={<Ui正文页 />} />
|
||||
<Route
|
||||
path="/works/:work_id/candidates/:chapter_id"
|
||||
element={<Ui候选审阅页 />}
|
||||
/>
|
||||
<Route path="/tasks" element={<Ui任务列表 />} />
|
||||
<Route path="/system" element={<Ui连接状态 />} />
|
||||
<Route path="*" element={<Navigate to="/works" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function Ui连接状态() {
|
||||
const [message, setMessage] = useState("查看工作台当前的连接情况。");
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function 检查() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await 客户端.GET("/ready");
|
||||
setMessage("连接正常,工作台已就绪。");
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : "连接暂不可用。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="content">
|
||||
<p className="eyebrow">工作台</p>
|
||||
<h1>连接状态</h1>
|
||||
<section className="paper-panel">
|
||||
<h2>服务连接</h2>
|
||||
<p className="muted">{message}</p>
|
||||
<button onClick={检查} disabled={busy}>
|
||||
{busy ? "检查中…" : "检查连接"}
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
34
web/src/接口/事件连接.ts
Normal file
34
web/src/接口/事件连接.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { operations } from "./生成/类型";
|
||||
|
||||
export type 任务事件 =
|
||||
operations["read_task_events"]["responses"][200]["content"]["application/json"]["items"][number];
|
||||
|
||||
/** 原生 EventSource 携带 Last-Event-ID 重连;页面离开时显式关闭。 */
|
||||
export function 订阅任务事件(
|
||||
任务ID: string,
|
||||
回调: { 收到: (事件: 任务事件) => void; 缺口: () => void; 重连: () => void },
|
||||
) {
|
||||
const 连接 = new EventSource(
|
||||
`/api/v1/tasks/${encodeURIComponent(任务ID)}/events/stream`,
|
||||
);
|
||||
let 活跃 = true;
|
||||
let 最后序号 = 0;
|
||||
连接.addEventListener("task-event", (消息) => {
|
||||
if (!活跃) return;
|
||||
const 事件 = JSON.parse((消息 as MessageEvent).data) as 任务事件;
|
||||
if (事件.sequence <= 最后序号) return;
|
||||
最后序号 = 事件.sequence;
|
||||
回调.收到(事件);
|
||||
});
|
||||
连接.addEventListener("reset", () => {
|
||||
if (活跃) 回调.缺口();
|
||||
连接.close();
|
||||
});
|
||||
连接.onerror = () => {
|
||||
if (活跃) 回调.重连();
|
||||
};
|
||||
return () => {
|
||||
活跃 = false;
|
||||
连接.close();
|
||||
};
|
||||
}
|
||||
34
web/src/接口/传输.ts
Normal file
34
web/src/接口/传输.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/** 统一凭据、取消和错误;服务器错误保留稳定代码与中文说明。 */
|
||||
export class 接口错误 extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function 传输(request: Request): Promise<Response> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(request);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
throw error;
|
||||
throw new 接口错误(
|
||||
"NETWORK_UNAVAILABLE",
|
||||
"暂时无法连接工作台,请稍后重试。",
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new 接口错误(
|
||||
body.code ?? "HTTP_ERROR",
|
||||
body.message ?? "请求未完成,请检查连接后重试。",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
9
web/src/接口/查询键.ts
Normal file
9
web/src/接口/查询键.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/** 查询身份包含作品和目标,切换作品不会共用同一条缓存。 */
|
||||
export function 查询键(
|
||||
作品: string | null,
|
||||
目标: string,
|
||||
版本: string = "current",
|
||||
用途: string = "production",
|
||||
) {
|
||||
return ["作品", 作品, 目标, 版本, 用途] as const;
|
||||
}
|
||||
9
web/src/接口/生成/客户端.ts
Normal file
9
web/src/接口/生成/客户端.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "./类型";
|
||||
import { 传输 } from "../传输";
|
||||
|
||||
export const 客户端 = createClient<paths>({
|
||||
baseUrl: globalThis.location.origin, fetch: 传输,
|
||||
credentials: "same-origin"
|
||||
});
|
||||
4499
web/src/接口/生成/类型.ts
Normal file
4499
web/src/接口/生成/类型.ts
Normal file
File diff suppressed because it is too large
Load Diff
563
web/src/界面/主题.css
Normal file
563
web/src/界面/主题.css
Normal file
@ -0,0 +1,563 @@
|
||||
:root {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #30372e;
|
||||
background: #f4f1e9;
|
||||
font-synthesis: none;
|
||||
--paper: #fffdf7;
|
||||
--line: #dddccf;
|
||||
--accent: #405e43;
|
||||
--muted: #7d8073;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
padding: 12px 19px;
|
||||
background: var(--paper);
|
||||
color: inherit;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.12s,
|
||||
background 0.12s;
|
||||
}
|
||||
button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #fffdf5;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
input {
|
||||
border: 1px solid #d7d7c9;
|
||||
background: #fffcf6;
|
||||
color: #30372e;
|
||||
border-radius: 5px;
|
||||
padding: 13px 15px;
|
||||
min-width: 0;
|
||||
}
|
||||
input:focus,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid #789472;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.9px;
|
||||
line-height: 1.3;
|
||||
font-weight: 550;
|
||||
margin: 10px 0 16px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 550;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
p {
|
||||
line-height: 1.9;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2.5px;
|
||||
color: #77806a;
|
||||
margin: 0;
|
||||
}
|
||||
.brand-mark {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1px solid #687361;
|
||||
color: #53644c;
|
||||
border-radius: 14px;
|
||||
font-size: 30px;
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
.brand-mark.small {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 23px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.connect-page {
|
||||
max-width: 1120px;
|
||||
min-height: 100dvh;
|
||||
margin: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
align-items: center;
|
||||
gap: 90px;
|
||||
padding: 70px;
|
||||
}
|
||||
.connect-intro h1 {
|
||||
font-size: 58px;
|
||||
line-height: 1.45;
|
||||
letter-spacing: 1px;
|
||||
margin: 32px 0 20px;
|
||||
}
|
||||
.connect-intro > .eyebrow {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.connect-intro > p:last-child {
|
||||
color: #7c8272;
|
||||
line-height: 2.2;
|
||||
font-size: 15px;
|
||||
}
|
||||
.connect-form {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 45px;
|
||||
box-shadow: 0 18px 50px #41472d08;
|
||||
}
|
||||
.connect-form h2 {
|
||||
font-size: 25px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.connect-form label {
|
||||
display: block;
|
||||
margin: 25px 0 9px;
|
||||
}
|
||||
.connect-form input,
|
||||
.connect-form button {
|
||||
width: 100%;
|
||||
}
|
||||
.connect-form button {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.loading-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.workbench {
|
||||
display: grid;
|
||||
grid-template-columns: 218px 1fr;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 31px 23px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #eeeee4;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.brand strong {
|
||||
font-size: 23px;
|
||||
font-weight: 550;
|
||||
letter-spacing: 0.4px;
|
||||
display: block;
|
||||
}
|
||||
.brand div > span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.nav-caption {
|
||||
color: #929788;
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
padding-left: 13px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
nav a {
|
||||
padding: 13px;
|
||||
color: #727969;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
nav a.active {
|
||||
background: #dde5d4;
|
||||
color: #3f5a3b;
|
||||
}
|
||||
nav a > span {
|
||||
font-size: 10px;
|
||||
opacity: 0.55;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.sidebar-note {
|
||||
margin-top: auto;
|
||||
padding: 30px 3px 5px;
|
||||
color: #68705e;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sidebar-note p {
|
||||
font-size: 10px;
|
||||
color: #9a9e90;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #6f8c60;
|
||||
margin-right: 7px;
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
padding: 8px 2px;
|
||||
background: none;
|
||||
text-align: left;
|
||||
color: #959b8c;
|
||||
font-size: 11px;
|
||||
}
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar {
|
||||
height: 72px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #757b6d;
|
||||
}
|
||||
.topbar > .muted {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.content {
|
||||
max-width: 1400px;
|
||||
padding: 43px 48px 64px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.page-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
.page-heading .muted {
|
||||
margin: 0;
|
||||
}
|
||||
.page-number {
|
||||
font-size: 10px;
|
||||
color: #a0a492;
|
||||
letter-spacing: 1px;
|
||||
padding-top: 7px;
|
||||
}
|
||||
.task-search {
|
||||
padding: 24px 28px;
|
||||
background: #eeede4;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.task-search > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.task-search input {
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 73px 20px 60px;
|
||||
color: #7e8574;
|
||||
}
|
||||
.empty-symbol {
|
||||
font-size: 35px;
|
||||
color: #a5ae94;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.empty-state h2 {
|
||||
color: #59674e;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 12px;
|
||||
line-height: 2.2;
|
||||
color: #9a9f90;
|
||||
}
|
||||
.task-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 27px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.paper-panel {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 28px;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.panel-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.state-pill {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #d5dfc9;
|
||||
background: #edf1e5;
|
||||
color: #617551;
|
||||
font-size: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.step-list,
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 27px 0 0;
|
||||
}
|
||||
.step-list li {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 19px 0;
|
||||
border-top: 1px solid #e8e7de;
|
||||
}
|
||||
.step-number {
|
||||
font-size: 11px;
|
||||
color: #a3ab98;
|
||||
padding-top: 3px;
|
||||
}
|
||||
.step-list strong {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.step-list li div > span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #8a9380;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.event-panel {
|
||||
padding: 28px 0;
|
||||
}
|
||||
.event-list li {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid #e4e4d9;
|
||||
}
|
||||
.event-list time {
|
||||
color: #9ca18f;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.feedback {
|
||||
padding: 15px 18px;
|
||||
border: 1px solid #d7dfca;
|
||||
background: #edf0e3;
|
||||
color: #70815d;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.feedback.error {
|
||||
background: #f2eae1;
|
||||
color: #8e5b3a;
|
||||
border-color: #e3cfb9;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.connect-page {
|
||||
gap: 35px;
|
||||
padding: 40px;
|
||||
}
|
||||
.connect-intro h1 {
|
||||
font-size: 42px;
|
||||
}
|
||||
.workbench {
|
||||
grid-template-columns: 175px 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
padding: 25px 15px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 0 26px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px 26px;
|
||||
}
|
||||
.task-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.page-number {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.connect-page {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 35px 22px;
|
||||
gap: 26px;
|
||||
}
|
||||
.connect-intro h1 {
|
||||
font-size: 34px;
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
.connect-intro > p:last-child {
|
||||
display: none;
|
||||
}
|
||||
.connect-form {
|
||||
padding: 28px;
|
||||
}
|
||||
.workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 18px 22px;
|
||||
display: block;
|
||||
}
|
||||
.brand {
|
||||
margin: 0 0 17px;
|
||||
}
|
||||
.nav-caption,
|
||||
.sidebar-note,
|
||||
.text-button {
|
||||
display: none;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
}
|
||||
nav a {
|
||||
gap: 35px;
|
||||
}
|
||||
.topbar {
|
||||
height: 55px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
.content {
|
||||
padding: 29px 22px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
.task-search {
|
||||
padding: 19px;
|
||||
}
|
||||
.task-search > div {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.empty-state {
|
||||
padding-top: 45px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.scope-filter select {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: #59674e;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: #fffdf7;
|
||||
}
|
||||
.recent-tasks {
|
||||
margin-top: 34px;
|
||||
}
|
||||
.recent-tasks ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.recent-tasks li {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.recent-tasks li button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 21px 5px;
|
||||
text-align: left;
|
||||
}
|
||||
.recent-tasks strong {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.recent-tasks small {
|
||||
display: block;
|
||||
color: #919985;
|
||||
font-size: 11px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 23px;
|
||||
}
|
||||
.task-actions button {
|
||||
font-size: 12px;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
16
web/src/界面/反馈状态.tsx
Normal file
16
web/src/界面/反馈状态.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
export function Ui反馈状态({
|
||||
message,
|
||||
error = false,
|
||||
}: {
|
||||
message: string;
|
||||
error?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={error ? "feedback error" : "feedback"}
|
||||
role={error ? "alert" : "status"}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
web/tests/交互/退出会话.test.tsx
Normal file
37
web/tests/交互/退出会话.test.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
/** 组件级会话观察:传输替身不计真实登录旅程。 */
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Ui应用 } from "../../src/应用/应用";
|
||||
import { Ui依赖装配 } from "../../src/应用/依赖装配";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("NC-ui-session-close:退出成功卸载任务并回到连接表单", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (request: Request) => {
|
||||
const path = new URL(request.url).pathname;
|
||||
const data = path.endsWith("session")
|
||||
? request.method === "DELETE"
|
||||
? { closed: true }
|
||||
: { author_id: "author-local" }
|
||||
: [];
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<Ui依赖装配>
|
||||
<Ui应用 />
|
||||
</Ui依赖装配>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "断开会话" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "进入工作台" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "断开会话" })).toBeNull();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user