Merge pull request #398 from answerdev/feat/plugins

Feat/plugins
This commit is contained in:
haitao.jarvis 2023-06-12 15:48:02 +08:00 committed by GitHub
commit 19138a1c67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 750 additions and 180 deletions

View File

@ -882,6 +882,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1317,13 +1320,11 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:

View File

@ -62,7 +62,7 @@ backend:
description:
other: 等级 3 (成熟社区所需的高声望)
rank_question_add_label:
other: 提问s
other: 提问
rank_answer_add_label:
other: 写入答案
rank_comment_add_label:
@ -1274,13 +1274,10 @@ ui:
plugins: 插件
installed_plugins: 已安装插件
website_welcome: 欢迎来到 {{site_name}}
plugins:
user_center:
login: 登录
qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码并登录。
login_failed_email_tip: 登录失败,请允许此应用访问您的邮箱信息,然后重试。
oauth:
connect: 连接到 {{ auth_name }}
remove: 移除 {{ auth_name }}
admin:
admin_header:
title: 后台管理

View File

@ -2,8 +2,9 @@ package cli
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
@ -101,8 +102,10 @@ func BuildNewAnswer(outputPath string, plugins []string, originalAnswerInfo Orig
builder := newAnswerBuilder(outputPath, plugins, originalAnswerInfo)
builder.DoTask(createMainGoFile)
builder.DoTask(downloadGoModFile)
builder.DoTask(copyUIFiles)
builder.DoTask(overwriteIndexTs)
builder.DoTask(buildUI)
builder.DoTask(mergeI18nFiles)
builder.DoTask(replaceNecessaryFile)
builder.DoTask(buildBinary)
builder.DoTask(cleanByproduct)
return builder.BuildError
@ -120,6 +123,7 @@ func formatPlugins(plugins []string) (formatted []*pluginInfo) {
return formatted
}
// createMainGoFile creates main.go file in tmp dir that content is mainGoTpl
func createMainGoFile(b *buildingMaterial) (err error) {
fmt.Printf("[build] tmp dir: %s\n", b.tmpDir)
err = dir.CreateDirIfNotExist(b.tmpDir)
@ -169,6 +173,7 @@ func createMainGoFile(b *buildingMaterial) (err error) {
return
}
// downloadGoModFile run go mod commands to download dependencies
func downloadGoModFile(b *buildingMaterial) (err error) {
// If user specify a module replacement, use it. Otherwise, use the latest version.
if len(b.answerModuleReplacement) > 0 {
@ -191,6 +196,81 @@ func downloadGoModFile(b *buildingMaterial) (err error) {
return
}
// copyUIFiles copy ui files from answer module to tmp dir
func copyUIFiles(b *buildingMaterial) (err error) {
goListCmd := b.newExecCmd("go", "list", "-mod=mod", "-m", "-f", "{{.Dir}}", "github.com/answerdev/answer")
buf := new(bytes.Buffer)
goListCmd.Stdout = buf
if err = goListCmd.Run(); err != nil {
return fmt.Errorf("failed to run go list: %w", err)
}
goModUIDir := filepath.Join(strings.TrimSpace(buf.String()), "ui")
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui/")
if err = copyDirEntries(os.DirFS(goModUIDir), ".", localUIBuildDir); err != nil {
return fmt.Errorf("failed to copy ui files: %w", err)
}
return nil
}
// overwriteIndexTs overwrites index.ts file in ui/src/plugins/ dir
func overwriteIndexTs(b *buildingMaterial) (err error) {
localUIPluginDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui/src/plugins/")
folders, err := getFolders(localUIPluginDir)
if err != nil {
return fmt.Errorf("failed to get folders: %w", err)
}
content := generateIndexTsContent(folders)
err = os.WriteFile(filepath.Join(localUIPluginDir, "index.ts"), []byte(content), 0644)
if err != nil {
return fmt.Errorf("failed to write index.ts: %w", err)
}
return nil
}
func getFolders(dir string) ([]string, error) {
var folders []string
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, file := range files {
if file.IsDir() && file.Name() != "builtin" {
folders = append(folders, file.Name())
}
}
return folders, nil
}
func generateIndexTsContent(folders []string) string {
builder := &strings.Builder{}
builder.WriteString("export default null;\n\n")
for _, folder := range folders {
builder.WriteString(fmt.Sprintf("export { default as %s } from './%s';\n", folder, folder))
}
return builder.String()
}
// buildUI run pnpm install and pnpm build commands to build ui
func buildUI(b *buildingMaterial) (err error) {
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui")
pnpmInstallCmd := b.newExecCmd("pnpm", "install")
pnpmInstallCmd.Dir = localUIBuildDir
if err = pnpmInstallCmd.Run(); err != nil {
return err
}
pnpmBuildCmd := b.newExecCmd("pnpm", "build")
pnpmBuildCmd.Dir = localUIBuildDir
if err = pnpmBuildCmd.Run(); err != nil {
return err
}
return nil
}
func replaceNecessaryFile(b *buildingMaterial) (err error) {
fmt.Printf("try to replace ui build directory\n")
uiBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui")
@ -198,6 +278,7 @@ func replaceNecessaryFile(b *buildingMaterial) (err error) {
return err
}
// mergeI18nFiles merge i18n files
func mergeI18nFiles(b *buildingMaterial) (err error) {
fmt.Printf("try to merge i18n files\n")
@ -285,37 +366,60 @@ func mergeI18nFiles(b *buildingMaterial) (err error) {
return err
}
func copyDirEntries(sourceFs embed.FS, sourceDir string, targetDir string) (err error) {
entries, err := ui.Build.ReadDir(sourceDir)
if err != nil {
return err
}
func copyDirEntries(sourceFs fs.FS, sourceDir string, targetDir string) (err error) {
err = dir.CreateDirIfNotExist(targetDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
err = copyDirEntries(sourceFs, filepath.Join(sourceDir, entry.Name()), filepath.Join(targetDir, entry.Name()))
err = fs.WalkDir(sourceFs, sourceDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Convert the path to use forward slashes, important because we use embedded FS which always uses forward slashes
path = filepath.ToSlash(path)
// Construct the absolute path for the source file/directory
srcPath := filepath.Join(sourceDir, path)
// Construct the absolute path for the destination file/directory
dstPath := filepath.Join(targetDir, path)
if d.IsDir() {
// Create the directory in the destination
err := os.MkdirAll(dstPath, os.ModePerm)
if err != nil {
return err
return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
}
} else {
// Open the source file
srcFile, err := sourceFs.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file %s: %w", srcPath, err)
}
defer srcFile.Close()
// Create the destination file
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create destination file %s: %w", dstPath, err)
}
defer dstFile.Close()
// Copy the file contents
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file contents from %s to %s: %w", srcPath, dstPath, err)
}
continue
}
file, err := sourceFs.ReadFile(filepath.Join(sourceDir, entry.Name()))
if err != nil {
return err
}
filename := filepath.Join(targetDir, entry.Name())
err = os.WriteFile(filename, file, 0666)
if err != nil {
return err
}
}
return nil
return nil
})
return err
}
// buildBinary build binary file
func buildBinary(b *buildingMaterial) (err error) {
versionInfo := b.originalAnswerInfo
cmdPkg := "github.com/answerdev/answer/cmd"
@ -329,6 +433,7 @@ func buildBinary(b *buildingMaterial) (err error) {
return
}
// cleanByproduct delete tmp dir
func cleanByproduct(b *buildingMaterial) (err error) {
return os.RemoveAll(b.tmpDir)
}

5
ui/.gitignore vendored
View File

@ -31,3 +31,8 @@ yarn.lock
package-lock.json
.eslintcache
/.vscode/
/* !/src/plugins
/src/plugins/*
!/src/plugins/builtin
!/src/plugins/Demo

View File

@ -8,7 +8,6 @@
"build": "react-app-rewired build",
"lint": "eslint . --cache --fix --ext .ts,.tsx",
"prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"prepare": "cd .. && husky install",
"preinstall": "node ./scripts/preinstall.js",
"pre-commit": "lint-staged"
},

View File

@ -560,13 +560,10 @@ export interface OauthBindEmailReq {
must: boolean;
}
export interface OauthConnectorItem {
export interface UserOauthConnectorItem {
icon: string;
name: string;
link: string;
}
export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean;
external_id: string;
}

View File

@ -0,0 +1,72 @@
import { FC, ReactNode, memo } from 'react';
import builtin from '@/plugins/builtin';
import * as plugins from '@/plugins';
import { Plugin, PluginType } from '@/utils/pluginKit';
/**
* NotePlease set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered.
*
* @field slug_name: The `slug_name` of the plugin needs to be rendered.
* If this property is set, `PluginRender` will use it first (regardless of whether `type` is set)
* to find the corresponding plugin and render it.
* @field type: Used to formulate the rendering of all plugins of this type.
* (if the `slug_name` attribute is set, it will be ignored)
* @field prop: Any attribute you want to configure, e.g. `className`
*/
interface Props {
slug_name?: string;
type?: PluginType;
children?: ReactNode;
[prop: string]: any;
}
const findPlugin: (s, k: 'slug_name' | 'type', v) => Plugin[] = (
source,
k,
v,
) => {
const ret: Plugin[] = [];
if (source) {
Object.keys(source).forEach((i) => {
const p = source[i];
if (p && p.component && p.info && p.info[k] === v) {
ret.push(p);
}
});
}
return ret;
};
const Index: FC<Props> = ({ slug_name, type, children, ...props }) => {
const fk = slug_name ? 'slug_name' : 'type';
const fv = fk === 'slug_name' ? slug_name : type;
const bp = findPlugin(builtin, fk, fv);
const vp = findPlugin(plugins, fk, fv);
const pluginSlice = [...bp, ...vp];
if (!pluginSlice.length) {
return null;
}
/**
* TODO: Rendering control for non-builtin plug-ins
* ps: Logic such as version compatibility determination can be placed here
*/
return (
<>
{pluginSlice.map((ps) => {
const PluginFC = ps.component;
return (
// @ts-ignore
<PluginFC key={ps.info.slug_name} {...props}>
{children}
</PluginFC>
);
})}
</>
);
};
export default memo(Index);

View File

@ -1,6 +1,3 @@
---
sidebar_position: 0
---
# Schema Form
## Introduction
@ -12,7 +9,7 @@ A React component capable of building HTML forms out of a [JSON schema](https://
```tsx
import React, { useState } from 'react';
import { SchemaForm, initFormData, JSONSchema, UISchema } from '@/components';
import { SchemaForm, initFormData, JSONSchema, UISchema, FormKit } from '@/components';
const schema: JSONSchema = {
title: 'General',
@ -51,6 +48,14 @@ const uiSchema: UISchema = {
const Form = () => {
const [formData, setFormData] = useState(initFormData(schema));
const formRef = useRef<{
validator: () => Promise<boolean>;
}>(null);
const refreshConfig: FormKit['refreshConfig'] = async () => {
// refreshFormConfig();
};
const handleChange = (data) => {
setFormData(data);
@ -58,27 +63,56 @@ const Form = () => {
return (
<SchemaForm
ref={formRef}
schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={handleChange}
refreshConfig={refreshConfig}
/>
);
};
export default Form;
```
## Props
---
| Property | Description | Type | Default |
| -------- | ---------------------------------------- | ------------------------------------- | ------- |
| schema | Describe the form structure with schema | [JSONSchema](#json-schema) | - |
| uiSchema | Describe the properties of the field | [UISchema](#uischema) | - |
| formData | Describe form data | [FormData](#formdata) | - |
| onChange | Callback function when form data changes | (data: [FormData](#formdata)) => void | - |
| onSubmit | Callback function when form is submitted | (data: React.FormEvent) => void | - |
## Form Props
```ts
interface FormProps {
// Describe the form structure with schema
schema: JSONSchema | null;
// Describe the properties of the field
uiSchema?: UISchema;
// Describe form data
formData: Type.FormDataType | null;
// Callback function when form data changes
onChange?: (data: Type.FormDataType) => void;
// Handler for when a form fires a `submit` event
onSubmit?: (e: React.FormEvent) => void;
/**
* Callback method for updating form configuration
* information (schema/uiSchema) in UIAction
*/
refreshConfig?: FormKit['refreshConfig'];
}
```
## Form Ref
```ts
export interface FormRef {
validator: () => Promise<boolean>;
}
```
When you need to validate a form and get the result outside the form, you can create a `FormRef` with `useRef` and pass it to the form using the `ref` property.
This allows you to validate the form and get the result outside the form using `formRef.current.validator()`.
---
## Types Definition
### JSONSchema
@ -102,24 +136,70 @@ export interface JSONSchema {
}
```
### UIOptions
### UISchema
```ts
export interface UIOptions {
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
```
### UIWidget
```ts
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
```
---
### UIOptions
```ts
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
```
#### BaseUIOptions
```ts
export interface BaseUIOptions {
empty?: string;
className?: string | string[];
// Will be appended to the className of the form component itself
className?: classnames.Argument;
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
```
### InputOptions
#### InputOptions
```ts
export interface InputOptions extends UIOptions {
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
type?:
inputType?:
| 'color'
| 'date'
| 'datetime-local'
@ -136,82 +216,88 @@ export interface InputOptions extends UIOptions {
| 'week';
}
```
### SelectOptions
#### SelectOptions
```ts
export interface SelectOptions extends UIOptions {}
```
### UploadOptions
#### UploadOptions
```ts
export interface UploadOptions extends UIOptions {
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: 'post' | 'avatar' | 'branding';
imageType?: Type.UploadType;
}
```
### SwitchOptions
#### SwitchOptions
```ts
export interface SwitchOptions extends UIOptions {}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
```
### TimezoneOptions
#### TimezoneOptions
```ts
export interface TimezoneOptions extends UIOptions {
placeholder?: string;
}
```
### CheckboxOptions
#### CheckboxOptions
```ts
export interface CheckboxOptions extends UIOptions {}
```
### RadioOptions
#### RadioOptions
```ts
export interface RadioOptions extends UIOptions {}
```
### TextareaOptions
#### TextareaOptions
```ts
export interface TextareaOptions extends UIOptions {
placeholder?: string;
rows?: number;
}
```
### UIWidget
#### ButtonOptions
```ts
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
```
### UISchema
#### UIAction
```ts
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?:
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions;
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
loading?: {
text: string;
state?: 'none' | 'pending' | 'completed';
};
on_complete?: {
toast_return_message?: boolean;
refresh_form_config?: boolean;
};
}
```
#### FormKit
```ts
export interface FormKit {
refreshConfig(): void;
}
```
---
### FormData
```ts
export interface FormValue<T = any> {
@ -226,6 +312,44 @@ export interface FormDataType {
}
```
---
## Backend API
For backend generating modal form you can return json like this.
### Response
```json
{
"name": "string",
"slug_name": "string",
"description": "string",
"version": "string",
"config_fields": [
{
"name": "string",
"type": "textarea",
"title": "string",
"description": "string",
"required": true,
"value": "string",
"ui_options": {
"placeholder": "placeholder",
"rows": 4
},
"options": [
{
"value": "string",
"label": "string"
}
]
}
]
}
```
## reference
- [json schema](https://json-schema.org/understanding-json-schema/index.html)

View File

@ -12,7 +12,13 @@ import classnames from 'classnames';
import type * as Type from '@/common/interface';
import type { JSONSchema, UISchema, BaseUIOptions, FormKit } from './types';
import type {
JSONSchema,
FormProps,
FormRef,
BaseUIOptions,
FormKit,
} from './types';
import {
Legend,
Select,
@ -27,20 +33,6 @@ import {
export * from './types';
interface IProps {
schema: JSONSchema | null;
formData: Type.FormDataType | null;
uiSchema?: UISchema;
refreshConfig?: FormKit['refreshConfig'];
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit?: (e: React.FormEvent) => void;
}
interface IRef {
validator: () => Promise<boolean>;
}
/**
* TODO:
* - [!] Standardised `Admin/Plugins/Config/index.tsx` method for generating dynamic form configurations.
@ -60,7 +52,7 @@ interface IRef {
* @param onChange change event
* @param onSubmit submit event
*/
const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
const SchemaForm: ForwardRefRenderFunction<FormRef, FormProps> = (
{
schema,
uiSchema = {},

View File

@ -1,9 +1,24 @@
import { ButtonProps } from 'react-bootstrap';
import React from 'react';
import classnames from 'classnames';
import * as Type from '@/common/interface';
export interface FormProps {
schema: JSONSchema | null;
uiSchema?: UISchema;
formData: Type.FormDataType | null;
refreshConfig?: FormKit['refreshConfig'];
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit?: (e: React.FormEvent) => void;
}
export interface FormRef {
validator: () => Promise<boolean>;
}
export interface JSONSchema {
title: string;
description?: string;

View File

@ -41,6 +41,7 @@ import HttpErrorContent from './HttpErrorContent';
import CustomSidebar from './CustomSidebar';
import ImgViewer from './ImgViewer';
import SideNav from './SideNav';
import PluginRender from './PluginRender';
export {
Avatar,
@ -88,5 +89,6 @@ export {
CustomSidebar,
ImgViewer,
SideNav,
PluginRender,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -4,22 +4,42 @@ import i18next from 'i18next';
import en_US from '@i18n/en_US.yaml';
import zh_CN from '@i18n/zh_CN.yaml';
import { DEFAULT_LANG } from '@/common/constants';
import { DEFAULT_LANG, LANG_RESOURCE_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
/**
* Prevent i18n from re-initialising when the page is refreshed and switching to `fallbackLng`.
*/
const initLng = i18next.resolvedLanguage || DEFAULT_LANG;
const initResources = {
en_US: {
translation: en_US.ui,
},
zh_CN: {
translation: zh_CN.ui,
},
};
const storageLang = Storage.get(LANG_RESOURCE_STORAGE_KEY);
if (
storageLang &&
storageLang.resources &&
storageLang.lng &&
storageLang.lng !== 'en_US' &&
storageLang.lng !== 'zh_CN'
) {
initResources[storageLang.lng] = {
translation: storageLang.resources,
};
}
i18next
// pass the i18n instance to react-i18next.
.use(initReactI18next)
.init({
resources: {
en_US: {
translation: en_US.ui,
},
zh_CN: {
translation: zh_CN.ui,
},
},
// debug: process.env.NODE_ENV === 'development',
fallbackLng: process.env.REACT_APP_LANG || DEFAULT_LANG,
resources: initResources,
lng: initLng,
fallbackLng: DEFAULT_LANG,
interpolation: {
escapeValue: false,
},

View File

@ -13,7 +13,7 @@ import { getLoginConf, checkLoginResult } from './service';
let checkTimer: NodeJS.Timeout;
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins' });
const { t } = useTranslation('translation', { keyPrefix: 'user_center' });
const navigate = useNavigate();
const ucAgent = userCenterStore().agent;
const agentName = ucAgent?.agent_info?.name || '';

View File

@ -28,7 +28,7 @@ const data = [
];
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins' });
const { t } = useTranslation('translation', { keyPrefix: 'user_center' });
const ucAgent = userCenterStore().agent;
return (
<Col lg={4} className="mx-auto mt-3 py-5">

View File

@ -9,8 +9,7 @@ import type {
ImgCodeRes,
FormDataType,
} from '@/common/interface';
import { Unactivate, WelcomeTitle } from '@/components';
import { PluginOauth, PluginUcLogin } from '@/plugins';
import { Unactivate, WelcomeTitle, PluginRender } from '@/components';
import {
loggedUserInfoStore,
loginSettingStore,
@ -172,15 +171,16 @@ const Index: React.FC = () => {
usePageTags({
title: t('login', { keyPrefix: 'page_title' }),
});
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<WelcomeTitle />
{step === 1 ? (
<Col className="mx-auto" md={6} lg={4} xl={3}>
{ucAgentInfo ? (
<PluginUcLogin className="mb-5" />
<PluginRender slug_name="uc_login" className="mb-5" />
) : (
<PluginOauth className="mb-5" />
<PluginRender type="Connector" className="mb-5" />
)}
{canOriginalLogin ? (
<>

View File

@ -3,8 +3,7 @@ import { Container, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { Unactivate, WelcomeTitle } from '@/components';
import { PluginOauth } from '@/plugins';
import { Unactivate, WelcomeTitle, PluginRender } from '@/components';
import { guard } from '@/utils';
import SignUpForm from './components/SignUpForm';
@ -27,7 +26,7 @@ const Index: React.FC = () => {
{showForm ? (
<Col className="mx-auto" md={6} lg={4} xl={3}>
<PluginOauth className="mb-5" />
<PluginRender type="Connector" className="mb-5" />
<SignUpForm callback={onStep} />
</Col>
) : (

View File

@ -18,7 +18,7 @@ const Index = () => {
});
const { t: t2 } = useTranslation('translation', {
keyPrefix: 'plugins.oauth',
keyPrefix: 'oauth',
});
const deleteLogins = (e, item) => {

View File

@ -0,0 +1,20 @@
package demo
import "github.com/answerdev/answer/plugin"
type DemoPlugin struct {
}
func init() {
plugin.Register(&DemoPlugin{})
}
func (d DemoPlugin) Info() plugin.Info {
return plugin.Info{
Name: plugin.MakeTranslator("i18n.demo.name"),
SlugName: "demo_plugin",
Description: plugin.MakeTranslator("i18n.demo.description"),
Author: "answerdev",
Version: "0.0.1",
}
}

View File

@ -0,0 +1,4 @@
plugin:
ui_plugin_demo:
ui:
msg: UI Plugin Demo

View File

@ -0,0 +1,9 @@
import pluginKit from '@/utils/pluginKit';
import en_US from './en_US.yaml';
import zh_CN from './zh_CN.yaml';
pluginKit.initI18nResource({
en_US,
zh_CN,
});

View File

@ -0,0 +1,4 @@
plugin:
ui_plugin_demo:
ui:
msg: UI 插件示例

View File

@ -0,0 +1,24 @@
import { memo, FC } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert } from 'react-bootstrap';
import pluginKit, { PluginInfo } from '@/utils/pluginKit';
import './i18n';
import info from './info.yaml';
const pluginInfo: PluginInfo = {
slug_name: info.slug_name,
};
const Index: FC = () => {
const { t } = useTranslation(pluginKit.getTransNs(), {
keyPrefix: pluginKit.getTransKeyPrefix(pluginInfo),
});
return <Alert variant="info">{t('msg')}</Alert>;
};
export default {
info: pluginInfo,
component: memo(Index),
};

View File

@ -0,0 +1,4 @@
slug_name: ui_plugin_demo
version: 0.0.1
author: Answer.dev

View File

@ -0,0 +1,5 @@
plugin:
connector:
ui:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}

View File

@ -0,0 +1,9 @@
import pluginKit from '@/utils/pluginKit';
import en_US from './en_US.yaml';
import zh_CN from './zh_CN.yaml';
pluginKit.initI18nResource({
en_US,
zh_CN,
});

View File

@ -0,0 +1,6 @@
plugin:
connector:
ui:
connect: 连接到 {{ auth_name }}
remove: 解绑 {{ auth_name }}

View File

@ -4,14 +4,25 @@ import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import { useGetStartUseOauthConnector } from '@/services';
import pluginKit, { PluginInfo } from '@/utils/pluginKit';
import { SvgIcon } from '@/components';
import info from './info.yaml';
import { useGetStartUseOauthConnector } from './services';
import './i18n';
const pluginInfo: PluginInfo = {
slug_name: info.slug_name,
type: info.type,
};
interface Props {
className?: string;
}
const Index: FC<Props> = ({ className }) => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins.oauth' });
const { t } = useTranslation(pluginKit.getTransNs(), {
keyPrefix: pluginKit.getTransKeyPrefix(pluginInfo),
});
const { data } = useGetStartUseOauthConnector();
if (!data?.length) return null;
@ -29,4 +40,7 @@ const Index: FC<Props> = ({ className }) => {
);
};
export default memo(Index);
export default {
info: pluginInfo,
component: memo(Index),
};

View File

@ -0,0 +1,6 @@
slug_name: connector
type: Connector
version: 0.0.1
link: https://github.com/answerdev/plugins/tree/main/connector/
author: Answer.dev

View File

@ -0,0 +1,21 @@
import useSWR from 'swr';
import request from '@/utils/request';
export interface OauthConnectorItem {
icon: string;
name: string;
link: string;
}
export const useGetStartUseOauthConnector = () => {
const { data, error } = useSWR<OauthConnectorItem[]>(
'/answer/api/v1/connector/info',
request.instance.get,
);
return {
data,
error,
};
};

View File

@ -0,0 +1,6 @@
plugin:
uc_login:
ui:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.

View File

@ -0,0 +1,9 @@
import pluginKit from '@/utils/pluginKit';
import en_US from './en_US.yaml';
import zh_CN from './zh_CN.yaml';
pluginKit.initI18nResource({
en_US,
zh_CN,
});

View File

@ -0,0 +1,6 @@
plugin:
uc_login:
ui:
login: 登录
qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码登录
login_failed_email_tip: 登录失败, 请允许该应用程序访问您的电子邮件信息,然后再试一次。

View File

@ -4,14 +4,25 @@ import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import pluginKit, { PluginInfo } from '@/utils/pluginKit';
import { SvgIcon } from '@/components';
import { userCenterStore } from '@/stores';
import './i18n';
import info from './info.yaml';
interface Props {
className?: classnames.Argument;
}
const pluginInfo: PluginInfo = {
slug_name: info.slug_name,
};
const Index: FC<Props> = ({ className }) => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins.oauth' });
const { t } = useTranslation(pluginKit.getTransNs(), {
keyPrefix: pluginKit.getTransKeyPrefix(pluginInfo),
});
const ucAgent = userCenterStore().agent;
const ucLoginRedirect =
ucAgent?.enabled && ucAgent?.agent_info?.login_redirect_url;
@ -31,5 +42,7 @@ const Index: FC<Props> = ({ className }) => {
}
return null;
};
export default memo(Index);
export default {
info: pluginInfo,
component: memo(Index),
};

View File

@ -0,0 +1,4 @@
slug_name: uc_login
version: 0.0.1
author: Answer.dev

View File

@ -0,0 +1,7 @@
import Connector from './Connector';
import UcLogin from './UcLogin';
export default {
Connector,
UcLogin,
};

View File

@ -1,4 +1,3 @@
import PluginOauth from './PluginOauth';
import PluginUcLogin from './PluginUcLogin';
export default null;
export { PluginOauth, PluginUcLogin };
// export { default as Demo } from './Demo';

View File

@ -1,23 +0,0 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}

View File

@ -2,7 +2,29 @@ import qs from 'qs';
import useSWR from 'swr';
import request from '@/utils/request';
import type { PluginConfig } from '@/plugins/types';
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}
export const useQueryPlugins = (params) => {
const apiUrl = `/answer/admin/api/plugins?${qs.stringify(params)}`;

View File

@ -23,14 +23,3 @@ export const useOauthConnectorInfoByUser = () => {
export const userOauthUnbind = (data: { external_id: string }) => {
return request.delete('/answer/api/v1/connector/user/unbinding', data);
};
export const useGetStartUseOauthConnector = () => {
const { data, error } = useSWR<Type.OauthConnectorItem[]>(
'/answer/api/v1/connector/info',
request.instance.get,
);
return {
data,
error,
};
};

View File

@ -17,6 +17,9 @@ import {
import Storage from './storage';
/**
* localize kit for i18n
*/
export const loadLanguageOptions = async (forAdmin = false) => {
const languageOptions = forAdmin
? await getAdminLanguageOptions()
@ -68,13 +71,6 @@ const addI18nResource = async (langName) => {
}
};
dayjs.extend(utc);
dayjs.extend(timezone);
const localeDayjs = (langName) => {
langName = langName.replace('_', '-').toLowerCase();
dayjs.locale(langName);
};
export const getCurrentLang = () => {
const loggedUser = loggedUserInfoStore.getState().user;
const adminInterface = interfaceStore.getState().interface;
@ -88,6 +84,16 @@ export const getCurrentLang = () => {
return currentLang;
};
/**
* localize for Day.js
*/
dayjs.extend(utc);
dayjs.extend(timezone);
const localeDayjs = (langName) => {
langName = langName.replace('_', '-').toLowerCase();
dayjs.locale(langName);
};
export const setupAppLanguage = async () => {
const lang = getCurrentLang();
if (!i18next.getDataByLanguage(lang)) {

78
ui/src/utils/pluginKit.ts Normal file
View File

@ -0,0 +1,78 @@
import { NamedExoticComponent, FC } from 'react';
import i18next from 'i18next';
/**
* This information is to be defined for all components.
* It may be used for feature upgrades or version compatibility processing.
*
* @field slug_name: Unique identity string for the plugin, usually configured in `info.yaml`
* @field type: The type of plugin is defined and a single type of plugin can have multiple implementations.
* For example, a plugin of type `Connector` can have a `google` implementation and a `github` implementation.
* `PluginRender` automatically renders the plug-in types already included in `PluginType`.
* @field name: Plugin name, optionally configurable. Usually read from the `i18n` file
* @field description: Plugin description, optionally configurable. Usually read from the `i18n` file
*/
const I18N_NS = 'plugin';
export type PluginType = 'Connector';
export interface PluginInfo {
slug_name: string;
type?: PluginType;
name?: string;
description?: string;
}
export interface Plugin {
info: PluginInfo;
component: NamedExoticComponent | FC;
}
interface I18nResource {
[lng: string]: {
plugin: {
[slug_name: string]: {
ui: any;
};
};
};
}
const addResourceBundle = (resource: I18nResource) => {
if (resource) {
Object.keys(resource).forEach((lng) => {
const r = resource[lng];
i18next.addResourceBundle(lng, I18N_NS, r.plugin, true, true);
});
}
};
const initI18nResource = (resource: I18nResource) => {
addResourceBundle(resource);
/**
* Note: In development mode,
* when the base i18n file is changed, `i18next` will reinitialise the updated resource file,
* which will cause the resource package added in the plugin to be lost
* and will need to be automatically re-added by listening for events
*/
i18next.on('initialized', () => {
addResourceBundle(resource);
});
};
const getTransNs = () => {
return I18N_NS;
};
const getTransKeyPrefix = (info: PluginInfo) => {
const kp = `${info.slug_name}.ui`;
return kp;
};
export default {
initI18nResource,
getTransNs,
getTransKeyPrefix,
};