docs: add docs (#75)

* docs: add docs

* ignore dumi theme test

* fix: error TS2717: Subsequent property declarations must have the same type.

* update docs

* deploy gh-pages

* plugins docs

* hash & cname

* exportStatic

* ssr

* vercel

* vercel

* fix: deploy vercel

* Delete vercel.json

* docs

* fix APP_DIST

* on master branch
This commit is contained in:
chenos 2021-04-17 21:33:21 +08:00 committed by GitHub
parent f6dcfae28c
commit d5d0e1036b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 20219 additions and 5557 deletions

57
.dumi/theme/builtins/API.tsx Executable file
View File

@ -0,0 +1,57 @@
import React, { useContext } from 'react';
import type { IApiComponentProps} from 'dumi/theme';
import { context, useApiData, AnchorLink } from 'dumi/theme';
const LOCALE_TEXTS = {
'zh-CN': {
name: '属性名',
description: '描述',
type: '类型',
default: '默认值',
required: '(必选)',
},
'en-US': {
name: 'Name',
description: 'Description',
type: 'Type',
default: 'Default',
required: '(required)',
},
};
export default ({ identifier, export: expt }: IApiComponentProps) => {
const data = useApiData(identifier);
const { locale } = useContext(context);
const texts = /^zh|cn$/i.test(locale) ? LOCALE_TEXTS['zh-CN'] : LOCALE_TEXTS['en-US'];
return (
<>
{data && (
<table style={{ marginTop: 24 }}>
<thead>
<tr>
<th>{texts.name}</th>
<th>{texts.description}</th>
<th>{texts.type}</th>
<th>{texts.default}</th>
</tr>
</thead>
<tbody>
{data[expt].map(row => (
<tr key={row.identifier}>
<td>{row.identifier}</td>
<td>{row.description || '--'}</td>
<td>
<code>{row.type}</code>
</td>
<td>
<code>{row.default || (row.required && texts.required) || '--'}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
};

68
.dumi/theme/builtins/Alert.less Executable file
View File

@ -0,0 +1,68 @@
@import (reference) '../style/variables.less';
.@{prefix}-alert {
@s-border-right: 3px;
position: relative;
margin: 24px 0;
padding: 15px;
color: @c-text;
font-size: 14px;
line-height: 20px;
border-left: 0;
background: #ffffff;
// box-shadow: 0 6px 16px -2px rgba(0, 0, 0, 0.06);
border-radius: 1px;
&::after {
content: '';
position: absolute;
display: inline-block;
top: 0;
left: 0;
bottom: 0;
width: @s-border-right;
border-radius: 1px;
}
&:first-child {
margin-top: 0;
}
&:not([type]),
&[type='warning'] {
background-color: #fffbe6;
&::after {
background: #ffe58f;
}
}
&[type='info'] {
background: rgba(105, 185, 255, .15);
&::after {
background: #69b9ff;
}
.@{prefix}-alert-title {
color: #69b9ff;
}
}
&[type='success'] {
&::after {
background: #8cd225;
}
}
&[type='error'] {
&::after {
background: #ff4646;
}
}
.@{prefix}-alert-title {
margin-top: 0;
font-weight: 600;
color: #fab00e;
margin-bottom: .6em;
}
}

17
.dumi/theme/builtins/Alert.tsx Executable file
View File

@ -0,0 +1,17 @@
import React from 'react';
import './Alert.less';
import { Alert } from 'antd';
import micromark from 'micromark'
export default (props: any) => {
const { title, children, ...others } = props;
console.log({children});
return (
<div className="__dumi-default-alert" {...others}>
{title && (
<p className={'__dumi-default-alert-title'}>{title}</p>
)}
{typeof children === 'string' ? <div dangerouslySetInnerHTML={{__html: micromark(`\n ${children} \n`)}}/> : children}
</div>
)
};

31
.dumi/theme/builtins/Badge.less Executable file
View File

@ -0,0 +1,31 @@
@import (reference) '../style/variables.less';
.@{prefix}-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
color: #fff;
font-size: 13px;
line-height: 20px;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.1);
box-shadow: 0 6px 16px -2px rgba(0, 0, 0, 0.06);
border-radius: 1px;
vertical-align: top;
&:not([type]),
&[type='info'] {
background: #4569d4;
}
&[type='warning'] {
background: #ffc121;
}
&[type='success'] {
background: #8cd225;
}
&[type='error'] {
background: #ff4646;
}
}

4
.dumi/theme/builtins/Badge.tsx Executable file
View File

@ -0,0 +1,4 @@
import React from 'react';
import './Badge.less';
export default (props: any) => <span className="__dumi-default-badge" {...props} />;

View File

@ -0,0 +1,47 @@
@import (reference) '../style/variables.less';
.@{prefix}-example-wrapper {
padding-top: 16px;
height: calc(100vh - @s-nav-height - 2px);
@media @mobile {
padding-top: 16px;
}
&-toolbar {
display: flex;
color: @c-heading;
font-size: 16px;
font-weight: 500;
justify-content: space-between;
margin-bottom: 16px;
border-bottom: 1px solid @c-border;
button,
a {
display: inline-block;
margin: 6px 0 6px 12px;
width: 16px;
height: 16px;
font-weight: 400;
border: 0;
outline: none;
vertical-align: middle;
cursor: pointer;
&:first-child {
background-position-x: -144px;
}
&:nth-child(2) {
background-position-x: -126px;
}
}
}
iframe {
width: 100%;
min-height: 100%;
border: 0;
}
}

View File

@ -0,0 +1,34 @@
import React, { useRef, useEffect, useState } from 'react';
import './Example.less';
export default (props: { route: any }) => {
const elm = useRef<HTMLIFrameElement>();
const [height, setHeight] = useState(0);
useEffect(() => {
setHeight(elm.current.contentWindow.document.documentElement.scrollHeight);
}, [elm]);
return (
props.route.meta.examplePath && (
<div className="__dumi-default-example-wrapper">
<div className="__dumi-default-example-wrapper-toolbar">
{props.route.meta.description || props.route.meta.title}
<span>
<button
className="__dumi-default-icon"
onClick={() => elm.current.contentWindow.location.reload()}
/>
<a
target="_blank"
rel="noopener noreferrer"
href={props.route.meta.examplePath}
className="__dumi-default-icon"
/>
</span>
</div>
<iframe src={props.route.meta.examplePath} ref={elm} style={{ height }} title="dumi" />
</div>
)
);
};

View File

@ -0,0 +1,364 @@
@import (reference) '../style/variables.less';
.@{prefix}-previewer {
background-color: #fff;
border: 1px solid @c-border;
border-radius: 1px;
&[data-debug] {
margin-top: 32px;
border-color: #ffcb00;
&::before {
content: 'DEV ONLY';
float: left;
margin-left: -1px;
margin-top: -18px;
padding: 3px 6px;
font-size: 12px;
line-height: 1;
background-color: #ffcb00;
color: #735600;
text-shadow: 0.5px 0.5px 0 rgba(255, 255, 255, 0.5);
border-top-left-radius: 1px;
border-top-right-radius: 1px;
}
}
&[data-iframe] {
.@{prefix}-previewer-browser-nav {
padding: 2px 6px;
background-color: @c-border;
&::before {
@s-btn: 12px;
@s-btn-gap: 8px;
content: '';
display: inline-block;
width: @s-btn;
height: @s-btn;
border-radius: 50%;
background-color: #fd6458;
box-shadow: (@s-btn + @s-btn-gap) 0 0 #ffbf2b, (@s-btn + @s-btn-gap) * 2 0 0 #24cc3d;
}
}
.@{prefix}-previewer-demo > iframe {
border: 0;
width: 100%;
height: 300px;
}
}
+ .@{prefix}-previewer {
margin-top: 32px;
}
&-demo {
padding: 40px 24px;
}
&-target {
border-color: fade(@c-primary, 50%);
box-shadow: 0 0 0 5px fade(@c-primary, 5%);
}
&-desc {
> div:last-child {
padding: 1.2em 1em 1em;
color: @c-text;
border-top: 1px solid @c-border;
}
.markdown > p:first-child {
margin-top: 0;
}
.markdown > p:last-child {
margin-bottom: 0;
}
&[data-title] {
position: relative;
> a:first-child {
position: absolute;
top: 0;
left: 1em;
margin-left: -4px;
padding: 0 4px;
color: @c-heading;
font-size: inherit;
font-weight: 500;
background: linear-gradient(to top, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
transform: translateY(-50%);
pointer-events: auto;
cursor: pointer;
}
&:empty {
padding-top: 0;
// modify action area style when only has title field
+ .@{prefix}-previewer-actions {
height: 46px;
border-top-style: solid;
}
}
}
}
&-actions {
display: flex;
height: 40px;
padding: 0 1em;
align-items: center;
border-top: 1px dashed @c-border;
> a:not(:last-child),
> button:not(:last-child) {
margin-right: 8px;
}
> a {
display: flex;
}
button {
position: relative;
display: inline-block;
width: 16px;
height: 16px;
padding: 0;
border: 0;
box-sizing: border-box;
cursor: pointer;
opacity: 0.6;
outline: none;
transition: opacity 0.2s, background 0.2s;
// expand click area
&::after {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
}
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.9;
}
&:disabled {
opacity: 0.2;
cursor: not-allowed;
}
&[role='codesandbox'] {
background-position: -18px 0;
}
&[role='codepen'] {
background-position: -36px 0;
}
&[role='source'] {
background-position: -72px 0;
}
&[role='change-jsx'] {
background-position: -90px 0;
}
&[role='change-tsx'] {
background-position: -108px 0;
}
&[role='open-demo'] {
background-position: -126px 0;
}
&[role='motions'] {
background-position: -162px 0;
}
&[role='sketch-component'] {
background-position: -182px 0;
}
&[role='sketch-group'] {
background-position: -200px 0;
}
&[role='copy'][data-status='ready'] {
background-position: -54px 0;
}
&[role='copy'][data-status='copied'] {
pointer-events: none;
background-position: -54px -16px;
}
&[role='refresh'] {
background-position-x: -144px;
}
}
// split action buttons by a blank node
> span {
flex: 1;
display: inline-block;
}
}
&-source {
border-top: 1px dashed @c-border;
&-tab {
border-top: 1px dashed @c-border;
.@{prefix}-tabs-tab-btn {
position: relative;
padding-left: 32px;
&::before,
&::after {
content: '';
position: absolute;
margin-right: 4px;
display: inline-block;
}
&::before {
left: 16px;
top: 50%;
margin-top: -6px;
width: 10px;
height: 12px;
border: 1px solid @c-secondary;
}
&::after {
top: 50%;
left: 23px;
margin-top: -7px;
width: 4px;
height: 4px;
background: #fff;
border-bottom: 1px solid @c-secondary;
transform: rotate(45deg);
}
}
}
}
}
.@{prefix}-tabs {
overflow: hidden;
&.@{prefix}-tabs-top {
flex-direction: column;
.@{prefix}-tabs-ink-bar {
bottom: 0;
}
}
&-nav {
display: flex;
&-wrap {
display: flex;
white-space: nowrap;
overflow: hidden;
&&-ping-left {
box-shadow: 5px 0 5px -5px rgba(0, 0, 0, 0.1) inset;
}
&&-ping-right ~ * > .@{prefix}-tabs-nav-more {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
}
&-list {
position: relative;
display: flex;
transition: transform 0.2s;
}
&-more {
height: 100%;
cursor: pointer;
background: none;
border: 0;
transition: box-shadow 0.2s;
}
}
&-tab {
display: flex;
&-btn {
padding: 0 16px;
font-size: 14px;
line-height: 36px;
border: 0;
outline: none;
background: transparent;
box-sizing: border-box;
cursor: pointer;
&:hover {
color: @c-primary;
}
}
}
&-ink-bar {
position: absolute;
height: 2px;
background: @c-primary;
transition: left 0.2s, width 0.2s;
pointer-events: none;
}
&-dropdown {
position: absolute;
background: #fff;
border: 1px solid @c-border;
max-height: 200px;
overflow: auto;
> ul {
list-style: none;
margin: 0;
padding: 0;
> li {
padding: 4px 12px;
font-size: 14px;
cursor: pointer;
&:hover {
color: @c-primary;
}
&:not(:last-child) {
border-bottom: 1px dashed @c-border;
}
}
}
&-hidden {
display: none;
}
}
}

View File

@ -0,0 +1,253 @@
import React, { useState, useContext, useRef, useEffect } from 'react';
import Tabs, { TabPane } from 'rc-tabs';
// @ts-ignore
import { history } from 'dumi';
import type { IPreviewerComponentProps} from 'dumi/theme';
import {
context,
useCodeSandbox,
useRiddle,
useMotions,
useCopy,
useLocaleProps,
useDemoUrl,
useTSPlaygroundUrl,
Link,
AnchorLink,
usePrefersColor
} from 'dumi/theme';
import type { ICodeBlockProps } from './SourceCode';
import SourceCode from './SourceCode';
import './Previewer.less';
export interface IPreviewerProps extends IPreviewerComponentProps {
/**
* enable transform to change CSS containing block for demo
*/
transform?: boolean;
/**
* modify background for demo area
*/
background?: string;
/**
* collapse padding of demo area
*/
compact?: boolean;
/**
* configurations for action button
*/
hideActions?: ('CSB' | 'EXTERNAL' | 'RIDDLE')[];
/**
* show source code by default
*/
defaultShowCode?: boolean;
/**
* use iframe mode for this demo
*/
iframe?: true | number;
/**
* replace builtin demo url
*/
demoUrl?: string;
}
/**
* get source code type for file
* @param file file path
* @param source file source object
*/
function getSourceType(file: string, source: IPreviewerComponentProps['sources']['_']) {
// use file extension as source type first
let type = file.match(/\.(\w+)$/)?.[1];
if (!type) {
type = source.tsx ? 'tsx' : 'jsx';
}
return type as ICodeBlockProps['lang'];
}
const Previewer: React.FC<IPreviewerProps> = oProps => {
const demoRef = useRef();
const { locale } = useContext(context);
const props = useLocaleProps<IPreviewerProps>(locale, oProps);
const builtinDemoUrl = useDemoUrl(props.identifier);
const demoUrl = props.demoUrl || builtinDemoUrl;
const isActive = history?.location.hash === `#${props.identifier}`;
const isSingleFile = Object.keys(props.sources).length === 1;
const openCSB = useCodeSandbox(props.hideActions?.includes('CSB') ? null : props);
const openRiddle = useRiddle(props.hideActions?.includes('RIDDLE') ? null : props);
const [execMotions, isMotionRunning] = useMotions(props.motions || [], demoRef.current);
const [copyCode, copyStatus] = useCopy();
const [currentFile, setCurrentFile] = useState('_');
const [sourceType, setSourceType] = useState(
getSourceType(currentFile, props.sources[currentFile]),
);
const [showSource, setShowSource] = useState(Boolean(props.defaultShowCode));
const [iframeKey, setIframeKey] = useState(Math.random());
const currentFileCode =
props.sources[currentFile][sourceType] || props.sources[currentFile].content;
const playgroundUrl = useTSPlaygroundUrl(locale, currentFileCode);
const iframeRef = useRef<HTMLIFrameElement>();
const [color] = usePrefersColor();
// re-render iframe if prefers color changed
useEffect(() => {
setIframeKey(Math.random());
}, [color]);
function handleFileChange(filename: string) {
setCurrentFile(filename);
setSourceType(getSourceType(filename, props.sources[filename]));
}
return (
<div
style={props.style}
className={[
props.className,
'__dumi-default-previewer',
isActive ? '__dumi-default-previewer-target' : '',
]
.filter(Boolean)
.join(' ')}
id={props.identifier}
data-debug={props.debug || undefined}
data-iframe={props.iframe || undefined}
>
{props.iframe && <div className="__dumi-default-previewer-browser-nav" />}
<div
ref={demoRef}
className="__dumi-default-previewer-demo"
style={{
transform: props.transform ? 'translate(0, 0)' : undefined,
padding: props.compact || (props.iframe && props.compact !== false) ? '0' : undefined,
background: props.background,
}}
>
{props.iframe ? (
<iframe
title="dumi-previewer"
style={{
// both compatible with unit or non-unit, such as 100, 100px, 100vh
height: String(props.iframe).replace(/(\d)$/, '$1px'),
}}
key={iframeKey}
src={demoUrl}
ref={iframeRef}
/>
) : (
props.children
)}
</div>
<div className="__dumi-default-previewer-desc" data-title={props.title}>
{props.title && <AnchorLink to={`#${props.identifier}`}>{props.title}</AnchorLink>}
{props.description && (
<div
// eslint-disable-next-line
dangerouslySetInnerHTML={{ __html: props.description }}
/>
)}
</div>
<div className="__dumi-default-previewer-actions">
{openCSB && (
<button
title="Open demo on CodeSandbox.io"
className="__dumi-default-icon"
role="codesandbox"
onClick={openCSB}
/>
)}
{openRiddle && (
<button
title="Open demo on Riddle"
className="__dumi-default-icon"
role="riddle"
onClick={openRiddle}
/>
)}
{props.motions && (
<button
title="Execute motions"
className="__dumi-default-icon"
role="motions"
disabled={isMotionRunning}
onClick={() => execMotions()}
/>
)}
{props.iframe && (
<button
title="Reload demo iframe page"
className="__dumi-default-icon"
role="refresh"
onClick={() => setIframeKey(Math.random())}
/>
)}
{!props.hideActions?.includes('EXTERNAL') && (
<Link target="_blank" to={demoUrl}>
<button
title="Open demo in new tab"
className="__dumi-default-icon"
role="open-demo"
type="button"
/>
</Link>
)}
<span />
<button
title="Copy source code"
className="__dumi-default-icon"
role="copy"
data-status={copyStatus}
onClick={() => copyCode(currentFileCode)}
/>
{sourceType === 'tsx' && showSource && (
<Link target="_blank" to={playgroundUrl}>
<button
title="Get JSX via TypeScript Playground"
className="__dumi-default-icon"
role="change-tsx"
type="button"
/>
</Link>
)}
<button
title="Toggle source code panel"
className={`__dumi-default-icon${showSource ? ' __dumi-default-btn-expand' : ''}`}
role="source"
type="button"
onClick={() => setShowSource(!showSource)}
/>
</div>
{showSource && (
<div className="__dumi-default-previewer-source-wrapper">
{!isSingleFile && (
<Tabs
className="__dumi-default-previewer-source-tab"
prefixCls="__dumi-default-tabs"
moreIcon="···"
defaultActiveKey={currentFile}
onChange={handleFileChange}
>
{Object.keys(props.sources).map(filename => (
<TabPane
tab={
filename === '_'
? `index.${getSourceType(filename, props.sources[filename])}`
: filename
}
key={filename}
/>
))}
</Tabs>
)}
<div className="__dumi-default-previewer-source">
<SourceCode code={currentFileCode} lang={sourceType} showCopy={false} />
</div>
</div>
)}
</div>
);
};
export default Previewer;

View File

@ -0,0 +1,58 @@
@import (reference) '../style/variables.less';
.@{prefix}-code-block {
position: relative;
background-color: @c-light-bg;
& + &,
& + table {
margin-top: 16px;
}
> pre[class*='language-'] {
margin: 0;
background: transparent;
.token-line:not(:last-child) .plain:empty {
display: inline-block;
min-height: 1em;
}
}
&-copy-btn {
position: absolute;
top: 1.1em;
right: 1em;
display: inline-block;
width: 16px;
height: 16px;
border: 0;
outline: none;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s, background 0.2s;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.9;
}
&[data-status='ready'] {
background-position: -54px 0;
}
&[data-status='copied'] {
opacity: 1;
pointer-events: none;
background-position: -54px -16px;
}
}
&:not(:hover) &-copy-btn {
visibility: hidden;
opacity: 0;
}
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import type { Language } from 'prism-react-renderer';
import Highlight, { defaultProps } from 'prism-react-renderer';
import { useCopy } from 'dumi/theme';
import 'prismjs/themes/prism.css';
import './SourceCode.less';
export interface ICodeBlockProps {
code: string;
lang: Language;
showCopy?: boolean;
}
export default ({ code, lang, showCopy = true }: ICodeBlockProps) => {
const [copyCode, copyStatus] = useCopy();
return (
<div className="__dumi-default-code-block">
<Highlight {...defaultProps} code={code} language={lang} theme={undefined}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={style}>
{showCopy && (
<button
className="__dumi-default-icon __dumi-default-code-block-copy-btn"
data-status={copyStatus}
onClick={() => copyCode(code)}
/>
)}
{tokens.map((line, i) => (
<div {...getLineProps({ line, key: i })}>
{line.map((token, key) => (
<span {...getTokenProps({ token, key })} />
))}
</div>
))}
</pre>
)}
</Highlight>
</div>
);
};

View File

@ -0,0 +1,22 @@
@import (reference) '~dumi-theme-default/src/style/variables.less';
.__dumi-site-tree {
padding: 16px;
border: 1px solid @c-border;
border-radius: 2px;
background-color: @c-light-bg;
small {
padding-left: 24px;
font-size: 14px;
color: @c-secondary;
&::before {
content: '# ';
}
}
.ant-tree-switcher {
background: transparent;
}
}

View File

@ -0,0 +1,60 @@
import React, { useEffect, useState, ReactNode, ComponentProps } from 'react';
import { Tree } from 'antd';
import { TreeProps } from 'antd/es/tree';
import './Tree.less';
function getTreeFromList(nodes: ReactNode, prefix = '') {
const data: TreeProps['treeData'] = [];
[].concat(nodes).forEach((node, i) => {
const key = `${prefix ? `${prefix}-` : ''}${i}`;
switch (node.type) {
case 'ul':
const parent = data[data.length - 1]?.children || data;
const ulLeafs = getTreeFromList(node.props.children || [], key);
parent.push(...ulLeafs);
break;
case 'li':
const liLeafs = getTreeFromList(node.props.children, key);
data.push({
title: [].concat(node.props.children).filter(child => child.type !== 'ul'),
key,
children: liLeafs,
isLeaf: !liLeafs.length,
});
break;
default:
}
});
return data;
}
const useListToTree = (nodes: ReactNode) => {
const [tree, setTree] = useState(getTreeFromList(nodes));
useEffect(() => {
setTree(getTreeFromList(nodes));
}, [nodes]);
return tree;
};
export default (props: ComponentProps<'div'>) => {
const data = useListToTree(props.children);
return (
<Tree.DirectoryTree
className="__dumi-site-tree"
showLine={{ showLeafIcon: false }}
selectable={false}
treeData={[{ key: '0', title: props.title || '<root>', children: data }]}
defaultExpandAll
/>
);
};

View File

@ -0,0 +1,48 @@
@import (reference) '../style/variables.less';
.@{prefix}-locale-select {
position: relative;
display: inline-block;
border: 1px solid #dadadf;
border-radius: 14px;
transition: background 0.2s;
&:hover {
background-color: #fafafa;
}
&:not([data-locale-count='1']):not([data-locale-count='2'])::after {
content: '';
position: absolute;
top: 50%;
right: 10px;
margin-top: -3px;
width: 0;
height: 0;
border: 4px solid transparent;
border-top: 6px solid #7b7f8d;
pointer-events: none;
}
a,
span,
select {
padding: 0 24px 0 16px;
height: 28px;
text-align: center;
text-decoration: none;
line-height: 28px;
appearance: none;
border: 0;
font-size: 16px;
color: #7b7f8d;
background: transparent;
outline: none;
cursor: pointer;
}
a,
span {
padding-right: 16px;
}
}

View File

@ -0,0 +1,53 @@
import type { FC} from 'react';
import React, { useContext } from 'react';
// @ts-ignore
import { history } from 'dumi';
import { context, Link } from 'dumi/theme';
import './LocaleSelect.less';
const LocaleSelect: FC<{ location: any }> = ({ location }) => {
const {
base,
locale,
config: { locales },
} = useContext(context);
const firstDiffLocale = locales.find(({ name }) => name !== locale);
function getLocaleTogglePath(target: string) {
const baseWithoutLocale = base.replace(`/${locale}`, '');
const pathnameWithoutLocale = location.pathname.replace(base, baseWithoutLocale) || '/';
// append locale prefix to path if it is not the default locale
if (target !== locales[0].name) {
// compatiable with integrate route prefix /~docs
const routePrefix = `${baseWithoutLocale}/${target}`.replace(/\/\//, '/');
const pathnameWithoutBase = location.pathname.replace(
// to avoid stripped the first /
base.replace(/^\/$/, '//'),
'',
);
return `${routePrefix}${pathnameWithoutBase}`.replace(/\/$/, '');
}
return pathnameWithoutLocale;
}
return firstDiffLocale ? (
<div className="__dumi-default-locale-select" data-locale-count={locales.length}>
{locales.length > 2 ? (
<select value={locale} onChange={ev => history.push(getLocaleTogglePath(ev.target.value))}>
{locales.map(localeItem => (
<option value={localeItem.name} key={localeItem.name}>
{localeItem.label}
</option>
))}
</select>
) : (
<Link to={getLocaleTogglePath(firstDiffLocale.name)}>{firstDiffLocale.label}</Link>
)}
</div>
) : null;
};
export default LocaleSelect;

View File

@ -0,0 +1,172 @@
@import (reference) '../style/variables.less';
.@{prefix}-navbar {
position: fixed;
z-index: 101;
top: 0;
left: 0;
right: 0;
display: none;
align-items: center;
justify-content: space-between;
padding: 0 58px;
height: @s-nav-height;
white-space: nowrap;
background: #fff;
box-shadow: 0 8px 24px -2px rgba(0, 0, 0, 0.05);
@media @mobile {
display: flex;
justify-content: center;
height: @s-mobile-nav-height;
}
&-toggle {
position: absolute;
top: 14px;
left: 16px;
display: none;
width: 22px;
height: 22px;
border: 0;
outline: none;
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFgAAAA4CAYAAAB5YT9uAAAAAXNSR0IArs4c6QAAASVJREFUeAHt3DuOwjAUBVAHscWZjaSkAIqU2QjskV80QwpsF3ArdGiw3hW3OGnQM2Iof6/dfr7+n71/LjAdx+HRsvm8SkNPAHBPJ5ABDiD2KgD3dAIZ4ABirwJwTyeQAQ4g9ioA93QC2fbZsSm/z7MDAQIECBAgQIAAAQIECBAgQIAAAQKvAsvV8mO8O8yn19jkXYHpMC7byXVdeS0/75b5XFvAwr1tE0kARxjbJYDbNpEEcISxXQK4bRNJAEcY2yWA2zaRZP0ePJRzpFEJAQIECBAgQIAAAQIECBAgQIAAgW8VWK/tj7Nb5eBTnvbjsp1c15WX4ncRQeB7lb8zyHrW29xo1F1iU8AxynoR4LpLbAo4RlkvAlx3iU0BxyjrRYDrLrHpDVSAEEPXScHTAAAAAElFTkSuQmCC')
no-repeat center / contain;
@media @mobile {
display: block;
}
}
&-logo {
display: inline-block;
height: 40px;
width: 180px;
color: #080e29;
font-weight: 500;
text-decoration: none;
font-size: 24px;
line-height: 40px;
&:not([data-plaintext]) {
padding-left: 56px;
background: url(@img-logo) no-repeat 0 / contain;
}
&:active,
&:hover {
color: #080e29;
}
@media @mobile {
height: 28px;
line-height: 28px;
&:not([data-plaintext]) {
padding-left: 36px;
}
}
}
nav {
> span {
position: relative;
margin-left: 40px;
display: inline-block;
color: @c-text;
height: @s-nav-height;
cursor: pointer;
font-size: 14px;
line-height: @s-nav-height;
text-decoration: none;
letter-spacing: 0;
> a {
color: #4d5164;
text-decoration: none;
&:hover,
&.active {
color: @c-primary;
}
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: -18px;
left: -18px;
}
&.active::after {
content: '';
position: absolute;
bottom: 0;
left: -2px;
right: -2px;
height: 2px;
background-color: @c-primary;
border-radius: 1px;
}
}
+ *:not(a) {
margin-left: 40px;
}
// second nav
> ul {
list-style: none;
position: absolute;
top: 100%;
left: 50%;
margin: 0;
min-width: 100px;
padding: 8px 18px;
line-height: 2;
background-color: #fff;
box-shadow: 0 8px 24px -2px rgba(0, 0, 0, 0.08);
transform: translate(-50%);
transform-origin: top;
border-radius: 1px;
transition: all 0.2s;
a {
position: relative;
display: block;
color: @c-text;
text-decoration: none;
&:hover,
&.active {
color: @c-primary;
}
}
}
&:not(:hover) > ul {
visibility: hidden;
pointer-events: none;
transform: translate(-50%) scaleY(0.9);
opacity: 0;
}
}
.@{prefix}-search + .@{prefix}-locale-select {
margin-left: 40px;
}
@media @mobile {
> a,
> span,
> div {
display: none;
}
}
}
&[data-mode='site'] {
display: flex;
}
}

View File

@ -0,0 +1,68 @@
import type { FC, MouseEvent } from 'react';
import React, { useContext } from 'react';
import { context, Link, NavLink } from 'dumi/theme';
import LocaleSelect from './LocaleSelect';
import './Navbar.less';
interface INavbarProps {
location: any;
navPrefix?: React.ReactNode;
onMobileMenuClick: (ev: MouseEvent<HTMLButtonElement>) => void;
}
const Navbar: FC<INavbarProps> = ({ onMobileMenuClick, navPrefix, location }) => {
const {
base,
config: { mode, title, logo },
nav: navItems,
} = useContext(context);
return (
<div className="__dumi-default-navbar" data-mode={mode}>
{/* menu toogle button (only for mobile) */}
<button className="__dumi-default-navbar-toggle" onClick={onMobileMenuClick} />
{/* logo & title */}
<Link
className="__dumi-default-navbar-logo"
style={{
backgroundImage: logo && `url('${logo}')`,
}}
to={base}
data-plaintext={logo === false || undefined}
>
{title}
</Link>
<nav>
{navPrefix}
{/* nav */}
{navItems.map(nav => {
const child = Boolean(nav.children?.length) && (
<ul>
{nav.children.map(item => (
<li key={item.path}>
<NavLink to={item.path}>{item.title}</NavLink>
</li>
))}
</ul>
);
return (
<span key={nav.title || nav.path}>
{nav.path ? (
<NavLink to={nav.path} key={nav.path}>
{nav.title}
</NavLink>
) : (
nav.title
)}
{child}
</span>
);
})}
<LocaleSelect location={location} />
</nav>
</div>
);
};
export default Navbar;

View File

@ -0,0 +1,107 @@
@import (reference) '../style/variables.less';
.@{prefix}-search {
margin-left: 20px;
position: relative;
display: inline-block;
&-input {
width: 200px;
height: 32px;
padding: 0 38px 0 14px;
color: @c-heading;
font-size: 14px;
border: 0;
outline: none;
transition: all 0.2s;
border-radius: 16px;
background: url('data:image/png;base64,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')
#f5f6f7 no-repeat right 14px center / 16px;
appearance: none;
}
> ul {
list-style: none;
position: absolute;
top: 100%;
left: 0;
z-index: 10;
margin: 8px 0 0;
min-width: 280px;
max-width: 500px;
padding: 6px 0;
background-color: #fff;
border: 1px solid @c-border;
border-radius: 1px;
box-shadow: 0 2px 20px 0 rgba(0, 0, 0, 0.05);
box-sizing: border-box;
&:empty {
display: none;
}
li {
font-size: 15px;
a {
display: block;
padding: 6px 20px;
color: @c-secondary;
text-decoration: none;
transition: background-color 0.3s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: @c-primary;
background-color: @c-light-bg;
}
}
span:first-child {
position: relative;
display: inline-block;
max-width: 100%;
padding-right: 26px;
vertical-align: -0.37em;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
&::after {
content: '>';
position: absolute;
top: 50%;
right: 6px;
opacity: 0.6;
transform: translateY(-54%);
}
}
}
}
@media only screen and (max-width: 1024px) {
margin-right: -14px;
> input:not(:focus) {
width: 32px;
padding-right: 0;
box-shadow: none;
cursor: pointer;
background-position: right 8px center;
+ ul {
transition: 0.1s visibility;
visibility: hidden;
}
}
}
@media @mobile {
position: absolute;
top: 9px;
right: 24px;
display: block !important;
}
}

View File

@ -0,0 +1,41 @@
import React, { useState, useEffect, useRef } from 'react';
import { useSearch, AnchorLink } from 'dumi/theme';
import './SearchBar.less';
export default () => {
const [keywords, setKeywords] = useState<string>('');
const [items, setItems] = useState([]);
const input = useRef<HTMLInputElement>();
const result = useSearch(keywords);
useEffect(() => {
if (Array.isArray(result)) {
setItems(result);
} else if (typeof result === 'function') {
result(`.${input.current.className}`);
}
}, [result]);
return (
<div className="__dumi-default-search">
<input
className="__dumi-default-search-input"
type="search"
ref={input}
{...(Array.isArray(result)
? { value: keywords, onChange: ev => setKeywords(ev.target.value) }
: {})}
/>
<ul>
{items.map(meta => (
<li key={meta.path} onClick={() => setKeywords('')}>
<AnchorLink to={meta.path}>
{meta.parent?.title && <span>{meta.parent.title}</span>}
{meta.title}
</AnchorLink>
</li>
))}
</ul>
</div>
);
};

View File

@ -0,0 +1,321 @@
@import (reference) '../style/variables.less';
.@{prefix}-menu {
position: fixed;
z-index: 100;
top: 0;
left: 0;
bottom: 0;
width: @s-menu-width;
background-color: #f2f5fa;
box-sizing: border-box;
transition: left 0.3s;
&[data-hidden] {
display: none;
}
@media @mobile {
left: -@s-menu-mobile-width;
top: @s-mobile-nav-height;
display: block !important;
width: @s-menu-mobile-width;
background-color: #fff;
&[data-mobile-show] {
left: 0;
}
}
// shadow
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: block;
width: 20px;
background: linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.03));
pointer-events: none;
// use border on mobile devices
@media @mobile {
width: 1px;
background: @c-border;
}
}
&-header {
position: relative;
padding-top: 40px;
text-align: center;
border-bottom: 1px solid @c-border;
@media @mobile {
display: none;
}
.@{prefix}-menu-logo {
display: inline-block;
width: 66px;
height: 65px;
background: url(@img-logo) no-repeat 0 / contain;
}
h1 {
margin: 10px 0 0;
color: @c-heading;
font-weight: 500;
line-height: 1.40625;
}
p {
margin: 0 0 5px;
color: lighten(@c-secondary, 10%);
// badges
> object[data^='https://img.shields.io'] {
max-height: 20px;
}
+ p {
margin-bottom: 10px;
}
}
}
&-doc-locale {
padding: 16px 0;
text-align: center;
border-bottom: 1px solid @c-border;
&:empty {
display: none;
}
}
&-inner {
width: 100%;
height: 100%;
overflow: auto;
overscroll-behavior: contain;
// common list styles
ul {
list-style: none;
margin: 0;
padding: 0;
font-size: 16px;
li {
color: @c-text;
a,
> span {
position: relative;
display: block;
padding-right: 24px;
color: @c-heading;
line-height: 2.4;
text-decoration: none;
outline: none;
transition: color 0.3s, background 0.3s;
span {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&:hover,
&.active {
color: @c-primary;
}
&::before {
content: '';
position: absolute;
top: 50%;
left: -10px;
margin-top: -2.5px;
display: inline-block;
width: 5px;
height: 5px;
background-color: @c-primary;
border-radius: 50%;
opacity: 0;
transition: transform 0.2s, opacity 0.2s;
transform: scale(0) translateX(-10px);
}
}
&.active a,
a.active {
&::before {
opacity: 1;
transform: scale(1) translateX(0);
}
}
// level larger, offset larger, font size smaller
ul {
font-size: 0.9em;
padding-left: 1em;
}
}
}
// 1-level list styles
> ul {
> li > a {
line-height: 2.875;
&:not([href]) {
padding-top: 24px;
line-height: 1;
font-weight: 500;
color: @c-heading !important;
background: transparent !important;
cursor: default;
}
}
> li:first-child > a:not([href]) {
padding-top: 0;
}
}
// n-level list styles
> ul ul {
a {
color: @c-secondary;
&.active {
color: @c-primary;
}
}
}
.@{prefix}-menu-mobile-area {
display: none;
padding-bottom: 16px;
margin-bottom: 16px;
text-align: center;
border-bottom: 1px solid @c-border;
@media @mobile {
display: block;
}
}
// mobile nav list
.@{prefix}-menu-nav-list {
padding: 16px 0;
> li,
> li > a {
padding-right: 0;
line-height: 2.4;
ul {
padding-left: 0;
a {
padding-right: 0;
font-size: 90%;
}
}
}
}
// menu list
.@{prefix}-menu-list {
padding: 8px 0;
margin-bottom: 40px;
> li > a {
@c-active-bg: #e8ecf4;
padding-left: 28px;
&.active {
background: linear-gradient(to left, #e8ecf4, rgba(232, 236, 244, 0));
}
~ ul {
margin-top: 8px;
margin-left: 28px;
}
@media @mobile {
padding-left: 16px;
~ ul {
margin-left: 16px;
}
}
}
}
}
&[data-mode='site'] {
&::after {
width: 1px;
background: @c-border;
}
.@{prefix}-menu-list {
padding: 0;
> li > a {
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: 0;
display: block;
width: 3px;
background-color: @c-primary;
visibility: hidden;
opacity: 0;
transition: all 0.3s;
border-radius: 1px;
}
&.active {
z-index: 1;
background: linear-gradient(to left, #f8faff, rgba(248, 250, 255, 0));
&::after {
opacity: 1;
visibility: visible;
}
}
}
}
@media @desktop {
top: @s-nav-height;
width: @s-site-menu-width;
padding-top: 50px;
background: transparent;
.@{prefix}-menu-nav,
.@{prefix}-menu-header {
display: none;
}
.@{prefix}-menu-list > li > a {
padding-left: 58px;
~ ul {
margin-left: 58px;
}
}
}
}
}

View File

@ -0,0 +1,139 @@
import type { FC} from 'react';
import React, { useContext } from 'react';
import { context, Link, NavLink } from 'dumi/theme';
import LocaleSelect from './LocaleSelect';
import SlugList from './SlugList';
import './SideMenu.less';
interface INavbarProps {
mobileMenuCollapsed: boolean;
location: any;
}
const SideMenu: FC<INavbarProps> = ({ mobileMenuCollapsed, location }) => {
const {
config: {
logo,
title,
description,
mode,
repository: { url: repoUrl },
},
menu,
nav: navItems,
base,
meta,
} = useContext(context);
const isHiddenMenus =
Boolean((meta.hero || meta.features || meta.gapless) && mode === 'site') ||
meta.sidemenu === false ||
undefined;
return (
<div
className="__dumi-default-menu"
data-mode={mode}
data-hidden={isHiddenMenus}
data-mobile-show={!mobileMenuCollapsed || undefined}
>
<div className="__dumi-default-menu-inner">
<div className="__dumi-default-menu-header">
<Link
to={base}
className="__dumi-default-menu-logo"
style={{
backgroundImage: logo && `url('${logo}')`,
}}
/>
<h1>{title}</h1>
<p>{description}</p>
{/* github star badge */}
{/github\.com/.test(repoUrl) && mode === 'doc' && (
<p>
<object
type="image/svg+xml"
data={`https://img.shields.io/github/stars${
repoUrl.match(/((\/[^\/]+){2})$/)[1]
}?style=social`}
/>
</p>
)}
</div>
{/* mobile nav list */}
{navItems.length ? (
<div className="__dumi-default-menu-mobile-area">
<ul className="__dumi-default-menu-nav-list">
{navItems.map(nav => {
const child = Boolean(nav.children?.length) && (
<ul>
{nav.children.map(item => (
<li key={item.path || item.title}>
<NavLink to={item.path}>{item.title}</NavLink>
</li>
))}
</ul>
);
return (
<li key={nav.path || nav.title}>
{nav.path ? <NavLink to={nav.path}>{nav.title}</NavLink> : nav.title}
{child}
</li>
);
})}
</ul>
{/* site mode locale select */}
<LocaleSelect location={location} />
</div>
) : (
<div className="__dumi-default-menu-doc-locale">
{/* doc mode locale select */}
<LocaleSelect location={location} />
</div>
)}
{/* menu list */}
<ul className="__dumi-default-menu-list">
{!isHiddenMenus &&
menu.map(item => {
// always use meta from routes to reduce menu data size
const hasSlugs = Boolean(meta.slugs?.length);
const hasChildren = item.children && Boolean(item.children.length);
const show1LevelSlugs =
meta.toc === 'menu' && !hasChildren && hasSlugs && item.path === location.pathname.replace(/([^^])\/$/, '$1');
return (
<li key={item.path || item.title}>
<NavLink to={item.path} exact={!(item.children && item.children.length)}>
{item.title}
</NavLink>
{/* group children */}
{Boolean(item.children && item.children.length) && (
<ul>
{item.children.map(child => (
<li key={child.path}>
<NavLink to={child.path} exact>
<span>{child.title}</span>
</NavLink>
{/* group children slugs */}
{Boolean(
meta.toc === 'menu' &&
typeof window !== 'undefined' &&
child.path === location.pathname &&
hasSlugs,
) && <SlugList slugs={meta.slugs} />}
</li>
))}
</ul>
)}
{/* group slugs */}
{show1LevelSlugs && <SlugList slugs={meta.slugs} />}
</li>
);
})}
</ul>
</div>
</div>
);
};
export default SideMenu;

View File

@ -0,0 +1,22 @@
@import (reference) '../style/variables.less';
ul[role='slug-list'] {
&:empty {
margin: 0 !important;
padding: 0 !important;
}
li {
> a.active {
color: darken(@c-primary, 2%);
}
&[data-depth='3'] {
padding-left: 24px;
}
&[data-depth='4'] {
padding-left: 48px;
}
}
}

View File

@ -0,0 +1,20 @@
import type { FC } from 'react';
import React from 'react';
import { AnchorLink } from 'dumi/theme';
import './SlugList.less';
const SlugsList: FC<{ slugs: any; className?: string }> = ({ slugs, ...props }) => (
<ul role="slug-list" {...props}>
{slugs
.filter(({ depth }) => depth > 1 && depth < 5)
.map(slug => (
<li key={slug.heading} title={slug.value} data-depth={slug.depth}>
<AnchorLink to={`#${slug.heading}`}>
<span>{slug.value}</span>
</AnchorLink>
</li>
))}
</ul>
);
export default SlugsList;

117
.dumi/theme/layout.tsx Executable file
View File

@ -0,0 +1,117 @@
import React, { useContext, useState, useEffect } from 'react';
import type { IRouteComponentProps } from '@umijs/types';
import { context, Link } from 'dumi/theme';
import Navbar from './components/Navbar';
import SideMenu from './components/SideMenu';
import SlugList from './components/SlugList';
import SearchBar from './components/SearchBar';
import './style/layout.less';
const Hero = hero => (
<>
<div className="__dumi-default-layout-hero">
{hero.image && <img src={hero.image} />}
<h1>{hero.title}</h1>
<div dangerouslySetInnerHTML={{ __html: hero.desc }} />
{hero.actions &&
hero.actions.map(action => (
<Link to={action.link} key={action.text}>
<button type="button">{action.text}</button>
</Link>
))}
</div>
</>
);
const Features = features => (
<div className="__dumi-default-layout-features">
{features.map(feat => (
<dl key={feat.title} style={{ backgroundImage: feat.icon ? `url(${feat.icon})` : undefined }}>
{feat.link ? (
<Link to={feat.link}>
<dt>{feat.title}</dt>
</Link>
) : (
<dt>{feat.title}</dt>
)}
<dd dangerouslySetInnerHTML={{ __html: feat.desc }} />
</dl>
))}
</div>
);
const Layout: React.FC<IRouteComponentProps> = ({ children, location }) => {
const {
config: { mode, repository },
meta,
locale,
} = useContext(context);
const { url: repoUrl, branch, platform } = repository;
const [menuCollapsed, setMenuCollapsed] = useState<boolean>(true);
const isSiteMode = mode === 'site';
const showHero = isSiteMode && meta.hero;
const showFeatures = isSiteMode && meta.features;
const showSideMenu = meta.sidemenu !== false && !showHero && !showFeatures && !meta.gapless;
const showSlugs =
!showHero &&
!showFeatures &&
Boolean(meta.slugs?.length) &&
(meta.toc === 'content' || meta.toc === undefined) &&
!meta.gapless;
const isCN = /^zh|cn$/i.test(locale);
const updatedTimeIns = new Date(meta.updatedTime);
const updatedTime: any = `${updatedTimeIns.toLocaleDateString([], { hour12: false })} ${updatedTimeIns.toLocaleTimeString([], { hour12: false })}`;
const repoPlatform =
{ github: 'GitHub', gitlab: 'GitLab' }[
(repoUrl || '').match(/(github|gitlab)/)?.[1] || 'nothing'
] || platform;
return (
<div
className="__dumi-default-layout"
data-route={location.pathname}
data-show-sidemenu={String(showSideMenu)}
data-show-slugs={String(showSlugs)}
data-site-mode={isSiteMode}
data-gapless={String(!!meta.gapless)}
onClick={() => {
if (menuCollapsed) return;
setMenuCollapsed(true);
}}
>
<Navbar
location={location}
navPrefix={<SearchBar />}
onMobileMenuClick={ev => {
setMenuCollapsed(val => !val);
ev.stopPropagation();
}}
/>
<SideMenu mobileMenuCollapsed={menuCollapsed} location={location} />
{showSlugs && <SlugList slugs={meta.slugs} className="__dumi-default-layout-toc" />}
{showHero && Hero(meta.hero)}
{showFeatures && Features(meta.features)}
<div className="__dumi-default-layout-content">
{children}
{!showHero && !showFeatures && meta.filePath && !meta.gapless && (
<div className="__dumi-default-layout-footer-meta">
{repoPlatform && (
<Link to={`${repoUrl}/edit/${branch}/${meta.filePath}`}>
{isCN ? `${repoPlatform} 上编辑此页` : `Edit this doc on ${repoPlatform}`}
</Link>
)}
<span data-updated-text={isCN ? '最后更新时间:' : 'Last update: '}>{updatedTime}</span>
</div>
)}
{(showHero || showFeatures) && meta.footer && (
<div
className="__dumi-default-layout-footer"
dangerouslySetInnerHTML={{ __html: meta.footer }}
/>
)}
</div>
</div>
);
};
export default Layout;

325
.dumi/theme/style/layout.less Executable file
View File

@ -0,0 +1,325 @@
@import './markdown.less';
@import './variables.less';
@s-toc-width: 350px;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB,
Microsoft YaHei, Helvetica Neue, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol;
font-variant: tabular-nums;
font-feature-settings: 'tnum';
-webkit-font-smoothing: antialiased;
}
.@{prefix}-layout {
box-sizing: border-box;
min-height: 100vh;
padding: 16px (@s-content-margin + @s-toc-width) 50px @s-menu-width + @s-content-margin;
@media @mobile {
padding-top: 66px !important;
padding-left: 16px !important;
padding-right: 16px !important;
}
&[data-gapless='true'] {
padding-top: @s-nav-height !important;
padding-right: 0 !important;
padding-left: 0 !important;
padding-bottom: 0;
@media @mobile {
padding-top: @s-mobile-nav-height !important;
}
}
&[data-show-sidemenu='false'] {
padding-left: @s-content-margin;
}
&[data-show-slugs='false'] {
padding-right: @s-content-margin;
}
&[data-site-mode='true'] {
padding-top: @s-nav-height + 50px;
&[data-show-sidemenu='true'] {
padding-left: @s-site-menu-width + 50px;
}
&[data-show-slugs='true'] {
padding-right: @s-content-margin + @s-toc-width + 14;
}
.@{prefix}-layout-content > .markdown:first-child > *:first-child {
margin-top: 0;
}
.@{prefix}-layout-toc {
top: 114px;
max-height: calc(100vh - 154px);
}
}
&-hero {
margin: -50px -58px 0;
padding: 100px 0;
text-align: center;
background-color: #f5f6f8;
@media @mobile {
margin: -16px -16px 0;
padding: 48px 0;
}
img {
max-width: 100%;
max-height: 200px;
margin-bottom: 1rem;
}
h1 {
margin: 0 0 16px;
font-size: 48px;
font-weight: 600;
line-height: 56px;
color: #080e29;
+ div {
margin: 16px 0 32px;
opacity: 0.78;
.markdown {
font-size: 16px;
}
}
}
button {
margin-right: 16px;
padding: 0 32px;
height: 44px;
color: @c-primary;
font-size: 16px;
background: transparent;
border: 1px solid @c-primary;
border-radius: 22px;
box-sizing: border-box;
cursor: pointer;
outline: none;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.9;
}
}
a:last-child button {
margin-right: 0;
color: #fff;
background: @c-primary;
}
}
&-features {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-column-gap: 96px;
grid-row-gap: 56px;
padding: 72px 0;
> dl {
flex: 1;
margin: 0;
text-align: center;
background: no-repeat center top / auto 48px;
&[style*='background-image'] {
padding-top: 64px;
}
dt {
margin-bottom: 12px;
font-size: 20px;
line-height: 1;
color: @c-heading;
}
a {
transition-duration: none;
}
a dt {
color: @c-link;
transition: opacity 0.2s;
&:hover {
opacity: 0.7;
text-decoration: underline;
}
&:active {
opacity: 0.9;
}
}
dd {
margin: 0;
.markdown {
color: @c-secondary;
font-size: 14px;
line-height: 22px;
> p:first-child {
margin-top: 0;
}
> p:last-child {
margin-bottom: 0;
}
}
}
}
@media @mobile {
display: block;
padding: 40px 0;
> dl {
text-align: left;
background-position: left top;
&[style*='background-image'] {
padding: 0 0 0 60px;
}
+ dl {
margin-top: 32px;
}
}
}
}
&-features,
&-features + &-content,
&-hero + &-content {
margin-left: auto;
margin-right: auto;
max-width: 960px;
}
&-hero + &-content {
margin-top: 60px;
}
&-toc {
list-style: none;
position: fixed;
z-index: 10;
top: 50px;
right: 0;
width: @s-toc-width;
max-height: calc(90vh - 80px);
margin: 0;
padding: 0 24px 0 0;
background-color: #fff;
box-shadow: 0 0 16px 16px #fff;
box-sizing: content-box;
overflow: auto;
@media @mobile {
display: none;
}
li {
position: relative;
margin: 0;
padding: 4px 0 4px 6px;
text-indent: 24px;
font-size: 13px;
// line-height: 1.40625;
line-height: 1.60625;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
a {
color: @c-text;
text-decoration: none;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
display: inline-block;
width: 2px;
background: @c-border;
}
&:hover {
color: lighten(@c-primary, 5%);
}
&:active {
color: lighten(@c-primary, 3%);
}
&.active {
color: @c-primary;
&::before {
background: @c-primary;
}
}
}
}
}
&-footer-meta {
margin-top: 40px;
padding-top: 24px;
display: flex;
color: @c-secondary;
font-size: 14px;
justify-content: space-between;
border-top: 1px solid @c-border;
@media only screen and (max-width: 960px) {
display: block;
}
> a {
margin-bottom: 4px;
display: block;
color: @c-primary;
text-decoration: none;
}
> span:last-child {
&::before {
content: attr(data-updated-text);
color: @c-primary;
}
}
}
}
.__dumi-default-layout-footer {
margin: 72px 0 -32px;
padding-top: 24px;
border-top: 1px solid @c-border;
text-align: center;
.markdown {
color: #b0b1ba;
}
}

