perf: 新增官方客户端Certd Client的多语言配置与页面入口

1. 新增官方客户端Certd Client的多语言配置与页面入口
2. 更新文档生态部分,将官方客户端与第三方客户端区分开
3. 移除旧的菜单组件与相关冗余代码,简化路由配置
4. 修复证书统计接口,按证书状态active进行计数
This commit is contained in:
xiaojunnuo
2026-08-15 22:38:29 +08:00
parent 5074572694
commit fbfe1256a5
15 changed files with 46 additions and 670 deletions
+8 -1
View File
@@ -141,7 +141,14 @@ https://certd.handfree.work/
## 五、生态
### 1. 客户端工具 SSL-Assistant
### 1. 官方客户端 Certd Client
`Certd Client` 是 Certd 官方证书部署客户端,运行于应用服务器上。自动发现本机 Nginx、Apache 和 IIS 站点,从 Certd 获取新证书并部署到本机。适用于不希望服务器开放 SSH、无法由 Certd 直接访问目标服务器的场景。
项目主页:[AtomGit](https://atomgit.com/certd/certd-client/) | [GitHub](https://github.com/certd/certd-client/)
下载地址:[AtomGit Releases](https://atomgit.com/certd/certd-client/releases) | [GitHub Releases](https://github.com/certd/certd-client/releases)
### 2. 第三方客户端 SSL-Assistant
`SSL Assistant` 是一个运行于主机上的证书部署管理助手客户端。
支持自动扫描主机`Nginx`配置,然后从`Certd`拉取证书并部署。
+8 -1
View File
@@ -123,7 +123,14 @@ You can choose one of the following deployment methods based on your needs:
## 5. Ecosystem
### 1. Client Tool: SSL-Assistant
### 1. Official Client: Certd Client
`Certd Client` is the official Certd certificate deployment client that runs on application servers. It automatically discovers local Nginx, Apache, and IIS sites, retrieves new certificates from Certd, and deploys them locally. It is designed for servers that should not expose SSH or cannot be accessed directly by Certd.
Project Home: [AtomGit](https://atomgit.com/certd/certd-client/) | [GitHub](https://github.com/certd/certd-client/)
Downloads: [AtomGit Releases](https://atomgit.com/certd/certd-client/releases) | [GitHub Releases](https://github.com/certd/certd-client/releases)
### 2. Third-Party Client: SSL-Assistant
`SSL Assistant` is a certificate deployment and management assistant client that runs on hosts. It supports automatic scanning of the host's `Nginx` configuration and pulling certificates from `Certd` for deployment. This tool is very useful when you don't want to expose your SSH host password.
+7 -1
View File
@@ -47,7 +47,13 @@ header中传入x-certd-token即可调用开放接口
## 客户端工具
### SSL-Assistant
### 官方客户端:Certd Client
`Certd Client` 是 Certd 的官方证书部署客户端。运行在应用服务器上,自动发现本机 Nginx、Apache 和 IIS 站点,然后从 Certd 获取新证书并部署到本机。适用于不希望服务器开放 SSH、无法由 Certd 直接访问目标服务器的场景。
项目主页:[AtomGit](https://atomgit.com/certd/certd-client/) | [GitHub](https://github.com/certd/certd-client/)
下载地址:[AtomGit Releases](https://atomgit.com/certd/certd-client/releases) | [GitHub Releases](https://github.com/certd/certd-client/releases)
### 第三方客户端:SSL-Assistant
`SSL Assistant` 是一个基于 Go 语言开发的跨平台证书部署管理助手。
支持自动扫描主机`Nginx`配置,然后从Certd拉取证书并部署。
在不想暴露ssh主机密码情况下,该工具非常好用。
@@ -1,15 +0,0 @@
.fs-menu-wrapper {
height: 100%;
overflow-y: auto;
&.fs-menu-better-scroll {
overflow-y: hidden;
}
.menu-item-title {
display: flex;
align-items: center;
.fs-icon {
font-size: 16px !important;
min-width: 16px !important;
}
}
}
@@ -1,124 +0,0 @@
<template>
<a-menu v-model:open-keys="openKeys" v-model:selected-keys="selectedKeys" class="fs-menu" mode="inline" theme="light" :items="items" @click="onClick" />
</template>
<script lang="tsx" setup>
import { ref, watch } from "vue";
import { routerUtils } from "/@/utils/util.router";
import { useRoute } from "vue-router";
import { utils } from "@fast-crud/fast-crud";
import * as _ from "lodash-es";
defineOptions({
name: "FsMenu",
});
const props = defineProps<{
menus: any[];
expandSelected: boolean;
}>();
const items = ref([]);
function buildItemMenus(menus: any) {
if (menus == null) {
return;
}
const list: any = [];
for (const sub of menus) {
if (sub.meta?.show != null) {
if (sub.meta.show === false || (typeof sub.meta.show === "function" && !sub.meta.show())) {
continue;
}
}
const item: any = {
key: sub.path,
label: sub.title,
title: sub.title,
icon: () => {
return <fsIcon icon={sub.icon ?? sub.meta?.icon} />;
},
};
list.push(item);
if (sub.children && sub.children.length > 0) {
item.children = buildItemMenus(sub.children);
}
}
return list;
}
watch(
() => props.menus,
menus => {
items.value = buildItemMenus(menus);
},
{ immediate: true }
);
async function onClick(item: any) {
await routerUtils.open(item.key);
}
const route = useRoute();
const selectedKeys = ref([]);
const openKeys = ref([]);
function openSelectedParents(fullPath: any) {
if (!props.expandSelected) {
return;
}
if (props.menus == null) {
return;
}
const keys: any = [];
let changed = false;
utils.deepdash.forEachDeep(props.menus, (value: any, key: any, parent: any, context: any) => {
if (value == null) {
return;
}
if (value.path === fullPath) {
_.forEach(context.parents, item => {
if (item.value instanceof Array) {
return;
}
keys.push(item.value.path);
});
}
});
if (keys.length > 0) {
for (const key of keys) {
if (openKeys.value.indexOf(key) === -1) {
openKeys.value.push(key);
changed = true;
}
}
}
return changed;
}
watch(
() => {
return route.fullPath;
},
path => {
// path = route.fullPath;
selectedKeys.value = [path];
const changed = openSelectedParents(path);
if (changed) {
// onOpenChange();
}
},
{
immediate: true,
}
);
</script>
<style lang="less">
.fs-menu {
height: 100%;
overflow-y: auto;
.fs-icon {
font-size: 16px !important;
min-width: 16px !important;
}
}
</style>
@@ -1,183 +0,0 @@
import { useRoute, useRouter } from "vue-router";
import { ref, watch, onMounted, onUnmounted, resolveComponent, nextTick, defineComponent } from "vue";
import * as _ from "lodash-es";
import BScroll from "better-scroll";
import "./index.less";
import { utils } from "@fast-crud/fast-crud";
import { routerUtils } from "/@/utils/util.router";
defineOptions();
export default defineComponent({
name: "FsMenu",
inheritAttrs: true,
props: {
menus: {},
expandSelected: {
default: false,
},
scroll: {},
},
setup(props, ctx) {
async function onSelect(item: any) {
await routerUtils.open(item.key);
}
const items = ref([]);
function buildItemMenus(menus: any) {
if (menus == null) {
return;
}
const list: any = [];
for (const sub of menus) {
const item: any = {
key: sub.path,
label: sub.title,
title: sub.title,
icon: () => {
return <fsIcon icon={sub.icon ?? sub.meta?.icon} />;
},
};
list.push(item);
if (sub.children && sub.children.length > 0) {
item.children = buildItemMenus(sub.children);
}
}
return list;
}
items.value = buildItemMenus(props.menus);
console.log("items", items.value);
const fsIcon = resolveComponent("FsIcon");
const buildMenus = (children: any) => {
const slots: any = [];
if (children == null) {
return slots;
}
for (const sub of children) {
if (sub.meta?.show != null) {
if (sub.meta.show === false || (typeof sub.meta.show === "function" && !sub.meta.show())) {
continue;
}
}
const title: any = () => {
const icon = sub.icon || sub?.meta?.icon;
if (icon) {
// @ts-ignore , anticon必须要有,不然不能折叠
return (
<div class={"menu-item-title"}>
<fsIcon class={"anticon"} icon={icon} />
<span>{sub.title}</span>
</div>
);
}
return sub.title;
};
if (sub.children && sub.children.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const subSlots = {
default: () => {
return buildMenus(sub.children);
},
title,
};
function onTitleClick() {
if (sub.path && ctx.attrs.mode === "horizontal") {
open(sub.path);
}
}
slots.push(<a-sub-menu key={sub.path} v-slots={subSlots} />);
} else {
slots.push(
<a-menu-item key={sub.path} title={sub.title}>
{title}
</a-menu-item>
);
}
}
return slots;
};
const slots = {
default() {
return buildMenus(props.menus);
},
};
const selectedKeys = ref([]);
const openKeys = ref([]);
const route = useRoute();
const router = useRouter();
function openSelectedParents(fullPath: any) {
if (!props.expandSelected) {
return;
}
if (props.menus == null) {
return;
}
const keys: any = [];
let changed = false;
utils.deepdash.forEachDeep(props.menus, (value: any, key: any, parent: any, context: any) => {
if (value == null) {
return;
}
if (value.path === fullPath) {
_.forEach(context.parents, item => {
if (item.value instanceof Array) {
return;
}
keys.push(item.value.path);
});
}
});
if (keys.length > 0) {
for (const key of keys) {
if (openKeys.value.indexOf(key) === -1) {
openKeys.value.push(key);
changed = true;
}
}
}
return changed;
}
// const { asideMenuRef, onOpenChange } = useBetterScroll(props.scroll as any);
watch(
() => {
return route.fullPath;
},
path => {
// path = route.fullPath;
selectedKeys.value = [path];
const changed = openSelectedParents(path);
if (changed) {
// onOpenChange();
}
},
{
immediate: true,
}
);
return () => {
const menu = (
<a-menu
mode={"inline"}
theme={"light"}
// v-slots={slots}
// onClick={onSelect}
// onOpenChange={onOpenChange}
v-models={[
[openKeys.value, "openKeys"],
[selectedKeys.value, "selectedKeys"],
]}
items={items.value}
inlineCollapsed={!props.expandSelected}
/>
);
const classNames = { "fs-menu-wrapper": true, "fs-menu-better-scroll": props.scroll };
return <div>{menu}</div>;
};
},
});
@@ -75,6 +75,9 @@ onMounted(async () => {
function goGithub() {
window.open("https://github.com/certd/certd");
}
function goCertdClient() {
window.open("https://github.com/certd/certd-client");
}
const settingsStore = useSettingStore();
const chatBox = ref();
const openChat = (q: string) => {
@@ -105,6 +108,9 @@ const projectStore = useProjectStore();
<div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full">
<vip-button class="flex-center header-btn" mode="nav" />
</div>
<div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full hidden md:block">
<fs-button shape="circle" type="text" icon="clarity:host-solid-badged" :text="null" :tooltip="{ title: t('certd.client') }" @click="goCertdClient" />
</div>
<div v-if="!settingStore.isComm" class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full hidden md:block">
<fs-button shape="circle" type="text" icon="ion:logo-github" :text="null" @click="goGithub" />
</div>
@@ -1,303 +0,0 @@
<template>
<a-layout class="fs-framework">
<a-layout-sider v-model:collapsed="asideCollapsed" :trigger="null" collapsible :width="210">
<div class="header-logo">
<img :src="siteInfo.logo" />
<span v-if="!asideCollapsed" class="title">{{ siteInfo.title }}</span>
</div>
<div class="aside-menu">
<fs-menu :scroll="true" :menus="asideMenus" :expand-selected="!asideCollapsed" />
</div>
</a-layout-sider>
<a-layout class="layout-body">
<a-layout-header class="header">
<div class="header-left header-buttons">
<div class="menu-fold" @click="asideCollapsedToggle">
<MenuUnfoldOutlined v-if="asideCollapsed" />
<MenuFoldOutlined v-else />
</div>
<!-- <fs-menu class="header-menu" mode="horizontal" :expand-selected="false" :selectable="false" :menus="frameworkMenus" />-->
<div
v-for="menu of resourceStore.authedTopMenus"
:key="menu.name"
class="top-menu flex-center header-btn"
:class="{ current: resourceStore.currentTopMenu === menu }"
:style="{ color: resourceStore.currentTopMenu === menu ? token.colorPrimary : '' }"
@click="menuClick(menu)"
>
<fs-icon :icon="menu.meta.icon"></fs-icon>
<span class="ml-5">{{ menu.title }} </span>
</div>
<tutorial-button v-if="!settingStore.isComm" class="flex-center header-btn" />
<vip-button class="flex-center header-btn" mode="nav" />
</div>
<div class="header-right header-buttons">
<!-- <button-->
<!-- w:bg="blue-400 hover:blue-500 dark:blue-500 dark:hover:blue-600"-->
<!-- w:text="sm white"-->
<!-- w:font="mono light"-->
<!-- w:p="y-2 x-4"-->
<!-- w:border="2 rounded blue-200"-->
<!-- >-->
<!-- Button-->
<!-- </button>-->
<fs-menu class="header-menu" mode="horizontal" :expand-selected="false" :selectable="false" :menus="settingStore.getHeaderMenus" />
<fs-menu v-if="!settingStore?.isAgent && !settingStore.isComm" class="header-menu" mode="horizontal" :expand-selected="false" :selectable="false" :menus="headerMenus" />
<div class="header-btn">
<fs-locale />
</div>
<!-- <div class="header-btn">-->
<!-- <fs-theme-mode-set />-->
<!-- </div>-->
<div class="header-btn">
<fs-theme-set />
</div>
<div class="header-btn">
<fs-user-info />
</div>
</div>
</a-layout-header>
<fs-tabs></fs-tabs>
<a-layout-content class="fs-framework-content">
<router-view>
<template #default="{ Component, route }">
<transition name="fade-transverse">
<keep-alive :include="keepAlive">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</template>
</router-view>
</a-layout-content>
<a-layout-footer class="fs-framework-footer">
<div>
<span v-if="!settingStore.isComm">
<span>Powered by</span>
<a href="https://certd.docmirror.cn/" target="_blank"> handfree.work </a>
</span>
<template v-if="siteInfo.licenseTo">
<a-divider type="vertical" />
<a :href="siteInfo.licenseToUrl || ''">{{ siteInfo.licenseTo }}</a>
</template>
<template v-if="sysPublic.icpNo">
<a-divider type="vertical" />
<span>
<a href="https://beian.miit.gov.cn/" target="_blank">{{ sysPublic.icpNo }}</a>
</span>
</template>
</div>
<div>v{{ version }}</div>
<!-- <fs-source-link />-->
</a-layout-footer>
</a-layout>
</a-layout>
</template>
<script lang="ts" setup>
import { computed, onErrorCaptured, onMounted, ref } from "vue";
import FsMenu from "./components/menu/index.vue";
import FsLocale from "./components/locale/index.vue";
import FsUserInfo from "./components/user-info/index.vue";
import FsTabs from "./components/tabs/index.vue";
import { useResourceStore } from "../store/modules/resource";
import { usePageStore } from "/@/store/modules/page";
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons-vue";
import FsThemeSet from "/@/layout/components/theme/index.vue";
import { env } from "../utils/util.env";
import VipButton from "/@/components/vip-button/index.vue";
import TutorialButton from "/@/components/tutorial/index.vue";
import { useUserStore } from "/@/store/user";
import { useSettingStore } from "/@/store/settings";
import { routerUtils } from "/@/utils/util.router";
import { theme } from "ant-design-vue";
const resourceStore = useResourceStore();
const frameworkMenus = computed(() => {
return resourceStore.getFrameworkMenus;
});
const headerMenus = computed(() => {
return resourceStore.getHeaderMenus;
});
const asideMenus = computed(() => {
return resourceStore.getAsideMenus;
});
const pageStore = usePageStore();
const keepAlive = pageStore.keepAlive;
const asideCollapsed = ref(false);
function asideCollapsedToggle() {
asideCollapsed.value = !asideCollapsed.value;
}
onErrorCaptured(e => {
console.error("ErrorCaptured:", e);
// notification.error({ message: e.message });
//阻止错误向上传递
return false;
});
const version = ref(import.meta.env.VITE_APP_VERSION);
const envRef = ref(env);
const userStore = useUserStore();
const settingStore = useSettingStore();
const sysPublic = computed(() => {
return settingStore.sysPublic;
});
const siteInfo = computed(() => {
return settingStore.siteInfo;
});
onMounted(async () => {
await settingStore.checkUrlBound();
});
function menuClick(menu: any) {
routerUtils.open(menu.path);
}
const { useToken } = theme;
const { token } = useToken();
</script>
<style lang="less">
@import "../style/theme/index.less";
.fs-framework {
height: 100%;
overflow-x: hidden;
min-width: 1200px;
.menu-fold {
display: flex;
justify-content: center;
align-items: center;
}
.header-logo {
width: 100%;
height: 50px;
display: flex;
justify-items: center;
align-items: center;
justify-content: center;
// margin: 16px 24px 16px 0;
//background: rgba(255, 255, 255, 0.3);
img {
height: 80%;
}
.title {
margin-left: 5px;
font-weight: bold;
}
}
.fs-framework-content {
flex: 1;
border-left: 1px solid #f0f0f0;
}
.fs-framework-footer {
border-left: 1px solid #f0f0f0;
padding: 10px 20px;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
background: #f6f6f6;
display: flex;
justify-content: space-between;
> div {
height: auto;
padding: 5px;
}
}
.ant-layout-header.header {
display: flex;
justify-content: space-between;
align-items: center;
.header-buttons {
display: flex;
align-items: center;
& > * {
cursor: pointer;
padding: 0 15px;
}
height: 100%;
& > .header-btn {
display: inline-flex;
justify-content: center;
align-items: center;
height: 100%;
//border-bottom: 1px solid rgba(255, 255, 255, 0);
&:hover {
background-color: rgba(0, 0, 0, 0.06);
}
&.current {
//color: #1890ff;
//background-color: hsla(0, 0%, 100%, 0.5) !important;
}
}
}
.header-right {
justify-content: flex-end;
align-items: center;
display: flex;
flex: 1;
}
}
.aside-menu {
flex: 1;
ui {
height: 100%;
}
overflow: hidden;
// overflow-y: auto;
}
.layout-body {
flex: 1;
}
}
//antdv
.fs-framework {
&.ant-layout {
flex-direction: row;
}
.ant-layout-sider-children {
display: flex;
flex-direction: column;
}
.ant-layout-sider {
// border-right: 1px solid #eee;
}
.ant-layout-header {
height: 50px;
padding: 0 10px;
line-height: 50px;
display: flex;
justify-content: flex-start;
align-items: center;
}
.ant-layout-content {
background: #fff;
height: 100%;
position: relative;
}
}
//element
.fs-framework {
.el-aside {
.el-menu {
height: 100%;
}
}
}
</style>
@@ -26,6 +26,7 @@ export default {
myTrade: "My Orders",
inviteCommission: "Incentive Plan",
paymentReturn: "Payment Return",
client: "Certd Client",
source: "Source Code",
github: "GitHub",
gitee: "Gitee",
@@ -27,6 +27,7 @@ export default {
myWallet: "我的钱包",
inviteCommission: "激励计划",
paymentReturn: "支付返回",
client: "主机自动拉取证书客户端",
source: "源码",
github: "github",
gitee: "gitee",
@@ -1,7 +1,6 @@
import LayoutPass from "/src/layout/layout-pass.vue";
import { cloneDeep } from "lodash-es";
import { outsideResource } from "./source/outside";
import { headerResource } from "./source/header";
import { frameworkResource } from "./source/framework";
const modules = import.meta.glob("/src/views/**/*.vue");
@@ -144,11 +143,9 @@ function flatSubRouters(routers: any) {
const frameworkRet = buildMenusAndRouters(frameworkResource);
const outsideRet = buildMenusAndRouters(outsideResource);
const headerRet = buildMenusAndRouters(headerResource);
const outsideRoutes = outsideRet.routes;
const frameworkRoutes = frameworkRet.routes;
const routes = [...outsideRoutes, ...frameworkRoutes];
const frameworkMenus = frameworkRet.menus;
const headerMenus = headerRet.menus;
export { routes, outsideRoutes, frameworkRoutes, frameworkMenus, headerMenus, findMenus, filterMenus };
export { routes, outsideRoutes, frameworkRoutes, frameworkMenus, findMenus, filterMenus };
@@ -1,35 +0,0 @@
import i18n from "/@/locales/i18n";
export const headerResource = [
{
title: "certd.helpDoc",
path: "https://certd.docmirror.cn",
meta: {
icon: "ion:document-text-outline",
},
},
{
title: "certd.source",
name: "source",
key: "source",
meta: {
icon: "ion:git-branch-outline",
},
children: [
{
title: "certd.github",
path: "https://github.com/certd/certd",
meta: {
icon: "ion:logo-github",
},
},
{
title: "certd.gitee",
path: "https://gitee.com/certd/certd",
meta: {
icon: "ion:logo-octocat",
},
},
],
},
];
@@ -8,6 +8,9 @@
</a-button>
</div>
</template>
<template #extra>
<fs-button shape="circle" type="text" icon="clarity:host-solid-badged" :text="null" :tooltip="{ title: 'certd无法访问主机?试试主动拉取证书的客户端CertdClient吧' }" @click="goCertdClient" />
</template>
<template v-if="currentTask">
<pi-container class="task-form-container">
<a-form ref="taskFormRef" class="task-form md:ml-20 md:mr-20" :model="currentTask" :label-col="labelCol" :wrapper-col="wrapperCol">
@@ -145,6 +148,10 @@ export default {
const selectedStepIds = ref<string[]>([]);
const stepBatchEditing = ref(false);
function goCertdClient() {
window.open("https://github.com/certd/certd-client");
}
function useStep() {
const stepFormRef: Ref<any> = ref(null);
const currentStepIndex = ref(0);
@@ -433,6 +440,7 @@ export default {
return {
userStore,
settingStore,
goCertdClient,
labelCol: { span: 4 },
wrapperCol: { span: 20 },
...useTaskForm(),
@@ -4,7 +4,7 @@ import { UserService } from "../../../modules/sys/authority/service/user-service
import { RoleService } from "../../../modules/sys/authority/service/role-service.js";
import { PipelineService } from "../../../modules/pipeline/service/pipeline-service.js";
import { HistoryService } from "../../../modules/pipeline/service/history-service.js";
import { CertInfoService } from "../../../modules/monitor/index.js";
import { CertInfoService, CertStatus } from "../../../modules/monitor/index.js";
import { ApiTags } from "@midwayjs/swagger";
export type ChartItem = {
@@ -56,7 +56,7 @@ export class StatisticController extends BaseController {
const historyCount = await this.historyService.countPerDay({ userId, projectId, days: 7 });
const expiringList = await this.pipelineService.latestExpiringList({ userId, projectId, count: 5 });
const certCount = await this.certInfoService.count({ userId, projectId });
const certCount = await this.certInfoService.count({ userId, projectId, status: CertStatus.active });
const count: UserStatisticCount = {
pipelineCount,
@@ -356,11 +356,12 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
});
}
async count({ userId, projectId }: { userId: number; projectId?: number }) {
async count({ userId, projectId,status }: { userId: number; projectId?: number,status?:string }) {
const userProjectQuery = this.buildUserProjectQuery(userId, projectId);
const total = await this.repository.count({
where: {
...userProjectQuery,
status,
expiresTime: Not(IsNull()),
},
});
@@ -368,6 +369,7 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
const expired = await this.repository.count({
where: {
...userProjectQuery,
status,
expiresTime: LessThan(new Date().getTime()),
},
});
@@ -375,6 +377,7 @@ export class CertInfoService extends BaseService<CertInfoEntity> {
const expiring = await this.repository.count({
where: {
...userProjectQuery,
status,
expiresTime: Between(new Date().getTime(), new Date().getTime() + 15 * 24 * 60 * 60 * 1000),
},
});