diff --git a/Dockerfile b/Dockerfile index c70786aa..66e0326a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,3 @@ -# FROM amd64/node AS node-builder -FROM amd64/node:18 AS node-builder - -LABEL maintainer="mingcheng" - -COPY . /answer -WORKDIR /answer -RUN node -v -RUN make install-ui-packages ui && mv ui/build /tmp - -# stage2 build the main binary within static resource FROM golang:1.19-alpine AS golang-builder LABEL maintainer="aichy@sf.com" @@ -28,9 +17,9 @@ ARG CGO_EXTRA_CFLAGS COPY . ${BUILD_DIR} WORKDIR ${BUILD_DIR} -COPY --from=node-builder /tmp/build ${BUILD_DIR}/ui/build -RUN apk --no-cache add build-base git bash \ - && make clean build +RUN apk --no-cache add build-base git bash nodejs npm && npm install -g pnpm corepack \ + && make install-ui-packages clean build + RUN chmod 755 answer RUN ["/bin/bash","-c","script/build_plugin.sh"] RUN cp answer /usr/bin/answer @@ -38,7 +27,6 @@ RUN cp answer /usr/bin/answer RUN mkdir -p /data/uploads && chmod 777 /data/uploads \ && mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n -# stage3 copy the binary and resource files into fresh container FROM alpine LABEL maintainer="maintainers@sf.com" diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 09aa2e3b..dde026a6 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -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: diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 71a62ba2..356ae243 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -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: 后台管理 diff --git a/internal/cli/build.go b/internal/cli/build.go index 33826d24..21e661e1 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -2,7 +2,6 @@ package cli import ( "bytes" - "embed" "fmt" "io" "io/fs" @@ -103,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 @@ -122,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) @@ -171,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 { @@ -193,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") @@ -200,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") @@ -287,7 +366,7 @@ func mergeI18nFiles(b *buildingMaterial) (err error) { return err } -func copyDirEntries(sourceFs embed.FS, sourceDir string, targetDir string) (err error) { +func copyDirEntries(sourceFs fs.FS, sourceDir string, targetDir string) (err error) { err = dir.CreateDirIfNotExist(targetDir) if err != nil { return err @@ -308,29 +387,29 @@ func copyDirEntries(sourceFs embed.FS, sourceDir string, targetDir string) (err if d.IsDir() { // Create the directory in the destination - err := os.MkdirAll(dstPath, d.Type()) + 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 err + 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 err + 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 err + return fmt.Errorf("failed to copy file contents from %s to %s: %w", srcPath, dstPath, err) } } @@ -340,6 +419,7 @@ func copyDirEntries(sourceFs embed.FS, sourceDir string, targetDir string) (err return err } +// buildBinary build binary file func buildBinary(b *buildingMaterial) (err error) { versionInfo := b.originalAnswerInfo cmdPkg := "github.com/answerdev/answer/cmd" @@ -353,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) } diff --git a/script/build_plugin.sh b/script/build_plugin.sh index 89d03cdf..bc5116e1 100755 --- a/script/build_plugin.sh +++ b/script/build_plugin.sh @@ -19,7 +19,7 @@ echo "cmd is "$cmd $cmd if [ ! -f "./new_answer" ]; then echo "new_answer is not exist build failed" - exit 0 + exit 1 fi rm answer mv new_answer answer diff --git a/ui/.gitignore b/ui/.gitignore index f5128c7c..9bef4b59 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -31,3 +31,8 @@ yarn.lock package-lock.json .eslintcache /.vscode/ + +/* !/src/plugins +/src/plugins/* +!/src/plugins/builtin +!/src/plugins/Demo diff --git a/ui/package.json b/ui/package.json index 52523768..f6d869d7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" }, diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index ac35596f..151a8c39 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -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; } diff --git a/ui/src/components/PluginRender/index.tsx b/ui/src/components/PluginRender/index.tsx new file mode 100644 index 00000000..3ed8a20d --- /dev/null +++ b/ui/src/components/PluginRender/index.tsx @@ -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'; + +/** + * Note:Please 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 = ({ 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 + + {children} + + ); + })} + + ); +}; + +export default memo(Index); diff --git a/ui/src/components/SchemaForm/README.md b/ui/src/components/SchemaForm/README.md index 74fb514e..c5e42e04 100644 --- a/ui/src/components/SchemaForm/README.md +++ b/ui/src/components/SchemaForm/README.md @@ -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; + }>(null); + + const refreshConfig: FormKit['refreshConfig'] = async () => { + // refreshFormConfig(); + }; const handleChange = (data) => { setFormData(data); @@ -58,27 +63,56 @@ const Form = () => { return ( ); }; 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; + } +``` + +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 | 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 { @@ -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) diff --git a/ui/src/components/SchemaForm/index.tsx b/ui/src/components/SchemaForm/index.tsx index 2fec88e0..5810590f 100644 --- a/ui/src/components/SchemaForm/index.tsx +++ b/ui/src/components/SchemaForm/index.tsx @@ -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; -} - /** * 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 = ( +const SchemaForm: ForwardRefRenderFunction = ( { schema, uiSchema = {}, diff --git a/ui/src/components/SchemaForm/types.ts b/ui/src/components/SchemaForm/types.ts index a6eed6ef..d7965ca7 100644 --- a/ui/src/components/SchemaForm/types.ts +++ b/ui/src/components/SchemaForm/types.ts @@ -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; +} + export interface JSONSchema { title: string; description?: string; diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index dfe906c5..8639d4d6 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -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 }; diff --git a/ui/src/i18n/init.ts b/ui/src/i18n/init.ts index ad88c161..7f95398b 100644 --- a/ui/src/i18n/init.ts +++ b/ui/src/i18n/init.ts @@ -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, }, diff --git a/ui/src/pages/UserCenter/Auth/components/WeCom/index.tsx b/ui/src/pages/UserCenter/Auth/components/WeCom/index.tsx index 70e4a018..07646953 100644 --- a/ui/src/pages/UserCenter/Auth/components/WeCom/index.tsx +++ b/ui/src/pages/UserCenter/Auth/components/WeCom/index.tsx @@ -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 || ''; diff --git a/ui/src/pages/UserCenter/AuthFailed/components/WeCom.tsx b/ui/src/pages/UserCenter/AuthFailed/components/WeCom.tsx index b69561b4..bea92758 100644 --- a/ui/src/pages/UserCenter/AuthFailed/components/WeCom.tsx +++ b/ui/src/pages/UserCenter/AuthFailed/components/WeCom.tsx @@ -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 ( diff --git a/ui/src/pages/Users/Login/index.tsx b/ui/src/pages/Users/Login/index.tsx index 84379676..a6bedd59 100644 --- a/ui/src/pages/Users/Login/index.tsx +++ b/ui/src/pages/Users/Login/index.tsx @@ -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 ( {step === 1 ? ( {ucAgentInfo ? ( - + ) : ( - + )} {canOriginalLogin ? ( <> diff --git a/ui/src/pages/Users/Register/index.tsx b/ui/src/pages/Users/Register/index.tsx index d102d71a..efc10351 100644 --- a/ui/src/pages/Users/Register/index.tsx +++ b/ui/src/pages/Users/Register/index.tsx @@ -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 ? ( - + ) : ( diff --git a/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx b/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx index 816ee0bb..3b44354f 100644 --- a/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx +++ b/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx @@ -18,7 +18,7 @@ const Index = () => { }); const { t: t2 } = useTranslation('translation', { - keyPrefix: 'plugins.oauth', + keyPrefix: 'oauth', }); const deleteLogins = (e, item) => { diff --git a/ui/src/plugins/Demo/demo.go b/ui/src/plugins/Demo/demo.go new file mode 100644 index 00000000..2f1795f1 --- /dev/null +++ b/ui/src/plugins/Demo/demo.go @@ -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", + } +} diff --git a/ui/src/plugins/Demo/i18n/en_US.yaml b/ui/src/plugins/Demo/i18n/en_US.yaml new file mode 100644 index 00000000..b4ee4286 --- /dev/null +++ b/ui/src/plugins/Demo/i18n/en_US.yaml @@ -0,0 +1,4 @@ +plugin: + ui_plugin_demo: + ui: + msg: UI Plugin Demo diff --git a/ui/src/plugins/Demo/i18n/index.ts b/ui/src/plugins/Demo/i18n/index.ts new file mode 100644 index 00000000..b693440a --- /dev/null +++ b/ui/src/plugins/Demo/i18n/index.ts @@ -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, +}); diff --git a/ui/src/plugins/Demo/i18n/zh_CN.yaml b/ui/src/plugins/Demo/i18n/zh_CN.yaml new file mode 100644 index 00000000..099e3bd9 --- /dev/null +++ b/ui/src/plugins/Demo/i18n/zh_CN.yaml @@ -0,0 +1,4 @@ +plugin: + ui_plugin_demo: + ui: + msg: UI 插件示例 diff --git a/ui/src/plugins/Demo/index.tsx b/ui/src/plugins/Demo/index.tsx new file mode 100644 index 00000000..5d6b57f1 --- /dev/null +++ b/ui/src/plugins/Demo/index.tsx @@ -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 {t('msg')}; +}; +export default { + info: pluginInfo, + component: memo(Index), +}; diff --git a/ui/src/plugins/Demo/info.yaml b/ui/src/plugins/Demo/info.yaml new file mode 100644 index 00000000..17d31a9e --- /dev/null +++ b/ui/src/plugins/Demo/info.yaml @@ -0,0 +1,4 @@ +slug_name: ui_plugin_demo +version: 0.0.1 +author: Answer.dev + diff --git a/ui/src/plugins/builtin/Connector/i18n/en_US.yaml b/ui/src/plugins/builtin/Connector/i18n/en_US.yaml new file mode 100644 index 00000000..fe679444 --- /dev/null +++ b/ui/src/plugins/builtin/Connector/i18n/en_US.yaml @@ -0,0 +1,5 @@ +plugin: + connector: + ui: + connect: Connect with {{ auth_name }} + remove: Remove {{ auth_name }} diff --git a/ui/src/plugins/builtin/Connector/i18n/index.ts b/ui/src/plugins/builtin/Connector/i18n/index.ts new file mode 100644 index 00000000..b693440a --- /dev/null +++ b/ui/src/plugins/builtin/Connector/i18n/index.ts @@ -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, +}); diff --git a/ui/src/plugins/builtin/Connector/i18n/zh_CN.yaml b/ui/src/plugins/builtin/Connector/i18n/zh_CN.yaml new file mode 100644 index 00000000..aede9204 --- /dev/null +++ b/ui/src/plugins/builtin/Connector/i18n/zh_CN.yaml @@ -0,0 +1,6 @@ +plugin: + connector: + ui: + connect: 连接到 {{ auth_name }} + remove: 解绑 {{ auth_name }} + diff --git a/ui/src/plugins/PluginOauth/index.tsx b/ui/src/plugins/builtin/Connector/index.tsx similarity index 62% rename from ui/src/plugins/PluginOauth/index.tsx rename to ui/src/plugins/builtin/Connector/index.tsx index 40a28785..c5b39431 100644 --- a/ui/src/plugins/PluginOauth/index.tsx +++ b/ui/src/plugins/builtin/Connector/index.tsx @@ -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 = ({ 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 = ({ className }) => { ); }; -export default memo(Index); +export default { + info: pluginInfo, + component: memo(Index), +}; diff --git a/ui/src/plugins/builtin/Connector/info.yaml b/ui/src/plugins/builtin/Connector/info.yaml new file mode 100644 index 00000000..7155daec --- /dev/null +++ b/ui/src/plugins/builtin/Connector/info.yaml @@ -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 + diff --git a/ui/src/plugins/builtin/Connector/services.ts b/ui/src/plugins/builtin/Connector/services.ts new file mode 100644 index 00000000..a1ca232d --- /dev/null +++ b/ui/src/plugins/builtin/Connector/services.ts @@ -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( + '/answer/api/v1/connector/info', + request.instance.get, + ); + + return { + data, + error, + }; +}; diff --git a/ui/src/plugins/builtin/UcLogin/i18n/en_US.yaml b/ui/src/plugins/builtin/UcLogin/i18n/en_US.yaml new file mode 100644 index 00000000..b61e3d64 --- /dev/null +++ b/ui/src/plugins/builtin/UcLogin/i18n/en_US.yaml @@ -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. diff --git a/ui/src/plugins/builtin/UcLogin/i18n/index.ts b/ui/src/plugins/builtin/UcLogin/i18n/index.ts new file mode 100644 index 00000000..b693440a --- /dev/null +++ b/ui/src/plugins/builtin/UcLogin/i18n/index.ts @@ -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, +}); diff --git a/ui/src/plugins/builtin/UcLogin/i18n/zh_CN.yaml b/ui/src/plugins/builtin/UcLogin/i18n/zh_CN.yaml new file mode 100644 index 00000000..d8956ef3 --- /dev/null +++ b/ui/src/plugins/builtin/UcLogin/i18n/zh_CN.yaml @@ -0,0 +1,6 @@ +plugin: + uc_login: + ui: + login: 登录 + qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码登录 + login_failed_email_tip: 登录失败, 请允许该应用程序访问您的电子邮件信息,然后再试一次。 diff --git a/ui/src/plugins/PluginUcLogin/index.tsx b/ui/src/plugins/builtin/UcLogin/index.tsx similarity index 71% rename from ui/src/plugins/PluginUcLogin/index.tsx rename to ui/src/plugins/builtin/UcLogin/index.tsx index b6dceb24..4e2f8c66 100644 --- a/ui/src/plugins/PluginUcLogin/index.tsx +++ b/ui/src/plugins/builtin/UcLogin/index.tsx @@ -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 = ({ 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 = ({ className }) => { } return null; }; - -export default memo(Index); +export default { + info: pluginInfo, + component: memo(Index), +}; diff --git a/ui/src/plugins/builtin/UcLogin/info.yaml b/ui/src/plugins/builtin/UcLogin/info.yaml new file mode 100644 index 00000000..d181d870 --- /dev/null +++ b/ui/src/plugins/builtin/UcLogin/info.yaml @@ -0,0 +1,4 @@ +slug_name: uc_login +version: 0.0.1 +author: Answer.dev + diff --git a/ui/src/plugins/builtin/index.ts b/ui/src/plugins/builtin/index.ts new file mode 100644 index 00000000..95c102e1 --- /dev/null +++ b/ui/src/plugins/builtin/index.ts @@ -0,0 +1,7 @@ +import Connector from './Connector'; +import UcLogin from './UcLogin'; + +export default { + Connector, + UcLogin, +}; diff --git a/ui/src/plugins/index.ts b/ui/src/plugins/index.ts index 09d25445..bb6104ab 100644 --- a/ui/src/plugins/index.ts +++ b/ui/src/plugins/index.ts @@ -1,4 +1,3 @@ -import PluginOauth from './PluginOauth'; -import PluginUcLogin from './PluginUcLogin'; +export default null; -export { PluginOauth, PluginUcLogin }; +// export { default as Demo } from './Demo'; diff --git a/ui/src/plugins/types.ts b/ui/src/plugins/types.ts deleted file mode 100644 index faca6ad0..00000000 --- a/ui/src/plugins/types.ts +++ /dev/null @@ -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[]; -} diff --git a/ui/src/services/admin/plugins.ts b/ui/src/services/admin/plugins.ts index e58acb02..178fd1af 100644 --- a/ui/src/services/admin/plugins.ts +++ b/ui/src/services/admin/plugins.ts @@ -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)}`; diff --git a/ui/src/services/client/Oauth.ts b/ui/src/services/client/Oauth.ts index b02e88c8..7474757f 100644 --- a/ui/src/services/client/Oauth.ts +++ b/ui/src/services/client/Oauth.ts @@ -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( - '/answer/api/v1/connector/info', - request.instance.get, - ); - return { - data, - error, - }; -}; diff --git a/ui/src/utils/localize.ts b/ui/src/utils/localize.ts index 775896e8..ae5fe479 100644 --- a/ui/src/utils/localize.ts +++ b/ui/src/utils/localize.ts @@ -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)) { diff --git a/ui/src/utils/pluginKit.ts b/ui/src/utils/pluginKit.ts new file mode 100644 index 00000000..3b88b582 --- /dev/null +++ b/ui/src/utils/pluginKit.ts @@ -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, +};