196
.dumi/theme/style/markdown.less Executable file
View File

@ -0,0 +1,196 @@
@import (reference) './variables.less';
.markdown {
color: @c-text;
font-size: 15px;
line-height: 1.60625;
&:not(:first-child):empty {
min-height: 32px;
}
// titles
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 42px 0 18px;
color: @c-heading;
font-weight: 500;
line-height: 1.40625;
// anchor link
&:hover > a[aria-hidden] {
float: left;
margin-top: 0.06em;
margin-left: -20px;
width: 20px;
padding-right: 4px;
line-height: 1;
box-sizing: border-box;
@media @mobile {
width: 14px;
margin-left: -14px;
}
&::after {
content: '#';
display: inline-block;
vertical-align: middle;
font-size: 20px;
}
span {
display: none;
}
}
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
margin-top: 16px;
}
}
h1 {
margin-top: 48px;
margin-bottom: 32px;
font-size: 32px;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 20px;
}
h4 {
font-size: 18px;
}
h5 {
font-size: 16px;
}
h6 {
font-size: 14px;
}
// paragraph
p {
margin: 16px 0;
}
// inline code
*:not(pre) code {
padding: 2px 5px;
color: #d56161;
background: darken(@c-light-bg, 1%);
}
code {
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
}
// code block
pre {
font-size: 14px;
background: darken(@c-light-bg, 1%);
&:not([class^='language-']) {
padding: 1em;
}
}
// horizontal line
hr {
margin: 16px 0;
border: 0;
border-top: 1px solid @c-border;
}
// blockquote
blockquote {
margin: 16px 0;
padding: 0 24px;
color: fadeout(@c-text, 30%);
border-left: 4px solid @c-border;
overflow: hidden;
}
// list
ul,
ol {
margin: 8px 0 8px 32px;
padding: 0;
li {
margin-bottom: 4px;
}
}
// table
table {
width: 100%;
border-collapse: collapse;
border: 1px solid @c-border;
th,
td {
padding: 10px 24px;
border: 1px solid @c-border;
}
th {
font-weight: 600;
background: @c-light-bg;
}
td:first-child {
font-weight: 500;
}
a {
svg {
display: none;
}
}
}
// links
a {
color: @c-link;
text-decoration: none;
transition: opacity 0.2s;
outline: none;
&:hover {
opacity: 0.7;
text-decoration: underline;
}
&:active {
opacity: 0.9;
}
}
// images
img {
max-width: 100%;
}
}
.@{prefix} {
&-external-link-icon {
vertical-align: -0.155em;
margin-left: 2px;
}
}

