This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"label": "Openapi Platform Plugin",
"label.zh-CN": "开放平台插件",
"name": "com.msgbyte.openapi",
"url": "/plugins/com.msgbyte.openapi/index.js",
"version": "0.0.0",
"author": "msgbyte",
"description": "Provide the operating capability of the open platform for the application",
"description.zh-CN": "为应用提供开放平台的操作能力",
"requireRestart": true
}

View File

@@ -0,0 +1,7 @@
{
"name": "@plugins/com.msgbyte.openapi",
"main": "src/index.ts",
"version": "0.0.0",
"private": true,
"dependencies": {}
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import {
DefaultFullModalInputEditorRender,
FullModalField,
Switch,
} from '@capital/component';
import { useOpenAppInfo } from '../context';
import { Translate } from '../../translate';
import { useOpenAppAction } from './useOpenAppAction';
const Bot: React.FC = React.memo(() => {
const { capability, bot } = useOpenAppInfo();
const { loading, handleChangeAppCapability, handleUpdateBotInfo } =
useOpenAppAction();
return (
<div className="plugin-openapi-app-info_bot">
<FullModalField
title={Translate.enableBotCapability}
content={
<Switch
disabled={loading}
checked={capability.includes('bot')}
onChange={(checked) => handleChangeAppCapability('bot', checked)}
/>
}
/>
{capability.includes('bot') && (
<FullModalField
title={Translate.bot.callback}
tip={Translate.bot.callbackTip}
value={bot?.callbackUrl}
editable={true}
renderEditor={DefaultFullModalInputEditorRender}
onSave={(str: string) =>
handleUpdateBotInfo('callbackUrl', String(str))
}
/>
)}
</div>
);
});
Bot.displayName = 'Bot';
export default Bot;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {
FullModalField,
DefaultFullModalTextAreaEditorRender,
Switch,
} from '@capital/component';
import { useOpenAppInfo } from '../context';
import { useOpenAppAction } from './useOpenAppAction';
import { Translate } from '../../translate';
const OAuth: React.FC = React.memo(() => {
const { capability, oauth } = useOpenAppInfo();
const { loading, handleChangeAppCapability, handleUpdateOAuthInfo } =
useOpenAppAction();
return (
<div className="plugin-openapi-app-info_oauth">
<FullModalField
title={Translate.oauth.open}
content={
<Switch
disabled={loading}
checked={capability.includes('oauth')}
onChange={(checked) => handleChangeAppCapability('oauth', checked)}
/>
}
/>
{capability.includes('oauth') && (
<FullModalField
title={Translate.oauth.allowedCallbackUrls}
tip={Translate.oauth.allowedCallbackUrlsTip}
content={
<>
{(oauth?.redirectUrls ?? []).map((url, i) => (
<p key={i}>{url}</p>
))}
</>
}
value={(oauth?.redirectUrls ?? []).join('\n')}
editable={true}
renderEditor={DefaultFullModalTextAreaEditorRender}
onSave={(str: string) =>
handleUpdateOAuthInfo(
'redirectUrls',
String(str)
.split('\n')
.map((t) => t.trim())
)
}
/>
)}
</div>
);
});
OAuth.displayName = 'OAuth';
export default OAuth;

View File

@@ -0,0 +1,5 @@
.plugin-openapi-app-info_profile {
h2 {
margin-bottom: 10px;
}
}

View File

@@ -0,0 +1,89 @@
import { useOpenAppInfo } from '../context';
import React from 'react';
import {
FullModalField,
Divider,
SensitiveText,
Button,
Avatar,
AvatarUploader,
DefaultFullModalInputEditorRender,
} from '@capital/component';
import { Translate } from '../../translate';
import { useOpenAppAction } from './useOpenAppAction';
import styled from 'styled-components';
import './Profile.less';
const TwoColumnContainer = styled.div`
display: flex;
> div {
flex: 1;
}
`;
/**
* 基础信息
*/
const Profile: React.FC = React.memo(() => {
const { appId, appSecret, appName, appDesc, appIcon } = useOpenAppInfo();
const { handleSetAppInfo, handleDeleteApp } = useOpenAppAction();
return (
<div className="plugin-openapi-app-info_profile">
<h2>{Translate.app.basicInfo}</h2>
<TwoColumnContainer>
<div>
<FullModalField
title={Translate.app.appName}
value={appName}
editable={true}
renderEditor={DefaultFullModalInputEditorRender}
onSave={(val) => handleSetAppInfo('appName', val)}
/>
<FullModalField
title={Translate.app.appDesc}
value={appDesc}
editable={true}
renderEditor={DefaultFullModalInputEditorRender}
onSave={(val) => handleSetAppInfo('appDesc', val)}
/>
</div>
<div>
<AvatarUploader
onUploadSuccess={(fileInfo) => {
handleSetAppInfo('appIcon', fileInfo.url);
}}
>
<Avatar name={appName} src={appIcon} size={72} />
</AvatarUploader>
</div>
</TwoColumnContainer>
<Divider />
<h2>{Translate.app.appcret}</h2>
<div>
<FullModalField title="App ID" content={appId} />
<FullModalField
title="App Secret"
content={<SensitiveText text={appSecret} />}
/>
</div>
<Divider />
<Button type="primary" danger={true} onClick={handleDeleteApp}>
{Translate.delete}
</Button>
</div>
);
});
Profile.displayName = 'Profile';
export default Profile;

View File

@@ -0,0 +1,11 @@
import { useOpenAppInfo } from '../context';
import React from 'react';
const Summary: React.FC = React.memo(() => {
const { refresh, ...other } = useOpenAppInfo();
return <pre>{JSON.stringify(other, undefined, 2)}</pre>;
});
Summary.displayName = 'Summary';
export default Summary;

View File

@@ -0,0 +1,8 @@
import React from 'react';
const Webpage: React.FC = React.memo(() => {
return <div></div>;
});
Webpage.displayName = 'Webpage';
export default Webpage;

View File

@@ -0,0 +1,8 @@
.plugin-openapi-app-info {
display: flex;
height: 100%;
.plugin-openapi-app-info_body {
padding: 0 10px;
}
}

View File

@@ -0,0 +1,83 @@
import React, { useMemo } from 'react';
import { Icon, SidebarView } from '@capital/component';
import { Loadable, useEvent } from '@capital/common';
import { useOpenAppInfo } from '../context';
import { Translate } from '../../translate';
import styled from 'styled-components';
import './index.less';
const MenuTitle = styled.div`
display: flex;
.iconify {
margin-right: 4px;
font-size: 16px;
cursor: pointer;
}
`;
// const Summary = Loadable(() => import('./Summary'));
const Profile = Loadable(() => import('./Profile'));
const Bot = Loadable(() => import('./Bot'));
const Webpage = Loadable(() => import('./Webpage'));
const OAuth = Loadable(() => import('./OAuth'));
const AppInfo: React.FC = React.memo(() => {
const { appName, onSelectApp } = useOpenAppInfo();
const handleBack = useEvent(() => {
onSelectApp(null);
});
const menu = useMemo(
() => [
{
type: 'group',
title: (
<MenuTitle>
<Icon icon="mdi:arrow-left" onClick={handleBack} /> {appName}
</MenuTitle>
),
children: [
// {
// type: 'item',
// title: '总览',
// content: <Summary />,
// isDev: true,
// },
{
type: 'item',
title: Translate.app.basicInfo,
content: <Profile />,
},
{
type: 'item',
title: Translate.app.bot,
content: <Bot />,
},
{
type: 'item',
title: Translate.app.webpage,
content: <Webpage />,
isDev: true,
},
{
type: 'item',
title: Translate.app.oauth,
content: <OAuth />,
},
],
},
],
[]
);
return (
<div className="plugin-openapi-app-info">
<SidebarView menu={menu} defaultContentPath="0.children.0.content" />
</div>
);
});
AppInfo.displayName = 'AppInfo';
export default AppInfo;

View File

@@ -0,0 +1,102 @@
import {
openReconfirmModal,
postRequest,
showErrorToasts,
useAsyncFn,
useAsyncRequest,
useEvent,
} from '@capital/common';
import { useOpenAppInfo } from '../context';
import type { OpenAppBot, OpenAppCapability, OpenAppOAuth } from '../types';
/**
* 开放应用操作
*/
export function useOpenAppAction() {
const { refresh, appId, capability, onSelectApp } = useOpenAppInfo();
const [{ loading }, handleChangeAppCapability] = useAsyncRequest(
async (targetCapability: OpenAppCapability, checked: boolean) => {
const newCapability: OpenAppCapability[] = [...capability];
const findIndex = newCapability.findIndex((c) => c === targetCapability);
if (checked) {
if (findIndex === -1) {
newCapability.push(targetCapability);
}
} else {
if (findIndex !== -1) {
newCapability.splice(findIndex, 1);
}
}
await postRequest('/openapi/app/setAppCapability', {
appId,
capability: newCapability,
});
await refresh();
},
[appId, capability, refresh]
);
const [, handleSetAppInfo] = useAsyncRequest(
async (fieldName: string, fieldValue: string) => {
await postRequest('/openapi/app/setAppInfo', {
appId,
fieldName,
fieldValue,
});
await refresh();
},
[appId, refresh]
);
const [, handleUpdateOAuthInfo] = useAsyncRequest(
async <T extends keyof OpenAppOAuth>(name: T, value: OpenAppOAuth[T]) => {
await postRequest('/openapi/app/setAppOAuthInfo', {
appId,
fieldName: name,
fieldValue: value,
});
await refresh();
},
[]
);
const [, handleUpdateBotInfo] = useAsyncRequest(
async <T extends keyof OpenAppBot>(name: T, value: OpenAppBot[T]) => {
await postRequest('/openapi/app/setAppBotInfo', {
appId,
fieldName: name,
fieldValue: value,
});
await refresh();
},
[appId, refresh]
);
const handleDeleteApp = useEvent(() => {
openReconfirmModal({
onConfirm: async () => {
try {
await postRequest('/openapi/app/delete', {
appId,
});
onSelectApp(null);
await refresh();
} catch (err) {
showErrorToasts(err);
}
},
});
});
return {
loading,
handleSetAppInfo,
handleDeleteApp,
handleChangeAppCapability,
handleUpdateOAuthInfo,
handleUpdateBotInfo,
};
}

View File

@@ -0,0 +1,34 @@
import React, { useContext } from 'react';
import { OpenApp } from './types';
interface OpenAppInfoContextProps extends OpenApp {
refresh: () => Promise<void>;
onSelectApp: (appId: string | null) => void;
}
const OpenAppInfoContext = React.createContext<OpenAppInfoContextProps>(null);
OpenAppInfoContext.displayName = 'OpenAppInfoContext';
export const OpenAppInfoProvider: React.FC<
React.PropsWithChildren<{
appInfo: OpenApp;
refresh: OpenAppInfoContextProps['refresh'];
onSelectApp: OpenAppInfoContextProps['onSelectApp'];
}>
> = (props) => {
return (
<OpenAppInfoContext.Provider
value={{
...props.appInfo,
refresh: props.refresh,
onSelectApp: props.onSelectApp,
}}
>
{props.children}
</OpenAppInfoContext.Provider>
);
};
export function useOpenAppInfo() {
return useContext(OpenAppInfoContext);
}

View File

@@ -0,0 +1,3 @@
.plugin-openapi-main-panel {
height: 100%;
}

View File

@@ -0,0 +1,86 @@
import React, { useMemo } from 'react';
import { openModal, closeModal } from '@capital/common';
import { Space, Table, Button, Loading } from '@capital/component';
import { OpenApp } from './types';
import AppInfo from './AppInfo';
import { OpenAppInfoProvider } from './context';
import { CreateOpenApp } from '../modals/CreateOpenApp';
import { ServiceChecker } from '../components/ServiceChecker';
import { useOpenAppList } from './useOpenAppList';
import { Translate } from '../translate';
import './index.less';
const OpenApiMainPanel: React.FC = React.memo(() => {
const { loading, allApps, refresh, appInfo, handleSetSelectedApp } =
useOpenAppList();
const columns = useMemo(
() => [
{
title: Translate.name,
dataIndex: 'appName',
},
{
title: Translate.operation,
key: 'action',
render: (_, record: OpenApp) => (
<Space>
<Button onClick={() => handleSetSelectedApp(record._id)}>
{Translate.enter}
</Button>
</Space>
),
},
],
[]
);
const handleCreateOpenApp = () => {
const key = openModal(
<CreateOpenApp
onSuccess={() => {
refresh();
closeModal(key);
}}
/>
);
};
return (
<Loading spinning={loading} style={{ height: '100%' }}>
<div className="plugin-openapi-main-panel">
{appInfo ? (
<OpenAppInfoProvider
appInfo={appInfo}
onSelectApp={handleSetSelectedApp}
refresh={refresh}
>
<AppInfo />
</OpenAppInfoProvider>
) : (
<>
<Button
style={{ marginBottom: 10 }}
type="primary"
onClick={handleCreateOpenApp}
>
{Translate.createApplication}
</Button>
<Table columns={columns} dataSource={allApps} pagination={false} />
</>
)}
</div>
</Loading>
);
});
OpenApiMainPanel.displayName = 'OpenApiMainPanel';
const OpenApiMainPanelWrapper = () => {
return (
<ServiceChecker>
<OpenApiMainPanel />
</ServiceChecker>
);
};
export default OpenApiMainPanelWrapper;

View File

@@ -0,0 +1,29 @@
const openAppCapability = [
'bot', // 机器人
'webpage', // 网页
'oauth', // 第三方登录
] as const;
export type OpenAppCapability = typeof openAppCapability[number];
export interface OpenAppOAuth {
redirectUrls: string[];
}
export interface OpenAppBot {
callbackUrl: string;
}
export interface OpenApp {
_id: string;
appId: string;
appSecret: string;
appName: string;
appDesc: string;
appIcon: string;
capability: OpenAppCapability[];
oauth?: OpenAppOAuth;
bot?: OpenAppBot;
owner: string;
}

View File

@@ -0,0 +1,59 @@
import {
postRequest,
appendUrlSearch,
useAsyncRefresh,
useLocation,
urlSearchParse,
isValidStr,
useNavigate,
} from '@capital/common';
import { useEffect, useState } from 'react';
import { OpenApp } from './types';
/**
* 开放应用列表
*/
export function useOpenAppList() {
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
const {
loading,
value: allApps = [],
refresh,
} = useAsyncRefresh(async (): Promise<OpenApp[]> => {
const { data } = await postRequest('/openapi/app/all');
return data ?? [];
}, []);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
// 仅初始化的时候才处理
const { appId } = urlSearchParse(location.search, {
ignoreQueryPrefix: true,
});
if (isValidStr(appId)) {
setSelectedAppId(appId);
}
}, []);
return {
loading,
allApps,
refresh,
appInfo: allApps.find((a) => a._id === selectedAppId),
/**
* 设置当前选中的开放app
*/
handleSetSelectedApp(appId: string | null) {
navigate({
search: appendUrlSearch({
appId,
}),
});
setSelectedAppId(appId);
},
};
}

