style: 统一Admin JSDoc规范
This commit is contained in:
parent
1d07233114
commit
523f96c3b2
@ -1,8 +1,3 @@
|
||||
/**
|
||||
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
|
||||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||
*/
|
||||
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type { UploadChangeParam, UploadFile, UploadProps } from 'antdv-next';
|
||||
|
||||
@ -10,12 +10,6 @@ type BlogPreviewEnv = {
|
||||
VITE_KT_BLOG_WEB_BASE_URL?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the public KT Blog Web URL used by the Admin preview iframe.
|
||||
* @param article Article data whose slug is the preferred public post route key.
|
||||
* @param articleId Admin article id retained in the query string for preview traceability.
|
||||
* @returns Absolute or same-origin iframe URL for the KT Blog Web post route.
|
||||
*/
|
||||
export function buildKtBlogPreviewUrl(
|
||||
article: BlogPreviewArticle,
|
||||
articleId: string,
|
||||
@ -33,11 +27,6 @@ export function buildKtBlogPreviewUrl(
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Blog Web preview origin and refuses silent localhost fallback in production.
|
||||
* @param env Vite import meta environment; tests pass a small object to cover production guards.
|
||||
* @returns Configured Blog Web base URL, or the local development default outside production.
|
||||
*/
|
||||
export function resolveKtBlogWebBaseUrl(env: BlogPreviewEnv) {
|
||||
const configured = env.VITE_KT_BLOG_WEB_BASE_URL?.trim();
|
||||
if (configured) {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { baseRequestClient, requestClient } from '#/api/request';
|
||||
|
||||
export namespace AuthApi {
|
||||
/** 登录接口参数 */
|
||||
export interface LoginParams {
|
||||
password?: string;
|
||||
username?: string;
|
||||
@ -12,7 +11,6 @@ export namespace AuthApi {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** 登录接口返回值 */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
wordpressAvailable?: boolean;
|
||||
|
||||
@ -28,10 +28,6 @@ const messagePushMenuNames = [
|
||||
'QqBotAccountMessagePushToggle',
|
||||
];
|
||||
|
||||
/**
|
||||
* Reads the private menu whitelist's literal source array without exporting it.
|
||||
* @returns Every string literal used to initialize `SUPPORTED_ADMIN_MENU_NAMES`.
|
||||
*/
|
||||
function getSupportedAdminMenuNameLiterals() {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
'menu.ts',
|
||||
|
||||
@ -549,10 +549,6 @@ const messagePushCallerNames = [
|
||||
'updateMessageTemplate',
|
||||
];
|
||||
|
||||
/**
|
||||
* Type-checks this focused contract spec with a virtual request client boundary.
|
||||
* @returns Deterministic TypeScript diagnostics for only this spec and `message-push.ts`.
|
||||
*/
|
||||
function getMessagePushContractDiagnostics() {
|
||||
const appRoot = resolve('apps/web-antdv-next');
|
||||
const configPath = resolve(appRoot, 'tsconfig.json');
|
||||
|
||||
@ -181,42 +181,24 @@ export namespace QqbotMessagePushApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists registered system message sources available to the Admin UI.
|
||||
* @returns Public source definitions without internal adapter details.
|
||||
*/
|
||||
export function getMessagePushSources() {
|
||||
return requestClient.get<QqbotMessagePushApi.SystemMessageSourceDefinition[]>(
|
||||
'/qqbot/message-push/sources',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one public source definition by its stable source key.
|
||||
* @param sourceKey - String source identity, encoded before becoming a URL segment.
|
||||
* @returns The requested source definition.
|
||||
*/
|
||||
export function getMessagePushSourceDetail(sourceKey: string) {
|
||||
return requestClient.get<QqbotMessagePushApi.SystemMessageSourceDefinition>(
|
||||
`/qqbot/message-push/sources/${encodeURIComponent(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads server-evaluated options for the built-in STUN port-change source.
|
||||
* @returns Eligible and disabled port-forward and DDNS options.
|
||||
*/
|
||||
export function getStunMappingPortChangedOptions() {
|
||||
return requestClient.get<QqbotMessagePushApi.StunMappingPortChangedOptionsResponse>(
|
||||
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages global message subscriptions using independent list filters.
|
||||
* @param params - Pagination, source, name, and enabled-state filters.
|
||||
* @returns A strict `{ items, total }` subscription page.
|
||||
*/
|
||||
export function getMessageSubscriptionList(
|
||||
params: QqbotMessagePushApi.MessageSubscriptionListQuery,
|
||||
) {
|
||||
@ -225,11 +207,6 @@ export function getMessageSubscriptionList(
|
||||
>('/qqbot/message-push/subscriptions', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one source-scoped global message subscription.
|
||||
* @param data - Complete subscription name, source, configuration, and enabled state.
|
||||
* @returns The persisted subscription view.
|
||||
*/
|
||||
export function createMessageSubscription(
|
||||
data: QqbotMessagePushApi.MessageSubscriptionInput,
|
||||
) {
|
||||
@ -239,12 +216,6 @@ export function createMessageSubscription(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one global subscription without coercing its string ID.
|
||||
* @param id - Stable subscription ID, encoded before becoming a URL segment.
|
||||
* @param data - Complete replacement subscription input.
|
||||
* @returns The updated subscription view.
|
||||
*/
|
||||
export function updateMessageSubscription(
|
||||
id: string,
|
||||
data: QqbotMessagePushApi.MessageSubscriptionInput,
|
||||
@ -255,12 +226,6 @@ export function updateMessageSubscription(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes whether a subscription matches future source events.
|
||||
* @param id - Stable subscription ID, encoded before becoming a URL segment.
|
||||
* @param enabled - Requested future enabled state.
|
||||
* @returns The updated subscription view.
|
||||
*/
|
||||
export function setMessageSubscriptionEnabled(id: string, enabled: boolean) {
|
||||
return requestClient.put<QqbotMessagePushApi.MessageSubscriptionView>(
|
||||
`/qqbot/message-push/subscriptions/${encodeURIComponent(id)}/enabled`,
|
||||
@ -268,22 +233,12 @@ export function setMessageSubscriptionEnabled(id: string, enabled: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes one global message subscription.
|
||||
* @param id - Stable subscription ID, encoded before becoming a URL segment.
|
||||
* @returns Whether the subscription was deleted.
|
||||
*/
|
||||
export function deleteMessageSubscription(id: string) {
|
||||
return requestClient.delete<boolean>(
|
||||
`/qqbot/message-push/subscriptions/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages global message templates using independent list filters.
|
||||
* @param params - Pagination, source, name, and enabled-state filters.
|
||||
* @returns A strict `{ items, total }` template page.
|
||||
*/
|
||||
export function getMessageTemplateList(
|
||||
params: QqbotMessagePushApi.MessageTemplateListQuery,
|
||||
) {
|
||||
@ -292,11 +247,6 @@ export function getMessageTemplateList(
|
||||
>('/qqbot/message-push/templates', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one source-scoped global message template.
|
||||
* @param data - Complete template content, source, metadata, and enabled state.
|
||||
* @returns The persisted template view.
|
||||
*/
|
||||
export function createMessageTemplate(
|
||||
data: QqbotMessagePushApi.MessageTemplateInput,
|
||||
) {
|
||||
@ -306,12 +256,6 @@ export function createMessageTemplate(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one global message template without coercing its string ID.
|
||||
* @param id - Stable template ID, encoded before becoming a URL segment.
|
||||
* @param data - Complete replacement template input.
|
||||
* @returns The updated template view.
|
||||
*/
|
||||
export function updateMessageTemplate(
|
||||
id: string,
|
||||
data: QqbotMessagePushApi.MessageTemplateInput,
|
||||
@ -322,12 +266,6 @@ export function updateMessageTemplate(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes whether a template can be selected for future bindings and events.
|
||||
* @param id - Stable template ID, encoded before becoming a URL segment.
|
||||
* @param enabled - Requested future enabled state.
|
||||
* @returns The updated template view.
|
||||
*/
|
||||
export function setMessageTemplateEnabled(id: string, enabled: boolean) {
|
||||
return requestClient.put<QqbotMessagePushApi.MessageTemplateView>(
|
||||
`/qqbot/message-push/templates/${encodeURIComponent(id)}/enabled`,
|
||||
@ -335,22 +273,12 @@ export function setMessageTemplateEnabled(id: string, enabled: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes one global message template when the backend permits it.
|
||||
* @param id - Stable template ID, encoded before becoming a URL segment.
|
||||
* @returns Whether the template was deleted.
|
||||
*/
|
||||
export function deleteMessageTemplate(id: string) {
|
||||
return requestClient.delete<boolean>(
|
||||
`/qqbot/message-push/templates/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a source-scoped message template using server-controlled example data.
|
||||
* @param data - Template content and source identity only.
|
||||
* @returns Safe rendered text and the example variables used.
|
||||
*/
|
||||
export function previewMessageTemplate(
|
||||
data: QqbotMessagePushApi.MessageTemplatePreviewInput,
|
||||
) {
|
||||
@ -360,23 +288,12 @@ export function previewMessageTemplate(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists message-push bindings belonging to exactly one QQBot account.
|
||||
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
|
||||
* @returns Account-scoped binding views with their configured targets.
|
||||
*/
|
||||
export function getAccountMessagePushBindings(selfId: string) {
|
||||
return requestClient.get<
|
||||
QqbotMessagePushApi.QqbotMessagePublishBindingView[]
|
||||
>(`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/bindings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one account-scoped binding from a global subscription and template.
|
||||
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
|
||||
* @param data - Subscription, template, target, and enabled-state input.
|
||||
* @returns The persisted account binding view.
|
||||
*/
|
||||
export function createAccountMessagePushBinding(
|
||||
selfId: string,
|
||||
data: QqbotMessagePushApi.QqbotMessagePublishBindingInput,
|
||||
@ -387,13 +304,6 @@ export function createAccountMessagePushBinding(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one binding for exactly one QQBot account.
|
||||
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
|
||||
* @param id - Stable binding ID, encoded before becoming a URL segment.
|
||||
* @param data - Subscription, template, target, and enabled-state input.
|
||||
* @returns The updated account binding view.
|
||||
*/
|
||||
export function updateAccountMessagePushBinding(
|
||||
selfId: string,
|
||||
id: string,
|
||||
@ -405,13 +315,6 @@ export function updateAccountMessagePushBinding(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes whether one account binding creates future delivery work.
|
||||
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
|
||||
* @param id - Stable binding ID, encoded before becoming a URL segment.
|
||||
* @param enabled - Requested future enabled state.
|
||||
* @returns The updated account binding view.
|
||||
*/
|
||||
export function setAccountMessagePushBindingEnabled(
|
||||
selfId: string,
|
||||
id: string,
|
||||
@ -423,23 +326,12 @@ export function setAccountMessagePushBindingEnabled(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes one binding from exactly one QQBot account.
|
||||
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
|
||||
* @param id - Stable binding ID, encoded before becoming a URL segment.
|
||||
* @returns Whether the account binding was deleted.
|
||||
*/
|
||||
export function deleteAccountMessagePushBinding(selfId: string, id: string) {
|
||||
return requestClient.delete<boolean>(
|
||||
`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/bindings/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists group and private-message target choices for exactly one QQBot account.
|
||||
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
|
||||
* @returns Availability state plus currently discoverable string-ID targets.
|
||||
*/
|
||||
export function getAccountMessagePushTargets(selfId: string) {
|
||||
return requestClient.get<QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse>(
|
||||
`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/targets`,
|
||||
|
||||
@ -246,9 +246,6 @@ export function getQqbotNapcatRuntimeDetail(accountId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a short-lived gateway session for opening one account's NapCat WebUI.
|
||||
*/
|
||||
export function createQqbotNapcatWebuiSession(
|
||||
data: QqbotNapcatApi.WebuiGatewaySessionCreateBody,
|
||||
) {
|
||||
@ -258,18 +255,12 @@ export function createQqbotNapcatWebuiSession(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends an active NapCat WebUI gateway session while the page is alive.
|
||||
*/
|
||||
export function heartbeatQqbotNapcatWebuiSession(sessionId: string) {
|
||||
return requestClient.post<QqbotNapcatApi.WebuiGatewayLifecycleResult>(
|
||||
`/qqbot/napcat/webui/session/${sessionId}/heartbeat`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a NapCat WebUI gateway session when the page leaves the WebUI view.
|
||||
*/
|
||||
export function revokeQqbotNapcatWebuiSession(sessionId: string) {
|
||||
return requestClient.post<QqbotNapcatApi.WebuiGatewayLifecycleResult>(
|
||||
`/qqbot/napcat/webui/session/${sessionId}/revoke`,
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
/**
|
||||
* 该文件可自行根据业务逻辑进行调整
|
||||
*/
|
||||
import type { AxiosResponseHeaders, RequestClientOptions } from '@vben/request';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
|
||||
@ -142,34 +142,18 @@ export namespace EnvironmentDashboardApi {
|
||||
| 'snapshot-required';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the aggregate Admin environment dashboard snapshot.
|
||||
*
|
||||
* @returns Dashboard response unwrapped by the shared Vben request client.
|
||||
*/
|
||||
export function getEnvironmentDashboard() {
|
||||
return requestClient.get<EnvironmentDashboardApi.EnvironmentDashboardResponse>(
|
||||
'/system/environment/dashboard',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the API read-only self-check endpoint without exposing write actions.
|
||||
*
|
||||
* @returns Fresh dashboard response after backend read-only probes complete.
|
||||
*/
|
||||
export function runEnvironmentSelfCheck() {
|
||||
return requestClient.post<EnvironmentDashboardApi.EnvironmentDashboardResponse>(
|
||||
'/system/environment/self-check',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the EventSource URL for API-emitted dashboard updates.
|
||||
*
|
||||
* @param lastEventId Optional SSE replay cursor held only in the current page instance.
|
||||
* @returns Absolute API URL when the request client exposes a base URL; otherwise a relative path.
|
||||
*/
|
||||
export function getEnvironmentDashboardEventsUrl(lastEventId?: string) {
|
||||
const query = lastEventId
|
||||
? `?lastEventId=${encodeURIComponent(lastEventId)}`
|
||||
@ -177,12 +161,6 @@ export function getEnvironmentDashboardEventsUrl(lastEventId?: string) {
|
||||
return buildApiUrl(`/system/environment/events/stream${query}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the existing API URL joining convention used by local SSE helpers.
|
||||
*
|
||||
* @param path API path produced by the environment dashboard wrapper.
|
||||
* @returns Browser-ready URL that respects proxy or absolute API base configuration.
|
||||
*/
|
||||
function buildApiUrl(path: string) {
|
||||
const getBaseUrl = (requestClient as unknown as { getBaseUrl?: () => string })
|
||||
.getBaseUrl;
|
||||
|
||||
@ -5,7 +5,6 @@ import { requestClient } from '#/api/request';
|
||||
import { isSupportedAdminMenuName } from '../core/menu';
|
||||
|
||||
export namespace SystemMenuApi {
|
||||
/** 徽标颜色集合 */
|
||||
export const BadgeVariants = [
|
||||
'default',
|
||||
'destructive',
|
||||
@ -13,9 +12,7 @@ export namespace SystemMenuApi {
|
||||
'success',
|
||||
'warning',
|
||||
] as const;
|
||||
/** 徽标类型集合 */
|
||||
export const BadgeTypes = ['dot', 'normal'] as const;
|
||||
/** 菜单类型集合 */
|
||||
export const MenuTypes = [
|
||||
'catalog',
|
||||
'menu',
|
||||
@ -23,73 +20,40 @@ export namespace SystemMenuApi {
|
||||
'link',
|
||||
'button',
|
||||
] as const;
|
||||
/** 系统菜单 */
|
||||
export interface SystemMenu {
|
||||
[key: string]: any;
|
||||
/** 后端权限标识 */
|
||||
authCode: string;
|
||||
/** 子级 */
|
||||
children?: SystemMenu[];
|
||||
/** 组件 */
|
||||
component?: string;
|
||||
/** 菜单ID */
|
||||
id: string;
|
||||
/** 菜单元数据 */
|
||||
meta?: {
|
||||
/** 激活时显示的图标 */
|
||||
activeIcon?: string;
|
||||
/** 作为路由时,需要激活的菜单的Path */
|
||||
activePath?: string;
|
||||
/** 固定在标签栏 */
|
||||
affixTab?: boolean;
|
||||
/** 在标签栏固定的顺序 */
|
||||
affixTabOrder?: number;
|
||||
/** 徽标内容(当徽标类型为normal时有效) */
|
||||
badge?: string;
|
||||
/** 徽标类型 */
|
||||
badgeType?: (typeof BadgeTypes)[number];
|
||||
/** 徽标颜色 */
|
||||
badgeVariants?: (typeof BadgeVariants)[number];
|
||||
/** 在菜单中隐藏下级 */
|
||||
hideChildrenInMenu?: boolean;
|
||||
/** 在面包屑中隐藏 */
|
||||
hideInBreadcrumb?: boolean;
|
||||
/** 在菜单中隐藏 */
|
||||
hideInMenu?: boolean;
|
||||
/** 在标签栏中隐藏 */
|
||||
hideInTab?: boolean;
|
||||
/** 菜单图标 */
|
||||
icon?: string;
|
||||
/** 内嵌Iframe的URL */
|
||||
iframeSrc?: string;
|
||||
/** 是否缓存页面 */
|
||||
keepAlive?: boolean;
|
||||
/** 外链页面的URL */
|
||||
link?: string;
|
||||
/** 同一个路由最大打开的标签数 */
|
||||
maxNumOfOpenTab?: number;
|
||||
/** 无需基础布局 */
|
||||
noBasicLayout?: boolean;
|
||||
/** 是否在新窗口打开 */
|
||||
openInNewWindow?: boolean;
|
||||
/** 菜单排序 */
|
||||
order?: number;
|
||||
/** 额外的路由参数 */
|
||||
query?: Recordable<any>;
|
||||
/** 菜单标题 */
|
||||
title?: string;
|
||||
};
|
||||
/** 菜单名称 */
|
||||
name: string;
|
||||
/** 路由路径 */
|
||||
path: string;
|
||||
/** 父级ID */
|
||||
pid: string;
|
||||
/** 重定向 */
|
||||
redirect?: string;
|
||||
/** 排序值 */
|
||||
sort?: number;
|
||||
/** 菜单类型 */
|
||||
type: (typeof MenuTypes)[number];
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,22 +193,12 @@ export namespace SystemNetworkApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the persisted automatic-DDNS record page for either address family.
|
||||
* @param params - Pagination and independent record/status filters.
|
||||
* @returns DDNS rows with API-provided FQDN and source state.
|
||||
*/
|
||||
export function getNetworkDdnsList(params: SystemNetworkApi.DdnsRecordQuery) {
|
||||
return requestClient.get<
|
||||
SystemNetworkApi.PageResult<SystemNetworkApi.DdnsRecord>
|
||||
>('/system/network/ddns/list', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads eligible and disabled source choices for one DNS address family.
|
||||
* @param recordType - A selects port-forward IPv4 sources; AAAA selects Agent IPv6.
|
||||
* @returns Server-controlled source options and their current address state.
|
||||
*/
|
||||
export function getNetworkDdnsSourceOptions(
|
||||
recordType: SystemNetworkApi.DdnsRecordType,
|
||||
) {
|
||||
@ -218,21 +208,12 @@ export function getNetworkDdnsSourceOptions(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the redacted DNSPod runtime readiness state.
|
||||
* @returns Provider name plus enabled/configured flags, never credentials.
|
||||
*/
|
||||
export function getNetworkDdnsProviderStatus() {
|
||||
return requestClient.get<SystemNetworkApi.DdnsProviderStatus>(
|
||||
'/system/network/ddns/provider-status',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one generic A or AAAA automatic-DDNS binding.
|
||||
* @param data - User-editable record and server-recognized source selector.
|
||||
* @returns Persisted DDNS record with asynchronous synchronization state.
|
||||
*/
|
||||
export function createNetworkDdnsRecord(
|
||||
data: SystemNetworkApi.DdnsRecordInput,
|
||||
) {
|
||||
@ -242,12 +223,6 @@ export function createNetworkDdnsRecord(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates one persisted automatic-DDNS binding.
|
||||
* @param id - Stable string record ID.
|
||||
* @param data - Complete editable DDNS input.
|
||||
* @returns Updated record with pending synchronization state.
|
||||
*/
|
||||
export function updateNetworkDdnsRecord(
|
||||
id: string,
|
||||
data: SystemNetworkApi.DdnsRecordInput,
|
||||
@ -258,33 +233,18 @@ export function updateNetworkDdnsRecord(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes only the local automatic-update binding, leaving DNSPod unchanged.
|
||||
* @param id - Stable string record ID.
|
||||
* @returns Deleted local record state.
|
||||
*/
|
||||
export function deleteNetworkDdnsRecord(id: string) {
|
||||
return requestClient.delete<SystemNetworkApi.DdnsRecord>(
|
||||
`/system/network/ddns/${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests one immediate provider reconciliation for an eligible source.
|
||||
* @param id - Stable string record ID.
|
||||
* @returns Latest DDNS synchronization state.
|
||||
*/
|
||||
export function retryNetworkDdnsRecord(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.DdnsRecord>(
|
||||
`/system/network/ddns/${id}/retry`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the persisted port-forward resource page from the API fact source.
|
||||
* @param params - Pagination and independent protocol/synchronization filters.
|
||||
* @returns Port-forward rows and the total record count.
|
||||
*/
|
||||
export function getNetworkPortForwardList(
|
||||
params: SystemNetworkApi.PortForwardQuery,
|
||||
) {
|
||||
@ -293,11 +253,6 @@ export function getNetworkPortForwardList(
|
||||
>('/system/network/port-forward/list', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a desired router mapping without exposing router credentials.
|
||||
* @param data - User-editable mapping fields.
|
||||
* @returns The persisted pending desired record.
|
||||
*/
|
||||
export function createNetworkPortForward(
|
||||
data: SystemNetworkApi.PortForwardInput,
|
||||
) {
|
||||
@ -307,12 +262,6 @@ export function createNetworkPortForward(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the editable desired fields of one active mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @param data - Complete editable mapping fields.
|
||||
* @returns The updated pending desired record.
|
||||
*/
|
||||
export function updateNetworkPortForward(
|
||||
id: string,
|
||||
data: SystemNetworkApi.PortForwardInput,
|
||||
@ -323,69 +272,36 @@ export function updateNetworkPortForward(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests confirmed asynchronous deletion of one managed mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @returns The deleting tombstone retained until Agent acknowledgement.
|
||||
*/
|
||||
export function deleteNetworkPortForward(id: string) {
|
||||
return requestClient.delete<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a new reconciliation revision for a failed or conflicted mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @returns The latest pending desired record.
|
||||
*/
|
||||
export function retryNetworkPortForward(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/retry`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables persistent UDP STUN observation and triggers an immediate probe.
|
||||
* @param id - Eligible UDP same-port mapping ID.
|
||||
* @returns The latest pending desired record.
|
||||
*/
|
||||
export function enableNetworkPortForwardKeeper(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/keeper/enable`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops future STUN renewals without deleting the router mapping.
|
||||
* @param id - UDP mapping ID whose Keeper is currently desired.
|
||||
* @returns The latest pending desired record.
|
||||
*/
|
||||
export function disableNetworkPortForwardKeeper(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/keeper/disable`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an idempotent request ID for one immediate UDP STUN cycle.
|
||||
* @param id - Enabled UDP same-port mapping ID.
|
||||
* @returns The latest desired record carrying the new request ID.
|
||||
*/
|
||||
export function probeNetworkPortForward(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/probe`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads append-only endpoint transition history for one mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @param params - History pagination values.
|
||||
* @param params.pageNo - One-based history page number.
|
||||
* @param params.pageSize - Maximum rows requested for the page.
|
||||
* @returns Endpoint transition records and their total count.
|
||||
*/
|
||||
export function getNetworkPortForwardEndpointHistory(
|
||||
id: string,
|
||||
params: { pageNo?: number; pageSize?: number },
|
||||
@ -395,21 +311,12 @@ export function getNetworkPortForwardEndpointHistory(
|
||||
>(`/system/network/port-forward/${id}/endpoint-history`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads independent Agent connectivity and revision convergence state.
|
||||
* @returns Current Agent status without inferring per-record failures.
|
||||
*/
|
||||
export function getNetworkAgentStatus() {
|
||||
return requestClient.get<SystemNetworkApi.AgentStatus>(
|
||||
'/system/network/agent/status',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the credentialed EventSource URL for committed network-state changes.
|
||||
* @param lastEventId - Optional replay cursor retained by the current route instance.
|
||||
* @returns Browser-ready SSE URL using the configured API base path.
|
||||
*/
|
||||
export function getNetworkManagementEventsUrl(lastEventId?: string) {
|
||||
const query = lastEventId
|
||||
? `?lastEventId=${encodeURIComponent(lastEventId)}`
|
||||
@ -417,11 +324,6 @@ export function getNetworkManagementEventsUrl(lastEventId?: string) {
|
||||
return buildApiUrl(`/system/network/events/stream${query}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins one network API path with the request client's configured base URL.
|
||||
* @param path - Relative API route for the EventSource connection.
|
||||
* @returns Absolute or proxy-relative URL matching normal Admin requests.
|
||||
*/
|
||||
function buildApiUrl(path: string) {
|
||||
const getBaseUrl = (requestClient as unknown as { getBaseUrl?: () => string })
|
||||
.getBaseUrl;
|
||||
|
||||
@ -81,15 +81,6 @@ export default defineComponent({
|
||||
name: 'KtTable',
|
||||
props: ktTableProps,
|
||||
emits: ['register'],
|
||||
/**
|
||||
* 初始化 KtTable 主组件,组装表单、按钮、列、分页、选择和注册式 API。
|
||||
*
|
||||
* @param rawProps 组件显式传入的 props,后续会和 register 配置合并。
|
||||
* @param emit Vue setup context。
|
||||
* @param emit.emit Vue 事件发送器,用于向业务侧暴露 register API。
|
||||
* @param emit.expose Vue 暴露实例方法的函数,用于模板 ref 直接访问表格 API。
|
||||
* @param emit.slots 业务侧传入的 title、toolbar、bodyCell、summary、footer 等插槽。
|
||||
*/
|
||||
setup(rawProps, { emit, expose, slots }) {
|
||||
const { props, setProps } = useKtTableResolvedProps(
|
||||
rawProps as KtTableProps,
|
||||
@ -662,9 +653,6 @@ export default defineComponent({
|
||||
loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染搜索表单区域和表单按钮。
|
||||
*/
|
||||
const renderSearchArea = () => {
|
||||
const hasSearch = (formOptions.value.schema?.length || 0) > 0;
|
||||
const hasFormButtons = formButtons.value.length > 0;
|
||||
@ -714,11 +702,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染操作列里的行操作按钮。
|
||||
*
|
||||
* @param record 当前行数据。
|
||||
*/
|
||||
const renderActionCell = (record: KtTableRecord) => {
|
||||
const { inlineActions, overflowActions } = splitRowActions(
|
||||
getVisibleRowActions(record),
|
||||
@ -797,9 +780,6 @@ export default defineComponent({
|
||||
return Math.max(0, Math.floor(visibleCount));
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染表格头部左侧业务按钮和 toolbar 插槽。
|
||||
*/
|
||||
const renderHeaderButtons = () => {
|
||||
const toolbar = slots.toolbar?.(context);
|
||||
const buttons = headerButtons.value.map((button) => renderButton(button));
|
||||
@ -820,9 +800,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染表格头部右侧设置按钮组。
|
||||
*/
|
||||
const renderHeaderSettings = () => {
|
||||
if (!props.showTableSetting) return null;
|
||||
|
||||
|
||||
@ -39,14 +39,6 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
emits: ['pageChange'],
|
||||
/**
|
||||
* 初始化表格底部选择状态和分页区域。
|
||||
*
|
||||
* @param props 底部分页、选择数量和显示开关配置。
|
||||
* @param emit Vue setup context。
|
||||
* @param emit.emit 分页变化事件发送器。
|
||||
* @param emit.slots 底部左侧自定义内容插槽。
|
||||
*/
|
||||
setup(props, { emit, slots }) {
|
||||
/**
|
||||
* 将 Antdv Pagination 的页码变化转成 KtTable 分页事件。
|
||||
|
||||
@ -35,13 +35,6 @@ export default defineComponent({
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 初始化表格头部区域。
|
||||
*
|
||||
* @param props 表格头部 props,目前用于接收默认标题。
|
||||
* @param slots Vue setup context。
|
||||
* @param slots.slots 头部标题、控制区、按钮和设置区插槽。
|
||||
*/
|
||||
setup(props, { slots }) {
|
||||
return () => {
|
||||
const slotTitle = resolveSlotContent(slots.title?.());
|
||||
|
||||
@ -30,14 +30,6 @@ export default defineComponent({
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 初始化可拖拽列宽表头单元格。
|
||||
*
|
||||
* @param props 表头单元格宽度和列宽变化回调。
|
||||
* @param attrs Vue setup context。
|
||||
* @param attrs.attrs Antdv Table 透传给 th 的原生属性。
|
||||
* @param attrs.slots 表头默认内容插槽。
|
||||
*/
|
||||
setup(props, { attrs, slots }) {
|
||||
const dragging = ref(false);
|
||||
const stopNextClick = ref(false);
|
||||
|
||||
@ -33,14 +33,6 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
emits: ['transitionEnd', 'transitionStart'],
|
||||
/**
|
||||
* 初始化搜索区域的展开/收起动画状态。
|
||||
*
|
||||
* @param props 搜索区显示状态和收起状态。
|
||||
* @param emit Vue setup context。
|
||||
* @param emit.emit 搜索动画开始和结束事件发送器。
|
||||
* @param emit.slots 搜索表单和操作按钮插槽。
|
||||
*/
|
||||
setup(props, { emit, slots }) {
|
||||
const shellRef = ref<HTMLElement | null>(null);
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
@ -76,13 +76,6 @@ export default defineComponent({
|
||||
'sizeChange',
|
||||
'visibleColumnKeysChange',
|
||||
],
|
||||
/**
|
||||
* 初始化 KtTable 设置栏内部状态和事件。
|
||||
*
|
||||
* @param props 组件入参,包含列配置、列顺序、可见列、密度、搜索显示和全屏状态。
|
||||
* @param emit Vue setup context。
|
||||
* @param emit.emit 组件事件发送器,用于把刷新、列设置、密度切换等操作同步给父级表格。
|
||||
*/
|
||||
setup(props, { emit }) {
|
||||
const draggingColumnKey = ref('');
|
||||
const dragOverColumnKey = ref('');
|
||||
@ -241,14 +234,6 @@ export default defineComponent({
|
||||
emit('sizeChange', next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染设置栏中的图标按钮。
|
||||
*
|
||||
* @param key 按钮渲染 key,用于让 Vue 稳定追踪按钮节点。
|
||||
* @param title 按钮提示文案,同时作为无障碍名称。
|
||||
* @param icon 按钮图标节点。
|
||||
* @param onClick 点击按钮时执行的回调函数。
|
||||
*/
|
||||
const renderIconButton = (
|
||||
key: string,
|
||||
title: string,
|
||||
@ -271,9 +256,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染列设置弹层和列设置触发按钮。
|
||||
*/
|
||||
const renderColumnSetting = () => {
|
||||
if (!props.setting.column) return null;
|
||||
|
||||
|
||||
@ -24,12 +24,6 @@ type RenderKtTableSummaryOptions = {
|
||||
statistics: KtTableStatistic[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染单个统计项内容。
|
||||
*
|
||||
* @param item 当前统计项配置。
|
||||
* @param context KtTable 运行时上下文,用于统计项读取行数据、搜索值和选择状态。
|
||||
*/
|
||||
const renderStatisticValue = (
|
||||
item: KtTableStatistic,
|
||||
context: KtTableContext,
|
||||
@ -48,13 +42,6 @@ const renderStatisticValue = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 summary 单元格内容。
|
||||
*
|
||||
* @param context KtTable 运行时上下文。
|
||||
* @param statistic 当前列匹配到的统计项配置。
|
||||
* @param showDefaultLabel 当前单元格是否需要显示默认“本页统计”文案。
|
||||
*/
|
||||
const renderCellContent = (
|
||||
context: KtTableContext,
|
||||
statistic: KtTableStatistic | undefined,
|
||||
@ -68,16 +55,6 @@ const renderCellContent = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 KtTable 底部固定 summary 行。
|
||||
*
|
||||
* @param options summary 渲染参数。
|
||||
* @param options.columns 当前最终展示的表格列。
|
||||
* @param options.context KtTable 运行时上下文。
|
||||
* @param options.customSummary 业务侧传入的自定义 summary 插槽。
|
||||
* @param options.showSelection 当前表格是否启用选择列。
|
||||
* @param options.statistics 当前表格和模块提供的统计项列表。
|
||||
*/
|
||||
export const renderKtTableSummary = (options: RenderKtTableSummaryOptions) => {
|
||||
const { columns, context, customSummary, showSelection, statistics } =
|
||||
options;
|
||||
|
||||
@ -36,11 +36,6 @@ export function useKtTable<
|
||||
return tableApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新表格 props;未注册时先缓存,注册后再一次性同步。
|
||||
*
|
||||
* @param nextProps 需要合并到表格上的 props 补丁,或基于当前 props 返回补丁的函数。
|
||||
*/
|
||||
const setProps: KtTableSetProps<Row, SearchValues> = (nextProps) => {
|
||||
if (tableApi) {
|
||||
tableApi.setProps(nextProps);
|
||||
@ -57,11 +52,6 @@ export function useKtTable<
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 接收 KtTable 组件实例暴露的 API,并同步注册前缓存的 props。
|
||||
*
|
||||
* @param api KtTable 组件注册时暴露的命令式 API。
|
||||
*/
|
||||
const register: KtTableRegisterFn<Row, SearchValues> = (api) => {
|
||||
tableApi = api;
|
||||
api.setProps(pendingProps);
|
||||
|
||||
@ -36,7 +36,6 @@ vi.mock('antdv-next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Creates the smallest action runtime needed to exercise row rendering. */
|
||||
function createActionRuntime() {
|
||||
const context = {
|
||||
formApi: {},
|
||||
|
||||
@ -77,9 +77,6 @@ export function useKtTableActions(options: UseKtTableActionsOptions) {
|
||||
customButtons.value.filter((button) => button.placement !== 'form'),
|
||||
);
|
||||
|
||||
/**
|
||||
* 生成 KtTable 默认查询和重置按钮。
|
||||
*/
|
||||
const getDefaultButtons = (): KtTableButton[] => {
|
||||
if (!props.showDefaultButtons) return [];
|
||||
|
||||
@ -102,12 +99,6 @@ export function useKtTableActions(options: UseKtTableActionsOptions) {
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染按钮图标,兼容静态图标和函数式图标。
|
||||
*
|
||||
* @param icon 按钮配置中的图标节点或图标渲染函数。
|
||||
* @param targetContext 图标渲染时使用的表格上下文。
|
||||
*/
|
||||
const renderIcon = (
|
||||
icon: KtTableButton['icon'],
|
||||
targetContext: KtTableContext = context,
|
||||
@ -205,11 +196,6 @@ export function useKtTableActions(options: UseKtTableActionsOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染头部或搜索区按钮。
|
||||
*
|
||||
* @param button 当前按钮配置。
|
||||
*/
|
||||
const renderButton = (button: KtTableButton) => {
|
||||
return (
|
||||
<AButton
|
||||
@ -226,12 +212,6 @@ export function useKtTableActions(options: UseKtTableActionsOptions) {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染单行操作按钮。
|
||||
*
|
||||
* @param action 当前行操作配置。
|
||||
* @param row 当前行数据。
|
||||
*/
|
||||
const renderRowAction = (action: KtTableRowAction, row: KtTableRecord) => {
|
||||
const disabled =
|
||||
typeof action.disabled === 'function'
|
||||
@ -262,14 +242,6 @@ export function useKtTableActions(options: UseKtTableActionsOptions) {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a readable disabled reason only while a row action is disabled.
|
||||
*
|
||||
* @param action Current row action configuration.
|
||||
* @param row Current table row.
|
||||
* @param disabled Resolved disabled state for this row.
|
||||
* @returns Tooltip text, or undefined when no explanation should render.
|
||||
*/
|
||||
function resolveDisabledReason(
|
||||
action: KtTableRowAction,
|
||||
row: KtTableRecord,
|
||||
|
||||
@ -397,11 +397,6 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
|
||||
return {
|
||||
...column,
|
||||
ellipsis: column.ellipsis ?? true,
|
||||
/**
|
||||
* 合并业务侧 header cell 配置和 KtTable 列宽拖拽配置。
|
||||
*
|
||||
* @param targetColumn Antdv 传入的当前表头列配置。
|
||||
*/
|
||||
onHeaderCell: (targetColumn: TableColumnType<KtTableRecord>) => {
|
||||
const originalProps = (originalHeaderCell?.(targetColumn) ||
|
||||
{}) as Record<string, any>;
|
||||
@ -421,12 +416,6 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
|
||||
width: nextWidth,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 合并业务侧 body cell 配置和 KtTable 列最小宽度配置。
|
||||
*
|
||||
* @param record 当前行数据。
|
||||
* @param index 当前行在本页的下标。
|
||||
*/
|
||||
onCell: (record: KtTableRecord, index?: number) => {
|
||||
const originalProps = (originalCell?.(record, index) || {}) as Record<
|
||||
string,
|
||||
|
||||
@ -78,11 +78,6 @@ export function useKtTableResolvedProps(rawProps: KtTableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 register 模式下的动态 props,并同步最终 props。
|
||||
*
|
||||
* @param nextProps 需要合并的 props 补丁,或基于当前 props 返回补丁的函数。
|
||||
*/
|
||||
const setProps: KtTableSetProps = (nextProps) => {
|
||||
const patch =
|
||||
typeof nextProps === 'function' ? nextProps({ ...props }) : nextProps;
|
||||
|
||||
@ -23,9 +23,6 @@ type TiptapExpose = ComponentPublicInstance & {
|
||||
setHtml: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 模拟 Tiptap Editor 的最小行为,支持组件测试 HTML 同步和禁用态。
|
||||
*/
|
||||
class FakeEditor {
|
||||
public html = '';
|
||||
|
||||
@ -46,11 +43,6 @@ class FakeEditor {
|
||||
|
||||
public options: FakeEditorOptions;
|
||||
|
||||
/**
|
||||
* 记录传入配置并初始化测试用 HTML。
|
||||
*
|
||||
* @param options 编辑器初始化配置,测试只关心内容、可编辑态和更新回调。
|
||||
*/
|
||||
constructor(options: FakeEditorOptions) {
|
||||
this.options = options;
|
||||
this.html = options.content || '';
|
||||
@ -126,11 +118,6 @@ vi.mock('@tiptap/vue-3', () => ({
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 渲染当前 FakeEditor HTML,使测试能观察组件传入内容。
|
||||
*
|
||||
* @param props EditorContent 入参,包含当前编辑器实例。
|
||||
*/
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', {
|
||||
@ -157,15 +144,6 @@ vi.mock('antdv-next', () => ({
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
/**
|
||||
* 用原生 button 替代 antdv-next Button,保留 attrs/disabled/click。
|
||||
*
|
||||
* @param props 按钮入参,测试只读取 disabled。
|
||||
* @param context Vue setup context,透传 attrs 和 slots。
|
||||
* @param context.attrs 透传到原生 button 的属性。
|
||||
* @param context.emit click 事件发送器。
|
||||
* @param context.slots 按钮默认插槽。
|
||||
*/
|
||||
setup(props, { attrs, emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
@ -182,9 +160,6 @@ vi.mock('antdv-next', () => ({
|
||||
}),
|
||||
Divider: defineComponent({
|
||||
name: 'MockDivider',
|
||||
/**
|
||||
* 渲染工具栏分隔符占位。
|
||||
*/
|
||||
setup() {
|
||||
return () => h('span', { class: 'mock-divider' });
|
||||
},
|
||||
@ -198,13 +173,6 @@ vi.mock('antdv-next', () => ({
|
||||
},
|
||||
},
|
||||
emits: ['update:value'],
|
||||
/**
|
||||
* 用 input 替代 antdv-next Input,保留 value 双向更新。
|
||||
*
|
||||
* @param props 输入框入参,测试只使用 value。
|
||||
* @param context Vue setup context,发送 update:value。
|
||||
* @param context.emit update:value 事件发送器。
|
||||
*/
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('input', {
|
||||
@ -224,13 +192,6 @@ vi.mock('antdv-next', () => ({
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'ok', 'update:open'],
|
||||
/**
|
||||
* 模拟 Modal,只在 open=true 时渲染内容。
|
||||
*
|
||||
* @param props 弹窗入参,测试仅关心 open。
|
||||
* @param context Vue setup context。
|
||||
* @param context.slots 弹窗默认插槽。
|
||||
*/
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
props.open ? h('div', { role: 'dialog' }, slots.default?.()) : null;
|
||||
@ -238,13 +199,6 @@ vi.mock('antdv-next', () => ({
|
||||
}),
|
||||
Space: defineComponent({
|
||||
name: 'MockSpace',
|
||||
/**
|
||||
* 以 div 包装 Space 内容。
|
||||
*
|
||||
* @param _props 未使用的 Space props。
|
||||
* @param context Vue setup context,读取默认 slot。
|
||||
* @param context.slots Space 默认插槽。
|
||||
*/
|
||||
setup(_props, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
@ -257,13 +211,6 @@ vi.mock('antdv-next', () => ({
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 直接渲染 Tooltip 默认 slot,避免测试依赖浮层实现。
|
||||
*
|
||||
* @param _props 未使用的 Tooltip props。
|
||||
* @param context Vue setup context,读取默认 slot。
|
||||
* @param context.slots Tooltip 默认插槽。
|
||||
*/
|
||||
setup(_props, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
@ -279,11 +226,6 @@ vi.mock('@vben/icons', () => ({
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 渲染可识别的图标占位。
|
||||
*
|
||||
* @param props 图标入参,测试不读取但保留 data 属性。
|
||||
*/
|
||||
setup(props) {
|
||||
return () => h('i', { 'data-icon': props.icon });
|
||||
},
|
||||
@ -350,9 +292,6 @@ describe('ktTiptapHtmlEditor', () => {
|
||||
it('syncs setHtml changes through a v-model parent', async () => {
|
||||
const Parent = defineComponent({
|
||||
name: 'TiptapParentHarness',
|
||||
/**
|
||||
* 建立父子 v-model 绑定,验证编辑器 emit 会回写父级状态。
|
||||
*/
|
||||
setup() {
|
||||
const html = ref('<p>旧正文</p>');
|
||||
const editorRef = ref<null | TiptapExpose>(null);
|
||||
|
||||
@ -98,12 +98,6 @@ function readEditorHtml(editor: Editor | null | undefined, fallback: string) {
|
||||
return editor?.getHTML() || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染工具栏图标,统一使用 Iconify 图标来源。
|
||||
*
|
||||
* @param icon 图标名称,遵循 Vben/Iconify 的 lucide 命名。
|
||||
* @returns 图标节点。
|
||||
*/
|
||||
const renderIcon = (icon: string) => (
|
||||
<IconifyIcon class="kt-tiptap-editor__toolbar-icon" icon={icon} />
|
||||
);
|
||||
@ -138,14 +132,6 @@ export default defineComponent({
|
||||
focus: () => true,
|
||||
'update:modelValue': (_value: string) => true,
|
||||
},
|
||||
/**
|
||||
* 初始化 Tiptap HTML 编辑器,桥接 toolbar 命令、v-model 和 expose API。
|
||||
*
|
||||
* @param props 组件入参,包含 HTML、禁用态、占位符和最小高度。
|
||||
* @param context Vue setup context,用于发送编辑器事件并暴露实例方法。
|
||||
* @param context.emit 组件事件发送器,用于同步 HTML、change、focus 和 blur。
|
||||
* @param context.expose 组件方法暴露器,用于公开编辑器实例与 HTML 读写方法。
|
||||
*/
|
||||
setup(props, { emit, expose }) {
|
||||
const currentHtml = ref(props.modelValue || '');
|
||||
const linkModalOpen = ref(false);
|
||||
@ -417,12 +403,6 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 渲染一个工具栏命令按钮。
|
||||
*
|
||||
* @param command 当前命令配置。
|
||||
* @returns 工具栏按钮节点。
|
||||
*/
|
||||
const renderCommandButton = (command: ToolbarCommand): VNodeChild => {
|
||||
const currentEditor = editor.value;
|
||||
const active = currentEditor ? command.isActive?.(currentEditor) : false;
|
||||
@ -447,9 +427,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染格式工具栏,按文本、段落、插入、历史分组。
|
||||
*/
|
||||
const renderToolbar = () => {
|
||||
const groups = [
|
||||
toolbarCommands.slice(0, 4),
|
||||
@ -473,9 +450,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染链接与图片输入弹窗。
|
||||
*/
|
||||
const renderModals = () => (
|
||||
<>
|
||||
<AModal
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import { defineOverridesPreferences } from '@vben/preferences';
|
||||
|
||||
/**
|
||||
* @description 项目配置文件
|
||||
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
|
||||
* !!! 更改配置后请清空缓存,否则可能不生效
|
||||
*/
|
||||
export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
app: {
|
||||
|
||||
@ -1,29 +1,14 @@
|
||||
export const ADMIN_SSO_DEFAULT_REDIRECT = '/blog/article';
|
||||
|
||||
/**
|
||||
* Reads a scalar router query value while tolerating Vue Router array values.
|
||||
* @param value Raw route-query value.
|
||||
* @returns First string value or an empty string when absent.
|
||||
*/
|
||||
function readQueryValue(value: unknown) {
|
||||
const scalar = Array.isArray(value) ? value[0] : value;
|
||||
return typeof scalar === 'string' ? scalar : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the explicit cross-site Admin SSO bootstrap flag.
|
||||
* @param value Raw `sso` route-query value.
|
||||
* @returns Whether silent Admin-cookie restoration should be attempted.
|
||||
*/
|
||||
export function isAdminSsoRequest(value: unknown) {
|
||||
return readQueryValue(value) === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the fixed Blog management landing route without accepting open redirects.
|
||||
* @param value Raw `redirect` route-query value, optionally URL-encoded once.
|
||||
* @returns The allow-listed Blog article management route.
|
||||
*/
|
||||
export function resolveAdminSsoRedirect(value: unknown) {
|
||||
const rawValue = readQueryValue(value);
|
||||
let decodedValue = rawValue;
|
||||
|
||||
@ -203,9 +203,7 @@ function setupAccessGuard(router: Router) {
|
||||
* @param router
|
||||
*/
|
||||
function createRouterGuard(router: Router) {
|
||||
/** 通用 */
|
||||
setupCommonGuard(router);
|
||||
/** 权限访问 */
|
||||
setupAccessGuard(router);
|
||||
}
|
||||
|
||||
|
||||
@ -9,9 +9,6 @@ import { resetStaticRoutes } from '@vben/utils';
|
||||
import { createRouterGuard } from './guard';
|
||||
import { routes } from './routes';
|
||||
|
||||
/**
|
||||
* @zh_CN 创建vue-router实例
|
||||
*/
|
||||
const router = createRouter({
|
||||
history:
|
||||
import.meta.env.VITE_ROUTER_HISTORY === 'hash'
|
||||
|
||||
@ -7,7 +7,6 @@ import { $t } from '#/locales';
|
||||
|
||||
const BasicLayout = () => import('#/layouts/basic.vue');
|
||||
const AuthPageLayout = () => import('#/layouts/auth.vue');
|
||||
/** 全局404页面 */
|
||||
const fallbackNotFoundRoute: RouteRecordRaw = {
|
||||
component: () => import('#/views/_core/fallback/not-found.vue'),
|
||||
meta: {
|
||||
@ -20,13 +19,7 @@ const fallbackNotFoundRoute: RouteRecordRaw = {
|
||||
path: '/:path(.*)*',
|
||||
};
|
||||
|
||||
/** 基本路由,这些路由是必须存在的 */
|
||||
const coreRoutes: RouteRecordRaw[] = [
|
||||
/**
|
||||
* 根路由
|
||||
* 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。
|
||||
* 此路由必须存在,且不应修改
|
||||
*/
|
||||
{
|
||||
component: BasicLayout,
|
||||
meta: {
|
||||
|
||||
@ -12,27 +12,21 @@ const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
|
||||
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
|
||||
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
|
||||
|
||||
/** 动态路由 */
|
||||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
||||
|
||||
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
|
||||
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
||||
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
||||
const staticRoutes: RouteRecordRaw[] = [];
|
||||
const externalRoutes: RouteRecordRaw[] = [];
|
||||
|
||||
/** 路由列表,由基本路由、外部路由和404兜底路由组成
|
||||
* 无需走权限验证(会一直显示在菜单中) */
|
||||
const routes: RouteRecordRaw[] = [
|
||||
...coreRoutes,
|
||||
...externalRoutes,
|
||||
fallbackNotFoundRoute,
|
||||
];
|
||||
|
||||
/** 基本路由列表,这些路由不需要进入权限拦截 */
|
||||
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
|
||||
|
||||
/** 有权限校验的路由列表,包含动态路由和静态路由 */
|
||||
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
|
||||
|
||||
const componentKeys: string[] = Object.keys({
|
||||
|
||||
@ -27,11 +27,6 @@ const messagePushRoutes: ExpectedMessagePushRoute[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the text key of a non-computed object-literal property.
|
||||
* @param property - Candidate route object property from the parsed source.
|
||||
* @returns The literal property key, or `undefined` when it cannot be read statically.
|
||||
*/
|
||||
function getPropertyName(property: ts.ObjectLiteralElementLike) {
|
||||
if (!ts.isPropertyAssignment(property) || property.name === undefined) {
|
||||
return undefined;
|
||||
@ -42,12 +37,6 @@ function getPropertyName(property: ts.ObjectLiteralElementLike) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one directly declared property on a route object.
|
||||
* @param object - Route object literal to inspect.
|
||||
* @param name - Required literal property key.
|
||||
* @returns The matching property assignment, if present.
|
||||
*/
|
||||
function getProperty(object: ts.ObjectLiteralExpression, name: string) {
|
||||
return object.properties.find(
|
||||
(property): property is ts.PropertyAssignment =>
|
||||
@ -55,12 +44,6 @@ function getProperty(object: ts.ObjectLiteralExpression, name: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a required string-literal property from a route object.
|
||||
* @param object - Route object literal to inspect.
|
||||
* @param name - Required literal property key.
|
||||
* @returns The declared string value, or `undefined` when it is not a string literal.
|
||||
*/
|
||||
function getStringProperty(object: ts.ObjectLiteralExpression, name: string) {
|
||||
const property = getProperty(object, name);
|
||||
|
||||
@ -69,11 +52,6 @@ function getStringProperty(object: ts.ObjectLiteralExpression, name: string) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the literal target from a route's lazy dynamic import without invoking it.
|
||||
* @param object - Route object literal to inspect.
|
||||
* @returns The module path passed to `import()`.
|
||||
*/
|
||||
function getLazyComponentPath(object: ts.ObjectLiteralExpression) {
|
||||
const component = getProperty(object, 'component');
|
||||
|
||||
@ -91,11 +69,6 @@ function getLazyComponentPath(object: ts.ObjectLiteralExpression) {
|
||||
return (importCall.arguments[0] as ts.StringLiteral).text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the direct `QqBot.children` array in the route initializer.
|
||||
* @param sourceFile - Parsed QQBot route module source.
|
||||
* @returns The direct child route objects of the `QqBot` parent.
|
||||
*/
|
||||
function getQqBotChildren(sourceFile: ts.SourceFile) {
|
||||
const routesDeclaration = sourceFile.statements.find(
|
||||
(statement): statement is ts.VariableStatement => {
|
||||
@ -139,16 +112,10 @@ function getQqBotChildren(sourceFile: ts.SourceFile) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every route object that declares one of the locked message-push names.
|
||||
* @param sourceFile - Parsed QQBot route module source.
|
||||
* @returns All matching objects, including accidental nested or duplicate routes.
|
||||
*/
|
||||
function getAllMessagePushRoutes(sourceFile: ts.SourceFile) {
|
||||
const routeNames = new Set(messagePushRoutes.map((route) => route.name));
|
||||
const matches: ts.ObjectLiteralExpression[] = [];
|
||||
|
||||
/** Visits route objects so nested copies cannot evade the direct-child assertion. */
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isObjectLiteralExpression(node)) {
|
||||
const routeName = getStringProperty(node, 'name');
|
||||
|
||||
@ -178,10 +178,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the Admin client session from its origin-scoped HttpOnly refresh cookie.
|
||||
* @returns Whether a fresh access token was restored; failures clear stale client identity state.
|
||||
*/
|
||||
async function restoreSessionFromCookie() {
|
||||
if (accessStore.accessToken) return true;
|
||||
|
||||
|
||||
@ -7,12 +7,6 @@ import { describe, expect, it } from 'vitest';
|
||||
const source = readFileSync(new URL('list.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('blog article list preview entry', () => {
|
||||
/**
|
||||
* Reads a source slice between two stable markers for modal ordering guards.
|
||||
* @param start Marker before the function body under assertion.
|
||||
* @param end Marker after the function body under assertion.
|
||||
* @returns Source slice used by regression assertions.
|
||||
*/
|
||||
function getSourceSlice(start: string, end: string) {
|
||||
const startIndex = source.indexOf(start);
|
||||
const endIndex = source.indexOf(end, startIndex + start.length);
|
||||
|
||||
@ -393,39 +393,21 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies editor schema and form values after the modal has opened so the modal-contained form can mount first.
|
||||
* @param values Article values selected for the current modal session.
|
||||
*/
|
||||
async function resetArticleModalForm(values: BlogArticleFormValues) {
|
||||
await setArticleEditorMode(values.editorMode || 'markdown');
|
||||
await resetArticleForm(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets modal form state before applying create or edit values.
|
||||
* @param values Article values selected for the current modal session.
|
||||
*/
|
||||
async function resetArticleForm(values: BlogArticleFormValues) {
|
||||
await articleFormApi.resetForm();
|
||||
await articleFormApi.setValues(values);
|
||||
await articleFormApi.resetValidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles editor mode changes from the form control without clearing the current content draft.
|
||||
* @param mode Editor mode selected by the user.
|
||||
*/
|
||||
function handleArticleEditorModeChange(mode: BlogArticleEditorMode) {
|
||||
void setArticleEditorMode(mode, { preserveContent: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the content field between Markdown, rich HTML, and raw WordPress HTML editing.
|
||||
* @param mode Editor mode selected for the current modal session.
|
||||
* @param options Whether the current content draft should be reapplied after schema replacement.
|
||||
* @param options.preserveContent Reapplies the existing content value after the editor component changes.
|
||||
*/
|
||||
async function setArticleEditorMode(
|
||||
mode: BlogArticleEditorMode,
|
||||
options: { preserveContent?: boolean } = {},
|
||||
@ -458,10 +440,6 @@ export default defineComponent({
|
||||
await articleFormApi.setValues(nextValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the article modal in Markdown authoring mode with current table filters as defaults.
|
||||
* @param context Optional table context supplied by a toolbar button.
|
||||
*/
|
||||
async function openCreate(
|
||||
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
|
||||
) {
|
||||
@ -474,10 +452,6 @@ export default defineComponent({
|
||||
articleModalApi.setData({ values }).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the article modal using raw HTML mode for imported WordPress/Argon content.
|
||||
* @param row Article row selected from the table.
|
||||
*/
|
||||
function openEdit(row: WordpressBlogApi.Article) {
|
||||
editingId.value = `${row.id}`;
|
||||
const values = getBlogArticleEditFormValues(row);
|
||||
@ -496,9 +470,6 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and submits the article form with the active content format preserved.
|
||||
*/
|
||||
async function submitArticle() {
|
||||
const { valid } = await articleFormApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
@ -27,9 +27,6 @@ const articleStatusOptions = [
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BlogArticlePreview',
|
||||
/**
|
||||
* Wires the current article route id to a full-bleed Blog Web iframe preview.
|
||||
*/
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -64,16 +61,10 @@ export default defineComponent({
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigates back to the Blog article list.
|
||||
*/
|
||||
function goBack() {
|
||||
void router.push({ name: 'BlogArticle' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the current public KT Blog Web preview in a browser tab for direct inspection.
|
||||
*/
|
||||
function openPreviewInNewWindow() {
|
||||
if (!iframeUrl.value) {
|
||||
return;
|
||||
@ -82,9 +73,6 @@ export default defineComponent({
|
||||
window.open(iframeUrl.value, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the article detail and rebuilds the iframe URL for the current route id.
|
||||
*/
|
||||
async function loadArticlePreview() {
|
||||
const articleId = routeArticleId.value;
|
||||
article.value = null;
|
||||
@ -108,11 +96,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the floating status card without taking layout space from the preview iframe.
|
||||
*
|
||||
* @returns Overlay card with article metadata and navigation actions.
|
||||
*/
|
||||
const renderFloatingCard = () => {
|
||||
return (
|
||||
<div class="blog-article-preview__floating-card">
|
||||
@ -157,11 +140,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders loading, error, and ready iframe states inside the fixed preview canvas.
|
||||
*
|
||||
* @returns Current preview body content.
|
||||
*/
|
||||
const renderBody = () => {
|
||||
if (state.value === 'ready' && iframeUrl.value) {
|
||||
return (
|
||||
@ -197,11 +175,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the stable page root required by Vben route transitions.
|
||||
*
|
||||
* @returns Single-root preview page shell.
|
||||
*/
|
||||
const renderPage = () => {
|
||||
return (
|
||||
<div class="blog-article-preview-page">
|
||||
@ -217,19 +190,11 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @param value Raw vue-router route param value.
|
||||
* @returns A single trimmed article id string.
|
||||
*/
|
||||
function normalizeRouteParam(value: unknown) {
|
||||
if (Array.isArray(value)) return `${value[0] || ''}`.trim();
|
||||
return `${value || ''}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param article Blog article detail returned by the Admin API.
|
||||
* @returns Plain article title for metadata and iframe title attributes.
|
||||
*/
|
||||
function getArticleTitle(article?: null | WordpressBlogApi.Article) {
|
||||
const value = article?.title;
|
||||
if (typeof value === 'string') return stripHtml(value);
|
||||
@ -237,10 +202,6 @@ function getArticleTitle(article?: null | WordpressBlogApi.Article) {
|
||||
return stripHtml(value?.raw || value?.rendered || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param previewUrl Iframe URL built from the KT Blog Web base URL.
|
||||
* @returns Host shown in the floating card so operators can verify the embedded target.
|
||||
*/
|
||||
function getPreviewHost(previewUrl: string) {
|
||||
if (!previewUrl) {
|
||||
return '';
|
||||
@ -254,10 +215,6 @@ function getPreviewHost(previewUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value HTML-ish rendered text from WordPress-compatible article fields.
|
||||
* @returns Plain text safe for compact Admin metadata.
|
||||
*/
|
||||
function stripHtml(value: string) {
|
||||
return value
|
||||
.replaceAll(/<[^>]*>/g, ' ')
|
||||
@ -268,10 +225,6 @@ function stripHtml(value: string) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param state Current preview page lifecycle state.
|
||||
* @returns Ant Design tag color and Chinese label for the floating card.
|
||||
*/
|
||||
function getStatusMeta(state: PreviewState) {
|
||||
const statusMap = {
|
||||
error: { color: 'error', label: '异常' },
|
||||
@ -282,10 +235,6 @@ function getStatusMeta(state: PreviewState) {
|
||||
return statusMap[state];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param status Article publish status returned by the Admin API.
|
||||
* @returns Ant Design tag color and Chinese label for article metadata.
|
||||
*/
|
||||
function getArticleStatusMeta(status?: string) {
|
||||
return (
|
||||
articleStatusOptions.find((item) => item.value === status) ||
|
||||
|
||||
@ -21,13 +21,6 @@ const SOURCE_ONLY_HTML_PATTERN =
|
||||
/\b(?:hljs-ln|hljs-control|fancybox-wrapper|lazyload|collapse-block|wp-block-(?!code\b)[\w-]+)/;
|
||||
const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i;
|
||||
|
||||
/**
|
||||
* Builds the content field schema for Markdown, rich HTML, or raw WordPress HTML preservation.
|
||||
* @param mode Current editor mode; source mode uses a raw textarea to avoid runtime DOM rewrites.
|
||||
* @param markdownEditor Markdown editor component used for normal local article authoring.
|
||||
* @param richHtmlEditor Tiptap HTML editor component used for plain HTML authoring.
|
||||
* @returns Vben form schema for the article content field.
|
||||
*/
|
||||
export function createBlogArticleContentSchema(
|
||||
mode: BlogArticleEditorMode,
|
||||
markdownEditor: unknown,
|
||||
@ -79,11 +72,6 @@ export function createBlogArticleContentSchema(
|
||||
} as VbenFormSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the editor mode selector schema and forwards mode changes to the article list page.
|
||||
* @param onChange Callback that receives the next editor mode selected in the form.
|
||||
* @returns Vben form schema for selecting the article editor mode.
|
||||
*/
|
||||
export function createBlogArticleEditorModeSchema(
|
||||
onChange?: (mode: BlogArticleEditorMode) => void,
|
||||
): VbenFormSchema {
|
||||
@ -107,22 +95,12 @@ export function createBlogArticleEditorModeSchema(
|
||||
} as VbenFormSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the UI editor mode back to the API's persisted content format contract.
|
||||
* @param mode Current editor mode from the article form.
|
||||
* @returns API content format saved with the article.
|
||||
*/
|
||||
export function getContentFormatForEditorMode(
|
||||
mode: BlogArticleEditorMode,
|
||||
): BlogArticleContentFormat {
|
||||
return mode === 'markdown' ? 'markdown' : 'html';
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the editor mode for an existing article without forcing Markdown source or imported Argon HTML into the wrong editor.
|
||||
* @param article Article row returned by the local blog API.
|
||||
* @returns Markdown for source-backed articles, source HTML for strong runtime DOM, rich HTML for plain tags.
|
||||
*/
|
||||
export function getBlogArticleEditorMode(
|
||||
article?: Partial<WordpressBlogApi.Article>,
|
||||
): BlogArticleEditorMode {
|
||||
@ -134,24 +112,12 @@ export function getBlogArticleEditorMode(
|
||||
return HTML_TAG_PATTERN.test(html) ? 'html-rich' : 'markdown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the persisted content format for an existing article.
|
||||
* @param article Article row returned by the local blog API.
|
||||
* @returns API content format derived from the detected editor mode.
|
||||
*/
|
||||
export function getBlogArticleContentFormat(
|
||||
article?: Partial<WordpressBlogApi.Article>,
|
||||
): BlogArticleContentFormat {
|
||||
return getContentFormatForEditorMode(getBlogArticleEditorMode(article));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds form defaults for creating a new article from current table filters.
|
||||
* @param searchValues Active table search filters used to prefill category and tag fields.
|
||||
* @param searchValues.categories Active category filters copied into the create form.
|
||||
* @param searchValues.tags Active tag filters copied into the create form.
|
||||
* @returns Article form defaults in Markdown mode.
|
||||
*/
|
||||
export function getBlogArticleCreateFormDefaults(
|
||||
searchValues: {
|
||||
categories?: string[];
|
||||
@ -172,11 +138,6 @@ export function getBlogArticleCreateFormDefaults(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds edit form values, preserving WordPress/Argon HTML when the article depends on runtime classes.
|
||||
* @param row Article row selected from the article table.
|
||||
* @returns Form values and content format for the edit modal.
|
||||
*/
|
||||
export function getBlogArticleEditFormValues(
|
||||
row: WordpressBlogApi.Article,
|
||||
): BlogArticleFormValues {
|
||||
@ -201,13 +162,6 @@ export function getBlogArticleEditFormValues(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the API payload while preserving the current content format selected for the modal.
|
||||
* @param values Current form values from Vben Form.
|
||||
* @param editingId Current article id, or undefined for create.
|
||||
* @param editorMode Current editor mode.
|
||||
* @returns Payload accepted by the blog article save/update API.
|
||||
*/
|
||||
export function buildBlogArticleSubmitPayload(
|
||||
values: BlogArticleFormValues,
|
||||
editingId: string | undefined,
|
||||
@ -222,43 +176,22 @@ export function buildBlogArticleSubmitPayload(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts WordPress rendered fields or strings into readable plain text.
|
||||
* @param value Rendered field or string value.
|
||||
* @returns HTML-free text.
|
||||
*/
|
||||
export function getRenderedText(
|
||||
value?: string | WordpressBlogApi.RenderedField,
|
||||
) {
|
||||
return stripHtml(getRenderedValue(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a rendered field without stripping HTML.
|
||||
* @param value Rendered field or string value.
|
||||
* @returns Raw/rendered string.
|
||||
*/
|
||||
function getRenderedValue(value?: string | WordpressBlogApi.RenderedField) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return value.raw || value.rendered || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the article still owns an editable Markdown source snapshot.
|
||||
* @param article Article row returned by the local blog API.
|
||||
* @returns true when Admin should prefer Milkdown unless strong runtime-only HTML is present.
|
||||
*/
|
||||
function hasMarkdownSource(article?: Partial<WordpressBlogApi.Article>) {
|
||||
return !!article?.contentMarkdown?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses editable Markdown from an API rendered field and its explicit Markdown source.
|
||||
* @param value Rendered field or string value.
|
||||
* @param markdown Explicit Markdown source saved on local articles.
|
||||
* @returns Markdown text for the Milkdown editor.
|
||||
*/
|
||||
function getEditableMarkdown(
|
||||
value?: string | WordpressBlogApi.RenderedField,
|
||||
markdown?: string,
|
||||
@ -267,11 +200,6 @@ function getEditableMarkdown(
|
||||
return getRenderedValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips simple HTML tags from Admin labels and excerpts.
|
||||
* @param value HTML or text value.
|
||||
* @returns Trimmed text.
|
||||
*/
|
||||
function stripHtml(value: string) {
|
||||
return value
|
||||
.replaceAll(/<[^>]+>/g, '')
|
||||
|
||||
@ -7,12 +7,6 @@ defineProps<{
|
||||
events: EnvironmentEvent[];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Maps event severity to a compact tag color.
|
||||
*
|
||||
* @param status Event severity from the API stream or snapshot.
|
||||
* @returns Antdv tag color value.
|
||||
*/
|
||||
function getStatusColor(status: EnvironmentHealthStatus) {
|
||||
if (status === 'ok') return 'success';
|
||||
if (status === 'degraded') return 'warning';
|
||||
|
||||
@ -21,13 +21,6 @@ defineEmits<{
|
||||
selfCheck: [];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Returns evidence rows for the selected service or signal.
|
||||
*
|
||||
* @param service Selected topology service from the current site.
|
||||
* @param signal Selected signal when the user drills into one.
|
||||
* @returns Evidence list shown in the right-side panel.
|
||||
*/
|
||||
function getEvidenceRows(
|
||||
service?: EnvironmentService,
|
||||
signal?: EnvironmentSignal,
|
||||
@ -38,12 +31,6 @@ function getEvidenceRows(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels evidence with a stable source and missing-config status.
|
||||
*
|
||||
* @param evidence Evidence record from the API contract.
|
||||
* @returns Human-readable evidence title.
|
||||
*/
|
||||
function getEvidenceTitle(evidence: EnvironmentEvidence) {
|
||||
return `${evidence.type || 'evidence'} · ${evidence.source}`;
|
||||
}
|
||||
|
||||
@ -12,12 +12,6 @@ defineEmits<{
|
||||
selectSite: [siteId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Counts service signals under a site for compact rail evidence.
|
||||
*
|
||||
* @param site Site record from the dashboard snapshot.
|
||||
* @returns Total number of service signals in the site tree.
|
||||
*/
|
||||
function countSignals(site: EnvironmentSite) {
|
||||
return site.nodes.reduce(
|
||||
(count, node) =>
|
||||
@ -30,12 +24,6 @@ function countSignals(site: EnvironmentSite) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts unwired signals so missing config remains visible.
|
||||
*
|
||||
* @param site Site record from the dashboard snapshot.
|
||||
* @returns Number of signals marked as unwired by the API.
|
||||
*/
|
||||
function countUnwiredSignals(site: EnvironmentSite) {
|
||||
return site.nodes.reduce(
|
||||
(count, node) =>
|
||||
@ -51,12 +39,6 @@ function countUnwiredSignals(site: EnvironmentSite) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps site status to antdv badge status.
|
||||
*
|
||||
* @param site Site record from the dashboard snapshot.
|
||||
* @returns Badge status that preserves unknown/unwired as non-green.
|
||||
*/
|
||||
function getBadgeStatus(site: EnvironmentSite) {
|
||||
if (site.status === 'online') return 'success';
|
||||
if (site.status === 'degraded') return 'warning';
|
||||
|
||||
@ -17,11 +17,6 @@ defineEmits<{
|
||||
selfCheck: [];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Chooses the strongest global status from summary counters.
|
||||
*
|
||||
* @returns Status tag value used by the top dashboard bar.
|
||||
*/
|
||||
function getGlobalStatus(): EnvironmentHealthStatus {
|
||||
const summary = props.dashboard?.summary;
|
||||
if (!summary) return 'unknown';
|
||||
@ -32,12 +27,6 @@ function getGlobalStatus(): EnvironmentHealthStatus {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps health status to antdv tag colors without hiding unwired integrations.
|
||||
*
|
||||
* @param status Global or signal health status from the API.
|
||||
* @returns Antdv tag color name.
|
||||
*/
|
||||
function getStatusColor(status: EnvironmentHealthStatus) {
|
||||
if (status === 'ok') return 'success';
|
||||
if (status === 'degraded') return 'warning';
|
||||
|
||||
@ -16,12 +16,6 @@ defineEmits<{
|
||||
selectService: [serviceId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Maps service health to a compact tag color.
|
||||
*
|
||||
* @param status Service or signal status from the dashboard model.
|
||||
* @returns Antdv tag color value.
|
||||
*/
|
||||
function getStatusColor(status: EnvironmentHealthStatus) {
|
||||
if (status === 'ok') return 'success';
|
||||
if (status === 'degraded') return 'warning';
|
||||
@ -30,12 +24,6 @@ function getStatusColor(status: EnvironmentHealthStatus) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts unwired signals in a service.
|
||||
*
|
||||
* @param service Service record selected from the current site.
|
||||
* @returns Number of signals that are visibly missing configuration.
|
||||
*/
|
||||
function countUnwiredSignals(service: EnvironmentService) {
|
||||
return service.signals.filter((signal) => signal.status === 'unwired').length;
|
||||
}
|
||||
|
||||
@ -20,12 +20,6 @@ export interface UseEnvironmentDashboardStreamOptions {
|
||||
onSnapshotRequired: (event: StreamEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one browser EventSource for Admin environment updates after the first HTTP snapshot.
|
||||
*
|
||||
* @param options Page-owned callbacks that merge SSE payloads into visible state.
|
||||
* @returns Controls for starting and closing the stream plus connection state for display.
|
||||
*/
|
||||
export function useEnvironmentDashboardStream(
|
||||
options: UseEnvironmentDashboardStreamOptions,
|
||||
) {
|
||||
@ -33,9 +27,6 @@ export function useEnvironmentDashboardStream(
|
||||
const lastEventId = ref<string>();
|
||||
let source: EventSource | undefined;
|
||||
|
||||
/**
|
||||
* Starts the SSE connection once and lets the browser handle native reconnects.
|
||||
*/
|
||||
function start() {
|
||||
if (source) return;
|
||||
connectionState.value = 'connecting';
|
||||
@ -53,9 +44,6 @@ export function useEnvironmentDashboardStream(
|
||||
source.addEventListener('error', handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the EventSource when the route leaves or the component unmounts.
|
||||
*/
|
||||
function close() {
|
||||
if (!source) return;
|
||||
source.removeEventListener('open', handleOpen);
|
||||
@ -69,18 +57,10 @@ export function useEnvironmentDashboardStream(
|
||||
connectionState.value = 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the stream as open after the browser confirms the connection.
|
||||
*/
|
||||
function handleOpen() {
|
||||
connectionState.value = 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a backend event entry to the visible event stream.
|
||||
*
|
||||
* @param event Browser SSE message carrying an EnvironmentEvent JSON payload.
|
||||
*/
|
||||
function handleEnvironmentEvent(event: Event) {
|
||||
const payload = parseStreamEvent(event);
|
||||
if (!payload) return;
|
||||
@ -88,11 +68,6 @@ export function useEnvironmentDashboardStream(
|
||||
options.onEnvironmentEvent(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one signal-level update without reloading the dashboard snapshot.
|
||||
*
|
||||
* @param event Browser SSE message carrying an EnvironmentEvent JSON payload.
|
||||
*/
|
||||
function handleEnvironmentSignal(event: Event) {
|
||||
const payload = parseStreamEvent(event);
|
||||
if (!payload) return;
|
||||
@ -100,11 +75,6 @@ export function useEnvironmentDashboardStream(
|
||||
options.onEnvironmentSignal(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the page that the API replay buffer requires one fresh snapshot.
|
||||
*
|
||||
* @param event Browser SSE message carrying the replay-gap event envelope.
|
||||
*/
|
||||
function handleSnapshotRequired(event: Event) {
|
||||
const payload = parseStreamEvent(event);
|
||||
if (!payload) return;
|
||||
@ -112,20 +82,12 @@ export function useEnvironmentDashboardStream(
|
||||
options.onSnapshotRequired(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves dashboard state unchanged for keepalive events.
|
||||
*/
|
||||
function handleHeartbeat() {
|
||||
if (connectionState.value === 'connecting') {
|
||||
connectionState.value = 'open';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records stream errors without starting polling or manual reconnect loops.
|
||||
*
|
||||
* @param event Optional API error payload when the server sends a typed error event.
|
||||
*/
|
||||
function handleError(event: Event) {
|
||||
connectionState.value = 'error';
|
||||
const payload = parseStreamEvent(event);
|
||||
@ -135,12 +97,6 @@ export function useEnvironmentDashboardStream(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses JSON payloads from typed SSE events.
|
||||
*
|
||||
* @param event Browser EventSource event that may include string data.
|
||||
* @returns Environment event payload, or undefined when malformed.
|
||||
*/
|
||||
function parseStreamEvent(event: Event) {
|
||||
const data = (event as MessageEvent<string>).data;
|
||||
if (!data) return undefined;
|
||||
@ -151,11 +107,6 @@ export function useEnvironmentDashboardStream(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the replay cursor scoped to the current route instance.
|
||||
*
|
||||
* @param event Parsed environment stream event from the API.
|
||||
*/
|
||||
function rememberEventId(event: StreamEvent) {
|
||||
if (event.eventId) {
|
||||
lastEventId.value = event.eventId;
|
||||
|
||||
@ -131,41 +131,21 @@ class FakeEventSource {
|
||||
readonly listeners = new Map<string, Set<FakeEventSourceListener>>();
|
||||
readonly url: string;
|
||||
|
||||
/**
|
||||
* Records the stream URL used by the page so tests can dispatch typed SSE messages.
|
||||
*
|
||||
* @param url Browser EventSource URL built from the Admin API wrapper.
|
||||
*/
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores typed SSE listeners registered by the production stream composable.
|
||||
*
|
||||
* @param type SSE event type supplied by the backend stream.
|
||||
* @param listener Component-side event handler receiving the JSON payload.
|
||||
*/
|
||||
addEventListener(type: string, listener: FakeEventSourceListener) {
|
||||
const listeners = this.listeners.get(type) ?? new Set();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the connection as closed so the unmount lifecycle can be asserted.
|
||||
*/
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers one typed SSE message to the mounted page.
|
||||
*
|
||||
* @param type SSE event name sent by the API.
|
||||
* @param payload JSON-serializable event payload.
|
||||
*/
|
||||
dispatch(type: string, payload: unknown) {
|
||||
const event = new MessageEvent(type, {
|
||||
data: JSON.stringify(payload),
|
||||
@ -175,20 +155,11 @@ class FakeEventSource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an SSE listener during component cleanup.
|
||||
*
|
||||
* @param type SSE event type supplied by the backend stream.
|
||||
* @param listener Previously registered component handler.
|
||||
*/
|
||||
removeEventListener(type: string, listener: FakeEventSourceListener) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes Vue microtasks created by API promises and reactive state updates.
|
||||
*/
|
||||
async function flushDashboardUpdates() {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
@ -196,11 +167,6 @@ async function flushDashboardUpdates() {
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete four-site dashboard fixture that mirrors the backend contract.
|
||||
*
|
||||
* @param includeMqttEvent Whether the API snapshot should already contain a MQTT-origin event.
|
||||
*/
|
||||
function createDashboardFixture(
|
||||
includeMqttEvent = false,
|
||||
): EnvironmentDashboardApi.EnvironmentDashboardResponse {
|
||||
|
||||
@ -51,30 +51,18 @@ const streamState = environmentStream.connectionState;
|
||||
onMounted(handleMounted);
|
||||
onBeforeUnmount(handleBeforeUnmount);
|
||||
|
||||
/**
|
||||
* Starts the initial snapshot load when the Vben route component mounts.
|
||||
*/
|
||||
function handleMounted() {
|
||||
void loadDashboardSnapshot('initial');
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the SSE stream when route transitions remove this page.
|
||||
*/
|
||||
function handleBeforeUnmount() {
|
||||
environmentStream.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles explicit user refresh without creating timer-based polling.
|
||||
*/
|
||||
function handleManualRefresh() {
|
||||
void loadDashboardSnapshot('manual');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the read-only backend self-check and replaces visible evidence with its response.
|
||||
*/
|
||||
async function handleSelfCheck() {
|
||||
selfChecking.value = true;
|
||||
errorText.value = '';
|
||||
@ -87,11 +75,6 @@ async function handleSelfCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a dashboard snapshot for first render, manual refresh, or replay gap recovery.
|
||||
*
|
||||
* @param reason Call origin that controls loading state and snapshot-required de-duping.
|
||||
*/
|
||||
async function loadDashboardSnapshot(reason: SnapshotLoadReason) {
|
||||
if (reason === 'snapshot-required') {
|
||||
if (snapshotRequestInFlight.value) return;
|
||||
@ -114,22 +97,12 @@ async function loadDashboardSnapshot(reason: SnapshotLoadReason) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a full dashboard response while preserving still-valid selections.
|
||||
*
|
||||
* @param next Dashboard response from GET snapshot or POST self-check.
|
||||
*/
|
||||
function applyDashboard(next: EnvironmentDashboard) {
|
||||
dashboard.value = next;
|
||||
recentEvents.value = [...(next.events ?? [])];
|
||||
ensureSelection(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves selected service when possible, otherwise selects the first risky service.
|
||||
*
|
||||
* @param next Dashboard response that becomes the new source of truth.
|
||||
*/
|
||||
function ensureSelection(next: EnvironmentDashboard) {
|
||||
const existingSite = selectedSiteId.value
|
||||
? next.sites.find((site) => site.id === selectedSiteId.value)
|
||||
@ -152,11 +125,6 @@ function ensureSelection(next: EnvironmentDashboard) {
|
||||
selectedSignalId.value = fallbackService?.signals[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates selected site and moves service selection to that site's first service.
|
||||
*
|
||||
* @param siteId Site ID emitted by the left rail.
|
||||
*/
|
||||
function handleSiteSelect(siteId: string) {
|
||||
selectedSiteId.value = siteId;
|
||||
const site = dashboard.value?.sites.find((item) => item.id === siteId);
|
||||
@ -165,61 +133,31 @@ function handleSiteSelect(siteId: string) {
|
||||
selectedSignalId.value = service?.signals[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates selected service inside the active site.
|
||||
*
|
||||
* @param serviceId Service ID emitted by the topology panel.
|
||||
*/
|
||||
function handleServiceSelect(serviceId: string) {
|
||||
const service = findService(selectedSite.value, serviceId);
|
||||
selectedServiceId.value = service?.id;
|
||||
selectedSignalId.value = service?.signals[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a stream event to the visible bottom event list.
|
||||
*
|
||||
* @param event SSE event envelope sent by the API stream.
|
||||
*/
|
||||
function handleEnvironmentEvent(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges one signal update into the current dashboard tree without re-fetching.
|
||||
*
|
||||
* @param event SSE signal envelope sent by the API stream.
|
||||
*/
|
||||
function handleEnvironmentSignal(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
applySignalEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one snapshot load when the API reports a replay buffer gap.
|
||||
*
|
||||
* @param event Snapshot-required event envelope from the API stream.
|
||||
*/
|
||||
function handleSnapshotRequired(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
void loadDashboardSnapshot('snapshot-required');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows stream errors as evidence without starting a polling fallback.
|
||||
*
|
||||
* @param event Error event envelope from the API stream.
|
||||
*/
|
||||
function handleStreamError(event: EnvironmentEvent) {
|
||||
addRecentEvent(event);
|
||||
errorText.value = event.summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one service or signal-level update in-place for visible topology state.
|
||||
*
|
||||
* @param event Parsed SSE environment-signal payload.
|
||||
*/
|
||||
function applySignalEvent(event: EnvironmentEvent) {
|
||||
const site = dashboard.value?.sites.find((item) => item.id === event.siteId);
|
||||
if (!site || !event.serviceId) return;
|
||||
@ -245,11 +183,6 @@ function applySignalEvent(event: EnvironmentEvent) {
|
||||
site.status = mapSiteStatus(site.nodes.map((item) => item.status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a recent event newest-first while de-duping by event ID.
|
||||
*
|
||||
* @param event Event from the initial snapshot or SSE stream.
|
||||
*/
|
||||
function addRecentEvent(event: EnvironmentEvent) {
|
||||
recentEvents.value = [
|
||||
event,
|
||||
@ -257,52 +190,26 @@ function addRecentEvent(event: EnvironmentEvent) {
|
||||
].slice(0, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the currently selected site from reactive IDs.
|
||||
*
|
||||
* @returns Selected site or undefined when the dashboard has not loaded.
|
||||
*/
|
||||
function resolveSelectedSite() {
|
||||
return dashboard.value?.sites.find(
|
||||
(site) => site.id === selectedSiteId.value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the currently selected service from the selected site.
|
||||
*
|
||||
* @returns Selected service or undefined when no service is selected.
|
||||
*/
|
||||
function resolveSelectedService() {
|
||||
return findService(selectedSite.value, selectedServiceId.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the currently selected signal from the selected service.
|
||||
*
|
||||
* @returns Selected signal or undefined when no signal is selected.
|
||||
*/
|
||||
function resolveSelectedSignal() {
|
||||
return findSignal(selectedService.value, selectedSignalId.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts events newest-first using their observed time while keeping stable IDs.
|
||||
*
|
||||
* @returns Event list used by the bottom stream panel.
|
||||
*/
|
||||
function resolveSortedEvents() {
|
||||
return recentEvents.value.toSorted(
|
||||
(left, right) => Date.parse(right.observedAt) - Date.parse(left.observedAt),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first non-green service as the default operational focus.
|
||||
*
|
||||
* @param sites Dashboard sites from the latest snapshot.
|
||||
* @returns Preferred site/service pair or undefined when all sites are empty.
|
||||
*/
|
||||
function findFirstAttentionService(sites: EnvironmentSite[]) {
|
||||
for (const site of sites) {
|
||||
for (const node of site.nodes) {
|
||||
@ -319,23 +226,10 @@ function findFirstAttentionService(sites: EnvironmentSite[]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first service under a site.
|
||||
*
|
||||
* @param site Site selected by route state or fallback selection.
|
||||
* @returns First service in the site tree.
|
||||
*/
|
||||
function getFirstService(site?: EnvironmentSite) {
|
||||
return site?.nodes.flatMap((node) => node.services)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one service by ID within a site.
|
||||
*
|
||||
* @param site Site tree to inspect.
|
||||
* @param serviceId Service ID from user selection or persisted state.
|
||||
* @returns Matching service when still present.
|
||||
*/
|
||||
function findService(site?: EnvironmentSite, serviceId?: string) {
|
||||
if (!site || !serviceId) return undefined;
|
||||
for (const node of site.nodes) {
|
||||
@ -345,48 +239,21 @@ function findService(site?: EnvironmentSite, serviceId?: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one service by ID within a single node.
|
||||
*
|
||||
* @param node Node that owns service records.
|
||||
* @param serviceId Service ID from an SSE event or user selection.
|
||||
* @returns Matching service when present.
|
||||
*/
|
||||
function findServiceInNode(node: EnvironmentNode, serviceId: string) {
|
||||
return node.services.find((service) => service.id === serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the node that owns a service ID.
|
||||
*
|
||||
* @param site Site tree to inspect.
|
||||
* @param serviceId Service ID from the SSE signal envelope.
|
||||
* @returns Owning node when the service exists.
|
||||
*/
|
||||
function findNodeByServiceId(site: EnvironmentSite, serviceId: string) {
|
||||
return site.nodes.find((node) =>
|
||||
node.services.some((service) => service.id === serviceId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one signal by ID within a service.
|
||||
*
|
||||
* @param service Service that owns signal records.
|
||||
* @param signalId Signal ID from selection or an SSE event.
|
||||
* @returns Matching signal when present.
|
||||
*/
|
||||
function findSignal(service?: EnvironmentService, signalId?: string) {
|
||||
if (!service || !signalId) return undefined;
|
||||
return service.signals.find((signal) => signal.id === signalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts event source metadata into signal source kinds supported by node badges.
|
||||
*
|
||||
* @param sourceKind Event source kind from the SSE envelope.
|
||||
* @returns Signal source kind stored on the affected signal.
|
||||
*/
|
||||
function mapEventSourceToSignalSource(
|
||||
sourceKind: EnvironmentEvent['sourceKind'],
|
||||
): EnvironmentSignal['sourceKind'] {
|
||||
@ -394,12 +261,6 @@ function mapEventSourceToSignalSource(
|
||||
return sourceKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the strongest status for a node after a signal update.
|
||||
*
|
||||
* @param statuses Service statuses under the same node.
|
||||
* @returns Strongest health status according to dashboard severity rules.
|
||||
*/
|
||||
function pickWorstHealthStatus(
|
||||
statuses: EnvironmentHealthStatus[],
|
||||
): EnvironmentHealthStatus {
|
||||
@ -421,12 +282,6 @@ function pickWorstHealthStatus(
|
||||
return worst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps node health back into the coarser site status union.
|
||||
*
|
||||
* @param statuses Node statuses under the same site.
|
||||
* @returns Site-level status that never marks unknown/unwired integrations green.
|
||||
*/
|
||||
function mapSiteStatus(statuses: EnvironmentHealthStatus[]) {
|
||||
const worst = pickWorstHealthStatus(statuses);
|
||||
if (worst === 'ok') return 'online';
|
||||
@ -437,12 +292,6 @@ function mapSiteStatus(statuses: EnvironmentHealthStatus[]) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes unknown thrown values from request wrappers into alert text.
|
||||
*
|
||||
* @param error Error thrown by the request client or runtime code.
|
||||
* @returns User-visible error summary.
|
||||
*/
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
|
||||
@ -56,9 +56,6 @@ const cropImage = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载图片
|
||||
*/
|
||||
const downloadImage = () => {
|
||||
if (!cropperImg.value) return;
|
||||
|
||||
|
||||
@ -440,9 +440,6 @@ function onSubmit(values: Record<string, any>) {
|
||||
}
|
||||
|
||||
function handleSetFormValue() {
|
||||
/**
|
||||
* 设置表单值(多个)
|
||||
*/
|
||||
baseFormApi.setValues({
|
||||
checkboxGroup: ['1'],
|
||||
datePicker: dayjs('2022-01-01'),
|
||||
|
||||
@ -46,9 +46,6 @@ vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockLegacyKtTable',
|
||||
inheritAttrs: false,
|
||||
/**
|
||||
* Renders the real parent-owned header callbacks and records legacy table props.
|
||||
*/
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => {
|
||||
mocks.legacyTableProps.push(attrs);
|
||||
@ -68,7 +65,6 @@ vi.mock('antdv-next', () => ({
|
||||
},
|
||||
Spin: defineComponent({
|
||||
name: 'MockSpin',
|
||||
/** Keeps the legacy table subtree mounted for regression assertions. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
@ -80,7 +76,6 @@ vi.mock('antdv-next', () => ({
|
||||
items: Array,
|
||||
},
|
||||
emits: ['update:activeKey'],
|
||||
/** Exposes every configured tab through a deterministic clickable button. */
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
@ -102,7 +97,6 @@ vi.mock('antdv-next', () => ({
|
||||
}),
|
||||
Tag: defineComponent({
|
||||
name: 'MockTag',
|
||||
/** Renders tag text without changing the parent structure. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
@ -117,7 +111,6 @@ vi.mock('./AccountMessagePushPanel', () => ({
|
||||
selfId: String,
|
||||
title: Function,
|
||||
},
|
||||
/** Renders both inherited header callbacks to prove the ownership boundary. */
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('section', { 'data-testid': 'message-push-panel' }, [
|
||||
@ -128,7 +121,6 @@ vi.mock('./AccountMessagePushPanel', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Creates the current route account with an unsafe-integer string self ID. */
|
||||
function createAccount(): QqbotApi.Account {
|
||||
return {
|
||||
connectStatus: 'online',
|
||||
|
||||
@ -50,7 +50,6 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
/** Creates one fluent no-op Zod rule for schema-focused tests. */
|
||||
function createRule(): any {
|
||||
const rule: any = {};
|
||||
for (const method of [
|
||||
@ -72,7 +71,6 @@ vi.mock('#/adapter/form', () => ({
|
||||
mocks.formOptions = options;
|
||||
const Form = defineComponent({
|
||||
name: 'MockBindingForm',
|
||||
/** Renders the schema's real local target-picker component and model wiring. */
|
||||
setup() {
|
||||
return () => {
|
||||
const targetField = mocks.formOptions.schema.find(
|
||||
@ -113,7 +111,6 @@ vi.mock('@vben/common-ui', () => ({
|
||||
mocks.modalOptions = options;
|
||||
const Modal = defineComponent({
|
||||
name: 'MockBindingModal',
|
||||
/** Renders modal content while lifecycle remains API-controlled. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('section', slots.default?.());
|
||||
},
|
||||
@ -134,7 +131,6 @@ vi.mock('./MessagePushTargetPicker', () => ({
|
||||
value: Array,
|
||||
},
|
||||
emits: ['update:value'],
|
||||
/** Exposes one button that exercises the Vben `modelPropName:value` path. */
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
@ -150,9 +146,6 @@ vi.mock('./MessagePushTargetPicker', () => ({
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Mirrors the production helper so modal tests retain the exact validation boundary.
|
||||
*/
|
||||
isValidMessagePushTargetId: (targetId: string) =>
|
||||
/^[1-9]\d{4,19}$/.test(targetId),
|
||||
}));
|
||||
@ -162,7 +155,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
|
||||
updateAccountMessagePushBinding: mocks.update,
|
||||
}));
|
||||
|
||||
/** Creates two source families to test compatible-template filtering. */
|
||||
function createSubscriptions(): QqbotMessagePushApi.MessageSubscriptionView[] {
|
||||
return [
|
||||
{
|
||||
@ -219,7 +211,6 @@ function createSubscriptions(): QqbotMessagePushApi.MessageSubscriptionView[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Creates templates in both source families. */
|
||||
function createTemplates(): QqbotMessagePushApi.MessageTemplateView[] {
|
||||
return [
|
||||
{
|
||||
@ -249,7 +240,6 @@ function createTemplates(): QqbotMessagePushApi.MessageTemplateView[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Creates an edit row whose targets retain API-provided names as strings. */
|
||||
function createBinding(): QqbotMessagePushApi.QqbotMessagePublishBindingView {
|
||||
return {
|
||||
available: true,
|
||||
@ -283,7 +273,6 @@ function createBinding(): QqbotMessagePushApi.QqbotMessagePublishBindingView {
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one manually resolved promise for async session-race assertions. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
@ -292,7 +281,6 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Mounts the modal with one implicit publisher and page-owned metadata. */
|
||||
function mountModal(selfId = '10000000000000001') {
|
||||
return mount(AccountMessagePushModal, {
|
||||
props: {
|
||||
|
||||
@ -64,7 +64,6 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
emits: ['saved'],
|
||||
/** Owns one account-scoped create/edit session without selecting another account. */
|
||||
setup(props, { emit, expose }) {
|
||||
const editingId = ref<string>();
|
||||
const modalOpen = ref(false);
|
||||
@ -75,11 +74,6 @@ export default defineComponent({
|
||||
commonConfig: {
|
||||
labelClass: 'w-24',
|
||||
},
|
||||
/**
|
||||
* Keeps the selected template only while it remains source-compatible.
|
||||
* @param values - Current form values after the update.
|
||||
* @param fieldsChanged - Exact form fields changed by this event.
|
||||
*/
|
||||
async handleValuesChange(values, fieldsChanged) {
|
||||
if (!fieldsChanged.includes('subscriptionId')) return;
|
||||
selectedSubscriptionId.value =
|
||||
@ -100,17 +94,12 @@ export default defineComponent({
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
/** Derives the title from the current isolated binding identity. */
|
||||
const modalTitle = computed(() =>
|
||||
editingId.value ? '编辑消息推送' : '新增消息推送',
|
||||
);
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-[760px]',
|
||||
fullscreenButton: false,
|
||||
/**
|
||||
* Persists the active session while containing failures at ModalApi's
|
||||
* non-awaited callback boundary.
|
||||
*/
|
||||
async onConfirm() {
|
||||
try {
|
||||
await submit();
|
||||
@ -118,10 +107,6 @@ export default defineComponent({
|
||||
// The request/form layer already presents the persistence error.
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Restores only the latest session after destroy-on-close content mounts.
|
||||
* @param isOpen - Whether the modal content is currently mounted.
|
||||
*/
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
modalOpen.value = isOpen;
|
||||
if (!isOpen) return;
|
||||
@ -138,7 +123,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/** Opens a fresh four-field binding session for the current account path. */
|
||||
function openCreate() {
|
||||
editingId.value = undefined;
|
||||
beginSession({
|
||||
@ -149,10 +133,6 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an edit session with only API-provided editable binding values.
|
||||
* @param row - Account binding selected from the panel-owned KtTable.
|
||||
*/
|
||||
function openEdit(row: QqbotMessagePushApi.QqbotMessagePublishBindingView) {
|
||||
editingId.value = row.id;
|
||||
beginSession({
|
||||
@ -167,10 +147,6 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores one revision-bound modal payload before opening its content.
|
||||
* @param values - Exact four editable values for the new modal session.
|
||||
*/
|
||||
function beginSession(values: AccountMessagePushFormValues) {
|
||||
sessionRevision += 1;
|
||||
sessionSelfId = props.selfId;
|
||||
@ -184,17 +160,12 @@ export default defineComponent({
|
||||
.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the mounted form before installing one isolated session.
|
||||
* @param values - Exact values captured before opening the modal.
|
||||
*/
|
||||
async function resetForm(values: AccountMessagePushFormValues) {
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues(values);
|
||||
await formApi.resetValidate();
|
||||
}
|
||||
|
||||
/** Validates and persists one session without putting selfId in the body. */
|
||||
async function submit() {
|
||||
const revision = sessionRevision;
|
||||
const selfId = sessionSelfId;
|
||||
@ -222,9 +193,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates an open old-account session before its path identity changes.
|
||||
*/
|
||||
async function invalidateForSelfIdChange() {
|
||||
sessionRevision += 1;
|
||||
sessionSelfId = '';
|
||||
@ -251,12 +219,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the exact account-binding schema with the local controlled picker.
|
||||
* @param props - Page-owned subscriptions, templates, and target candidates.
|
||||
* @param selectedSubscriptionId - Current subscription driving template options.
|
||||
* @returns Four fields in subscription/template/targets/enabled order.
|
||||
*/
|
||||
function createFormSchema(
|
||||
props: Readonly<{
|
||||
selfId: string;
|
||||
@ -329,11 +291,6 @@ function createFormSchema(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats one subscription while retaining server validity information.
|
||||
* @param subscription - Global subscription available to the current account.
|
||||
* @returns Select label with a stable invalid reason when applicable.
|
||||
*/
|
||||
function formatSubscriptionLabel(
|
||||
subscription: QqbotMessagePushApi.MessageSubscriptionView,
|
||||
): string {
|
||||
@ -344,12 +301,6 @@ function formatSubscriptionLabel(
|
||||
return `${subscription.name} · ${subscription.sourceName}${reason}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns templates whose source matches the selected global subscription.
|
||||
* @param props - Current page-owned subscription/template collections.
|
||||
* @param subscriptionId - Exact selected subscription string ID.
|
||||
* @returns Source-compatible template rows in server order.
|
||||
*/
|
||||
function compatibleTemplates(
|
||||
props: Readonly<{
|
||||
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
|
||||
@ -364,13 +315,6 @@ function compatibleTemplates(
|
||||
return props.templates.filter((template) => template.sourceKey === sourceKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks template/source compatibility without interpreting numeric IDs.
|
||||
* @param props - Current subscription/template collections.
|
||||
* @param subscriptionId - Selected subscription string ID.
|
||||
* @param templateId - Existing template string ID.
|
||||
* @returns Whether both rows exist and share the exact source key.
|
||||
*/
|
||||
function isTemplateCompatible(
|
||||
props: Readonly<{
|
||||
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
|
||||
@ -384,12 +328,6 @@ function isTemplateCompatible(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the exact four-field payload before invoking the account API.
|
||||
* @param props - Current source-compatible metadata.
|
||||
* @param values - Form-owned binding values.
|
||||
* @returns A clean API payload or undefined when any ID/target is invalid.
|
||||
*/
|
||||
function normalizeBindingPayload(
|
||||
props: Readonly<{
|
||||
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
|
||||
|
||||
@ -32,11 +32,9 @@ const mocks = vi.hoisted(() => {
|
||||
},
|
||||
tableOptions: undefined as any,
|
||||
};
|
||||
/** Installs the real list callback only after the rendered KtTable registers. */
|
||||
state.registerTable.mockImplementation(() => {
|
||||
state.registeredTableOptions = state.tableOptions;
|
||||
});
|
||||
/** Reloads through the registered list callback and records the applied page. */
|
||||
state.tableApi.reload.mockImplementation(async () => {
|
||||
if (!state.registeredTableOptions) {
|
||||
throw new Error('[MockKtTable]: table is not registered yet.');
|
||||
@ -86,7 +84,6 @@ vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockAccountMessagePushKtTable',
|
||||
emits: ['register'],
|
||||
/** Registers first, then renders inherited account header callbacks. */
|
||||
setup(_, { emit, slots }) {
|
||||
emit('register', {});
|
||||
return () =>
|
||||
@ -113,7 +110,6 @@ vi.mock('./AccountMessagePushModal', () => ({
|
||||
templates: Array,
|
||||
},
|
||||
emits: ['saved'],
|
||||
/** Exposes create/edit commands plus a deterministic save event. */
|
||||
setup(_, { emit, expose }) {
|
||||
expose({
|
||||
openCreate: mocks.modalOpenCreate,
|
||||
@ -141,7 +137,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
|
||||
setAccountMessagePushBindingEnabled: mocks.api.toggleBinding,
|
||||
}));
|
||||
|
||||
/** Creates one unsafe-integer string-ID binding row. */
|
||||
function createBinding(
|
||||
overrides: Partial<QqbotMessagePushApi.QqbotMessagePublishBindingView> = {},
|
||||
): QqbotMessagePushApi.QqbotMessagePublishBindingView {
|
||||
@ -171,7 +166,6 @@ function createBinding(
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one global subscription fixture for the account modal. */
|
||||
function createSubscription(
|
||||
id = '20000000000000001',
|
||||
): QqbotMessagePushApi.MessageSubscriptionView {
|
||||
@ -194,7 +188,6 @@ function createSubscription(
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one source-compatible template fixture. */
|
||||
function createTemplate(
|
||||
id = '50000000000000001',
|
||||
): QqbotMessagePushApi.MessageTemplateView {
|
||||
@ -212,7 +205,6 @@ function createTemplate(
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates a manually controlled promise for stale-response assertions. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
@ -221,7 +213,6 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Mounts the panel with visible account header callbacks. */
|
||||
function mountPanel(selfId = '10000000000000001') {
|
||||
return mount(AccountMessagePushPanel, {
|
||||
props: {
|
||||
|
||||
@ -66,7 +66,6 @@ export default defineComponent({
|
||||
type: Function as PropType<() => VNodeChild>,
|
||||
},
|
||||
},
|
||||
/** Owns one permission-gated account binding table and per-account option cache. */
|
||||
setup(props) {
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canList = hasAccessByCodes([PERMISSIONS.list]);
|
||||
@ -127,10 +126,6 @@ export default defineComponent({
|
||||
];
|
||||
const api: KtTableApi<QqbotMessagePushApi.QqbotMessagePublishBindingView> =
|
||||
{
|
||||
/**
|
||||
* Adapts the account binding array while returning the latest page for stale requests.
|
||||
* @returns Strict KtTable page retaining every string identifier.
|
||||
*/
|
||||
list: async () => {
|
||||
const revision = loadRevision;
|
||||
const selfId = props.selfId;
|
||||
@ -204,35 +199,20 @@ export default defineComponent({
|
||||
size: 'small',
|
||||
});
|
||||
|
||||
/** Opens a blank binding session for this panel's implicit account. */
|
||||
function openCreate() {
|
||||
modalRef.value?.openCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one existing binding without deriving another account identity.
|
||||
* @param row - Binding selected from this account's KtTable.
|
||||
*/
|
||||
function openEdit(row: QqbotMessagePushApi.QqbotMessagePublishBindingView) {
|
||||
modalRef.value?.openEdit(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds account-binding removal confirmation text.
|
||||
* @param row - Binding awaiting removal.
|
||||
* @returns Confirmation containing the global subscription name.
|
||||
*/
|
||||
function getDeleteConfirm(
|
||||
row: QqbotMessagePushApi.QqbotMessagePublishBindingView,
|
||||
): string {
|
||||
return `确认解绑消息订阅「${row.subscriptionName}」吗?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one binding and reloads only the mutable binding list after success.
|
||||
* @param row - Binding whose enabled state is inverted.
|
||||
* @param context - Registered KtTable action context.
|
||||
*/
|
||||
async function handleToggle(
|
||||
row: QqbotMessagePushApi.QqbotMessagePublishBindingView,
|
||||
context: KtTableContext<QqbotMessagePushApi.QqbotMessagePublishBindingView>,
|
||||
@ -245,11 +225,6 @@ export default defineComponent({
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one binding and reloads only the mutable binding list after success.
|
||||
* @param row - Binding confirmed for account-scoped removal.
|
||||
* @param context - Registered KtTable action context.
|
||||
*/
|
||||
async function handleDelete(
|
||||
row: QqbotMessagePushApi.QqbotMessagePublishBindingView,
|
||||
context: KtTableContext<QqbotMessagePushApi.QqbotMessagePublishBindingView>,
|
||||
@ -258,16 +233,10 @@ export default defineComponent({
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/** Reloads only bindings after one successful modal persistence. */
|
||||
async function handleModalSaved() {
|
||||
await tableApi.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the complete subscription/template collection in bounded 100-row pages.
|
||||
* @param loader - One strict page caller receiving pageNo/pageSize.
|
||||
* @returns Concatenated server rows until total is reached or progress stops.
|
||||
*/
|
||||
async function loadAllPages<Row>(
|
||||
loader: (params: {
|
||||
pageNo: number;
|
||||
@ -293,11 +262,6 @@ export default defineComponent({
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads per-account modal metadata once and applies only the latest revision.
|
||||
* @param selfId - Exact account identity captured for this load.
|
||||
* @param revision - Monotonic revision assigned by `loadAccount`.
|
||||
*/
|
||||
async function loadMetadata(selfId: string, revision: number) {
|
||||
targetOptionsLoading.value = canLoadTargets;
|
||||
const [subscriptionResult, templateResult, targetResult] =
|
||||
@ -321,10 +285,6 @@ export default defineComponent({
|
||||
targetOptionsLoading.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts exactly one logical load for a new nonempty account identity.
|
||||
* @param selfId - Current implicit account identity.
|
||||
*/
|
||||
async function loadAccount(selfId: string) {
|
||||
const revision = ++loadRevision;
|
||||
latestBindingPage = { items: [], total: 0 };
|
||||
@ -341,23 +301,14 @@ export default defineComponent({
|
||||
]);
|
||||
}
|
||||
|
||||
/** Starts the one authorized initial account load after table registration. */
|
||||
function activatePanel() {
|
||||
if (canList && props.selfId) void loadAccount(props.selfId);
|
||||
}
|
||||
|
||||
/** Invalidates any pending request when this tab's panel unmounts. */
|
||||
function invalidatePendingLoad() {
|
||||
loadRevision += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders source, targets, availability, and enabled state.
|
||||
* @param slot - KtTable body-cell slot payload.
|
||||
* @param slot.column - Current data column.
|
||||
* @param slot.record - Current account binding.
|
||||
* @returns Custom cell content or undefined for native field rendering.
|
||||
*/
|
||||
function renderBodyCell(slot: {
|
||||
column: TableColumnType<QqbotMessagePushApi.QqbotMessagePublishBindingView>;
|
||||
record: QqbotMessagePushApi.QqbotMessagePublishBindingView;
|
||||
|
||||
@ -11,7 +11,6 @@ import MessagePushTargetPicker, {
|
||||
isValidMessagePushTargetId,
|
||||
} from './MessagePushTargetPicker';
|
||||
|
||||
/** Creates type-scoped known group/private target candidates. */
|
||||
function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] {
|
||||
return [
|
||||
{
|
||||
@ -27,7 +26,6 @@ function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Mounts the controlled picker with optional contract overrides. */
|
||||
function mountPicker(
|
||||
overrides: Partial<{
|
||||
available: boolean;
|
||||
@ -49,7 +47,6 @@ function mountPicker(
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the newest controlled value emitted by the picker. */
|
||||
function latestValue(
|
||||
wrapper: ReturnType<typeof mountPicker>,
|
||||
): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] {
|
||||
|
||||
@ -27,11 +27,6 @@ export interface MessagePushTargetPickerProps {
|
||||
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one QQ group/user target without numeric conversion.
|
||||
* @param targetId - Trimmed target identifier supplied by the Select runtime.
|
||||
* @returns Whether the identifier is 5–20 digits and has no leading zero.
|
||||
*/
|
||||
export function isValidMessagePushTargetId(targetId: string): boolean {
|
||||
return /^[1-9]\d{4,19}$/.test(targetId);
|
||||
}
|
||||
@ -69,38 +64,23 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
/**
|
||||
* Emits one normalized controlled target collection.
|
||||
* @param value - Group-first then private targets with string IDs only.
|
||||
* @returns Whether the payload remains an array for Vue emit validation.
|
||||
*/
|
||||
'update:value': (
|
||||
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
) => Array.isArray(value),
|
||||
},
|
||||
/** Owns only selector remount revisions; all durable values stay controlled. */
|
||||
setup(props, { emit }) {
|
||||
const groupRevision = ref(0);
|
||||
const privateRevision = ref(0);
|
||||
|
||||
/** Derives the currently controlled group IDs without cloning other fields. */
|
||||
const groupIds = computed(() => targetIdsForType(props.value, 'group'));
|
||||
/** Derives the currently controlled private IDs independently. */
|
||||
const privateIds = computed(() => targetIdsForType(props.value, 'private'));
|
||||
/** Maps only server-returned group choices to raw Select values. */
|
||||
const groupOptions = computed(() =>
|
||||
createSelectOptions(props.options, 'group'),
|
||||
);
|
||||
/** Maps only server-returned private choices to raw Select values. */
|
||||
const privateOptions = computed(() =>
|
||||
createSelectOptions(props.options, 'private'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Cleans one installed Select update and remounts only rejected input.
|
||||
* @param targetType - Selector whose controlled string values changed.
|
||||
* @param runtimeValue - Raw installed Select emission; may contain bad runtime values.
|
||||
*/
|
||||
function handleValueUpdate(targetType: TargetType, runtimeValue: unknown) {
|
||||
const targetIds = normalizeTargetIds(runtimeValue);
|
||||
if (containsRejectedTarget(runtimeValue, targetIds)) {
|
||||
@ -164,12 +144,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns controlled target IDs for exactly one target type.
|
||||
* @param targets - Current API/form-controlled targets.
|
||||
* @param targetType - Group or private selector identity.
|
||||
* @returns Original string IDs in their controlled order.
|
||||
*/
|
||||
function targetIdsForType(
|
||||
targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
targetType: TargetType,
|
||||
@ -179,12 +153,6 @@ function targetIdsForType(
|
||||
.map((target) => target.targetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one type's API candidates to plain string-value Select options.
|
||||
* @param options - Current OneBot candidate snapshot.
|
||||
* @param targetType - Type allowed in the target selector.
|
||||
* @returns Type-scoped candidates retaining their server labels.
|
||||
*/
|
||||
function createSelectOptions(
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||
targetType: TargetType,
|
||||
@ -198,12 +166,6 @@ function createSelectOptions(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches one known candidate by case-insensitive label and string ID.
|
||||
* @param input - Search text entered into the installed Select.
|
||||
* @param option - Candidate option produced by `createSelectOptions`.
|
||||
* @returns Whether its label or target ID contains the query.
|
||||
*/
|
||||
function filterTargetOption(
|
||||
input: string,
|
||||
option: Record<string, unknown>,
|
||||
@ -215,11 +177,6 @@ function filterTargetOption(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the installed tags-mode payload without coercing non-string values.
|
||||
* @param runtimeValue - Raw `update:value` payload from Antdv Next.
|
||||
* @returns Trimmed, valid, deduplicated string IDs in input order.
|
||||
*/
|
||||
function normalizeTargetIds(runtimeValue: unknown): string[] {
|
||||
if (!Array.isArray(runtimeValue)) return [];
|
||||
const seen = new Set<string>();
|
||||
@ -234,12 +191,6 @@ function normalizeTargetIds(runtimeValue: unknown): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects internal Select state that differs from the cleaned controlled value.
|
||||
* @param runtimeValue - Raw installed component emission.
|
||||
* @param normalized - Accepted target IDs.
|
||||
* @returns Whether the affected Select must remount to discard invalid state.
|
||||
*/
|
||||
function containsRejectedTarget(
|
||||
runtimeValue: unknown,
|
||||
normalized: string[],
|
||||
@ -252,14 +203,6 @@ function containsRejectedTarget(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds group-first/private-second targets after one selector update.
|
||||
* @param current - Controlled value carrying existing API name snapshots.
|
||||
* @param options - Current type-scoped candidate labels.
|
||||
* @param changedType - Selector whose IDs are replaced.
|
||||
* @param changedIds - Sanitized replacement IDs.
|
||||
* @returns Normalized targets with no cross-type name leakage.
|
||||
*/
|
||||
function mergeTargets(
|
||||
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||
@ -291,14 +234,6 @@ function mergeTargets(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a display name from only the same target type and identifier.
|
||||
* @param current - Existing API/form values with optional name snapshots.
|
||||
* @param options - Current OneBot candidates.
|
||||
* @param targetType - Exact group/private namespace.
|
||||
* @param targetId - Exact string target identifier.
|
||||
* @returns Current candidate label, same-type existing name, or undefined.
|
||||
*/
|
||||
function resolveTargetName(
|
||||
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||
|
||||
@ -529,9 +529,6 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the route that will own the NapCat WebUI session lifecycle.
|
||||
*/
|
||||
function openNapcatWebui(row: QqbotApi.Account) {
|
||||
void router.push({
|
||||
name: 'QqBotAccountNapcatWebui',
|
||||
|
||||
@ -12,18 +12,9 @@ const accountRoot = resolve(
|
||||
const readAccountSource = (relativePath: string) =>
|
||||
readFileSync(resolve(accountRoot, relativePath), 'utf8');
|
||||
|
||||
/**
|
||||
* Reads repository-root files that define browser-facing deployment boundaries.
|
||||
*
|
||||
* @param relativePath - Repository-relative path to read.
|
||||
* @returns Source text for boundary assertions.
|
||||
*/
|
||||
const readRepoSource = (relativePath: string) =>
|
||||
readFileSync(resolve(cwd(), relativePath), 'utf8');
|
||||
|
||||
/**
|
||||
* Reads QQBot router module source for route boundary assertions.
|
||||
*/
|
||||
const readRouteSource = (relativePath: string) =>
|
||||
readFileSync(
|
||||
resolve(
|
||||
|
||||
@ -19,9 +19,6 @@ const ATag = Tag as any;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'QqBotAccountNapcatWebui',
|
||||
/**
|
||||
* Wires route account identity to the page-owned NapCat WebUI session.
|
||||
*/
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -48,32 +45,18 @@ export default defineComponent({
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigates back to the account list route.
|
||||
*/
|
||||
function goBack() {
|
||||
void router.push({ name: 'QqBotAccount' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the gateway session for the current route account.
|
||||
*/
|
||||
function reopen() {
|
||||
void session.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the current gateway session while staying on this page.
|
||||
*/
|
||||
function closeSession() {
|
||||
void session.revoke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the floating metadata panel without taking layout space from the iframe.
|
||||
*
|
||||
* @returns Overlay card content for the current session metadata and actions.
|
||||
*/
|
||||
const renderFloatingCard = () => {
|
||||
return (
|
||||
<div class="qqbot-napcat-webui__floating-card">
|
||||
@ -114,11 +97,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the main state area for loading, error, revoked, and ready states.
|
||||
*
|
||||
* @returns TSX content for the current gateway state.
|
||||
*/
|
||||
const renderBody = () => {
|
||||
if (session.state.value === 'ready' && session.iframeUrl.value) {
|
||||
return (
|
||||
@ -161,11 +139,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the page root required by Vben route transitions.
|
||||
*
|
||||
* @returns The stable single-root page shell.
|
||||
*/
|
||||
const renderPage = () => {
|
||||
return (
|
||||
<div class="qqbot-napcat-webui-page">
|
||||
@ -181,23 +154,11 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalizes a vue-router route param into a single trimmed account id.
|
||||
*
|
||||
* @param value - Raw route param value.
|
||||
* @returns Trimmed account id or an empty string.
|
||||
*/
|
||||
function normalizeRouteParam(value: unknown) {
|
||||
if (Array.isArray(value)) return `${value[0] || ''}`.trim();
|
||||
return `${value || ''}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the gateway expiry timestamp for compact page metadata.
|
||||
*
|
||||
* @param value - Epoch milliseconds returned by the Gateway heartbeat.
|
||||
* @returns Local date-time text, or an empty string when the value is absent.
|
||||
*/
|
||||
function formatGatewayExpiresAt(value?: number) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
@ -210,12 +171,6 @@ function formatGatewayExpiresAt(value?: number) {
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps gateway state to a compact Ant Design status tag.
|
||||
*
|
||||
* @param state - Current gateway lifecycle state.
|
||||
* @returns Tag color and Chinese label for the page header.
|
||||
*/
|
||||
function getStatusMeta(state: NapcatWebuiGatewaySessionState) {
|
||||
const statusMap = {
|
||||
error: { color: 'error', label: '异常' },
|
||||
|
||||
@ -134,12 +134,6 @@ vi.mock('#/api/qqbot/napcat', () => ({
|
||||
revokeQqbotNapcatWebuiSession: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Creates a mocked gateway session payload for page lifecycle tests.
|
||||
*
|
||||
* @param overrides - Partial fields that should replace the default fixture.
|
||||
* @returns A gateway session payload shaped like the backend response.
|
||||
*/
|
||||
function createSessionFixture(overrides = {}) {
|
||||
return {
|
||||
account: {
|
||||
|
||||
@ -19,12 +19,6 @@ export type NapcatWebuiGatewaySessionState =
|
||||
| 'ready'
|
||||
| 'revoked';
|
||||
|
||||
/**
|
||||
* Owns one NapCat WebUI gateway session for the current account page lifetime.
|
||||
*
|
||||
* @param accountId - Reactive account id read from the route.
|
||||
* @returns Reactive gateway session state and lifecycle actions.
|
||||
*/
|
||||
export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
const account = ref<QqbotNapcatApi.WebuiGatewaySessionAccount>();
|
||||
const container = ref<QqbotNapcatApi.WebuiGatewaySessionContainer>();
|
||||
@ -42,11 +36,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
|
||||
onBeforeUnmount(handleBeforeUnmount);
|
||||
|
||||
/**
|
||||
* Opens a new gateway session after revoking any existing local session.
|
||||
*
|
||||
* @returns A promise that settles after the open attempt finishes.
|
||||
*/
|
||||
async function open() {
|
||||
const nextAccountId = accountId.value.trim();
|
||||
heartbeat.pause();
|
||||
@ -89,11 +78,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes the current gateway session and clears local iframe credentials.
|
||||
*
|
||||
* @returns A promise that settles after local state has been cleared.
|
||||
*/
|
||||
async function revoke() {
|
||||
openToken += 1;
|
||||
heartbeat.pause();
|
||||
@ -118,11 +102,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one heartbeat for the active ready session and updates its expiry.
|
||||
*
|
||||
* @returns A promise that settles after the heartbeat attempt finishes.
|
||||
*/
|
||||
async function sendHeartbeat() {
|
||||
const currentSessionId = sessionId.value;
|
||||
if (state.value !== 'ready' || !currentSessionId) return;
|
||||
@ -142,11 +121,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the gateway session payload returned by the backend.
|
||||
*
|
||||
* @param result - Gateway session payload containing iframe URL and metadata.
|
||||
*/
|
||||
function applyGatewaySession(result: QqbotNapcatApi.WebuiGatewaySession) {
|
||||
account.value = result.account;
|
||||
container.value = result.container;
|
||||
@ -155,9 +129,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
sessionId.value = result.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all local data that can keep a gateway session reachable.
|
||||
*/
|
||||
function clearGatewaySession() {
|
||||
account.value = undefined;
|
||||
container.value = undefined;
|
||||
@ -166,12 +137,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
sessionId.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a session created after this composable was already invalidated.
|
||||
*
|
||||
* @param staleSessionId - Session id returned by an outdated open request.
|
||||
* @returns A promise that ignores revoke failures because the page is leaving.
|
||||
*/
|
||||
async function revokeDetachedSession(staleSessionId: string) {
|
||||
try {
|
||||
await revokeQqbotNapcatWebuiSession(staleSessionId);
|
||||
@ -180,9 +145,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the composable disposed and starts best-effort session revocation.
|
||||
*/
|
||||
function handleBeforeUnmount() {
|
||||
disposed = true;
|
||||
void revoke();
|
||||
@ -201,13 +163,6 @@ export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a user-facing error message from unknown rejection values.
|
||||
*
|
||||
* @param error - Unknown error thrown by the request client.
|
||||
* @param fallback - Message to show when the error has no readable text.
|
||||
* @returns A Chinese or backend-provided message for page alerts.
|
||||
*/
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === 'string' && error.trim()) return error.trim();
|
||||
|
||||
@ -39,9 +39,6 @@ export default defineComponent({
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads sanitized runtime profile evidence for the selected account.
|
||||
*/
|
||||
async function loadDetail() {
|
||||
if (!props.account?.id) return;
|
||||
loading.value = true;
|
||||
@ -52,19 +49,11 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits both drawer close contracts used by existing QQBot views.
|
||||
*/
|
||||
function closeDrawer() {
|
||||
emit('update:open', false);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders compact key/value evidence rows without exposing interactive controls.
|
||||
* @param label - Human readable field label.
|
||||
* @param value - Runtime evidence value already sanitized by the API.
|
||||
*/
|
||||
const renderField = (label: string, value: unknown) => {
|
||||
return (
|
||||
<div class="grid grid-cols-[120px_1fr] gap-3 border-b border-solid border-border py-2 text-sm">
|
||||
@ -74,11 +63,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a JSON evidence block for profile sections.
|
||||
* @param title - Section title shown above the evidence block.
|
||||
* @param value - Sanitized profile object returned by the API.
|
||||
*/
|
||||
const renderJsonBlock = (title: string, value: unknown) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
@ -91,11 +75,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts scalar runtime evidence into stable display text.
|
||||
* @param value - Sanitized value from profile detail.
|
||||
* @returns Display text for compact rows.
|
||||
*/
|
||||
function formatValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return '-';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
|
||||
@ -30,11 +30,9 @@ const mocks = vi.hoisted(() => {
|
||||
},
|
||||
tableOptions: undefined as any,
|
||||
};
|
||||
/** Installs the list path only when the rendered KtTable emits registration. */
|
||||
state.registerTable.mockImplementation(() => {
|
||||
state.registeredTableOptions = state.tableOptions;
|
||||
});
|
||||
/** Reloads through the registered table options and returns the caller result. */
|
||||
state.tableApi.reload.mockImplementation(async () => {
|
||||
if (!state.registeredTableOptions) {
|
||||
throw new Error('[MockKtTable]: table is not registered yet.');
|
||||
@ -58,7 +56,6 @@ vi.mock('@vben/common-ui', () => ({
|
||||
Page: defineComponent({
|
||||
name: 'MockPage',
|
||||
props: { autoContentHeight: Boolean },
|
||||
/** Renders a stable marker exposing the root height contract. */
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
@ -76,7 +73,6 @@ vi.mock('@vben/common-ui', () => ({
|
||||
vi.mock('antdv-next', () => ({
|
||||
Tag: defineComponent({
|
||||
name: 'MockTag',
|
||||
/** Renders enabled-state tag text for the body-cell contract. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
@ -87,7 +83,6 @@ vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockKtTable',
|
||||
emits: ['register'],
|
||||
/** Registers the table boundary before rendering its explicit refresh control. */
|
||||
setup(_, { emit, slots }) {
|
||||
emit('register', {});
|
||||
return () =>
|
||||
@ -117,7 +112,6 @@ vi.mock('./components/MessageSubscriptionModal', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockMessageSubscriptionModal',
|
||||
emits: ['saved'],
|
||||
/** Exposes create/edit commands and one deterministic saved trigger. */
|
||||
setup(_, { emit, expose }) {
|
||||
expose({
|
||||
openCreate: mocks.modalOpenCreate,
|
||||
@ -144,7 +138,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
|
||||
setMessageSubscriptionEnabled: mocks.api.toggle,
|
||||
}));
|
||||
|
||||
/** Creates one table row whose identifiers exceed JavaScript's safe integer. */
|
||||
function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
|
||||
return {
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
|
||||
@ -33,9 +33,6 @@ const AKtTable = KtTable as any;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'QqBotMessageSubscriptionList',
|
||||
/**
|
||||
* Owns the permission-gated subscription table, metadata cache, and row reloads.
|
||||
*/
|
||||
setup() {
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canList = hasAccessByCodes(['QqBot:MessageSubscription:List']);
|
||||
@ -85,7 +82,6 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<QqbotMessagePushApi.MessageSubscriptionView> = {
|
||||
/** Passes the caller's strict `{ items, total }` page through unchanged. */
|
||||
list: async (params) => await getMessageSubscriptionList(params),
|
||||
};
|
||||
const buttons: Array<
|
||||
@ -170,35 +166,20 @@ export default defineComponent({
|
||||
tableTitle: '消息订阅',
|
||||
});
|
||||
|
||||
/** Opens a blank modal session through the page-owned exposed ref. */
|
||||
function openCreate() {
|
||||
modalRef.value?.openCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one row without copying page-only runtime state into the modal.
|
||||
* @param row - Subscription selected from KtTable.
|
||||
*/
|
||||
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
|
||||
modalRef.value?.openEdit(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds KtTable's delete confirmation text for one subscription.
|
||||
* @param row - Subscription awaiting delete confirmation.
|
||||
* @returns Human-readable confirmation text containing the row name.
|
||||
*/
|
||||
function getDeleteConfirm(
|
||||
row: QqbotMessagePushApi.MessageSubscriptionView,
|
||||
) {
|
||||
return `确认删除消息订阅「${row.name}」吗?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one row and reloads only that table context after success.
|
||||
* @param row - Subscription whose enabled state is inverted.
|
||||
* @param context - KtTable row-action context owning the list reload.
|
||||
*/
|
||||
async function handleToggle(
|
||||
row: QqbotMessagePushApi.MessageSubscriptionView,
|
||||
context: KtTableContext<QqbotMessagePushApi.MessageSubscriptionView>,
|
||||
@ -207,11 +188,6 @@ export default defineComponent({
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one row and reloads only that table context after success.
|
||||
* @param row - Subscription confirmed through KtTable's action system.
|
||||
* @param context - KtTable row-action context owning the list reload.
|
||||
*/
|
||||
async function handleDelete(
|
||||
row: QqbotMessagePushApi.MessageSubscriptionView,
|
||||
context: KtTableContext<QqbotMessagePushApi.MessageSubscriptionView>,
|
||||
@ -220,12 +196,10 @@ export default defineComponent({
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/** Reloads the mutable list exactly once after a successful modal save. */
|
||||
async function handleModalSaved() {
|
||||
await tableApi.reload();
|
||||
}
|
||||
|
||||
/** Loads immutable source metadata once for the authorized page mount. */
|
||||
async function loadMetadata() {
|
||||
const [nextSources, nextStunOptions] = await Promise.all([
|
||||
getMessagePushSources(),
|
||||
@ -235,19 +209,11 @@ export default defineComponent({
|
||||
stunOptions.value = nextStunOptions;
|
||||
}
|
||||
|
||||
/** Starts the single authorized list and metadata load for this mount. */
|
||||
async function activatePage() {
|
||||
if (!canList) return;
|
||||
await Promise.all([tableApi.reload(), loadMetadata()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders source, status, and empty-value presentation for data columns.
|
||||
* @param slot - KtTable body-cell slot payload.
|
||||
* @param slot.column - Column whose custom presentation is requested.
|
||||
* @param slot.record - Subscription row rendered by the table.
|
||||
* @returns Custom cell content or undefined for native field rendering.
|
||||
*/
|
||||
function renderBodyCell(slot: {
|
||||
column: TableColumnType<QqbotMessagePushApi.MessageSubscriptionView>;
|
||||
record: QqbotMessagePushApi.MessageSubscriptionView;
|
||||
|
||||
@ -11,7 +11,6 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import MessageTemplateMentions from './MessageTemplateMentions';
|
||||
|
||||
/** Creates one complete source-variable fixture for Mentions behavior. */
|
||||
function createVariable(
|
||||
overrides: Partial<QqbotMessagePushApi.SystemMessageSourceVariableDefinition> = {},
|
||||
): QqbotMessagePushApi.SystemMessageSourceVariableDefinition {
|
||||
|
||||
@ -35,16 +35,9 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
/**
|
||||
* Keeps the wrapper controlled and forwards only the complete plain-text value.
|
||||
* @param value - Mentions textarea content after an explicit user update.
|
||||
* @returns Always true because string payloads are the only supported update.
|
||||
*/
|
||||
'update:value': (value: string) => typeof value === 'string',
|
||||
},
|
||||
/** Creates a request-free controlled boundary around the installed Mentions. */
|
||||
setup(props, { emit }) {
|
||||
/** Maps server variables to the exact `{{key}}` values required after `$`. */
|
||||
const options = computed<MessageTemplateMentionOption[]>(() =>
|
||||
props.variables.map((variable) => ({
|
||||
label: `${variable.key} · ${variable.label} · ${variable.description} · 示例:${variable.example}`,
|
||||
@ -53,12 +46,6 @@ export default defineComponent({
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Filters one option across the four server-owned variable descriptors.
|
||||
* @param input - Case-insensitive query entered after the `$` prefix.
|
||||
* @param option - Mentions option carrying the original variable definition.
|
||||
* @returns Whether key, label, description, or example includes the query.
|
||||
*/
|
||||
function filterVariableOption(
|
||||
input: string,
|
||||
option: Record<string, unknown>,
|
||||
@ -74,10 +61,6 @@ export default defineComponent({
|
||||
].some((field) => field.toLocaleLowerCase().includes(query));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the installed component's controlled value without interpreting its text.
|
||||
* @param value - Literal textarea value, including CQ-looking substrings.
|
||||
*/
|
||||
function handleValueUpdate(value: string) {
|
||||
emit('update:value', value);
|
||||
}
|
||||
@ -99,11 +82,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Narrows an unknown Mentions option payload to the server variable contract.
|
||||
* @param value - Candidate option metadata supplied by the installed component.
|
||||
* @returns Whether all searchable variable fields are strings.
|
||||
*/
|
||||
function isVariableDefinition(
|
||||
value: unknown,
|
||||
): value is QqbotMessagePushApi.SystemMessageSourceVariableDefinition {
|
||||
|
||||
@ -50,7 +50,6 @@ vi.mock('@vben/common-ui', () => ({
|
||||
Page: defineComponent({
|
||||
name: 'MockPage',
|
||||
props: { autoContentHeight: Boolean },
|
||||
/** Renders one stable route root and exposes the height contract. */
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
@ -72,7 +71,6 @@ vi.mock('@vben/icons', () => ({
|
||||
vi.mock('antdv-next/dist/tag/index', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockTag',
|
||||
/** Renders tag children as ordinary escaped text. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
@ -92,7 +90,6 @@ vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockKtTable',
|
||||
emits: ['register'],
|
||||
/** Emits the actual registration event before exposing refresh/list rendering. */
|
||||
setup(_, { emit, slots }) {
|
||||
emit('register', { registered: true });
|
||||
return () =>
|
||||
@ -132,7 +129,6 @@ vi.mock('./components/MessageTemplateModal', () => ({
|
||||
},
|
||||
},
|
||||
emits: ['saved'],
|
||||
/** Exposes create/edit and a saved trigger through the rendered modal marker. */
|
||||
setup(props, { emit, expose }) {
|
||||
expose({
|
||||
openCreate: mocks.modalOpenCreate,
|
||||
@ -152,7 +148,6 @@ vi.mock('./components/MessageTemplateModal', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Creates one template row while preserving unsafe-integer IDs as strings. */
|
||||
function createRow(
|
||||
overrides: Partial<QqbotMessagePushApi.MessageTemplateView> = {},
|
||||
): QqbotMessagePushApi.MessageTemplateView {
|
||||
@ -171,7 +166,6 @@ function createRow(
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates the page-lifetime source directory fixture. */
|
||||
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
||||
return [
|
||||
{
|
||||
|
||||
@ -32,7 +32,6 @@ const AKtTable = KtTable as any;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'QqBotMessageTemplateList',
|
||||
/** Owns the permission-gated template list, source labels, and row mutations. */
|
||||
setup() {
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canList = hasAccessByCodes(['QqBot:MessageTemplate:List']);
|
||||
@ -62,7 +61,6 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<QqbotMessagePushApi.MessageTemplateView> = {
|
||||
/** Passes the caller's strict `{ items, total }` page through unchanged. */
|
||||
list: async (params) => await getMessageTemplateList(params),
|
||||
};
|
||||
const buttons: Array<
|
||||
@ -149,33 +147,18 @@ export default defineComponent({
|
||||
tableTitle: '消息模板',
|
||||
});
|
||||
|
||||
/** Opens a blank template session through the page-owned exposed ref. */
|
||||
function openCreate() {
|
||||
modalRef.value?.openCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one row without copying page-only runtime state into the modal.
|
||||
* @param row - Template selected from KtTable.
|
||||
*/
|
||||
function openEdit(row: QqbotMessagePushApi.MessageTemplateView) {
|
||||
modalRef.value?.openEdit(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds KtTable confirmation for one currently unreferenced template.
|
||||
* @param row - Template awaiting delete confirmation.
|
||||
* @returns Confirmation text containing the template name.
|
||||
*/
|
||||
function getDeleteConfirm(row: QqbotMessagePushApi.MessageTemplateView) {
|
||||
return `确认删除消息模板「${row.name}」吗?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains why a referenced template cannot be deleted from the current row.
|
||||
* @param row - Template whose current reference count controls the action.
|
||||
* @returns Count-bearing reason, or undefined when delete is enabled.
|
||||
*/
|
||||
function getDeleteDisabledReason(
|
||||
row: QqbotMessagePushApi.MessageTemplateView,
|
||||
) {
|
||||
@ -184,11 +167,6 @@ export default defineComponent({
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one row and reloads only its KtTable context after success.
|
||||
* @param row - Template whose enabled state is inverted.
|
||||
* @param context - Row-action context owning the affected list.
|
||||
*/
|
||||
async function handleToggle(
|
||||
row: QqbotMessagePushApi.MessageTemplateView,
|
||||
context: KtTableContext<QqbotMessagePushApi.MessageTemplateView>,
|
||||
@ -197,11 +175,6 @@ export default defineComponent({
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one row and reloads only its KtTable context after success.
|
||||
* @param row - Unreferenced template confirmed through KtTable.
|
||||
* @param context - Row-action context owning the affected list.
|
||||
*/
|
||||
async function handleDelete(
|
||||
row: QqbotMessagePushApi.MessageTemplateView,
|
||||
context: KtTableContext<QqbotMessagePushApi.MessageTemplateView>,
|
||||
@ -210,29 +183,19 @@ export default defineComponent({
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/** Reloads the mutable list exactly once after one successful modal save. */
|
||||
async function handleModalSaved() {
|
||||
await tableApi.reload();
|
||||
}
|
||||
|
||||
/** Loads the page-lifetime source directory once for an authorized mount. */
|
||||
async function loadSources() {
|
||||
sources.value = await getMessagePushSources();
|
||||
}
|
||||
|
||||
/** Starts the only automatic list/source load for this route mount. */
|
||||
async function activatePage() {
|
||||
if (!canList) return;
|
||||
await Promise.all([tableApi.reload(), loadSources()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders source, literal content summary, and enabled presentation.
|
||||
* @param slot - KtTable body-cell payload.
|
||||
* @param slot.column - Column requesting a custom presentation.
|
||||
* @param slot.record - Template row rendered by the table.
|
||||
* @returns Escaped text/status content or undefined for native rendering.
|
||||
*/
|
||||
function renderBodyCell(slot: {
|
||||
column: TableColumnType<QqbotMessagePushApi.MessageTemplateView>;
|
||||
record: QqbotMessagePushApi.MessageTemplateView;
|
||||
|
||||
@ -412,9 +412,6 @@ export default defineComponent({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 KtTable 表头控制区。
|
||||
*/
|
||||
const renderHeaderControls = () => {
|
||||
return (
|
||||
<>
|
||||
@ -439,9 +436,6 @@ export default defineComponent({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 KtTable 按钮区里的权限过滤模式。
|
||||
*/
|
||||
const renderPermissionModeToolbar = () => {
|
||||
return (
|
||||
<div class="kt-table__header-control-group">
|
||||
|
||||
@ -42,9 +42,6 @@ export default defineComponent({
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads the latest task run records for the selected plugin task drawer.
|
||||
*/
|
||||
async function loadRuns() {
|
||||
if (!props.task?.id) return;
|
||||
loading.value = true;
|
||||
@ -59,11 +56,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one scheduled-task execution record with themed evidence blocks.
|
||||
*
|
||||
* @param item - Task run row returned by the plugin task API.
|
||||
*/
|
||||
const renderRun = (item: QqbotPluginTaskApi.TaskRun) => (
|
||||
<div class="border-b border-solid border-border py-3" key={item.id}>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
|
||||
@ -43,11 +43,6 @@ export default defineComponent({
|
||||
},
|
||||
emits: ['close', 'installationAction'],
|
||||
setup(props, { emit }) {
|
||||
/**
|
||||
* Maps plugin platform status values to readable themed status tags.
|
||||
*
|
||||
* @param status - Runtime, binding, or installation status from the API.
|
||||
*/
|
||||
const renderStatusTag = (status?: string) => {
|
||||
if (!status) return <Tag color="default">-</Tag>;
|
||||
const color =
|
||||
@ -55,9 +50,6 @@ export default defineComponent({
|
||||
return <Tag color={color}>{getQqbotStatusLabel(status)}</Tag>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders recent runtime events with safe summaries for diagnosis.
|
||||
*/
|
||||
const renderEvents = () =>
|
||||
props.runtimeEvents.length > 0 ? (
|
||||
<div class="space-y-3">
|
||||
@ -79,9 +71,6 @@ export default defineComponent({
|
||||
<span>暂无运行事件</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders account-to-plugin binding rows for the selected platform state.
|
||||
*/
|
||||
const renderBindings = () =>
|
||||
props.accountBindings.length > 0 ? (
|
||||
<div class="space-y-3">
|
||||
@ -98,9 +87,6 @@ export default defineComponent({
|
||||
<span>暂无账号绑定</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders installed plugin rows and exposes safe lifecycle actions.
|
||||
*/
|
||||
const renderInstallations = () =>
|
||||
props.installations.length > 0 ? (
|
||||
<div class="space-y-3">
|
||||
@ -140,9 +126,6 @@ export default defineComponent({
|
||||
<span>暂无安装记录</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Selects the drawer body according to the active platform state tab.
|
||||
*/
|
||||
const renderContent = () => {
|
||||
if (props.mode === 'events') return renderEvents();
|
||||
if (props.mode === 'bindings') return renderBindings();
|
||||
|
||||
@ -41,7 +41,6 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
/** Creates a fluent no-op validation rule for the form-schema fixture. */
|
||||
function createRule(): any {
|
||||
const rule: any = {};
|
||||
for (const method of [
|
||||
@ -110,7 +109,6 @@ vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
/** Builds one server-returned DDNS row with a Snowflake string ID. */
|
||||
function createDdnsRow(
|
||||
overrides: Partial<SystemNetworkApi.DdnsRecord> = {},
|
||||
): SystemNetworkApi.DdnsRecord {
|
||||
|
||||
@ -43,9 +43,6 @@ const dnsLabelPattern = /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i;
|
||||
export default defineComponent({
|
||||
name: 'NetworkDdnsRecordModal',
|
||||
emits: ['saved'],
|
||||
/**
|
||||
* Owns the dual-stack DDNS CRUD form while keeping provider details server-side.
|
||||
*/
|
||||
setup(_, { emit, expose }) {
|
||||
const editingRow = ref<SystemNetworkApi.DdnsRecord>();
|
||||
const recordType = ref<SystemNetworkApi.DdnsRecordType>('A');
|
||||
@ -55,11 +52,6 @@ export default defineComponent({
|
||||
commonConfig: {
|
||||
labelClass: 'w-24',
|
||||
},
|
||||
/**
|
||||
* Replaces address-family sources immediately without retaining a stale mapping ID.
|
||||
* @param values - Current form values after the field update.
|
||||
* @param fieldsChanged - Field names changed in this form event.
|
||||
*/
|
||||
async handleValuesChange(values, fieldsChanged) {
|
||||
if (!fieldsChanged.includes('recordType')) return;
|
||||
const nextRecordType =
|
||||
@ -97,10 +89,6 @@ export default defineComponent({
|
||||
async onConfirm() {
|
||||
await submit();
|
||||
},
|
||||
/**
|
||||
* Restores form state only after destroy-on-close content has mounted.
|
||||
* @param isOpen - Whether the modal content is now visible.
|
||||
*/
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
const { values } = modalApi.getData<NetworkDdnsRecordModalData>();
|
||||
@ -113,7 +101,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/** Opens a blank IPv4 DDNS form without provider or credential fields. */
|
||||
function openCreate() {
|
||||
editingRow.value = undefined;
|
||||
recordType.value = 'A';
|
||||
@ -133,10 +120,6 @@ export default defineComponent({
|
||||
.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one record with only the editable DDNS contract copied into the form.
|
||||
* @param row - Persisted DDNS row selected from KtTable.
|
||||
*/
|
||||
function openEdit(row: SystemNetworkApi.DdnsRecord) {
|
||||
editingRow.value = row;
|
||||
recordType.value = row.recordType;
|
||||
@ -159,20 +142,12 @@ export default defineComponent({
|
||||
.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets validation and installs only user-editable values.
|
||||
* @param values - Modal session values excluding provider/runtime state.
|
||||
*/
|
||||
async function resetForm(values: Partial<NetworkDdnsRecordFormValues>) {
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues(values);
|
||||
await formApi.resetValidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads sources for the selected family while dropping stale response races.
|
||||
* @param nextRecordType - Address family whose sources the server must evaluate.
|
||||
*/
|
||||
async function loadSourceOptions(
|
||||
nextRecordType: SystemNetworkApi.DdnsRecordType,
|
||||
) {
|
||||
@ -187,7 +162,6 @@ export default defineComponent({
|
||||
sourceOptions.value = result.items;
|
||||
}
|
||||
|
||||
/** Persists one normalized A/AAAA binding and leaves provider work asynchronous. */
|
||||
async function submit() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
@ -245,11 +219,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the approved dual-stack DDNS form schema without provider credentials.
|
||||
* @param sourceOptions - Reactive API-owned source choices used by the IPv4 select.
|
||||
* @returns Vben form fields for record identity, family, source and local behavior.
|
||||
*/
|
||||
function createFormSchema(
|
||||
sourceOptions: Readonly<{
|
||||
value: SystemNetworkApi.DdnsSourceOption[];
|
||||
@ -333,11 +302,6 @@ function createFormSchema(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats one server-evaluated source without making eligibility decisions locally.
|
||||
* @param source - Source option returned by the API.
|
||||
* @returns Ant Design Select option with a stable string ID and disabled reason.
|
||||
*/
|
||||
function formatSourceOption(source: SystemNetworkApi.DdnsSourceOption) {
|
||||
const endpoint =
|
||||
source.protocol && source.externalPort
|
||||
@ -355,22 +319,12 @@ function formatSourceOption(source: SystemNetworkApi.DdnsSourceOption) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a stable server reason code without inventing source eligibility rules.
|
||||
* @param reasonCode - Optional API reason code for an ineligible source.
|
||||
* @returns Localized prefix plus the stable code for diagnostics.
|
||||
*/
|
||||
function formatSourceDisabledReason(reasonCode?: null | string): string {
|
||||
return reasonCode
|
||||
? `${$t('system.network.ddnsSourceUnavailable')}: ${reasonCode}`
|
||||
: $t('system.network.ddnsSourceUnavailable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a DNSPod zone as ASCII DNS labels with no URL or port syntax.
|
||||
* @param value - Candidate zone name.
|
||||
* @returns True only for a normalized multi-label DNS zone.
|
||||
*/
|
||||
export function isValidDdnsDomain(value: string): boolean {
|
||||
const normalized = value.trim();
|
||||
if (
|
||||
@ -383,11 +337,6 @@ export function isValidDdnsDomain(value: string): boolean {
|
||||
return normalized.split('.').every((label) => dnsLabelPattern.test(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an A/AAAA host record, including DNSPod's root-record marker.
|
||||
* @param value - Candidate host record.
|
||||
* @returns True for `@` or one or more valid ASCII DNS labels.
|
||||
*/
|
||||
export function isValidDdnsSubDomain(value: string): boolean {
|
||||
const normalized = value.trim();
|
||||
if (normalized === '@') return true;
|
||||
|
||||
@ -101,7 +101,6 @@ vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
/** Builds one dual-stack-capable DDNS table fixture. */
|
||||
function createDdnsRow(
|
||||
overrides: Partial<SystemNetworkApi.DdnsRecord> = {},
|
||||
): SystemNetworkApi.DdnsRecord {
|
||||
|
||||
@ -66,9 +66,6 @@ export interface NetworkDdnsTableExposed {
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NetworkDdnsTable',
|
||||
/**
|
||||
* Owns the independent DDNS KtTable while the route page owns SSE and active-tab refresh.
|
||||
*/
|
||||
setup(_, { expose }) {
|
||||
const busyRowIds = ref<Set<string>>(new Set());
|
||||
const modalRef = ref<NetworkDdnsRecordModalExposed>();
|
||||
@ -116,11 +113,6 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<SystemNetworkApi.DdnsRecord> = {
|
||||
/**
|
||||
* Loads the latest persisted DDNS fact page.
|
||||
* @param params - KtTable pagination and search values.
|
||||
* @returns Server-owned DDNS rows and total.
|
||||
*/
|
||||
list: async (params) => await getNetworkDdnsList(params),
|
||||
};
|
||||
const buttons: Array<KtTableButton<SystemNetworkApi.DdnsRecord>> = [
|
||||
@ -217,30 +209,18 @@ export default defineComponent({
|
||||
tableTitle: $t('system.network.ddnsTitle'),
|
||||
});
|
||||
|
||||
/** Opens the blank dual-stack DDNS modal. */
|
||||
function openCreate() {
|
||||
modalRef.value?.openCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one DDNS record unless another mutation owns its row.
|
||||
* @param row - Persisted DDNS record.
|
||||
*/
|
||||
function openEdit(row: SystemNetworkApi.DdnsRecord) {
|
||||
if (!isRowBusy(row)) modalRef.value?.openEdit(row);
|
||||
}
|
||||
|
||||
/** Refreshes the table after a successful modal save. */
|
||||
function handleModalSaved() {
|
||||
void reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one DDNS mutation with per-row duplicate suppression.
|
||||
* @param row - Selected persisted record.
|
||||
* @param mutation - API operation accepting the stable record ID.
|
||||
* @param successMessage - Accurate asynchronous-success wording.
|
||||
*/
|
||||
async function runRowMutation(
|
||||
row: SystemNetworkApi.DdnsRecord,
|
||||
mutation: (id: string) => Promise<unknown>,
|
||||
@ -257,11 +237,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the reactive in-flight set instead of mutating it in place.
|
||||
* @param id - Stable DDNS record ID.
|
||||
* @param busy - Whether this row currently owns a write.
|
||||
*/
|
||||
function setRowBusy(id: string, busy: boolean) {
|
||||
const next = new Set(busyRowIds.value);
|
||||
if (busy) next.add(id);
|
||||
@ -269,16 +244,10 @@ export default defineComponent({
|
||||
busyRowIds.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether one row already owns a write request.
|
||||
* @param row - Persisted DDNS record.
|
||||
* @returns True while a mutation is in flight.
|
||||
*/
|
||||
function isRowBusy(row: SystemNetworkApi.DdnsRecord): boolean {
|
||||
return busyRowIds.value.has(row.id);
|
||||
}
|
||||
|
||||
/** Loads redacted provider readiness without translating failure into disabled. */
|
||||
async function loadProviderStatus() {
|
||||
try {
|
||||
providerStatus.value = await getNetworkDdnsProviderStatus();
|
||||
@ -288,14 +257,10 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads only DDNS-owned data when requested by the route or a local mutation.
|
||||
*/
|
||||
async function reload(): Promise<void> {
|
||||
await Promise.allSettled([tableApi.reload(), loadProviderStatus()]);
|
||||
}
|
||||
|
||||
/** Renders provider readiness without exposing credential values. */
|
||||
function renderProviderStatus() {
|
||||
const status = providerStatus.value;
|
||||
let color = 'warning';
|
||||
@ -316,13 +281,6 @@ export default defineComponent({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders dual-stack DDNS cells using only API-returned address and FQDN facts.
|
||||
* @param cell - KtTable body-cell slot context.
|
||||
* @param cell.column - Current table column.
|
||||
* @param cell.record - Current DDNS record.
|
||||
* @returns Cell content or undefined for default rendering.
|
||||
*/
|
||||
function renderBodyCell({ column, record }: any) {
|
||||
const row = record as SystemNetworkApi.DdnsRecord;
|
||||
if (column.key === 'identity') {
|
||||
@ -389,11 +347,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves whether retry is unsafe without hiding the action.
|
||||
* @param row - Persisted DDNS record with server-evaluated source eligibility.
|
||||
* @returns Exact disabled reason, or undefined when retry is allowed.
|
||||
*/
|
||||
export function getDdnsRetryDisabledReason(
|
||||
row: SystemNetworkApi.DdnsRecord,
|
||||
): string | undefined {
|
||||
|
||||
@ -62,7 +62,6 @@ vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
/** Creates the minimal row needed to scope a history request. */
|
||||
function createRow(id: string): SystemNetworkApi.PortForward {
|
||||
return {
|
||||
desiredPresence: 'present',
|
||||
|
||||
@ -27,7 +27,6 @@ const eventColors: Record<SystemNetworkApi.EndpointEventType, string> = {
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NetworkEndpointHistoryDrawer',
|
||||
/** Creates a lazy, record-scoped history table inside a disposable drawer. */
|
||||
setup(_, { expose }) {
|
||||
const selectedRow = ref<SystemNetworkApi.PortForward>();
|
||||
const columns: Array<
|
||||
@ -64,7 +63,6 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<SystemNetworkApi.EndpointHistoryItem> = {
|
||||
/** Loads only the selected mapping's append-only endpoint transitions. */
|
||||
list: async (params) => {
|
||||
if (!selectedRow.value) return { items: [], total: 0 };
|
||||
return await getNetworkPortForwardEndpointHistory(
|
||||
@ -108,10 +106,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens history for one mapping and discards rows from the prior selection.
|
||||
* @param row - Mapping whose append-only history should be queried.
|
||||
*/
|
||||
function open(row: SystemNetworkApi.PortForward) {
|
||||
selectedRow.value = row;
|
||||
drawerApi.open();
|
||||
|
||||
@ -35,7 +35,6 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
/** Creates a fluent no-op validation rule for the form-schema fixture. */
|
||||
function createRule(): any {
|
||||
const rule: any = {};
|
||||
for (const method of ['int', 'max', 'min', 'optional', 'or', 'trim']) {
|
||||
|
||||
@ -31,9 +31,6 @@ const protocolOptions = [
|
||||
export default defineComponent({
|
||||
name: 'NetworkPortForwardModal',
|
||||
emits: ['saved'],
|
||||
/**
|
||||
* Owns the CRUD form while keeping the NAS target and Keeper state read-only.
|
||||
*/
|
||||
setup(_, { emit, expose }) {
|
||||
const editingRow = ref<SystemNetworkApi.PortForward>();
|
||||
const targetIpv4 = ref('');
|
||||
@ -57,7 +54,6 @@ export default defineComponent({
|
||||
async onConfirm() {
|
||||
await submit();
|
||||
},
|
||||
/** Resets values only after destroy-on-close content has mounted. */
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
const { values } = modalApi.getData<NetworkPortForwardModalData>();
|
||||
@ -65,10 +61,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a blank form for one new desired mapping.
|
||||
* @param fixedTargetIpv4 - Server-controlled NAS target shown read-only.
|
||||
*/
|
||||
function openCreate(fixedTargetIpv4: string) {
|
||||
editingRow.value = undefined;
|
||||
targetIpv4.value = fixedTargetIpv4;
|
||||
@ -85,10 +77,6 @@ export default defineComponent({
|
||||
.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an existing row for editing without copying reported/runtime fields.
|
||||
* @param row - Persisted desired record selected from KtTable.
|
||||
*/
|
||||
function openEdit(row: SystemNetworkApi.PortForward) {
|
||||
editingRow.value = row;
|
||||
targetIpv4.value = row.targetIpv4;
|
||||
@ -105,10 +93,6 @@ export default defineComponent({
|
||||
.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets validation and installs only user-editable values.
|
||||
* @param values - Form values excluding target IP, Keeper and secrets.
|
||||
*/
|
||||
async function resetForm(
|
||||
values: Partial<SystemNetworkApi.PortForwardInput>,
|
||||
) {
|
||||
@ -117,9 +101,6 @@ export default defineComponent({
|
||||
await formApi.resetValidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the desired mapping and leaves all runtime effects asynchronous.
|
||||
*/
|
||||
async function submit() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
@ -173,10 +154,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the modal's approved editable schema without any credential field.
|
||||
* @returns Vben form fields for name, protocol, ports and optional remark.
|
||||
*/
|
||||
function createFormSchema(): VbenFormSchema[] {
|
||||
const portRule = z
|
||||
.number()
|
||||
|
||||
@ -9,18 +9,12 @@ export interface UseNetworkManagementStreamOptions {
|
||||
onStateChanged: (event: SystemNetworkApi.StateChangeEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges API SSE updates into the network page without exposing MQTT credentials.
|
||||
* @param options - Page-owned callbacks for committed changes and replay gaps.
|
||||
* @returns Idempotent start/close controls for route keep-alive lifecycle hooks.
|
||||
*/
|
||||
export function useNetworkManagementStream(
|
||||
options: UseNetworkManagementStreamOptions,
|
||||
) {
|
||||
const lastEventId = ref<string>();
|
||||
let source: EventSource | undefined;
|
||||
|
||||
/** Starts one EventSource and relies on native reconnect while the page is active. */
|
||||
function start() {
|
||||
if (source) return;
|
||||
source = new EventSource(getNetworkManagementEventsUrl(lastEventId.value), {
|
||||
@ -30,7 +24,6 @@ export function useNetworkManagementStream(
|
||||
source.addEventListener('snapshot-required', handleSnapshotRequired);
|
||||
}
|
||||
|
||||
/** Closes the active route stream and removes its typed listeners. */
|
||||
function close() {
|
||||
if (!source) return;
|
||||
source.removeEventListener('network-state-changed', handleStateChanged);
|
||||
@ -39,10 +32,6 @@ export function useNetworkManagementStream(
|
||||
source = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one committed MQTT-derived state event exactly once.
|
||||
* @param event - Browser SSE message containing a safe state-change envelope.
|
||||
*/
|
||||
function handleStateChanged(event: Event) {
|
||||
const payload = parseStateChange(event);
|
||||
if (!payload || payload.eventId === lastEventId.value) return;
|
||||
@ -50,16 +39,10 @@ export function useNetworkManagementStream(
|
||||
options.onStateChanged(payload);
|
||||
}
|
||||
|
||||
/** Requests one fresh snapshot only when the API cannot replay a missed topic event. */
|
||||
function handleSnapshotRequired() {
|
||||
options.onSnapshotRequired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the minimum browser payload before it reaches page state.
|
||||
* @param event - Typed EventSource message with JSON data.
|
||||
* @returns Parsed state event, or undefined for malformed/unexpected data.
|
||||
*/
|
||||
function parseStateChange(
|
||||
event: Event,
|
||||
): SystemNetworkApi.StateChangeEvent | undefined {
|
||||
|
||||
@ -32,26 +32,22 @@ class FakeEventSource {
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
/** Registers one typed SSE listener for the page test. */
|
||||
addEventListener(type: string, listener: FakeEventSourceListener) {
|
||||
const listeners = this.listeners.get(type) || new Set();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
/** Closes this fake stream and prevents later dispatches. */
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
/** Dispatches one JSON SSE payload to currently registered listeners. */
|
||||
dispatch(type: string, data: Record<string, unknown>) {
|
||||
if (this.closed) return;
|
||||
const event = new MessageEvent(type, { data: JSON.stringify(data) });
|
||||
this.listeners.get(type)?.forEach((listener) => listener(event));
|
||||
}
|
||||
|
||||
/** Removes one typed SSE listener from the page test. */
|
||||
removeEventListener(type: string, listener: FakeEventSourceListener) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
@ -224,7 +220,6 @@ vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
/** Creates a complete UDP row fixture with string revisions. */
|
||||
function createRow(
|
||||
overrides: Partial<SystemNetworkApi.PortForward> = {},
|
||||
): SystemNetworkApi.PortForward {
|
||||
|
||||
@ -92,9 +92,6 @@ const keeperStatusLabels: Record<SystemNetworkApi.KeeperStatus, string> = {
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SystemNetworkList',
|
||||
/**
|
||||
* Builds the generic persisted network-resource table and event-driven refresh stream.
|
||||
*/
|
||||
setup() {
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canViewPortForward = hasAccessByCodes([
|
||||
@ -192,7 +189,6 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<SystemNetworkApi.PortForward> = {
|
||||
/** Loads the latest API fact-source page for KtTable. */
|
||||
list: async (params) => await getNetworkPortForwardList(params),
|
||||
};
|
||||
const buttons: Array<KtTableButton<SystemNetworkApi.PortForward>> = [
|
||||
@ -364,7 +360,6 @@ export default defineComponent({
|
||||
tableTitle: $t('system.network.portForwardTitle'),
|
||||
});
|
||||
|
||||
/** Opens the create modal using the server-reported fixed target address. */
|
||||
function openCreate() {
|
||||
const rowTarget = tableApi.getRows()[0]?.targetIpv4 || '';
|
||||
void modalRef.value?.openCreate(
|
||||
@ -372,24 +367,20 @@ export default defineComponent({
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens one mutable row in the shared CRUD modal. */
|
||||
function openEdit(row: SystemNetworkApi.PortForward) {
|
||||
if (!isRowBusy(row) && !isDeleting(row)) {
|
||||
void modalRef.value?.openEdit(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the append-only endpoint history drawer for one row. */
|
||||
function openHistory(row: SystemNetworkApi.PortForward) {
|
||||
historyDrawerRef.value?.open(row);
|
||||
}
|
||||
|
||||
/** Reloads after a modal save while preserving serialized request order. */
|
||||
function handleModalSaved() {
|
||||
void requestRefresh('port-forward');
|
||||
}
|
||||
|
||||
/** Copies an API-approved current endpoint without logging it. */
|
||||
async function copyEndpoint(row: SystemNetworkApi.PortForward) {
|
||||
const endpoint = getCurrentEndpoint(row);
|
||||
if (!endpoint) return;
|
||||
@ -397,12 +388,6 @@ export default defineComponent({
|
||||
message.success($t('system.network.endpointCopied'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one row mutation with per-row duplicate suppression and fact-source reload.
|
||||
* @param row - Selected persisted mapping.
|
||||
* @param mutation - API operation accepting the stable record ID.
|
||||
* @param successMessage - Accurate asynchronous-success wording.
|
||||
*/
|
||||
async function runRowMutation(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
mutation: (id: string) => Promise<unknown>,
|
||||
@ -419,7 +404,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates the reactive per-row in-flight set without mutating it in place. */
|
||||
function setRowBusy(id: string, busy: boolean) {
|
||||
const next = new Set(busyRowIds.value);
|
||||
if (busy) next.add(id);
|
||||
@ -427,12 +411,10 @@ export default defineComponent({
|
||||
busyRowIds.value = next;
|
||||
}
|
||||
|
||||
/** Returns whether one row already owns an in-flight write request. */
|
||||
function isRowBusy(row: SystemNetworkApi.PortForward): boolean {
|
||||
return busyRowIds.value.has(row.id);
|
||||
}
|
||||
|
||||
/** Loads Agent connectivity without translating refresh failure into offline. */
|
||||
async function loadAgentStatus() {
|
||||
try {
|
||||
agentStatus.value = await getNetworkAgentStatus();
|
||||
@ -442,9 +424,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes table and Agent refreshes so stale responses cannot overtake writes.
|
||||
*/
|
||||
async function requestRefresh(
|
||||
resource: NetworkTabKey = activeTab.value,
|
||||
): Promise<void> {
|
||||
@ -470,10 +449,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads exactly one permission-approved resource without cross-tab requests.
|
||||
* @param resource - Active or locally-mutated network resource.
|
||||
*/
|
||||
async function performResourceRefresh(resource: NetworkTabKey) {
|
||||
if (resource === 'ddns') {
|
||||
await ddnsTableRef.value?.reload();
|
||||
@ -482,19 +457,10 @@ export default defineComponent({
|
||||
await Promise.allSettled([tableApi.reload(), loadAgentStatus()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks list permission before any resource request.
|
||||
* @param resource - Candidate tab resource.
|
||||
* @returns True when its List permission is present.
|
||||
*/
|
||||
function canViewResource(resource: NetworkTabKey): boolean {
|
||||
return resource === 'ddns' ? canViewDdns : canViewPortForward;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes only the active resource addressed by one semantic SSE event.
|
||||
* @param event - Committed API event classified by network resource source.
|
||||
*/
|
||||
function handleStateChanged(event: SystemNetworkApi.StateChangeEvent) {
|
||||
const resource = event.source === 'ddns' ? 'ddns' : 'port-forward';
|
||||
if (pageActive && activeTab.value === resource) {
|
||||
@ -502,22 +468,16 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes the active resource when the API cannot replay a missed event. */
|
||||
function handleSnapshotRequired() {
|
||||
if (pageActive) void requestRefresh(activeTab.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects one allowed tab and immediately requests its first fact snapshot.
|
||||
* @param key - Tab key emitted by Ant Design Tabs.
|
||||
*/
|
||||
function handleActiveTabChange(key: NetworkTabKey) {
|
||||
if (!canViewResource(key) || activeTab.value === key) return;
|
||||
activeTab.value = key;
|
||||
if (pageActive) void requestRefresh(key);
|
||||
}
|
||||
|
||||
/** Opens the stream before the one initial page snapshot to avoid a subscription gap. */
|
||||
function activatePage() {
|
||||
if (pageActive || tabItems.length === 0) return;
|
||||
pageActive = true;
|
||||
@ -525,13 +485,11 @@ export default defineComponent({
|
||||
void requestRefresh(activeTab.value);
|
||||
}
|
||||
|
||||
/** Closes the route-owned stream while preserving its replay cursor. */
|
||||
function deactivatePage() {
|
||||
pageActive = false;
|
||||
managementStream.close();
|
||||
}
|
||||
|
||||
/** Renders independent Agent connectivity and revision convergence controls. */
|
||||
function renderAgentControls() {
|
||||
const status = agentStatus.value;
|
||||
return (
|
||||
@ -552,7 +510,6 @@ export default defineComponent({
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders cells without collapsing independent synchronization dimensions. */
|
||||
function renderBodyCell({ column, record }: any) {
|
||||
const row = record as SystemNetworkApi.PortForward;
|
||||
if (column.key === 'protocol') {
|
||||
@ -663,11 +620,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns whether a mapping is immutable pending confirmed asynchronous deletion.
|
||||
* @param row - Persisted port-forward row.
|
||||
* @returns True for absent tombstones, deleting state, or confirmed deletion.
|
||||
*/
|
||||
export function isDeleting(row: SystemNetworkApi.PortForward): boolean {
|
||||
return (
|
||||
row.isDeleted ||
|
||||
@ -676,12 +628,6 @@ export function isDeleting(row: SystemNetworkApi.PortForward): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Keeper or probe disabled reason without consulting Agent online state.
|
||||
* @param row - Persisted port-forward row.
|
||||
* @param requireEnabled - Whether immediate probe additionally requires desired Keeper.
|
||||
* @returns Readable reason, or undefined when the action is valid.
|
||||
*/
|
||||
export function getKeeperDisabledReason(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
requireEnabled: boolean,
|
||||
@ -699,11 +645,6 @@ export function getKeeperDisabledReason(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the current endpoint already lease-filtered by the API.
|
||||
* @param row - Persisted port-forward row.
|
||||
* @returns Exact public IPv4 and port, or undefined when the API withdrew it.
|
||||
*/
|
||||
export function getCurrentEndpoint(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
): string | undefined {
|
||||
@ -712,12 +653,6 @@ export function getCurrentEndpoint(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the row summary without treating status-refresh failure as Agent offline.
|
||||
* @param row - Persisted mapping with redacted stable error fields.
|
||||
* @param status - Last successfully loaded Agent status, when available.
|
||||
* @returns Error, offline wait, pending wait, or an empty marker.
|
||||
*/
|
||||
export function getWaitingOrErrorSummary(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
status?: SystemNetworkApi.AgentStatus,
|
||||
@ -732,12 +667,6 @@ export function getWaitingOrErrorSummary(
|
||||
return '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the independent Agent status color without treating refresh failure as offline.
|
||||
* @param status - Last successfully loaded Agent status.
|
||||
* @param unknown - Whether the latest status request failed.
|
||||
* @returns Ant Design semantic color.
|
||||
*/
|
||||
function getAgentStatusColor(
|
||||
status: SystemNetworkApi.AgentStatus | undefined,
|
||||
unknown: boolean,
|
||||
@ -746,12 +675,6 @@ function getAgentStatusColor(
|
||||
return status?.online ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the independent Agent status label shown in the table header.
|
||||
* @param status - Last successfully loaded Agent status.
|
||||
* @param unknown - Whether the latest status request failed.
|
||||
* @returns Localized unknown, online, or offline label.
|
||||
*/
|
||||
function getAgentStatusLabel(
|
||||
status: SystemNetworkApi.AgentStatus | undefined,
|
||||
unknown: boolean,
|
||||
@ -762,7 +685,6 @@ function getAgentStatusLabel(
|
||||
: $t('system.network.agentOffline');
|
||||
}
|
||||
|
||||
/** Returns the immutable-row explanation used by CRUD write actions. */
|
||||
function getWriteDisabledReason(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
): string | undefined {
|
||||
|
||||
@ -64,6 +64,8 @@ function getStagedFiles() {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
run(getPnpmCommand(), ['run', 'check:jsdoc']);
|
||||
|
||||
const files = getStagedFiles().filter((file) => {
|
||||
if (!existsSync(file)) return false;
|
||||
if (!CHECK_ROOTS.some((root) => file.startsWith(root))) return false;
|
||||
|
||||
709
internal/jsdoc-policy/check-jsdoc.mjs
Normal file
709
internal/jsdoc-policy/check-jsdoc.mjs
Normal file
@ -0,0 +1,709 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import ts from 'typescript';
|
||||
import { parse as parseVueSfc } from 'vue/compiler-sfc';
|
||||
|
||||
const CODE_FILE_PATTERN = /\.(?:cjs|js|mjs|ts|tsx|vue)$/u;
|
||||
const HAN_CHARACTER_PATTERN = /\p{Script=Han}/u;
|
||||
const JSDOC_PREFIX = '/**';
|
||||
|
||||
const LIFECYCLE_METHOD_NAMES = new Set([
|
||||
'activated',
|
||||
'beforeCreate',
|
||||
'beforeDestroy',
|
||||
'beforeMount',
|
||||
'beforeUnmount',
|
||||
'beforeUpdate',
|
||||
'componentDidCatch',
|
||||
'componentDidMount',
|
||||
'componentDidUpdate',
|
||||
'componentWillMount',
|
||||
'componentWillReceiveProps',
|
||||
'componentWillUnmount',
|
||||
'componentWillUpdate',
|
||||
'created',
|
||||
'deactivated',
|
||||
'destroyed',
|
||||
'errorCaptured',
|
||||
'getSnapshotBeforeUpdate',
|
||||
'mounted',
|
||||
'renderTracked',
|
||||
'renderTriggered',
|
||||
'serverPrefetch',
|
||||
'shouldComponentUpdate',
|
||||
'unmounted',
|
||||
'updated',
|
||||
]);
|
||||
|
||||
function getIsolatedGitEnvironment() {
|
||||
return Object.fromEntries(
|
||||
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
|
||||
);
|
||||
}
|
||||
|
||||
function getLineStarts(source) {
|
||||
const starts = [0];
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source.codePointAt(index);
|
||||
if (character === 13) {
|
||||
if (source.codePointAt(index + 1) === 10) {
|
||||
index += 1;
|
||||
}
|
||||
starts.push(index + 1);
|
||||
} else if (character === 10) {
|
||||
starts.push(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return starts;
|
||||
}
|
||||
|
||||
function getLineAndColumn(lineStarts, position) {
|
||||
let low = 0;
|
||||
let high = lineStarts.length - 1;
|
||||
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (lineStarts[middle] <= position) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const lineIndex = Math.max(0, high);
|
||||
return {
|
||||
column: position - lineStarts[lineIndex] + 1,
|
||||
line: lineIndex + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getScriptKind(filePath, language) {
|
||||
const normalizedLanguage = language?.toLowerCase();
|
||||
|
||||
if (normalizedLanguage === 'tsx' || filePath.endsWith('.tsx')) {
|
||||
return ts.ScriptKind.TSX;
|
||||
}
|
||||
if (normalizedLanguage === 'jsx') {
|
||||
return ts.ScriptKind.JSX;
|
||||
}
|
||||
if (
|
||||
normalizedLanguage === 'ts' ||
|
||||
filePath.endsWith('.ts') ||
|
||||
filePath.endsWith('.mts') ||
|
||||
filePath.endsWith('.cts')
|
||||
) {
|
||||
return ts.ScriptKind.TS;
|
||||
}
|
||||
return ts.ScriptKind.JS;
|
||||
}
|
||||
|
||||
function getVueScriptBlocks(filePath, source) {
|
||||
const { descriptor, errors } = parseVueSfc(source, {
|
||||
filename: filePath,
|
||||
sourceMap: false,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
const message = errors
|
||||
.map((error) => (error instanceof Error ? error.message : String(error)))
|
||||
.join('; ');
|
||||
throw new Error(`Vue SFC 解析失败:${message}`);
|
||||
}
|
||||
|
||||
return [
|
||||
descriptor.script
|
||||
? {
|
||||
content: descriptor.script.content,
|
||||
label: '<script>',
|
||||
language: descriptor.script.lang,
|
||||
offset: descriptor.script.loc.start.offset,
|
||||
}
|
||||
: null,
|
||||
descriptor.scriptSetup
|
||||
? {
|
||||
content: descriptor.scriptSetup.content,
|
||||
label: '<script setup>',
|
||||
language: descriptor.scriptSetup.lang,
|
||||
offset: descriptor.scriptSetup.loc.start.offset,
|
||||
}
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.toSorted((left, right) => left.offset - right.offset);
|
||||
}
|
||||
|
||||
function getScriptBlocks(filePath, source) {
|
||||
if (filePath.endsWith('.vue')) {
|
||||
return getVueScriptBlocks(filePath, source);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
content: source,
|
||||
label: null,
|
||||
language: path.extname(filePath).slice(1),
|
||||
offset: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getNodeName(node, sourceFile) {
|
||||
if (!node.name) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
ts.isIdentifier(node.name) ||
|
||||
ts.isPrivateIdentifier(node.name) ||
|
||||
ts.isStringLiteralLike(node.name) ||
|
||||
ts.isNumericLiteral(node.name)
|
||||
) {
|
||||
return node.name.text;
|
||||
}
|
||||
return node.name.getText(sourceFile);
|
||||
}
|
||||
|
||||
function isSetupFunctionExpression(node, sourceFile) {
|
||||
if (getNodeName(node, sourceFile) === 'setup') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parent = node.parent;
|
||||
return (
|
||||
ts.isPropertyAssignment(parent) &&
|
||||
getNodeName(parent, sourceFile) === 'setup'
|
||||
);
|
||||
}
|
||||
|
||||
function classifyTarget(node, sourceFile) {
|
||||
if (!node) {
|
||||
return { allowed: false, target: 'unattached' };
|
||||
}
|
||||
|
||||
if (ts.isFunctionDeclaration(node)) {
|
||||
const name = getNodeName(node, sourceFile);
|
||||
if (!name) {
|
||||
return { allowed: false, target: 'anonymous-function-declaration' };
|
||||
}
|
||||
if (name === 'setup') {
|
||||
return { allowed: false, target: 'setup' };
|
||||
}
|
||||
return { allowed: true, target: 'named-function-declaration' };
|
||||
}
|
||||
|
||||
if (ts.isFunctionExpression(node)) {
|
||||
const name = getNodeName(node, sourceFile);
|
||||
if (!name) {
|
||||
return { allowed: false, target: 'anonymous-function-expression' };
|
||||
}
|
||||
if (isSetupFunctionExpression(node, sourceFile)) {
|
||||
return { allowed: false, target: 'setup' };
|
||||
}
|
||||
return { allowed: true, target: 'named-function-expression' };
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isMethodDeclaration(node) ||
|
||||
ts.isGetAccessorDeclaration(node) ||
|
||||
ts.isSetAccessorDeclaration(node)
|
||||
) {
|
||||
const name = getNodeName(node, sourceFile);
|
||||
if (name === 'setup') {
|
||||
return { allowed: false, target: 'setup' };
|
||||
}
|
||||
if (name && LIFECYCLE_METHOD_NAMES.has(name)) {
|
||||
return { allowed: false, target: 'lifecycle-entry' };
|
||||
}
|
||||
return {
|
||||
allowed: Boolean(name),
|
||||
target: ts.isMethodDeclaration(node) ? 'named-method' : 'accessor',
|
||||
};
|
||||
}
|
||||
|
||||
if (ts.isConstructorDeclaration(node)) {
|
||||
return { allowed: false, target: 'constructor' };
|
||||
}
|
||||
if (ts.isArrowFunction(node)) {
|
||||
return { allowed: false, target: 'arrow-function' };
|
||||
}
|
||||
if (ts.isInterfaceDeclaration(node)) {
|
||||
return { allowed: false, target: 'interface' };
|
||||
}
|
||||
if (ts.isTypeAliasDeclaration(node)) {
|
||||
return { allowed: false, target: 'type' };
|
||||
}
|
||||
if (
|
||||
ts.isPropertyDeclaration(node) ||
|
||||
ts.isPropertySignature(node) ||
|
||||
ts.isPropertyAssignment(node) ||
|
||||
ts.isShorthandPropertyAssignment(node)
|
||||
) {
|
||||
return { allowed: false, target: 'property' };
|
||||
}
|
||||
if (
|
||||
ts.isVariableDeclaration(node) ||
|
||||
ts.isVariableDeclarationList(node) ||
|
||||
ts.isVariableStatement(node)
|
||||
) {
|
||||
return { allowed: false, target: 'variable' };
|
||||
}
|
||||
if (
|
||||
ts.isMethodSignature(node) ||
|
||||
ts.isCallSignatureDeclaration(node) ||
|
||||
ts.isConstructSignatureDeclaration(node) ||
|
||||
ts.isFunctionTypeNode(node)
|
||||
) {
|
||||
return { allowed: false, target: 'type' };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
target: ts.SyntaxKind[node.kind] ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function getLeafTokens(sourceFile) {
|
||||
const tokens = [];
|
||||
|
||||
function visit(node) {
|
||||
if (
|
||||
node.kind >= ts.SyntaxKind.FirstJSDocNode &&
|
||||
node.kind <= ts.SyntaxKind.LastJSDocNode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
node.kind >= ts.SyntaxKind.FirstToken &&
|
||||
node.kind <= ts.SyntaxKind.LastToken
|
||||
) {
|
||||
tokens.push(node);
|
||||
return;
|
||||
}
|
||||
for (const child of node.getChildren(sourceFile)) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return tokens.toSorted(
|
||||
(left, right) => left.getStart(sourceFile) - right.getStart(sourceFile),
|
||||
);
|
||||
}
|
||||
|
||||
function scanTriviaGap(content, start, end) {
|
||||
if (start >= end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scanner = ts.createScanner(
|
||||
ts.ScriptTarget.Latest,
|
||||
false,
|
||||
ts.LanguageVariant.Standard,
|
||||
content.slice(start, end),
|
||||
);
|
||||
const comments = [];
|
||||
|
||||
for (
|
||||
let token = scanner.scan();
|
||||
token !== ts.SyntaxKind.EndOfFileToken;
|
||||
token = scanner.scan()
|
||||
) {
|
||||
if (token !== ts.SyntaxKind.MultiLineCommentTrivia) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commentStart = start + scanner.getTokenPos();
|
||||
const commentEnd = start + scanner.getTextPos();
|
||||
const text = content.slice(commentStart, commentEnd);
|
||||
if (!text.startsWith(JSDOC_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
comments.push({ end: commentEnd, start: commentStart, text });
|
||||
}
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
function collectRawJsdocComments(sourceFile) {
|
||||
const comments = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const token of getLeafTokens(sourceFile)) {
|
||||
const tokenStart = token.getStart(sourceFile);
|
||||
comments.push(...scanTriviaGap(sourceFile.text, cursor, tokenStart));
|
||||
cursor = Math.max(cursor, token.end);
|
||||
}
|
||||
comments.push(
|
||||
...scanTriviaGap(sourceFile.text, cursor, sourceFile.text.length),
|
||||
);
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
function collectJsdocOwners(sourceFile) {
|
||||
const owners = new Map();
|
||||
|
||||
function visit(node) {
|
||||
for (const jsdoc of node.jsDoc ?? []) {
|
||||
owners.set(`${jsdoc.getStart(sourceFile)}:${jsdoc.end}`, node);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return owners;
|
||||
}
|
||||
|
||||
function inspectScriptBlock(filePath, block, fullSource, lineStarts) {
|
||||
const scriptKind = getScriptKind(filePath, block.language);
|
||||
const virtualFilePath = block.label
|
||||
? `${filePath}${block.label.replaceAll(/[<> ]/gu, '-')}`
|
||||
: filePath;
|
||||
const sourceFile = ts.createSourceFile(
|
||||
virtualFilePath,
|
||||
block.content,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind,
|
||||
);
|
||||
const owners = collectJsdocOwners(sourceFile);
|
||||
const rawComments = collectRawJsdocComments(sourceFile);
|
||||
|
||||
return rawComments.map((rawComment) => {
|
||||
const owner = owners.get(`${rawComment.start}:${rawComment.end}`);
|
||||
const classification = classifyTarget(owner, sourceFile);
|
||||
const start = block.offset + rawComment.start;
|
||||
const end = block.offset + rawComment.end;
|
||||
const location = getLineAndColumn(lineStarts, start);
|
||||
let rule = null;
|
||||
let message = null;
|
||||
|
||||
if (!classification.allowed) {
|
||||
rule = 'invalid-target';
|
||||
message = `JSDoc 不允许用于 ${classification.target}`;
|
||||
} else if (!HAN_CHARACTER_PATTERN.test(rawComment.text)) {
|
||||
rule = 'non-chinese-description';
|
||||
message = 'JSDoc 自然语言描述必须使用中文';
|
||||
}
|
||||
|
||||
return {
|
||||
block: block.label,
|
||||
column: location.column,
|
||||
end,
|
||||
filePath,
|
||||
line: location.line,
|
||||
message,
|
||||
ownerKind: owner ? ts.SyntaxKind[owner.kind] : null,
|
||||
rule,
|
||||
start,
|
||||
target: classification.target,
|
||||
text: fullSource.slice(start, end),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectFileSource(filePath, source) {
|
||||
const normalizedFilePath = filePath.replaceAll(path.sep, '/');
|
||||
const lineStarts = getLineStarts(source);
|
||||
const blocks = getScriptBlocks(normalizedFilePath, source);
|
||||
const comments = blocks.flatMap((block) =>
|
||||
inspectScriptBlock(normalizedFilePath, block, source, lineStarts),
|
||||
);
|
||||
|
||||
return {
|
||||
comments,
|
||||
isVue: normalizedFilePath.endsWith('.vue'),
|
||||
scriptBlockCount: blocks.length,
|
||||
violations: comments.filter((comment) => comment.rule !== null),
|
||||
};
|
||||
}
|
||||
|
||||
function collectSyntaxTokens(source, scriptKind, filePath) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind,
|
||||
);
|
||||
const tokens = [];
|
||||
|
||||
function visit(node) {
|
||||
if (
|
||||
node.kind === ts.SyntaxKind.JSDocComment ||
|
||||
(node.kind >= ts.SyntaxKind.FirstJSDocNode &&
|
||||
node.kind <= ts.SyntaxKind.LastJSDocNode)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const children = node.getChildren(sourceFile);
|
||||
if (children.length > 0) {
|
||||
for (const child of children) {
|
||||
visit(child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.kind === ts.SyntaxKind.EndOfFileToken) {
|
||||
return;
|
||||
}
|
||||
const text = node.getText(sourceFile);
|
||||
if (text.length > 0) {
|
||||
tokens.push([node.kind, text]);
|
||||
}
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function getCodeTokenSignature(filePath, source) {
|
||||
const normalizedFilePath = filePath.replaceAll(path.sep, '/');
|
||||
const blocks = getScriptBlocks(normalizedFilePath, source);
|
||||
const tokenGroups = blocks.map((block) => ({
|
||||
block: block.label,
|
||||
language: block.language ?? null,
|
||||
tokens: collectSyntaxTokens(
|
||||
block.content,
|
||||
getScriptKind(normalizedFilePath, block.language),
|
||||
normalizedFilePath,
|
||||
),
|
||||
}));
|
||||
|
||||
return JSON.stringify(tokenGroups);
|
||||
}
|
||||
|
||||
function getRemovalEdit(source, start, end) {
|
||||
const lineStart = source.lastIndexOf('\n', start - 1) + 1;
|
||||
const nextLineBreak = source.indexOf('\n', end);
|
||||
const lineEnd = nextLineBreak === -1 ? source.length : nextLineBreak;
|
||||
const prefix = source.slice(lineStart, start);
|
||||
const suffix = source.slice(end, lineEnd);
|
||||
|
||||
if (prefix.trim() === '' && suffix.trim() === '') {
|
||||
return {
|
||||
end: nextLineBreak === -1 ? source.length : nextLineBreak + 1,
|
||||
replacement: '',
|
||||
start: lineStart,
|
||||
};
|
||||
}
|
||||
|
||||
if (suffix.trim() === '') {
|
||||
let expandedStart = start;
|
||||
while (
|
||||
expandedStart > lineStart &&
|
||||
(source[expandedStart - 1] === ' ' || source[expandedStart - 1] === '\t')
|
||||
) {
|
||||
expandedStart -= 1;
|
||||
}
|
||||
return { end: lineEnd, replacement: '', start: expandedStart };
|
||||
}
|
||||
|
||||
if (prefix.trim() === '') {
|
||||
let expandedEnd = end;
|
||||
while (
|
||||
expandedEnd < lineEnd &&
|
||||
(source[expandedEnd] === ' ' || source[expandedEnd] === '\t')
|
||||
) {
|
||||
expandedEnd += 1;
|
||||
}
|
||||
return { end: expandedEnd, replacement: '', start: lineStart };
|
||||
}
|
||||
|
||||
const newlines = source.slice(start, end).match(/\r\n|\r|\n/gu);
|
||||
return {
|
||||
end,
|
||||
replacement: newlines ? newlines.join('') : ' ',
|
||||
start,
|
||||
};
|
||||
}
|
||||
|
||||
function applyEdits(source, edits) {
|
||||
const sortedEdits = edits.toSorted(
|
||||
(left, right) => right.start - left.start || right.end - left.end,
|
||||
);
|
||||
let result = source;
|
||||
let previousStart = source.length + 1;
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
if (edit.end > previousStart) {
|
||||
throw new Error('JSDoc 清理范围发生重叠');
|
||||
}
|
||||
result =
|
||||
result.slice(0, edit.start) + edit.replacement + result.slice(edit.end);
|
||||
previousStart = edit.start;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applyJsdocPolicyFix(filePath, source) {
|
||||
const inspection = inspectFileSource(filePath, source);
|
||||
const uniqueViolations = [
|
||||
...new Map(
|
||||
inspection.violations.map((violation) => [
|
||||
`${violation.start}:${violation.end}`,
|
||||
violation,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const edits = uniqueViolations.map((violation) =>
|
||||
getRemovalEdit(source, violation.start, violation.end),
|
||||
);
|
||||
const beforeTokens = getCodeTokenSignature(filePath, source);
|
||||
const fixedSource = applyEdits(source, edits);
|
||||
const afterTokens = getCodeTokenSignature(filePath, fixedSource);
|
||||
|
||||
if (beforeTokens !== afterTokens) {
|
||||
throw new Error(`${filePath}: 清理前后非注释 token 不一致`);
|
||||
}
|
||||
|
||||
return {
|
||||
removedCount: uniqueViolations.length,
|
||||
source: fixedSource,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTrackedCodeFiles(rootDirectory = process.cwd()) {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||
{
|
||||
cwd: rootDirectory,
|
||||
encoding: 'utf8',
|
||||
env: getIsolatedGitEnvironment(),
|
||||
},
|
||||
);
|
||||
|
||||
return output
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.filter((filePath) => CODE_FILE_PATTERN.test(filePath))
|
||||
.filter((filePath) => existsSync(path.join(rootDirectory, filePath)))
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function summarizeInspections(inspections) {
|
||||
const summary = {
|
||||
'invalid-target': 0,
|
||||
'non-chinese-description': 0,
|
||||
fileCount: 0,
|
||||
jsdocCount: 0,
|
||||
scriptBlockCount: 0,
|
||||
violationCount: 0,
|
||||
vueFileCount: 0,
|
||||
};
|
||||
|
||||
for (const inspection of inspections) {
|
||||
summary.fileCount += 1;
|
||||
summary.jsdocCount += inspection.result.comments.length;
|
||||
summary.scriptBlockCount += inspection.result.scriptBlockCount;
|
||||
summary.violationCount += inspection.result.violations.length;
|
||||
if (inspection.result.isVue) {
|
||||
summary.vueFileCount += 1;
|
||||
}
|
||||
for (const violation of inspection.result.violations) {
|
||||
summary[violation.rule] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function inspectTrackedFiles(rootDirectory) {
|
||||
return getTrackedCodeFiles(rootDirectory).map((filePath) => {
|
||||
const absolutePath = path.join(rootDirectory, filePath);
|
||||
const source = readFileSync(absolutePath, 'utf8');
|
||||
return {
|
||||
absolutePath,
|
||||
filePath,
|
||||
result: inspectFileSource(filePath, source),
|
||||
source,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatViolation(violation) {
|
||||
const blockSuffix = violation.block ? ` ${violation.block}` : '';
|
||||
return `${violation.filePath}:${violation.line}:${violation.column} [${violation.rule}]${blockSuffix} ${violation.message}`;
|
||||
}
|
||||
|
||||
function printSummary(summary, prefix = 'JSDoc 检查') {
|
||||
process.stdout.write(
|
||||
`${prefix}:${summary.fileCount} 个受控代码文件,` +
|
||||
`${summary.vueFileCount} 个 Vue 文件,` +
|
||||
`${summary.scriptBlockCount} 个脚本区块,` +
|
||||
`${summary.jsdocCount} 个 JSDoc,` +
|
||||
`${summary.violationCount} 个违规` +
|
||||
`(非法位置 ${summary['invalid-target']},纯英文 ${summary['non-chinese-description']})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function runCli() {
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const fix = args.delete('--fix');
|
||||
if (args.size > 0) {
|
||||
console.error(`未知参数:${[...args].join(', ')}`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDirectory = process.cwd();
|
||||
const inspections = inspectTrackedFiles(rootDirectory);
|
||||
const initialSummary = summarizeInspections(inspections);
|
||||
|
||||
if (!fix) {
|
||||
for (const inspection of inspections) {
|
||||
for (const violation of inspection.result.violations) {
|
||||
console.error(formatViolation(violation));
|
||||
}
|
||||
}
|
||||
printSummary(initialSummary);
|
||||
if (initialSummary.violationCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let changedFileCount = 0;
|
||||
let removedCount = 0;
|
||||
for (const inspection of inspections) {
|
||||
if (inspection.result.violations.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const fixed = applyJsdocPolicyFix(inspection.filePath, inspection.source);
|
||||
writeFileSync(inspection.absolutePath, fixed.source);
|
||||
changedFileCount += 1;
|
||||
removedCount += fixed.removedCount;
|
||||
}
|
||||
|
||||
const finalInspections = inspectTrackedFiles(rootDirectory);
|
||||
const finalSummary = summarizeInspections(finalInspections);
|
||||
process.stdout.write(
|
||||
`JSDoc 清理:修改 ${changedFileCount} 个文件,删除 ${removedCount} 个违规块\n`,
|
||||
);
|
||||
printSummary(finalSummary, '清理后检查');
|
||||
if (finalSummary.violationCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href
|
||||
: null;
|
||||
if (invokedPath === import.meta.url) {
|
||||
try {
|
||||
runCli();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
252
internal/jsdoc-policy/check-jsdoc.test.mjs
Normal file
252
internal/jsdoc-policy/check-jsdoc.test.mjs
Normal file
@ -0,0 +1,252 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyJsdocPolicyFix,
|
||||
getCodeTokenSignature,
|
||||
getTrackedCodeFiles,
|
||||
inspectFileSource,
|
||||
} from './check-jsdoc.mjs';
|
||||
|
||||
function runFixtureGit(repository, args) {
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
|
||||
);
|
||||
execFileSync('git', args, { cwd: repository, env: environment });
|
||||
}
|
||||
|
||||
it('allows Chinese JSDoc only on explicitly named callable declarations', () => {
|
||||
const source = `
|
||||
/** 读取当前配置。 */
|
||||
function readConfig() {}
|
||||
|
||||
const loadConfig = /** 加载远端配置。 */ function loadRemoteConfig() {};
|
||||
|
||||
const service = {
|
||||
/** 执行任务。 */
|
||||
run() {},
|
||||
|
||||
/** 返回当前状态。 */
|
||||
get status() {
|
||||
return 'ready';
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
const result = inspectFileSource('valid.ts', source);
|
||||
|
||||
assert.equal(result.comments.length, 4);
|
||||
assert.deepEqual(result.violations, []);
|
||||
});
|
||||
|
||||
it('rejects pure-English JSDoc on an otherwise valid named function', () => {
|
||||
const result = inspectFileSource(
|
||||
'english.ts',
|
||||
'/** Loads the current configuration. */\nfunction loadConfig() {}\n',
|
||||
);
|
||||
|
||||
assert.equal(result.violations.length, 1);
|
||||
assert.equal(result.violations[0]?.rule, 'non-chinese-description');
|
||||
assert.equal(result.violations[0]?.target, 'named-function-declaration');
|
||||
});
|
||||
|
||||
it('rejects declarations and callables excluded by the policy', () => {
|
||||
const source = `
|
||||
/** 接口说明。 */
|
||||
interface Options {
|
||||
/** 属性说明。 */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 类型说明。 */
|
||||
type Result = string;
|
||||
|
||||
/** 变量说明。 */
|
||||
const count = 1;
|
||||
|
||||
const arrow = /** 箭头函数说明。 */ () => count;
|
||||
items.map(/** 匿名回调说明。 */ function () {});
|
||||
|
||||
class Service {
|
||||
/** 构造服务。 */
|
||||
constructor() {}
|
||||
|
||||
/** 初始化组件。 */
|
||||
setup() {}
|
||||
|
||||
/** 组件挂载入口。 */
|
||||
mounted() {}
|
||||
}
|
||||
|
||||
/** 即使初始化器具名,注释仍属于变量。 */
|
||||
const load = function loadConfig() {};
|
||||
`;
|
||||
|
||||
const result = inspectFileSource('invalid-targets.ts', source);
|
||||
|
||||
assert.deepEqual(
|
||||
result.violations.map(({ rule, target }) => [rule, target]),
|
||||
[
|
||||
['invalid-target', 'interface'],
|
||||
['invalid-target', 'property'],
|
||||
['invalid-target', 'type'],
|
||||
['invalid-target', 'variable'],
|
||||
['invalid-target', 'arrow-function'],
|
||||
['invalid-target', 'anonymous-function-expression'],
|
||||
['invalid-target', 'constructor'],
|
||||
['invalid-target', 'setup'],
|
||||
['invalid-target', 'lifecycle-entry'],
|
||||
['invalid-target', 'variable'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects raw JSDoc blocks that TypeScript does not attach to a node', () => {
|
||||
const source = `
|
||||
consume(/** 表达式内孤立注释。 */ 1);
|
||||
const stringValue = '/** 字符串内容。 */';
|
||||
const templateValue = \`/** 模板字符串内容。 */\`;
|
||||
; /** 文件尾孤立注释。 */
|
||||
`;
|
||||
|
||||
const result = inspectFileSource('unattached.ts', source);
|
||||
|
||||
assert.equal(result.comments.length, 2);
|
||||
assert.deepEqual(
|
||||
result.violations.map(({ rule, target }) => [rule, target]),
|
||||
[
|
||||
['invalid-target', 'unattached'],
|
||||
['invalid-target', 'unattached'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat string, template, or TSX text content as raw JSDoc', () => {
|
||||
const source = `
|
||||
const stringValue = '/** 字符串内容。 */';
|
||||
const templateValue = \`/** 模板字符串内容。 */\`;
|
||||
const view = <div>/** TSX 文本内容。 */</div>;
|
||||
consume(/** 只有这一块是真实孤立注释。 */ 1);
|
||||
`;
|
||||
|
||||
const result = inspectFileSource('literal-content.tsx', source);
|
||||
|
||||
assert.equal(result.comments.length, 1);
|
||||
assert.equal(result.violations[0]?.target, 'unattached');
|
||||
});
|
||||
|
||||
it('checks Vue script and script-setup blocks without scanning template or style', () => {
|
||||
const source = `
|
||||
<template>
|
||||
<div>/** 模板文本不属于 JSDoc。 */</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** setup 变量非法。 */
|
||||
const count = 1;
|
||||
|
||||
/** 返回当前数量。 */
|
||||
function readCount() {
|
||||
return count;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="tsx">
|
||||
defineComponent({
|
||||
/** TSX 组件 setup 禁止使用 JSDoc。 */
|
||||
setup() {
|
||||
return () => <span />;
|
||||
},
|
||||
|
||||
/** 渲染当前组件。 */
|
||||
render() {
|
||||
return <span />;
|
||||
},
|
||||
});
|
||||
|
||||
/** Loads remote data. */
|
||||
function loadRemote() {}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/** 样式注释不属于脚本 JSDoc。 */
|
||||
.root {}
|
||||
</style>
|
||||
`;
|
||||
|
||||
const result = inspectFileSource('Component.vue', source);
|
||||
|
||||
assert.equal(result.comments.length, 5);
|
||||
assert.deepEqual(
|
||||
result.violations.map(({ rule, target }) => [rule, target]),
|
||||
[
|
||||
['invalid-target', 'variable'],
|
||||
['invalid-target', 'setup'],
|
||||
['non-chinese-description', 'named-function-declaration'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('fix mode removes only violations and preserves non-comment tokens', () => {
|
||||
const source = `
|
||||
/** 保留有意义的中文说明。 */
|
||||
function keepComment() {}
|
||||
|
||||
/** Remove this historical English description. */
|
||||
function removeEnglishComment() {}
|
||||
|
||||
/** 删除变量位置的注释。 */
|
||||
const value = 1;
|
||||
|
||||
const named = /** 保留具名函数表达式说明。 */ function namedExpression() {
|
||||
return value;
|
||||
};
|
||||
`;
|
||||
const beforeTokens = getCodeTokenSignature('cleanup.ts', source);
|
||||
|
||||
const fixed = applyJsdocPolicyFix('cleanup.ts', source);
|
||||
|
||||
assert.match(fixed.source, /保留有意义的中文说明/);
|
||||
assert.match(fixed.source, /保留具名函数表达式说明/);
|
||||
assert.doesNotMatch(fixed.source, /historical English/);
|
||||
assert.doesNotMatch(fixed.source, /删除变量位置的注释/);
|
||||
assert.equal(fixed.removedCount, 2);
|
||||
assert.deepEqual(
|
||||
inspectFileSource('cleanup.ts', fixed.source).violations,
|
||||
[],
|
||||
);
|
||||
assert.equal(getCodeTokenSignature('cleanup.ts', fixed.source), beforeTokens);
|
||||
});
|
||||
|
||||
it('tracked-file discovery also includes untracked non-ignored code', () => {
|
||||
const repository = mkdtempSync(path.join(tmpdir(), 'admin-jsdoc-policy-'));
|
||||
|
||||
try {
|
||||
runFixtureGit(repository, ['init', '--quiet']);
|
||||
writeFileSync(path.join(repository, '.gitignore'), 'ignored.ts\n');
|
||||
writeFileSync(path.join(repository, 'deleted.ts'), 'export {};\n');
|
||||
writeFileSync(path.join(repository, 'tracked.ts'), 'export {};\n');
|
||||
writeFileSync(path.join(repository, 'untracked.tsx'), 'export {};\n');
|
||||
writeFileSync(path.join(repository, 'ignored.ts'), 'export {};\n');
|
||||
writeFileSync(path.join(repository, 'notes.md'), '# Notes\n');
|
||||
runFixtureGit(repository, [
|
||||
'add',
|
||||
'.gitignore',
|
||||
'deleted.ts',
|
||||
'tracked.ts',
|
||||
]);
|
||||
rmSync(path.join(repository, 'deleted.ts'));
|
||||
|
||||
assert.deepEqual(getTrackedCodeFiles(repository), [
|
||||
'tracked.ts',
|
||||
'untracked.tsx',
|
||||
]);
|
||||
} finally {
|
||||
rmSync(repository, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@ -25,9 +25,6 @@ const getDefaultPwaOptions = (name: string): Partial<PwaPluginOptions> => ({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* importmap CDN 暂时不开启,因为有些包不支持,且网络不稳定
|
||||
*/
|
||||
const defaultImportmapOptions: ImportmapPluginOptions = {
|
||||
// 通过 Importmap CDN 方式引入,
|
||||
// 目前只有esm.sh源兼容性好一点,jspm.io对于 esm 入口要求高
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
/**
|
||||
* 参考 https://github.com/jspm/vite-plugin-jspm,调整为需要的功能
|
||||
*/
|
||||
import type { GeneratorOptions } from '@jspm/generator';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
|
||||
// prettier-ignore
|
||||
import type {
|
||||
ConfigEnv,
|
||||
UserConfig,
|
||||
@ -7,328 +8,90 @@ import type {
|
||||
import type { PluginOptions } from 'vite-plugin-dts';
|
||||
import type { Options as PwaPluginOptions } from 'vite-plugin-pwa';
|
||||
|
||||
/**
|
||||
* ImportMap 配置接口
|
||||
* @description 用于配置模块导入映射,支持自定义导入路径和范围
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* imports: {
|
||||
* 'vue': 'https://unpkg.com/vue@3.2.47/dist/vue.esm-browser.js'
|
||||
* },
|
||||
* scopes: {
|
||||
* 'https://site.com/': {
|
||||
* 'vue': 'https://unpkg.com/vue@3.2.47/dist/vue.esm-browser.js'
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface IImportMap {
|
||||
/** 模块导入映射 */
|
||||
imports?: Record<string, string>;
|
||||
/** 作用域特定的导入映射 */
|
||||
scopes?: {
|
||||
[scope: string]: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印插件配置选项
|
||||
* @description 用于配置控制台打印信息
|
||||
*/
|
||||
interface PrintPluginOptions {
|
||||
/**
|
||||
* 打印的数据映射
|
||||
* @description 键值对形式的数据,将在控制台打印
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* 'App Version': '1.0.0',
|
||||
* 'Build Time': '2024-01-01'
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
infoMap?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitro Mock 插件配置选项
|
||||
* @description 用于配置 Nitro Mock 服务器的行为
|
||||
*/
|
||||
interface NitroMockPluginOptions {
|
||||
/**
|
||||
* Mock 服务器包名
|
||||
* @default '@vbenjs/nitro-mock'
|
||||
*/
|
||||
mockServerPackage?: string;
|
||||
|
||||
/**
|
||||
* Mock 服务端口
|
||||
* @default 3000
|
||||
*/
|
||||
port?: number;
|
||||
|
||||
/**
|
||||
* 是否打印 Mock 日志
|
||||
* @default false
|
||||
*/
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档插件配置选项
|
||||
* @description 用于配置构建产物的压缩归档
|
||||
*/
|
||||
interface ArchiverPluginOptions {
|
||||
/**
|
||||
* 输出文件名
|
||||
* @default 'dist'
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 输出目录
|
||||
* @default '.'
|
||||
*/
|
||||
outputDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImportMap 插件配置
|
||||
* @description 用于配置模块的 CDN 导入
|
||||
*/
|
||||
interface ImportmapPluginOptions {
|
||||
/**
|
||||
* CDN 供应商
|
||||
* @default 'jspm.io'
|
||||
* @description 支持 esm.sh 和 jspm.io 两种 CDN 供应商
|
||||
*/
|
||||
defaultProvider?: 'esm.sh' | 'jspm.io';
|
||||
/**
|
||||
* ImportMap 配置数组
|
||||
* @description 配置需要从 CDN 导入的包
|
||||
* @example
|
||||
* ```typescript
|
||||
* [
|
||||
* { name: 'vue' },
|
||||
* { name: 'pinia', range: '^2.0.0' }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
importmap?: Array<{ name: string; range?: string }>;
|
||||
/**
|
||||
* 手动配置 ImportMap
|
||||
* @description 自定义 ImportMap 配置
|
||||
*/
|
||||
inputMap?: IImportMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件插件配置
|
||||
* @description 用于根据条件动态加载插件
|
||||
*/
|
||||
interface ConditionPlugin {
|
||||
/**
|
||||
* 判断条件
|
||||
* @description 当条件为 true 时加载插件
|
||||
*/
|
||||
condition?: boolean;
|
||||
/**
|
||||
* 插件对象
|
||||
* @description 返回插件数组或 Promise
|
||||
*/
|
||||
plugins: () => PromiseLike<unknown[]> | unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用插件配置选项
|
||||
* @description 所有插件共用的基础配置
|
||||
*/
|
||||
interface CommonPluginOptions {
|
||||
/**
|
||||
* 是否开启开发工具
|
||||
* @default false
|
||||
*/
|
||||
devtools?: boolean;
|
||||
/**
|
||||
* 环境变量
|
||||
* @description 自定义环境变量
|
||||
*/
|
||||
env?: Record<string, any>;
|
||||
/**
|
||||
* 是否注入元数据
|
||||
* @default true
|
||||
*/
|
||||
injectMetadata?: boolean;
|
||||
/**
|
||||
* 是否为构建模式
|
||||
* @default false
|
||||
*/
|
||||
isBuild?: boolean;
|
||||
/**
|
||||
* 构建模式
|
||||
* @default 'development'
|
||||
*/
|
||||
mode?: string;
|
||||
/**
|
||||
* 是否开启依赖分析
|
||||
* @default false
|
||||
* @description 使用 rollup-plugin-visualizer 分析依赖
|
||||
*/
|
||||
visualizer?: boolean | PluginVisualizerOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用插件配置选项
|
||||
* @description 用于配置应用构建时的插件选项
|
||||
*/
|
||||
interface ApplicationPluginOptions extends CommonPluginOptions {
|
||||
/**
|
||||
* 应用标题
|
||||
* @description 用于 HTML 模板和 PWA 配置的默认标题
|
||||
*/
|
||||
appTitle?: string;
|
||||
/**
|
||||
* 是否开启压缩归档
|
||||
* @default false
|
||||
* @description 开启后会在打包目录生成 zip 文件
|
||||
*/
|
||||
archiver?: boolean;
|
||||
/**
|
||||
* 压缩归档插件配置
|
||||
* @description 配置压缩归档的行为
|
||||
*/
|
||||
archiverPluginOptions?: ArchiverPluginOptions;
|
||||
/**
|
||||
* 是否开启压缩
|
||||
* @default false
|
||||
* @description 支持 gzip 和 brotli 压缩
|
||||
*/
|
||||
compress?: boolean;
|
||||
/**
|
||||
* 压缩类型
|
||||
* @default ['gzip']
|
||||
* @description 可选的压缩类型
|
||||
*/
|
||||
compressTypes?: ('brotli' | 'gzip')[];
|
||||
/**
|
||||
* 是否抽离配置文件
|
||||
* @default false
|
||||
* @description 在构建时抽离配置文件
|
||||
*/
|
||||
extraAppConfig?: boolean;
|
||||
/**
|
||||
* 是否开启 HTML 插件
|
||||
* @default true
|
||||
*/
|
||||
html?: boolean;
|
||||
/**
|
||||
* 是否开启国际化
|
||||
* @default false
|
||||
*/
|
||||
i18n?: boolean;
|
||||
/**
|
||||
* 是否开启 ImportMap CDN
|
||||
* @default false
|
||||
*/
|
||||
importmap?: boolean;
|
||||
/**
|
||||
* ImportMap 插件配置
|
||||
*/
|
||||
importmapOptions?: ImportmapPluginOptions;
|
||||
/**
|
||||
* 是否注入应用加载动画
|
||||
* @default true
|
||||
*/
|
||||
injectAppLoading?: boolean;
|
||||
/**
|
||||
* 是否注入全局 SCSS
|
||||
* @default true
|
||||
*/
|
||||
injectGlobalScss?: boolean;
|
||||
/**
|
||||
* 是否注入版权信息
|
||||
* @default true
|
||||
*/
|
||||
license?: boolean;
|
||||
/**
|
||||
* 是否开启 Nitro Mock
|
||||
* @default false
|
||||
*/
|
||||
nitroMock?: boolean;
|
||||
/**
|
||||
* Nitro Mock 插件配置
|
||||
*/
|
||||
nitroMockOptions?: NitroMockPluginOptions;
|
||||
/**
|
||||
* 是否开启控制台打印
|
||||
* @default false
|
||||
*/
|
||||
print?: boolean;
|
||||
/**
|
||||
* 打印插件配置
|
||||
*/
|
||||
printInfoMap?: PrintPluginOptions['infoMap'];
|
||||
/**
|
||||
* 是否开启 PWA
|
||||
* @default false
|
||||
*/
|
||||
pwa?: boolean;
|
||||
/**
|
||||
* PWA 插件配置
|
||||
*/
|
||||
pwaOptions?: Partial<PwaPluginOptions>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 库插件配置选项
|
||||
* @description 用于配置库构建时的插件选项
|
||||
*/
|
||||
interface LibraryPluginOptions extends CommonPluginOptions {
|
||||
/**
|
||||
* 是否开启 DTS 输出
|
||||
* @default true
|
||||
* @description 生成 TypeScript 类型声明文件
|
||||
*/
|
||||
dts?: boolean | PluginOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用配置选项类型
|
||||
*/
|
||||
type ApplicationOptions = ApplicationPluginOptions;
|
||||
|
||||
/**
|
||||
* 库配置选项类型
|
||||
*/
|
||||
type LibraryOptions = LibraryPluginOptions;
|
||||
|
||||
/**
|
||||
* 应用配置定义函数类型
|
||||
* @description 用于定义应用构建配置
|
||||
*/
|
||||
type DefineApplicationOptions = (config?: ConfigEnv) => Promise<{
|
||||
/** 应用插件配置 */
|
||||
application?: ApplicationOptions;
|
||||
/** Vite 配置 */
|
||||
vite?: UserConfig;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 库配置定义函数类型
|
||||
* @description 用于定义库构建配置
|
||||
*/
|
||||
type DefineLibraryOptions = (config?: ConfigEnv) => Promise<{
|
||||
/** 库插件配置 */
|
||||
library?: LibraryOptions;
|
||||
/** Vite 配置 */
|
||||
vite?: UserConfig;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 配置定义类型
|
||||
* @description 应用或库的配置定义
|
||||
*/
|
||||
type DefineConfig = DefineApplicationOptions | DefineLibraryOptions;
|
||||
|
||||
type VbenViteConfig = Promise<UserConfig> | UserConfig | UserConfigFnPromise;
|
||||
|
||||
@ -29,11 +29,6 @@ function getConfFiles() {
|
||||
return ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the environment variables starting with the specified prefix
|
||||
* @param match prefix
|
||||
* @param confFiles ext
|
||||
*/
|
||||
async function loadEnv<T = Record<string, string>>(
|
||||
match = 'VITE_GLOB_',
|
||||
confFiles = getConfFiles(),
|
||||
|
||||
@ -9,12 +9,13 @@
|
||||
"build:analyze": "turbo build:analyze",
|
||||
"build:antdv-next": "pnpm run build --filter=@vben/web-antdv-next",
|
||||
"check": "pnpm run check:type",
|
||||
"check:jsdoc": "pnpm run test:jsdoc && node internal/jsdoc-policy/check-jsdoc.mjs",
|
||||
"check:type": "turbo run typecheck",
|
||||
"clean": "rimraf .turbo apps/*/dist apps/*/dist.zip packages/**/dist packages/**/dist.zip internal/**/dist internal/**/dist.zip",
|
||||
"dev": "pnpm run dev:antdv-next",
|
||||
"dev:antdv-next": "pnpm -F @vben/web-antdv-next run dev",
|
||||
"format": "eslint apps/web-antdv-next internal --cache --fix && prettier . --write --cache --log-level warn && stylelint \"**/*.{vue,css,less,scss}\" --cache --fix",
|
||||
"lint": "eslint apps/web-antdv-next internal --cache && prettier . --ignore-unknown --check --cache && stylelint \"**/*.{vue,css,less,scss}\" --cache",
|
||||
"lint": "pnpm run check:jsdoc && eslint apps/web-antdv-next internal --cache && prettier . --ignore-unknown --check --cache && stylelint \"**/*.{vue,css,less,scss}\" --cache",
|
||||
"lint:commit": "eslint apps/web-antdv-next internal/commit/validate-commit-msg.mjs --cache",
|
||||
"postinstall": "pnpm -r run stub --if-present",
|
||||
"prepare": "husky",
|
||||
@ -23,7 +24,8 @@
|
||||
"reinstall": "pnpm run clean && rimraf pnpm-lock.yaml && pnpm install",
|
||||
"verify:staged": "node internal/commit/husky-fast-check.mjs",
|
||||
"verify:commit": "pnpm run lint:commit && pnpm -F @vben/web-antdv-next run typecheck",
|
||||
"test:e2e": "turbo run test:e2e"
|
||||
"test:e2e": "turbo run test:e2e",
|
||||
"test:jsdoc": "vitest run internal/jsdoc-policy/check-jsdoc.test.mjs --environment node --reporter=dot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:",
|
||||
|
||||
@ -1,16 +1,8 @@
|
||||
/** layout content 组件的高度 */
|
||||
export const CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT = `--vben-content-height`;
|
||||
/** layout content 组件的宽度 */
|
||||
export const CSS_VARIABLE_LAYOUT_CONTENT_WIDTH = `--vben-content-width`;
|
||||
/** layout header 组件的高度 */
|
||||
export const CSS_VARIABLE_LAYOUT_HEADER_HEIGHT = `--vben-header-height`;
|
||||
/** layout footer 组件的高度 */
|
||||
export const CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT = `--vben-footer-height`;
|
||||
|
||||
/** 内容区域的组件ID */
|
||||
export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认命名空间
|
||||
*/
|
||||
export const DEFAULT_NAMESPACE = 'vben';
|
||||
|
||||
@ -1,22 +1,10 @@
|
||||
/**
|
||||
* @zh_CN GITHUB 仓库地址
|
||||
*/
|
||||
export const VBEN_GITHUB_URL = 'https://github.com/vbenjs/vue-vben-admin';
|
||||
|
||||
/**
|
||||
* @zh_CN 文档地址
|
||||
*/
|
||||
export const VBEN_DOC_URL = 'https://doc.vben.pro';
|
||||
|
||||
/**
|
||||
* @zh_CN Vben Logo
|
||||
*/
|
||||
export const VBEN_LOGO_URL =
|
||||
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp';
|
||||
|
||||
/**
|
||||
* @zh_CN Vben Admin 首页地址
|
||||
*/
|
||||
export const VBEN_PREVIEW_URL = 'https://www.vben.pro';
|
||||
|
||||
export const VBEN_ANTDV_NEXT_PREVIEW_URL = 'https://antdv-next.vben.pro';
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
/**
|
||||
* 全局复用的变量、组件、配置,各个模块之间共享
|
||||
* 通过单例模式实现,单例必须注意不受请求影响,例如用户信息这些需要根据请求获取的。后续如果有ssr需求,也不会影响
|
||||
*/
|
||||
|
||||
interface ComponentsState {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@ -44,32 +44,17 @@ export function isDayjsObject(value: any): value is dayjs.Dayjs {
|
||||
return dayjs.isDayjs(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时区
|
||||
* @returns 当前时区
|
||||
*/
|
||||
export const getSystemTimezone = () => {
|
||||
return dayjs.tz.guess();
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义设置的时区
|
||||
*/
|
||||
let currentTimezone = getSystemTimezone();
|
||||
|
||||
/**
|
||||
* 设置默认时区
|
||||
* @param timezone
|
||||
*/
|
||||
export const setCurrentTimezone = (timezone?: string) => {
|
||||
currentTimezone = timezone || getSystemTimezone();
|
||||
dayjs.tz.setDefault(currentTimezone);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取设置的时区
|
||||
* @returns 设置的时区
|
||||
*/
|
||||
export const getCurrentTimezone = () => {
|
||||
return currentTimezone;
|
||||
};
|
||||
|
||||
@ -96,10 +96,6 @@ export function downloadFileFromBlobPart({
|
||||
triggerDownload(url, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* img url to base64
|
||||
* @param url
|
||||
*/
|
||||
export function urlToBase64(url: string, mineType?: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let canvas = document.createElement('CANVAS') as HTMLCanvasElement | null;
|
||||
|
||||
@ -115,29 +115,6 @@ function isNumber(value: any): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first value in the provided list that is neither `null` nor `undefined`.
|
||||
*
|
||||
* This function iterates over the input values and returns the first one that is
|
||||
* not strictly equal to `null` or `undefined`. If all values are either `null` or
|
||||
* `undefined`, it returns `undefined`.
|
||||
*
|
||||
* @template T - The type of the input values.
|
||||
* @param {...(T | null | undefined)[]} values - A list of values to evaluate.
|
||||
* @returns {T | undefined} - The first value that is not `null` or `undefined`, or `undefined` if none are found.
|
||||
*
|
||||
* @example
|
||||
* // Returns 42 because it is the first non-null, non-undefined value.
|
||||
* getFirstNonNullOrUndefined(undefined, null, 42, 'hello'); // 42
|
||||
*
|
||||
* @example
|
||||
* // Returns 'hello' because it is the first non-null, non-undefined value.
|
||||
* getFirstNonNullOrUndefined(null, undefined, 'hello', 123); // 'hello'
|
||||
*
|
||||
* @example
|
||||
* // Returns undefined because all values are either null or undefined.
|
||||
* getFirstNonNullOrUndefined(undefined, null); // undefined
|
||||
*/
|
||||
function getFirstNonNullOrUndefined<T>(
|
||||
...values: (null | T | undefined)[]
|
||||
): T | undefined {
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
/**
|
||||
* @zh_CN 栈数据结构
|
||||
*/
|
||||
export class Stack<T> {
|
||||
/**
|
||||
* @zh_CN 栈内元素数量
|
||||
@ -8,18 +5,9 @@ export class Stack<T> {
|
||||
get size() {
|
||||
return this.items.length;
|
||||
}
|
||||
/**
|
||||
* @zh_CN 是否去重
|
||||
*/
|
||||
private readonly dedup: boolean;
|
||||
/**
|
||||
* @zh_CN 栈内元素
|
||||
*/
|
||||
private items: T[] = [];
|
||||
|
||||
/**
|
||||
* @zh_CN 栈的最大容量
|
||||
*/
|
||||
private readonly maxSize?: number;
|
||||
|
||||
constructor(dedup = true, maxSize?: number) {
|
||||
@ -93,11 +81,5 @@ export class Stack<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 创建一个栈实例
|
||||
* @param dedup 是否去重
|
||||
* @param maxSize 栈的最大容量
|
||||
* @returns 栈实例
|
||||
*/
|
||||
export const createStack = <T>(dedup = true, maxSize?: number) =>
|
||||
new Stack<T>(dedup, maxSize);
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
/**
|
||||
* @param { Readonly<Promise> } promise
|
||||
* @param {object=} errorExt - Additional Information you can pass to the err object
|
||||
* @return { Promise }
|
||||
*/
|
||||
export async function to<T, U = Error>(
|
||||
promise: Readonly<Promise<T>>,
|
||||
errorExt?: object,
|
||||
|
||||
46
packages/@core/base/typings/src/app.d.ts
vendored
46
packages/@core/base/typings/src/app.d.ts
vendored
@ -9,12 +9,6 @@ type LayoutType =
|
||||
|
||||
type ThemeModeType = 'auto' | 'dark' | 'light';
|
||||
|
||||
/**
|
||||
* 偏好设置按钮位置
|
||||
* fixed 固定在右侧
|
||||
* header 顶栏
|
||||
* auto 自动
|
||||
*/
|
||||
type PreferencesButtonPositionType = 'auto' | 'fixed' | 'header';
|
||||
|
||||
type BuiltinThemeType =
|
||||
@ -42,60 +36,20 @@ type ContentCompactType = 'compact' | 'wide';
|
||||
type LayoutHeaderModeType = 'auto' | 'auto-scroll' | 'fixed' | 'static';
|
||||
type LayoutHeaderMenuAlignType = 'center' | 'end' | 'start';
|
||||
|
||||
/**
|
||||
* 登录过期模式
|
||||
* modal 弹窗模式
|
||||
* page 页面模式
|
||||
*/
|
||||
type LoginExpiredModeType = 'modal' | 'page';
|
||||
|
||||
/**
|
||||
* 面包屑样式
|
||||
* background 背景
|
||||
* normal 默认
|
||||
*/
|
||||
type BreadcrumbStyleType = 'background' | 'normal';
|
||||
|
||||
/**
|
||||
* 权限模式
|
||||
* backend 后端权限模式
|
||||
* frontend 前端权限模式
|
||||
* mixed 混合权限模式
|
||||
*/
|
||||
type AccessModeType = 'backend' | 'frontend' | 'mixed';
|
||||
|
||||
/**
|
||||
* 导航风格
|
||||
* plain 朴素
|
||||
* rounded 圆润
|
||||
*/
|
||||
type NavigationStyleType = 'plain' | 'rounded';
|
||||
|
||||
/**
|
||||
* 标签栏风格
|
||||
* brisk 轻快
|
||||
* card 卡片
|
||||
* chrome 谷歌
|
||||
* plain 朴素
|
||||
*/
|
||||
type TabsStyleType = 'brisk' | 'card' | 'chrome' | 'plain';
|
||||
|
||||
/**
|
||||
* 页面切换动画
|
||||
*/
|
||||
type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
|
||||
|
||||
/**
|
||||
* 页面切换动画
|
||||
* panel-center 居中布局
|
||||
* panel-left 居左布局
|
||||
* panel-right 居右布局
|
||||
*/
|
||||
type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
|
||||
|
||||
/**
|
||||
* 时区选项
|
||||
*/
|
||||
interface TimezoneOption {
|
||||
label: string;
|
||||
offset: number;
|
||||
|
||||
15
packages/@core/base/typings/src/basic.d.ts
vendored
15
packages/@core/base/typings/src/basic.d.ts
vendored
@ -8,25 +8,10 @@ type SelectOption = BasicOption;
|
||||
type TabOption = BasicOption;
|
||||
|
||||
interface BasicUserInfo {
|
||||
/**
|
||||
* 头像
|
||||
*/
|
||||
avatar: string;
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
realName: string;
|
||||
/**
|
||||
* 用户角色
|
||||
*/
|
||||
roles?: string[];
|
||||
/**
|
||||
* 用户id
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
username: string;
|
||||
}
|
||||
|
||||
|
||||
54
packages/@core/base/typings/src/helper.d.ts
vendored
54
packages/@core/base/typings/src/helper.d.ts
vendored
@ -1,83 +1,41 @@
|
||||
import type { ComputedRef, MaybeRef } from 'vue';
|
||||
|
||||
/**
|
||||
* 深层递归所有属性为可选
|
||||
*/
|
||||
type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
/**
|
||||
* 深层递归所有属性为只读
|
||||
*/
|
||||
type DeepReadonly<T> = {
|
||||
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* 任意类型的异步函数
|
||||
*/
|
||||
|
||||
type AnyPromiseFunction<T extends any[] = any[], R = void> = (
|
||||
...arg: T
|
||||
) => PromiseLike<R>;
|
||||
|
||||
/**
|
||||
* 任意类型的普通函数
|
||||
*/
|
||||
type AnyNormalFunction<T extends any[] = any[], R = void> = (...arg: T) => R;
|
||||
|
||||
/**
|
||||
* 任意类型的函数
|
||||
*/
|
||||
type AnyFunction<T extends any[] = any[], R = void> =
|
||||
| AnyNormalFunction<T, R>
|
||||
| AnyPromiseFunction<T, R>;
|
||||
|
||||
/**
|
||||
* T | null 包装
|
||||
*/
|
||||
type Nullable<T> = null | T;
|
||||
|
||||
/**
|
||||
* T | Not null 包装
|
||||
*/
|
||||
type NonNullable<T> = T extends null | undefined ? never : T;
|
||||
|
||||
/**
|
||||
* 字符串类型对象
|
||||
*/
|
||||
type Recordable<T> = Record<string, T>;
|
||||
|
||||
/**
|
||||
* 字符串类型对象(只读)
|
||||
*/
|
||||
interface ReadonlyRecordable<T = any> {
|
||||
readonly [key: string]: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* setTimeout 返回值类型
|
||||
*/
|
||||
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* setInterval 返回值类型
|
||||
*/
|
||||
type IntervalHandle = ReturnType<typeof setInterval>;
|
||||
|
||||
/**
|
||||
* 也许它是一个计算的 ref,或者一个 getter 函数
|
||||
*
|
||||
*/
|
||||
type MaybeReadonlyRef<T> = (() => T) | ComputedRef<T>;
|
||||
|
||||
/**
|
||||
* 也许它是一个 ref,或者一个普通值,或者一个 getter 函数
|
||||
*
|
||||
*/
|
||||
type MaybeComputedRef<T> = MaybeReadonlyRef<T> | MaybeRef<T>;
|
||||
|
||||
type Merge<O extends object, T extends object> = {
|
||||
@ -88,18 +46,6 @@ type Merge<O extends object, T extends object> = {
|
||||
: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* T = [
|
||||
* { name: string; age: number; },
|
||||
* { sex: 'male' | 'female'; age: string }
|
||||
* ]
|
||||
* =>
|
||||
* MergeAll<T> = {
|
||||
* name: string;
|
||||
* sex: 'male' | 'female';
|
||||
* age: string
|
||||
* }
|
||||
*/
|
||||
type MergeAll<
|
||||
T extends object[],
|
||||
R extends object = Record<string, any>,
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import type { Component } from 'vue';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
/**
|
||||
* 扩展路由原始对象
|
||||
*/
|
||||
type ExRouteRecordRaw = RouteRecordRaw & {
|
||||
parent?: string;
|
||||
parents?: string[];
|
||||
@ -11,65 +8,21 @@ type ExRouteRecordRaw = RouteRecordRaw & {
|
||||
};
|
||||
|
||||
interface MenuRecordBadgeRaw {
|
||||
/**
|
||||
* 徽标
|
||||
*/
|
||||
badge?: string;
|
||||
/**
|
||||
* 徽标类型
|
||||
*/
|
||||
badgeType?: 'dot' | 'normal';
|
||||
/**
|
||||
* 徽标颜色
|
||||
*/
|
||||
badgeVariants?: 'destructive' | 'primary' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单原始对象
|
||||
*/
|
||||
interface MenuRecordRaw extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* 激活时的图标名
|
||||
*/
|
||||
activeIcon?: string;
|
||||
/**
|
||||
* 子菜单
|
||||
*/
|
||||
children?: MenuRecordRaw[];
|
||||
/**
|
||||
* 是否禁用菜单
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 图标名
|
||||
*/
|
||||
icon?: Component | string;
|
||||
/**
|
||||
* 菜单名
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 排序号
|
||||
*/
|
||||
order?: number;
|
||||
/**
|
||||
* 父级路径
|
||||
*/
|
||||
parent?: string;
|
||||
/**
|
||||
* 所有父级路径
|
||||
*/
|
||||
parents?: string[];
|
||||
/**
|
||||
* 菜单路径,唯一,可当作key
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* 是否显示菜单
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user