View File

@ -0,0 +1,26 @@
/* 颜色表 */
@c-primary: #4569d4;
@c-heading: #454d64;
@c-text: #454d64;
@c-secondary: #717484;
@c-link: @c-primary;
@c-border: #ebedf1;
@c-light-bg: #f9fafb;
/* 尺寸表 */
@s-nav-height: 64px;
@s-mobile-nav-height: 50px;
@s-menu-width: 260px;
@s-site-menu-width: 350px;
@s-menu-mobile-width: 80vw;
@s-content-margin: 58px;
@img-logo: 'data:image/png;base64,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';
@prefix: __dumi-default;
@mobile: ~'only screen and (max-width: 767px)';
@desktop: ~'only screen and (min-width: 768px)';
@icons: 'data:image/png;base64,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';
.@{prefix}-icon {
background: url(@icons) no-repeat ~'0 0/230px auto';
}

315
.dumi/theme/test/index.test.tsx Executable file
View File

@ -0,0 +1,315 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, queryByAttribute, fireEvent } from '@testing-library/react';
import type { MemoryHistory} from '@umijs/runtime';
import { createMemoryHistory, Router } from '@umijs/runtime';
import { context as Context } from 'dumi/theme';
import SourceCode from '../builtins/SourceCode';
import Alert from '../builtins/Alert';
import Badge from '../builtins/Badge';
import Previewer from '../builtins/Previewer';
import API from '../builtins/API';
import Layout from '../layout';
let history: MemoryHistory;
// mock history location which import from 'dumi'
jest.mock('dumi', () => ({
history: { location: { pathname: '/' } },
}));
describe('default theme', () => {
history = createMemoryHistory({ initialEntries: ['/', '/en-US'], initialIndex: 0 });
const baseCtx = {
title: 'test',
locale: 'zh-CN',
routes: [
{
path: '/',
title: '首页',
meta: {},
},
{
path: '/en-US',
title: 'Home',
meta: {},
},
],
config: {
locales: [
{ name: 'zh-CN', label: '中文' },
{ name: 'en-US', label: 'English' },
],
menus: {},
navs: {},
title: 'test',
logo: '/',
mode: 'site' as 'doc' | 'site',
repository: { branch: 'mater' },
},
meta: {},
menu: [
{
title: '分组',
children: [
{
title: 'English',
path: '/en',
},
],
},
],
nav: [
{
path: '/',
title: '首页',
children: [],
},
{
title: '生态',
children: [
{
path: 'https://d.umijs.org',
title: 'GitHub',
children: [],
},
],
},
],
base: '/',
};
const baseProps = {
history,
location: { ...history.location, query: {} },
match: { params: {}, isExact: true, path: '/', url: '/' },
route: { path: '/', routes: baseCtx.routes },
};
it('should render site home page', () => {
const wrapper = ({ children }) => (
<Context.Provider
value={{
...baseCtx,
meta: {
title: 'test',
hero: {
title: 'Hero',
desc: 'Hero Description',
actions: [{ text: '开始', link: '/' }],
},
features: [
{ title: 'Feat', desc: 'Feature' },
{ title: 'Feat2', link: '/' },
],
},
}}
>
{children}
</Context.Provider>
);
const { container, getAllByText, getByText } = render(
<Router history={history}>
<Layout {...baseProps}>
<h1>Home Page</h1>
</Layout>
</Router>,
{ wrapper },
);
// expect navbar be rendered
expect(getAllByText('首页')).not.toBeNull();
// expect content be rendered
expect(getByText('Home Page')).not.toBeNull();
// expect hero be rendered
expect(getByText('Hero')).not.toBeNull();
// expect features be rendered
expect(getByText('Feature')).not.toBeNull();
expect(getByText('Feat2')).not.toBeNull();
// trigger mobile menu display
queryByAttribute('class', container, '__dumi-default-navbar-toggle').click();
// expect sidemenu display for mobile
expect(queryByAttribute('data-mobile-show', container, 'true')).not.toBeNull();
});
it('should render documentation page', async () => {
const updatedTime = 1604026996000;
const wrapper = ({ children }) => (
<Context.Provider
value={{
...baseCtx,
meta: {
title: 'test',
slugs: [{ value: 'Slug A', heading: 'a', depth: 2 }],
updatedTime,
filePath: 'temp',
},
}}
>
{children}
</Context.Provider>
);
const { getByText, getAllByText } = render(
<Router history={history}>
<Layout {...baseProps}>
<h1>Doc</h1>
</Layout>
</Router>,
{ wrapper },
);
// expect slugs be rendered
expect(getByText('Slug A')).not.toBeNull();
// expect footer date show
expect(new Date(updatedTime).toLocaleString([], { hour12: false })).not.toBeNull();
// trigger locale change
getAllByText('English')[0].click();
// expect location change
expect(history.location.pathname).toEqual(baseCtx.routes[1].path);
});
it('should render builtin components correctly', () => {
const code = "console.log('Hello World!')";
const wrapper = ({ children }) => (
<Context.Provider
value={{
...baseCtx,
meta: {
title: 'test',
slugs: [{ value: 'Slug A', heading: 'a', depth: 2 }],
},
}}
>
{children}
</Context.Provider>
);
const { getByText, getByTitle, getAllByTitle, container } = render(
<Router history={history}>
<Layout {...baseProps}>
<>
<a href="" id="btn">
click
</a>
<SourceCode code={code} lang="javascript" />
<Alert type="info">Alert</Alert>
<Badge type="info">Badge</Badge>
<Previewer
title="demo-1"
identifier="demo-1"
sources={{
_: {
jsx: "export default () => 'JavaScript'",
tsx: "export default () => 'TypeScript'",
},
}}
dependencies={{}}
>
<>demo-1 Content</>
</Previewer>
<Previewer
title="demo-2"
identifier="demo-2"
sources={{
_: {
jsx: "export default () => 'Main'",
},
'Other.jsx': {
import: './Other.jsx',
content: "export default () => 'Other'",
},
}}
dependencies={{}}
>
<>demo-2 Content</>
</Previewer>
<Previewer
title="demo-3"
identifier="demo-3"
sources={{
_: {
jsx: "export default () => 'Main'",
},
'Other.jsx': {
import: './Other.jsx',
content: "export default () => 'Other'",
},
}}
dependencies={{}}
iframe={100}
>
<>demo-3 Content</>
</Previewer>
<API identifier="MultipleExports" export="Other" />
</>
</Layout>
</Router>,
{ wrapper },
);
// toggle side menu display
fireEvent(
container.querySelector('.__dumi-default-navbar-toggle'),
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
fireEvent(
container.querySelector('#btn'),
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
// expect SourceCode highlight
expect(getByText('console')).toHaveClass('token');
// expect Alert be rendered
expect(getByText('Alert')).toHaveAttribute('type', 'info');
// expect Badge be rendered
expect(getByText('Badge')).toHaveClass('__dumi-default-badge');
// expect Previewer be rendered
expect(getByText('demo-1')).not.toBeNull();
// trigger source code display for demo-1
getAllByTitle('Toggle source code panel')[0].click();
// expect show TypeScript code default
expect(getByText("'TypeScript'")).not.toBeNull();
// trigger source code display for demo-2
getAllByTitle('Toggle source code panel')[1].click();
// expect show code of main file
expect(getByText("'Main'")).not.toBeNull();
// trigger file change
getByText('Other.jsx').click();
// expect show code of main file
expect(getByText("'Other'")).not.toBeNull();
// expect render iframe demo
(container.querySelector('[data-iframe] button[role=refresh]') as HTMLElement).click();
expect(container.querySelector('[data-iframe]').innerHTML).not.toContain('demo-3 Content');
expect(container.querySelector('[data-iframe] iframe')).not.toBeNull();
expect((container.querySelector('[data-iframe] iframe') as HTMLElement).style.height).toEqual(
'100px',
);
// expect render API property
expect(getByText('other', { selector: 'table td' })).not.toBeNull();
});
});

38
.github/workflows/vercel.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: vercel
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Setup Node
uses: actions/setup-node@v2.1.2
with:
node-version: '12.x'
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm install
- run: npm run build-docs
- name: Deploy
uses: amondnet/vercel-action@v19
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }} # Required
github-token: ${{ secrets.GITHUB_TOKEN }} #Optional
vercel-args: '--prod' #Optional
vercel-org-id: ${{ secrets.ORG_ID}} #Required
vercel-project-id: ${{ secrets.PROJECT_ID}} #Required
working-directory: ./dist