View File

@@ -0,0 +1,27 @@
import { LoadingSpinner } from '@capital/component';
import { fetchAvailableServices, useAsync } from '@capital/common';
import React from 'react';
import { Translate } from '../translate';
/**
* 服务监测
*/
export const ServiceChecker: React.FC<React.PropsWithChildren> = React.memo(
(props) => {
const { loading, value: enabled } = useAsync(async () => {
const services = await fetchAvailableServices();
return services.includes('openapi.app');
}, []);
if (loading) {
return <LoadingSpinner />;
}
if (!enabled) {
return <div>{Translate.noservice}</div>;
}
return <>{props.children}</>;
}
);
ServiceChecker.displayName = 'ServiceChecker';

View File

@@ -0,0 +1,18 @@
import { Loadable, regCustomPanel, regPluginRootRoute } from '@capital/common';
import { Translate } from './translate';
const MainPanel = Loadable(() => import('./MainPanel'));
regCustomPanel({
position: 'setting',
icon: '',
name: 'com.msgbyte.openapi/mainPanel',
label: Translate.openapi,
render: MainPanel,
});
regPluginRootRoute({
name: 'com.msgbyte.openapi/route',
path: '/openapi',
component: MainPanel,
});

View File

@@ -0,0 +1,56 @@
import {
createFastFormSchema,
fieldSchema,
ModalWrapper,
postRequest,
showToasts,
showErrorToasts,
} from '@capital/common';
import { WebFastForm } from '@capital/component';
import React from 'react';
import { Translate } from '../translate';
const schema = createFastFormSchema({
appName: fieldSchema
.string()
.required(Translate.appNameCannotBeEmpty)
.max(20, Translate.appNameTooLong),
appDesc: fieldSchema.string(),
});
const fields = [
{ type: 'text', name: 'appName', label: Translate.app.appName },
{
type: 'textarea',
name: 'appDesc',
label: Translate.app.appDesc,
},
];
interface CreateOpenAppProps {
onSuccess?: () => void;
}
export const CreateOpenApp: React.FC<CreateOpenAppProps> = React.memo(
(props) => {
const handleSubmit = async (values: any) => {
try {
await postRequest('/openapi/app/create', {
...values,
appIcon: '',
});
showToasts(Translate.createApplicationSuccess, 'success');
props.onSuccess?.();
} catch (e) {
showErrorToasts(e);
}
};
return (
<ModalWrapper title={Translate.createApplication}>
<WebFastForm schema={schema} fields={fields} onSubmit={handleSubmit} />
</ModalWrapper>
);
}
);
CreateOpenApp.displayName = 'CreateOpenApp';

