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:
parent
f6dcfae28c
commit
d5d0e1036b
57
.dumi/theme/builtins/API.tsx
Executable file
57
.dumi/theme/builtins/API.tsx
Executable 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
68
.dumi/theme/builtins/Alert.less
Executable 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
17
.dumi/theme/builtins/Alert.tsx
Executable 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
31
.dumi/theme/builtins/Badge.less
Executable 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
4
.dumi/theme/builtins/Badge.tsx
Executable file
@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
import './Badge.less';
|
||||
|
||||
export default (props: any) => <span className="__dumi-default-badge" {...props} />;
|
47
.dumi/theme/builtins/Example.less
Executable file
47
.dumi/theme/builtins/Example.less
Executable 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;
|
||||
}
|
||||
}
|
34
.dumi/theme/builtins/Example.tsx
Executable file
34
.dumi/theme/builtins/Example.tsx
Executable 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>
|
||||
)
|
||||
);
|
||||
};
|
364
.dumi/theme/builtins/Previewer.less
Executable file
364
.dumi/theme/builtins/Previewer.less
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
253
.dumi/theme/builtins/Previewer.tsx
Executable file
253
.dumi/theme/builtins/Previewer.tsx
Executable 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;
|
58
.dumi/theme/builtins/SourceCode.less
Executable file
58
.dumi/theme/builtins/SourceCode.less
Executable 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;
|
||||
}
|
||||
}
|
41
.dumi/theme/builtins/SourceCode.tsx
Executable file
41
.dumi/theme/builtins/SourceCode.tsx
Executable 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>
|
||||
);
|
||||
};
|
22
.dumi/theme/builtins/Tree.less
Normal file
22
.dumi/theme/builtins/Tree.less
Normal 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;
|
||||
}
|
||||
}
|
60
.dumi/theme/builtins/Tree.tsx
Normal file
60
.dumi/theme/builtins/Tree.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
};
|
48
.dumi/theme/components/LocaleSelect.less
Executable file
48
.dumi/theme/components/LocaleSelect.less
Executable 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;
|
||||
}
|
||||
}
|
53
.dumi/theme/components/LocaleSelect.tsx
Executable file
53
.dumi/theme/components/LocaleSelect.tsx
Executable 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;
|
172
.dumi/theme/components/Navbar.less
Executable file
172
.dumi/theme/components/Navbar.less
Executable 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;
|
||||
}
|
||||
}
|
68
.dumi/theme/components/Navbar.tsx
Executable file
68
.dumi/theme/components/Navbar.tsx
Executable 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;
|
107
.dumi/theme/components/SearchBar.less
Executable file
107
.dumi/theme/components/SearchBar.less
Executable 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;
|
||||
}
|
||||
}
|
41
.dumi/theme/components/SearchBar.tsx
Executable file
41
.dumi/theme/components/SearchBar.tsx
Executable 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>
|
||||
);
|
||||
};
|
321
.dumi/theme/components/SideMenu.less
Executable file
321
.dumi/theme/components/SideMenu.less
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
139
.dumi/theme/components/SideMenu.tsx
Executable file
139
.dumi/theme/components/SideMenu.tsx
Executable 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;
|
22
.dumi/theme/components/SlugList.less
Executable file
22
.dumi/theme/components/SlugList.less
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
20
.dumi/theme/components/SlugList.tsx
Executable file
20
.dumi/theme/components/SlugList.tsx
Executable 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
117
.dumi/theme/layout.tsx
Executable 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
325
.dumi/theme/style/layout.less
Executable 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
196
.dumi/theme/style/markdown.less
Executable 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;
|
||||
}
|
||||
}
|
26
.dumi/theme/style/variables.less
Executable file
26
.dumi/theme/style/variables.less
Executable 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,iVBORw0KGgoAAAANSUhEUgAAAcwAAAA8CAMAAADc4HoZAAABFFBMVEUAAABGT2VGTmZaYXpCvGtIUGg3tGBGTmU3s2A/tmFKUGxFTmRFTWVQWmxFTWRJUGZFTWRGTmRLWWpFTWRGTmVGTmVLVG1FTmRGTWVHTmVFTWRHUGdFTWRGT2ZFTWVGTmU6t2I7xHA3tF9GTWRIT2dFTmRGTmVFTWQ3s2BFTWRGTmVGTmZKUmVFTWRFTWRGTWRGTmVcXHxFTmVFTmVGTmVFTWRIUGdGTWVNU3FGT2ZGTmVHTmVFTWRFTWVFTmVITWRHUGZFTWVFTmRGTmZGTmVFTWVLU2g4tF83tGBFTWQ3s1/LzdT09faoq7Zwdoieoq58gpLj5OeCh5fq6+2/wsmTmKWQlKJfZnpIUGfU1tu0t8BOVWynlyFSAAAASXRSTlMAkoAHEkDQ/dgYFuf0C8gj+KQQmm1oEuyNWNg53kSXfCYI5tEtzq7ivbOgTBy924R1BfHUibcpYw1JcU7v+7E3Nav6XVPDGraPqQuKawAACh1JREFUeNrsm2lT6kgUhg9QFFUkgWDYZfnAVsi+KQJeQdGqt1xm7jZ3lv//PyYdhe7QWQw1zP0w83xQsY9Um4fTp7vToeBczmaX5MN5rXZO/+NGJzGuLejnkw3dADehLHkQyceAWD5C/0my9XqWPLlCK9WHQScirUMk18g7J9ZosYLFajFyT8siLIpuyQkHKBDw4NgYsnDr0Xybaii60rjYzsmdbrqnw0TvpbvkhjYuzinygDXJXLewR2/O/f73w1cWCUj0LkmiU8SeYsc9LXMZIJNjyXkqmbWQCzV8ICawzLO8jh3q4IyciYfugMnMMGYT4C4UJ2fOEbbSc0EyrVp4T/7u4kiZs6jANjwBxkupWMLG7NIlLZvxM+As3nRLTsD/N5xtekmHIEQuhBAoBuREtmaXWVgB41Smc97JbMZA7pqcKKgopbu7FC1BLUgD22MyeVnPWD0bonLLeCQRhIkzQNnz6gHiK0HmxeF4qkKPSsVygh2x2q50SmlZIGIyiQo8OY+XGVExOLVM2WVRbAkDSma0609aQaxKMgOo6YjQ77Tc8d3laxPRxS7R564yI8WSFkymgUNuJqlbomQLisblpnNAf0nrB1j06rTsA7n0SE5L2skkh+Qcm2CP3vGV2QHWp5Ypu4wDosumRpyzNrBwcFmqk4166dBmrFgJ5aeDKhvSklWLBLokgBhcaF3bFL59lV45EQsR3QLVfV0uAuNFhEy2JaC/fcveMVC8ltKSy3RITtjRl34yDSj0r8rMNkyXQksByJOdCmIdslNAKS7V0BIKdpmGQ1+S9slA2IVa60My89HoRKyZ5XTD8rhBX1DwEN85Gw53drIsT6W0FGTKyYmYtgcI427rI1NB5bQyZZeTuNCSXaEpBX2Cotm9qWqdJOqqajN85y8zTC6E8SGZGalmjja4uaQC0OUy0UzSAckNTKS0FGTKyYmYbfQP42brcFGr/X5+N/XDNVG+36+eXCZ3Kbbkbd644cHBW6bpnTlx0vZO6PL0NI/LE8uksxtUqQ7sUgpoAfp0TgLzqQ4oAFkkeFqadCwFxJMz4SKTwogVpIsaBtrv+qdQzZ8ibSB8cpncJW+Z68iQTBq5EXG6N6UIvTHVr2hPpHTX9ZY5Rf0ImfIEyEMmFWHQmk89gHKhBShCP68UoHVfFtZnqV0yahWYVLTdJyMFwE0ms8l+cnFJfWyIuM2TyuQuecsW4xFJMMcd0S1PzBRQGdkaOKosc4DKYn1amSM2rb4H5lwmaVUVqEXJItoA1LBGokwoHWKUS0AqBZTKxOgocJXJl74uLi+Be+I2TyuTu+SkkCInmrZS3kNXkMnnF9RFT5Qpv1cVJkYwmRzxlavMIRClmTgBYmIeU1bpfC+WqS6RKPOKOTxjaWlZXSpWcp4xq1dBZIaBTxH+v95kySLyCQifSCZ3WYuTnYbDKNvpnVMVPUpulvSGPiFRJlq39M5E95bZNYZXD1icTOaoHophQ1EgLcpkrBOsdLJimbglsstMzpnGxZtSE0vjwlKalGVyuEzZJSXQIxJs2kVVDJOLC6NKVK/0jLWrzEzPYB/G6SxV9pJZq2XlyXSHDqlAjW5XjaSCzfsfom2XiX3hbEN4y3G/r64agy7ZifRrXOa6wmMkmT7YZfbwTuPsUoGi2WUyWOlkxZJIkskGWD7YkpWcb4NtAJlVm8tHYEF2m6KofW/pXLe2INxkTs0QeszB5N5rmJVckg55RzI+gTpEToFySRZ1GAcy94lg8AmOtmtSh2QnNebrTCnmWJlzHRatYeRegbomWSZpU2Cq0UdkdgLKlBMzA2EZNpJkmnmZQ9EwqtSDMijqGU+ZeeSqD/pCkikhZ6ZsU8cNc+kuc3EoU0tgT4hE5q3ELgZCTIBh1nECVAWm0fMs3daA8bV4wUN7f0nhAkdCgkztnx9mZ5iQ+zDLSLxdx5bZFK+Tp8wZDNLqFEAmr5myzRh36TfM8obXX01eAeyaqT4LhYvouMccLzNSRIlZmwGzLnGskVWWWWhBmgBZlXPpOwHieEyA5joGsktZJvumXBN5yzSQW/puGhy2XGBDTjZbWDGXLhMgRZ4ArQF8f375+vnP5y/gFawKYHzlEuOzNPGRSVFgSkT37LcCYDSidpnnCUCQaTmUlyaW1QAyxaVJAVjLLmWZViQSUW+Z9RsWE1DmFuMIOZAddIMtTSrA69PTy/dfXr798QMo7GVmzjXyijleJqVwV7d6t4rL2+NlUeY5GE6bBnNp0yCQTG4zBYVIWGa6y6SMCmDoKZOuFQDVYDI1FWlyJtimQR8/vv76/O319enrl89/wdjLZEnsFeO/nee6NImv8MAW6zpSssylKLMMxrHbebJM2eZohYrkUpL5HhKfqohdesokbZED1oFk0gC5M/Kje+e7nafi9fnl8y8mn1+ef6AtyXSNOV4mZd4q7wAo+8s8fqOdA7httJd3Hwlpo12WeUZUv0PaVWaCuTSVqxgGkznPYTYiP/w32lfAr0+/fAF+++2PV6ApyvSK8ZcpL034LbAWclm2kEU/4i8z8C0wf5mcENQIcTxkJnuTOMV1ZBxkniceqYkmnRmtR4ooQWVSJwbD16b/LbAGTEffnvD705NpC3lZphxzrEwbYVZg2Dd+c9pZZpCb08FltrChj8nsAGpiDD0py9RWUIvAkFWOuwcFuA0ok4bALCuKswQFvTk9gMnL85fvz99h0ttsmp8+tdt9LlOKuXC5OS1fOa42c3jUUrW6sIGetB8bwVCUuUCgYyPBZa6B+w/KpHsVgOq4adBhTQ8RonIOwE3ACRBjGMNquJ/ODcc9YgQ8NtJVYfLn568vMImtVrmcoiitVmLuFON6bMRfpiOPY/QOD3T16juZ9V6AA10+MhkkE0Ys6yuzXFgTY35fzTw6L03iV8MOMbTt8CpJwWVa02C9PSyUt8NPKtBK0hEHuoYAzAH0G0z0c+IEjIGALDMfdeYCuD88ahmrxJnMuBE77qilLHPkKnOZlhLz9CcNnFu06hg7lLBGRx21DMHkr9+ZJ6HFKya4TC9atIOf6woBIX6SK8AhaM8D0D//ELR3ryLXlV4xV0qElhEiz0PQbcNoOx+CvlJgIT6H4xUTHCMGd1LE4aVTKpa+jyf4y/z5jycE7lXwxxO0gtFu5svECRrz/4NDf7dvxjYQwzAMdGEE8RaWq2ySh/cf6OGoyQCRANLkBHenWqnzxyGU6aVP0zRN0zTtmzUru64ZWZ923kC0n6tT9WnnnL+y5R51pj6L9ahlx7k6UR8kVt2Sh1W35GHVLXlYdUseVt2Sh1W3fK8aDmuSOmyfelyGwpqkjtvnnvMyENYcdeA+fSxaDNYUdeg+TovBmqAO3sdpMVjD1eH7OC0Ga7A6QR+nxWANVafo47QYrIHqJH0eWhDWMHWaPosWhTVInahPHzisIepUffrAYQ1QJ+vTgVgD1IP6/AHM0QJdY511NAAAAABJRU5ErkJggg==';
|
||||
|
||||
.@{prefix}-icon {
|
||||
background: url(@icons) no-repeat ~'0 0/230px auto';
|
||||
}
|
315
.dumi/theme/test/index.test.tsx
Executable file
315
.dumi/theme/test/index.test.tsx
Executable 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
38
.github/workflows/vercel.yml
vendored
Normal 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
|
9
.gitignore
vendored
9
.gitignore
vendored
@ -12,4 +12,11 @@ packages/database/package-lock.json
|
||||
packages/resourcer/package-lock.json
|
||||
|
||||
verdaccio
|
||||
uploads/
|
||||
uploads/
|
||||
.umi
|
||||
.umi-production
|
||||
/dist
|
||||
/.vscode
|
||||
/.idea
|
||||
|
||||
.vercel
|
||||
|
23
.umirc.ts
Normal file
23
.umirc.ts
Normal 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
35
docs/cores/index.md
Normal 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"/>
|
||||
|
||||
## 数据的存储 —— 结构和关系
|
||||
|
||||
## 数据的行为 —— 操作和事件
|
||||
|
||||
## 数据的形态 —— 页面和区块
|
BIN
docs/cores/nocobase-core.png
Normal file
BIN
docs/cores/nocobase-core.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 117 KiB |
426
docs/cores/packages/actions.md
Normal file
426
docs/cores/packages/actions.md
Normal 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: [],
|
||||
}
|
||||
```
|
74
docs/cores/packages/blocks.md
Normal file
74
docs/cores/packages/blocks.md
Normal 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
|
||||
|
||||
## 自定义区块
|
732
docs/cores/packages/database.md
Normal file
732
docs/cores/packages/database.md
Normal 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 类型与字段数据类型的对应关系:
|
||||
|
||||
- boolean:boolean
|
||||
- string:string、text、time、date
|
||||
- number:integer、float、double、decimal、real
|
||||
- object:hasOne、belongsTo
|
||||
- array:hasMany、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 范围内的值
|
563
docs/cores/packages/resourcer.md
Normal file
563
docs/cores/packages/resourcer.md
Normal 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>
|
87
docs/cores/packages/sdk.md
Normal file
87
docs/cores/packages/sdk.md
Normal 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,
|
||||
});
|
||||
```
|
146
docs/cores/packages/server.md
Normal file
146
docs/cores/packages/server.md
Normal 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()
|
||||
|
||||
重载插件
|
||||
|
72
docs/cores/packages/test.md
Normal file
72
docs/cores/packages/test.md
Normal 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
118
docs/guide/config.md
Normal 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 信息
|
||||
|
||||
待补充...
|
||||
|
||||
### 配置插件
|
||||
|
||||
待补充...
|
196
docs/guide/getting-started.md
Normal file
196
docs/guide/getting-started.md
Normal 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
51
docs/guide/index.md
Normal 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
BIN
docs/guide/nocobase.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 279 KiB |
20
docs/index.md
Normal file
20
docs/index.md
Normal 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
20
docs/index.zh-CN.md
Normal 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.
|
||||
---
|
24
docs/plugins/concepts/actions.md
Normal file
24
docs/plugins/concepts/actions.md
Normal 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();
|
||||
}
|
||||
}
|
||||
```
|
56
docs/plugins/concepts/blocks.md
Normal file
56
docs/plugins/concepts/blocks.md
Normal 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: {
|
||||
// 字段联动
|
||||
},
|
||||
};
|
||||
```
|
25
docs/plugins/concepts/collections.md
Normal file
25
docs/plugins/concepts/collections.md
Normal 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: [],
|
||||
});
|
||||
```
|
117
docs/plugins/concepts/fields.md
Normal file
117
docs/plugins/concepts/fields.md
Normal 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 里,普通用户概念上的字段类型指的是 interface;field type 只表示数据类型 - data type;field 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
|
9
docs/plugins/concepts/hooks.md
Normal file
9
docs/plugins/concepts/hooks.md
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Hooks - 钩子
|
||||
---
|
||||
|
||||
# Hooks - 钩子
|
||||
|
||||
目前支持的 hook 有两类 database.addHook 和 Model.addHook
|
||||
|
||||
未完待续...
|
34
docs/plugins/concepts/locales.md
Normal file
34
docs/plugins/concepts/locales.md
Normal 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}',
|
||||
},
|
||||
}
|
||||
```
|
9
docs/plugins/concepts/middlewares.md
Normal file
9
docs/plugins/concepts/middlewares.md
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Middlewares - 中间件
|
||||
---
|
||||
|
||||
# Middlewares - 中间件
|
||||
|
||||
## koa
|
||||
## resourcer
|
||||
## action
|
5
docs/plugins/concepts/migrations.md
Normal file
5
docs/plugins/concepts/migrations.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Migrations - 迁移
|
||||
---
|
||||
|
||||
# Migrations - 迁移
|
33
docs/plugins/concepts/models.md
Normal file
33
docs/plugins/concepts/models.md
Normal 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();
|
||||
```
|
7
docs/plugins/concepts/resources.md
Normal file
7
docs/plugins/concepts/resources.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Resources - 资源
|
||||
---
|
||||
|
||||
# Resources - 资源
|
||||
|
||||
用法同 resourcer.define
|
16
docs/plugins/packages/action-logs.md
Normal file
16
docs/plugins/packages/action-logs.md
Normal 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
|
||||
```
|
100
docs/plugins/packages/automations.md
Normal file
100
docs/plugins/packages/automations.md
Normal 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()
|
13
docs/plugins/packages/collections.md
Normal file
13
docs/plugins/packages/collections.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: '@nocobase/plugin-collections'
|
||||
---
|
||||
|
||||
# @nocobase/plugin-collections
|
||||
|
||||
提供数据表配置接口,可通过 HTTP API 管理数据表。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
yarn nocobase pull collections --start
|
||||
```
|
45
docs/plugins/packages/file-manager.md
Normal file
45
docs/plugins/packages/file-manager.md
Normal 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
|
13
docs/plugins/packages/pages.md
Normal file
13
docs/plugins/packages/pages.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: '@nocobase/plugin-pages'
|
||||
---
|
||||
|
||||
# @nocobase/plugin-pages
|
||||
|
||||
将客户端的组件参数交由服务端管理,由服务端控制输出客户端所需的 ui schema。开发者可以随意适配任意前端组件。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
yarn nocobase pull pages --start
|
||||
```
|
54
docs/plugins/packages/permissions.md
Normal file
54
docs/plugins/packages/permissions.md
Normal 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 的权限判断
|
146
docs/plugins/packages/users.md
Normal file
146
docs/plugins/packages/users.md
Normal 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
|
||||
|
||||
最后更新人
|
12
docs/plugins/tutorials/developer-mode.md
Normal file
12
docs/plugins/tutorials/developer-mode.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
title: 开发者模式
|
||||
order: 3
|
||||
toc: menu
|
||||
---
|
||||
|
||||
# 开发者模式 <Badge>待完善</Badge>
|
||||
|
||||
开发者模式是专门为开发者提供的修改、调试复杂配置项的运行环境。在开发者模式下:
|
||||
|
||||
- 可以查看系统配置表
|
||||
- 配置开放 JSON Editor,可以修改和调试更多隐藏配置项
|
67
docs/plugins/tutorials/index.md
Normal file
67
docs/plugins/tutorials/index.md
Normal 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>`
|
23
docs/plugins/tutorials/plugin-cli.md
Normal file
23
docs/plugins/tutorials/plugin-cli.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
title: 插件命令行操作
|
||||
order: 2
|
||||
toc: menu
|
||||
---
|
||||
|
||||
# 插件命令行操作 <Badge>未实现</Badge>
|
||||
|
||||
## pull - 拉取
|
||||
|
||||
将插件包从远程下载到本地列表
|
||||
|
||||
## start - 启动
|
||||
|
||||
启动插件
|
||||
|
||||
## stop - 停止
|
||||
|
||||
停止插件
|
||||
|
||||
## rm - 移除
|
||||
|
||||
插件将从本地列表移除
|
74
docs/plugins/tutorials/server-lifecycle.md
Normal file
74
docs/plugins/tutorials/server-lifecycle.md
Normal file
@ -0,0 +1,74 @@
|
||||
---
|
||||
title: NocoBase 生命周期
|
||||
order: 2
|
||||
toc: menu
|
||||
---
|
||||
|
||||
# NocoBase 生命周期
|
||||
|
||||
每个 NocoBase 实例在被创建时都要经过一系列的初始化过程,在这个过程中某些节点会运行一些函数,这些节点就是生命周期的钩子,有了钩子的存在,代码不论按什么顺序书写,都会按照既定的顺序执行。NocoBase 的生命周期大概分为四个环节:
|
||||
|
||||
1. 初始化实例
|
||||
2. 加载配置
|
||||
3. 数据库操作
|
||||
4. 每次请求中
|
||||
|
||||
## 初始化实例
|
||||
|
||||
- koa:nocobase 实例
|
||||
- database:数据库实例
|
||||
- resourcer:koa 的分支,负责 resource router
|
||||
- pluginManager:插件管理器实例
|
||||
|
||||
现阶段的设计 sever 直接继承了 koa application,其他三个作为 server 实例的成员存在。
|
||||
|
||||
<Alert title="注意" type="warning">
|
||||
pluginManager 的初始化存在较大缺陷,在初始化时直接加载了配置,暂时无法处理数据库操作。
|
||||
</Alert>
|
||||
|
||||
## 加载配置
|
||||
|
||||
- table hooks:表配置事件
|
||||
- table options:表配置
|
||||
- model hooks:model 事件
|
||||
|
||||
系统表配置从文件目录里导入,业务表配置从数据库里导入,部分开放的系统表从文件目录导入之后,又从数据库里更新,如用户表。
|
||||
|
||||
<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
|
||||
|
||||
不过,每个层次的中间件执行顺序还依赖于编码顺序,如有需要再进行更细微的改进。
|
34
docs/plugins/tutorials/testing.md
Normal file
34
docs/plugins/tutorials/testing.md
Normal 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('/');
|
||||
});
|
||||
});
|
||||
```
|
@ -9,6 +9,7 @@ module.exports = {
|
||||
'**/?(*.)+(spec|test).[jt]s?(x)'
|
||||
],
|
||||
modulePathIgnorePatterns: [
|
||||
"<rootDir>/.dumi/",
|
||||
"<rootDir>/packages/father-build/"
|
||||
],
|
||||
};
|
15120
package-lock.json
generated
15120
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -5,6 +5,8 @@
|
||||
"start": "cd packages/app && npm start",
|
||||
"start-server": "nodemon",
|
||||
"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",
|
||||
"build-app": "cd packages/app && npm run build",
|
||||
"bootstrap": "lerna bootstrap --no-ci",
|
||||
@ -18,6 +20,12 @@
|
||||
"release:force": "lerna publish from-package --yes",
|
||||
"release": "lerna publish"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nocobase/nocobase.git",
|
||||
"branch": "master",
|
||||
"platform": "github"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@koa/router": "^9.3.1",
|
||||
"@types/bcrypt": "^3.0.0",
|
||||
@ -28,12 +36,16 @@
|
||||
"@types/koa__router": "^8.0.2",
|
||||
"@types/lodash": "^4.14.158",
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "^3.6.1",
|
||||
"@typescript-eslint/parser": "^3.6.1",
|
||||
"@umijs/plugin-antd": "^0.9.1",
|
||||
"antd": "^4.15.0",
|
||||
"cross-env": "^7.0.2",
|
||||
"dotenv": "^8.2.0",
|
||||
"dumi": "^1.0.12",
|
||||
"eslint": "^7.4.0",
|
||||
"eslint-plugin-import": "^2.22.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
@ -44,16 +56,19 @@
|
||||
"koa": "^2.13.0",
|
||||
"koa-bodyparser": "^4.3.0",
|
||||
"lerna": "^3.22.0",
|
||||
"lint-staged": "^10.0.7",
|
||||
"mysql2": "^2.1.0",
|
||||
"nodemon": "^2.0.4",
|
||||
"path-to-regexp": "^6.1.0",
|
||||
"pg": "^8.3.0",
|
||||
"pg-hstore": "^2.3.3",
|
||||
"pm2": "^4.5.6",
|
||||
"prettier": "^1.19.1",
|
||||
"sequelize": "^6.3.4",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "^26.1.2",
|
||||
"ts-node": "^8.10.2",
|
||||
"typescript": "^3.9.6"
|
||||
"typescript": "^3.9.6",
|
||||
"yorkie": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ DB_LOG_SQL=
|
||||
NOCOBASE_ENV=
|
||||
API_PORT=23000
|
||||
APP_USE_STATIC_SERVER=true
|
||||
APP_DIST=packages/app/dist
|
||||
APP_DIST=node_modules/@nocobase/app/dist
|
||||
|
||||
# ADMIN USER (Initialization only)
|
||||
|
||||
|
1
public/CNAME
Normal file
1
public/CNAME
Normal file
@ -0,0 +1 @@
|
||||
docs.nocobase.com
|
Loading…
Reference in New Issue
Block a user