7
.gitignore vendored
View File

@ -13,3 +13,10 @@ packages/resourcer/package-lock.json
verdaccio verdaccio
uploads/ uploads/
.umi
.umi-production
/dist
/.vscode
/.idea
.vercel

23
.umirc.ts Normal file
View File

@ -0,0 +1,23 @@
import { defineConfig } from 'dumi';
export default defineConfig({
title: ' ',
hash: true,
ssr: {},
exportStatic: {},
mode: 'site',
logo: 'https://www.nocobase.com/dist/images/logo.png',
navs: {
'en-US': [
null,
{ title: 'GitHub', path: 'https://github.com/nocobase/nocobase' },
{ title: 'Changelog', path: 'https://github.com/nocobase/nocobase/releases' },
],
'zh-CN': [
null,
{ title: 'GitHub', path: 'https://github.com/nocobase/nocobase' },
{ title: '更新日志', path: 'https://github.com/nocobase/nocobase/releases' },
],
},
// more config: https://d.umijs.org/config
});

35
docs/cores/index.md Normal file
View File

@ -0,0 +1,35 @@
---
title: 介绍
order: 1
toc: menu
nav:
title: 核心
order: 2
---
# 介绍
NocoBase 核心主要围绕三点:
- 数据的存储 —— 结构和关系
- 数据的行为 —— 操作和事件
- 数据的形态 —— 页面和区块
由此构建了
- @nocobase/database:提供灵活且强大的数据库构造器
- @nocobase/resourcer:为数据提供资源操作方法
- @nocobase/blocks:为数据提供的 UI 模块
更近一步
- @nocobase/database@nocobase/resourcer 组装成了 @nocobase/server,也就是最核心的 NocoBase
- @nocobase/blocks 是前端最重要部分,提供了现代化的 Block-styled Editor/Renderer
<img style="width:700px;" src="./nocobase-core.png"/>
## 数据的存储 —— 结构和关系
## 数据的行为 —— 操作和事件
## 数据的形态 —— 页面和区块

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -0,0 +1,426 @@
---
title: '@nocobase/actions'
order: 4
# toc: menu
---
# @nocobase/actions
## 介绍
为资源提供了几种默认方法:
- list查看列表
- get查看详情
- create创建数据
- update更新数据
- destroy删除数据
- set建立关联
- add附加关联
- remove删除关联
- toggle附加或删除关联
- sort排序
## 安装
```bash
yarn add @nocobase/actions
```
<Alert title="注意" type="warning">
@nocobase/actions 不能单独使用,依赖于 @nocobase/database@nocobase/resourcer
</Alert>
## Usage
```ts
import Koa from 'koa';
import Database from '@nocobase/database';
import { actions, middlewares } from '@nocobase/actions';
// 配置数据表
const db = new Database({
// 省略
});
const table = db.table({
name: 'posts',
fields: [
{type: 'string', name: 'title'},
],
});
await table.sync();
// 配置资源
const resourcer = new Resourcer();
resourcer.registerActionHandlers({ ...actions.common });
resourcer.define({
name: 'posts',
});
// 使用 Koa
const app = new Koa();
app.use(async (ctx, next) => {
ctx.db = database;
await next();
});
app.use(resourcer.middleware({
prefix: '/api',
}));
app.listen(3000);
```
创建一条 posts 数据
```bash
curl -d '{"title": "title 1"}' -H 'Content-Type: application/json' http://localhost:3000/api/posts
```
## Actions
### list
查询列表数据
API
```bash
# 常规
GET /api/<resourceName>?filter[col1]=val1&fields=col1,col2&sort=-created_at&page=2&perPage=10
# 关系资源
GET /api/<associatedName>/<associatedKey>/<resourceName>?filter[col1]=val1&fields=col1,col2&sort=-created_at&page=2&perPage=10
```
SDK
```ts
// 常规
api.resource('<resourceName>').list({
filter,
fields,
sort,
page,
perPage,
});
// 关系资源
api.resource('<associatedName>.<resourceName>').list({
associatedKey,
filter,
fields,
sort,
page,
perPage,
});
```
### get
查询详情数据
API
```bash
# 常规
GET /api/<resourceName>/<resourceKey>?filter[col1]=val1&fields=col1,col2&sort=-created_at&page=2&perPage=10
# 关系资源
GET /api/<associatedName>/<associatedKey>/<resourceName>/<resourceKey>?filter[col1]=val1&fields=col1,col2&sort=-created_at&page=2&perPage=10
```
SDK
```ts
// 常规
api.resource('<resourceName>').get({
resourceKey,
filter,
fields,
sort,
page,
perPage,
});
// 关系资源
api.resource('<associatedName>.<resourceName>').get({
associatedKey,
resourceKey,
filter,
fields,
sort,
page,
perPage,
});
```
### create
新增数据
API
```bash
# 常规
POST /api/<resourceName>?fields=col1,col2
# or 关系资源
POST /api/<associatedName>/<associatedKey>/<resourceName>?fields=col1,col2
values # JSON 格式
```
SDK
```ts
// 常规
api.resource('<resourceName>').create({
fields,
values
});
// 关系资源
api.resource('<associatedName>.<resourceName>').create({
associatedKey,
fields,
values,
});
```
### update
更新数据
API
```bash
# 常规
PUT /api/<resourceName>/<resourceKey>?fields=col1,col2
# 关系资源
PUT /api/<associatedName>/<associatedKey>/<resourceName>/<resourceKey>?fields=col1,col2
values # JSON 格式
```
SDK
```ts
// 常规
api.resource('<resourceName>').update({
resourceKey,
fields,
values,
});
// 关系资源
api.resource('<associatedName>.<resourceName>').update({
associatedKey,
resourceKey,
fields,
values,
});
```
### destroy
删除数据
API
```bash
# 常规
DELETE /api/<resourceName>/<resourceKey>
# 关系资源
DELETE /api/<associatedName>/<associatedKey>/<resourceName>/<resourceKey>
# 常规,通过 filter 参数
DELETE /api/<resourceName>?filter=
# 关系资源,通过 filter 参数
DELETE /api/<associatedName>/<associatedKey>/<resourceName>?filter=
```
SDK
```ts
// 常规
api.resource('<resourceName>').destroy({
resourceKey,
filter,
});
// 关系资源
api.resource('<associatedName>.<resourceName>').destroy({
associatedKey,
resourceKey,
filter,
});
```
### set
建立关联,旧关联会解除。此操作需要显式声明 actionName。
API
```bash
POST /api/<associatedName>/<associatedKey>/<resourceName>:set/<resourceKey>
values
```
SDK
```ts
api.resource('<associatedName>.<resourceName>').set({
associatedKey,
resourceKey,
values,
});
```
### add
关联的附加操作,此操作需要显式声明 actionName。
API
```bash
POST /api/<associatedName>/<associatedKey>/<resourceName>:add/<resourceKey>
values
```
SDK
```ts
api.resource('<associatedName>.<resourceName>').add({
associatedKey,
resourceKey,
values,
});
```
### remove
移除关联,此操作需要显式声明 actionName。
API
```bash
POST /api/<associatedName>/<associatedKey>/<resourceName>:remove/<resourceKey>
```
SDK
```ts
// 常规
api.resource('<resourceName>').remove({
resourceKey,
});
// 关系资源
api.resource('<associatedName>.<resourceName>').remove({
associatedKey,
resourceKey,
});
```
### toggle
API
```bash
POST /api/<associatedName>/<associatedKey>/<resourceName>:toggle/<resourceKey>
```
SDK
```ts
// 常规
api.resource('<resourceName>').list({
resourceKey,
fields,
});
// 关系资源
api.resource('<associatedName>.<resourceName>').list({
associatedKey,
resourceKey,
fields,
});
```
### sort
API
```bash
# 常规
GET /api/<resourceName>:sort/<resourceKey>
# 关系资源
GET /api/<associatedName>/<associatedKey>/<resourceName>:sort/<resourceKey>
```
SDK
```ts
// 常规
api.resource('<resourceName>').sort({
resourceKey,
values: {
field, // 默认为 sort 字段
target: {
id: 5,
},
},
});
// 关系资源
api.resource('<associatedName>.<resourceName>').list({
associatedKey,
resourceKey,
values: {
field, // 默认为 sort 字段
target: {
id: 5,
},
},
});
```
## Middlewares
### associated
注入 associated 实例,同时会提供 resourceField 字段。
<Alert title="resourceField 和 resourceName 的区别?" type="warning">
关系资源 `<associatedName>.<resourceName>`resourceName 并不一定是真实的 tableName而是 fieldName。resource 的 tableName 为 resourceField.target。
</Alert>
### dataWrapping
输出的 JSON 会用 data 包裹。
用法:
```ts
app.use(dataWrapping);
```
用例:
```ts
ctx.body = [];
```
最终输出的 response body 为:
```ts
{
data: [],
}
```

