DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops

- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages
- PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs)
- Auth/RBAC/workspace isolation, credential vault (AES-256-GCM)
- Servers/SSH, Docker discovery & lifecycle, Job Center
- Websites & deployments (git provider credential clone, env, health verification)
- Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring
- Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools)
- AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals)
- Notes: Vditor WYSIWYG markdown editor, non-blocking autosave
- Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
This commit is contained in:
phptits
2026-09-10 06:35:55 +00:00
commit 0d6cd37579
209 changed files with 22783 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DevOS — Developer Work OS</title>
<style>body { margin: 0; }</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "devos-web",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "bun build index.html --outdir dist --minify --production",
"dev": "bun --hot index.html"
},
"dependencies": {
"@ant-design/icons": "^5.6.0",
"@ant-design/pro-components": "^2.8.0",
"@devos/api-client": "workspace:*",
"@devos/auth": "workspace:*",
"@devos/types": "workspace:*",
"@tanstack/react-query": "^5.62.0",
"@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-task-item": "^3.31.3",
"@tiptap/extension-task-list": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"antd": "^5.22.0",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.0",
"vditor": "^4.0.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1"
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* # API 客户端单例
* 开发模式(bun dev 端口 3000)走 http://localhost:4100,生产同源。
*/
import { ApiClient } from "@devos/api-client";
import { useAuthStore } from "../stores/auth";
/** # 开发模式检测:非常规端口(bun dev server)时直连 API 端口;生产走同源反代 **/
const isDev =
typeof window !== "undefined" &&
!["", "80", "443", "4100"].includes(window.location.port);
export const api = new ApiClient({
baseUrl: isDev ? "http://127.0.0.1:4100/api/v1" : "/api/v1",
getToken: () => useAuthStore.getState().token,
getWorkspaceId: () => useAuthStore.getState().activeWorkspaceId,
onUnauthorized: () => {
useAuthStore.getState().logout();
if (typeof window !== "undefined" && !window.location.hash.includes("/login")) {
window.location.href = "/login";
}
},
});
+87
View File
@@ -0,0 +1,87 @@
/**
* # 路由表
*/
import { useEffect } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuthStore } from "./stores/auth";
import { AppLayout } from "./layouts/app-layout";
import LoginPage from "./pages/login";
import DashboardPage from "./pages/dashboard";
import ServersPage from "./pages/servers";
import ServerDetailPage from "./pages/server-detail";
import ContainersPage from "./pages/containers";
import WebsitesPage from "./pages/websites";
import WebsiteDetailPage from "./pages/website-detail";
import DeploymentsPage from "./pages/deployments";
import DeploymentDetailPage from "./pages/deployment-detail";
import DomainsPage from "./pages/domains";
import DomainDetailPage from "./pages/domain-detail";
import MonitoringPage from "./pages/monitoring";
import AutomationsPage from "./pages/automations";
import AiPage from "./pages/ai";
import CredentialsPage from "./pages/credentials";
import AuditPage from "./pages/audit";
import ProjectsPage from "./pages/projects";
import TasksPage from "./pages/tasks";
import NotesPage from "./pages/notes";
import CertificatesPage from "./pages/certificates";
import JobsPage from "./pages/jobs";
import GitPage from "./pages/git";
import RepositoriesPage from "./pages/repositories";
import ResourcesPage from "./pages/resources";
/** # 路由守卫:未登录跳转登录页 */
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const location = useLocation();
if (!token) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
return <>{children}</>;
}
export default function App() {
const location = useLocation();
// 路由切换时滚动置顶
useEffect(() => {
window.scrollTo(0, 0);
}, [location.pathname]);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/servers/:id" element={<ServerDetailPage />} />
<Route path="/containers" element={<ContainersPage />} />
<Route path="/websites" element={<WebsitesPage />} />
<Route path="/websites/:id" element={<WebsiteDetailPage />} />
<Route path="/deployments" element={<DeploymentsPage />} />
<Route path="/deployments/:id" element={<DeploymentDetailPage />} />
<Route path="/domains" element={<DomainsPage />} />
<Route path="/domains/:id" element={<DomainDetailPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/automations" element={<AutomationsPage />} />
<Route path="/ai" element={<AiPage />} />
<Route path="/credentials" element={<CredentialsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/audit" element={<AuditPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/certificates" element={<CertificatesPage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/git" element={<GitPage />} />
<Route path="/repositories" element={<RepositoriesPage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* # 统一模态约束组件(FormModal)
* 规则:所有模态整体高度不超过可视区 90%:
* - Modal 顶部偏移 5vh,body 限高 calc(90vh - 130px) 并内部滚动;
* - 长表单通过 children 内使用 Tabs 分组排版。
*/
import { ModalForm } from "@ant-design/pro-components";
import type { ModalFormProps } from "@ant-design/pro-components";
import { Modal } from "antd";
import type { ModalProps } from "antd";
import type { ReactNode } from "react";
/** # 通用模态高度约束 props(非表单 Modal 也使用) */
export const modalFitProps: ModalProps = {
centered: false,
style: { top: "5vh", paddingBottom: 0 },
styles: { body: { maxHeight: "calc(90vh - 130px)", overflowY: "auto", paddingRight: 6 } },
};
/** # 表单模态(所有新增/编辑统一走此组件) */
export type FormModalProps = Omit<ModalFormProps<Record<string, unknown>>, "onFinish"> & {
/** # 业务表单的 onFinish(values 为各表单自定义形状;ModalForm 运行时仍传 Record) **/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFinish?: (values: any) => Promise<boolean | void> | void;
};
export function FormModal(props: FormModalProps) {
const { modalProps, width = 560, onFinish, ...rest } = props;
return (
<ModalForm<Record<string, unknown>>
width={width}
modalProps={{
destroyOnClose: true,
maskClosable: false,
...modalFitProps,
...modalProps,
styles: {
...modalFitProps.styles,
...(modalProps?.styles ?? {}),
},
}}
{...rest}
onFinish={onFinish as ModalFormProps<Record<string, unknown>>["onFinish"]}
/>
);
}
/** # 普通模态(非表单:SSH 终端、日志查看等) */
export function FitModal(props: ModalProps) {
const { styles, ...rest } = props;
return (
<Modal
{...modalFitProps}
{...rest}
styles={{ ...modalFitProps.styles, ...(styles ?? {}) }}
/>
);
}
/** # 表单分组容器:长表单用 Tabs 排版(模态内滚动) */
export { Tabs as FormTabs } from "antd";
+61
View File
@@ -0,0 +1,61 @@
/**
* # 共享小组件:状态徽标 / 风险标签 / 时间显示
*/
import { Badge, Tag, Typography } from "antd";
/** # 资源/监控状态徽标 **/
const STATUS_COLOR: Record<string, "success" | "error" | "warning" | "default" | "processing"> = {
ACTIVE: "success",
UP: "success",
running: "success",
SUCCEEDED: "success",
PENDING: "warning",
QUEUED: "processing",
RUNNING: "processing",
UNKNOWN: "default",
OFFLINE: "error",
DOWN: "error",
FAILED: "error",
ERROR: "error",
DEAD: "error",
CANCELLED: "default",
ARCHIVED: "default",
exited: "default",
};
export function StatusBadge({ status }: { status: string | undefined | null }) {
if (!status) return <Badge status="default" text="未知" />;
return <Badge status={STATUS_COLOR[status] ?? "default"} text={status} />;
}
/** # 风险等级标签 **/
const RISK_COLOR: Record<string, string> = {
LOW: "green",
MEDIUM: "gold",
HIGH: "volcano",
CRITICAL: "red",
};
export function RiskTag({ risk }: { risk: string | undefined | null }) {
if (!risk) return null;
return <Tag color={RISK_COLOR[risk] ?? "default"}>{risk}</Tag>;
}
/** # 触发器/事件类型标签 **/
export function TypeTag({ type }: { type: string }) {
return <Tag>{type}</Tag>;
}
/** # 相对时间 + 绝对时间提示 **/
export function TimeText({ time }: { time: string | null | undefined }) {
if (!time) return <Typography.Text type="secondary">-</Typography.Text>;
const d = new Date(time);
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
let rel = d.toLocaleString();
if (mins < 1) rel = "刚刚";
else if (mins < 60) rel = `${mins} 分钟前`;
else if (mins < 60 * 24) rel = `${Math.floor(mins / 60)} 小时前`;
else rel = d.toLocaleDateString();
return <Typography.Text title={d.toLocaleString()}>{rel}</Typography.Text>;
}
+326
View File
@@ -0,0 +1,326 @@
/**
* # 应用主布局:ProLayout + 工作区切换 + 全局搜索 + 通知 + 主题切换 + 用户菜单
*/
import { useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { ProLayout } from "@ant-design/pro-components";
import {
GlobalOutlined,
CloudServerOutlined,
DeploymentUnitOutlined,
HddOutlined,
AlertOutlined,
RobotOutlined,
KeyOutlined,
AuditOutlined,
DatabaseOutlined,
ClusterOutlined,
UserOutlined,
LogoutOutlined,
ThunderboltOutlined,
DashboardOutlined,
ApiOutlined,
MoonOutlined,
SunOutlined,
BellOutlined,
FolderOutlined,
CheckSquareOutlined,
ReadOutlined,
SafetyCertificateOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { Select, Dropdown, App as AntApp, Button, Input, Popover, List, Typography, Empty, Space } from "antd";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "../stores/auth";
import { useThemeStore } from "../stores/theme";
import {
authApi,
workspaceApi,
searchApi,
notificationApi,
type WorkspaceRow,
} from "../services/api";
import { TimeText } from "../components/shared";
/** # 导航菜单(Phase 2:Projects / Jobs / Tasks / Notes / Certificates / Git) **/
const MENU = {
routes: [
{ path: "/dashboard", name: "工作台", icon: <DashboardOutlined /> },
{ path: "/ai", name: "AI 助手", icon: <RobotOutlined /> },
{ path: "/projects", name: "项目", icon: <FolderOutlined /> },
{
path: "/infra",
name: "基础设施",
icon: <ClusterOutlined />,
routes: [
{ path: "/servers", name: "服务器", icon: <CloudServerOutlined /> },
{ path: "/containers", name: "容器", icon: <DeploymentUnitOutlined /> },
{ path: "/monitoring", name: "监控", icon: <AlertOutlined /> },
{ path: "/jobs", name: "任务中心", icon: <ApiOutlined /> },
],
},
{
path: "/web",
name: "网站",
icon: <GlobalOutlined />,
routes: [
{ path: "/websites", name: "网站列表", icon: <GlobalOutlined /> },
{ path: "/domains", name: "域名", icon: <HddOutlined /> },
{ path: "/certificates", name: "证书", icon: <SafetyCertificateOutlined /> },
{ path: "/deployments", name: "部署", icon: <ThunderboltOutlined /> },
],
},
{
path: "/code",
name: "代码",
icon: <ApiOutlined />,
routes: [
{ path: "/git", name: "Git 连接", icon: <ApiOutlined /> },
{ path: "/repositories", name: "仓库", icon: <DatabaseOutlined /> },
],
},
{ path: "/tasks", name: "任务", icon: <CheckSquareOutlined /> },
{ path: "/notes", name: "笔记", icon: <ReadOutlined /> },
{ path: "/resources", name: "资源目录", icon: <DatabaseOutlined /> },
{ path: "/automations", name: "自动化", icon: <ThunderboltOutlined /> },
{ path: "/credentials", name: "凭据", icon: <KeyOutlined /> },
{ path: "/audit", name: "审计与事件", icon: <AuditOutlined /> },
],
};
export function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const { message } = AntApp.useApp();
const queryClient = useQueryClient();
const { token, user, activeWorkspaceId, switchWorkspace, logout } = useAuthStore();
const { mode, toggle } = useThemeStore();
const dark = mode === "dark";
const [searchText, setSearchText] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
/** # 我的工作区列表 **/
const { data: workspaces } = useQuery({
queryKey: ["workspaces"],
queryFn: workspaceApi.list,
enabled: Boolean(token),
});
/** # 全局搜索 **/
const { data: searchResults, isFetching: searching } = useQuery({
queryKey: ["global-search", searchText],
queryFn: () => searchApi.query(searchText),
enabled: searchOpen && searchText.trim().length >= 2,
});
/** # 未读通知 **/
const { data: notifications, refetch: refetchNotifications } = useQuery({
queryKey: ["notifications"],
queryFn: notificationApi.list,
refetchInterval: 30_000,
enabled: Boolean(token),
});
const unread = (notifications ?? []).filter((n) => !n.readAt);
const selected = useMemo(() => {
const top = "/" + location.pathname.split("/")[1];
return top;
}, [location.pathname]);
/** # 切换工作区后清空缓存(服务端状态按工作区隔离) **/
const onSwitch = (wid: string) => {
switchWorkspace(wid);
queryClient.clear();
message.success("工作区已切换");
};
const gotoResult = (item: { type: string; name: string; status: string; path: string }) => {
setSearchOpen(false);
setSearchText("");
navigate(item.path);
};
const searchPanel = (
<div style={{ width: 360 }}>
<Input
autoFocus
prefix={<SearchOutlined />}
placeholder="搜索服务器 / 网站 / 域名 / 任务 / 笔记…(≥2 字符)"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
<div style={{ marginTop: 8, maxHeight: "60vh", overflowY: "auto" }}>
{searchText.trim().length < 2 ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
输入至少 2 个字符;Ctrl/⌘+K 唤起
</Typography.Text>
) : searching ? (
<Typography.Text type="secondary">搜索中…</Typography.Text>
) : (searchResults?.length ?? 0) === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="无结果" />
) : (
<List
size="small"
dataSource={searchResults}
renderItem={(item) => (
<List.Item style={{ cursor: "pointer" }} onClick={() => gotoResult(item)}>
<Space>
<Typography.Text code style={{ fontSize: 12 }}>{item.type}</Typography.Text>
<Typography.Text strong>{item.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{item.status}</Typography.Text>
</Space>
</List.Item>
)}
/>
)}
</div>
</div>
);
const notificationPanel = (
<div style={{ width: 340 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<Typography.Text strong>通知</Typography.Text>
<a
onClick={async () => {
await notificationApi.markAllRead();
void refetchNotifications();
}}
>
全部已读
</a>
</div>
<div style={{ maxHeight: "60vh", overflowY: "auto" }}>
{(notifications ?? []).length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无通知" />
) : (
<List
size="small"
dataSource={notifications}
renderItem={(n) => (
<List.Item>
<Space direction="vertical" size={0}>
<Typography.Text strong={!n.readAt}>
{n.level === "ERROR" ? "🔴" : n.level === "WARN" ? "🟡" : "🔵"} {n.title}
</Typography.Text>
{n.body && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{n.body}</Typography.Text>
)}
<TimeText time={n.createdAt} />
</Space>
</List.Item>
)}
/>
)}
</div>
</div>
);
return (
<ProLayout
title="DevOS"
logo={<ClusterOutlined style={{ fontSize: 28, color: "#4f46e5" }} />}
layout="mix"
fixSiderbar
theme={dark ? "dark" : "light"}
location={{ pathname: location.pathname }}
selectedKeys={[selected]}
menu={{ request: async () => MENU.routes as never, params: {} }}
route={MENU as never}
menuItemRender={(item, dom) => (
<a onClick={() => item.path && navigate(item.path)}>{dom}</a>
)}
avatarProps={{
icon: <UserOutlined />,
title: user?.email ?? "admin",
render: (_props, dom) => {
return (
<Dropdown
menu={{
items: [{ key: "logout", icon: <LogoutOutlined />, label: "退出登录" }],
onClick: async ({ key }) => {
if (key === "logout") {
try {
await authApi.logout();
} catch {
/* 忽略 */
}
logout();
navigate("/login");
}
},
}}
>
{dom}
</Dropdown>
);
},
}}
headerContentRender={() => (
<div style={{ padding: "0 12px", display: "flex", alignItems: "center", gap: 8 }}>
<Select
size="small"
style={{ minWidth: 170 }}
value={activeWorkspaceId ?? undefined}
onChange={onSwitch}
placeholder="选择工作区"
options={((workspaces ?? []) as WorkspaceRow[]).map((w: WorkspaceRow) => ({
value: w.id,
label: `${w.name}(${w.role})`,
}))}
/>
<Popover
content={searchPanel}
trigger={["click"]}
open={searchOpen}
onOpenChange={(o) => {
setSearchOpen(o);
if (o) queryClient.invalidateQueries({ queryKey: ["global-search"] });
}}
placement="bottomLeft"
>
<Button size="small" icon={<SearchOutlined />}>
搜索
</Button>
</Popover>
<Popover content={notificationPanel} trigger={["click"]} placement="bottomRight">
<Button
size="small"
icon={
<span style={{ position: "relative", display: "inline-flex" }}>
<BellOutlined />
{unread.length > 0 && (
<span
style={{
position: "absolute",
top: -6,
right: -8,
background: "#ff4d4f",
color: "#fff",
borderRadius: 8,
fontSize: 10,
lineHeight: "14px",
padding: "0 4px",
}}
>
{unread.length}
</span>
)}
</span>
}
/>
</Popover>
<Button
size="small"
icon={dark ? <SunOutlined /> : <MoonOutlined />}
onClick={toggle}
title={dark ? "切换到亮色" : "切换到暗色"}
/>
</div>
)}
>
<Outlet />
</ProLayout>
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* # 应用入口
* Providers:TanStack Query + antd ConfigProvider + Router
*/
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfigProvider, App as AntApp, theme as antdTheme } from "antd";
import zhCN from "antd/locale/zh_CN";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import { BrowserRouter } from "react-router-dom";
import App from "./app";
import { useThemeStore } from "./stores/theme";
dayjs.locale("zh-cn");
/** # 全局 QueryClient(服务端状态缓存) **/
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 },
},
});
/** # 主题根组件:跟随全局主题切换 antd algorithm 与背景色 */
function ThemedRoot({ children }: { children: React.ReactNode }) {
const mode = useThemeStore((s) => s.mode);
const dark = mode === "dark";
if (typeof document !== "undefined") {
document.body.style.background = dark ? "#141619" : "#f5f5f5";
document.body.style.color = dark ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.88)";
}
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { colorPrimary: "#4f46e5", borderRadius: 6 },
}}
>
{children}
</ConfigProvider>
);
}
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<ThemedRoot>
<AntApp>
<BrowserRouter>
<App />
</BrowserRouter>
</AntApp>
</ThemedRoot>
</QueryClientProvider>,
);
+469
View File
@@ -0,0 +1,469 @@
/**
* # AI 聊天(产品核心入口)— AI 时代体验标准
* - SSE 流式:执行进度实时可见、回复逐字出现、可中断
* - Markdown 渲染、结果深链跳转、复制、会话持久化(localStorage 按工作区)
* - 高危操作审批卡片;Enter 发送 / Shift+Enter 换行 / 输入法 composing 保护
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PageContainer } from "@ant-design/pro-components";
import {
App as AntApp,
Button,
Input,
Popconfirm,
Space,
Tag,
Tooltip,
Typography,
} from "antd";
import {
CheckOutlined,
CloseOutlined,
CopyOutlined,
LinkOutlined,
ReloadOutlined,
RobotOutlined,
SendOutlined,
StopOutlined,
UserOutlined,
} from "@ant-design/icons";
import Markdown from "react-markdown";
import { chatApi, aiApi, type ChatResponse, type ChatToolCall } from "../services/api";
import { useAuthStore } from "../stores/auth";
/** # 聊天消息结构 **/
interface ChatMessage {
role: "user" | "assistant";
content: string;
toolCalls?: ChatToolCall[];
approval?: { runId: string; steps: { tool: string; args: Record<string, unknown>; risk: string }[] };
refs?: { type: string; id: string; name: string }[];
engine?: string;
approved?: boolean;
streaming?: boolean;
status?: string;
ts: number;
}
/** # 能力引导卡片(首次/清空后显示) **/
const CAPABILITY_CARDS: { title: string; color: string; prompts: string[] }[] = [
{ title: "🖥️ 基础设施", color: "#4f46e5", prompts: ["列出我的服务器", "重启容器 devos-redis"] },
{ title: "🌐 网站与部署", color: "#0ea5e9", prompts: ["列出网站", "网站健康检查"] },
{ title: "✅ 任务与笔记", color: "#10b981", prompts: ["创建任务:检查证书到期情况", "列出任务"] },
{ title: "📄 网页总结", color: "#f59e0b", prompts: ["总结 https://example.com 保存到笔记"] },
];
/** # ref 类型 → 路由 **/
const REF_PATH: Record<string, (id: string) => string> = {
TASK: () => "/tasks",
NOTE: () => "/notes",
WEBSITE: (id) => `/websites/${id}`,
SERVER: (id) => `/servers/${id}`,
DOMAIN: (id) => `/domains/${id}`,
};
export default function AiChatPage() {
const { message, modal } = AntApp.useApp();
const navigate = useNavigate();
const email = useAuthStore((s) => s.user?.email ?? "");
const wsId = useAuthStore((s) => s.activeWorkspaceId) ?? "default";
const storageKey = `devos-chat-${wsId}`;
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
const saved = localStorage.getItem(storageKey);
if (saved) return JSON.parse(saved) as ChatMessage[];
} catch {
/* ignore */
}
return [];
});
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
/** # 最新消息引用(SSE 回调读取,避免闭包旧值) **/
const messagesRef = useRef<ChatMessage[]>(messages);
messagesRef.current = messages;
/** # 会话持久化 **/
useEffect(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60)));
} catch {
/* 配额满忽略 */
}
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, storageKey]);
/** # IME 输入法保护 + Enter 发送 **/
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return;
if ((e.nativeEvent as KeyboardEvent).isComposing) return; // 中文输入法选词
if (e.shiftKey) return; // Shift+Enter 换行
e.preventDefault();
void send(input);
};
/** # 发送(SSE 流式) **/
const send = async (text: string) => {
const msg = text.trim();
if (!msg || streaming) return;
const history = messages.slice(-8).map((m) => ({ role: m.role, content: m.content }));
const userMsg: ChatMessage = { role: "user", content: msg, ts: Date.now() };
const assistantIdx = messages.length + 1;
setMessages((prev) => [
...prev,
userMsg,
{ role: "assistant", content: "", streaming: true, toolCalls: [], ts: Date.now() },
]);
setInput("");
setStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
const updateAssistant = (patch: Partial<ChatMessage>) => {
setMessages((prev) => {
const next = [...prev];
const i = next.findIndex((m, k) => k >= assistantIdx && m.role === "assistant" && m.streaming);
const target = i >= 0 ? i : next.length - 1;
next[target] = { ...next[target], ...patch };
return next;
});
};
try {
await chatApi.sendStream(msg, history, (event, data) => {
const d = data as { text?: string; tool?: string; ok?: boolean };
if (event === "status") {
updateAssistant({ status: d.text });
} else if (event === "tool") {
updateAssistant({
status: undefined,
toolCalls: [
...((messagesRef.current[assistantIdx]?.toolCalls ?? []) as ChatToolCall[]),
{ tool: d.tool ?? "", args: {}, ok: Boolean(d.ok), summary: "" },
],
});
} else if (event === "reply_token") {
updateAssistant({ status: undefined, content: (messagesRef.current[assistantIdx]?.content ?? "") + (d.text ?? "") });
} else if (event === "done") {
const final = data as ChatResponse;
updateAssistant({
content: final.reply,
toolCalls: final.toolCalls,
approval: final.approval,
refs: final.refs,
engine: final.engine,
streaming: false,
status: undefined,
});
}
});
} catch (e) {
if (!(e instanceof DOMException && e.name === "AbortError")) {
updateAssistant({ content: `出错了:${e instanceof Error ? e.message : "未知错误"}`, streaming: false });
} else {
updateAssistant({ content: (messagesRef.current[assistantIdx]?.content ?? "") + "\n(已中断)", streaming: false });
}
} finally {
setStreaming(false);
abortRef.current = null;
}
};
/** # 审批执行 **/
const approve = async (runId: string) => {
try {
await aiApi.approve(runId);
markApproved(runId, "\n\n✅ 已批准,正在执行…");
message.success("已批准,执行中");
setTimeout(async () => {
try {
const run = await aiApi.get(runId);
setMessages((prev) =>
prev.map((m) =>
m.approval?.runId === runId
? {
...m,
toolCalls: (run.toolCalls ?? []).map((c) => ({
tool: c.tool,
args: (c.args ?? {}) as Record<string, unknown>,
ok: c.ok,
summary: c.error ?? "完成",
})),
content: m.content.replace("正在执行…", `执行完成:${run.status}`),
}
: m,
),
);
} catch {
/* ignore */
}
}, 6000);
} catch (e) {
message.error(e instanceof Error ? e.message : "审批失败(需要 ADMIN 角色)");
}
};
const reject = (runId: string) => {
void aiApi.reject(runId).catch(() => undefined);
markApproved(runId, "\n\n🚫 已取消");
};
const markApproved = (runId: string, text: string) => {
setMessages((prev) =>
prev.map((m) => (m.approval?.runId === runId ? { ...m, approved: true, content: m.content + text } : m)),
);
};
const copyMessage = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
message.success("已复制");
} catch {
message.error("复制失败");
}
};
const empty = messages.length === 0;
return (
<PageContainer
ghost
title="AI 运维助手"
subTitle="对话式管理你的全部基础设施"
extra={[
<Popconfirm
key="reset"
title="清空当前对话?"
onConfirm={() => {
setMessages([]);
localStorage.removeItem(storageKey);
}}
>
<Tooltip title="新对话">
<Button icon={<ReloadOutlined />} />
</Tooltip>
</Popconfirm>,
]}
>
<div
style={{
display: "flex",
flexDirection: "column",
height: "calc(100vh - 190px)",
border: "1px solid rgba(128,128,128,0.2)",
borderRadius: 10,
background: "rgba(128,128,128,0.04)",
}}
>
{/* 消息区 */}
<div style={{ flex: 1, overflowY: "auto", padding: "16px 20px" }}>
{empty && (
<div style={{ textAlign: "center", marginTop: 24 }}>
<RobotOutlined style={{ fontSize: 44, color: "#4f46e5" }} />
<Typography.Title level={4} style={{ marginTop: 12 }}>
你好 {email.split("@")[0]},要我做什么?
</Typography.Title>
<Typography.Text type="secondary">
查询与管理服务器、网站、域名、任务、笔记;高危操作会先征求你的确认
</Typography.Text>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 12,
marginTop: 24,
maxWidth: 860,
marginLeft: "auto",
marginRight: "auto",
}}
>
{CAPABILITY_CARDS.map((c) => (
<div
key={c.title}
style={{
border: "1px solid rgba(128,128,128,0.25)",
borderRadius: 10,
padding: 14,
textAlign: "left",
}}
>
<Typography.Text strong>{c.title}</Typography.Text>
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 6 }}>
{c.prompts.map((prompt) => (
<Button key={prompt} size="small" onClick={() => void send(prompt)}>
{prompt}
</Button>
))}
</div>
</div>
))}
</div>
</div>
)}
{messages.map((m, idx) => (
<div
key={idx}
style={{ display: "flex", justifyContent: m.role === "user" ? "flex-end" : "flex-start", marginBottom: 14 }}
>
<div
style={{
maxWidth: "80%",
background: m.role === "user" ? "#4f46e5" : "rgba(128,128,128,0.1)",
color: m.role === "user" ? "#fff" : undefined,
borderRadius: 12,
padding: "10px 14px",
}}
>
<div style={{ marginBottom: 4, fontSize: 11, opacity: 0.65, display: "flex", justifyContent: "space-between", gap: 16 }}>
<span>
{m.role === "user" ? (
<Space size={4}>
<UserOutlined /> {email}
</Space>
) : (
<Space size={4}>
<RobotOutlined /> DevOS 助手
</Space>
)}
</span>
{m.role === "assistant" && !m.streaming && m.content && (
<a onClick={() => void copyMessage(m.content)} style={{ color: "inherit" }}>
<CopyOutlined />
</a>
)}
</div>
{m.role === "assistant" ? (
<div className="chat-markdown">
<Markdown>{m.content || (m.streaming ? "" : "(无回复)")}</Markdown>
{m.streaming && m.status && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Spin size="small" /> {m.status}
</Typography.Text>
)}
{m.streaming && !m.status && m.content && <span className="chat-caret">▍</span>}
</div>
) : (
<div style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{m.content}</div>
)}
{/* 工具调用 chips */}
{m.toolCalls && m.toolCalls.length > 0 && (
<div style={{ marginTop: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
执行步骤:
</Typography.Text>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{m.toolCalls.map((c, i) => (
<Tooltip key={i} title={JSON.stringify(c.args)}>
<Tag style={{ fontSize: 11 }} color={c.ok ? "green" : "red"}>
🔧 {c.tool} {c.ok ? "✓" : "✗"}
</Tag>
</Tooltip>
))}
</div>
</div>
)}
{/* 引用跳转 */}
{m.refs && m.refs.length > 0 && (
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 6 }}>
{m.refs.map((r) => {
const path = REF_PATH[r.type]?.(r.id) ?? "/";
return (
<Button key={r.id} size="small" icon={<LinkOutlined />} onClick={() => navigate(path)}>
打开{r.name}
</Button>
);
})}
</div>
)}
{/* 审批卡片 */}
{m.approval && !m.approved && (
<Alert
style={{ marginTop: 10 }}
type="warning"
showIcon
message="等待你的确认"
description={
<div>
<div style={{ marginBottom: 8 }}>
{m.approval.steps.map((s, i) => (
<div key={i}>
<Tag color="volcano">{s.risk}</Tag>
<Typography.Text code>{s.tool}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{" "}
{JSON.stringify(s.args).slice(0, 120)}
</Typography.Text>
</div>
))}
</div>
<Space>
<Popconfirm title="确认执行该高危操作?" onConfirm={() => approve(m.approval!.runId)}>
<Button size="small" type="primary" icon={<CheckOutlined />}>
确认执行
</Button>
</Popconfirm>
<Button size="small" icon={<CloseOutlined />} onClick={() => reject(m.approval!.runId)}>
取消
</Button>
</Space>
</div>
}
/>
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* 输入区 */}
<div style={{ padding: "0 20px 16px" }}>
<Space.Compact style={{ width: "100%" }}>
<Input.TextArea
placeholder="告诉我要做什么…(Enter 发送,Shift+Enter 换行)"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
autoSize={{ minRows: 1, maxRows: 4 }}
maxLength={4000}
autoFocus
/>
{streaming ? (
<Button
danger
icon={<StopOutlined />}
onClick={() => abortRef.current?.abort()}
style={{ height: "auto" }}
>
停止
</Button>
) : (
<Button type="primary" icon={<SendOutlined />} onClick={() => void send(input)} style={{ height: "auto" }}>
发送
</Button>
)}
</Space.Compact>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
高危操作会先出审批卡片;所有 AI 操作与人工操作走同一权限与审计。
</Typography.Text>
</div>
</div>
</PageContainer>
);
}
/** # 局部引用 **/
import { Alert, Spin } from "antd";
+87
View File
@@ -0,0 +1,87 @@
/**
* # 审计与事件页:Tab 切换审计日志 / 领域事件
*/
import { useState } from "react";
import {
PageContainer,
ProTable,
type ProColumns,
} from "@ant-design/pro-components";
import { Space, Tabs, Typography } from "antd";
import { auditApi, eventApi } from "../services/api";
import type { AuditItem, EventItem } from "../services/api";
import { RiskTag, StatusBadge, TimeText } from "../components/shared";
const auditColumns: ProColumns<AuditItem>[] = [
{ title: "时间", dataIndex: "createdAt", width: 160, render: (_, r) => <TimeText time={r.createdAt} /> },
{ title: "主体", width: 90, render: (_, r) => <Typography.Text code>{r.actorType}</Typography.Text> },
{ title: "动作", dataIndex: "action", render: (_, r) => <code>{r.action}</code> },
{ title: "资源", width: 140, render: (_, r) => `${r.resourceType ?? "-"} ${r.resourceId ? `#${r.resourceId.slice(0, 8)}` : ""}` },
{ title: "风险", dataIndex: "riskLevel", width: 90, render: (_, r) => RiskTag({ risk: r.riskLevel }) },
{ title: "结果", dataIndex: "status", width: 110, render: (_, r) => <StatusBadge status={r.status === "SUCCESS" ? "ACTIVE" : r.status === "FAILURE" ? "ERROR" : "UNKNOWN"} /> },
{ title: "请求 ID", dataIndex: "requestId", width: 130, ellipsis: true },
];
const eventColumns: ProColumns<EventItem>[] = [
{ title: "时间", dataIndex: "createdAt", width: 160, render: (_, r) => <TimeText time={r.createdAt} /> },
{ title: "事件", dataIndex: "eventType", render: (_, r) => <Typography.Text code>{r.eventType}</Typography.Text> },
{
title: "载荷",
dataIndex: "payload",
ellipsis: true,
render: (_, r) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{JSON.stringify(r.payload).slice(0, 120)}
</Typography.Text>
),
},
{ title: "关联 ID", dataIndex: "correlationId", width: 130, ellipsis: true, render: (_, r) => r.correlationId ?? "-" },
];
export default function AuditPage() {
const [tab, setTab] = useState("audit");
return (
<PageContainer>
<Tabs
activeKey={tab}
onChange={setTab}
items={[
{
key: "audit",
label: "审计日志",
children: (
<ProTable<AuditItem>
scroll={{ x: "max-content" }}
rowKey="id"
columns={auditColumns}
search={false}
pagination={{ pageSize: 20, defaultPageSize: 20 }}
request={async (params) => {
const res = await auditApi.list({ page: params.current ?? 1, pageSize: params.pageSize ?? 20 });
return { data: res.items, success: true, total: res.total };
}}
/>
),
},
{
key: "events",
label: "领域事件",
children: (
<ProTable<EventItem>
scroll={{ x: "max-content" }}
rowKey="id"
columns={eventColumns}
search={false}
pagination={{ pageSize: 20, defaultPageSize: 20 }}
request={async (params) => {
const res = await eventApi.list({ page: params.current ?? 1, pageSize: params.pageSize ?? 20 });
return { data: res.items, success: true, total: res.total };
}}
/>
),
},
]}
/>
</PageContainer>
);
}
+257
View File
@@ -0,0 +1,257 @@
/**
* # 自动化页:Trigger→Condition→Action 工作流管理
*/
import { useRef, useState } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
ProFormSwitch,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Popconfirm, Space, Switch, Tabs, Tag, Typography } from "antd";
import { FormModal } from "../components/form-modal";
import { PlusOutlined, CaretRightOutlined, DeleteOutlined } from "@ant-design/icons";
import { automationApi } from "../services/api";
import type { AutomationView } from "../services/api";
import { TimeText } from "../components/shared";
export default function AutomationsPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const columns: ProColumns<AutomationView>[] = [
{ title: "名称", dataIndex: "name" },
{
title: "触发器",
dataIndex: "triggerType",
render: (_, r) => {
const cfg = r.triggerConfig as { cron?: string; eventType?: string };
return (
<Space direction="vertical" size={0}>
<Tag color="processing">{r.triggerType}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.triggerType === "CRON" ? cfg.cron : r.triggerType === "EVENT" ? cfg.eventType : "-"}
</Typography.Text>
</Space>
);
},
},
{
title: "动作",
dataIndex: "actions",
render: (_, r) => (
<Space wrap>
{(r.actions ?? []).map((a, i) => (
<Tag key={i}>{String(a.type)}</Tag>
))}
</Space>
),
},
{ title: "最近触发", dataIndex: "lastTriggeredAt", render: (_, r) => <TimeText time={r.lastTriggeredAt} /> },
{
title: "启用",
dataIndex: "enabled",
render: (_, record) => (
<Switch
checked={record.enabled}
onChange={async (checked) => {
await (checked ? automationApi.enable(record.id) : automationApi.disable(record.id));
message.success(checked ? "已启用" : "已禁用");
actionRef.current?.reload();
}}
/>
),
},
{
title: "操作",
valueType: "option",
width: 180,
render: (_, record) => [
<a
key="run"
style={{ color: "#4f46e5" }}
onClick={async () => {
try {
const res = await automationApi.run(record.id);
message.info(`执行完成:${res.status}`);
actionRef.current?.reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "执行失败");
}
}}
>
<CaretRightOutlined /> 立即执行
</a>,
<Popconfirm
key="del"
title="删除自动化?"
onConfirm={async () => {
await automationApi.delete(record.id);
message.success("已删除");
actionRef.current?.reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /> 删除</a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProTable<AutomationView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="自动化工作流"
search={false}
pagination={false}
request={async () => {
const items = await automationApi.list();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<CreateAutomationButton key="create" onDone={() => actionRef.current?.reload()} />,
]}
/>
</PageContainer>
);
}
/** # 创建自动化(表单化 Trigger/Action;条件为 JSON 高级用法) */
function CreateAutomationButton({ onDone }: { onDone: () => void }) {
const { message } = AntApp.useApp();
const [open, setOpen] = useState(false as boolean);
return (
<>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
创建自动化
</Button>
<FormModal
title="创建自动化"
width={640}
open={open}
modalProps={{ onCancel: () => setOpen(false) }}
onFinish={async (values) => {
// 组装 Trigger → Conditions → Actions
const triggerType = values.triggerType;
const triggerConfig: Record<string, string> = {};
if (triggerType === "CRON") triggerConfig.cron = String(values.cron ?? "");
if (triggerType === "EVENT") triggerConfig.eventType = String(values.eventType ?? "");
if (triggerType === "WEBHOOK") triggerConfig.secret = String(values.webhookSecret ?? "");
const actions = [
{
type: values.actionType,
args:
values.actionType === "WEBHOOK"
? { url: values.webhookUrl }
: values.actionType === "DEPLOY_WEBSITE"
? { websiteId: values.websiteId }
: values.actionType === "CONTAINER_RESTART"
? { containerId: values.containerId }
: values.actionType === "TASK_CREATE"
? { title: values.taskTitle ?? "automation task" }
: values.actionType === "NOTE_CREATE"
? { title: values.noteTitle ?? "automation note" }
: values.actionType === "NOTIFICATION_SEND"
? { title: values.notifyTitle ?? "automation notification", body: values.logMessage ?? "" }
: { message: values.logMessage ?? "automation log" },
timeoutMs: 30000,
retries: 0,
},
];
try {
await automationApi.create({
name: values.name,
triggerType,
triggerConfig,
conditions: [],
actions,
enabled: values.enabled ?? false,
});
message.success("自动化已创建");
onDone();
setOpen(false);
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
>
<Tabs
items={[
{
key: "trigger",
label: "触发器",
forceRender: true,
children: (
<>
<ProFormText name="name" label="名称" rules={[{ required: true }]} />
<ProFormSelect
name="triggerType"
label="触发器"
initialValue="MANUAL"
rules={[{ required: true }]}
options={[
{ label: "手动", value: "MANUAL" },
{ label: "Cron 定时", value: "CRON" },
{ label: "事件", value: "EVENT" },
{ label: "Webhook(HMAC 签名)", value: "WEBHOOK" },
]}
/>
<ProFormText name="cron" label="Cron 表达式(5 字段)" placeholder="*/5 * * * *" />
<ProFormText name="eventType" label="事件类型" placeholder="deployment.failed" />
<ProFormText
name="webhookSecret"
label="Webhook Secret"
placeholder="调用需携带 X-Signature: t=<ts>,v1=<hmac(secret, ts.body)>"
/>
</>
),
},
{
key: "actions",
label: "动作",
forceRender: true,
children: (
<>
<ProFormSelect
name="actionType"
label="动作"
initialValue="LOG"
rules={[{ required: true }]}
options={[
{ label: "记录日志", value: "LOG" },
{ label: "调用 Webhook", value: "WEBHOOK" },
{ label: "部署网站", value: "DEPLOY_WEBSITE" },
{ label: "重启容器", value: "CONTAINER_RESTART" },
{ label: "创建任务", value: "TASK_CREATE" },
{ label: "创建笔记", value: "NOTE_CREATE" },
{ label: "发送通知", value: "NOTIFICATION_SEND" },
]}
/>
<ProFormText name="logMessage" label="日志内容(LOG / 通知正文)" placeholder="automation fired" />
<ProFormText name="webhookUrl" label="Webhook URL(WEBHOOK)" placeholder="https://..." />
<ProFormText name="websiteId" label="网站 ID(DEPLOY_WEBSITE)" />
<ProFormText name="containerId" label="容器 ID(CONTAINER_RESTART)" />
<ProFormText name="taskTitle" label="任务标题(TASK_CREATE)" />
<ProFormText name="noteTitle" label="笔记标题(NOTE_CREATE)" />
<ProFormText name="notifyTitle" label="通知标题(NOTIFICATION_SEND)" />
<ProFormSwitch name="enabled" label="创建后立即启用" />
</>
),
},
]}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
条件(Condition)与多步动作可在 API 中以 JSON 配置;事件触发示例:deployment.failed → NOTIFICATION_SEND。
</Typography.Text>
</FormModal>
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* # 证书页(Phase E SSL 基础):TLS 探针 + 到期监控
*/
import { useRef, useState } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Popconfirm, Tag, Typography } from "antd";
import { PlusOutlined, DeleteOutlined, ReloadOutlined } from "@ant-design/icons";
import { certificateApi } from "../services/api";
import type { CertificateView } from "../services/api";
import { StatusBadge, TimeText } from "../components/shared";
import { FormModal } from "../components/form-modal";
const CERT_STATUS: Record<string, "success" | "error" | "warning" | "default" | "processing"> = {
ACTIVE: "success",
PENDING: "processing",
EXPIRED: "error",
ERROR: "warning",
};
export default function CertificatesPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [createOpen, setCreateOpen] = useState(false);
const columns: ProColumns<CertificateView>[] = [
{ title: "域名", dataIndex: "domain", render: (_, r) => <Typography.Text strong>{r.domain}</Typography.Text> },
{ title: "状态", dataIndex: "status", width: 100, render: (_, r) => <StatusBadge status={r.status} /> },
{ title: "签发者", dataIndex: "issuer", render: (_, r) => r.issuer ?? "-" },
{
title: "到期",
dataIndex: "notAfter",
render: (_, r) =>
r.notAfter ? (
<Space_>
<span>{new Date(r.notAfter).toLocaleDateString()}</span>
{r.expiringSoon && <Tag color="volcano">即将到期</Tag>}
</Space_>
) : (
"-"
),
},
{ title: "剩余天数", dataIndex: "daysRemaining", width: 100, render: (_, r) => r.daysRemaining ?? "-" },
{ title: "检查时间", dataIndex: "lastCheckedAt", render: (_, r) => <TimeText time={r.lastCheckedAt} /> },
{
title: "操作",
valueType: "option",
width: 180,
render: (_, record) => [
<a
key="recheck"
onClick={async () => {
try {
const res = await certificateApi.recheck(record.id);
message.info(`探测完成:${res.status}(剩余 ${res.daysRemaining ?? "?"} 天)`);
actionRef.current?.reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "探测失败");
}
}}
>
<ReloadOutlined /> 重新探测
</a>,
<Popconfirm
key="del"
title="删除证书记录?"
onConfirm={async () => {
await certificateApi.delete(record.id);
message.success("已删除");
actionRef.current?.reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /></a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProTable<CertificateView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="TLS 证书"
search={false}
pagination={false}
request={async () => {
const items = await certificateApi.list();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
添加证书
</Button>,
]}
/>
<FormModal
title="添加证书(自动 TLS 探测)"
width={480}
open={createOpen}
modalProps={{ onCancel: () => setCreateOpen(false) }}
onFinish={async (values) => {
try {
const cert = await certificateApi.create(values as never);
message.success(`已添加:${cert.status}${cert.daysRemaining !== null ? `(剩余 ${cert.daysRemaining} 天)` : ""}`);
setCreateOpen(false);
actionRef.current?.reload();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "添加失败");
return false;
}
}}
>
<ProFormText
name="domain"
label="域名(443 端口探测)"
rules={[{ required: true }]}
placeholder="blog.example.com"
/>
</FormModal>
</PageContainer>
);
}
/** # 局部 Space */
import { Space as Space_ } from "antd";
+68
View File
@@ -0,0 +1,68 @@
/**
* # 容器列表页(全工作区)
*/
import { useRef } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
} from "@ant-design/pro-components";
import { App as AntApp, Popconfirm } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import { containerApi } from "../services/api";
import type { ContainerView } from "@devos/types";
import { StatusBadge } from "../components/shared";
export default function ContainersPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const columns: ProColumns<ContainerView>[] = [
{ title: "名称", dataIndex: "name" },
{ title: "镜像", dataIndex: "image", ellipsis: true },
{ title: "状态", dataIndex: "state", render: (_, r) => <StatusBadge status={r.state} /> },
{ title: "容器 ID", dataIndex: "containerId", copyable: true },
{
title: "操作",
valueType: "option",
render: (_, record) => [
<Popconfirm
key="restart"
title={`重启容器 ${record.name}?(HIGH 风险操作)`}
onConfirm={async () => {
try {
await containerApi.restart(record.id);
message.success("容器已重启");
actionRef.current?.reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}
>
<a style={{ color: "#fa8c16" }}>
<ReloadOutlined /> 重启
</a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProTable<ContainerView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="容器"
search={false}
pagination={{ pageSize: 20 }}
request={async () => {
const items = await containerApi.list();
return { data: items, success: true, total: items.length };
}}
/>
</PageContainer>
);
}
+121
View File
@@ -0,0 +1,121 @@
/**
* # 凭据管理页(仅元数据;明文永不可见)
*/
import { useRef } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ModalForm,
ProFormText,
ProFormSelect,
ProFormTextArea,
} from "@ant-design/pro-components";
import { Alert, App as AntApp, Button, Popconfirm } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { credentialApi } from "../services/api";
import type { CredentialView } from "../services/api";
import { TimeText } from "../components/shared";
export default function CredentialsPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const columns: ProColumns<CredentialView>[] = [
{ title: "名称", dataIndex: "name" },
{ title: "类型", dataIndex: "type", render: (_, r) => <code>{r.type}</code> },
{ title: "提供商", dataIndex: "provider", render: (_, r) => r.provider ?? "-" },
{ title: "最近使用", dataIndex: "lastUsedAt", render: (_, r) => <TimeText time={r.lastUsedAt} /> },
{ title: "创建时间", dataIndex: "createdAt", render: (_, r) => <TimeText time={r.createdAt} /> },
{
title: "操作",
valueType: "option",
width: 90,
render: (_, record) => [
<Popconfirm
key="del"
title="删除凭据?(HIGH 风险;引用它的服务器/DNS 将无法连接)"
onConfirm={async () => {
await credentialApi.delete(record.id);
message.success("已删除");
actionRef.current?.reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /> 删除</a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<Alert
type="info"
showIcon
message="凭据保险库"
description="所有机密以 AES-256-GCM 信封加密存储(密钥来自 ENCRYPTION_KEY,AAD 绑定工作区)。任何 API/UI/MCP 均无法读取明文;日志与审计自动脱敏。"
style={{ marginBottom: 16 }}
/>
<ProTable<CredentialView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="凭据"
search={false}
pagination={false}
request={async () => {
const items = await credentialApi.list();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<ModalForm
key="create"
title="创建凭据"
trigger={
<Button type="primary" icon={<PlusOutlined />}>
创建凭据
</Button>
}
width={480}
modalProps={{ destroyOnClose: true }}
onFinish={async (values: { name: string; type: string; provider?: string; secret: string }) => {
try {
await credentialApi.create(values);
message.success("凭据已加密保存");
actionRef.current?.reload();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
>
<ProFormText name="name" label="名称" rules={[{ required: true }]} />
<ProFormSelect
name="type"
label="类型"
rules={[{ required: true }]}
options={[
{ label: "SSH 私钥", value: "SSH_KEY" },
{ label: "SSH 密码", value: "SSH_PASSWORD" },
{ label: "API Token", value: "API_TOKEN" },
{ label: "DNS 提供商凭据", value: "DNS_PROVIDER" },
{ label: "Git Token", value: "GIT_TOKEN" },
]}
/>
<ProFormText name="provider" label="提供商(可选)" placeholder="cloudflare / github / ..." />
<ProFormTextArea
name="secret"
label="机密内容"
placeholder="私钥 PEM / 密码 / Token"
fieldProps={{ rows: 4, autoComplete: "off" }}
rules={[{ required: true }]}
/>
</ModalForm>,
]}
/>
</PageContainer>
);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* # 工作台(Dashboard)
* 运行指挥中心:AI 命令入口 + 基础设施健康 + 项目/活动流
*/
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
PageContainer,
ProCard,
StatisticCard,
} from "@ant-design/pro-components";
import { Input, List, Space, Typography, Empty, Spin, Alert } from "antd";
import {
CloudServerOutlined,
GlobalOutlined,
DeploymentUnitOutlined,
AlertOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useState } from "react";
import { dashboardApi, aiApi } from "../services/api";
import { StatusBadge, TimeText } from "../components/shared";
import { useAuthStore } from "../stores/auth";
import { App as AntApp } from "antd";
export default function DashboardPage() {
const navigate = useNavigate();
const { message, modal } = AntApp.useApp();
const email = useAuthStore((s) => s.user?.email ?? "");
const [objective, setObjective] = useState("");
const [sending, setSending] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["dashboard"],
queryFn: dashboardApi.get,
refetchInterval: 30_000,
});
/** # AI 命令入口:创建 AI 运行并跳转 */
const askAi = async () => {
if (!objective.trim()) return;
setSending(true);
try {
const run = await aiApi.create(objective.trim());
message.info(`AI 任务已创建(风险 ${run.riskLevel}),已跳转详情`);
setObjective("");
navigate(`/ai`);
} catch (error) {
message.error(error instanceof Error ? error.message : "创建失败");
} finally {
setSending(false);
}
};
if (isLoading) return <Spin style={{ display: "block", margin: "120px auto" }} />;
if (error) return <Alert type="error" message="加载失败" description={String(error)} style={{ margin: 24 }} />;
const hour = new Date().getHours();
const greeting = hour < 12 ? "早上好" : hour < 18 ? "下午好" : "晚上好";
return (
<PageContainer
header={{ title: `${greeting},${email}` }}
content={
<Input.Search
size="large"
placeholder="AI:你想做什么?例如「检查服务器状态」或「部署我的网站」"
value={objective}
onChange={(e) => setObjective(e.target.value)}
onSearch={askAi}
enterButton="执行"
loading={sending}
style={{ maxWidth: 640 }}
/>
}
>
<Space direction="vertical" style={{ width: "100%" }} size={16}>
<Space wrap size={16}>
<StatisticCard
statistic={{ title: "服务器", value: data?.serverCount ?? 0, prefix: <CloudServerOutlined /> }}
style={{ minWidth: 180 }}
onClick={() => navigate("/servers")}
/>
<StatisticCard
statistic={{ title: "容器", value: data?.containerCount ?? 0, prefix: <DeploymentUnitOutlined /> }}
style={{ minWidth: 180 }}
onClick={() => navigate("/containers")}
/>
<StatisticCard
statistic={{ title: "网站", value: data?.websiteCount ?? 0, prefix: <GlobalOutlined /> }}
style={{ minWidth: 180 }}
onClick={() => navigate("/websites")}
/>
<StatisticCard
statistic={{ title: "域名", value: data?.domainCount ?? 0 }}
style={{ minWidth: 180 }}
onClick={() => navigate("/domains")}
/>
<StatisticCard
statistic={{
title: "监控",
value: `${data?.monitorsUp ?? 0} 正常 / ${data?.monitorsDown ?? 0} 故障`,
prefix: <AlertOutlined />,
}}
style={{ minWidth: 220 }}
onClick={() => navigate("/monitoring")}
/>
</Space>
<ProCard gutter={16} ghost style={{ minHeight: 300 }}>
<ProCard colSpan={{ md: 12 }} title="最近部署" headerBordered>
{(data?.recentDeployments?.length ?? 0) === 0 ? (
<Empty description="暂无部署" />
) : (
<List
dataSource={data?.recentDeployments}
renderItem={(d: { id: string; status: string; websiteName?: string; trigger: string; createdAt: string }) => (
<List.Item
style={{ cursor: "pointer" }}
onClick={() => navigate(`/deployments/${d.id}`)}
>
<Space>
<StatusBadge status={d.status} />
<Typography.Text strong>{d.websiteName ?? d.id.slice(0, 8)}</Typography.Text>
<Typography.Text type="secondary">{d.trigger}</Typography.Text>
<TimeText time={d.createdAt} />
</Space>
</List.Item>
)}
/>
)}
</ProCard>
<ProCard colSpan={{ md: 12 }} title="最近活动(事件)" headerBordered>
{(data?.recentEvents?.length ?? 0) === 0 ? (
<Empty description="暂无活动" />
) : (
<List
dataSource={data?.recentEvents}
renderItem={(e: { id: string; eventType: string; createdAt: string }) => (
<List.Item>
<Space direction="vertical" size={0}>
<Typography.Text code>{e.eventType}</Typography.Text>
<Typography.Text type="secondary">
<TimeText time={e.createdAt} />
</Typography.Text>
</Space>
</List.Item>
)}
/>
)}
</ProCard>
</ProCard>
</Space>
</PageContainer>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* # 部署详情页:进度 + 实时日志(5s 轮询)
*/
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PageContainer, ProCard, ProDescriptions } from "@ant-design/pro-components";
import { Alert, App as AntApp, Button, Progress, Space } from "antd";
import { ArrowLeftOutlined, StopOutlined, ReloadOutlined } from "@ant-design/icons";
import { deploymentApi } from "../services/api";
import { StatusBadge, TimeText } from "../components/shared";
export default function DeploymentDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { message } = AntApp.useApp();
const queryClient = useQueryClient();
const { data, error, isLoading } = useQuery({
queryKey: ["deployment", id],
queryFn: () => deploymentApi.get(id!),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "RUNNING" || status === "QUEUED" ? 3000 : false;
},
});
if (isLoading) return <Alert type="info" message="加载中..." style={{ margin: 24 }} />;
if (error || !data) return <Alert type="error" message="部署不存在或加载失败" style={{ margin: 24 }} />;
const active = data.status === "RUNNING" || data.status === "QUEUED";
return (
<PageContainer
header={{
title: `部署 ${data.id.slice(0, 8)}`,
extra: [
<Button key="back" onClick={() => navigate("/deployments")}>返回</Button>,
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={() => queryClient.invalidateQueries({ queryKey: ["deployment", id] })}
>
刷新
</Button>,
<Button
key="cancel"
danger
icon={<StopOutlined />}
disabled={!active}
onClick={async () => {
try {
await deploymentApi.cancel(id!);
message.success(active ? "已请求取消(步骤间生效)" : "已取消");
queryClient.invalidateQueries({ queryKey: ["deployment", id] });
} catch (e) {
message.error(e instanceof Error ? e.message : "取消失败");
}
}}
>
取消
</Button>,
],
}}
>
<Space direction="vertical" style={{ width: "100%" }} size={16}>
<ProCard>
<ProDescriptions column={3} size="small">
<ProDescriptions.Item label="状态">
<StatusBadge status={data.status} />
</ProDescriptions.Item>
<ProDescriptions.Item label="网站">{data.website?.name ?? "-"}</ProDescriptions.Item>
<ProDescriptions.Item label="触发">{data.trigger}</ProDescriptions.Item>
<ProDescriptions.Item label="commit">{data.commit ?? "-"}</ProDescriptions.Item>
<ProDescriptions.Item label="创建">
<TimeText time={data.createdAt} />
</ProDescriptions.Item>
<ProDescriptions.Item label="完成">
<TimeText time={data.finishedAt} />
</ProDescriptions.Item>
</ProDescriptions>
<div style={{ marginTop: 16, maxWidth: 480 }}>
<Progress percent={data.progress} status={data.status === "FAILED" ? "exception" : active ? "active" : "normal"} />
</div>
{data.error && <Alert type="error" message="部署失败" description={data.error} style={{ marginTop: 12 }} />}
</ProCard>
<ProCard title="执行日志" headerBordered>
<pre
style={{
maxHeight: 420,
overflow: "auto",
background: "#0b1021",
color: "#d1e4ff",
padding: 12,
borderRadius: 6,
fontSize: 12,
whiteSpace: "pre-wrap",
}}
>
{(data.logs ?? []).map((l: { line: string }) => l.line).join("\n") || "暂无日志"}
</pre>
</ProCard>
</Space>
</PageContainer>
);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* # 部署列表页
*/
import { useRef } from "react";
import { Link } from "react-router-dom";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
} from "@ant-design/pro-components";
import { App as AntApp } from "antd";
import { deploymentApi } from "../services/api";
import type { DeploymentFullView } from "../services/api";
import { StatusBadge, TimeText } from "../components/shared";
export default function DeploymentsPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const columns: ProColumns<DeploymentFullView>[] = [
{
title: "部署",
dataIndex: "id",
render: (_, r) => <Link to={`/deployments/${r.id}`}>{r.id.slice(0, 8)}</Link>,
},
{ title: "网站", dataIndex: ["website", "name"] },
{ title: "状态", dataIndex: "status", render: (_, r) => <StatusBadge status={r.status} /> },
{ title: "进度", dataIndex: "progress", render: (_, r) => `${r.progress}%` },
{ title: "commit", dataIndex: "commit", render: (_, r) => r.commit ?? "-" },
{ title: "触发", dataIndex: "trigger", render: (_, r) => <Tag2 v={r.trigger} /> },
{ title: "创建时间", dataIndex: "createdAt", render: (_, r) => <TimeText time={r.createdAt} /> },
{
title: "操作",
valueType: "option",
render: (_, record) => [
<a
key="cancel"
style={{ color: "#fa8c16" }}
onClick={async () => {
try {
await deploymentApi.cancel(record.id);
message.success("已请求取消");
actionRef.current?.reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "取消失败");
}
}}
>
取消
</a>,
],
},
];
return (
<PageContainer>
<ProTable<DeploymentFullView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="部署记录"
search={false}
pagination={{ pageSize: 20 }}
request={async () => {
const items = await deploymentApi.list();
return { data: items, success: true, total: items.length };
}}
/>
</PageContainer>
);
}
/** # 触发方式标签 **/
import { Tag } from "antd";
function Tag2({ v }: { v: string }) {
return <Tag>{v}</Tag>;
}
+143
View File
@@ -0,0 +1,143 @@
/**
* # 域名详情页:DNS 记录读取与写入
*/
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
PageContainer,
ProTable,
ProDescriptions,
type ProColumns,
ModalForm,
ProFormText,
ProFormSelect,
ProFormDigit,
ProFormSwitch,
} from "@ant-design/pro-components";
import { Alert, App as AntApp, Button, Popconfirm, Space } from "antd";
import { ArrowLeftOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { domainApi } from "../services/api";
import type { DomainRecordView } from "@devos/types";
export default function DomainDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { message } = AntApp.useApp();
const queryClient = useQueryClient();
const [recordOpen, setRecordOpen] = useState(false);
const { data: domain, isLoading, error } = useQuery({
queryKey: ["domain", id],
queryFn: () => domainApi.get(id!),
});
const { data: records, refetch, isFetching } = useQuery({
queryKey: ["domain-records", id],
queryFn: () => domainApi.records(id!),
});
if (isLoading) return <Alert type="info" message="加载中..." style={{ margin: 24 }} />;
if (error || !domain) return <Alert type="error" message="域名不存在或加载失败" style={{ margin: 24 }} />;
const columns: ProColumns<DomainRecordView>[] = [
{ title: "类型", dataIndex: "type", width: 80 },
{ title: "名称", dataIndex: "name" },
{ title: "值", dataIndex: "value", ellipsis: true },
{ title: "TTL", dataIndex: "ttl", width: 80 },
{ title: "代理", dataIndex: "proxied", render: (_, r) => (r.proxied ? "是" : "否"), width: 70 },
{
title: "操作",
valueType: "option",
width: 80,
render: (_, record) => [
<Popconfirm
key="del"
title="删除该记录?"
onConfirm={async () => {
try {
await domainApi.deleteRecord(id!, (record as { localId?: string }).localId ?? record.id);
message.success("已删除");
void refetch();
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}
>
<a style={{ color: "#ff4d4f" }}>删除</a>
</Popconfirm>,
],
},
];
return (
<PageContainer
header={{
title: domain.name,
extra: [
<Button key="back" onClick={() => navigate("/domains")}>返回</Button>,
<Button key="refresh" icon={<ReloadOutlined />} loading={isFetching} onClick={() => void refetch()}>
刷新记录
</Button>,
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setRecordOpen(true)}>
添加记录
</Button>,
],
}}
>
<Space direction="vertical" style={{ width: "100%" }} size={16}>
<ProCard_>
<ProDescriptions column={3} size="small">
<ProDescriptions.Item label="提供商">
{domain.provider}
</ProDescriptions.Item>
<ProDescriptions.Item label="记录数">{records?.length ?? 0}</ProDescriptions.Item>
</ProDescriptions>
</ProCard_>
<ProTable<DomainRecordView>
rowKey={(r) => (r as { localId?: string }).localId ?? r.id ?? Math.random().toString()}
columns={columns}
dataSource={records}
search={false}
pagination={false}
options={false}
headerTitle={`DNS 记录(${domain.provider === "CLOUDFLARE" ? "实时拉取自 Cloudflare" : "手动管理"})`}
/>
</Space>
<FormModal
title="添加/更新 DNS 记录"
width={520}
open={recordOpen}
modalProps={{ destroyOnClose: true, onCancel: () => setRecordOpen(false) }}
onFinish={async (values: { type: string; name: string; value: string; ttl?: number; proxied?: boolean }) => {
try {
await domainApi.upsertRecord(id!, values);
message.success("记录已写入");
setRecordOpen(false);
void refetch();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
return false;
}
}}
>
<ProFormSelect
name="type"
label="类型"
rules={[{ required: true }]}
options={["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"].map((t) => ({ label: t, value: t }))}
/>
<ProFormText name="name" label="名称" placeholder="blog.example.com 或 @" rules={[{ required: true }]} />
<ProFormText name="value" label="值" rules={[{ required: true }]} />
<ProFormDigit name="ttl" label="TTL(秒)" initialValue={300} min={60} max={86400} />
<ProFormSwitch name="proxied" label="Cloudflare 代理" />
</FormModal>
</PageContainer>
);
}
/** # 包装 ProCard(避免与上方 import 混淆的别名) **/
import { ProCard as ProCard_ } from "@ant-design/pro-components";
import { FormModal } from "../components/form-modal";
+142
View File
@@ -0,0 +1,142 @@
/**
* # 域名列表页
*/
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Popconfirm, Typography } from "antd";
import { FormModal } from "../components/form-modal";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { credentialApi, domainApi, type CredentialView } from "../services/api";
import type { DomainView } from "@devos/types";
export default function DomainsPage() {
const navigate = useNavigate();
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [createOpen, setCreateOpen] = useState(false);
const columns: ProColumns<DomainView>[] = [
{
title: "域名",
dataIndex: "name",
render: (_, record) => (
<Typography.Link onClick={() => navigate(`/domains/${record.id}`)}>{record.name}</Typography.Link>
),
},
{ title: "DNS 提供商", dataIndex: "provider", render: (_, r) => <Typography.Text code>{r.provider}</Typography.Text> },
{ title: "创建时间", dataIndex: "createdAt", render: (_, r) => new Date(r.createdAt).toLocaleString() },
{
title: "操作",
valueType: "option",
render: (_, record) => [
<Popconfirm
key="del"
title="删除域名?(HIGH 风险;不影响提供商侧记录)"
onConfirm={async () => {
await domainApi.delete(record.id);
message.success("已删除");
actionRef.current?.reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /> 删除</a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProTable<DomainView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="域名"
search={false}
pagination={false}
request={async () => {
const items = await domainApi.list();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
添加域名
</Button>,
]}
/>
<CreateDomainModal
open={createOpen}
onCancel={() => setCreateOpen(false)}
onDone={() => {
setCreateOpen(false);
actionRef.current?.reload();
}}
/>
</PageContainer>
);
}
function CreateDomainModal({ open, onCancel, onDone }: { open: boolean; onCancel: () => void; onDone: () => void }) {
const { message } = AntApp.useApp();
const [credentials, setCredentials] = useState<CredentialView[]>([]);
return (
<FormModal
title="添加域名"
width={520}
open={open}
modalProps={{ destroyOnClose: true, onCancel }}
onFinish={async (values: { name: string; provider: string; credentialId?: string }) => {
try {
await domainApi.create(values);
message.success("域名已创建");
onDone();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
onOpenChange={async (o) => {
if (o) {
try {
setCredentials(await credentialApi.list());
} catch {
/* ignore */
}
}
if (!o) onCancel();
}}
>
<ProFormText
name="name"
label="域名"
placeholder="example.com"
rules={[{ required: true, pattern: /^(?!-)[a-zA-Z0-9-]{1,63}(\.[a-zA-Z0-9-]{1,63})+$/, message: "域名格式不正确" }]}
/>
<ProFormSelect
name="provider"
label="DNS 提供商"
initialValue="MANUAL"
options={[
{ label: "手动管理(本地记录)", value: "MANUAL" },
{ label: "Cloudflare", value: "CLOUDFLARE" },
]}
/>
<ProFormSelect
name="credentialId"
label="Cloudflare API Token 凭据(CLOUDFLARE 必填)"
options={credentials
.filter((c) => c.type === "DNS_PROVIDER" || c.type === "API_TOKEN")
.map((c) => ({ label: c.name, value: c.id }))}
/>
</FormModal>
);
}
+205
View File
@@ -0,0 +1,205 @@
/**
* # Git 连接页(Phase D):GitHub/GitLab/Gitea 连接管理 + 远端仓库浏览导入
*/
import { useRef, useState } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
} from "@ant-design/pro-components";
import { App as AntApp, Button, List, Popconfirm, Typography } from "antd";
import { PlusOutlined, DeleteOutlined, CloudDownloadOutlined } from "@ant-design/icons";
import { credentialApi, gitApi, type GitConnectionView } from "../services/api";
import type { CredentialView } from "../services/api";
import { TimeText } from "../components/shared";
import { FormModal } from "../components/form-modal";
interface RemoteRepo {
fullName: string;
cloneUrl: string;
defaultBranch: string;
private: boolean;
description?: string;
}
export default function GitPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [createOpen, setCreateOpen] = useState(false);
const [browseConn, setBrowseConn] = useState<GitConnectionView | null>(null);
const [remoteRepos, setRemoteRepos] = useState<RemoteRepo[]>([]);
const [loadingRemote, setLoadingRemote] = useState(false);
const loadRemote = async (conn: GitConnectionView) => {
setBrowseConn(conn);
setLoadingRemote(true);
try {
setRemoteRepos(await gitApi.listRemoteRepos(conn.id));
} catch (e) {
message.error(e instanceof Error ? e.message : "拉取失败");
setRemoteRepos([]);
} finally {
setLoadingRemote(false);
}
};
const columns: ProColumns<GitConnectionView>[] = [
{ title: "名称", dataIndex: "name" },
{ title: "提供商", dataIndex: "provider", render: (_, r) => <Typography.Text code>{r.provider}</Typography.Text> },
{ title: "账号", dataIndex: "account", render: (_, r) => r.account ?? "-" },
{ title: "API 基址", dataIndex: "apiBase", ellipsis: true, render: (_, r) => r.apiBase ?? "官方" },
{ title: "创建时间", dataIndex: "createdAt", render: (_, r) => <TimeText time={r.createdAt} /> },
{
title: "操作",
valueType: "option",
width: 220,
render: (_, record) => [
<a key="browse" onClick={() => void loadRemote(record)}>
<CloudDownloadOutlined /> 浏览仓库
</a>,
<Popconfirm
key="del"
title="删除连接?(引用它的仓库将失去凭据克隆能力)"
onConfirm={async () => {
await gitApi.deleteConnection(record.id);
message.success("已删除");
actionRef.current?.reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /> 删除</a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProTable<GitConnectionView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="Git Provider 连接"
search={false}
pagination={false}
request={async () => {
const items = await gitApi.listConnections();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
添加连接
</Button>,
]}
/>
<FormModal
title="添加 Git 连接"
width={560}
open={createOpen}
modalProps={{ onCancel: () => setCreateOpen(false) }}
onFinish={async (values) => {
try {
const conn = await gitApi.createConnection(values as never);
message.success(`连接成功(账号 ${conn.account ?? ""})`);
setCreateOpen(false);
actionRef.current?.reload();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
onOpenChange={async (o) => {
if (!o) setCreateOpen(false);
}}
>
<ProFormText name="name" label="名称" rules={[{ required: true }]} placeholder="my-github" />
<ProFormSelect
name="provider"
label="提供商"
initialValue="GITHUB"
rules={[{ required: true }]}
options={[
{ label: "GitHub", value: "GITHUB" },
{ label: "GitLab", value: "GITLAB" },
{ label: "Gitea", value: "GITEA" },
]}
/>
<ProFormText
name="apiBase"
label="API 基址(自建实例必填)"
placeholder="https://git.example.com/api/v4"
/>
<CredentialSelect />
</FormModal>
<FormModal
title={`远端仓库(${browseConn?.name ?? ""})`}
width={680}
open={Boolean(browseConn)}
modalProps={{ footer: null, onCancel: () => setBrowseConn(null) }}
onFinish={async () => true}
submitter={false}
>
{loadingRemote ? (
<Typography.Text type="secondary">加载中…</Typography.Text>
) : (
<List
size="small"
dataSource={remoteRepos}
renderItem={(r) => (
<List.Item
actions={[
<a
key="import"
onClick={async () => {
if (!browseConn) return;
try {
await gitApi.importRepo(browseConn.id, r.fullName);
message.success(`已导入 ${r.fullName}`);
} catch (e) {
message.error(e instanceof Error ? e.message : "导入失败");
}
}}
>
导入
</a>,
]}
>
<List.Item.Meta
title={r.fullName}
description={`${r.description ?? r.cloneUrl} · 默认分支 ${r.defaultBranch}${r.private ? " · 私有" : ""}`}
/>
</List.Item>
)}
/>
)}
</FormModal>
</PageContainer>
);
}
/** # DNS/Token 凭据选择(创建连接时) */
function CredentialSelect() {
const [credentials, setCredentials] = useState<CredentialView[]>([]);
return (
<ProFormSelect
name="credentialId"
label="Token 凭据"
placeholder="选择已登记的 API Token 凭据"
rules={[{ required: true, message: "请选择凭据" }]}
options={credentials.map((c) => ({ label: `${c.name}(${c.type})`, value: c.id }))}
request={async () => {
const list = await credentialApi.list();
setCredentials(list);
return list
.filter((c) => c.type === "API_TOKEN" || c.type === "GIT_TOKEN")
.map((c) => ({ label: `${c.name}(${c.type})`, value: c.id }));
}}
/>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* # 任务中心(Phase C):统一长任务视图 + 重试/取消
*/
import { useRef, useState } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Drawer, Select, Space, Typography } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import { jobApi } from "../services/api";
import type { JobView } from "../services/api";
import { StatusBadge, TimeText } from "../components/shared";
export default function JobsPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [detail, setDetail] = useState<JobView | null>(null);
const reload = () => actionRef.current?.reload();
const columns: ProColumns<JobView>[] = [
{ title: "任务", dataIndex: "id", width: 110, render: (_, r) => (
<Typography.Link onClick={() => setDetail(r)}>{r.id.slice(0, 8)}</Typography.Link>
) },
{ title: "类型", dataIndex: "type", render: (_, r) => <Typography.Text code>{r.type}</Typography.Text> },
{ title: "状态", dataIndex: "status", width: 110, render: (_, r) => <StatusBadge status={r.status} /> },
{ title: "尝试", width: 80, render: (_, r) => `${r.attempts}/${r.maxAttempts}` },
{ title: "创建", dataIndex: "createdAt", width: 130, render: (_, r) => <TimeText time={r.createdAt} /> },
{ title: "完成", dataIndex: "finishedAt", width: 130, render: (_, r) => <TimeText time={r.finishedAt} /> },
{
title: "操作",
valueType: "option",
width: 150,
render: (_, record) => [
["FAILED", "DEAD", "CANCELLED"].includes(record.status) && (
<a
key="retry"
onClick={async () => {
try {
const res = await jobApi.retry(record.id);
message.success(`已重新入队:${res.newJobId.slice(0, 8)}`);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "重试失败");
}
}}
>
重试
</a>
),
record.status === "PENDING" && (
<a
key="cancel"
style={{ color: "#fa8c16" }}
onClick={async () => {
try {
await jobApi.cancel(record.id);
message.success("已取消");
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "取消失败");
}
}}
>
取消
</a>
),
].filter(Boolean),
},
];
return (
<PageContainer
extra={[
<Select
key="filter"
allowClear
placeholder="按状态筛选"
style={{ minWidth: 150 }}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
reload();
}}
options={["PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "DEAD"].map((s) => ({ value: s, label: s }))}
/>,
]}
>
<ProTable<JobView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="任务中心(异步 Job)"
search={false}
pagination={{ pageSize: 20 }}
request={async (params) => {
const res = await jobApi.list({ status: statusFilter, page: params.current ?? 1, pageSize: params.pageSize ?? 20 });
return { data: res.items, success: true, total: res.total };
}}
/>
<Drawer title={`任务 ${detail?.id.slice(0, 8) ?? ""}`} open={Boolean(detail)} onClose={() => setDetail(null)} width={560}>
{detail && (
<Space direction="vertical" style={{ width: "100%" }}>
<Typography.Text code>{detail.type}</Typography.Text>
<StatusBadge status={detail.status} />
<Typography.Paragraph>
<Typography.Text type="secondary">Payload:</Typography.Text>
<pre style={{ background: "rgba(128,128,128,0.1)", padding: 8, borderRadius: 6, fontSize: 12 }}>
{JSON.stringify(detail.payload, null, 2)}
</pre>
</Typography.Paragraph>
{detail.error && (
<Typography.Paragraph type="danger">错误:{detail.error}</Typography.Paragraph>
)}
{detail.result && (
<Typography.Paragraph>
<Typography.Text type="secondary">Result:</Typography.Text>
<pre style={{ background: "rgba(128,128,128,0.1)", padding: 8, borderRadius: 6, fontSize: 12 }}>
{JSON.stringify(detail.result, null, 2)}
</pre>
</Typography.Paragraph>
)}
<Button icon={<ReloadOutlined />} onClick={reload}>刷新</Button>
</Space>
)}
</Drawer>
</PageContainer>
);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* # 登录 / 注册页
*/
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { ProCard, ProFormText, LoginForm } from "@ant-design/pro-components";
import { App as AntApp, Tabs } from "antd";
import { ClusterOutlined } from "@ant-design/icons";
import { authApi } from "../services/api";
import { useAuthStore } from "../stores/auth";
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { message } = AntApp.useApp();
const setAuth = useAuthStore((s) => s.setAuth);
const [tab, setTab] = useState("login");
const from = (location.state as { from?: string } | undefined)?.from ?? "/dashboard";
const handleLogin = async (values: { email: string; password: string }) => {
try {
const res = await authApi.login(values.email, values.password);
setAuth(res.token, res.user);
message.success("登录成功");
navigate(from, { replace: true });
} catch (error) {
message.error(error instanceof Error ? error.message : "登录失败");
}
};
const handleRegister = async (values: { email: string; password: string; name?: string }) => {
try {
const res = await authApi.register(values.email, values.password, values.name);
setAuth(res.token, res.user);
message.success("注册成功,已创建默认工作区");
navigate("/dashboard", { replace: true });
} catch (error) {
message.error(error instanceof Error ? error.message : "注册失败");
}
};
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #eef2ff 0%, #f8fafc 100%)",
}}
>
<ProCard
style={{
width: "100%",
maxWidth: 420,
margin: "0 16px",
boxShadow: "0 8px 32px rgba(0,0,0,0.08)",
}}
>
<div style={{ textAlign: "center", marginBottom: 16 }}>
<ClusterOutlined style={{ fontSize: 40, color: "#4f46e5" }} />
<h1 style={{ margin: "8px 0 0", fontSize: 22 }}>DevOS</h1>
<p style={{ color: "#888", margin: 0 }}>Developer Work OS</p>
</div>
<Tabs
activeKey={tab}
onChange={setTab}
centered
items={[
{ key: "login", label: "登录", children: null },
{ key: "register", label: "注册", children: null },
]}
/>
{tab === "login" ? (
<LoginForm onFinish={handleLogin} submitter={{ searchConfig: { submitText: "登 录" }, submitButtonProps: { htmlType: "submit" } }}>
<ProFormText
name="email"
label="邮箱"
placeholder="admin"
rules={[{ required: true, message: "请输入邮箱" }]}
/>
<ProFormText.Password
name="password"
label="密码"
placeholder="密码"
rules={[{ required: true, message: "请输入密码" }]}
/>
</LoginForm>
) : (
<LoginForm onFinish={handleRegister} submitter={{ searchConfig: { submitText: "注 册" }, submitButtonProps: { htmlType: "submit" } }}>
<ProFormText name="name" label="名称" placeholder="可选" />
<ProFormText
name="email"
label="邮箱"
placeholder="you@example.com"
rules={[{ required: true, message: "请输入邮箱" }]}
/>
<ProFormText.Password
name="password"
label="密码"
placeholder="至少 8 位"
rules={[
{ required: true, message: "请输入密码" },
{ min: 8, message: "至少 8 位" },
]}
/>
</LoginForm>
)}
</ProCard>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
/**
* # 监控页:监控列表 + 历史结果抽屉
*/
import { useRef, useState } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
ProFormDigit,
ProFormSwitch,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Drawer, Space, Switch, Typography, Empty } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { useQuery } from "@tanstack/react-query";
import { monitorApi, websiteApi, type MonitorView } from "../services/api";
import { StatusBadge, TimeText } from "../components/shared";
import { FormModal } from "../components/form-modal";
export default function MonitoringPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [createOpen, setCreateOpen] = useState(false);
const [detail, setDetail] = useState<MonitorView | null>(null);
const { data: monitorDetail } = useQuery({
queryKey: ["monitor", detail?.id],
queryFn: () => monitorApi.get(detail!.id),
enabled: Boolean(detail),
refetchInterval: 30_000,
});
const columns: ProColumns<MonitorView>[] = [
{ title: "名称", dataIndex: "name" },
{ title: "类型", dataIndex: "kind", width: 80 },
{ title: "目标", dataIndex: "target", ellipsis: true },
{ title: "间隔", dataIndex: "intervalSec", render: (_, r) => `${r.intervalSec}s`, width: 80 },
{
title: "状态",
dataIndex: "lastStatus",
render: (_, r) => <StatusBadge status={r.lastStatus} />,
width: 100,
},
{
title: "最近检查",
dataIndex: "lastCheckedAt",
render: (_, r) => <TimeText time={r.lastCheckedAt} />,
},
{
title: "启用",
dataIndex: "enabled",
width: 70,
render: (_, record) => (
<Switch
checked={record.enabled}
onChange={async (checked) => {
await monitorApi.update(record.id, checked);
message.success(checked ? "已启用" : "已禁用");
actionRef.current?.reload();
}}
/>
),
},
{
title: "操作",
valueType: "option",
width: 130,
render: (_, record) => [
<a key="history" onClick={() => setDetail(record)}>历史</a>,
<a
key="del"
style={{ color: "#ff4d4f" }}
onClick={async () => {
await monitorApi.delete(record.id);
message.success("已删除");
actionRef.current?.reload();
}}
>
<DeleteOutlined />
</a>,
],
},
];
return (
<PageContainer>
<ProTable<MonitorView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="健康检查"
search={false}
pagination={false}
request={async () => {
const items = await monitorApi.list();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
添加监控
</Button>,
]}
/>
<CreateMonitorModal
open={createOpen}
onCancel={() => setCreateOpen(false)}
onDone={() => {
setCreateOpen(false);
actionRef.current?.reload();
}}
/>
<Drawer
title={`监控历史:${detail?.name ?? ""}`}
open={Boolean(detail)}
onClose={() => setDetail(null)}
width={480}
>
{monitorDetail?.results?.length ? (
<Space direction="vertical" style={{ width: "100%" }}>
{monitorDetail.results.map((r: { ok: boolean; status?: string; latencyMs?: number; error?: string; createdAt: string }, i: number) => (
<Typography.Text key={i} type={r.ok ? undefined : "danger"}>
<StatusBadge status={r.ok ? "UP" : "DOWN"} /> {r.status ?? ""} {r.latencyMs ? `${r.latencyMs}ms` : ""} {r.error ?? ""} · <TimeText time={r.createdAt} />
</Typography.Text>
))}
</Space>
) : (
<Empty description="暂无检查结果" />
)}
</Drawer>
</PageContainer>
);
}
function CreateMonitorModal({ open, onCancel, onDone }: { open: boolean; onCancel: () => void; onDone: () => void }) {
const { message } = AntApp.useApp();
const [websites, setWebsites] = useState<{ label: string; value: string }[]>([]);
return (
<FormModal
title="添加监控"
width={520}
open={open}
modalProps={{ destroyOnClose: true, onCancel }}
onFinish={async (values) => {
try {
await monitorApi.create(values);
message.success("监控已创建,调度器将在下一个周期执行检查");
onDone();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
onOpenChange={async (o) => {
if (o) {
try {
const list = await websiteApi.list();
setWebsites(list.map((w: { id: string; name: string }) => ({ label: w.name, value: w.id })));
} catch {
/* ignore */
}
}
if (!o) onCancel();
}}
>
<ProFormText name="name" label="名称" rules={[{ required: true }]} />
<ProFormSelect
name="kind"
label="类型"
initialValue="HTTP"
options={[
{ label: "HTTP", value: "HTTP" },
{ label: "TCP(host:port)", value: "TCP" },
]}
/>
<ProFormText
name="target"
label="目标"
placeholder="https://blog.example.com/ 或 1.2.3.4:22"
rules={[{ required: true }]}
/>
<ProFormSelect name="websiteId" label="关联网站(可选)" options={websites} />
<ProFormDigit name="intervalSec" label="间隔(秒)" initialValue={60} min={30} max={3600} />
<ProFormDigit name="timeoutMs" label="超时(毫秒)" initialValue={10000} />
<ProFormSwitch name="enabled" label="启用" initialValue={true} />
</FormModal>
);
}
+475
View File
@@ -0,0 +1,475 @@
/**
* # 笔记(v3):BlockNote 编辑器(主流 Notion 风格开源编辑器)
* - 斜杠菜单(/)、选中浮动工具栏、拖拽手柄、任务列表 —— 主流交互开箱即用
* - Markdown 进出(blocksToMarkdownLossy / tryParseMarkdownToBlocks),存储为 markdown
* - **非阻塞保存**:保存请求后台执行(队列 + 防抖),切换笔记零卡顿
* - AI 助手:续写 / 总结 / 润色 / 翻译 / 大纲 / 起标题(结果以 markdown 回填)
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PageContainer } from "@ant-design/pro-components";
import {
App as AntApp,
Button,
Dropdown,
Empty,
Input,
List,
Popconfirm,
Space,
Spin,
Tag,
Tooltip,
Typography,
} from "antd";
import {
DeleteOutlined,
PlusOutlined,
RobotOutlined,
SearchOutlined,
StarFilled,
StarOutlined,
} from "@ant-design/icons";
import Vditor from "vditor";
import "vditor/dist/index.css";
import dayjs from "dayjs";
import { noteApi } from "../services/api";
import type { NoteListItem } from "../services/api";
import { useThemeStore } from "../stores/theme";
/** # AI 动作定义:replaces = true 整篇替换 / false 追加到末尾 / "title" 设标题 **/
const AI_ACTIONS: { key: string; label: string; replaces: boolean | "title" }[] = [
{ key: "continue", label: "✍️ 续写(追加到末尾)", replaces: false },
{ key: "summarize", label: "📌 总结要点", replaces: true },
{ key: "improve", label: "✨ 润色全文", replaces: true },
{ key: "translate_en", label: "🇬🇧 翻译成英文", replaces: true },
{ key: "translate_zh", label: "🇨🇳 翻译成中文", replaces: true },
{ key: "outline", label: "🗂️ 生成大纲", replaces: true },
{ key: "title", label: "🏷️ 起标题", replaces: "title" },
];
/** # 遗留 HTML 内容 → 纯文本(旧数据兼容) **/
function legacyToMarkdown(content: string): string {
const trimmed = (content ?? "").trim();
if (!trimmed) return "";
if (trimmed.startsWith("<")) {
return trimmed
.replace(/<\/(p|h[1-6]|li|div)>/gi, "\n\n")
.replace(/<li[^>]*>/gi, "- ")
.replace(/<[^>]+>/g, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
return trimmed;
}
export default function NotesPage() {
const { message } = AntApp.useApp();
const queryClient = useQueryClient();
const { mode } = useThemeStore();
const dark = mode === "dark";
const [activeId, setActiveId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [title, setTitle] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [pinned, setPinned] = useState(false);
const [saveState, setSaveState] = useState<"idle" | "pending" | "saving" | "saved">("idle");
const [aiRunning, setAiRunning] = useState<string | null>(null);
const [editorReady, setEditorReadyState] = useState(false);
/** # 编辑器就绪前暂存待加载内容 **/
const pendingContentRef = useRef<string | null>(null);
const composingRef = useRef(false);
/** # Vditor 编辑器实例 **/
const editorRef = useRef<Vditor | null>(null);
const mountRef = useRef<HTMLDivElement | null>(null);
/** # 非阻塞保存队列:防抖 + 后台执行 + 完成后再补发 **/
const pendingRef = useRef<{ id: string; dto: Parameters<typeof noteApi.update>[1] } | null>(null);
const inFlightRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadingRef = useRef(false);
const flush = useCallback(() => {
if (inFlightRef.current) return;
const job = pendingRef.current;
if (!job) return;
pendingRef.current = null;
inFlightRef.current = true;
setSaveState("saving");
noteApi
.update(job.id, job.dto)
.then(() => setSaveState("saved"))
.catch((e) => {
setSaveState("idle");
message.error(`保存失败:${e instanceof Error ? e.message : e}`);
})
.finally(() => {
inFlightRef.current = false;
if (pendingRef.current) flush();
});
}, [message]);
const queueSave = useCallback(
(immediate = false) => {
if (!activeId) return;
pendingRef.current = {
id: activeId,
dto: { title: title || "无标题", tags, pinned },
};
setSaveState("pending");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(flush, immediate ? 0 : 900);
},
[activeId, title, tags, pinned, flush],
);
/** # 编辑器内容变更(Vditor input 回调;排除程序化加载) **/
const onVditorInput = useCallback(() => {
if (loadingRef.current) return;
setSaveState("pending");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(flush, 900);
}, [flush]);
/** # 初始化 Vditor(主题切换时重建) **/
useEffect(() => {
if (!mountRef.current || editorRef.current) return;
const vditor = new Vditor(mountRef.current, {
mode: "wysiwyg",
theme: dark ? "dark" : "classic",
height: "52vh",
placeholder: "开始写点什么…(/ 唤起菜单;支持 Markdown 快捷输入)",
cache: { enable: false },
value: "",
after: () => {
setEditorReadyState(true);
if (pendingContentRef.current !== null) {
vditor.setValue(pendingContentRef.current);
pendingContentRef.current = null;
}
},
input: () => onVditorInput(),
counter: { enable: true },
toolbar: [
"headings", "bold", "italic", "strike", "|",
"list", "ordered-list", "check", "outdent", "indent", "|",
"quote", "code", "inline-code", "link", "table", "|",
"undo", "redo", "|", "edit-mode", "export",
],
});
editorRef.current = vditor;
return () => {
vditor.destroy();
editorRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dark]);
/** # 笔记列表 **/
const { data: notes, isLoading: listLoading } = useQuery({
queryKey: ["notes", search],
queryFn: () => noteApi.list(search ? { q: search } : {}),
});
/** # 打开笔记:后台补发未存内容 + 立即加载(零等待) **/
const openNote = useCallback(
async (item: NoteListItem) => {
if (item.id === activeId) return;
// 未保存内容后台补发,不阻塞切换
if (pendingRef.current || saveState === "pending") queueSave(true);
loadingRef.current = true;
setActiveId(item.id);
setTitle(item.title);
setTags((item.tags ?? []) as string[]);
setPinned(item.pinned);
setSaveState("idle");
try {
const full = await noteApi.get(item.id);
setTitle(full.title);
setTags((full.tags ?? []) as string[]);
setPinned(full.pinned);
if (editorRef.current && editorReady) {
editorRef.current.setValue(legacyToMarkdown(full.content));
} else {
pendingContentRef.current = legacyToMarkdown(full.content);
}
} catch (e) {
message.error(e instanceof Error ? e.message : "加载失败");
} finally {
loadingRef.current = false;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeId, editorReady, saveState, queueSave],
);
/** # 新建笔记 **/
const createNote = async () => {
try {
const created = await noteApi.create({ title: "无标题", content: "", tags: [] });
queryClient.invalidateQueries({ queryKey: ["notes"] });
setActiveId(created.id);
setTitle(created.title);
setTags([]);
setPinned(false);
if (editorRef.current && editorReady) editorRef.current.setValue("");
else pendingContentRef.current = "";
message.success("已创建");
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
}
};
/** # 删除笔记 **/
const removeNote = async (id: string) => {
pendingRef.current = null;
await noteApi.delete(id);
if (activeId === id) {
setActiveId(null);
if (editorRef.current && editorReady) editorRef.current.setValue("");
setTitle("");
}
queryClient.invalidateQueries({ queryKey: ["notes"] });
message.success("已删除");
};
/** # 切换置顶 **/
const togglePin = async () => {
if (!activeId) return;
const next = !pinned;
setPinned(next);
pendingRef.current = { id: activeId, dto: { pinned: next } };
flush();
queryClient.invalidateQueries({ queryKey: ["notes"] });
};
/** # AI 动作 **/
const runAi = async (action: string, replaces: boolean | "title") => {
if (!activeId) return;
setAiRunning(action);
try {
const res = await noteApi.ai(activeId, { action });
if (replaces === "title") {
const t = res.result.replace(/^["'#\s]+|["'\s]+$/g, "").slice(0, 60);
setTitle(t);
pendingRef.current = { id: activeId!, dto: { title: t } };
flush();
message.success("标题已生成");
return;
}
if (editorRef.current && editorReady) {
if (replaces) editorRef.current.setValue(res.result);
else editorRef.current.insertValue("\n\n" + res.result);
}
queueSave(true);
message.success(replaces ? "已替换全文" : "已追加到末尾");
} catch (e) {
message.error(e instanceof Error ? e.message : "AI 执行失败");
} finally {
setAiRunning(null);
}
};
const sidebarItems = notes ?? [];
return (
<PageContainer ghost>
<div style={{ display: "flex", gap: 16, minHeight: "calc(100vh - 160px)" }}>
{/* ── 左栏 ── */}
<div
style={{
width: 290,
flexShrink: 0,
border: "1px solid rgba(128,128,128,0.2)",
borderRadius: 8,
padding: 12,
display: "flex",
flexDirection: "column",
maxHeight: "calc(100vh - 160px)",
}}
>
<Space.Compact style={{ marginBottom: 8 }}>
<Input
prefix={<SearchOutlined />}
placeholder="搜索标题与正文"
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
/>
<Tooltip title="新建笔记">
<Button type="primary" icon={<PlusOutlined />} onClick={createNote} />
</Tooltip>
</Space.Compact>
<div style={{ flex: 1, overflowY: "auto" }}>
{listLoading ? (
<Spin style={{ display: "block", margin: "40px auto" }} />
) : sidebarItems.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有笔记" />
) : (
<List
size="small"
dataSource={sidebarItems}
renderItem={(item) => (
<List.Item
style={{
cursor: "pointer",
padding: "6px 8px",
borderRadius: 6,
background: item.id === activeId ? "rgba(79,70,229,0.12)" : undefined,
}}
onClick={() => void openNote(item)}
actions={[
<Popconfirm
key="del"
title="删除笔记?"
onConfirm={(e) => {
e?.stopPropagation();
void removeNote(item.id);
}}
onCancel={(e) => e?.stopPropagation()}
>
<Button type="text" size="small" icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>,
]}
>
<List.Item.Meta
title={
<Space size={4}>
{item.pinned && <StarFilled style={{ color: "#faad14", fontSize: 12 }} />}
<Typography.Text strong={item.id === activeId} ellipsis style={{ maxWidth: 170 }}>
{item.title}
</Typography.Text>
</Space>
}
description={
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(item.updatedAt).format("MM-DD HH:mm")}
{(item.tags ?? []).length > 0 ? ` · ${item.tags.join(" · ")}` : ""}
</Typography.Text>
}
/>
</List.Item>
)}
/>
)}
</div>
</div>
{/* ── 右栏:Vditor 所见即所得 Markdown 编辑器(容器常驻,Empty 覆盖层控制显隐) ── */}
<div
style={{
flex: 1,
minWidth: 0,
border: "1px solid rgba(128,128,128,0.2)",
borderRadius: 8,
padding: 16,
display: "flex",
flexDirection: "column",
position: "relative",
}}
>
<Empty
style={{
margin: "auto",
display: activeId ? "none" : "block",
position: "absolute",
inset: 0,
zIndex: 5,
pointerEvents: "none",
}}
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<span>
选择或新建一篇笔记
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
所见即所得 Markdown(Typora 式)· AI 助手续写/总结/润色/翻译
</Typography.Text>
</span>
}
/>
<div style={{ display: activeId ? "flex" : "none", flexDirection: "column", flex: 1, minHeight: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<Input
variant="borderless"
placeholder="无标题"
value={title}
onChange={(e) => {
setTitle(e.target.value);
queueSave();
}}
style={{ fontSize: 22, fontWeight: 700, padding: 0 }}
/>
<Tooltip title={pinned ? "取消置顶" : "置顶"}>
<Button
type="text"
icon={pinned ? <StarFilled style={{ color: "#faad14" }} /> : <StarOutlined />}
onClick={togglePin}
/>
</Tooltip>
<Typography.Text type="secondary" style={{ fontSize: 12, whiteSpace: "nowrap" }}>
{saveState === "saving" ? "保存中…" : saveState === "pending" ? "待保存" : saveState === "saved" ? "已保存" : ""}
</Typography.Text>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
<Input
size="small"
placeholder="+ 标签,回车添加"
style={{ width: 200 }}
onPressEnter={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (v && !tags.includes(v)) {
setTags([...tags, v]);
queueSave();
}
(e.target as HTMLInputElement).value = "";
}}
/>
<Space style={{ flex: 1 }} wrap>
{tags.map((t) => (
<Tag
key={t}
closable
onClose={() => {
setTags(tags.filter((x) => x !== t));
queueSave();
}}
>
{t}
</Tag>
))}
</Space>
<Dropdown
menu={{
items: AI_ACTIONS.map((a) => ({ key: a.key, label: a.label })),
onClick: ({ key }) => {
const def = AI_ACTIONS.find((a) => a.key === key)!;
void runAi(def.key, def.replaces);
},
}}
>
<Button size="small" type="primary" ghost icon={<RobotOutlined />} loading={Boolean(aiRunning)}>
{aiRunning ? "AI 处理中…" : "AI 助手"}
</Button>
</Dropdown>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
<div
ref={mountRef}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
/>
</div>
<Typography.Text type="secondary" style={{ fontSize: 11, marginTop: 4 }}>
所见即所得 Markdown · 输入「/」唤起菜单 · 工具栏可切换标题级别 · 自动保存(后台执行,切换不卡顿)
</Typography.Text>
</div>
</div>
</div>
</PageContainer>
);
}
+235
View File
@@ -0,0 +1,235 @@
/**
* # 项目页(Phase B/I):项目 = 工作顶层上下文
* 卡片网格 + 关联资源计数 + 详情抽屉(关系导航 / 最近活动 / 任务与笔记)
*/
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
PageContainer,
ProFormText,
ProFormTextArea,
} from "@ant-design/pro-components";
import {
App as AntApp,
Button,
Card,
Descriptions,
Drawer,
Empty,
Input,
Space,
Spin,
Tag,
Typography,
} from "antd";
import { PlusOutlined, DatabaseOutlined } from "@ant-design/icons";
import { eventApi, resourceApi } from "../services/api";
import type { ResourceView } from "@devos/types";
import { StatusBadge, TimeText } from "../components/shared";
import { FormModal } from "../components/form-modal";
/** # 资源类型 → 前端路由 */
const TYPE_PATH: Record<string, (id: string) => string> = {
SERVER: (id) => `/servers/${id}`,
WEBSITE: (id) => `/websites/${id}`,
DOMAIN: (id) => `/domains/${id}`,
};
/** # 关联类型中文名 **/
const REL_LABEL: Record<string, string> = {
PROJECT_HAS_REPOSITORY: "仓库",
PROJECT_HAS_WEBSITE: "网站",
PROJECT_HAS_SERVER: "服务器",
PROJECT_HAS_DOMAIN: "域名",
PROJECT_HAS_TASK: "任务",
PROJECT_HAS_NOTE: "笔记",
};
export default function ProjectsPage() {
const { message } = AntApp.useApp();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [detail, setDetail] = useState<ResourceView | null>(null);
/** # 项目列表(PROJECT 资源) **/
const { data: projects, isLoading } = useQuery({
queryKey: ["projects"],
queryFn: () => resourceApi.list({ type: "PROJECT", pageSize: 100 }),
});
/** # 详情:关系 + 任务 + 活动 **/
const { data: relations, isLoading: loadingRelations } = useQuery({
queryKey: ["project-relations", detail?.id],
queryFn: () => resourceApi.relations(detail!.id),
enabled: Boolean(detail),
});
const { data: events } = useQuery({
queryKey: ["project-events", detail?.id],
queryFn: () => eventApi.list({ pageSize: 8 }),
enabled: Boolean(detail),
});
/** # 统计某项目的关联资源计数 **/
const countByType = (rows: ReturnType<typeof Object>[]) => {
const counts: Record<string, number> = {};
for (const r of rows as unknown as { other: { type: string } }[]) {
counts[r.other.type] = (counts[r.other.type] ?? 0) + 1;
}
return counts;
};
const linked = ((relations ?? []) as {
id: string;
relationType: string;
direction: string;
source: { id: string; type: string; name: string; status: string };
target: { id: string; type: string; name: string; status: string };
}[]).map((r) => ({
key: r.id,
relationType: r.relationType,
other: r.direction === "OUTGOING" ? r.target : r.source,
}));
return (
<PageContainer
subTitle="项目是工作的顶层上下文:仓库 / 网站 / 服务器 / 域名 / 任务 / 笔记 从这里展开"
>
{isLoading ? (
<Spin style={{ display: "block", margin: "80px auto" }} />
) : (projects?.items ?? []).length === 0 ? (
<Empty description="还没有项目">
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
创建第一个项目
</Button>
</Empty>
) : (
<Space wrap size={16}>
{(projects?.items ?? []).map((p) => (
<Card
key={p.id}
hoverable
style={{ width: 300 }}
onClick={() => setDetail(p)}
title={
<Space>
<StatusBadge status={p.status} />
<Typography.Text strong>{p.name}</Typography.Text>
</Space>
}
>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }}>
{String(p.metadata?.description ?? "(无描述)")}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
更新于 <TimeText time={p.updatedAt} />
</Typography.Text>
</Card>
))}
<Card
hoverable
style={{ width: 300, display: "flex", alignItems: "center", justifyContent: "center", minHeight: 120 }}
onClick={() => setCreateOpen(true)}
>
<Typography.Text type="secondary">
<PlusOutlined /> 新建项目
</Typography.Text>
</Card>
</Space>
)}
<FormModal
title="新建项目"
width={480}
open={createOpen}
modalProps={{ onCancel: () => setCreateOpen(false) }}
onFinish={async (values) => {
try {
await resourceApi.create({
type: "PROJECT",
name: String(values.name ?? ""),
metadata: { description: String(values.description ?? "") },
});
message.success("项目已创建");
setCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ["projects"] });
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
>
<ProFormText name="name" label="名称" rules={[{ required: true }]} />
<ProFormTextArea name="description" label="描述" placeholder="这个项目是做什么的" fieldProps={{ rows: 2 }} />
</FormModal>
<Drawer
title={`项目:${detail?.name ?? ""}`}
open={Boolean(detail)}
onClose={() => setDetail(null)}
width={640}
extra={
<Button icon={<DatabaseOutlined />} onClick={() => navigateToResources()}>
资源目录
</Button>
}
>
{detail && (
<Space direction="vertical" style={{ width: "100%" }} size={16}>
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="状态"><StatusBadge status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="创建"><TimeText time={detail.createdAt} /></Descriptions.Item>
<Descriptions.Item label="描述" span={2}>
{String(detail.metadata?.description ?? "-")}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5}>关联资源</Typography.Title>
{loadingRelations ? (
<Spin />
) : linked.length === 0 ? (
<Typography.Text type="secondary">
暂无关联。在服务器 / 网站 / 仓库等资源的详情页可将它们与本项目建立关系。
</Typography.Text>
) : (
<Space wrap>
{linked.map((l) => {
const path = TYPE_PATH[l.other.type]?.(l.other.id);
const label = REL_LABEL[l.relationType] ?? l.relationType;
return path ? (
<Link key={l.key} to={path}>
<Tag color="blue" style={{ cursor: "pointer" }}>
{label}: {l.other.name}
</Tag>
</Link>
) : (
<Tag key={l.key}>{label}: {l.other.name}</Tag>
);
})}
</Space>
)}
<Typography.Title level={5}>最近活动</Typography.Title>
{(events?.items ?? []).length === 0 ? (
<Typography.Text type="secondary">暂无活动</Typography.Text>
) : (
(events?.items ?? []).map((e) => (
<Space key={e.id} size={8}>
<Typography.Text code style={{ fontSize: 12 }}>{e.eventType}</Typography.Text>
<TimeText time={e.createdAt} />
</Space>
))
)}
</Space>
)}
</Drawer>
</PageContainer>
);
function navigateToResources() {
setDetail(null);
navigate("/resources");
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* # 仓库页(Phase D):已登记仓库 + 分支查看 + 手工登记
*/
import { useRef, useState } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Drawer, Popconfirm, Typography } from "antd";
import { PlusOutlined, DeleteOutlined, BranchesOutlined } from "@ant-design/icons";
import { gitApi } from "../services/api";
import type { RepositoryView } from "../services/api";
import { TimeText } from "../components/shared";
import { FormModal } from "../components/form-modal";
export default function RepositoriesPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [createOpen, setCreateOpen] = useState(false);
const [branches, setBranches] = useState<{ repo: RepositoryView; list: { name: string; commit: string }[] } | null>(null);
const columns: ProColumns<RepositoryView>[] = [
{ title: "仓库", dataIndex: "fullName" },
{ title: "提供商", dataIndex: "provider", render: (_, r) => <Typography.Text code>{r.provider}</Typography.Text> },
{ title: "默认分支", dataIndex: "defaultBranch" },
{ title: "可见性", dataIndex: "visibility" },
{ title: "克隆地址", dataIndex: "cloneUrl", ellipsis: true, render: (_, r) => <Typography.Text copyable style={{ fontSize: 12 }}>{r.cloneUrl}</Typography.Text> },
{ title: "更新时间", dataIndex: "updatedAt", render: (_, r) => <TimeText time={r.updatedAt} /> },
{
title: "操作",
valueType: "option",
width: 180,
render: (_, record) => [
<a
key="branches"
onClick={async () => {
try {
const list = await gitApi.listBranches(record.id);
setBranches({ repo: record, list });
} catch (e) {
message.error(e instanceof Error ? e.message : "分支拉取失败");
}
}}
>
<BranchesOutlined /> 分支
</a>,
<Popconfirm
key="del"
title="移除仓库登记?"
onConfirm={async () => {
await gitApi.deleteRepository(record.id);
message.success("已移除");
actionRef.current?.reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /></a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProTable<RepositoryView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="代码仓库"
search={false}
pagination={{ pageSize: 20 }}
request={async () => {
const items = await gitApi.listRepositories();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
登记仓库
</Button>,
]}
/>
<FormModal
title="登记仓库(手工)"
width={560}
open={createOpen}
modalProps={{ onCancel: () => setCreateOpen(false) }}
onFinish={async (values) => {
try {
await gitApi.createRepository(values as never);
message.success("仓库已登记");
setCreateOpen(false);
actionRef.current?.reload();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "登记失败");
return false;
}
}}
>
<ProFormText
name="fullName"
label="全名(owner/repo)"
rules={[{ required: true }]}
placeholder="my-user/my-repo"
/>
<ProFormText
name="cloneUrl"
label="HTTPS 克隆地址(不含凭据)"
rules={[{ required: true }]}
placeholder="https://github.com/my-user/my-repo.git"
/>
<ProFormSelect
name="provider"
label="提供商"
initialValue="GENERIC"
options={["GITHUB", "GITLAB", "GITEA", "GENERIC"].map((p) => ({ label: p, value: p }))}
/>
<ProFormText name="defaultBranch" label="默认分支" initialValue="main" />
<ProFormSelect
name="visibility"
label="可见性"
initialValue="private"
options={[
{ label: "私有", value: "private" },
{ label: "公开", value: "public" },
]}
/>
</FormModal>
<Drawer
title={`分支:${branches?.repo.fullName ?? ""}`}
open={Boolean(branches)}
onClose={() => setBranches(null)}
width={420}
>
<Typography.Paragraph type="secondary">
默认分支:{branches?.repo.defaultBranch}
</Typography.Paragraph>
{(branches?.list ?? []).map((b) => (
<div key={b.name} style={{ padding: "6px 0", borderBottom: "1px solid rgba(128,128,128,0.15)" }}>
<Typography.Text strong>{b.name}</Typography.Text>
<Typography.Text type="secondary" code style={{ marginLeft: 8, fontSize: 12 }}>
{b.commit.slice(0, 8)}
</Typography.Text>
</div>
))}
</Drawer>
</PageContainer>
);
}
+75
View File
@@ -0,0 +1,75 @@
/**
* # 资源目录页:统一资源模型的总览(类型过滤 + 关键字搜索)
*/
import { useRef } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
} from "@ant-design/pro-components";
import { Space, Tag, Typography } from "antd";
import { resourceApi } from "../services/api";
import type { ResourceView } from "@devos/types";
import { StatusBadge, TimeText } from "../components/shared";
const TYPE_COLOR: Record<string, string> = {
SERVER: "geekblue",
WEBSITE: "green",
DOMAIN: "purple",
CONTAINER: "cyan",
DEPLOYMENT: "volcano",
MONITOR: "orange",
CREDENTIAL: "red",
AUTOMATION: "blue",
AI_RUN: "magenta",
};
export default function ResourcesPage() {
const actionRef = useRef<ActionType>(null);
const columns: ProColumns<ResourceView>[] = [
{
title: "类型",
dataIndex: "type",
width: 120,
render: (_, r) => <Tag color={TYPE_COLOR[r.type] ?? "default"}>{r.type}</Tag>,
filters: Object.keys(TYPE_COLOR).map((t) => ({ text: t, value: t })),
onFilter: (value, record) => record.type === value,
},
{
title: "名称",
dataIndex: "name",
render: (_, r) => <Typography.Text strong>{r.name}</Typography.Text>,
},
{ title: "提供商", dataIndex: "provider", width: 110, render: (_, r) => r.provider ?? "-" },
{ title: "状态", dataIndex: "status", width: 110, render: (_, r) => <StatusBadge status={r.status} /> },
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (_, r) => <TimeText time={r.updatedAt} /> },
];
return (
<PageContainer>
<Typography.Paragraph type="secondary">
统一资源模型:所有资源共享身份、状态与关系图谱,供自动化与 AI 检索。
</Typography.Paragraph>
<ProTable<ResourceView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="全部资源"
search={false}
pagination={{ pageSize: 20 }}
request={async (params) => {
const res = await resourceApi.list({
page: params.current ?? 1,
pageSize: params.pageSize ?? 20,
q: params.name,
type: (params as { type?: string }).type,
});
return { data: res.items, success: true, total: res.total };
}}
/>
</PageContainer>
);
}
+250
View File
@@ -0,0 +1,250 @@
/**
* # 服务器详情页:概览 / 容器 / SSH 执行 / 关系 / 审计
*/
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PageContainer, ProDescriptions, ProCard, ProTable } from "@ant-design/pro-components";
import type { ProColumns } from "@ant-design/pro-components";
import { Alert, App as AntApp, Button, Popconfirm, Space, Tag, Input } from "antd";
import {
ArrowLeftOutlined,
ApiOutlined,
ReloadOutlined,
CodeOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { auditApi, containerApi, resourceApi, serverApi } from "../services/api";
import { RiskTag, StatusBadge, TimeText } from "../components/shared";
import type { ContainerView } from "@devos/types";
import { FitModal } from "../components/form-modal";
export default function ServerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { message } = AntApp.useApp();
const queryClient = useQueryClient();
const [execOpen, setExecOpen] = useState(false);
const [command, setCommand] = useState("docker ps");
const [execResult, setExecResult] = useState("");
const [execRunning, setExecRunning] = useState(false);
const { data: server } = useQuery({ queryKey: ["server", id], queryFn: () => serverApi.get(id!) });
const { data: containers } = useQuery({
queryKey: ["server-containers", id],
queryFn: () => serverApi.containers(id!),
});
const { data: relations } = useQuery({
queryKey: ["server-relations", server?.resourceId],
queryFn: () => resourceApi.relations(server!.resourceId),
enabled: Boolean(server?.resourceId),
});
const { data: audit } = useQuery({
queryKey: ["server-audit", server?.resourceId],
queryFn: () => auditApi.list({ pageSize: 10, resourceId: server!.resourceId }),
enabled: Boolean(server?.resourceId),
});
if (!server) {
return <Alert type="info" message="加载中或服务器不存在" style={{ margin: 24 }} />;
}
const refresh = () => queryClient.invalidateQueries({ queryKey: ["server", id] });
const containerColumns: ProColumns<ContainerView>[] = [
{ title: "名称", dataIndex: "name" },
{ title: "镜像", dataIndex: "image", ellipsis: true },
{ title: "状态", dataIndex: "state", render: (_, r) => <StatusBadge status={r.state} /> },
{
title: "操作",
valueType: "option",
render: (_, record) => (
<Popconfirm
key="restart"
title={`重启容器 ${record.name}?(HIGH 风险操作)`}
onConfirm={async () => {
try {
await containerApi.restart(record.id);
message.success("容器已重启");
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}
>
<a style={{ color: "#fa8c16" }}>
<ReloadOutlined /> 重启
</a>
</Popconfirm>
),
},
];
const runExec = async () => {
setExecRunning(true);
setExecResult("");
try {
const res = await serverApi.exec(id!, command);
setExecResult(`exit code: ${res.exitCode}\n\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`);
} catch (e) {
setExecResult(`执行失败:${e instanceof Error ? e.message : e}`);
} finally {
setExecRunning(false);
queryClient.invalidateQueries({ queryKey: ["server-audit"] });
}
};
return (
<PageContainer
header={{
title: server.name,
extra: [
<Button key="back" icon={<ArrowLeftOutlined />} onClick={() => navigate("/servers")}>
返回
</Button>,
<Button
key="connect"
icon={<ApiOutlined />}
onClick={async () => {
try {
const res = await serverApi.connect(id!);
message.info(`连通性:${res.ok ? "在线" : "离线"}(${res.latencyMs}ms)`);
refresh();
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}
>
连接测试
</Button>,
<Button key="exec" icon={<CodeOutlined />} onClick={() => setExecOpen(true)}>
执行命令
</Button>,
<Popconfirm
key="delete"
title="删除服务器?(HIGH 风险)"
onConfirm={async () => {
await serverApi.delete(id!);
message.success("已删除");
navigate("/servers");
}}
>
<Button danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>,
],
}}
>
<Space direction="vertical" style={{ width: "100%" }} size={16}>
<ProCard>
<ProDescriptions column={3} size="small">
<ProDescriptions.Item label="状态">
<StatusBadge status={server.status} />
</ProDescriptions.Item>
<ProDescriptions.Item label="地址">{`${server.host}:${server.port}`}</ProDescriptions.Item>
<ProDescriptions.Item label="用户">{server.username}</ProDescriptions.Item>
<ProDescriptions.Item label="认证">{server.authType}</ProDescriptions.Item>
<ProDescriptions.Item label="系统">{server.osInfo ?? "-"}</ProDescriptions.Item>
<ProDescriptions.Item label="最近在线">
<TimeText time={server.lastSeenAt} />
</ProDescriptions.Item>
</ProDescriptions>
</ProCard>
<ProCard
title="容器"
headerBordered
extra={
<Button
size="small"
onClick={async () => {
try {
const list = await serverApi.discoverContainers(id!);
message.success(`发现 ${list.length} 个容器`);
queryClient.invalidateQueries({ queryKey: ["server-containers", id] });
} catch (e) {
message.error(e instanceof Error ? e.message : "发现失败");
}
}}
>
<ApiOutlined /> 重新发现
</Button>
}
>
<ProTable<ContainerView>
rowKey="id"
columns={containerColumns}
dataSource={containers}
search={false}
pagination={false}
options={false}
/>
</ProCard>
<ProCard gutter={16} ghost>
<ProCard colSpan={{ md: 12 }} title="资源关系" headerBordered>
<Space direction="vertical">
{(relations ?? []).map((r: { id: string; direction: string; relationType: string; target: { name: string; type: string }; source: { name: string; type: string } }) => (
<Tag key={r.id}>
{r.direction === "OUTGOING"
? `${r.relationType} → ${r.target.name} (${r.target.type})`
: `← ${r.relationType} ${r.source.name} (${r.source.type})`}
</Tag>
))}
</Space>
</ProCard>
<ProCard colSpan={{ md: 12 }} title="审计记录" headerBordered>
<Space direction="vertical" style={{ width: "100%" }}>
{(audit?.items ?? []).map((a: { id: string; createdAt: string; action: string; riskLevel: string; status: string }) => (
<Space key={a.id} size={8}>
<TimeText time={a.createdAt} />
<code>{a.action}</code>
<RiskTag risk={a.riskLevel} />
<StatusBadge status={a.status === "SUCCESS" ? "ACTIVE" : "ERROR"} />
</Space>
))}
</Space>
</ProCard>
</ProCard>
</Space>
<FitModal
title="SSH 执行命令"
open={execOpen}
onCancel={() => setExecOpen(false)}
footer={[
<Button key="close" onClick={() => setExecOpen(false)}>
关闭
</Button>,
<Button key="run" type="primary" loading={execRunning} onClick={runExec}>
执行
</Button>,
]}
width={720}
>
<Alert
type="warning"
showIcon
message="命令将在目标服务器执行并写入审计(危险命令将被标记 CRITICAL)"
style={{ marginBottom: 12 }}
/>
<Input value={command} onChange={(e) => setCommand(e.target.value)} onPressEnter={runExec} />
<pre
style={{
marginTop: 12,
maxHeight: 320,
overflow: "auto",
background: "#0b1021",
color: "#d1e4ff",
padding: 12,
borderRadius: 6,
fontSize: 12,
whiteSpace: "pre-wrap",
}}
>
{execResult || "(等待执行)"}
</pre>
</FitModal>
</PageContainer>
);
}
+247
View File
@@ -0,0 +1,247 @@
/**
* # 服务器列表页
*/
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
ProFormTextArea,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Popconfirm, Space, Tabs, Tag, Typography } from "antd";
import { FormModal } from "../components/form-modal";
import { PlusOutlined, ApiOutlined, DeleteOutlined } from "@ant-design/icons";
import { serverApi, credentialApi, type CredentialView } from "../services/api";
import type { ServerView } from "@devos/types";
import { StatusBadge, TimeText } from "../components/shared";
export default function ServersPage() {
const navigate = useNavigate();
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [createOpen, setCreateOpen] = useState(false);
const columns: ProColumns<ServerView>[] = [
{
title: "名称",
dataIndex: "name",
render: (_, record) => (
<Typography.Link onClick={() => navigate(`/servers/${record.id}`)}>{record.name}</Typography.Link>
),
},
{ title: "地址", dataIndex: "host", render: (_, r) => `${r.host}:${r.port}` },
{ title: "用户", dataIndex: "username" },
{
title: "状态",
dataIndex: "status",
render: (_, r) => <StatusBadge status={r.status} />,
},
{
title: "系统",
dataIndex: "osInfo",
ellipsis: true,
render: (_, r) => r.osInfo ?? "-",
},
{ title: "最近在线", dataIndex: "lastSeenAt", render: (_, r) => <TimeText time={r.lastSeenAt} /> },
{
title: "操作",
valueType: "option",
width: 220,
render: (_, record) => [
<a
key="connect"
onClick={async () => {
try {
const res = await serverApi.connect(record.id);
message.info(`连通性:${res.ok ? "在线" : "离线"}(${res.latencyMs}ms) ${res.info}`);
actionRef.current?.reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}
>
连接测试
</a>,
<a key="discover" onClick={async () => {
try {
const list = await serverApi.discoverContainers(record.id);
message.success(`发现 ${list.length} 个容器`);
actionRef.current?.reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}>
<ApiOutlined /> 发现容器
</a>,
<Popconfirm
key="del"
title="删除服务器?"
description="相关资源关系将一并删除"
onConfirm={async () => {
try {
await serverApi.delete(record.id);
message.success("已删除");
actionRef.current?.reload();
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /> 删除</a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProTable<ServerView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
request={async () => {
const items = await serverApi.list();
return { data: items, success: true, total: items.length };
}}
search={false}
pagination={false}
headerTitle="服务器列表"
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
添加服务器
</Button>,
]}
/>
<CreateServerModal
open={createOpen}
onCancel={() => setCreateOpen(false)}
onDone={() => {
setCreateOpen(false);
actionRef.current?.reload();
}}
/>
</PageContainer>
);
}
/** # 创建服务器弹窗(local 提供者管理本机 docker;ssh 需凭据) */
function CreateServerModal({
open,
onCancel,
onDone,
}: {
open: boolean;
onCancel: () => void;
onDone: () => void;
}) {
const { message } = AntApp.useApp();
const [provider, setProvider] = useState("ssh");
const [credentials, setCredentials] = useState<CredentialView[]>([]);
const loadCredentials = async () => {
try {
setCredentials(await credentialApi.list());
} catch {
/* ignore */
}
};
return (
<FormModal
title="添加服务器"
width={620}
open={open}
modalProps={{ onCancel }}
onFinish={async (values) => {
try {
await serverApi.create(values);
message.success("服务器已创建");
onDone();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
onOpenChange={(o) => {
if (o) void loadCredentials();
if (!o) onCancel();
}}
>
<Tabs
items={[
{
key: "base",
label: "基本信息",
forceRender: true,
children: (
<>
<ProFormText name="name" label="名称" rules={[{ required: true }]} />
<ProFormSelect
name="provider"
label="类型"
initialValue="ssh"
options={[
{ label: "SSH 远程服务器", value: "ssh" },
{ label: "本机 Docker Socket", value: "local" },
]}
fieldProps={{ onChange: (v) => setProvider(v as string) }}
/>
<ProFormText name="host" label="主机" rules={[{ required: true }]} />
<ProFormText name="port" label="端口" initialValue={22} />
<ProFormText
name="username"
label="用户名"
initialValue={provider === "local" ? "local" : "root"}
rules={[{ required: true }]}
/>
</>
),
},
{
key: "auth",
label: "访问凭据",
forceRender: true,
children: provider === "ssh" ? (
<>
<ProFormSelect
name="authType"
label="认证方式"
initialValue="SSH_KEY"
options={[
{ label: "SSH 私钥", value: "SSH_KEY" },
{ label: "密码", value: "PASSWORD" },
]}
/>
<ProFormTextArea
name="privateKey"
label="私钥(PEM,或留空引用已有凭据)"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
fieldProps={{ rows: 4 }}
/>
<ProFormText.Password name="password" label="密码(密码认证时填写)" />
<ProFormSelect
name="credentialId"
label="或引用已有凭据"
options={credentials.map((c) => ({ label: `${c.name}(${c.type})`, value: c.id }))}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
凭据将以 AES-256-GCM 信封加密入库,任何接口不会返回明文。
</Typography.Text>
</>
) : (
<Typography.Text type="secondary">
本机 Docker Socket 模式无需凭据(通过宿主 /var/run/docker.sock 管理)。
</Typography.Text>
),
},
]}
/>
</FormModal>
);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* # 任务页(Phase E/06):backlog/todo/doing/review/done + 优先级 + 标签 + 截止日
*/
import { useRef, useState } from "react";
import {
PageContainer,
ProTable,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
ProFormDatePicker,
ProFormTextArea,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Popconfirm, Select, Space, Tag, Typography } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { taskApi } from "../services/api";
import type { TaskView } from "../services/api";
import { TimeText } from "../components/shared";
import { FormModal } from "../components/form-modal";
const STATUS_COLOR: Record<string, string> = {
BACKLOG: "default",
TODO: "blue",
DOING: "processing",
REVIEW: "gold",
DONE: "success",
};
const PRIORITY_COLOR: Record<string, string> = { LOW: "default", MEDIUM: "blue", HIGH: "volcano", URGENT: "red" };
export default function TasksPage() {
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [editing, setEditing] = useState<TaskView | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const reload = () => actionRef.current?.reload();
const columns: ProColumns<TaskView>[] = [
{
title: "标题",
dataIndex: "title",
render: (_, r) => <Typography.Text strong>{r.title}</Typography.Text>,
},
{
title: "状态",
dataIndex: "status",
width: 150,
render: (_, r) => (
<Select
size="small"
value={r.status}
style={{ width: 110 }}
onChange={async (v) => {
await taskApi.update(r.id, { status: v });
reload();
}}
options={Object.keys(STATUS_COLOR).map((s) => ({ value: s, label: s }))}
/>
),
},
{ title: "优先级", dataIndex: "priority", width: 100, render: (_, r) => <Tag color={PRIORITY_COLOR[r.priority]}>{r.priority}</Tag> },
{
title: "标签",
dataIndex: "labels",
render: (_, r) => (
<Space wrap>{(r.labels ?? []).map((l) => <Tag key={l}>{l}</Tag>)}</Space>
),
},
{ title: "截止", dataIndex: "dueAt", width: 120, render: (_, r) => (r.dueAt ? new Date(r.dueAt).toLocaleDateString() : "-") },
{ title: "更新", dataIndex: "updatedAt", width: 120, render: (_, r) => <TimeText time={r.updatedAt} /> },
{
title: "操作",
valueType: "option",
width: 140,
render: (_, record) => [
<a key="edit" onClick={() => setEditing(record)}>编辑</a>,
<Popconfirm
key="del"
title="删除任务?"
onConfirm={async () => {
await taskApi.delete(record.id);
message.success("已删除");
reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /></a>
</Popconfirm>,
],
},
];
const TaskFormFields = () => (
<>
<ProFormText name="title" label="标题" rules={[{ required: true }]} />
<ProFormTextArea name="description" label="描述" fieldProps={{ rows: 3 }} />
<ProFormSelect
name="status"
label="状态"
initialValue="TODO"
options={Object.keys(STATUS_COLOR).map((s) => ({ label: s, value: s }))}
/>
<ProFormSelect
name="priority"
label="优先级"
initialValue="MEDIUM"
options={Object.keys(PRIORITY_COLOR).map((p) => ({ label: p, value: p }))}
/>
<ProFormDatePicker name="dueAt" label="截止日期" />
</>
);
return (
<PageContainer
extra={[
<Select
key="filter"
allowClear
placeholder="按状态筛选"
style={{ minWidth: 140 }}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
reload();
}}
options={Object.keys(STATUS_COLOR).map((s) => ({ value: s, label: s }))}
/>,
]}
>
<ProTable<TaskView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="任务"
search={false}
pagination={{ pageSize: 20 }}
request={async () => {
const items = await taskApi.list({ status: statusFilter });
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
新建任务
</Button>,
]}
/>
<FormModal
title="新建任务"
width={520}
open={createOpen}
modalProps={{ onCancel: () => setCreateOpen(false) }}
onFinish={async (values) => {
try {
await taskApi.create(values as never);
message.success("任务已创建");
setCreateOpen(false);
reload();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
>
<TaskFormFields />
</FormModal>
<FormModal
title={`编辑任务:${editing?.title ?? ""}`}
width={520}
open={Boolean(editing)}
modalProps={{ onCancel: () => setEditing(null) }}
initialValues={editing ? { ...editing, dueAt: editing.dueAt ?? undefined } : undefined}
onFinish={async (values) => {
if (!editing) return false;
try {
await taskApi.update(editing.id, values as never);
message.success("已更新");
setEditing(null);
reload();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "更新失败");
return false;
}
}}
>
<TaskFormFields />
</FormModal>
</PageContainer>
);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* # 网站详情页:概览 / 关系 / 部署历史 / 日志 / 健康
*/
import { useParams, useNavigate, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
PageContainer,
ProDescriptions,
ProCard,
ProTable,
type ProColumns,
} from "@ant-design/pro-components";
import { Alert, App as AntApp, Button, Popconfirm, Space, Tag, Typography } from "antd";
import { ArrowLeftOutlined, ThunderboltOutlined, HeartOutlined, DeleteOutlined } from "@ant-design/icons";
import { deploymentApi, resourceApi, websiteApi, type DeploymentFullView } from "../services/api";
import { StatusBadge, TimeText } from "../components/shared";
export default function WebsiteDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { message } = AntApp.useApp();
const queryClient = useQueryClient();
const { data: site, isLoading, error } = useQuery({
queryKey: ["website", id],
queryFn: () => websiteApi.get(id!),
});
const { data: deployments } = useQuery({
queryKey: ["deployments", { websiteId: id }],
queryFn: () => deploymentApi.list(id),
});
const { data: logs } = useQuery({
queryKey: ["website-logs", id],
queryFn: () => websiteApi.logs(id!),
refetchInterval: 5_000,
});
const columns: ProColumns<DeploymentFullView>[] = [
{
title: "部署 ID",
dataIndex: "id",
render: (_, r) => <Link to={`/deployments/${r.id}`}>{r.id.slice(0, 8)}</Link>,
},
{ title: "状态", dataIndex: "status", render: (_, r) => <StatusBadge status={r.status} /> },
{ title: "进度", dataIndex: "progress", render: (_, r) => `${r.progress}%` },
{ title: "commit", dataIndex: "commit" },
{ title: "触发", dataIndex: "trigger" },
{ title: "时间", dataIndex: "createdAt", render: (_, r) => <TimeText time={r.createdAt} /> },
];
if (isLoading) return <Alert type="info" message="加载中..." style={{ margin: 24 }} />;
if (error || !site) return <Alert type="error" message="网站不存在或加载失败" style={{ margin: 24 }} />;
return (
<PageContainer
header={{
title: site.name,
extra: [
<Button key="back" onClick={() => navigate("/websites")}>返回</Button>,
<Button
key="deploy"
type="primary"
icon={<ThunderboltOutlined />}
onClick={async () => {
try {
const d = await websiteApi.deploy(id!);
message.success("部署已创建");
navigate(`/deployments/${d.id}`);
} catch (e) {
message.error(e instanceof Error ? e.message : "部署失败");
}
}}
>
部署(HIGH 风险)
</Button>,
<Button
key="health"
icon={<HeartOutlined />}
onClick={async () => {
try {
const res = await websiteApi.health(id!);
message.info(res.ok ? `健康(HTTP ${res.status})` : `异常:${res.error ?? res.status}`);
} catch (e) {
message.error(e instanceof Error ? e.message : "失败");
}
}}
>
健康检查
</Button>,
<Popconfirm
key="del"
title="删除网站?(HIGH 风险)"
onConfirm={async () => {
await websiteApi.delete(id!);
navigate("/websites");
}}
>
<Button danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>,
]}}
>
<Space direction="vertical" style={{ width: "100%" }} size={16}>
<ProCard>
<ProDescriptions column={3} size="small">
<ProDescriptions.Item label="状态">
<StatusBadge status={site.resource?.status} />
</ProDescriptions.Item>
<ProDescriptions.Item label="域名">
{site.primaryDomain ? <Typography.Text copyable>{site.primaryDomain}</Typography.Text> : "-"}
</ProDescriptions.Item>
<ProDescriptions.Item label="服务器">{site.server?.name ?? "-"}</ProDescriptions.Item>
<ProDescriptions.Item label="仓库">
<Typography.Text ellipsis style={{ maxWidth: 300 }}>
{site.repositoryUrl ?? "-"}
</Typography.Text>
</ProDescriptions.Item>
<ProDescriptions.Item label="部署类型">{site.deployType}</ProDescriptions.Item>
<ProDescriptions.Item label="端口">{site.port}{site.healthPath}</ProDescriptions.Item>
</ProDescriptions>
<div style={{ marginTop: 12 }}>
<Space wrap>
{(site.resource?.relations ?? []).map((r: { id: string; relationType: string; target?: { name: string }; source?: { name: string } }) => (
<Tag key={r.id}>
{r.relationType} → {(r.target ?? r.source)?.name}
</Tag>
))}
</Space>
</div>
</ProCard>
<ProCard title="部署历史" headerBordered>
<ProTable<DeploymentFullView>
rowKey="id"
columns={columns}
dataSource={deployments}
search={false}
pagination={{ pageSize: 5 }}
options={false}
/>
</ProCard>
<ProCard title="实时日志(最近一次部署)" headerBordered>
<pre
style={{
maxHeight: 320,
overflow: "auto",
background: "#0b1021",
color: "#d1e4ff",
padding: 12,
borderRadius: 6,
fontSize: 12,
}}
>
{(logs?.lines ?? []).map((l: { line: string }) => l.line).join("\n") || "暂无日志"}
</pre>
</ProCard>
</Space>
</PageContainer>
);
}
+229
View File
@@ -0,0 +1,229 @@
/**
* # 网站列表页
*/
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
PageContainer,
ProTable,
ProCard,
type ActionType,
type ProColumns,
ProFormText,
ProFormSelect,
ProFormDigit,
} from "@ant-design/pro-components";
import { App as AntApp, Button, Popconfirm, Tabs, Typography } from "antd";
import { FormModal } from "../components/form-modal";
import { PlusOutlined, ThunderboltOutlined, HeartOutlined, DeleteOutlined } from "@ant-design/icons";
import { serverApi, websiteApi } from "../services/api";
import type { WebsiteFullView } from "../services/api";
import type { ServerView } from "@devos/types";
import { StatusBadge } from "../components/shared";
export default function WebsitesPage() {
const navigate = useNavigate();
const { message } = AntApp.useApp();
const actionRef = useRef<ActionType>(null);
const [createOpen, setCreateOpen] = useState(false);
const columns: ProColumns<WebsiteFullView>[] = [
{
title: "名称",
dataIndex: "name",
render: (_, record) => (
<Typography.Link onClick={() => navigate(`/websites/${record.id}`)}>{record.name}</Typography.Link>
),
},
{
title: "域名",
dataIndex: "primaryDomain",
render: (_, r) =>
r.primaryDomain ? (
<Typography.Text copyable>{r.primaryDomain}</Typography.Text>
) : (
<Typography.Text type="secondary">未绑定</Typography.Text>
),
},
{ title: "服务器", dataIndex: ["server", "name"], render: (_, r) => r.server?.name ?? "-" },
{ title: "端口", dataIndex: "port" },
{ title: "部署类型", dataIndex: "deployType" },
{
title: "状态",
dataIndex: "status",
render: () => <StatusBadge status="-" />,
},
{
title: "操作",
valueType: "option",
width: 240,
render: (_, record) => [
<a
key="deploy"
style={{ color: "#4f46e5" }}
onClick={async () => {
try {
const d = await websiteApi.deploy(record.id);
message.success(`部署已创建(${d.id.slice(0, 8)}),进入部署页查看进度`);
navigate(`/deployments/${d.id}`);
} catch (e) {
message.error(e instanceof Error ? e.message : "部署失败");
}
}}
>
<ThunderboltOutlined /> 部署
</a>,
<a
key="health"
onClick={async () => {
try {
const res = await websiteApi.health(record.id);
message.info(res.ok ? `健康(HTTP ${res.status},${res.latencyMs}ms)` : `异常:${res.error ?? res.status}`);
} catch (e) {
message.error(e instanceof Error ? e.message : "检查失败");
}
}}
>
<HeartOutlined /> 健康检查
</a>,
<Popconfirm
key="del"
title="删除网站?"
onConfirm={async () => {
await websiteApi.delete(record.id);
message.success("已删除");
actionRef.current?.reload();
}}
>
<a style={{ color: "#ff4d4f" }}><DeleteOutlined /> 删除</a>
</Popconfirm>,
],
},
];
return (
<PageContainer>
<ProCard style={{ marginBottom: 16 }} bordered>
<Typography.Text type="secondary">
网站关联目标服务器与代码仓库;「部署」会在目标服务器上执行 git 拉取 → Docker 构建 → 容器替换 → 健康验证的完整流水线。
</Typography.Text>
</ProCard>
<ProTable<WebsiteFullView>
scroll={{ x: "max-content" }}
rowKey="id"
columns={columns}
actionRef={actionRef}
headerTitle="网站"
search={false}
pagination={false}
request={async () => {
const items = await websiteApi.list();
return { data: items, success: true, total: items.length };
}}
toolBarRender={() => [
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
添加网站
</Button>,
]}
/>
<CreateWebsiteModal
open={createOpen}
onCancel={() => setCreateOpen(false)}
onDone={() => {
setCreateOpen(false);
actionRef.current?.reload();
}}
/>
</PageContainer>
);
}
function CreateWebsiteModal({
open,
onCancel,
onDone,
}: {
open: boolean;
onCancel: () => void;
onDone: () => void;
}) {
const { message } = AntApp.useApp();
const [servers, setServers] = useState<ServerView[]>([]);
return (
<FormModal
title="添加网站"
width={600}
open={open}
modalProps={{ onCancel }}
onFinish={async (values) => {
try {
await websiteApi.create(values);
message.success("网站已创建");
onDone();
return true;
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
return false;
}
}}
onOpenChange={async (o) => {
if (o) {
try {
setServers(await serverApi.list());
} catch {
/* ignore */
}
}
if (!o) onCancel();
}}
>
<Tabs
items={[
{
key: "base",
label: "基本信息",
forceRender: true,
children: (
<>
<ProFormText name="name" label="名称" rules={[{ required: true }]} />
<ProFormText name="primaryDomain" label="主域名" placeholder="blog.example.com" />
<ProFormSelect
name="serverId"
label="目标服务器"
options={servers.map((sv) => ({ label: sv.name, value: sv.id }))}
/>
</>
),
},
{
key: "deploy",
label: "代码与部署",
forceRender: true,
children: (
<>
<ProFormText
name="repositoryUrl"
label="代码仓库(HTTPS)"
placeholder="https://github.com/user/repo.git"
extra="私有仓库请先在「代码 → Git 连接」登记凭据,避免把 token 放进 URL"
/>
<ProFormSelect
name="deployType"
label="部署类型"
initialValue="DOCKER"
options={[
{ label: "Docker", value: "DOCKER" },
{ label: "静态", value: "STATIC" },
{ label: "Node", value: "NODE" },
]}
/>
<ProFormDigit name="port" label="容器端口" initialValue={80} min={1} max={65535} />
<ProFormText name="healthPath" label="健康检查路径" initialValue="/" />
</>
),
},
]}
/>
</FormModal>
);
}
+557
View File
@@ -0,0 +1,557 @@
/**
* # API 服务层:按领域组织的端点封装
*/
import { api } from "../api/client";
import { useAuthStore } from "../stores/auth";
import type {
AuthUserView,
ContainerView,
DeploymentView,
DomainRecordView,
DomainView,
ResourceView,
ServerView,
WebsiteView,
} from "@devos/types";
// ---------- 通用类型 ----------
export interface Paged<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface MonitorView {
id: string;
resourceId: string;
name: string;
kind: string;
target: string;
intervalSec: number;
enabled: boolean;
lastStatus: string;
consecutiveFailures: number;
lastCheckedAt: string | null;
websiteId: string | null;
website?: { id: string; name: string } | null;
}
export interface AutomationView {
id: string;
resourceId: string;
name: string;
enabled: boolean;
triggerType: string;
triggerConfig: Record<string, unknown>;
conditions: unknown[];
actions: Record<string, unknown>[];
lastTriggeredAt: string | null;
createdAt: string;
runs?: AutomationRunView[];
}
export interface AutomationRunView {
id: string;
status: string;
trigger: unknown;
stepIndex: number;
logs: { step: number; type: string; ok: boolean; output?: string; error?: string; at: string }[];
error: string | null;
startedAt: string;
finishedAt: string | null;
}
export interface AiRunView {
id: string;
resourceId: string;
objective: string;
source?: string;
status: string;
riskLevel: string;
plan: { tool: string; args: Record<string, unknown>; reason: string; risk: string }[];
toolCalls: { tool: string; args: Record<string, unknown>; ok: boolean; result?: unknown; error?: string; at: string }[];
verification: { ok: boolean; checks: { tool: string; ok: boolean; detail?: string }[] } | null;
error?: string;
approvedBy?: string | null;
createdAt: string;
}
export interface AuditItem {
id: string;
actorType: string;
actorId: string | null;
action: string;
resourceType: string | null;
resourceId: string | null;
riskLevel: string;
status: string;
requestId: string | null;
metadata: Record<string, unknown>;
createdAt: string;
}
export interface EventItem {
id: string;
eventType: string;
resourceId: string | null;
payload: Record<string, unknown>;
correlationId?: string | null;
createdAt: string;
}
export interface DeploymentLogLine {
seq: number;
stream: string;
line: string;
time: string;
}
// ---------- Auth ----------
export const authApi = {
login: (email: string, password: string) =>
api.post<{ token: string; expiresIn: number; user: AuthUserView }>("/auth/login", { email, password }),
register: (email: string, password: string, name?: string) =>
api.post<{ token: string; user: AuthUserView }>("/auth/register", { email, password, name }),
me: () => api.get<AuthUserView>("/auth/me"),
logout: () => api.post("/auth/logout"),
};
// ---------- Workspace ----------
export interface WorkspaceRow {
id: string;
name: string;
slug: string;
role: string;
createdAt?: string;
}
export interface MemberRow {
id: string;
userId: string;
email: string;
name: string | null;
role: string;
joinedAt: string;
}
export const workspaceApi = {
list: () => api.get<WorkspaceRow[]>("/workspaces"),
create: (name: string) => api.post<WorkspaceRow>("/workspaces", { name }),
get: (id: string) =>
api.get<{ id: string; name: string; slug: string; memberCount: number; resourceCount: number }>(`/workspaces/${id}`),
members: (id: string) => api.get<MemberRow[]>(`/workspaces/${id}/members`),
addMember: (id: string, email: string, role: string) =>
api.post(`/workspaces/${id}/members`, { email, role }),
removeMember: (id: string, memberId: string) => api.delete(`/workspaces/${id}/members/${memberId}`),
};
// ---------- Dashboard ----------
export interface DashboardData {
serverCount: number;
websiteCount: number;
domainCount: number;
containerCount: number;
deploymentCount: number;
monitorsUp: number;
monitorsDown: number;
recentEvents: EventItem[];
recentDeployments: { id: string; status: string; websiteName?: string; createdAt: string; trigger: string }[];
}
export const dashboardApi = {
get: () => api.get<DashboardData>("/dashboard"),
};
// ---------- Servers ----------
export const serverApi = {
list: () => api.get<ServerView[]>("/servers"),
create: (dto: Record<string, unknown>) => api.post<ServerView>("/servers", dto),
get: (id: string) => api.get<ServerView>(`/servers/${id}`),
delete: (id: string) => api.delete(`/servers/${id}`),
connect: (id: string) => api.post<{ ok: boolean; latencyMs: number; info: string; status: string }>(`/servers/${id}/connect`),
status: (id: string) => api.get<{ serverId: string; name: string; docker: { available: boolean; version: string } }>(`/servers/${id}/status`),
exec: (id: string, command: string, timeoutMs = 30000) =>
api.post<{ exitCode: number; stdout: string; stderr: string }>(`/servers/${id}/exec`, { command, timeoutMs }),
restart: (id: string) => api.post(`/servers/${id}/restart`, { confirm: true }),
discoverContainers: (id: string) => api.post<ContainerView[]>(`/servers/${id}/containers`),
containers: (id: string) => api.get<ContainerView[]>(`/servers/${id}/containers`),
stats: (id: string) =>
api.get<{
uptime: string | null;
cpuCores: number | null;
load: string | null;
memory: { usedMb: number; totalMb: number; usedPct: number } | null;
disk: { usedPct: number; totalKb: number } | null;
topProcesses: { command: string; memPct: number }[];
}>(`/servers/${id}/stats`),
images: (id: string) =>
api.get<{ repository: string; tag: string; size: string; created: string }[]>(`/servers/${id}/images`),
};
// ---------- Containers ----------
export const containerApi = {
list: () => api.get<ContainerView[]>("/containers"),
inspect: (id: string) => api.get<Record<string, unknown>>(`/containers/${id}/inspect`),
restart: (id: string) => api.post<{ restarted: boolean; name: string }>(`/containers/${id}/restart`, { confirm: true }),
start: (id: string) => api.post<{ start: boolean; name: string }>(`/containers/${id}/start`),
stop: (id: string) => api.post<{ stop: boolean; name: string }>(`/containers/${id}/stop`),
logs: (id: string, tail = 200) => api.get<{ name: string; lines: string[] }>(`/containers/${id}/logs`, { tail }),
stats: (id: string) =>
api.get<{ name: string; cpuPct: string; memUsage: string; memPct: string }>(`/containers/${id}/stats`),
};
// ---------- Git(Phase D) ----------
export interface GitConnectionView {
id: string;
resourceId: string;
name: string;
provider: string;
apiBase: string | null;
credentialId: string;
account?: string;
createdAt: string;
}
export interface RepositoryView {
id: string;
resourceId: string;
connectionId: string | null;
provider: string;
fullName: string;
cloneUrl: string;
defaultBranch: string;
visibility: string;
updatedAt: string;
}
export const gitApi = {
listConnections: () => api.get<GitConnectionView[]>("/git/connections"),
createConnection: (dto: { name: string; provider: string; apiBase?: string; credentialId: string }) =>
api.post<GitConnectionView>("/git/connections", dto),
deleteConnection: (id: string) => api.delete(`/git/connections/${id}`),
listRemoteRepos: (connectionId: string) =>
api.post<{ fullName: string; cloneUrl: string; defaultBranch: string; private: boolean; description?: string }[]>(
`/git/connections/${connectionId}/repos`,
{ fullName: "-" },
),
importRepo: (connectionId: string, fullName: string) =>
api.post<RepositoryView>(`/git/connections/${connectionId}/repos/import`, { fullName }),
listRepositories: () => api.get<RepositoryView[]>("/git/repositories"),
createRepository: (dto: { connectionId?: string; provider?: string; fullName: string; cloneUrl: string; defaultBranch?: string; visibility?: string }) =>
api.post<RepositoryView>("/git/repositories", dto),
deleteRepository: (id: string) => api.delete(`/git/repositories/${id}`),
listBranches: (id: string) => api.get<{ name: string; commit: string }[]>(`/git/repositories/${id}/branches`),
};
// ---------- Tasks(Phase E) ----------
export interface TaskView {
id: string;
resourceId: string;
title: string;
description: string;
status: string;
priority: string;
labels: string[];
dueAt: string | null;
projectId: string | null;
updatedAt: string;
}
export const taskApi = {
list: (params?: { status?: string; projectId?: string }) => api.get<TaskView[]>("/tasks", params),
create: (dto: { title: string; description?: string; status?: string; priority?: string; labels?: string[]; dueAt?: string; projectId?: string }) =>
api.post<TaskView>("/tasks", dto),
update: (id: string, dto: Partial<{ title: string; description: string; status: string; priority: string; labels: string[]; dueAt: string | null; projectId: string | null }>) =>
api.patch<TaskView>(`/tasks/${id}`, dto),
delete: (id: string) => api.delete(`/tasks/${id}`),
};
// ---------- Notes(Phase E) ----------
export interface NoteListItem {
id: string;
resourceId: string;
title: string;
tags: string[];
projectId: string | null;
pinned: boolean;
folder: string | null;
updatedAt: string;
}
export interface NoteView extends NoteListItem {
content: string;
}
export const noteApi = {
list: (params?: { projectId?: string; tag?: string; q?: string; folder?: string }) =>
api.get<NoteListItem[]>("/notes", params),
get: (id: string) => api.get<NoteView>(`/notes/${id}`),
create: (dto: { title: string; content?: string; tags?: string[]; projectId?: string }) => api.post<NoteView>("/notes", dto),
update: (id: string, dto: Partial<{ title: string; content: string; tags: string[]; projectId: string | null; pinned: boolean; folder: string | null }>) =>
api.patch<NoteView>(`/notes/${id}`, dto),
delete: (id: string) => api.delete(`/notes/${id}`),
ai: (id: string | null, dto: { action: string; selection?: string }) =>
api.post<{ action: string; result: string }>(`/notes/${id ? `${id}/ai` : "ai"}`, dto),
};
// ---------- Certificates(Phase E) ----------
export interface CertificateView {
id: string;
resourceId: string;
domain: string;
domainId: string | null;
status: string;
issuer: string | null;
notAfter: string | null;
daysRemaining: number | null;
expiringSoon: boolean;
lastCheckedAt: string | null;
}
export const certificateApi = {
list: () => api.get<CertificateView[]>("/certificates"),
create: (dto: { domain: string; domainId?: string; probe?: boolean }) => api.post<CertificateView>("/certificates", dto),
recheck: (id: string) => api.post<CertificateView & { probe: { ok: boolean; daysRemaining?: number; error?: string } }>(`/certificates/${id}/recheck`),
delete: (id: string) => api.delete(`/certificates/${id}`),
};
// ---------- Job Center(Phase C) ----------
export interface JobView {
id: string;
workspaceId: string;
type: string;
status: string;
payload: Record<string, unknown>;
result: Record<string, unknown> | null;
error: string | null;
attempts: number;
maxAttempts: number;
runAt: string;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
}
export const jobApi = {
list: (params?: { status?: string; type?: string; page?: number; pageSize?: number }) =>
api.get<Paged<JobView>>("/jobs", params),
get: (id: string) => api.get<JobView>(`/jobs/${id}`),
cancel: (id: string) => api.post(`/jobs/${id}/cancel`),
retry: (id: string) => api.post<{ retried: boolean; newJobId: string }>(`/jobs/${id}/retry`),
};
// ---------- 全局搜索 / 通知(Phase I) ----------
export interface SearchResultItem {
type: string;
name: string;
status: string;
path: string;
updatedAt: string;
}
export interface NotificationView {
id: string;
title: string;
body: string;
level: string;
readAt: string | null;
createdAt: string;
}
export const searchApi = {
query: (q: string) => api.get<SearchResultItem[]>("/search", { q }),
};
export const notificationApi = {
list: () => api.get<NotificationView[]>("/notifications"),
markRead: (id: string) => api.post(`/notifications/${id}/read`),
markAllRead: () => api.post(`/notifications/read-all`),
};
// ---------- Websites ----------
export interface WebsiteFullView extends WebsiteView {
server?: { id: string; name: string; host: string } | null;
}
export interface WebsiteDetail extends WebsiteFullView {
resource: {
status: string;
relations: { id: string; relationType: string; target?: ResourceLite; source?: ResourceLite }[];
};
}
export interface ResourceLite {
id: string;
type: string;
name: string;
status: string;
}
export const websiteApi = {
list: () => api.get<WebsiteFullView[]>("/websites"),
create: (dto: Record<string, unknown>) => api.post<WebsiteDetail>("/websites", dto),
get: (id: string) => api.get<WebsiteDetail>(`/websites/${id}`),
update: (id: string, dto: Record<string, unknown>) => api.patch<WebsiteFullView>(`/websites/${id}`, dto),
delete: (id: string) => api.delete(`/websites/${id}`),
deploy: (id: string) => api.post<DeploymentView>(`/websites/${id}/deploy`),
logs: (id: string, limit = 200) =>
api.get<{ deploymentId: string | null; lines: DeploymentLogLine[] }>(`/websites/${id}/logs`, { limit }),
health: (id: string) =>
api.get<{ ok: boolean; status?: number; latencyMs?: number; error?: string }>(`/websites/${id}/health`),
};
// ---------- Deployments ----------
export interface DeploymentFullView extends DeploymentView {
website?: { id: string; name: string };
error?: string;
}
export interface DeploymentDetail extends DeploymentFullView {
logs: DeploymentLogLine[];
}
export const deploymentApi = {
list: (websiteId?: string) => api.get<DeploymentFullView[]>("/deployments", { websiteId }),
get: (id: string) => api.get<DeploymentDetail>(`/deployments/${id}`),
create: (websiteId: string, branch?: string) => api.post<DeploymentView>("/deployments", { websiteId, branch }),
cancel: (id: string) => api.post<{ cancelled?: boolean; cancelling?: boolean; message?: string }>(`/deployments/${id}/cancel`),
};
// ---------- Domains ----------
export const domainApi = {
list: () => api.get<DomainView[]>("/domains"),
create: (dto: { name: string; provider: string; credentialId?: string }) => api.post<DomainView>("/domains", dto),
get: (id: string) => api.get<DomainView>(`/domains/${id}`),
delete: (id: string) => api.delete(`/domains/${id}`),
records: (id: string) =>
api.get<(DomainRecordView & { localId?: string })[]>(`/domains/${id}/records`),
upsertRecord: (id: string, dto: { type: string; name: string; value: string; ttl?: number; proxied?: boolean }) =>
api.post<DomainRecordView>(`/domains/${id}/records`, dto),
deleteRecord: (id: string, localId: string) => api.post(`/domains/${id}/records/${localId}/delete`),
};
// ---------- Monitors ----------
export const monitorApi = {
list: () => api.get<MonitorView[]>("/monitors"),
create: (dto: Record<string, unknown>) => api.post<MonitorView>("/monitors", dto),
get: (id: string) =>
api.get<MonitorView & { results: { ok: boolean; status?: string; latencyMs?: number; error?: string; createdAt: string }[] }>(`/monitors/${id}`),
update: (id: string, enabled: boolean) => api.patch<MonitorView>(`/monitors/${id}`, { enabled }),
delete: (id: string) => api.delete(`/monitors/${id}`),
};
// ---------- Automations ----------
export const automationApi = {
list: () => api.get<AutomationView[]>("/automations"),
create: (dto: Record<string, unknown>) => api.post<AutomationView>("/automations", dto),
get: (id: string) => api.get<AutomationView>(`/automations/${id}`),
enable: (id: string) => api.post<AutomationView>(`/automations/${id}/enable`),
disable: (id: string) => api.post<AutomationView>(`/automations/${id}/disable`),
run: (id: string) =>
api.post<{ runId: string; status: string; logs: AutomationRunView["logs"] }>(`/automations/${id}/run`),
update: (id: string, dto: Partial<{ name: string; enabled: boolean; conditions: unknown[]; actions: unknown[] }>) =>
api.patch<AutomationView>(`/automations/${id}`, dto),
delete: (id: string) => api.delete(`/automations/${id}`),
};
// ---------- AI ----------
export interface ChatToolCall {
tool: string;
args: Record<string, unknown>;
ok: boolean;
summary: string;
}
export interface ChatResponse {
reply: string;
engine: "llm" | "rules";
toolCalls: ChatToolCall[];
approval?: { runId: string; steps: { tool: string; args: Record<string, unknown>; risk: string }[] };
refs?: { type: string; id: string; name: string }[];
}
export const chatApi = {
send: (message: string, history: { role: "user" | "assistant"; content: string }[]) =>
api.post<ChatResponse>("/ai/chat", { message, history }),
/** # SSE 流式聊天(现代聊天体验:进度/工具/逐字回复) */
sendStream: async (
message: string,
history: { role: "user" | "assistant"; content: string }[],
onEvent: (event: string, data: unknown) => void,
signal?: AbortSignal,
): Promise<void> => {
const auth = useAuthStore.getState();
const isDev = !["", "80", "443", "4100"].includes(window.location.port);
const base = isDev ? "http://127.0.0.1:4100/api/v1" : "/api/v1";
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (auth.token) headers["Authorization"] = `Bearer ${auth.token}`;
if (auth.activeWorkspaceId) headers["X-Workspace-Id"] = auth.activeWorkspaceId;
const res = await fetch(`${base}/ai/chat/stream`, {
method: "POST",
headers,
body: JSON.stringify({ message, history }),
signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(text.slice(0, 200) || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let event = "message";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) data += line.slice(6);
}
if (data) {
try {
onEvent(event, JSON.parse(data));
} catch {
/* 忽略坏块 */
}
}
}
}
},
};
export const aiApi = {
list: () => api.get<AiRunView[]>("/ai/runs"),
create: (objective: string) => api.post<AiRunView>("/ai/runs", { objective }),
get: (id: string) => api.get<AiRunView>(`/ai/runs/${id}`),
approve: (id: string) => api.post<AiRunView>(`/ai/runs/${id}/approve`),
reject: (id: string) => api.post<AiRunView>(`/ai/runs/${id}/reject`),
};
// ---------- Credentials ----------
export interface CredentialView {
id: string;
resourceId: string | null;
name: string;
type: string;
provider: string | null;
lastUsedAt: string | null;
createdAt: string;
}
export const credentialApi = {
list: () => api.get<CredentialView[]>("/credentials"),
create: (dto: { name: string; type: string; provider?: string; secret: string }) =>
api.post<CredentialView>("/credentials", dto),
update: (id: string, dto: { name?: string; secret?: string }) => api.patch<CredentialView>(`/credentials/${id}`, dto),
delete: (id: string) => api.delete(`/credentials/${id}`),
};
// ---------- Resources / Audit / Events ----------
export const resourceApi = {
list: (params: { type?: string; status?: string; q?: string; page?: number; pageSize?: number }) =>
api.get<Paged<ResourceView>>("/resources", params),
create: (dto: { type: string; name: string; provider?: string; metadata?: Record<string, unknown> }) =>
api.post<ResourceView>("/resources", dto),
delete: (id: string) => api.delete(`/resources/${id}`),
get: (id: string) => api.get<Record<string, unknown>>(`/resources/${id}`),
relations: (id: string) =>
api.get<{ id: string; relationType: string; direction: string; source: ResourceLite; target: ResourceLite }[]>(
`/resources/${id}/relations`,
),
};
export const auditApi = {
list: (params: { page?: number; pageSize?: number; resourceType?: string; resourceId?: string }) =>
api.get<Paged<AuditItem>>("/audit", params),
};
export const eventApi = {
list: (params: { page?: number; pageSize?: number; eventType?: string }) => api.get<Paged<EventItem>>("/events", params),
};
+36
View File
@@ -0,0 +1,36 @@
/**
* # 认证状态(zustand + localStorage 持久化)
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { AuthUserView } from "@devos/types";
/** # 认证状态形状 **/
interface AuthState {
/** # JWT 令牌 **/
token: string | null;
/** # 当前用户 **/
user: AuthUserView | null;
/** # 激活工作区 ID **/
activeWorkspaceId: string | null;
setAuth: (token: string, user: AuthUserView) => void;
setUser: (user: AuthUserView) => void;
switchWorkspace: (workspaceId: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
activeWorkspaceId: null,
setAuth: (token, user) =>
set({ token, user, activeWorkspaceId: user.activeWorkspaceId }),
setUser: (user) => set({ user, activeWorkspaceId: user.activeWorkspaceId }),
switchWorkspace: (workspaceId) => set({ activeWorkspaceId: workspaceId }),
logout: () => set({ token: null, user: null, activeWorkspaceId: null }),
}),
{ name: "devos-auth" },
),
);
+28
View File
@@ -0,0 +1,28 @@
/**
* # 主题状态(zustand + localStorage 持久化)
* light / dark 全局主题;antd algorithm 与 ProLayout 均跟随此状态。
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
/** # 主题类型 **/
export type ThemeMode = "light" | "dark";
/** # 主题状态形状 **/
interface ThemeState {
/** # 当前主题 **/
mode: ThemeMode;
toggle: () => void;
setMode: (mode: ThemeMode) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
mode: "light",
toggle: () => set((s) => ({ mode: s.mode === "light" ? "dark" : "light" })),
setMode: (mode) => set({ mode }),
}),
{ name: "devos-theme" },
),
);
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}