View File

@@ -0,0 +1,101 @@
import { localTrans } from '@capital/common';
export const Translate = {
openapi: localTrans({ 'zh-CN': '开放平台', 'en-US': 'Open Api' }),
noservice: localTrans({
'zh-CN': '管理员没有开放 Openapi 服务',
'en-US': 'The administrator did not open the Openapi service',
}),
enableBotCapability: localTrans({
'zh-CN': '开启机器人能力',
'en-US': 'Enable Bot Capability',
}),
name: localTrans({
'zh-CN': '名称',
'en-US': 'Name',
}),
operation: localTrans({
'zh-CN': '操作',
'en-US': 'Operation',
}),
delete: localTrans({
'zh-CN': '删除',
'en-US': 'Delete',
}),
enter: localTrans({
'zh-CN': '进入',
'en-US': 'Enter',
}),
createApplication: localTrans({
'zh-CN': '创建应用',
'en-US': 'Create Application',
}),
createApplicationSuccess: localTrans({
'zh-CN': '创建应用成功',
'en-US': 'Create Application Success',
}),
appNameCannotBeEmpty: localTrans({
'zh-CN': '应用名不能为空',
'en-US': 'App Name Cannot be Empty',
}),
appNameTooLong: localTrans({
'zh-CN': '应用名过长',
'en-US': 'App Name too Long',
}),
app: {
basicInfo: localTrans({
'zh-CN': '基础信息',
'en-US': 'Basic Info',
}),
appName: localTrans({
'zh-CN': '应用名称',
'en-US': 'App Name',
}),
appDesc: localTrans({
'zh-CN': '应用描述',
'en-US': 'App Description',
}),
bot: localTrans({
'zh-CN': '机器人',
'en-US': 'Bot',
}),
webpage: localTrans({
'zh-CN': '网页',
'en-US': 'Web Page',
}),
oauth: localTrans({
'zh-CN': '第三方登录',
'en-US': 'OAuth',
}),
appcret: localTrans({
'zh-CN': '应用凭证',
'en-US': 'Application Credentials',
}),
},
bot: {
callback: localTrans({
'zh-CN': '消息回调地址',
'en-US': 'Callback Url',
}),
callbackTip: localTrans({
'zh-CN':
'机器人被 @ 的时候会向该地址发送请求(收件箱接受到新内容时会发送回调)',
'en-US':
'The bot will send a request to this address when it is mentioned (callback will be sent when the inbox receives new content)',
}),
},
oauth: {
open: localTrans({
'zh-CN': '开启 OAuth',
'en-US': 'Open OAuth',
}),
allowedCallbackUrls: localTrans({
'zh-CN': '允许的回调地址',
'en-US': 'Allowed Callback Urls',
}),
allowedCallbackUrlsTip: localTrans({
'zh-CN': '多个回调地址单独一行',
'en-US': 'Multiple callback addresses on a single line',
}),
},
};

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"esModuleInterop": true,
"jsx": "react",
"paths": {
"@capital/*": ["../../src/plugin/*"],
}
}
}