View File

@ -0,0 +1,74 @@
---
title: '@nocobase/blocks'
order: 6
---
# @nocobase/blocks <Badge>未实现</Badge>
<Alert title="注意" type="warning">
因为前端代码的打包还有许多细节问题,核心代码暂时也都放在 @nocobase/app 里了。
v0.4 版本 components 分为了 Action、Field、View 三部分,下一把版本打算都整合到 Block 里。
</Alert>
在 NocoBase 中,区块是所有 HTML 元素片段(包括 React/Vue 等框架的自定义元素)的总称。区块可以任意组合或嵌套,为了方便使用,内置了常用的一些区块,大致分类有:
- Database - 数据
- Fields - 字段
- Buttons - 按钮
- Media - 多媒体
- Design - 设计
- 自定义区块
## Database - 数据
数据类型的区块需要绑定数据源。
### table
### form
### descriptions
### calendar
### kanban
## Fields - 字段
字段是一种特殊的子区块,只用于数据类型区块中,有两种形态:静态纯展示和动态可输入。
### boolean
### checkbox
### cascader
### time
### markdown
### string
### icon
### textarea
### number
### remoteSelect
### drawerSelect
### colorSelect
### subTable
### icon
## Buttons - 按钮
### create
### update
### destroy
### filter
### print
### export
### button
## Media - 多媒体
### markdown
## Design - 设计
### grid
### drawer
### page
## 自定义区块

View File

@ -0,0 +1,732 @@
---
title: '@nocobase/database'
order: 1
# toc: menu
group:
order: 1
title: 核心库
---
# @nocobase/database
## 介绍
用于配置数据表Table提供数据操作Model可单独使用。核心主要有两个东西
- Table描述数据的类型、结构和关系
- Model数据库操作
<Alert title="Table 和 Model 的关系" type="warning">
Model 是动态的,由 Table 初始化一般并不需要单独配置。Model 目前已经适配了 Sequelize.Model如果需要后续还可以适配 Mongoose.Model 等。Table 也可以更进一步抽象,提供更灵活的适配方法。
</Alert>
## 安装
```bash
yarn add @nocobase/database
```
## Usage
```ts
import Database from '@nocobase/database';
const db = new Database({
// 省略配置信息
});
const table = db.table({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
],
});
table.addField({ type: 'text', name: 'content' });
await table.sync();
const Post = db.getModel('posts');
const post = await Post.create({
title: '这是标题',
content: '这是内容',
});
```
## Field Types
<Alert title="注意" type="warning">
在 NocoBase 里Field Type 表示字段的数据类型。
</Alert>
在 JSON 里,数据有六种基本类型:
- boolean
- string
- number
- object
- array
- null
其中表示数据的结构有两种:
- object
- array
两种结构可以内嵌再结合其他数据类型可以呈现出无数种结构化的数据。JSON 数据是可以直接储存的,但是可能会产生冗余,所以在关系型数据库的设计规范中,会根据需要把 object 或 array 的数据存放在另一张表里,然后通过主外键字段建立关联。在关系型数据库里,数据有四种基本关系:
- O2O/hasOne - 一对一
- O2M/hasMany - 一对多
- M2O/belongsTo - 多对一
- M2M/belongsToMany - 多对多
- Polymorphic - 多态(反模式)
字段的数据类型Field Type也由此扩展而来以下为 JSON 类型与字段数据类型的对应关系:
- booleanboolean
- stringstring、text、time、date
- numberinteger、float、double、decimal、real
- objecthasOne、belongsTo
- arrayhasMany、belongsToMany
- null不填写时为空需要区分空字符串
- json一种特殊的数据类型即使是根节点也可以存任意类型数据
除此之外,字段类型还提供了 virtual 的情况,是一种虚拟字段
### boolean
布尔型
### integer
整型
### float
单精度浮点型
### double
双精度浮点型
### decimal
货币类型
### string
字符串
### text
长文本
### json <Badge>待完善</Badge>
JSON 是个特殊的数据类型,在不同数据库里有所不同,目前只兼容了 PostgreSQL 的一些情况。
### time
时间
### date
日期
### virtual
虚拟字段
```ts
interface VirtualOptions {
name: string;
type: 'virtual';
get?(this: M): unknown;
set?(this: M, val: unknown): void;
}
```
### formula <Badge>待完善</Badge>
计算公式,一种特殊的虚拟字段
```ts
interface FormulaOptions {
name: string;
type: 'formula';
/**
* 计算公式
*/
formula: string;
/**
* 输出格式
*/
format: 'string' | 'number';
}
```
### reference <Badge>未实现</Badge>
引用
```ts
interface ReferenceOptions {
name: string;
type: 'reference';
/**
* 数据路径
*/
dataIndex: string;
}
```
### password
密码
```ts
interface PasswordOptions {
name: string;
type: 'password';
/**
* 暂时只支持 bcrypt 算法
*
* 默认值bcrypt
*/
algo: 'bcrypt' | 'argon2';
}
```
### sort
顺序类型
```ts
interface SortOptions {
name: string;
type: 'sort';
/**
* 限定范围
*/
scope?: string[];
/**
* 新值创建策略
*
* max: 使用最大值
* min: 使用最小值
*
* Defaults to 'max'
*/
next?: 'min' | 'max';
}
```
### radio
radio 类型
```ts
interface RadioOptions {
name: string;
type: 'radio';
/**
* 限定范围
*/
scope?: string[];
}
```
### hasOne
一对一
```ts
interface HasManyOptions {
type: 'hasMany';
/**
* 关系字段名称
*/
name: string;
/**
* 目标数据表名称
*
* 默认值:<name>
*/
target?: string;
/**
* 目标数据表字段(外键字段)
*
* 默认值:<targetName>_<targetPrimaryKeyAttribute>
*/
foreignKey?: string;
/**
* 来源数据表字段(一般为主键字段)
*
* 默认值:<sourcePrimaryKeyAttribute>
*/
sourceKey?: string;
}
```
### hasMany
一对多
```ts
interface HasManyOptions {
type: 'hasMany';
/**
* 关系字段名称
*/
name: string;
/**
* 目标数据表名称
*
* 默认值:<name>
*/
target?: string;
/**
* 目标数据表字段(外键字段)
*
* 默认值:<targetName>_<targetPrimaryKeyAttribute>
*/
foreignKey?: string;
/**
* 来源数据表字段(一般为主键字段)
*
* 默认值:<sourcePrimaryKeyAttribute>
*/
sourceKey?: string;
}
```
### belongsTo
多对一
```ts
interface BelongsToOptions {
type: 'belongsTo';
/**
* 关系字段名称
*/
name: string;
/**
* 目标数据表名称
*
* 默认值:<name plural>
*/
target?: string;
/**
* 来源数据表字段(外键字段)
*
* 默认值:<sourceName>_<sourcePrimaryKeyAttribute>
*/
foreignKey?: string;
/**
* 目标数据表字段(一般为主键字段)
*
* 默认值:<targetPrimaryKeyAttribute>
*/
targetKey?: string;
}
```
### belongsToMany
多对多
```ts
interface BelongsToManyOptions {
type: 'belongsToMany';
/**
* 关系字段名称
*/
name: string;
/**
* 目标数据表名称
*
* 默认值:<name>
*/
target?: string;
/**
* 中间表名称
*
* 默认值sourceName 和 targetName 按字母顺序排序之后连接
*/
through?: string;
/**
* 来源数据表字段(一般为主键字段)
*
* 默认值:<sourcePrimaryKeyAttribute>
*/
sourceKey?: string;
/**
* 来源数据表字段(外键字段)
*
* 默认值:<sourceName>_<sourcePrimaryKeyAttribute>
*/
foreignKey?: string;
/**
* 目标数据表字段(一般为主键字段)
*
* 默认值:<targetPrimaryKeyAttribute>
*/
targetKey?: string;
/**
* 目标数据表字段(外键字段)
*
* 默认值:<targetName>_<targetPrimaryKeyAttribute>
*/
otherKey?: string;
}
```
### 字段扩展
以密码字段为例:
```ts
import bcrypt from 'bcrypt';
import { StringOptions, FieldContext, registerFields, getField } from '@nocobase/database';
export class PASSWORD extends STRING {
getDataType() {
return DataTypes.STRING;
}
constructor(options: StringOptions, context: FieldContext) {
super(options, context);
const Model = context.sourceTable.getModel();
Model.addHook('beforeCreate', PASSWORD.hash.bind(this));
Model.addHook('beforeUpdate', PASSWORD.hash.bind(this));
}
static async hash(this: PASSWORD, model) {
const { name } = this.options;
if (!model.changed(name as any)) {
return;
}
const value = model.get(name) as string;
if (value) {
if (value.startsWith('$2b$10$') && value.length === 60) {
return;
}
const hash = await bcrypt.hash(value, 10);
model.set(name, hash);
} else {
model.set(name, null);
}
}
static async verify(value: string, hash: string) {
return await bcrypt.compare(value, hash);
}
}
registerFields({ PASSWORD });
const db = new Database({
// 省略配置信息
});
const table = db.table({
name: 'users',
fields: [
{ type: 'password', name: 'password' },
],
});
await table.sync();
const User = db.getModel('users');
const user = User.create({
password: '123456',
});
const Pwd = getField('password');
await Pwd.verify('123456', user.password); // true
```
## API
### registerField
注册字段
### registerFields
批量注册字段,用法同 registerField
### getField
获取已注册字段类
<Alert title="注意" type="warning">
虽然直接 import/require 已导出的字段类型也可以,但是更推荐用 getField 来处理。
</Alert>
### registerModel
注册模型
### registerModels
批量注册模型,用法同 registerModel
### getRegisteredModel
获取已注册字段类型
### extend <Badge>实验性</Badge>
与 database.import 配合使用
### Operator.register <Badge>实验性</Badge>
注册自定义 op
### database.constructor
初始化实例
### database.import
批量导入配置
### database.table
配置数据表
### database.extend
配置扩展
### database.isDefined
判断数据表是否已定义
### database.getModel
获取已定义的 Model
### database.getModels
批量获取已定义的 Models
### database.getTable
获取已定义的 Table
### database.getTables
批量获取已定义的 Tables
### database.sync
数据表配置与数据库表结构同步
### database.close
关闭数据库连接
### database.addHook <Badge>待完善</Badge>
为数据表配置提供的钩子,目前 HookType 有:
- beforeTableInit
- afterTableInit
- beforeAddField
- afterAddField
### database.runHooks <Badge>待完善</Badge>
运行当前钩子挂载的函数
### table.getOptions
获取数据表配置
### table.getModel
获取当前配置表对应的 Model
### table.hasField
判断字段是否存在
### table.getField
获取已配置字段
### table.addField
新增字段(附加操作)
### table.setFields
批量新增字段(替换操作)
### table.getFields
获取当前表字段列表
### table.addIndex
建立索引
### table.addIndexes
批量建立索引,同 table.addIndex
### table.extend
配置扩展
### table.sync
当前表配置与数据库表结构同步
### Model.database
获取当前 database 实例
### Model.parseApiJson
将 filter、fields、sort 等参数转换为 where、attributes、include、order 等
#### filter
过滤条件,支持两种格式
```ts
{
filter: {
[field: string]: {
[operator: Op]: any,
}
},
}
```
逻辑运算符 - and/or
```ts
{
['and'|'or']: [
{
[field: string]: {
[operator: Op]: any,
}
},
{
['and'|'or']: [
{
[field: string]: {
[operator: Op]: any,
}
},
],
},
],
}
```
#### fields
简单用法(同 only 用法):
```ts
{
fields: ['col1', 'col2'],
}
```
复杂用法:
```ts
{
fields: {
// 附加,一般用于关系字段(默认不输出关系字段数据)
appends: ['col1', 'col2'],
// 白名单
only: ['col1', 'col2'],
// 黑名单
except: ['col1', 'col2'],
},
}
```
#### sort
排序
```ts
{
sort: [
'-created_at', // 优先创建时间倒序
'id' // 其次 id 正序
],
}
```
### model.database
获取当前 database 实例
### model.updateAssociations
更新关系数据
### model.getValuesByFieldNames <Badge>实验性</Badge>
获取当前 model 在 scope 范围内的值

