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:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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" },
|
||||
),
|
||||
);
|
||||
@@ -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" },
|
||||
),
|
||||
);
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user