View File

@ -0,0 +1,563 @@
---
title: '@nocobase/resourcer'
order: 2
# toc: menu
---
# @nocobase/resourcer
## 介绍
提供数据资源操作方法,可单独使用。
基于资源resource和操作方法action设计将 REST 和 RPC 思想融合起来为资源提供操作方法执行方法时提供相关参数params
特点:
- 可注册的 Middlewares 和 Actions
- 提供灵活的多来源 Action Params 合并方案
- 不局限于 Koa 框架
- 不局限于 HTTP
<Alert title="何为多来源 Action Params" type="info">
以 filter 为例,某数据表的过滤条件可能来自:
- 客户端请求参数
- 视图限定了筛选范围
- 权限也可能限定了筛选范围
- 通过中间件注入定制化的过滤条件
resourcer 提供了非常方便的接口用于处理参数的合并(包括自定义合并或替换规则)
</Alert>
资源有五种类型:
- single 独立资源
- hasOne 一对一的关系资源
- hasMany 一对多的关系资源
- belongsTo 一对一的关系资源
- belongsToMany 多对多的关系资源
举例说明:
- users独立资源
- posts独立资源
- users.profile关系资源一对一关系 User.hasOne(Profile),资源为 profile属于 users
- posts.user关系资源多对一关系 Post.belongsTo(User),资源为 user属于 posts
- users.posts关系资源一对多关系 User.hasMany(Post),资源为 posts属于 users
- posts.tags关系资源多对多关系 Post.belongsToMany(Tag),资源为 tags属于 posts。
资源名称的格式:
- resourceName独立资源
- associatedName.resourceName关系资源
操作名称的格式:
- create全局操作
- resourceName:create隶属某个资源的操作
- associatedName.resourceName:create隶属某个关系资源的操作
这种资源和操作的格式,在 SDK 的协助下,看起来会更加直观,如:
```ts
api.resource('users').login({
// 省略具体参数
});
```
<Alert title="为什么不是 REST 或 GraphQL" type="warning">
Resourcer 是基于资源设计的,可以看做是 REST 的扩展(兼容的同时,但有些许不同),提供了类 REST 的 Resource Action API[点此查看细节](#resourcerkoarestapimiddleware)),非常灵活,同时弥补了 REST 的一些缺陷。不用 GraphQL 的原因是因为还不够完善,尤其为了追求 GraphQL 需要写很多代码,并不适合作为常规 API 开放给大众。后续如果反应强烈会考虑适配 GraphQL其他感兴趣的开发也可以自由发挥来完善 GraphQL API。
</Alert>
## 安装
```bash
yarn add @nocobase/resourcer
```
## Usage
```ts
import Resourcer from '@nocobase/resourcer';
const resourcer = new Resourcer();
resourcer.registerActions({
async list(ctx, next) {
ctx.arr.push(3);
await next();
ctx.arr.push(4);
},
async create(ctx, next) {
ctx.arr.push(5);
await next();
ctx.arr.push(6);
},
});
resourcer.define({
name: 'users',
});
const context = {
arr: [],
};
await resourcer.execute({
resource: 'users',
action: 'list',
}, context);
console.log(context.arr);
// [1,3,4,2]
```
## API
### context.action.params
action.params 分为三类:
- 用于定位资源和操作的参数(这些暂时也放在 action.params 里了)
- actionName
- resourceName
- associatedName
- 定位资源 ID 相关参数
- resourceKey
- associatedKey
- request 的 query 和 body 相关参数
- filter
- fields
- sort
- page
- perPage
- values 对应为 request.body
- 其他 query params
#### actionName
资源操作名称
#### resourceName
资源名称
#### associatedName
所属资源名称
#### resourceKey
资源 ID
#### associatedKey
所属资源 ID
#### filter
过滤条件
#### fields
字段
#### sort
排序
#### page
分页
#### perPage
每页显示条数
#### values
body 数据
#### 其他自定义参数
待补充...
### context.action.mergeParams
为 action.params 提供的多来源参数合并的方法
```ts
action.mergeParams({
filter: {col1: 'val1'},
});
```
<Alert title="注意" type="warning">
后续可能会使用 [deepmerge](https://www.npmjs.com/package/deepmerge) 重构,以便处理更灵活的自定义合并规则。
</Alert>
### resourcer.define
配置资源
用例:
```ts
resourcer.define({
name: 'posts',
middlewares: [
async (ctx, next) => {
await next();
},
{
only: [],
except: [],
handler: async (ctx, next) => {
await next();
},
},
],
actions: {
async get(ctx, next) {
await next();
},
list: {
fields,
filter,
sort,
page,
perPage,
middlewares: [],
handler: async (ctx, next) => {
await next();
},
},
},
});
```
### resourcer.execute <Badge>实验性</Badge>
注入 context
### resourcer.import
批量导入已配置的资源
### resourcer.isDefined
判断资源是否已定义
### resourcer.koaRestApiMiddleware
原 resourcer.middleware
为 Koa 提供的类 REST API 中间件。提供了标准的 REST API 映射Resource Action 与 Request Method 的对应关系如下:
| Resource Action | Request Method |
| :-------------- | :---------------------- |
| list | GET \<collection URL\> |
| get | GET \<resource URL\> |
| create | POST \<collection URL\> |
| update | PUT \<resource URL\> |
| destroy | DELETE \<resource URL\> |
标准的 REST API 映射下actionName 可以缺失,但也可以显式声明,当指定 actionName 时,将不受 Request Method 影响。相关 HTTP 格式如下:
```bash
# 独立资源
<requestMethod> /api/<resourceName>:<actionName>?<queryString>
<requestMethod> /api/<resourceName>:<actionName>/<resourceKey>?<queryString>
# 关系资源
<requestMethod> /api/<associatedName>/<associatedKey>/<resourceName>:<actionName>?<queryString>
<requestMethod> /api/<associatedName>/<associatedKey>/<resourceName>:<actionName>/<resourceKey>?<queryString>
<body> # 非 GET 请求时,可以提供 body 数据,一般为 JSON 格式
```
为了让大家更理解 Resource Action API 设计,我们接下来举几个具体的例子:
##### 查看文章列表
```bash
GET /api/posts?filter={"col1": "val1"}&fields=col1,col2&sort=-created_at
```
对应的 context.action.params 为:
```ts
{
actionName: 'list',
resourceName: 'posts',
filter: {'col1': 'val1'},
fields: ['col1', 'col2'],
sort: ['-created_at'],
}
```
##### 新增文章
```bash
POST /api/posts
{"title": "title1"}
```
对应的 context.action.params 为:
```ts
{
resourceName: 'posts',
actionName: 'create',
values: {
title: 'title1',
},
}
```
##### 查看文章详情
```bash
GET /api/posts/1?fields=col1,col2
```
对应的 context.action.params 为:
```ts
{
resourceName: 'posts',
resourceKey: 1,
actionName: 'get',
fields: ['col1', 'col2'],
}
```
##### 更新文章
```bash
PUT /api/posts/1
{"title": "title1"}
```
对应的 context.action.params 为:
```ts
{
resourceName: 'posts',
resourceKey: 1,
actionName: 'update',
values: {
title: 'title1',
},
}
```
##### 删除文章
```bash
DELETE /api/posts/1
```
对应的 context.action.params 为:
```ts
{
resourceName: 'posts',
resourceKey: 1,
actionName: 'destroy',
}
```
##### 文章评论列表
```bash
GET /api/posts/1/comments?filter={"col1": "val1"}&fields=col1,col2&sort=-created_at
```
对应的 context.action.params 为:
```ts
{
associatedName: 'posts',
associatedKey: 1,
resourceName: 'comments',
actionName: 'list',
filter: {'col1': 'val1'},
fields: ['col1', 'col2'],
sort: ['-created_at'],
}
```
##### 文章评论详情
```bash
GET /api/posts/1/comments/2
```
对应的 context.action.params 为:
```ts
{
associatedName: 'posts',
associatedKey: 1,
resourceName: 'comments',
resourceKey: 2,
actionName: 'get',
}
```
##### 显式声明 actionName 的例子
```bash
POST /api/users:login
{"username": "admin", "password": "password"}
```
对应的 context.action.params 为:
```ts
{
resourceName: 'users',
actionName: 'login',
values: {
username: 'admin',
password: 'password',
},
}
```
当不指定 actionName 时,默认为 create对应的是「新建用户」操作但是指定 actionName=login 时,就变为了「用户登录」操作了。
<Alert title="注意" type="warning">
在 Resource Action API 的设计理念里,即使类似 login、register、logout 等非标准的 REST API 也可以非常方便的扩展。大家可以更专注于 action 本身,而不必纠结于 request method 和 route 应该如何设计,也不需要考虑 routes 优先级等问题。
</Alert>
### resourcer.registerAction(name: ActionName, options: ActionOptions)
原 resourcer.registerActionHandler
- name操作名称
- options操作配置
可用于注册全局的或某资源特有的 action。
<Alert title="注意" type="warning">
与 resourcer.define 不同registerAction 的 actionName 支持三种格式:
- `<actionName>` 全局操作
- `<resourceName>:<actionName>` 某资源特有操作
- `<associatedName>.<resourceName>:<actionName>` 某关系资源特有操作
更复杂判断条件,需要结合 `resourcer.use` 方法一起处理
</Alert>
示例
```ts
resourcer.registerAction('actionName', async (ctx, next) => {
await next();
});
// 带配置
resourcer.registerAction('actionName', {
filter,
fields,
middlewares: [],
handler: async (ctx, next) => {
await next();
},
});
resourcer.registerAction('resourceName:actionName', async (ctx, next) => {
await next();
});
resourcer.registerAction('associatedName.resourceName:actionName', async (ctx, next) => {
await next();
});
```
<Alert title="注意" type="warning">
resourcer 使用 koa-compress 来处理中间件,是一种洋葱圈模型,因此在 action handler 里也不要忘了 `next()`,不然会影响后置逻辑的处理。
</Alert>
更复杂的情况:
```ts
resourcer.use(async (ctx, next) => {
const { actionName, resourceName } = ctx.action.params;
if (actionName === 'foo') {
// 其他判断条件
// 不符合条件的 404 处理
ctx.throw(404);
}
await next();
});
```
### resourcer.registerActions(actions)
原 resourcer.registerActionHandlers
批量注册 actions用法同 [resourcer.registerAction](#resourcerregisterActionname-actionname-handler-handlertype)
示例:
```ts
resourcer.registerActions({
async foo(ctx, next) {
await next();
},
async bar(ctx, next) {
await next();
},
});
```
### resourcer.registerActionMiddleware(actionName, handler) <Badge>未实现</Badge>
为某操作action注册特有的 middleware
### resourcer.registerResourceMiddleware(resourceName, options) <Badge>未实现</Badge>
为某资源resource注册特有的 middleware
### resourcer.use
注册 resourcer 全局 middleware
<Alert title="为什么要提供不同的中间件注册方法?" type="warning">
虽然大部分框架都提供了中间件,但是中间件的执行顺序(优先级)依赖于编码顺序,这种方式非常不利于插件化管理。因此,在 Resourcer 设计思想里,将中间件做了分层,不同层级的 middlewares 不依赖于编码顺序,而是如下顺序:
1. 首先koa 层:`koa.use`
2. 其次resourcer 层:`resourcer.use`
3. 再次resource 层(每个资源独立):`resourcer.registerActionMiddleware`
4. 最后action 层:`resourcer.registerResourceMiddleware`
不过,每个层次的中间件执行顺序还依赖于编码顺序,如有需要再进行更细微的改进。
</Alert>

View File

@ -0,0 +1,87 @@
---
title: '@nocobase/sdk'
order: 3
# toc: menu
---
# @nocobase/sdk
## 介绍
## 安装
```bash
yarn add @nocobase/sdk
```
## Usage
SDK
```ts
import API from '@nocobase/sdk';
const api = new API({
baseUrl: 'http://localhost:3000/api'
});
// 细节待补充
await api.resource('demos').list();
await api.resource('demos').create();
await api.resource('demos').get();
await api.resource('demos').update();
await api.resource('demos').destroy();
```
HTTP API
```bash
GET http://localhost:3000/api/demos
POST http://localhost:3000/api/demos
GET http://localhost:3000/api/demos/1
PUT http://localhost:3000/api/demos/1
DELETE http://localhost:3000/api/demos/1
```
## API
```bash
# 独立资源
<requestMethod> /api/<resourceName>:<actionName>?<queryString>
<requestMethod> /api/<resourceName>:<actionName>/<resourceKey>?<queryString>
# 关系资源
<requestMethod> /api/<associatedName>/<associatedKey>/<resourceName>:<actionName>?<queryString>
<requestMethod> /api/<associatedName>/<associatedKey>/<resourceName>:<actionName>/<resourceKey>?<queryString>
<body> # 非 GET 请求时,可以提供 body 数据,一般为 JSON 格式
```
## SDK
与 context.action.params 参数大体一致:
```ts
api.resource('<resourceName>').<actionName>({
resourceKey,
filter,
fields,
sort,
page,
perPage,
values,
});
// 关系资源
api.resource('<associatedName>.<resourceName>').<actionName>({
associatedKey,
resourceKey,
filter,
fields,
sort,
page,
perPage,
values,
});
```

View File

@ -0,0 +1,146 @@
---
title: '@nocobase/server'
order: 5
# toc: menu
---
# @nocobase/server
## 介绍
提供最小核心的 NocoBase 服务
## 安装
```bash
yarn add @nocobase/server
```
## Usage
```ts
import { Application } from '@nocobase/server';
const api = new Application({
database: {},
resourcer: {},
});
// 配置数据表
api.database.table({
name: 'demos',
fields: [
{ type: 'string', name: 'name' },
],
});
await api.database.sync();
app.listen(3000);
```
HTTP API
```bash
GET http://localhost:3000/api/demos
POST http://localhost:3000/api/demos
GET http://localhost:3000/api/demos/1
PUT http://localhost:3000/api/demos/1
DELETE http://localhost:3000/api/demos/1
```
SDK
```ts
import API from '@nocobase/sdk';
const api = new API({
baseUrl: 'http://localhost:3000/api'
});
// 细节待定
api.resource('demos').list();
api.resource('demos').create();
api.resource('demos').get();
api.resource('demos').update();
api.resource('demos').destroy();
```
## Middlewares
### initializeActionParams
初始化 action.params
### appDistServe
为 app dist 提供静态文件代理服务
### dbResourceRouter
resource 动态初始化,如果 resource 不存在,从 database 里同步。
<Alert title="注意" type="warning">
与 resourcer.koaRestApiMiddleware 方法存在大量重复,需要把 database 与 resource 的同步逻辑提炼出来
</Alert>
### demoBlacklistedActions
actions 黑名单
```ts
app.use(demoBlacklistedActions({
blacklist: [],
}));
```
## API
### Server
Server 继承 Koa更多用法可查阅 Koa API
#### server.constructor
初始化 server 实例
#### server.database
当前 server 实例的 database
#### server.resourcer
当前 server 实例的 resourcer
#### server.pluginManager <Badge>未实现</Badge>
当前 server 实例的 pluginManager
### PluginManager <Badge>未实现</Badge>
插件管理器
<Alert title="注意" type="warning">
不同 server 实例也可能需要 PluginManager后续 CLI 和后台可管理也都需要,插件管理器独立出来处理比较合适。
</Alert>
#### pluginManager.register(name, options)
注册插件
#### pluginManager.has(name)
判断插件是否存在
#### pluginManager.get(name)
获取当前插件实例
#### pluginManager.load()
加载插件
#### pluginManager.reload()
重载插件

View File

@ -0,0 +1,72 @@
---
title: '@nocobase/test'
order: 7
---
# @nocobase/test
## mockDatabase
为 database 提供的测试套件,同时提供数据 mock使用 mockjs
```ts
import { mockDatabase } from '@nocobase/test';
describe('test', () => {
let db;
beforeEach(async () => {
db = mockDatabase({});
db.table({
name: 'examples',
fields: [{
name: 'name',
type: 'string',
mock: {
"1-10": "★"
},
}],
});
await db.sync();
});
afterEach(async () => {
await db.close();
});
it('test model', () => {
const Test = db.getModel('tests');
Test.mockCreate({});
Test.mockBulkCreate([{}]);
});
});
```
## mockServer
为 server 提供的测试套件
```ts
import { mockServer } from '@nocobase/test';
describe('test', () => {
let api;
beforeEach(async () => {
api = mockServer({});
await api.database.sync();
});
afterEach(async () => {
await api.database.close();
});
it('test resource', () => {
await api.resource('demos').get();
});
it('test request', () => {
await api.request().get('/');
});
});
```

118
docs/guide/config.md Normal file
View File

@ -0,0 +1,118 @@
---
title: 配置文件
order: 3
toc: menu
---
# 配置文件
<Alert title="注意" type="warning">
暂时只支持 <code>.env</code> 环境变量配置,这种方式有些局限,接下来会提供更完整的 <code>.nocobaserc.ts</code> 文件,用于支持更灵活的配置,包括但不局限于数据库信息、插件等。
</Alert>
## .env
### 数据库信息
<Alert title="注意" type="warning">
暂时只提供了核心的一些配置参数,后续还需优化
</Alert>
#### DB_DIALECT
#### DB_HOST
#### DB_PORT
#### DB_DATABASE
#### DB_USER
#### DB_PASSWORD
#### DB_LOG_SQL
### APP 信息
<Alert title="注意" type="warning">
暂时只提供了核心的一些配置参数,后续还需优化
</Alert>
#### NOCOBASE_ENV
NOCOBASE 环境
#### API_PREFIX
API 前缀
#### API_PORT
API 端口
#### APP_DIST
APP 前端静态文件路径
#### APP_USE_STATIC_SERVER
APP 前端静态文件是否使用 koa-static 代理。如不需要,你也可以使用 nginx 等服务。
### ADMIN 账号
<Alert title="注意" type="warning">
仅用于初始化配置,后续会集成到 cli 里,安装时用户可输入。
</Alert>
#### ADMIN_EMAIL
初始化的管理员邮箱
#### ADMIN_PASSWORD
初始化的管理员密码
### 文件管理器插件
<Alert title="注意" type="warning">
仅用于初始化,目前还未提供插件管理面板,如果有修改,需要去 storages 表里修改。后续优化会集成到 cli 里,在插件安装时由用户输入,或通过插件管理器修改。
</Alert>
#### STORAGE_TYPE
目前已支持的 storage 有:
- `local` 本地
- `ali-oss` 阿里云 OSS
#### LOCAL_STORAGE_USE_STATIC_SERVER
文件静态文件是否使用 koa-static 代理。如不需要,你也可以使用 nginx 等服务。
#### LOCAL_STORAGE_BASE_URL
静态文件地址前缀
#### ALI_OSS_REGION
#### ALI_OSS_ACCESS_KEY_ID
#### ALI_OSS_ACCESS_KEY_SECRET
#### ALI_OSS_BUCKET
#### ALI_OSS_STORAGE_BASE_URL
## .nocobaserc.ts
<Alert title="注意" type="warning">
暂不支持 .nocobaserc 配置
</Alert>
### 数据库
待补充...
### APP 信息
待补充...
### 配置插件
待补充...

View File

@ -0,0 +1,196 @@
---
title: 快速上手
order: 2
toc: menu
---
# 快速上手
为大家提供了三种方式安装并运行 NocoBase
- [Npm & Yarn](#npm--yarn):仅用于无代码平台体验
- [Docker](#docker):仅用于无代码平台体验
- [Git](#git):参与项目开发
<Alert title="安装前准备" type="warning">
**OS**
- Docker
- Linux
- MacOS仅开发环境
- Windows仅开发环境没有在 win 平台测试过,可能存在一些小问题
**Node**
- Node.js 12.x or 14.x
**Database任选其一**
现阶段还是以关系型数据库为主,接下来陆续兼容更多的关系型数据库。未来 @nocobase/database 稳定之后,会考虑适配 MongoDB。因为有几处用到了 JSON 类型字段,不同数据库的 JSON 字段有差异,现只兼容了 PostgreSQL 数据库的查询。
- PostgreSQL 10.x+(推荐)
- MySQL 5.7.x+JSON 类型查询有问题,后续会提供支持)
- MariaDB 10.x未知后续会提供支持
- SQLite 3未知后续会提供支持
</Alert>
## Npm & Yarn
找个地方创建并进入新目录
```bash
mkdir my-nocobase-project && cd my-nocobase-project
```
使用 yarn 或 npm 包管理器进行初始化
```bash
yarn init # or npm init
```
安装 NocoBase 依赖
```bash
yarn add @nocobase/api @nocobase/app
```
复制并配置 .env[环境变量说明点此查看](config.md#.env)
```bash
cp -r node_modules/@nocobase/api/.env.example .env
```
数据库初始化
```bash
yarn nocobase db-init
```
启动应用
```bash
yarn nocobase start
```
<Alert title="注意" type="warning">
为什么是 @nocobase/api@nocobase/app,而不是 create-nocobase-project
早之前想提供 create-nocobase-project 的,但是有些细节还没想清楚,暂时不会提供,目前只提供了封装度较高的 @nocobase/api@nocobase/app 两个前后端包,如果有二次开发需要,可 fork 相关 api 或 app 源码,再重新构建。
</Alert>
## Docker
<Alert title="注意" type="warning">
Docker 镜像暂未发布
</Alert>
为了更方便的安装与部署NocoBase 也发布了 Docker 镜像 `nocobase/nocobase`,你可以找个地方创建并配置 `docker-compose.yml` 文件,样例如下:
```yaml
version: "3"
services:
postgres:
image: postgres:10
networks:
- nocobase
environment:
POSTGRES_USER: nocobase
POSTGRES_DB: nocobase
POSTGRES_PASSWORD: nocobase
nocobase:
image: nocobase/nocobase:latest
networks:
- nocobase
ports:
- 23000:23000
environment:
DB_DIALECT: postgres
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: nocobase
DB_USER: nocobase
DB_PASSWORD: nocobase
API_PORT: 23000
ADMIN_EMAIL: admin@nocobase.com
ADMIN_PASSWORD: admin
networks:
nocobase:
driver: bridge
```
初始化并启动 NocoBase 应用
```bash
docker-compose up -d
```
## Git
通过 git 克隆 nocobase 仓库
```bash
git clone https://github.com/nocobase/nocobase.git my-nocobase-project
```
进入项目目录
```bash
cd my-nocobase-project
```
复制并配置 .env[环境变量说明点此查看](#)
```bash
cp .env.example .env
```
安装依赖
```bash
yarn install
```
构建 packages 依赖
```bash
yarn bootstrap
yarn build
```
数据库初始化
```bash
yarn db-migrate init
```
启动应用
```bash
yarn start
```
开发过程中其他常用的命令还有:
```bash
### 测试 ###
yarn test
# or
yarn test packages/<name> packages/<name>
### 打包 ###
yarn build
# or
yarn build <package_name_1> <package_name_2> ...
```

51
docs/guide/index.md Normal file
View File

@ -0,0 +1,51 @@
---
title: 介绍
order: 1
toc: menu
nav:
title: 指南
order: 1
---
## NocoBase 是什么?
NocoBase 是一个开源免费的无代码、低代码开发平台。 无论是不懂编程的业务主管,还是精通编程的开发人员,都可以快速搭建各类定制化、私有部署的协作平台、管理系统。
## 架构
<img src="./nocobase.png" style="max-width: 800px;"/>
### 微内核
NocoBase 采用微内核架构,框架只保留核心的概念,具体各类功能都以插件的形式扩展。各个包可以拆出来单独或组合使用,可用于现有项目中,这也是渐进式框架的意义所在。除此之外,我们也非常注重与现有技术框架融合,做连接现有生态的桥梁,而不是闭门造车。
### 插件化
所有的功能需求都通过插件形式扩展,除了现有的几个核心插件以外,开发者还可以自由的扩展,包括但不局限于:
- Collection - 数据
- Relationship - 相关数据
- Field - 字段
- Model - 模型
- Hook - 触发事件
- Resource - 资源
- Action - 操作方法
- Middleware - 中间件
- Block - 区块
- Page - 页面
### 配置化驱动
配置化是常见的无代码/低代码技术方案NocoBase 也是基于配置驱动的,为了方便各类配置需求,配置有三类写法:
- 直接写在代码里,多用于处理动态配置
- 保存在文件里,多用于系统表配置或纯开发配置
- 保存在数据表里,多用于业务表配置
## 核心点在哪里?
NocoBase 本质上是对数据的信息化处理,包括三点核心:
- 数据的存储 —— 结构和关系
- 数据的行为 —— 方法和事件
- 数据的形态 —— 页面和区块

BIN
docs/guide/nocobase.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

20
docs/index.md Normal file
View File

@ -0,0 +1,20 @@
---
title: NocoBase - An open source and free no-code development platform
hero:
title: NocoBase
desc: An open source and free no-code development platform
actions:
- text: Getting Started
link: /guide
features:
- icon: https://gw.alipayobjects.com/zos/bmw-prod/881dc458-f20b-407b-947a-95104b5ec82b/k79dm8ih_w144_h144.png
title: Feature 1
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d60657df-0822-4631-9d7c-e7a869c2f21c/k79dmz3q_w126_h126.png
title: Feature 2
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d1ee0c6f-5aed-4a45-a507-339a4bfe076c/k7bjsocq_w144_h144.png
title: Feature 3
desc: Balabala
footer: Copyright © 2020-2021 NocoBase. All rights reserved.
---

20
docs/index.zh-CN.md Normal file
View File

@ -0,0 +1,20 @@
---
title: NocoBase - An open source and free no-code development platform
hero:
title: NocoBase
desc: An open source and free no-code development platform
actions:
- text: 快速上手
link: /zh-CN/guide
features:
- icon: https://gw.alipayobjects.com/zos/bmw-prod/881dc458-f20b-407b-947a-95104b5ec82b/k79dm8ih_w144_h144.png
title: 特性 1
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d60657df-0822-4631-9d7c-e7a869c2f21c/k79dmz3q_w126_h126.png
title: 特性 2
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d1ee0c6f-5aed-4a45-a507-339a4bfe076c/k7bjsocq_w144_h144.png
title: 特性 3
desc: Balabala
footer: Copyright © 2020-2021 NocoBase. All rights reserved.
---

View File

@ -0,0 +1,24 @@
---
title: Actions - 操作方法
group:
order: 2
title: 概念
---
# Actions - 操作方法
与 resourcer.registerAction 用法一致
```ts
export async function get(ctx, next) {
await next();
}
export const list = {
filter,
fields, // 初始化的参数
async handler(ctx, next) {
await next();
}
}
```

View File

@ -0,0 +1,56 @@
---
title: Blocks - 区块
---
# Blocks - 区块
## 字段区块
```ts
export default {
name: 'examples',
fields: [
{
name: 'content',
interface: 'string',
type: 'string',
block: {
type: 'textarea',
},
}
],
};
```
## 数据表区块
```ts
export default {
name: 'examples',
blocks: [
{
type: 'form',
name: 'form',
title: '表单',
fields: ['col1', 'col2'],
},
],
};
```
## 后台可配置
```ts
export const form = {
title: '表单',
options: {
// form blocks 配置项默认值
},
properties: {
// 需要开放配置的字段
},
linkages: {
// 字段联动
},
};
```

View File

@ -0,0 +1,25 @@
---
title: Collections - 数据集
---
# Collections - 数据集
与 database.table 用法一致
```ts
export default {
name: 'examples',
fields: [],
}
```
配置扩展
```ts
import { extend } from '@nocobase/database';
export default extend({
name: 'examples',
fields: [],
});
```

View File

@ -0,0 +1,117 @@
---
title: Fields - 字段
---
# Fields
一个完整的字段由「数据类型」和「区块类型」两部分参数组合而成。同一个数据类型可能对应多种区块类型同一个区块类型的数据值也可能是多种数据类型。因此提炼了字段的接口Interface作为唯一标识。
字段的 Types 和 Blocks 就不赘述了,请查阅核心章节介绍
- [Field Types](/cores/packages/database#field-types)
- [Field Blocks](/cores/packages/blocks#fields---字段)
<Alert title="注意" type="warning">
在 NocoBase 里,普通用户概念上的字段类型指的是 interfacefield type 只表示数据类型 - data typefield block 是 ui 组件同一个组件也可能表示多种字段类型interface
</Alert>
## Interfaces - 接口类型
```ts
{
// 多行文本框
interface: 'textarea',
// 长文本类型
type: 'text', // 也可以是 string
// 使用 多行文本框 区块渲染 UI
block: {
type: 'textarea',
},
}
{
// 电子邮箱
interface: 'email',
// 字符串
type: 'string',
format: 'email',
// 使用 String 区块渲染
block: {
type: 'string',
},
}
{
// 手机号
interface: 'phone',
// 字符串
type: 'string',
format: 'phone',
// 使用 String 区块渲染
block: {
type: 'string',
},
}
```
分类有:
- basic
- media
- choices
- datetime
- relation
- systemInfo
- developerMode
- others
### basic
#### string
#### textarea
#### phone
#### email
#### number
#### percent
### media
#### markdown
#### wysiwyg
#### attachment
### choices
#### select
#### multipleSelect
#### radio
#### checkboxes
#### boolean
#### chinaRegion
### datetime
#### datetime
#### time
### relation
#### subTable
#### linkTo
### systemInfo
#### createdAt
#### createdBy
#### updatedAt
#### updatedBy
### developerMode
#### primaryKey
#### sort
#### password
#### icon
#### json
### Others
以下字段可能会被遗弃
#### description
#### group
#### status

View File

@ -0,0 +1,9 @@
---
title: Hooks - 钩子
---
# Hooks - 钩子
目前支持的 hook 有两类 database.addHook 和 Model.addHook
未完待续...

View File

@ -0,0 +1,34 @@
---
title: Locales - 国际化
---
# Locales - 国际化 <Badge>未实现</Badge>
NocoBase 采用 [formatjs](https://formatjs.io/docs/getting-started/installation) 来处理国际化问题。
直接写在代码里,详情参考 formatjs 用例NocoBase 会封装一个更易用的 `__` 函数来处理国际化,如:
```ts
__('Hello, {name}');
```
在配置里有两种写法:
模板字符串:
```ts
{
name: `{{ __('Hello, {name}') }}`
}
```
json 格式:
```ts
{
name: {
'zh-CN': '{name},您好',
'en-US': 'Hello, {name}',
},
}
```

View File

@ -0,0 +1,9 @@
---
title: Middlewares - 中间件
---
# Middlewares - 中间件
## koa
## resourcer
## action

View File

@ -0,0 +1,5 @@
---
title: Migrations - 迁移
---
# Migrations - 迁移

View File

@ -0,0 +1,33 @@
---
title: Models - 模型
---
# Models - 模型
将 models 统一放在 `/src/models`、`/lib/models` 目录下,将自动导入 database。
database.table 提供的数据表配置支持指定特殊 model
```ts
import { Model } from '@nocobase/database';
class Test extends Model {
// 在这个类里可以为 Test Model 扩展其他 API
static hello() {
}
}
export default {
name: 'tests',
model: Test,
};
```
调用 Model
```ts
const Test = db.getModel('tests');
// Test 可以调用 hello 方法了
Test.hello();
```

View File

@ -0,0 +1,7 @@
---
title: Resources - 资源
---
# Resources - 资源
用法同 resourcer.define

View File

@ -0,0 +1,16 @@
---
title: '@nocobase/plugin-action-logs'
group:
order: 3
title: 官方插件
---
# @nocobase/plugin-action-logs
操作记录
## 安装
```bash
yarn nocobase pull action-logs --start
```

View File

@ -0,0 +1,100 @@
---
title: '@nocobase/plugin-automations'
---
# @nocobase/plugin-automations
提供自动化模块
## 安装
```bash
yarn nocobase pull automations --start
```
## 用例
```ts
// 省略上文
database.table({
name: 'tests',
fields: [
{
type: 'string',
name: 'name1',
},
{
type: 'string',
name: 'name2',
},
],
});
database.table({
name: 'demos',
fields: [
{
type: 'string',
name: 'col1',
},
{
type: 'string',
name: 'col2',
},
],
});
const [Automation, Test] = database.getModels(['automations', 'tests']);
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreate',
collection_name: 'tests',
});
automation.startJob('test', async (result, options) => {
// job 代码
});
// 使用内置的 job
await automation.updateAssociations({
jobs: [
{
title: 'j1',
enabled: true,
type: 'create',
collection_name: 'demos',
values: [
{
column: 'col1',
op: 'eq',
value: 'n1'
},
{
column: 'col2',
op: 'ref',
value: 'name2'
},
],
}
],
});
// tests 表新增数据会触发上面执行 job 任务
await Test.create({
name1: 'n11',
name2: 'n22',
});
```
## Model API
### Automation.load()
### automation.loadJobs()
### automation.startJob(jobName: string, callback: any)
### automation.cancelJob(jobName: string)
### Job.bootstrap()
### Job.process(result?: any, options?: any)
### Job.cancel()

View File

@ -0,0 +1,13 @@
---
title: '@nocobase/plugin-collections'
---
# @nocobase/plugin-collections
提供数据表配置接口,可通过 HTTP API 管理数据表。
## 安装
```bash
yarn nocobase pull collections --start
```

View File

@ -0,0 +1,45 @@
---
title: '@nocobase/plugin-file-manager'
---
# @nocobase/plugin-file-manager
文件管理器
## 安装
```bash
yarn nocobase pull file-manager --start
```
## Field Interfaces
### attachment
附件字段
## Action API
### upload
文件上传
```ts
// 文件管理器接口
await api.resource('attachments').upload({});
// 附件字段接口
await api.resource('users.avatar').upload({
associatedKey: 1,
});
```
## Storages
### local
本地存储
### ali-oss
阿里云 OSS

View File

@ -0,0 +1,13 @@
---
title: '@nocobase/plugin-pages'
---
# @nocobase/plugin-pages
将客户端的组件参数交由服务端管理,由服务端控制输出客户端所需的 ui schema。开发者可以随意适配任意前端组件。
## 安装
```bash
yarn nocobase pull pages --start
```

View File

@ -0,0 +1,54 @@
---
title: '@nocobase/plugin-permissions'
---
# @nocobase/plugin-permissions
提供权限模块
## 安装
```bash
yarn nocobase pull permissions --start
```
## API
<Alert title="还需改进的一些细节" type="warning">
- 提供 Permission Model 相关快捷数据操作 API
- 需要支持从代码层面快捷配置权限,无需经由后台
系统表相关操作权限可能是直接通过权限 api 配置或者 permission model api直接写入数据库
- 提供 ui schema 需要的 fields/actions/pages 相关 permission api
现在有个 ac.can 可用,实际体验不够直接
- 数据表权限设置只开放了业务表,系统表权限需要开发自行处理,但无提供相关 api
如操作记录表、中国行政区表、附件表的情况:
- 操作记录表只开放查看,只能查看自己有权限能查看的数据表的操作
- 中国行政区的省市区等数据仅登录用户可查看
- 附件暂时没做权限限制
</Alert>
### context.ac.isRoot
是否为 root 权限
### context.ac.can(collection)
判断当前用户权限,支持链式操作。
#### ac.can(collection).permissions()
获取当前 collection 的所有权限配置
#### ac.can(collection).act(actionName).any()
是否允许 collection:actionName 操作,允许则返回相关配置
#### ac.can(collection).act(actionName).one(resourceKey)
具体 resourceKey 值的 collection是否允许 collection:actionName 操作,允许则返回相关配置
#### ac.as(roles).can(collection)
指定 roles 的权限判断

View File

@ -0,0 +1,146 @@
---
title: '@nocobase/plugin-users'
---
# @nocobase/plugin-users
提供用户模块
<Alert title="注意" type="warning">
用户模块目前的实现较简单
</Alert>
## 安装
```bash
yarn nocobase pull users --start
```
## Action API
### users:check
检查用户是否已登录
```ts
await api.resource('users').check();
```
### users:login
登录
```ts
await api.resource('users').login({
values: {
email,
password,
},
});
```
### users:register
注册
```ts
await api.resource('users').register({
values: {
email,
password,
...others,
},
});
```
### users:logout
注销
<Alert title="注意" type="warning">
注销后端暂无任何处理,实际需要清除 token。
</Alert>
```ts
await api.resource('users').logout();
```
### users:lostpassword
忘记密码
```ts
await api.resource('users').lostpassword({
values: {
email,
}
});
```
### users:resetpassword
重置密码
<Alert title="注意" type="warning">
未实现邮件发送
</Alert>
```ts
await api.resource('users').lostpassword({
values: {
email,
password,
reset_token,
}
});
```
### users:getUserByResetToken
根据 reset token 获取用户信息
```ts
await api.resource('users').getUserByResetToken({
values: {
reset_token,
}
});
```
## Fields Types
### context <Badge>未实现</Badge>
上下文类型,可以从 app.context 里获取信息,如 UA、Client IP 等。利用 context 类型createdBy/updatedBy 的实现也变得更简单了:
createdBy
```ts
{
name: 'created_by_id',
type: 'context',
dataIndex: 'state.currentUser.id',
createOnly: true,
}
```
updatedBy
```ts
{
name: 'updated_by_id',
type: 'context',
dataIndex: 'state.currentUser.id',
}
```
## Field Interfaces
### createdBy
创建人
### updatedBy
最后更新人

View File

@ -0,0 +1,12 @@
---
title: 开发者模式
order: 3
toc: menu
---
# 开发者模式 <Badge>待完善</Badge>
开发者模式是专门为开发者提供的修改、调试复杂配置项的运行环境。在开发者模式下:
- 可以查看系统配置表
- 配置开放 JSON Editor可以修改和调试更多隐藏配置项

View File

@ -0,0 +1,67 @@
---
title: 了解插件
order: 1
nav:
title: 插件
order: 3
path: /plugins
group:
order: 1
title: 教程
---
# 了解插件
NocoBase 核心提供了丰富的 API 用于处理扩展,但是直接调用底层 API 成本较高。因此,又提供了更为灵活、便捷的插件化管理方式,用户只需要将代码放在约定的几个目录里即可。
<Alert title="重要提示" type="warning">
NocoBase 插件之间是平行的,不存在直接的依赖关系,不过插件在加载时可能有优先级。
</Alert>
## 目录结构 <Badge>未实现</Badge>
```bash
|- @nocobase/plugin-[name] 或 nocobase-plugin-[name]
|- src
|- actions
|- collections
|- fields
|- hooks
|- interfaces
|- middlewares
|- models
|- resources
|- blocks
```
<Alert title="注意" type="warning">
目前 v0.4 版本的插件还十分简陋,只提供了一个非常原生态的函数扩展,其他的都需要开发者根据情况调用核心 API 来完成各类功能扩展,并未提供约定式目录,也没有完整的生命周期机制。没有安装/卸载、激活/禁用,加载即激活。
</Alert>
## PluginManager <Badge>未实现</Badge>
插件的几个状态
- 下载
- 启动
- 停止
- 重启
- 删除
### API <Badge>未实现</Badge>
- `pluginManager.pull()`
- `pluginManager.start()`
- `pluginManager.stop()`
- `pluginManager.restart()`
- `pluginManager.remove()`
### CLI <Badge>未实现</Badge>
- `yarn nocobase pull <name>`
- `yarn nocobase start <name>`
- `yarn nocobase stop <name>`
- `yarn nocobase restart <name>`
- `yarn nocobase remove <name>`

View File

@ -0,0 +1,23 @@
---
title: 插件命令行操作
order: 2
toc: menu
---
# 插件命令行操作 <Badge>未实现</Badge>
## pull - 拉取
将插件包从远程下载到本地列表
## start - 启动
启动插件
## stop - 停止
停止插件
## rm - 移除
插件将从本地列表移除

View File

@ -0,0 +1,74 @@
---
title: NocoBase 生命周期
order: 2
toc: menu
---
# NocoBase 生命周期
每个 NocoBase 实例在被创建时都要经过一系列的初始化过程在这个过程中某些节点会运行一些函数这些节点就是生命周期的钩子有了钩子的存在代码不论按什么顺序书写都会按照既定的顺序执行。NocoBase 的生命周期大概分为四个环节:
1. 初始化实例
2. 加载配置
3. 数据库操作
4. 每次请求中
## 初始化实例
- koanocobase 实例
- database数据库实例
- resourcerkoa 的分支,负责 resource router
- pluginManager插件管理器实例
现阶段的设计 sever 直接继承了 koa application其他三个作为 server 实例的成员存在。
<Alert title="注意" type="warning">
pluginManager 的初始化存在较大缺陷,在初始化时直接加载了配置,暂时无法处理数据库操作。
</Alert>
## 加载配置
- table hooks表配置事件
- table options表配置
- model hooksmodel 事件
系统表配置从文件目录里导入,业务表配置从数据库里导入,部分开放的系统表从文件目录导入之后,又从数据库里更新,如用户表。
<Alert title="注意" type="warning">
resourcer 配置是运行时初始化,在 koa middleware 中。hooks 分 table、model、plugin、resourcer 四类,暂时并不统一。
另外,还有个非常重要的细节,生命周期解决了大结构的执行顺序,但并未解决同一挂载点多钩子之间的执行顺序。
</Alert>
## 数据库操作
- initialize初始化app 启动或重启时都执行
- install安装操作只执行一次
- upgrade更新操作只执行一次
- uninstall卸载操作只执行一次
<Alert title="注意" type="warning">
目前还不支持,这部分的钩子主要用于管理插件。
</Alert>
## 每次请求中
- koa middleware
- resourcer middleware
- resource middleware
- action middleware
- action handler
客户端请求时,都会执行。
虽然大部分框架都提供了中间件,但是中间件的执行顺序(优先级)依赖于编码顺序,这种方式非常不利于插件化管理。因此,在 Resourcer 设计思想里,将中间件做了分层,不同层级的 middlewares 不依赖于编码顺序,而是如下顺序:
1. 首先koa 层:`koa.use`
2. 其次resourcer 层:`resourcer.use`
3. 再次resource 层(每个资源独立):`resourcer.registerActionMiddleware`
4. 然后action 层:`resourcer.registerResourceMiddleware`
5. 最后,执行 action handler
不过,每个层次的中间件执行顺序还依赖于编码顺序,如有需要再进行更细微的改进。

View File

@ -0,0 +1,34 @@
---
title: 如何编写测试
order: 4
toc: menu
---
# 如何编写测试 <Badge>未实现</Badge>
NocoBase 提供了 @nocobase/test 用于编写和调试插件。
```ts
import { mockServer } from '@nocobase/test';
describe('test', () => {
let api;
beforeEach(async () => {
api = mockServer({});
await api.database.sync();
});
afterEach(async () => {
await api.database.close();
});
it('test resource', () => {
await app.resource('demos').get();
});
it('test request', () => {
await app.request().get('/');
});
});
```

View File

@ -9,6 +9,7 @@ module.exports = {
'**/?(*.)+(spec|test).[jt]s?(x)' '**/?(*.)+(spec|test).[jt]s?(x)'
], ],
modulePathIgnorePatterns: [ modulePathIgnorePatterns: [
"<rootDir>/.dumi/",
"<rootDir>/packages/father-build/" "<rootDir>/packages/father-build/"
], ],
}; };

15106
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,8 @@
"start": "cd packages/app && npm start", "start": "cd packages/app && npm start",
"start-server": "nodemon", "start-server": "nodemon",
"start-client": "cd packages/app && npx umi dev", "start-client": "cd packages/app && npx umi dev",
"start-docs": "dumi dev",
"build-docs": "dumi build",
"pm2-start": "npx pm2 start packages/api/lib/index.js", "pm2-start": "npx pm2 start packages/api/lib/index.js",
"build-app": "cd packages/app && npm run build", "build-app": "cd packages/app && npm run build",
"bootstrap": "lerna bootstrap --no-ci", "bootstrap": "lerna bootstrap --no-ci",
@ -18,6 +20,12 @@
"release:force": "lerna publish from-package --yes", "release:force": "lerna publish from-package --yes",
"release": "lerna publish" "release": "lerna publish"
}, },
"repository": {
"type": "git",
"url": "git+https://github.com/nocobase/nocobase.git",
"branch": "master",
"platform": "github"
},
"devDependencies": { "devDependencies": {
"@koa/router": "^9.3.1", "@koa/router": "^9.3.1",
"@types/bcrypt": "^3.0.0", "@types/bcrypt": "^3.0.0",
@ -28,12 +36,16 @@
"@types/koa__router": "^8.0.2", "@types/koa__router": "^8.0.2",
"@types/lodash": "^4.14.158", "@types/lodash": "^4.14.158",
"@types/node": "^14.0.23", "@types/node": "^14.0.23",
"@types/react": "16.14.0", "@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"@types/supertest": "^2.0.10", "@types/supertest": "^2.0.10",
"@typescript-eslint/eslint-plugin": "^3.6.1", "@typescript-eslint/eslint-plugin": "^3.6.1",
"@typescript-eslint/parser": "^3.6.1", "@typescript-eslint/parser": "^3.6.1",
"@umijs/plugin-antd": "^0.9.1",
"antd": "^4.15.0",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"dumi": "^1.0.12",
"eslint": "^7.4.0", "eslint": "^7.4.0",
"eslint-plugin-import": "^2.22.0", "eslint-plugin-import": "^2.22.0",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
@ -44,16 +56,19 @@
"koa": "^2.13.0", "koa": "^2.13.0",
"koa-bodyparser": "^4.3.0", "koa-bodyparser": "^4.3.0",
"lerna": "^3.22.0", "lerna": "^3.22.0",
"lint-staged": "^10.0.7",
"mysql2": "^2.1.0", "mysql2": "^2.1.0",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"path-to-regexp": "^6.1.0", "path-to-regexp": "^6.1.0",
"pg": "^8.3.0", "pg": "^8.3.0",
"pg-hstore": "^2.3.3", "pg-hstore": "^2.3.3",
"pm2": "^4.5.6", "pm2": "^4.5.6",
"prettier": "^1.19.1",
"sequelize": "^6.3.4", "sequelize": "^6.3.4",
"supertest": "^4.0.2", "supertest": "^4.0.2",
"ts-jest": "^26.1.2", "ts-jest": "^26.1.2",
"ts-node": "^8.10.2", "ts-node": "^8.10.2",
"typescript": "^3.9.6" "typescript": "^3.9.6",
"yorkie": "^2.0.0"
} }
} }

View File

@ -23,7 +23,7 @@ DB_LOG_SQL=
NOCOBASE_ENV= NOCOBASE_ENV=
API_PORT=23000 API_PORT=23000
APP_USE_STATIC_SERVER=true APP_USE_STATIC_SERVER=true
APP_DIST=packages/app/dist APP_DIST=node_modules/@nocobase/app/dist
# ADMIN USER (Initialization only) # ADMIN USER (Initialization only)

1
public/CNAME Normal file
View File

@ -0,0 +1 @@
docs.nocobase.com

4081
yarn.lock

File diff suppressed because it is too large Load Diff