├── .eslintignore ├── pre-commit ├── docs ├── api │ ├── index.html │ └── README.md ├── config │ └── index.html ├── intro │ ├── index.html │ └── README.md ├── components │ └── index.html ├── index.html └── reciper.json ├── demo ├── schemes │ ├── config │ │ ├── hidden.yaml │ │ ├── error.yaml │ │ ├── form.yaml │ │ ├── filter.yaml │ │ └── list.yaml │ └── inputs │ │ ├── caption.json │ │ ├── datetime.json │ │ ├── other.json │ │ ├── combines.json │ │ └── keyboards.json ├── authorize.json ├── index.html └── config.yaml ├── logo.png ├── src ├── components │ ├── OutputButton.js │ ├── OutputNumber.js │ ├── Value.js │ ├── ListItem.js │ ├── FrameMain.js │ ├── ErrorDialog.js │ ├── Input.js │ ├── InputCustom.js │ ├── FrameBody.js │ ├── ButtonHollow.js │ ├── MainWithCustom.js │ ├── InputCascader.js │ ├── MainWithError.js │ ├── FormItemWithDiv.js │ ├── Caption.js │ ├── OutputEnum.js │ ├── FrameNav.js │ ├── PanelFailure.js │ ├── PanelSuccess.js │ ├── OutputUser.js │ ├── Output.js │ ├── MainWithEditor.js │ ├── SvgIcon.js │ ├── OutputBoolean.js │ ├── OutputTextTip.js │ ├── InputForest.js │ ├── InputFileTokenWithInfo.js │ ├── InputCaption.js │ ├── TableTip.js │ ├── FormItemWithTable.js │ ├── Item.js │ ├── OutputHTML.js │ ├── Alert.js │ ├── OutputLink2.js │ ├── OutputMarkdown.js │ ├── OutputTable.js │ ├── ListHeaders.js │ ├── TableRow.js │ ├── PanelWithIcon.js │ ├── OutputBreadcrumbs.js │ ├── InputGrouping.js │ ├── PureDialog.js │ ├── InputRadio.js │ ├── OutputDateTime.js │ ├── OutputLink.js │ ├── FormItem.js │ ├── InputPassword.js │ ├── OutputClipboard.js │ ├── OutputIconTip.js │ ├── InputTime.js │ ├── InputNumber.js │ ├── InputString.js │ ├── InputText.js │ ├── ListFlex.js │ ├── InputGroupingSelect.js │ ├── InputGroupingCheckbox.js │ ├── FatalError.js │ ├── InputBoolean.js │ ├── InputPair.js │ ├── OutputImage.js │ ├── InputDate.js │ ├── InputCheckbox.js │ ├── FormSubmit.js │ ├── OutputQRCode.js │ ├── OutputAutoRefresh.js │ ├── InputTextAround.js │ ├── InputSelect.js │ ├── SubGroupMap.js │ ├── Button.js │ ├── Confirm.js │ ├── InputDateTime.js │ ├── XPut.js │ ├── Tip.js │ ├── FrameAside.js │ ├── ErrorDisplay.js │ ├── InputImageSelector.js │ ├── InputList.js │ ├── Form.js │ ├── MainWithList.js │ ├── TableRowActions.js │ ├── InputFileBase64.js │ ├── InputCode.js │ ├── InputMarkdown.js │ ├── MainWithDefault.js │ ├── InputAutoComplete.js │ ├── Frame.js │ └── InputSuggestion.js ├── utils │ ├── refactor.js │ ├── debounce.js │ ├── condition.js │ ├── doAction.js │ ├── amdx.js │ └── api.js └── duang.js ├── .gitignore ├── README.md ├── .eslintrc.yaml ├── package.json ├── logo.svg └── tests ├── menu.html └── normal-list.html /.eslintignore: -------------------------------------------------------------------------------- 1 | playground 2 | -------------------------------------------------------------------------------- /pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /docs/api/index.html: -------------------------------------------------------------------------------- 1 | ../index.html -------------------------------------------------------------------------------- /docs/config/index.html: -------------------------------------------------------------------------------- 1 | ../index.html -------------------------------------------------------------------------------- /docs/intro/index.html: -------------------------------------------------------------------------------- 1 | ../index.html -------------------------------------------------------------------------------- /docs/components/index.html: -------------------------------------------------------------------------------- 1 | ../index.html -------------------------------------------------------------------------------- /demo/schemes/config/hidden.yaml: -------------------------------------------------------------------------------- 1 | key: "hidden" 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eleme/duang/HEAD/logo.png -------------------------------------------------------------------------------- /src/components/OutputButton.js: -------------------------------------------------------------------------------- 1 | def((Button) => class extends Button { 2 | 3 | }); 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.swp 3 | *.log 4 | dist 5 | node_modules 6 | !.gitignore 7 | !.eslintrc 8 | -------------------------------------------------------------------------------- /demo/authorize.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": { 3 | "name": "Mock User" 4 | }, 5 | "permissions": [ "HEHE" ] 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Duang 2 | 3 | 一种基于配置自动生成 CMS 的工具 4 | 5 | * 文档:https://eleme.github.io/duang/docs/ 6 | * 实例:https://eleme.github.io/duang/demo/ 7 | 8 | -------------------------------------------------------------------------------- /src/components/OutputNumber.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | init() { 3 | let { value, fixed } = this; 4 | if (fixed !== void 0) value = value.toFixed(fixed); 5 | this.element.textContent = value; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /demo/schemes/config/error.yaml: -------------------------------------------------------------------------------- 1 | - key: "404" 2 | title: "配置 - 异常 - 404" 3 | fields: 4 | - key: "id" 5 | - key: "500" 6 | title: "配置 - 异常 - 500" 7 | fields: 8 | - key: "id" 9 | - key: "hehe" 10 | title: "配置 - 异常 - 字段未配置" 11 | -------------------------------------------------------------------------------- /src/components/Value.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | init() { 3 | Object.defineProperty(this.element, 'value', { 4 | get: () => { return this.value; }, 5 | set: (value) => { this.value = value; } 6 | }); 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/ListItem.js: -------------------------------------------------------------------------------- 1 | def((Item) => class extends Item { 2 | get template() { return '
  • {text}
  • '; } 3 | get styleSheet() { 4 | return ` 5 | :scope { 6 | > a { display: block; } 7 | } 8 | `; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/FrameMain.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | get styleSheet() { 3 | return ` 4 | :scope { 5 | box-sizing: border-box; 6 | height: 100%; 7 | width: 100%; 8 | flex: 1; 9 | overflow: auto; 10 | } 11 | `; 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/ErrorDialog.js: -------------------------------------------------------------------------------- 1 | def((ErrorDisplay) => class extends Jinkela { 2 | init() { 3 | new ErrorDisplay({ error: this.error }).to(this); 4 | } 5 | static popup(...args) { 6 | dialog.popup(new this(...args)); 7 | } 8 | beforeParse(params) { 9 | params.title = params.title || params.error && params.error.title || '错误'; 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/Input.js: -------------------------------------------------------------------------------- 1 | def((XPut) => class extends XPut { 2 | get hint() { return 'Input'; } 3 | get defaultComponent() { return 'String'; } 4 | buildComponent() { 5 | let { args = {}, depot } = this; 6 | return req(this.componentName).then(Component => { 7 | this.result = new Component(args, { depot }).to(this.element); 8 | }); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/InputCustom.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | get value() { return this.$value; } 3 | set value(value = this.defaultValue) { 4 | if (this.$value === value) return; 5 | this.$value = value; 6 | this.render(); 7 | } 8 | init() { 9 | this.render(); 10 | this.value = this.value; 11 | } 12 | render() { 13 | this.element.innerHTML = this.html; 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/FrameBody.js: -------------------------------------------------------------------------------- 1 | def((FrameMain, FrameAside) => class extends Jinkela { 2 | init() { 3 | new FrameAside().to(this); 4 | this.$promise = new FrameMain({ Main: this.Main }).to(this).$promise; 5 | } 6 | get styleSheet() { 7 | return ` 8 | :scope { 9 | height: 100%; 10 | width: 100%; 11 | display: flex; 12 | flex: 1; 13 | } 14 | `; 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/ButtonHollow.js: -------------------------------------------------------------------------------- 1 | def((Button) => class extends Button { 2 | init() { 3 | this.element.removeAttribute('style'); 4 | } 5 | get styleSheet() { 6 | return ` 7 | :scope { 8 | background-color: transparent; 9 | border-color: #c0ccda; 10 | color: #1f2d3d; 11 | &:not([disabled]):hover { 12 | color: #20a0ff; 13 | border-color: #20a0ff; 14 | } 15 | } 16 | `; 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/MainWithCustom.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | 3 | init() { 4 | Object.defineProperty(this.depot, 'main', { configurable: true, value: this }); 5 | this.element.src = this.depot.params.href; 6 | } 7 | 8 | get template() { 9 | return ` 10 | 11 | `; 12 | } 13 | 14 | get styleSheet() { 15 | return ` 16 | :scope { 17 | width: 100%; 18 | height: 100%; 19 | } 20 | `; 21 | } 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/InputCascader.js: -------------------------------------------------------------------------------- 1 | def((Cascader) => { 2 | 3 | class CascaderWithDuang extends Cascader { 4 | beforeParse(params) { 5 | if (!('placeholder' in params)) params.placeholder = '请选择'; 6 | super.beforeParse(params); 7 | } 8 | get styleSheet() { 9 | return ` 10 | :scope { 11 | > input { height: 28px; } 12 | } 13 | `; 14 | } 15 | } 16 | 17 | return function(...args) { 18 | return new CascaderWithDuang(...args); 19 | }; 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/MainWithError.js: -------------------------------------------------------------------------------- 1 | def((FatalError) => class extends Jinkela { 2 | 3 | get FatalError() { return FatalError; } 4 | 5 | init() { 6 | Object.defineProperty(this.depot, 'main', { configurable: true, value: this }); 7 | console.log(this.depot); 8 | this.message = `找不到模块: ${this.depot.module}`; 9 | } 10 | 11 | get template() { 12 | return ` 13 |
    14 | 15 |
    16 | `; 17 | } 18 | 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/FormItemWithDiv.js: -------------------------------------------------------------------------------- 1 | def((FormItem) => class extends FormItem { 2 | 3 | get styleSheet() { 4 | return ` 5 | :scope { 6 | white-space: nowrap; 7 | display: flex; 8 | > .text { margin-right: 1em; } 9 | > .desc { margin-left: 1em; } 10 | > span { 11 | vertical-align: top; 12 | line-height: 28px; 13 | width: auto; 14 | display: inline-block; 15 | align-items: center; 16 | } 17 | } 18 | `; 19 | } 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Caption.js: -------------------------------------------------------------------------------- 1 | def((Output) => class extends Jinkela { 2 | init() { 3 | let params = {}; 4 | let { scheme, uParams, pageSize } = depot; 5 | let { page, where } = uParams; 6 | if (pageSize) { 7 | params.limit = pageSize; 8 | params.offset = pageSize * (page - 1 || 0); 9 | } 10 | if (where) params.where = where; 11 | Output.cast(scheme.caption, { params }).to(this); 12 | } 13 | get styleSheet() { 14 | return ` 15 | :scope { 16 | text-align: left; 17 | } 18 | `; 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Duang 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/OutputEnum.js: -------------------------------------------------------------------------------- 1 | def((Output) => class extends Jinkela { 2 | init() { 3 | let { value, map, otherwise } = this; 4 | // 强转 map 5 | map = Object(map); 6 | if (map instanceof Array) { 7 | let temp = {}; 8 | map.forEach(({ value, text }) => { 9 | temp[value] = text; 10 | }); 11 | map = temp; 12 | } 13 | // 处理所有 value(支持数组) 14 | [].concat(value).forEach(value => { 15 | if (otherwise === void 0) otherwise = value; 16 | Output.createAny(value in map ? map[value] : otherwise).to(this); 17 | }); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/FrameNav.js: -------------------------------------------------------------------------------- 1 | def((Output) => class extends Jinkela { 2 | init() { 3 | let nav = depot.config.nav; 4 | if (!(nav instanceof Array)) nav = [ { component: 'User' } ]; 5 | nav.forEach(item => { 6 | Output.createAny(item).to(this); 7 | }); 8 | } 9 | get styleSheet() { 10 | return ` 11 | :scope { 12 | height: 50px; 13 | line-height: 50px; 14 | display: flex; 15 | justify-content: flex-end; 16 | background: #20A0FF; 17 | color: #fff; 18 | > * { 19 | margin-right: 2em; 20 | } 21 | } 22 | `; 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/PanelFailure.js: -------------------------------------------------------------------------------- 1 | def((PanelWithIcon) => class extends PanelWithIcon { 2 | get iconTemplate() { 3 | return ` 4 | 5 | 6 | 7 | `; 8 | } 9 | get defaultTitle() { return '错误'; } 10 | get defaultText() { return '一个神奇的错误'; } 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/PanelSuccess.js: -------------------------------------------------------------------------------- 1 | def((PanelWithIcon) => class extends PanelWithIcon { 2 | get iconTemplate() { 3 | return ` 4 | 5 | 6 | 7 | 8 | `; 9 | } 10 | get defaultTitle() { return '成功'; } 11 | get defaultText() { return '操作成功'; } 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/OutputUser.js: -------------------------------------------------------------------------------- 1 | def((Output) => class extends Jinkela { 2 | init() { 3 | let { session = {}, config } = this.depot || window.depot; 4 | let value = session.username || session.user && session.user.name || session.name; 5 | if (!value) return; 6 | let { component, args } = config.session; 7 | if (!component) { 8 | component = 'HTML'; 9 | args = { html: '{value}' }; 10 | } 11 | void new Output({ component, args, value }).to(this); 12 | } 13 | get styleSheet() { 14 | return ` 15 | :scope { 16 | text-align: right; 17 | font-size: 14px; 18 | } 19 | `; 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Output.js: -------------------------------------------------------------------------------- 1 | def((XPut) => class extends XPut { 2 | static createAny(what, params) { 3 | switch (typeof what) { 4 | case 'object': 5 | return new this(what, params); 6 | case 'string': 7 | default: 8 | return new this({ component: 'HTML', args: { html: String(what) } }, params); 9 | } 10 | } 11 | get hint() { return 'Output'; } 12 | get defaultComponent() { return 'HTML'; } 13 | buildComponent() { 14 | let { args = {}, depot, value } = this; 15 | return req(this.componentName).then(Component => { 16 | this.result = new Component(args, { value, depot }).to(this.element); 17 | }); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/MainWithEditor.js: -------------------------------------------------------------------------------- 1 | def((Form, FormSubmit, PanelFailure, ErrorDisplay) => class extends Jinkela { 2 | load() { 3 | let { id, resolvedKey, params } = this.depot || depot; 4 | id = id || params.copy; 5 | if (!id) return Promise.resolve(); 6 | return api([resolvedKey, id]); 7 | } 8 | init() { 9 | Object.defineProperty(this.depot, 'main', { configurable: true, value: this }); 10 | this.$promise = this.load().then(value => { 11 | let form = new Form({ depot: this.depot }).to(this); 12 | form.value = value; 13 | return form.$promise; 14 | }, error => { 15 | new ErrorDisplay({ error }).to(this); 16 | }); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/SvgIcon.js: -------------------------------------------------------------------------------- 1 | define(() => { 2 | 3 | return class extends Jinkela { 4 | init() { 5 | this.element.setAttribute('viewBox', this.viewBox || '0 0 1000 1000'); 6 | let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 7 | path.setAttribute('d', this.data); 8 | this.element.appendChild(path); 9 | } 10 | set class(value) { this.element.setAttribute('class', value); } 11 | get class() { return this.element.getAttribute('class'); } 12 | get template() { 13 | return ` 14 | 15 | `; 16 | } 17 | }; 18 | 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/OutputBoolean.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | init() { 3 | this.element.setAttribute('data-value', !!this.value); 4 | let text = this.text ? this.text[!!this.value] : !!this.value; 5 | this.element.setAttribute('data-text', text); 6 | } 7 | get styleSheet() { 8 | return ` 9 | :scope { 10 | font-family: monospace; 11 | color: #fff; 12 | &::before { content: attr(data-text); } 13 | text-align: center; 14 | line-height: 1.4; 15 | font-size: 12px; 16 | padding: 3px 5px; 17 | width: 42px; 18 | &[data-value=true] { 19 | background: #13CE66; 20 | } 21 | &[data-value=false] { 22 | background: #F7BA2A; 23 | } 24 | } 25 | `; 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | ## API 设计规范 2 | 3 | API 要求是基于 [Restful](https://zh.wikipedia.org/wiki/REST) 设计的,比如: 4 | 5 | ### 1. 列表 6 | 7 | ``` 8 | GET /list?limit=:limit&offset=:offset 9 | ``` 10 | ``` 11 | [ 12 | { id, ...fields }, 13 | ] 14 | ``` 15 | 16 | **注意**:需要编辑的记录必须返回 id 字段,以供 Duang 通过 `/:scheme_key/:id` 获取编辑记录的数据 17 | 18 | ### 2. 创建 19 | 20 | ``` 21 | POST /:list 22 | { ...fields } 23 | ``` 24 | 25 | ### 3. 编辑 26 | 27 | ``` 28 | PUT /:list/:id 29 | { ...fields } 30 | ``` 31 | 32 | ### 4. 删除 33 | 34 | ``` 35 | DELETE /:list/:id 36 | ``` 37 | 38 | ### 5. 获取 39 | 40 | ``` 41 | GET /:list/:id 42 | ``` 43 | ``` 44 | { ...fields } 45 | ``` 46 | 47 | ### 6. 行级事务 48 | 49 | ``` 50 | POST /:list/:id/:action 51 | { ...args } 52 | ``` 53 | 54 | ### 7. 表级事务(预留) 55 | 56 | ``` 57 | POST /:list/:action 58 | { ...args } 59 | ``` -------------------------------------------------------------------------------- /demo/schemes/config/form.yaml: -------------------------------------------------------------------------------- 1 | key: "the-form" 2 | title: "配置 - 表单" 3 | module: "editor" 4 | inputs: 5 | - component: "String" 6 | args: 7 | width: 100 8 | title: "文本框(100px)" 9 | description: "描述" 10 | - component: "String" 11 | args: 12 | width: 150 13 | title: "文本框(150px)" 14 | description: "描述" 15 | - component: "String" 16 | args: 17 | width: 300 18 | title: 19 | value: "文本框(300px)" 20 | description: 21 | value: "描述 呵呵" 22 | - component: "String" 23 | title: "HEHE 权限可见" 24 | require: "HEHE" 25 | - component: "String" 26 | title: "HAHA 权限可见" 27 | require: "HAHA" 28 | - component: "OutputMarkdown" 29 | title: "OutputMarkdown" 30 | args: 31 | defaultValue: "# HAHA\n\n**bold** x *italic*" 32 | 33 | -------------------------------------------------------------------------------- /src/components/OutputTextTip.js: -------------------------------------------------------------------------------- 1 | def((Tip) => { 2 | 3 | return class extends Tip { 4 | init() { 5 | if (this.underline === false) this.element.style.textDecoration = 'none'; 6 | if (!('$value' in this)) this.value = void 0; 7 | } 8 | get value() { return this.$value; } 9 | set value(value = this.defaultValue) { 10 | this.$value = value; 11 | if (typeof value !== 'object') value = { text: value }; 12 | let { text = this.text, tip = this.tip } = Object(value); 13 | super.value = { data: text, tip }; 14 | } 15 | get styleSheet() { 16 | return ` 17 | :scope { 18 | &[data-hastip=true] { 19 | text-decoration: underline; 20 | text-decoration-style: dotted; 21 | } 22 | } 23 | `; 24 | } 25 | }; 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | es6: true 4 | browser: true 5 | parserOptions: 6 | ecmaVersion: 2017 7 | globals: 8 | CodeMirror: false 9 | FCeptor: false 10 | UParams: false 11 | Jinkela: false 12 | JSONPath: false 13 | Checkbox: false 14 | Radio: false 15 | ClickTip: false 16 | TimePicker: false 17 | DatePicker: false 18 | FastResolve: false 19 | Cascader: false 20 | Forest: false 21 | debounce: false 22 | define: false 23 | doAction: false 24 | refactor: false 25 | condition: false 26 | dialog: false 27 | depot: false 28 | req: false 29 | api: false 30 | config: false 31 | def: false 32 | excavate: false 33 | marked: false 34 | setStaleWhileRevalidate: false 35 | duang: false 36 | mock: false 37 | extends: elemefe 38 | -------------------------------------------------------------------------------- /demo/schemes/inputs/caption.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "Input::Caption", 4 | "title": "组件 - 分组", 5 | "module": "editor", 6 | "inputs": [ 7 | { "component": "Caption", "args": { "h1": "第零组", "h2": "一堆神奇的字段" } }, 8 | { "title": "a", "args": {} }, 9 | { 10 | "component": "Caption", 11 | "args": { "h1": "第壹组", "h2": { "component": "IconTip", "args": { "tip": "hehe" } } } 12 | }, 13 | { "title": "b", "args": {} }, 14 | { "component": "Caption", "args": { "h1": "第贰组", "h2": "双堆神奇的字段" } }, 15 | { "title": "c", "args": {} }, 16 | { "component": "Caption", "args": { "h1": "第叁组", "h2": "叒堆神奇的字段" } }, 17 | { "title": "d", "args": {} }, 18 | { "component": "Caption", "args": { "h1": "第肆组", "h2": "叕堆神奇的字段" } }, 19 | { "title": "e", "args": {} } 20 | ] 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /demo/config.yaml: -------------------------------------------------------------------------------- 1 | nav: 2 | - component: HTML 3 | args: 4 | html: Make everything 30' 5 | - Duang 6 | - component: User 7 | schemes: 8 | - schemes/inputs/datetime.json 9 | - schemes/inputs/keyboards.json 10 | - schemes/inputs/clicking.json 11 | - schemes/inputs/grouping.json 12 | - schemes/inputs/other.json 13 | - schemes/inputs/combines.json 14 | - schemes/inputs/caption.json 15 | - schemes/config/list.yaml 16 | - schemes/config/filter.yaml 17 | - schemes/config/error.yaml 18 | - schemes/config/form.yaml 19 | - schemes/config/hidden.yaml 20 | - key: doc 21 | title: 文档 22 | module: Custom 23 | params: 24 | href: 'https://eleme.github.io/duang/docs/' 25 | logo: Duang Demo 26 | session: 27 | authorize: authorize.json 28 | method: get 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleme-duang", 3 | "version": "1.0.0", 4 | "description": "docs => https://eleme.github.io/duang/docs/", 5 | "main": "src/duang.js", 6 | "directories": { 7 | "doc": "docs", 8 | "test": "tests" 9 | }, 10 | "scripts": { 11 | "lint": "eslint . --fix", 12 | "test": "eslint . --fix && ui-tester-start tests", 13 | "install": "ln -f pre-commit .git/hooks" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/eleme/duang.git" 18 | }, 19 | "author": "rongyi.liu@ele.me", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/eleme/duang/issues" 23 | }, 24 | "homepage": "https://github.com/eleme/duang#readme", 25 | "devDependencies": { 26 | "babel-eslint": "^7.2.3", 27 | "eslint": "^4.13.1", 28 | "eslint-config-elemefe": "^0.3.0", 29 | "ui-tester": "^1.2.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/InputForest.js: -------------------------------------------------------------------------------- 1 | def((Forest) => { 2 | 3 | class ForestWithDuang extends Forest { 4 | beforeParse(params) { 5 | if (!(params.options instanceof Array)) throw new Error('options 必须是数组'); 6 | let { idAlias = 'id', parentIdAlias = 'parentId', textAlias = 'text' } = params; 7 | let options = []; 8 | for (let item of params.options) options.push({ id: item[idAlias], parentId: item[parentIdAlias], text: item[textAlias] }); 9 | params.options = options; 10 | if (!('placeholder' in params)) params.placeholder = '请选择'; 11 | super.beforeParse(params); 12 | } 13 | get styleSheet() { 14 | return ` 15 | :scope { 16 | white-space: normal; 17 | > input { height: 28px; } 18 | } 19 | `; 20 | } 21 | } 22 | 23 | return function(...args) { 24 | return new ForestWithDuang(...args); 25 | }; 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/InputFileTokenWithInfo.js: -------------------------------------------------------------------------------- 1 | def((InputFileToken) => { 2 | return class extends Jinkela { 3 | init(...args) { 4 | this.$cache = {}; 5 | this.input = new InputFileToken(...args, { defaultValue: Object(this.defaultValue).token }).to(this); 6 | this.element.addEventListener('change', this.change.bind(this)); 7 | if (!this.$hasValue) this.value = void 0; 8 | } 9 | change() { this.$cache = {}; } 10 | set value(value = this.defaultValue) { 11 | this.$hasValue = true; 12 | value = Object(value); 13 | this.input.value = value.token; 14 | this.$cache = value; 15 | } 16 | get value() { 17 | if (this.$cache.token) return this.$cache; 18 | let { width, height } = this.input.info || {}; 19 | let token = this.input.value; 20 | return (width && height && token) ? { token, width, height } : null; 21 | } 22 | }; 23 | }); 24 | -------------------------------------------------------------------------------- /src/utils/refactor.js: -------------------------------------------------------------------------------- 1 | const refactor = (tmpl, data) => { 2 | if (!tmpl || typeof tmpl !== 'object') return tmpl; 3 | return Object.keys(tmpl).reduce((result, key) => { 4 | let value = tmpl[key]; 5 | if (/^(@|#)/.test(key)) { // 废弃,暂时保留兼容 6 | key = key.slice(1); 7 | value = JSONPath(value, data); 8 | if (/^(@|#)/.test(key)) { 9 | key = key.slice(1); 10 | } else { 11 | value = value[0]; 12 | } 13 | } else if (typeof value === 'string' && value[0] === '$') { // 以「$」开头的作为表达式解析 14 | try { 15 | excavate({ $: data }, value, value => { throw value; }); // 只找第一个 16 | } catch (what) { 17 | if (what instanceof Error) throw what; 18 | value = what; 19 | } 20 | } else { 21 | value = refactor(value, data); 22 | } 23 | result[key] = value; 24 | return result; 25 | }, tmpl instanceof Array ? [] : {}); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/InputCaption.js: -------------------------------------------------------------------------------- 1 | def((Output) => class extends Jinkela { 2 | get template() { 3 | return ` 4 |
    5 |

    6 |

    7 |
    8 | `; 9 | } 10 | set h1(what) { 11 | this.h1Ref = Output.createAny(what); 12 | } 13 | set h2(what) { 14 | this.h2Ref = Output.createAny(what); 15 | } 16 | get styleSheet() { 17 | return ` 18 | :scope { 19 | margin-top: 1em; 20 | display: flex; 21 | align-items: center; 22 | h1 { 23 | font-size: 1.5em; 24 | margin: 0; 25 | } 26 | h2 { 27 | font-size: 1em; 28 | font-weight: normal; 29 | margin: 0; 30 | margin-left: 1em; 31 | } 32 | } 33 | /* 黑科技 */ 34 | .table > :first-child :scope { 35 | margin-top: 0em; 36 | } 37 | `; 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/TableTip.js: -------------------------------------------------------------------------------- 1 | def((ErrorDisplay) => class extends Jinkela { 2 | init() { 3 | this.text = '正在拼命加载 ...'; 4 | } 5 | set error(error) { 6 | if (!error) return; 7 | this.element.innerHTML = ''; 8 | new ErrorDisplay({ error, noIcon: true }).to(this); 9 | } 10 | hide() { 11 | this.element.style.display = 'none'; 12 | } 13 | get template() { 14 | return ` 15 |
    16 |

    17 |
    18 | `; 19 | } 20 | get styleSheet() { 21 | return ` 22 | :scope { 23 | p { 24 | text-align: center; 25 | font-size: 16px; 26 | padding: 3em; 27 | color: inherit; 28 | white-space: pre; 29 | margin: 0 1em; 30 | border-radius: 4px; 31 | } 32 | > iframe { 33 | display: none; 34 | width: 100%; 35 | } 36 | } 37 | `; 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/FormItemWithTable.js: -------------------------------------------------------------------------------- 1 | def((FormItem) => class extends FormItem { 2 | 3 | init() { 4 | if (this.text.style.display === 'none') { 5 | // 伪 td 不支持 col-span,于是创建一个真实的 td 来替代伪 td 6 | let td = document.createElement('td'); 7 | td.colSpan = 2; 8 | while (this.ctrl.firstChild) td.appendChild(this.ctrl.firstChild); // this.ctrl 里面的所有子元素移动到 td 里面 9 | this.element.insertBefore(td, this.ctrl); 10 | this.ctrl.remove(); 11 | this.ctrl = td; 12 | } 13 | } 14 | 15 | get styleSheet() { 16 | return ` 17 | :scope { 18 | display: table-row; 19 | break-inside: avoid-column; 20 | > span { 21 | display: table-cell; 22 | vertical-align: top; 23 | line-height: 28px; 24 | text-align: left; 25 | } 26 | > span:first-child { 27 | width: 80px; 28 | white-space: nowrap; 29 | } 30 | } 31 | `; 32 | } 33 | 34 | }); 35 | -------------------------------------------------------------------------------- /src/utils/debounce.js: -------------------------------------------------------------------------------- 1 | const debounce = (func, delay, isImmediate) => { // eslint-disable-line no-unused-vars 2 | let timeout; 3 | let result; 4 | const later = (context, args) => { 5 | return setTimeout(() => { 6 | timeout = null; 7 | result = func.apply(context, args); 8 | }, delay); 9 | }; 10 | const debounced = function(...args) { 11 | let context = this; 12 | if (timeout) clearTimeout(timeout); 13 | if (isImmediate) { 14 | let callNow = !timeout; 15 | if (callNow) { 16 | result = func.apply(context, ...args); 17 | timeout = setTimeout(() => (timeout = null), delay); 18 | } else { 19 | timeout = later(context, ...args); 20 | } 21 | } else { 22 | timeout = later(context, ...args); 23 | } 24 | return result; 25 | }; 26 | debounced.cancel = () => { 27 | if (timeout) { 28 | clearTimeout(timeout); 29 | timeout = null; 30 | } 31 | }; 32 | return debounced; 33 | }; 34 | 35 | -------------------------------------------------------------------------------- /src/components/Item.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | init() { 3 | if (typeof this.onClick === 'function') { 4 | this.element.addEventListener('click', event => this.click(event)); 5 | } 6 | } 7 | set onclick(handler) { 8 | if (this.onClick) return; 9 | this.onClick = handler; 10 | } 11 | static cast(list, ...args) { 12 | list = list.map(item => new this(item, ...args)); 13 | list.to = target => { 14 | list.forEach(item => item.to(target)); 15 | return list; 16 | }; 17 | return list; 18 | } 19 | click() { 20 | if (this.element.classList.contains('busy')) return; 21 | if (typeof this.onClick !== 'function') return; 22 | this.element.classList.add('busy'); 23 | let what = this.onClick(event); 24 | if (what && what.then) { 25 | what.catch(() => {}).then(() => { 26 | this.element.classList.remove('busy'); 27 | }); 28 | } else { 29 | this.element.classList.remove('busy'); 30 | } 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/OutputHTML.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | get value() { return this.$value; } 3 | set value(value = this.defaultValue) { 4 | if (this.$value === value) return; 5 | this.$value = value; 6 | this.render(); 7 | } 8 | init() { 9 | this.render(); 10 | this.value = this.value; 11 | } 12 | render() { 13 | if ('html' in this) { 14 | this.element.innerHTML = String(this.html).replace(/\{(.*?)\}/g, ($0, key) => { 15 | let base = this.value instanceof Object ? this.value : this; 16 | return key.split('.').reduce((base, name) => Object(base)[name], base); 17 | }).replace(/ 3 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /src/components/InputGrouping.js: -------------------------------------------------------------------------------- 1 | def((SubGroupMap) => class extends Jinkela { 2 | 3 | init() { 4 | let { depot = window.depot, inputs, readonly, mode } = this; 5 | 6 | // 渲染 inputs(不可动态设置) 7 | let group = inputs || []; 8 | if (group.length) { 9 | this.sub = new SubGroupMap({ group, depot, readonly, mode }).to(this); 10 | } else { 11 | this.sub = null; 12 | } 13 | 14 | // 支持多列 15 | if (this.columns > 1) { 16 | this.element.dataset.columns = this.columns; 17 | this.element.style.columns = this.columns; 18 | } 19 | 20 | // 初始赋值 21 | this.value = this.$value; 22 | 23 | // 特殊样式 24 | if (this.style) Object.assign(this.element.style, this.style); 25 | } 26 | 27 | get value() { 28 | return Object.assign({}, this.sub && this.sub.value || this.$value || {}); 29 | } 30 | 31 | set value(value = this.defaultValue || {}) { 32 | // 已经初始化就直接赋值,如果尚未初始化就记录下来 33 | if (this.sub) { 34 | this.sub.value = value; 35 | } else { 36 | this.$value = value; 37 | } 38 | } 39 | 40 | get styleSheet() { 41 | return ` 42 | :scope { 43 | &[data-columns] > table { 44 | break-inside: initial; 45 | margin-top: -1em; 46 | } 47 | } 48 | `; 49 | } 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/PureDialog.js: -------------------------------------------------------------------------------- 1 | def((ErrorDialog) => class extends Jinkela { 2 | static showImage(args) { 3 | let panel; 4 | let img = new Image(); 5 | img.src = args.url; 6 | let handler = () => dialog.cancel(); 7 | img.addEventListener('load', () => { 8 | panel = new this({ children: [ img ], handler }, args); 9 | dialog.popup(panel, { minWidth: '0' }); 10 | dialog.once('transitionend', () => (panel.element.style.cursor = 'zoom-out')); 11 | }); 12 | img.addEventListener('error', () => ErrorDialog.popup({ error: new Error('图片加载失败') })); 13 | } 14 | get styleSheet() { 15 | return ` 16 | :scope { 17 | margin-top: -50px; 18 | position: relative; 19 | background-position: 0px 0px, 10px 10px; 20 | background-size: 20px 20px; 21 | background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee 100%), 22 | linear-gradient(45deg, #eee 25%, white 25%, white 75%, #eee 75%, #eee 100%); 23 | z-index: 1; 24 | > img { 25 | display: block; 26 | margin: auto; 27 | max-height: 70vh; 28 | max-width: 80vw; 29 | } 30 | } 31 | `; 32 | } 33 | get template() { 34 | return ` 35 |
    36 | `; 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/utils/doAction.js: -------------------------------------------------------------------------------- 1 | const doAction = (data, depot = window.depot) => { 2 | let { action, args } = data || {}; 3 | args = refactor(args, depot); 4 | let popupPanel = Component => Component.popup(args).then(result => doAction(result, depot)); 5 | switch (action) { 6 | case 'success': return req('PanelSuccess').then(popupPanel); 7 | case 'failure': return req('PanelFailure').then(popupPanel); 8 | case 'confirm': return req('Confirm').then(Confirm => Confirm.popup(args)); 9 | case 'replace': return new Promise((resolve, reject) => { location.replace(args.href); setTimeout(reject, 300); }); 10 | case 'assign': return new Promise((resolve, reject) => { location.assign(args.href); setTimeout(reject, 300); }); 11 | case 'open': return new Promise((resolve, reject) => { open(args.href); setTimeout(reject, 300); }); 12 | case 'go': return location.replace('#!' + new URLSearchParams(args)); 13 | case 'get': 14 | case 'put': 15 | case 'delete': 16 | case 'post': 17 | case 'patch': 18 | { 19 | let body = JSON.stringify(args.body); 20 | return api([ depot.key, args.key ], { method: action, body }).then(result => doAction(result, depot)); 21 | } 22 | case 'reject': return Promise.reject(args); 23 | case 'resolve': return Promise.resolve(args); 24 | case 'noop': return Promise.reject(null); 25 | } 26 | return Promise.resolve(data); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/InputRadio.js: -------------------------------------------------------------------------------- 1 | def((Radio, Item, Value) => { 2 | 3 | return class extends Value { 4 | get styleSheet() { 5 | return ` 6 | :scope { 7 | display: inline-block; 8 | vertical-align: middle; 9 | > * { 10 | vertical-align: top; 11 | } 12 | } 13 | `; 14 | } 15 | change(event) { 16 | if (this.changing) return; 17 | this.changing = true; 18 | this.list.forEach(item => (item.checked = item.element === event.target)); 19 | this.changing = false; 20 | } 21 | init() { 22 | this.element.addEventListener('change', event => this.change(event)); 23 | let { options, readonly } = this; 24 | let list = options instanceof Array ? options : Object.keys(options).map(key => ({ value: key, text: options[key] })); 25 | list = list.map(raw => Object.assign({}, raw, { readonly })); 26 | this.list = Radio.from(list).to(this); 27 | if (!this.$hasValue) this.value = void 0; 28 | } 29 | set value(value = this.defaultValue) { 30 | this.$hasValue = true; 31 | if (this.list.length === 0) return; 32 | if (!this.list.some(item => (item.checked = item.value === value))) this.list[0].checked = true; 33 | } 34 | get value() { 35 | let found = this.list.find(item => item.checked); 36 | return found && found.value; 37 | } 38 | }; 39 | 40 | }); 41 | -------------------------------------------------------------------------------- /src/components/OutputDateTime.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | 3 | update() { 4 | let { value, format = '$Y-$M-$D', offset, mode } = this; 5 | if (mode === 'UNIX_TIMESTAMP') { 6 | value = typeof value === 'number' ? value * 1000 : value; 7 | offset = typeof offset === 'number' ? offset * 1000 : offset; 8 | } 9 | value = new Date(value); 10 | if (offset) value = new Date(value.getTime() + offset); 11 | const lz = date => (date + '').replace(/\b\d\b/g, '0$&'); 12 | this.element.innerHTML = format.replace(/\$(.)/g, ($0, char) => { 13 | switch (char) { 14 | case 'Y': return lz(value.getFullYear()); 15 | case 'M': return lz(value.getMonth() + 1); 16 | case 'D': return lz(value.getDate()); 17 | case 'H': return lz(value.getHours()); 18 | case 'I': return lz(value.getMinutes()); 19 | case 'S': return lz(value.getSeconds()); 20 | default: return ''; 21 | } 22 | }); 23 | } 24 | 25 | get format() { return this.$format; } 26 | set format(value) { 27 | Object.defineProperty(this, '$format', { configurable: true, value }); 28 | this.update(); 29 | } 30 | 31 | get format() { return this.$format; } 32 | set format(value) { 33 | Object.defineProperty(this, '$format', { configurable: true, value }); 34 | this.update(); 35 | } 36 | 37 | get value() { return this.$value; } 38 | set value(value) { 39 | Object.defineProperty(this, '$value', { configurable: true, value }); 40 | this.update(); 41 | } 42 | 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/OutputLink.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | onClick(e) { 3 | e.stopPropagation(); 4 | let { module, key, params = {}, where = {}, title, _blank, target } = this; 5 | params = JSON.stringify(refactor(params, this.value)); 6 | where = JSON.stringify(refactor(where, this.value)); 7 | let uParams = new URLSearchParams({ module, key, params, where }); 8 | if (_blank) target = '_blank'; 9 | switch (target) { 10 | case '_blank': 11 | return open(location.href.replace(/(#.*)?$/, '#' + uParams)); 12 | case 'dialog': 13 | return req('MainWith' + String(module || 'default').replace(/./, $0 => $0.toUpperCase())).then(Main => { 14 | let main = new Main({ depot: depot.fork(uParams), title }); 15 | return Promise.resolve(main.$promise).then(() => dialog.popup(main)); 16 | }, error => { 17 | console.log(error); // eslint-disable-line 18 | }); 19 | default: 20 | location.hash = '#' + uParams; 21 | return; 22 | } 23 | } 24 | get template() { 25 | return ` 26 | {title} 27 | `; 28 | } 29 | set value(value) { 30 | this.$value = value; 31 | if (value) this.title = value.title || value; 32 | } 33 | get value() { return this.$value; } 34 | get styleSheet() { 35 | return ` 36 | :scope { 37 | color: #20A0FF; 38 | font-size: 12px; 39 | &:hover { 40 | color: #1D8CE0; 41 | } 42 | } 43 | `; 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /src/components/FormItem.js: -------------------------------------------------------------------------------- 1 | def((Item, Input, Output) => { 2 | 3 | return class extends Item { 4 | 5 | get template() { 6 | return ` 7 |
    8 | 9 | 10 | 11 |
    12 | `; 13 | } 14 | 15 | get styleSheet() { 16 | return ` 17 | :scope { 18 | } 19 | `; 20 | } 21 | 22 | set value(value) { 23 | if (!this.input) return setTimeout(() => (this.value = value)); 24 | this.input.value = value; 25 | } 26 | 27 | get value() { 28 | return this.input.value; 29 | } 30 | 31 | set hidden(value) { 32 | if (value) { 33 | this.element.style.display = 'none'; 34 | } else { 35 | this.element.style.removePropery('display'); 36 | } 37 | } 38 | 39 | init() { 40 | let { depot } = this; 41 | this.ctrl.depot = depot; 42 | this.input = this.createInput().to(this.ctrl); 43 | this.$promise = this.input.$promise; 44 | if ('title' in this) { 45 | Output.createAny(this.title, { depot }).to(this.text); 46 | } else { 47 | this.text.style.display = 'none'; 48 | } 49 | if ('description' in this) { 50 | Output.createAny(this.description, { depot }).to(this.desc); 51 | } else { 52 | this.desc.style.display = 'none'; 53 | } 54 | } 55 | 56 | createInput() { 57 | let { component, args, depot } = this; 58 | return new Input({ component, args, depot }); 59 | } 60 | 61 | }; 62 | 63 | }); 64 | -------------------------------------------------------------------------------- /src/components/InputPassword.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | get value() { 3 | let { value } = this.element; 4 | if (this.autoTrim) value = value.trim(); 5 | if (this.minlength && value.length < this.minlength) throw new Error(`必须大于 ${this.minLength} 个字符`); 6 | if (this.minLength && value.length < this.minLength) throw new Error(`必须大于 ${this.minLength} 个字符`); 7 | if (this.notEmpty && !value) throw new Error('不能为空'); 8 | return value; 9 | } 10 | set value(value = this.defaultValue) { 11 | this.$hasValue = true; 12 | this.element.value = value === void 0 ? '' : value; 13 | } 14 | init() { 15 | if (this.width !== void 0) this.element.style.width = this.width; 16 | if (this.readonly) this.element.setAttribute('readonly', 'readonly'); 17 | if ('placeholder' in this) this.element.setAttribute('placeholder', this.placeholder); 18 | if (!this.$hasValue) this.value = void 0; 19 | } 20 | get template() { return ''; } 21 | get styleSheet() { 22 | return ` 23 | :scope { 24 | &:hover { border-color: #8492a6; } 25 | &:focus { border-color: #20a0ff; } 26 | &[readonly] { 27 | background-color: #eff2f7; 28 | border-color: #d3dce6; 29 | color: #bbb; 30 | cursor: not-allowed; 31 | } 32 | transition: border-color .2s cubic-bezier(.645,.045,.355,1); 33 | vertical-align: middle; 34 | box-sizing: border-box; 35 | width: 300px; 36 | height: 28px; 37 | font-size: 12px; 38 | line-height: 28px; 39 | border: 1px solid #C0CCDA; 40 | border-radius: 5px; 41 | padding: .4em .5em; 42 | } 43 | `; 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /src/utils/amdx.js: -------------------------------------------------------------------------------- 1 | { 2 | 3 | const SHADOW = 'https://shadow.elemecdn.com'; 4 | const internalMap = { 5 | TimePicker: SHADOW + '/npm/jinkela-timepicker@1.3.2/umd.min.js', 6 | DatePicker: SHADOW + '/npm/jinkela-datepicker@1.3.2/umd.min.js', 7 | Forest: SHADOW + '/npm/jinkela-forest@1.1.1/umd.min.js', 8 | Cascader: SHADOW + '/npm/jinkela-cascader@1.1.0/umd.min.js', 9 | Checkbox: SHADOW + '/npm/jinkela-checkbox@1.1.0/umd.min.js', 10 | Radio: SHADOW + '/npm/jinkela-radio@1.1.0/umd.min.js', 11 | ClickTip: SHADOW + '/npm/jinkela-clicktip@1.1.0/umd.min.js' 12 | }; 13 | 14 | window.def = factory => { 15 | let matched = (factory + '').match(/\([\s\S]*?\)/g) || []; 16 | matched = String(matched[0]).slice(1, -1); 17 | let names = matched.match(/[^,\s]+/g) || []; 18 | let deps = names.map(name => { 19 | if (name in internalMap) { 20 | return internalMap[name]; 21 | } else { 22 | return 'components/' + name.replace(/\$/g, '/') + '.js'; 23 | } 24 | }); 25 | define(deps, factory); 26 | }; 27 | 28 | window.req = dep => { 29 | let url; 30 | if (dep in internalMap) { 31 | dep = internalMap[dep]; 32 | url = dep; 33 | } else if (/\W/.test(dep)) { 34 | dep = new Function('return `' + dep + '`')(); 35 | url = dep; 36 | } else { 37 | url = `components/${dep.replace(/\$/g, '/')}.js`; 38 | } 39 | if (url in req.cache) return req.cache[url]; 40 | req.cache[url] = new Promise((resolve, reject) => { // eslint-disable-line no-unused-vars 41 | require([ url ], resolve, error => { 42 | void error; 43 | reject(new Error(`组件 <${dep}> 未找到`)); 44 | }); 45 | }); 46 | return req.cache[url]; 47 | }; 48 | 49 | req.cache = {}; 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/components/OutputClipboard.js: -------------------------------------------------------------------------------- 1 | def((ClickTip) => class extends Jinkela { 2 | get value() { return this.$value; } 3 | set value(value = this.defaultValue) { 4 | if (this.$value === value) return; 5 | this.$value = value; 6 | this.render(); 7 | } 8 | init() { 9 | this.element.style.maxWidth = this.maxWidth || 420; 10 | this.element.addEventListener('click', event => this.click(event)); 11 | this.render(); 12 | this.value = this.value; 13 | } 14 | click(event) { 15 | // 创建选区并执行复制 16 | let first = this.element.firstChild; 17 | let last = this.element.lastChild; 18 | let range = document.createRange(); 19 | range.setStart(first, 0); 20 | range.setEnd(last, last.data.length); 21 | let selection = getSelection(); 22 | selection.removeAllRanges(); 23 | selection.addRange(range); 24 | document.execCommand('Copy'); 25 | ClickTip.show(event, { text: '已复制' }); 26 | } 27 | render() { 28 | if (this.html) { 29 | this.element.innerHTML = String(this.html).replace(/\{(.*?)\}/g, ($0, key) => { 30 | let base = this.value instanceof Object ? this.value : this; 31 | return key.split('.').reduce((base, name) => Object(base)[name], base); 32 | }).replace(/ 3 | 4 | 27 | 28 | 50 | -------------------------------------------------------------------------------- /src/components/InputNumber.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | init() { 3 | this.element.addEventListener('blur', event => this.blur(event)); 4 | if (this.min !== void 0) this.element.min = this.min; 5 | if (this.max !== void 0) this.element.max = this.max; 6 | if (this.step !== void 0) this.element.step = this.step; 7 | if (this.width !== void 0) this.element.style.width = this.width; 8 | if (this.readonly) this.element.setAttribute('readonly', 'readonly'); 9 | if (!('defaultValue' in this)) this.defaultValue = this.min; 10 | this.value = this.default; // default 已废弃,暂时保持兼容,请使用 defaultValue 11 | } 12 | get value() { return +this.element.value; } 13 | set value(value) { 14 | if (+value !== +value) value = this.defaultValue; 15 | if (+value !== +value) value = 0; 16 | value *= 1; 17 | if (typeof this.decimal === 'number') value = +value.toFixed(this.decimal); 18 | this.element.value = value; 19 | } 20 | get template() { return ''; } 21 | blur() { 22 | if (this.min !== void 0 && this.value < this.min) this.value = this.min; 23 | if (this.max !== void 0 && this.value > this.max) this.value = this.max; 24 | this.value = this.value; 25 | } 26 | get styleSheet() { 27 | return ` 28 | :scope { 29 | &:hover { border-color: #8492a6; } 30 | &:focus { border-color: #20a0ff; } 31 | &[readonly] { 32 | background-color: #eff2f7; 33 | border-color: #d3dce6; 34 | color: #bbb; 35 | cursor: not-allowed; 36 | } 37 | transition: border-color .2s cubic-bezier(.645,.045,.355,1); 38 | width: 6em; 39 | box-sizing: border-box; 40 | border: 1px solid #C0CCDA; 41 | border-radius: 5px; 42 | padding: .4em .5em; 43 | width: 120px; 44 | height: 28px; 45 | font-size: 12px; 46 | line-height: 28px; 47 | } 48 | `; 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /src/components/InputString.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | get value() { 3 | let { value } = this.element; 4 | if (this.autoTrim) value = value.trim(); 5 | if (this.minlength && value.length < this.minlength) throw new Error(`必须大于 ${this.minLength} 个字符`); 6 | if (this.minLength && value.length < this.minLength) throw new Error(`必须大于 ${this.minLength} 个字符`); 7 | if (this.notEmpty && !value) throw new Error('不能为空'); 8 | return value; 9 | } 10 | set value(value = this.defaultValue) { 11 | this.$hasValue = true; 12 | this.element.value = value === void 0 ? '' : value; 13 | } 14 | init() { 15 | if (this.width !== void 0) this.element.style.width = this.width; 16 | if (this.readonly) this.element.setAttribute('readonly', 'readonly'); 17 | if (typeof this.placeholder === 'string') this.element.setAttribute('placeholder', this.placeholder); 18 | if ('maxlength' in this) this.element.setAttribute('maxlength', this.maxlength); 19 | if ('maxLength' in this) this.element.setAttribute('maxlength', this.maxLength); 20 | if (!this.$hasValue) this.value = this.default; // default 已废弃,暂时保持兼容,请使用 defaultValue 21 | } 22 | get template() { return ''; } 23 | get styleSheet() { 24 | return ` 25 | :scope { 26 | &:hover { border-color: #8492a6; } 27 | &:focus { border-color: #20a0ff; } 28 | &[readonly] { 29 | background-color: #eff2f7; 30 | border-color: #d3dce6; 31 | color: #bbb; 32 | cursor: not-allowed; 33 | } 34 | transition: border-color .2s cubic-bezier(.645,.045,.355,1); 35 | vertical-align: middle; 36 | box-sizing: border-box; 37 | width: 300px; 38 | height: 28px; 39 | font-size: 12px; 40 | line-height: 28px; 41 | border: 1px solid #C0CCDA; 42 | border-radius: 5px; 43 | padding: .4em .5em; 44 | } 45 | `; 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/InputText.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | get value() { 3 | let { value } = this.element; 4 | if (this.autoTrim) value = value.trim(); 5 | if (this.minlength && value.length < this.minlength) throw new Error(`必须大于 ${this.minLength} 个字符`); 6 | if (this.minLength && value.length < this.minLength) throw new Error(`必须大于 ${this.minLength} 个字符`); 7 | if (this.notEmpty && !value) throw new Error('不能为空'); 8 | return value; 9 | } 10 | set value(value = this.defaultValue) { 11 | this.$hasValue = true; 12 | this.element.value = value === void 0 ? '' : value; 13 | } 14 | init() { 15 | if (this.width !== void 0) this.element.style.width = this.width; 16 | if (this.height !== void 0) this.element.style.height = this.height; 17 | if (this.readonly) this.element.setAttribute('readonly', 'readonly'); 18 | if ('placeholder' in this) this.element.setAttribute('placeholder', this.placeholder); 19 | if ('maxlength' in this) this.element.setAttribute('maxlength', this.maxlength); 20 | if ('maxLength' in this) this.element.setAttribute('maxlength', this.maxLength); 21 | if (!this.$hasValue) this.value = this.default; // default 已废弃,暂时保持兼容,请使用 defaultValue 22 | } 23 | get tagName() { return 'textarea'; } 24 | get styleSheet() { 25 | return ` 26 | :scope { 27 | &:hover { border-color: #8492a6; } 28 | &:focus { border-color: #20a0ff; } 29 | &[readonly] { 30 | background-color: #eff2f7; 31 | border-color: #d3dce6; 32 | color: #bbb; 33 | cursor: not-allowed; 34 | } 35 | transition: border-color .2s cubic-bezier(.645,.045,.355,1); 36 | vertical-align: middle; 37 | width: 300px; 38 | height: 60px; 39 | border: 1px solid #C0CCDA; 40 | border-radius: 5px; 41 | padding: .5em; 42 | font-size: 12px; 43 | outline: none; 44 | } 45 | `; 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/ListFlex.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | 3 | createAnimation() { 4 | let maxHeight = -this.height + 'px'; 5 | if (this.element.animate) { // 支持 animate 6 | return this.element.animate([ 7 | { transform: 'scale(1)', opacity: 1, marginBottom: '-1em', marginTop: '1em' }, 8 | { transform: 'scale(.001)', opacity: 0, marginBottom: maxHeight, marginTop: 0 } 9 | ], { 10 | duration: 300, 11 | fill: 'forwards' 12 | }); 13 | } else { // 不支持 animate,降级处理 14 | let finish = () => {}; 15 | let reverse = () => this.element.style.removeProperty('display'); 16 | this.element.style.setProperty('display', 'none'); 17 | return { finish, reverse }; 18 | } 19 | } 20 | 21 | toggle() { 22 | if (this.element.classList.contains('hidden')) { 23 | this.element.classList.remove('hidden'); 24 | this.toggleObject = this.createAnimation(); 25 | this.toggleObject.finish(); 26 | } 27 | if (this.toggleObject) { 28 | this.toggleObject.reverse(); 29 | delete this.toggleObject; 30 | } else { 31 | this.toggleObject = this.createAnimation(); 32 | return this.toggleObject; 33 | } 34 | } 35 | 36 | get height() { 37 | this.element.style.display = 'flex'; 38 | this.element.style.position = 'absolute'; 39 | let height = this.element.offsetHeight; 40 | this.element.style.removeProperty('display'); 41 | this.element.style.removeProperty('position'); 42 | return height; 43 | } 44 | 45 | init() { 46 | if (this['hidden-default']) this.element.classList.add('hidden'); 47 | } 48 | 49 | get template() { 50 | return ` 51 |
    52 | 53 |
    54 | `; 55 | } 56 | 57 | get styleSheet() { 58 | return ` 59 | :scope { 60 | &.hidden { display: none; } 61 | transform-origin: top; 62 | margin: 1em 1em -1em 1em; 63 | display: flex; 64 | } 65 | `; 66 | } 67 | 68 | }); 69 | -------------------------------------------------------------------------------- /docs/intro/README.md: -------------------------------------------------------------------------------- 1 | ## 1. 背景 2 | 3 | 所有 CMS 都是千篇一律的增删改查,看起来并没有什么特别的功能却要投入人力去开发。 4 | 5 | 为了**解决前端开发资源浪费**的问题,就有了「Duang」这个工具。 6 | 7 | ## 2. Duang 8 | 9 | 「Duang」是一个通过**配置文件**来创建 CMS 的解决方案。 10 | 11 | ## 3. 原理 12 | 13 | 所有 CMS 的页面结构都是基本相同的,不同之处只是列表项、筛选器、表单项等,这些与业务耦合在一起的东西。 14 | 15 | 「Duang」通过读取一个 json 文件来配置出 CMS 的列表、表单等页面。 16 | 17 | ## 4. 快速上手 18 | 19 | 从零开始,手把手教你跑起一个最简单的「Duang」项目。 20 | 21 | ### 4.1. 创建项目 22 | 23 | 创建一个名为 `demo` 的目录作为这个示例的**项目根目录**。 24 | 25 | ### 4.2. 创建入口文件 index.html 26 | 27 | 在**项目根目录**中创建 `index.html`,在这个文件中使用 SCRIPT 标签引入 `duang.js`,并且在 SCRIPT 标签上添加 `config` 属性来指定配置文件所在的位置。 28 | 29 | 以下就是这个文件的完整内容: 30 | 31 | ```html 32 | 33 | 37 | ``` 38 | 39 | ### 4.3. 创建配置文件 config.json 40 | 41 | 上一步中我们在 SCRIPT 标签上指定了配置文件路径,于是我们应该正确地提供这个文件。 42 | 43 | 以下就是这个文件的完整内容: 44 | 45 | ```json 46 | { 47 | "schemes": [ 48 | { 49 | "key": "/list.json", 50 | "title": "一个神奇的列表", 51 | "module": "list", 52 | "fields": [ 53 | { "key": "id", "title": "ID" }, 54 | { "key": "title", "title": "标题" }, 55 | { "key": "price", "title": "价格" } 56 | ] 57 | } 58 | ] 59 | } 60 | ``` 61 | 62 | ### 4.4. 创建 mock 数据 63 | 64 | 由于这个示例项目并没有真正的后端 API 可以调用,我们就直接以 json 文件的形式 mock API 的结果。 65 | 66 | 上一步中我们在配置文件中指定了一个 `key` 为 `/list.json`,于是我们应该正确地提供这个接口 mock。 67 | 68 | 以下就是这个文件的完整内容: 69 | 70 | ```json 71 | [ 72 | { "id": 1, "title": "这是一条神奇的记录", "price": 500 }, 73 | { "id": 2, "title": "这也是一条神奇的记录", "price": 1000 }, 74 | { "id": 3, "title": "这并不是一条神奇的记录", "price": 5000 } 75 | ] 76 | ``` 77 | 78 | ### 4.5. 跑起来 79 | 80 | 到目前为止已经万事俱备了,我们可以在**项目根目录**上启动一个 HTTP 服务来把这个示例项目跑起来。 81 | 82 | 启动 HTTP 服务的方法有很多,如果大家不知道怎启动的话可以先在终端 `cd` 到**项目根目录**下,然后执行以下命令: 83 | 84 | ```bash 85 | python -m SimpleHTTPServer 86 | ``` 87 | 88 | 之后用 Chrome 打开 http://127.0.0.1:8000 即可访问到这个示例项目。 89 | 90 | ### 4.6. 后记 91 | 92 | 这个最简单的实例中只是把一个 mock 的列表 API 以表格的形式展示出来了而已。 93 | 94 | 实际上「Duang」能做到的远不止这些,具体可以参考其它[配置文档](/duang/config/)。 95 | -------------------------------------------------------------------------------- /src/components/InputGroupingSelect.js: -------------------------------------------------------------------------------- 1 | def((InputSelect, SubGroupMap) => class extends Jinkela { 2 | beforeParse(params) { 3 | this.options = params.options; 4 | this.readonly = params.readonly; 5 | } 6 | get InputSelect() { return InputSelect; } 7 | get template() { 8 | return ` 9 |
    10 | 15 |
    16 |
    17 | `; 18 | } 19 | init() { 20 | if (!this.$hasValue) this.value = void 0; 21 | if (this.mode) this.container.classList.add(this.mode); 22 | } 23 | get selectChange() { 24 | let value = () => { 25 | let { depot } = this; 26 | let group = this.subGroupMap[this.select.value] || []; 27 | if (group.length) { 28 | let table = new SubGroupMap({ group, depot }); 29 | this.table = this.table ? table.renderWith(this.table) : table.to(this.container); 30 | } else { 31 | if (this.table) this.table.element.remove(); 32 | this.table = null; 33 | } 34 | }; 35 | Object.defineProperty(this, 'selectChange', { value, configurable: true }); 36 | return value; 37 | } 38 | get value() { 39 | let base = this.hideKey ? {} : { [this.aliasKey || '']: this.select.value }; 40 | return Object.assign(base, this.table ? this.table.value : {}); 41 | } 42 | set value(value = this.defaultValue) { 43 | this.$hasValue = true; 44 | if (value) this.select.value = value[this.aliasKey || '']; 45 | this.selectChange(); 46 | if (this.table) this.table.value = value; 47 | } 48 | get styleSheet() { 49 | return ` 50 | :scope { 51 | text-align: left; 52 | > .container { 53 | margin-top: 1em; 54 | &:empty { display: none; } 55 | &.line { 56 | margin: 0 0 0 1em; 57 | display: inline-block; 58 | vertical-align: top; 59 | } 60 | } 61 | } 62 | `; 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/InputGroupingCheckbox.js: -------------------------------------------------------------------------------- 1 | def((SubGroupMap, Checkbox) => class extends Jinkela { 2 | get Checkbox() { return Checkbox; } 3 | 4 | beforeParse(params) { 5 | this.options = { true: params.label }; 6 | } 7 | 8 | get template() { 9 | return ` 10 |
    11 | 15 |
    16 |
    17 | `; 18 | } 19 | 20 | init() { 21 | if (!this.$hasValue) this.value = void 0; 22 | this.container.classList.add('line'); 23 | } 24 | 25 | get inputValue() { return this.input.checked; } 26 | set inputValue(value) { this.input.checked = value; } 27 | 28 | get change() { 29 | let value = () => { 30 | let { depot } = this; 31 | let group = this.inputValue ? this.subGroup : []; 32 | if (group.length) { 33 | let table = new SubGroupMap({ group, depot }); 34 | this.table = this.table ? table.renderWith(this.table) : table.to(this.container); 35 | } else { 36 | if (this.table) this.table.element.remove(); 37 | this.table = null; 38 | } 39 | }; 40 | Object.defineProperty(this, 'change', { value, configurable: true }); 41 | return value; 42 | } 43 | 44 | get value() { 45 | let base = this.hideKey ? {} : { [this.aliasKey || '']: this.inputValue }; 46 | return Object.assign(base, this.table ? this.table.value : {}); 47 | } 48 | 49 | set value(value = this.defaultValue) { 50 | this.$hasValue = true; 51 | if (value) this.inputValue = value[this.aliasKey || '']; 52 | this.change(); 53 | if (this.table) this.table.value = value; 54 | } 55 | 56 | get styleSheet() { 57 | return ` 58 | :scope { 59 | text-align: left; 60 | > .container { 61 | margin-top: 1em; 62 | &:empty { display: none; } 63 | &.line { 64 | margin: 0 0 0 1em; 65 | display: inline-block; 66 | vertical-align: top; 67 | } 68 | } 69 | } 70 | `; 71 | } 72 | 73 | }); 74 | -------------------------------------------------------------------------------- /src/components/FatalError.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | 3 | set depot(value) { 4 | let { uParams, where, params, scheme } = value || window.depot; 5 | uParams = Object.assign({}, uParams, { where, params }); 6 | this.detail = JSON.stringify({ uParams, scheme }, null, 2);; 7 | } 8 | 9 | toggle() { 10 | this.showDetail = !this.showDetail; 11 | this.update(); 12 | } 13 | 14 | update() { 15 | if (this.showDetail) { 16 | this.toggleText = '[隐藏详情]'; 17 | this.pre.style.height = this.pre.scrollHeight + 'px'; 18 | this.pre.style.opacity = 1; 19 | } else { 20 | this.toggleText = '[查看详情]'; 21 | this.pre.style.height = 0; 22 | this.pre.style.opacity = 0; 23 | } 24 | } 25 | 26 | init() { 27 | this.update(); 28 | } 29 | 30 | get template() { 31 | return ` 32 |
    33 |

    34 | {message} 35 | 36 |

    37 |
    38 |
    {detail}
    39 |
    40 | `; 41 | } 42 | 43 | get styleSheet() { 44 | return ` 45 | :scope { 46 | text-align: left; 47 | font-size: 16px; 48 | padding: .5em 2em; 49 | > h2 { 50 | display: flex; 51 | align-items: center; 52 | &::before { 53 | display: block; 54 | width: 1em; 55 | height: 1em; 56 | background: url('https://shadow.elemecdn.com/iconfont/icons/3961876/fills/%23F56C6C'); 57 | background-size: cover; 58 | content: ''; 59 | margin-right: .5em; 60 | } 61 | > button { 62 | border: 0; 63 | padding: 0; 64 | background: none; 65 | color: #409eff; 66 | cursor: pointer; 67 | margin-left: 2em; 68 | outline: none; 69 | } 70 | } 71 | > pre { 72 | margin: 1em 0; 73 | overflow: hidden; 74 | transition: height 200ms linear, opacity 200ms linear; 75 | } 76 | } 77 | `; 78 | } 79 | 80 | }); 81 | -------------------------------------------------------------------------------- /src/components/InputBoolean.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | 3 | get value() { return this.$value; } 4 | 5 | set value(value = this.defaultValue) { 6 | if (typeof value === 'string') { 7 | try { 8 | value = JSON.parse(value); 9 | } catch (error) { 10 | setTimeout(() => { throw error; }); 11 | } 12 | } 13 | this.element.dataset.value = this.$value = !!value; 14 | } 15 | 16 | init() { 17 | Object.defineProperty(this.element, 'value', { 18 | get: () => this.value, 19 | set: value => (this.value = value) 20 | }); 21 | this.value = this.value; 22 | if (this.readonly) { 23 | if (this.readonly) this.element.setAttribute('readonly', 'readonly'); 24 | } else { 25 | this.element.addEventListener('click', () => (this.value = !this.$value)); 26 | } 27 | let { text = {} } = this; 28 | this.element.setAttribute('data-text-true', text.true || '开'); 29 | this.element.setAttribute('data-text-false', text.false || '关'); 30 | if (this.fontSize) this.element.style.fontSize = this.fontSize + 'px'; 31 | } 32 | 33 | get styleSheet() { 34 | return ` 35 | :scope { 36 | width: 80px; 37 | height: 24px; 38 | line-height: 24px; 39 | border-radius: 12px; 40 | border: 1px solid #D3DCE6; 41 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 42 | display: inline-block; 43 | cursor: pointer; 44 | text-align: left; 45 | &[readonly] { 46 | cursor: not-allowed; 47 | filter: saturate(0); 48 | } 49 | &:before { 50 | content: attr(data-text-false); 51 | display: inline-block; 52 | text-align: center; 53 | color: #fff; 54 | width: 60px; 55 | height: 24px; 56 | border-radius: 12px; 57 | background: #D3DCE6; 58 | transition: transform 200ms ease; 59 | padding: 1px; 60 | margin: -1px; 61 | } 62 | &[data-value=true]:before { 63 | content: attr(data-text-true); 64 | background: #58B7FF; 65 | transform: translateX(20px); 66 | } 67 | } 68 | `; 69 | } 70 | 71 | }); 72 | -------------------------------------------------------------------------------- /src/components/InputPair.js: -------------------------------------------------------------------------------- 1 | def((Input) => { 2 | 3 | class StaticKey extends Jinkela { 4 | set keyFieldALign(value) { this.element.style.textAlign = value; } 5 | set keyFieldWidth(value) { this.element.style.width = value; } 6 | get template() { return '{value}'; } 7 | get styleSheet() { 8 | return ` 9 | :scope { 10 | display: inline-block; 11 | text-align: right; 12 | } 13 | `; 14 | } 15 | } 16 | 17 | return class extends Jinkela { 18 | init() { 19 | let { readonly, staticKey, keyField, keyFieldAlign, keyFieldWidth, valueField } = this; 20 | if (staticKey) { 21 | this.keyInputObject = new StaticKey({ value: '', keyFieldAlign, keyFieldWidth }).to(this); 22 | } else { 23 | if (!keyField || typeof keyField !== 'object') keyField = { component: 'String', args: { width: 120 } }; 24 | keyField = Object.assign({}, keyField); 25 | keyField.args = Object.assign({ readonly }, keyField.args); 26 | this.keyInputObject = new Input(keyField).to(this); 27 | } 28 | if (!valueField || typeof valueField !== 'object') valueField = { component: 'String', args: { width: 200 } }; 29 | valueField = Object.assign({}, valueField); 30 | valueField.args = Object.assign({ readonly }, valueField.args); 31 | this.valueInputObject = new Input(valueField).to(this); 32 | if (!this.$hasValue) this.value = void 0; 33 | } 34 | get value() { 35 | return { 36 | [this.keyFieldName || 'key']: this.keyInputObject.value, 37 | [this.valueFieldName || 'value']: this.valueInputObject.value 38 | }; 39 | } 40 | set value(pair = this.defaultValue) { 41 | this.$hasValue = true; 42 | pair = Object(pair); 43 | this.keyInputObject.value = pair[this.keyFieldName || 'key']; 44 | this.valueInputObject.value = pair[this.valueFieldName || 'value']; 45 | } 46 | get styleSheet() { 47 | return ` 48 | :scope { 49 | display: inline-block; 50 | white-space: nowrap; 51 | > * { 52 | &:first-child { margin-right: .5em; } 53 | display: inline-block; 54 | } 55 | } 56 | `; 57 | } 58 | }; 59 | 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/OutputImage.js: -------------------------------------------------------------------------------- 1 | def((PureDialog) => { 2 | 3 | let cache = {}; 4 | 5 | return class extends Jinkela { 6 | 7 | get src() { return this.value || this.defaultValue; } 8 | 9 | load() { 10 | // 如果缓存可用则同步取缓存(异常直接 throw) 11 | let { noCache, src } = this; 12 | if (!noCache && src in cache) { 13 | let what = cache[src]; 14 | if (what instanceof Error) throw what; 15 | return what.cloneNode(); 16 | } 17 | // 正常的异步加载流程 18 | return new Promise((resolve, reject) => { 19 | let img = new Image(); 20 | img.src = src; 21 | img.addEventListener('load', () => { 22 | cache[src] = img; 23 | resolve(img.cloneNode()); 24 | }); 25 | img.addEventListener('error', () => { 26 | let exception = new Error('加载失败'); 27 | cache[src] = exception; 28 | reject(exception); 29 | }); 30 | }); 31 | } 32 | 33 | init() { 34 | if (!this.src) return; 35 | try { 36 | FastResolve.fastResolve(this.load(), img => { 37 | if (this.maxWidth) img.style.setProperty('max-width', this.maxWidth); 38 | if (this.maxHeight) img.style.setProperty('max-height', this.maxHeight); 39 | img.addEventListener('click', event => { 40 | event.preventDefault(); 41 | PureDialog.showImage({ url: this.src }); 42 | }); 43 | this.element.href = this.src; 44 | this.element.appendChild(img); 45 | }, error => { 46 | this.errorHandler(error); 47 | }); 48 | } catch (error) { 49 | this.errorHandler(error); 50 | } 51 | } 52 | 53 | errorHandler() { 54 | this.element.textContent = '加载失败'; 55 | } 56 | 57 | get template() { 58 | return ` 59 | 60 | `; 61 | } 62 | 63 | get styleSheet() { 64 | return ` 65 | :scope { 66 | display: inline-block; 67 | cursor: default; 68 | > img { 69 | cursor: zoom-in; 70 | display: block; 71 | max-width: 32px; 72 | max-height: 32px; 73 | } 74 | } 75 | `; 76 | } 77 | 78 | }; 79 | 80 | }); 81 | -------------------------------------------------------------------------------- /src/components/InputDate.js: -------------------------------------------------------------------------------- 1 | def((DatePicker) => { 2 | 3 | const format = value => { 4 | let now = new Date(); 5 | switch (value) { 6 | case 'today': return now.valueOf(); 7 | case 'nextDay': return now.setDate(now.getDate() + 1); 8 | case 'lastDay': return now.setDate(now.getDate() - 1); 9 | case 'nextWeek': return now.setDate(now.getDate() + 7); 10 | case 'lastWeek': return now.setDate(now.getDate() - 7); 11 | case 'nextMonth': return now.setMonth(now.getMonth() + 1); 12 | case 'lastMonth': return now.setMonth(now.getMonth() - 1); 13 | case 'nextYear': return now.setYear(now.getFullYear() + 1); 14 | case 'lastYear': return now.setYear(now.getFullYear() - 1); 15 | default: return value || ''; 16 | } 17 | }; 18 | 19 | class DatePickerWithDuang extends Jinkela { 20 | beforeParse(params) { 21 | if (!('value' in params)) params.value = params.defaultValue; 22 | this.dp = new DatePicker(params); 23 | } 24 | init() { 25 | this.dp.to(this); 26 | if (!this.$hasValue) this.value = void 0; 27 | if (this.readonly) { 28 | this.element.classList.add('readonly'); 29 | this.element.addEventListener('mousedown', event => { 30 | event.preventDefault(); 31 | event.stopPropagation(); 32 | }, true); 33 | } 34 | } 35 | set value(value = this.defaultValue) { 36 | this.$hasValue = true; 37 | if (this.mode === 'UNIX_TIMESTAMP' && typeof value === 'number') { value *= 1000; } 38 | this.dp.value = format(value); 39 | } 40 | get value() { 41 | let date = this.dp.value; 42 | if (this.mode === 'UNIX_TIMESTAMP') { 43 | return Math.round(date / 1000); 44 | } else { 45 | return date; 46 | } 47 | } 48 | get styleSheet() { 49 | return ` 50 | :scope { 51 | input { height: 28px; } 52 | &.readonly { 53 | input { 54 | background-color: #eff2f7; 55 | border-color: #d3dce6; 56 | color: #bbb; 57 | cursor: not-allowed; 58 | } 59 | } 60 | } 61 | `; 62 | } 63 | } 64 | 65 | return function(...args) { 66 | return new DatePickerWithDuang(...args); 67 | }; 68 | 69 | }); 70 | -------------------------------------------------------------------------------- /src/components/InputCheckbox.js: -------------------------------------------------------------------------------- 1 | def((Checkbox, Item, Output, Value) => { 2 | 3 | return class extends Value { 4 | get styleSheet() { 5 | return ` 6 | :scope { 7 | display: inline-block; 8 | vertical-align: middle; 9 | > * { 10 | white-space: nowrap; 11 | vertical-align: top; 12 | } 13 | } 14 | `; 15 | } 16 | init() { 17 | this.element.addEventListener('change', event => this.change(event)); 18 | let { options, readonly } = this; 19 | let list = options instanceof Array ? options : Object.keys(options).map(key => ({ value: key, text: options[key] })); 20 | if (list.length > 1 && !readonly) this.toggleItem = new Checkbox({ readonly, text: '全选' }).to(this); 21 | list.forEach(item => { 22 | item.text = Output.createAny(item.text); 23 | }); 24 | this.list = Checkbox.from(list.map(item => Object.assign({ readonly }, item))).to(this); 25 | for (let item of this.list) this.element.insertBefore(new Text(' '), item.element); // 为了自动换行强行插入一堆空文本节点 26 | this.value = this.$value || this.defaultValue; 27 | } 28 | change(event) { 29 | if (this.changing === true) return; 30 | this.changing = true; 31 | if (this.toggleItem && this.toggleItem.element === event.target) { 32 | this.list.forEach(item => (item.checked = this.toggleItem.checked)); 33 | } else { 34 | this.updateTheAll(); 35 | } 36 | this.changing = false; 37 | if (typeof this.onchange === 'function') this.onchange(); 38 | } 39 | updateTheAll() { 40 | if (this.toggleItem) this.toggleItem.checked = this.list.every(item => item.checked); 41 | } 42 | set value(value = this.defaultValue || []) { 43 | this.$value = value; 44 | if (value && typeof value === 'object' && value.all === true) { // 默认全选 45 | if (this.list) this.list.forEach(item => (item.checked = true)); 46 | } else { 47 | let set = new Set(value); 48 | if (this.list) this.list.forEach(item => (item.checked = set.has(item.value))); 49 | } 50 | this.updateTheAll(); 51 | } 52 | get value() { 53 | if (this.list) return this.list.filter(item => item.checked).map(item => item.value); 54 | } 55 | }; 56 | 57 | }); 58 | -------------------------------------------------------------------------------- /src/components/FormSubmit.js: -------------------------------------------------------------------------------- 1 | def((Button, ButtonHollow, ErrorDialog) => { 2 | 3 | return class extends Jinkela { 4 | 5 | beforeParse() { 6 | this.submit = this.submit.bind(this); 7 | } 8 | 9 | get Button() { return Button; } 10 | get ButtonHollow() { return ButtonHollow; } 11 | 12 | get template() { 13 | return ` 14 |
    15 | 提交 16 | 返回 17 |
    18 | `; 19 | } 20 | 21 | back() { 22 | if (dialog.element.contains(this.element)) { 23 | dialog.cancel(); 24 | } else { 25 | history.back(); 26 | } 27 | } 28 | 29 | submit() { 30 | this.backComponent.busy = true; 31 | let { form, depot } = this; 32 | let { id, resolvedKey } = depot; 33 | let { beforeSubmit, afterSubmit, defaultAfterSubmit } = depot.scheme; 34 | return Promise.resolve().then(() => doAction(beforeSubmit, depot)).then(value => { 35 | if (value === false) return Promise.reject(null); // Confirm 拒绝 36 | }).then(() => { 37 | // 准备数据,调接口 38 | let value = JSON.stringify(form.value); 39 | if (id) { 40 | return api([ resolvedKey, id ], { method: 'PUT', body: value }); 41 | } else { 42 | return api(resolvedKey, { method: 'POST', body: value }); 43 | } 44 | }).then(result => doAction(afterSubmit || result || defaultAfterSubmit, depot)).then(() => { 45 | // 处理结果 46 | if (window.depot.module === 'editor') { 47 | if (history.length > 1) { 48 | history.back(); 49 | } else { 50 | if (opener) opener.depot.refresh(); 51 | close(); 52 | } 53 | } else { 54 | dialog.cancel(); 55 | window.depot.refresh(); 56 | } 57 | }).catch(error => { 58 | if (error) ErrorDialog.popup({ error }); 59 | }).then(() => { 60 | this.backComponent.busy = false; 61 | }); 62 | } 63 | 64 | get styleSheet() { 65 | return ` 66 | :scope { 67 | border-top: 1px solid #e0e6ed; 68 | margin-top: var(--spacing); 69 | padding-top: var(--spacing); 70 | :first-child { margin-right: 10px; } 71 | } 72 | `; 73 | } 74 | 75 | }; 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /src/components/OutputQRCode.js: -------------------------------------------------------------------------------- 1 | define([ 'https://shadow.elemecdn.com/gh/davidshimjs/qrcodejs@04f46c6a/qrcode.min.js' ], () => { 2 | 3 | class QRCodePanel extends Jinkela { 4 | init() { 5 | let { QRCode } = window; 6 | void new QRCode(this.element, { 7 | text: this.value === void 0 ? this.defaultValue : this.value, 8 | width: 1000, 9 | height: 1000, 10 | colorDark: '#000000', 11 | colorLight: '#ffffff', 12 | correctLevel: QRCode.CorrectLevel.L 13 | }); 14 | } 15 | get styleSheet() { 16 | return ` 17 | :scope { 18 | background: #fff; 19 | position: relative; 20 | margin-top: -50px; 21 | z-index: 1; 22 | overflow: hidden; 23 | text-align: center; 24 | box-sizing: border-box; 25 | padding: 10px; 26 | height: calc(100% - 20px); 27 | img { 28 | margin: auto; 29 | height: inherit; 30 | } 31 | } 32 | `; 33 | } 34 | } 35 | 36 | return class extends Jinkela { 37 | get panel() { 38 | let value = new QRCodePanel(this); 39 | Object.defineProperty(this, 'panel', { configurable: true, value }); 40 | return value; 41 | } 42 | click() { 43 | dialog.popup(this.panel); 44 | } 45 | get template() { 46 | return ` 47 |
    48 | 49 |
    50 | `; 51 | } 52 | get styleSheet() { 53 | return ` 54 | :scope { 55 | width: 24px; 56 | height: 24px; 57 | > svg { 58 | fill: currentColor; 59 | width: 100%; 60 | height: 100%; 61 | display: inline-block; 62 | } 63 | cursor: pointer; 64 | } 65 | `; 66 | } 67 | }; 68 | 69 | }); 70 | -------------------------------------------------------------------------------- /demo/schemes/inputs/datetime.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "key": "Input::DateTime", 5 | "title": "组件 - 日期时间相关 - DateTime", 6 | "module": "editor", 7 | "inputs": [ 8 | { "key": "datetime1", "component": "DateTime", "title": "无参数" }, 9 | { "key": "datetime2", "component": "DateTime", "title": "数值", "args": { "defaultValue": 1499157139261 } }, 10 | { 11 | "key": "datetime3", "component": "DateTime", "title": "ISO 8601", 12 | "args": { "defaultValue": "2017-07-13T03:27:44.856Z" } 13 | }, 14 | { "key": "datetime4", "component": "DateTime", "title": "now", "args": { "defaultValue": "now" } }, 15 | { "key": "datetime4", "component": "DateTime", "title": "today", "args": { "defaultValue": "today" } }, 16 | { 17 | "key": "datetime5", "component": "DateTime", "title": "UNIX_TIMESTAMP", 18 | "args": { "mode": "UNIX_TIMESTAMP" } 19 | }, 20 | { 21 | "key": "datetime6", "component": "DateTime", "title": "UNIX_TIMESTAMP 数值", 22 | "args": { "defaultValue": 1499157139, "mode": "UNIX_TIMESTAMP" } 23 | }, 24 | { 25 | "key": "datetime7", "component": "DateTime", "title": "UNIX_TIMESTAMP now", 26 | "args": { "defaultValue": "now", "mode": "UNIX_TIMESTAMP" } 27 | } 28 | ] 29 | }, 30 | 31 | { 32 | "key": "Input::Date", 33 | "title": "组件 - 日期时间相关 - Date", 34 | "module": "editor", 35 | "inputs": [ 36 | { "key": "date1", "component": "Date", "title": "日期" }, 37 | { "key": "date1", "component": "Date", "title": "日期", "args": { "defaultValue": 1499157139261 } }, 38 | { "key": "date2", "component": "Date", "title": "日期", "args": { "defaultValue": "2017-07-13T03:27:44.856Z" } }, 39 | { "key": "date3", "component": "Date", "title": "日期", "args": { "defaultValue": "today" } } 40 | ] 41 | }, 42 | 43 | { 44 | "key": "Input::Time", 45 | "title": "组件 - 日期时间相关 - Time", 46 | "module": "editor", 47 | "inputs": [ 48 | { "key": "time5", "component": "Time", "title": "时间" }, 49 | { "key": "time1", "component": "Time", "title": "时间", "args": { "defaultValue": 1499157139261 } }, 50 | { "key": "time2", "component": "Time", "title": "时间", "args": { "defaultValue": "23:59:59" } }, 51 | { "key": "time3", "component": "Time", "title": "时间", "args": { "defaultValue": "23:59" } }, 52 | { "key": "time4", "component": "Time", "title": "时间", "args": { "defaultValue": "now" } } 53 | ] 54 | } 55 | 56 | ] 57 | -------------------------------------------------------------------------------- /src/components/OutputAutoRefresh.js: -------------------------------------------------------------------------------- 1 | def(() => { 2 | 3 | return class extends Jinkela { 4 | 5 | get template() { 6 | let icon = (k, i = 1) => `https://httpizza.ele.me/common/icon.svg?keyword=${k}&index=${i}&fill=%2320a0ff`; 7 | return ` 8 |
    9 | 10 | 每 {intervalText}s 自动刷新, 11 | 12 | 13 | 14 | 15 | 已暂停, 16 | 17 | 18 | 19 |
    20 | `; 21 | } 22 | 23 | updateAutoRefreshPaused(autoRefreshPaused) { 24 | let params = JSON.stringify(Object.assign({}, this.depot.params, { autoRefreshPaused })); 25 | this.depot.update({ params }); 26 | } 27 | 28 | get pause() { 29 | let value = () => this.updateAutoRefreshPaused(true); 30 | Object.defineProperty(this, 'pause', { value, configurable: true }); 31 | return value; 32 | } 33 | 34 | get play() { 35 | let value = () => this.updateAutoRefreshPaused(false); 36 | Object.defineProperty(this, 'pause', { value, configurable: true }); 37 | return value; 38 | } 39 | 40 | init() { 41 | this.interval = Number(this.interval) || 10000; 42 | this.intervalText = +(this.interval / 1000).toFixed(2); 43 | if (!this.depot.params.autoRefreshPaused) this.setTimer(); 44 | } 45 | 46 | get manualRefresh() { 47 | let value = () => this.depot.refresh(); 48 | Object.defineProperty(this, 'manualRefresh', { value, configurable: true }); 49 | return value; 50 | } 51 | 52 | setTimer() { 53 | clearTimeout(this.timer); 54 | this.timer = setTimeout(() => { 55 | // 只有当自己依然在 DOM 上时才刷新 56 | if (document.body.contains(this.element)) this.depot.refresh(); 57 | }, this.interval); 58 | } 59 | 60 | get styleSheet() { 61 | return ` 62 | :scope { 63 | * { 64 | display: inline-block; 65 | vertical-align: middle; 66 | } 67 | a { color: #20a0ff; } 68 | img { 69 | width: 16px; 70 | } 71 | } 72 | `; 73 | } 74 | 75 | }; 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /src/components/InputTextAround.js: -------------------------------------------------------------------------------- 1 | def((Input, Output) => { 2 | 3 | return class extends Jinkela { 4 | 5 | beforeParse(params) { 6 | let { component = 'String', args = {} } = params; 7 | this.component = component; 8 | this.args = args; 9 | } 10 | 11 | get input() { 12 | let { component, args, readonly } = this; 13 | args = Object.assign({ readonly }, args); 14 | let value = new Input({ component, args }); 15 | Object.defineProperty(this, 'input', { configurable: true, value }); 16 | return value; 17 | } 18 | 19 | get value() { return this.input.value; } 20 | set value(value) { this.input.value = value; } 21 | 22 | init() { 23 | let { before, after } = this; 24 | if (this.readonly) this.element.setAttribute('readonly', 'readonly'); 25 | if (before) { 26 | Output.createAny(before).to(this); 27 | this.element.classList.add('has-before'); 28 | } 29 | this.input.to(this); 30 | this.input.element.classList.add('input'); 31 | if (after) { 32 | Output.createAny(after).to(this); 33 | this.element.classList.add('has-after'); 34 | } 35 | } 36 | 37 | get styleSheet() { 38 | return ` 39 | :scope { 40 | display: flex; 41 | > :not(.input) { 42 | box-sizing: border-box; 43 | border: 1px solid #C0CCDA; 44 | border-radius: 5px; 45 | padding: 0 .5em; 46 | height: 28px; 47 | font-size: 12px; 48 | line-height: 28px; 49 | } 50 | &[readonly] > :not(.input) { 51 | background-color: #eff2f7; 52 | border-color: #d3dce6; 53 | color: #bbb; 54 | cursor: not-allowed; 55 | } 56 | &.has-before { 57 | > .input input { 58 | border-top-left-radius: 0px; 59 | border-bottom-left-radius: 0px; 60 | } 61 | > :first-child { 62 | border-right: 0; 63 | border-top-right-radius: 0px; 64 | border-bottom-right-radius: 0px; 65 | } 66 | } 67 | &.has-after { 68 | .input input { 69 | border-top-right-radius: 0px; 70 | border-bottom-right-radius: 0px; 71 | } 72 | > :last-child { 73 | border-left: 0; 74 | border-top-left-radius: 0px; 75 | border-bottom-left-radius: 0px; 76 | } 77 | } 78 | } 79 | `; 80 | } 81 | 82 | }; 83 | }); 84 | -------------------------------------------------------------------------------- /src/components/InputSelect.js: -------------------------------------------------------------------------------- 1 | def((Item) => { 2 | 3 | class InputSelectItem extends Item { 4 | get tagName() { return 'option'; } 5 | init() { 6 | this.element.jinkela = this; 7 | this.element.setAttribute('value', this.value); 8 | this.element.textContent = this.text; 9 | } 10 | } 11 | 12 | return class extends Jinkela { 13 | get tagName() { return 'select'; } 14 | init() { 15 | this.element.addEventListener('change', event => this.change(event)); 16 | this.initOptions(); 17 | this.value = this.$hasValue ? this.$value : void 0; 18 | } 19 | get readonly() { return this.element.hasAttribute('disabled'); } 20 | set readonly(value) { 21 | if (value) { 22 | this.element.setAttribute('disabled', 'dsabled'); 23 | } else { 24 | this.element.removeAttribute('disabled'); 25 | } 26 | } 27 | initOptions() { 28 | let { options } = this; 29 | while (this.element.firstChild) this.element.firstChild.remove(); 30 | if (options instanceof Array) { 31 | options = options.slice(0); 32 | } else { 33 | options = Object.keys(Object(options)).map(key => ({ text: options[key], value: key })); 34 | } 35 | if ('null' in this) options.unshift({ text: this.null, value: null }); 36 | InputSelectItem.cast(options).to(this); 37 | } 38 | change() { 39 | if (typeof this.onChange === 'function') this.onChange(event); 40 | if (typeof this.onchange === 'function') this.onchange(event); 41 | } 42 | get styleSheet() { 43 | return ` 44 | :scope { 45 | &:hover { border-color: #8492a6; } 46 | &:focus { border-color: #20a0ff; } 47 | &[disabled] { 48 | background-color: #eff2f7; 49 | border-color: #d3dce6; 50 | color: #bbb; 51 | cursor: not-allowed; 52 | } 53 | transition: border-color .2s cubic-bezier(.645,.045,.355,1); 54 | vertical-align: middle; 55 | border: 1px solid #C0CCDA; 56 | background-color: transparent; 57 | border-radius: 5px; 58 | padding: .5em; 59 | font-size: 12px; 60 | min-width: 120px; 61 | height: 28px; 62 | } 63 | `; 64 | } 65 | get value() { 66 | if (!this.keepValueType) return this.element.value; 67 | let [ option ] = this.element.selectedOptions; 68 | return option && option.jinkela && option.jinkela.value; 69 | } 70 | set value(value = this.defaultValue) { 71 | this.$hasValue = true; 72 | if (value !== void 0) this.$value = this.element.value = value; 73 | } 74 | }; 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /src/components/SubGroupMap.js: -------------------------------------------------------------------------------- 1 | def((FormItemWithTable, FormItemWithDiv) => class extends Jinkela { 2 | 3 | set value(data) { 4 | if (!data) return; 5 | this.inputs.forEach(item => { 6 | switch (item.squash) { 7 | case 'direct': 8 | item.value = Object.assign({ '': data[item.key] }, data); 9 | break; 10 | default: 11 | item.value = data[item.key]; 12 | } 13 | }); 14 | } 15 | 16 | get value() { 17 | return this.inputs.reduce((result, item) => { 18 | let { value } = item; 19 | switch (item.squash) { 20 | case 'direct': 21 | result[item.key] = value['']; 22 | Object.keys(value).filter(key => key).forEach(key => (result[key] = value[key])); 23 | break; 24 | default: 25 | result[item.key] = value; 26 | } 27 | return result; 28 | }, Object.create(null)); 29 | } 30 | 31 | init() { 32 | let { group, depot, mode = 'table' } = this; 33 | let { formMode } = depot; 34 | group = JSON.parse(JSON.stringify(group)); // group 消除引用 35 | group = group.filter(item => item[formMode] !== 'none'); // 过滤隐藏项 36 | group = group.filter(item => this.checkPermissions(item)); // 过滤权限 37 | group.forEach((item) => { // read 方式默认是只读的 38 | if (formMode === 'read' && item[formMode] === void 0) item[formMode] = 'readonly'; 39 | if (item[formMode] === 'readonly') { 40 | if (!item.args) item.args = {}; 41 | item.args.readonly = true; 42 | } 43 | if (item[formMode] === 'hidden') item.hidden = true; 44 | }); 45 | this.element.classList.add(mode); 46 | switch (mode) { 47 | case 'table': 48 | this.inputs = FormItemWithTable.cast(group, { depot }).to(this); 49 | break; 50 | case 'line': 51 | this.inputs = FormItemWithDiv.cast(group, { depot }).to(this); 52 | break; 53 | } 54 | } 55 | 56 | checkPermissions(item) { 57 | if (!item.require) return true; 58 | let requireList = [].concat(item.require); 59 | if (requireList.length === 0) return true; 60 | let { session } = this.depot; 61 | let { permissions } = session; 62 | return requireList.some(code => permissions.includes(code)); 63 | } 64 | 65 | get styleSheet() { 66 | return ` 67 | .table:scope { 68 | break-inside: avoid-column; 69 | border-spacing: var(--spacing); 70 | margin: calc(var(--spacing) * -1); 71 | font-size: inherit; 72 | > div > span:first-child { width: 0; } 73 | } 74 | .line:scope { 75 | display: flex; 76 | margin-left: -1em; 77 | > * { margin-left: 1em; } 78 | } 79 | `; 80 | } 81 | 82 | }); 83 | -------------------------------------------------------------------------------- /src/components/Button.js: -------------------------------------------------------------------------------- 1 | def((Item) => class extends Item { 2 | init() { 3 | if (this.children && this.children.length) this.text = this.children[0].data; 4 | } 5 | set text(value) { 6 | this.element.setAttribute('text', value || '一个神奇的按钮'); 7 | } 8 | get tag() { return 'button'; } 9 | get tagName() { return this.tag; } 10 | 11 | get disabled() { return this.element.hasAttribute('disabled'); } 12 | set disabled(value) { this.element[value ? 'setAttribute' : 'removeAttribute']('disabled', 'disabled'); } 13 | 14 | get small() { return this.element.classList.contains('small'); } 15 | set small(value) { this.element.classList[value ? 'add' : 'remove']('small'); } 16 | 17 | get busy() { return this.element.classList.contains('busy'); } 18 | set busy(value) { this.element.classList[value ? 'add' : 'remove']('busy'); } 19 | 20 | get styleSheet() { 21 | return ` 22 | :scope { 23 | border: 0; 24 | border-radius: 4px; 25 | padding: 7px 9px; 26 | font-size: 12px; 27 | font-family: inherit; 28 | border: 1px solid; 29 | background-color: #20a0ff; 30 | border-color: #20a0ff; 31 | line-height: 1; 32 | cursor: pointer; 33 | color: #fff; 34 | position: relative; 35 | text-align: center; 36 | &.small { padding: 4px 5px; } 37 | &:hover { opacity: .8; } 38 | &:before { 39 | content: attr(text); 40 | } 41 | &:after { 42 | position: absolute; 43 | left: .8em; 44 | right: .8em; 45 | top: .4em; 46 | bottom: .4em; 47 | } 48 | &.busy { 49 | cursor: wait; 50 | opacity: .5; 51 | } 52 | &.busy:before { 53 | visibility: hidden; 54 | } 55 | &.busy:after { 56 | content: ''; 57 | animation: button-busy 1000ms infinite; 58 | } 59 | &:focus { outline: none; } 60 | &[disabled] { 61 | color: #c0ccda; 62 | cursor: not-allowed; 63 | background-image: none; 64 | background-color: #eff2f7; 65 | border-color: #d3dce6; 66 | &:hover { opacity: 1; } 67 | } 68 | &.hollow { 69 | background-color: transparent; 70 | border-color: #c0ccda; 71 | color: #1f2d3d; 72 | &:not([disabled]):hover { 73 | color: #20a0ff; 74 | border-color: #20a0ff; 75 | } 76 | } 77 | } 78 | @keyframes button-busy { 79 | 0% { content: '·'; } 80 | 25% { content: '··'; } 81 | 50% { content: '···'; } 82 | 75% { content: '····'; } 83 | 100% { content: '·'; } 84 | } 85 | `; 86 | } 87 | }); 88 | -------------------------------------------------------------------------------- /demo/schemes/inputs/other.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "key": "Input::Suggestion", 5 | "title": "组件 - 其它 - Suggestion", 6 | "module": "editor", 7 | "inputs": [ 8 | { 9 | "description": "必选", 10 | "key": "suggestion1", "component": "Suggestion", "title": "带描述", 11 | "args": { "api": "value-list" } 12 | }, 13 | { 14 | "key": "suggestion2", "component": "Suggestion", "title": "Placeholder", 15 | "args": { "api": "value-list", "placeholder": "placeholder" } 16 | }, 17 | { 18 | "key": "suggestion3", "component": "Suggestion", "title": "设置宽度", 19 | "args": { "api": "value-list", "width": 100 } 20 | }, 21 | { 22 | "key": "suggestion4", "component": "Suggestion", "title": "有默认值", 23 | "args": { "api": "value-list", "defaultValue": "hehe" } 24 | }, 25 | { 26 | "key": "suggestion5", "component": "Suggestion", "title": "正常", 27 | "args": { "api": "empty-list" } 28 | }, 29 | { 30 | "key": "suggestion6", "component": "Suggestion", "title": "空提示", 31 | "args": { "api": "empty-list", "emptyTip": "并没有内容" } 32 | }, 33 | { 34 | "key": "suggestion7", "component": "Suggestion", "title": "", 35 | "args": { "api": "empty-list", "width": 100, "emptyTip": "并没有内容" } 36 | } 37 | ] 38 | }, 39 | 40 | { 41 | "key": "Input::TagCollector", 42 | "title": "组件 - 其它 - TagCollector", 43 | "module": "editor", 44 | "inputs": [ 45 | { 46 | "key": "tagCollector1", "component": "TagCollector", "title": "正常", 47 | "args": { "api": "value-list" } 48 | }, 49 | { 50 | "key": "tagCollector2", "component": "TagCollector", "title": "设置宽度", 51 | "args": { "api": "value-list", "width": 500 } 52 | }, 53 | { 54 | "key": "tagCollector3", "component": "TagCollector", "title": "带默认值", 55 | "args": { "api": "value-list", "defaultValue": [ "item 1", "hehe" ] } 56 | }, 57 | { 58 | "key": "tagCollector4", "component": "TagCollector", "title": "空提示", 59 | "args": { "api": "empty-list", "emptyTip": "并没有内容" } 60 | }, 61 | { 62 | "key": "tagCollector5", "component": "TagCollector", "title": "Placeholder", 63 | "args": { "api": "empty-list", "placeholder": "placeholder" } 64 | }, 65 | { 66 | "key": "tagCollector6", "component": "TagCollector", "title": "api 带参数", 67 | "args": { "api": "empty-list?hehe=123" } 68 | } 69 | ] 70 | }, 71 | 72 | { 73 | "key": "Input::Custom", 74 | "title": "组件 - 其它 - Custom", 75 | "module": "editor", 76 | "inputs": [ 77 | { 78 | "key": "custom1", "component": "https://fuss10.elemecdn.com/c/d6/9a844cf9941910a965c88b5af4f70js.js", 79 | "title": "外部组件", "args": { "defaultValue": "这是一个外部组件,呵呵" } 80 | } 81 | ] 82 | } 83 | 84 | ] 85 | -------------------------------------------------------------------------------- /demo/schemes/config/filter.yaml: -------------------------------------------------------------------------------- 1 | - key: "the-filter?_=1" 2 | title: 配置 - 筛选器 - 单个筛选器 3 | filters: 4 | - title: 名称 5 | key: f1 6 | fields: 7 | - key: id 8 | title: ID 9 | sortable: true 10 | - key: title 11 | title: 名称 12 | - key: description 13 | title: 描述 14 | 15 | - key: "the-filter?_=where" 16 | title: 配置 - 筛选器 - 默认条件 17 | where: 18 | f1: 烧 19 | filters: 20 | - title: 名称 21 | key: f1 22 | fields: 23 | - key: id 24 | title: ID 25 | sortable: true 26 | - key: title 27 | title: 名称 28 | - key: description 29 | title: 描述 30 | 31 | 32 | - key: "the-filter?_=2" 33 | title: "配置 - 筛选器 - 两个筛选器" 34 | filters: 35 | - title: 名称 36 | key: f1 37 | - title: 描述 38 | key: f2 39 | fields: 40 | - key: id 41 | title: ID 42 | sortable: true 43 | - key: title 44 | title: 名称 45 | - key: description 46 | title: 描述 47 | 48 | - key: "the-filter?_=folded" 49 | params: 50 | filterState: folded 51 | title: "配置 - 筛选器 - 默认收起" 52 | filters: 53 | - title: 名称 54 | key: f1 55 | - title: 描述 56 | key: f2 57 | fields: 58 | - key: id 59 | title: ID 60 | sortable: true 61 | - key: title 62 | title: 名称 63 | - key: description 64 | title: 描述 65 | 66 | 67 | - key: "the-filter?_=style" 68 | title: "配置 - 筛选器 - 浮动样式" 69 | filterStyle: floating 70 | filters: 71 | - title: 名称 72 | key: f1 73 | args: { width: 80 } 74 | - title: 描述 75 | key: f2 76 | args: { width: 100 } 77 | - title: 没卵用的字段 78 | key: x1 79 | component: DateTime 80 | - title: 没卵用的字段 81 | key: x2 82 | component: Select 83 | args: { '@options': the-options } 84 | - title: 没卵用的字段 85 | key: x3 86 | component: Radio 87 | args: { '@options': the-options } 88 | - title: 没卵用的字段 89 | key: x4 90 | component: Checkbox 91 | args: { '@options': the-options } 92 | fields: 93 | - key: id 94 | title: ID 95 | sortable: true 96 | - key: title 97 | title: 名称 98 | - key: description 99 | title: 描述 100 | 101 | - key: "the-list-data-2?3" 102 | noWhere: "请先选择筛选条件" 103 | title: "配置 - 筛选器 - 默认不搜索" 104 | filters: 105 | - title: "条件" 106 | key: "f1" 107 | component: "String" 108 | fields: 109 | - key: "id" 110 | title: "ID" 111 | sortable: true 112 | - key: "a" 113 | title: "a" 114 | component: "TextTip" 115 | 116 | - key: "the-filter?_=4" 117 | title: "配置 - 筛选器 - beforeApply" 118 | beforeApply: 119 | action: "get" 120 | args: 121 | key: "before" 122 | filters: 123 | - title: 名称 124 | key: f1 125 | fields: 126 | - key: id 127 | title: ID 128 | sortable: true 129 | - key: title 130 | title: 名称 131 | - key: description 132 | title: 描述 133 | -------------------------------------------------------------------------------- /src/components/Confirm.js: -------------------------------------------------------------------------------- 1 | def((Output, Button, ButtonHollow) => class extends Jinkela { 2 | static popup(config, depot) { 3 | if (typeof config === 'string') config = { text: config }; 4 | let ins = new this(config, { depot }); 5 | if (config.autoCancel !== false) ins.then(dialog.cancel, dialog.cancel); 6 | dialog.once('dialogcancel', () => ins.resolve(false)); 7 | dialog.once('transitionend', () => ins.focus()); 8 | dialog.popup(ins); 9 | return Promise.resolve(ins); 10 | } 11 | init() { 12 | this.title = this.title || '操作确认'; 13 | this.text = this.text || '确定要执行此操作吗?'; 14 | Output.createAny(this.text).to(this.h5); 15 | if (this.onYes.action) this.onYes = doAction.bind(null, this.onYes, this.depot); 16 | if (this.onCancel.action) this.onCancel = doAction.bind(null, this.onCancel, this.depot); 17 | let onYes = () => { 18 | let promise = Promise.resolve().then(this.onYes); 19 | this.resolve(promise); 20 | return promise; 21 | }; 22 | let onCancel = () => { 23 | let promise = Promise.resolve().then(this.onCancel); 24 | this.resolve(promise); 25 | return promise; 26 | }; 27 | if (!this.yes) this.yes = '是的'; 28 | this.yes = typeof this.yes === 'string' ? { text: this.yes } : this.yes; 29 | this.theYesButton = this.yesButton = new Button(this.yes, { onClick: onYes }); 30 | 31 | if (!this.cancel) this.cancel = { text: '取消', color: '#D3DCE6' }; 32 | this.cancel = typeof this.cancel === 'string' ? { text: this.cancel } : this.cancel; 33 | this.theCancelButton = this.cancelButton = new ButtonHollow(this.cancel, { onClick: onCancel }); 34 | 35 | this.element.addEventListener('keydown', event => this.keydown(event)); 36 | } 37 | keydown(event) { 38 | if (event.keyCode === 27) this.theCancelButton.click(); 39 | } 40 | focus() { 41 | if (this.theYesButton && this.theYesButton.element && this.theYesButton.element.focus) this.theYesButton.element.focus(); 42 | } 43 | get handlers() { 44 | let value = new Set(); 45 | Object.defineProperty(this, 'handlers', { configurable: true, value }); 46 | return value; 47 | } 48 | then(...handler) { this.handlers.add(handler); } 49 | resolve(arg) { 50 | let $result = Promise.resolve(arg); 51 | this.handlers.forEach(handler => { 52 | $result.then(...handler); 53 | this.handlers.delete(handler); 54 | }); 55 | } 56 | onYes() { return true; } 57 | onCancel() { return false; } 58 | get template() { 59 | return ` 60 |
    61 |
    62 |
    63 | 64 | 65 |
    66 |
    67 | `; 68 | } 69 | get styleSheet() { 70 | return ` 71 | :scope { 72 | padding: 2em; 73 | > h5 { 74 | margin: 0 0 2em 0; 75 | font-size: 16px; 76 | font-weight: normal; 77 | } 78 | > div > button { 79 | margin: 0 1em; 80 | } 81 | } 82 | `; 83 | } 84 | }); 85 | -------------------------------------------------------------------------------- /src/components/InputDateTime.js: -------------------------------------------------------------------------------- 1 | def((DatePicker, TimePicker) => { 2 | 3 | const format = value => { 4 | let now = new Date(); 5 | switch (value) { 6 | case 'now': return now.valueOf(); 7 | case 'today': 8 | break; 9 | case 'nextDay': 10 | now.setDate(now.getDate() + 1); 11 | break; 12 | case 'lastDay': 13 | now.setDate(now.getDate() - 1); 14 | break; 15 | case 'nextWeek': 16 | now.setDate(now.getDate() + 7); 17 | break; 18 | case 'lastWeek': 19 | now.setDate(now.getDate() - 7); 20 | break; 21 | case 'nextMonth': 22 | now.setMonth(now.getMonth() + 1); 23 | break; 24 | case 'lastMonth': 25 | now.setMonth(now.getMonth() - 1); 26 | break; 27 | case 'nextYear': 28 | now.setYear(now.getFullYear() + 1); 29 | break; 30 | case 'lastYear': 31 | now.setYear(now.getFullYear() - 1); 32 | break; 33 | default: return value || ''; 34 | } 35 | return now.setHours(0, 0, 0, 0); 36 | }; 37 | 38 | class DateTimePicker extends Jinkela { 39 | init() { 40 | this.dp = new DatePicker().to(this); 41 | this.tp = new TimePicker().to(this); 42 | if (!this.$hasValue) this.value = void 0; 43 | if (this.readonly) { 44 | this.element.classList.add('readonly'); 45 | this.element.addEventListener('focus', event => { 46 | event.preventDefault(); 47 | event.stopPropagation(); 48 | }, true); 49 | } 50 | } 51 | get styleSheet() { 52 | return ` 53 | :scope { 54 | position: relative; 55 | > span { 56 | > input { 57 | height: 28px; 58 | } 59 | &:first-child { 60 | margin-right: 14px; 61 | } 62 | } 63 | &.readonly { 64 | input { 65 | background-color: #eff2f7; 66 | border-color: #d3dce6; 67 | color: #bbb; 68 | cursor: not-allowed; 69 | } 70 | } 71 | } 72 | `; 73 | } 74 | get value() { 75 | let [ hours, minutes, seconds ] = this.tp.value.match(/\d+/g); 76 | let date = this.dp.value; 77 | if (hours) date.setHours(hours); 78 | if (minutes) date.setMinutes(minutes); 79 | if (seconds) date.setSeconds(seconds); 80 | if (this.mode === 'UNIX_TIMESTAMP') { 81 | return Math.round(date / 1000); 82 | } else { 83 | return date; 84 | } 85 | } 86 | set value(value = this.defaultValue) { 87 | this.$hasValue = true; 88 | if (this.mode === 'UNIX_TIMESTAMP' && typeof value === 'number') value *= 1000; 89 | value = format(value); 90 | if (typeof value === 'string' || typeof value === 'number') value = new Date(value); 91 | if (!(value instanceof Date)) return; 92 | this.dp.value = value; 93 | this.tp.value = [ value.getHours(), value.getMinutes(), value.getSeconds() ].join(':'); 94 | } 95 | } 96 | 97 | return function(...args) { 98 | return new DateTimePicker(...args); 99 | }; 100 | 101 | }); 102 | -------------------------------------------------------------------------------- /src/duang.js: -------------------------------------------------------------------------------- 1 | // 设置 base 标签(TODO:太恶心了,副作用太大,应该想办法不修改 base) 2 | { 3 | let [ , path ] = document.currentScript.src.match(/^(.*\/)duang\.js$/); 4 | let base = document.createElement('base'); 5 | base.setAttribute('href', path); 6 | document.head.appendChild(base); 7 | } 8 | 9 | // loading 效果(TODO:太丑,应该重新设计一下) 10 | { 11 | let element = document.createElement('div'); 12 | element.style.position = 'absolute'; 13 | element.style.top = element.style.left = '50%'; 14 | element.style.transform = 'translate(-50%, -50%)'; 15 | let ready = document.body ? Promise.resolve() : new Promise(resolve => addEventListener('DOMContentLoaded', resolve)); 16 | ready.then(() => document.body.appendChild(element)); 17 | let state = 'LOADING'; 18 | addEventListener('duang::fatal', () => { 19 | if (state !== 'LOADING') return; 20 | element.innerHTML = event.detail; 21 | element.style.color = 'red'; 22 | state = 'FATAL'; 23 | }); 24 | addEventListener('duang::notify', event => { 25 | if (state !== 'LOADING') return; 26 | element.innerHTML = event.detail; 27 | }); 28 | addEventListener('duang::done', () => { 29 | if (state !== 'LOADING') return; 30 | state = 'DONE'; 31 | element.remove(); 32 | }); 33 | } 34 | 35 | // 加载资源(考虑依赖关系) 36 | { 37 | let w = (...args) => { 38 | let src = String.raw(...args); 39 | return new Promise((resolve, reject) => { 40 | let loader; 41 | loader = document.createElement('script'); 42 | loader.setAttribute('src', src); 43 | loader.addEventListener('load', resolve); 44 | loader.addEventListener('error', reject); 45 | document.head.appendChild(loader); 46 | }).then(() => { 47 | let detail = '正在加载依赖 ···'; 48 | dispatchEvent(new CustomEvent('duang::notify', { detail })); 49 | }); 50 | }; 51 | 52 | // 加载 CSS(TODO:收到具体控件中懒加载) 53 | let link = document.createElement('link'); 54 | link.setAttribute('rel', 'stylesheet'); 55 | link.setAttribute('href', `https://shadow.elemecdn.com/bundle/${[ 56 | 'gh/codemirror/CodeMirror@5.19.0/lib/codemirror.css', 57 | 'gh/codemirror/CodeMirror@5.19.0/theme/neo.css', 58 | 'gh/sindresorhus/github-markdown-css@gh-pages/github-markdown.css' 59 | ].join(',')}`); 60 | document.head.appendChild(link); 61 | 62 | // 加载 JS 63 | Promise.all([ 64 | w`https://shadow.elemecdn.com/bundle/${[ 65 | 'npm/excavator@0.2.1/bundle.min.js', 66 | 'npm/jinkela@1.3.5/umd.min.js', 67 | 'npm/stale-while-revalidate@0.1.0/bundle.min.js', 68 | 'npm/fast-resolve@0.2.0/umd.min.js', 69 | 'npm/jinkela-dialog@0.1.6/dialog.min.js', 70 | 'npm/UParams@1.4.0/UParams.min.js', 71 | 'gh/s3u/JSONPath@v0.15.0/lib/jsonpath.min.js', 72 | 'gh/YanagiEiichi/requirejs@caae34b/require.min.js', 73 | 'placeholder/bundle.js' 74 | ].join(',')}`, 75 | 76 | w`utils/api.js`, 77 | w`utils/doAction.js`, 78 | w`utils/refactor.js`, 79 | w`utils/debounce.js`, 80 | w`utils/condition.js`, 81 | w`utils/amdx.js`, 82 | w`utils/depot.js` 83 | ]).then(() => { 84 | duang(); 85 | dispatchEvent(new CustomEvent('duang::notify', { detail: '依赖加载完毕' })); 86 | }, error => { 87 | let { src } = error.target; 88 | dispatchEvent(new CustomEvent('duang::fatal', { detail: `依赖(${src})加载失败` })); 89 | setTimeout(() => { throw error; }); 90 | }); 91 | 92 | } 93 | -------------------------------------------------------------------------------- /src/components/XPut.js: -------------------------------------------------------------------------------- 1 | def((Item) => { 2 | 3 | const swrApi = setStaleWhileRevalidate(api, 60); 4 | 5 | const parse = (base, depot = window.depot, query) => Promise.all(Object.keys(base).map(key => { 6 | let item = base[key]; 7 | if (key[0] === '@') { 8 | delete base[key]; 9 | let options = { expires: 1000 }; 10 | if (query) options.query = { where: depot.where }; 11 | let path = []; 12 | if (depot.scheme) path.push(depot.resolvedKey); 13 | path = path.concat(item); 14 | return swrApi(path, options).then(value => { 15 | if (value && typeof value === 'object') return parse(value, depot, query).then(() => value); 16 | return value; 17 | }, error => { 18 | console.error(error); 19 | throw new Error(`组件参数(${key}: ${item})拉取失败`); 20 | }).then(value => { 21 | base[key.slice(1)] = value; 22 | }); 23 | } else { 24 | if (item && typeof item === 'object') return parse(item, depot, query); 25 | } 26 | })); 27 | 28 | return class extends Item { 29 | 30 | beforeParse(params) { 31 | Object.defineProperty(this, '$value', { configurable: true, writable: true }); 32 | Object.defineProperty(this, '$hasValue', { configurable: true, writable: true, value: false }); 33 | Object.defineProperty(this, '$resolveAt', { configurable: true, writable: true, value: false }); 34 | let { depot, query } = params; 35 | this.depot = depot; 36 | let tmpl = JSON.parse(JSON.stringify(Object.assign({}, params, { depot: void 0 }))); 37 | this.$resolveAt = parse(tmpl, depot, query).then(() => { 38 | Object.assign(this, refactor(tmpl, params)); 39 | }); 40 | } 41 | 42 | get tagName() { return 'span'; } 43 | 44 | get componentName() { 45 | let componentName = this.component || this.defaultComponent; 46 | if (/\W/.test(componentName)) { 47 | return componentName; 48 | } else { 49 | let [ , hint = this.hint, name ] = String(componentName).match(/^(Input|Output)?(.*)$/); 50 | return hint + name; 51 | } 52 | } 53 | 54 | init() { 55 | return Promise.resolve(this.$resolveAt).then(() => this.buildComponent()).then(() => { 56 | this.$promise.resolve(); 57 | }, error => { 58 | setTimeout(() => { throw error; }); 59 | this.element.textContent = error.message; 60 | this.$promise.resolve(); 61 | }); 62 | } 63 | 64 | get $promise() { 65 | let resolve, reject; 66 | let value = new Promise((...args) => ([ resolve, reject ] = args)); 67 | void reject; 68 | value.then(() => { 69 | if (this.$hasValue) { 70 | this.value = this.$value; 71 | delete this.$value; 72 | delete this.$hasValue; 73 | } 74 | if (typeof this.onReady === 'function') this.onReady(); 75 | }); 76 | value.resolve = resolve; 77 | value.reject = reject; 78 | Object.defineProperty(this, '$promise', { value }); 79 | return value; 80 | } 81 | 82 | get value() { return this.$hasValue ? this.$value : this.result && this.result.value; } 83 | set value(value) { 84 | if (!this.result) { 85 | this.$hasValue = true; 86 | return (this.$value = value); 87 | } 88 | this.result.value = value; 89 | } 90 | 91 | }; 92 | 93 | }); 94 | 95 | -------------------------------------------------------------------------------- /src/components/Tip.js: -------------------------------------------------------------------------------- 1 | def((Output) => { 2 | 3 | class Panel extends Jinkela { 4 | popup(content, x, y) { 5 | this.element.innerHTML = content; 6 | this.element.style.top = y + 'px'; 7 | this.element.style.left = x + 'px'; 8 | if (content) this.to(document.body); 9 | } 10 | get styleSheet() { 11 | return ` 12 | :scope { 13 | position: absolute; 14 | color: #fff; 15 | background: #000; 16 | border-radius: 5px; 17 | padding: .5em .5em; 18 | margin-top: -6px; 19 | transform: translate(-50%, -100%); 20 | &::before { 21 | content: ''; 22 | position: absolute; 23 | width: 0; 24 | height: 0; 25 | margin: auto; 26 | left: 0; 27 | right: 0; 28 | bottom: -10px; 29 | border: 5px solid transparent; 30 | border-top: 5px solid #000; 31 | } 32 | } 33 | `; 34 | } 35 | } 36 | 37 | return class extends Jinkela { 38 | init() { 39 | this.element.addEventListener('mouseenter', this.enter); 40 | this.element.addEventListener('mouseleave', this.leave); 41 | } 42 | get value() { return this.$value; } 43 | set value(value = this.defaultValue) { 44 | if (value === null || value === void 0) value = {}; 45 | let { data, tip } = typeof value === 'object' ? value : { data: value }; 46 | this.tip = tip; 47 | this.element.dataset['hastip'] = !!tip; 48 | while (this.element.firstChild) this.element.firstChild.remove(); 49 | if (data === void 0) data = ''; 50 | Output.createAny(data).to(this.element); 51 | } 52 | get panel() { 53 | let value = new Panel(); 54 | Object.defineProperty(this, 'panel', { configurable: true, value }); 55 | return value; 56 | } 57 | get enter() { 58 | let value = () => { 59 | document.addEventListener('mousemove', this.move); 60 | document.body.addEventListener('scroll', this.updatePanelPosition, true); 61 | }; 62 | Object.defineProperty(this, 'enter', { configurable: true, value }); 63 | return value; 64 | } 65 | get updatePanelPosition() { 66 | let value = () => { 67 | let rect = this.element.getBoundingClientRect(); 68 | this.panel.popup(this.tip, rect.left + rect.width / 2, rect.top); 69 | }; 70 | Object.defineProperty(this, 'updatePanelPosition', { configurable: true, value }); 71 | return value; 72 | } 73 | get move() { 74 | let value = (event) => { 75 | if (event.target === this.element || this.element.contains(event.target)) { 76 | this.updatePanelPosition(); 77 | } else { 78 | this.leave(event); 79 | } 80 | }; 81 | Object.defineProperty(this, 'move', { configurable: true, value }); 82 | return value; 83 | } 84 | get leave() { 85 | let value = () => { 86 | document.removeEventListener('mousemove', this.move); 87 | this.panel.element.remove(); 88 | document.body.removeEventListener('scroll', this.updatePanelPosition, true); 89 | }; 90 | Object.defineProperty(this, 'leave', { configurable: true, value }); 91 | return value; 92 | } 93 | get styleSheet() { 94 | return ` 95 | :scope { 96 | display: inline-block; 97 | cursor: default; 98 | } 99 | `; 100 | } 101 | }; 102 | 103 | }); 104 | -------------------------------------------------------------------------------- /src/components/FrameAside.js: -------------------------------------------------------------------------------- 1 | def((FrameAsideMenu) => { 2 | 3 | class Toggle extends Jinkela { 4 | toggle() { 5 | document.body.classList.toggle('folded'); 6 | } 7 | get template() { 8 | return ` 9 |

    10 | `; 11 | } 12 | get styleSheet() { 13 | return ` 14 | :scope { 15 | cursor: pointer; 16 | &::after, &::before { 17 | content: ''; 18 | display: inline-block; 19 | height: 10px; 20 | width: 4px; 21 | transition: opacity 200ms ease; 22 | opacity: .6; 23 | } 24 | &::before { 25 | border-left: 1px solid #fff; 26 | border-right: 1px solid #fff; 27 | } 28 | &::after { 29 | border-right: 1px solid #fff; 30 | } 31 | &:hover::after, &:hover::before { 32 | opacity: 1; 33 | } 34 | text-align: center; 35 | color: #ccc; 36 | background: #1f2d3d; 37 | padding: 12px; 38 | margin: 0; 39 | } 40 | `; 41 | } 42 | } 43 | 44 | class Container extends Jinkela { 45 | get Menu() { return FrameAsideMenu; } 46 | get template() { 47 | return ` 48 |
    49 | 50 |
    51 | `; 52 | } 53 | get styleSheet() { 54 | return ` 55 | :scope { 56 | flex: 1; 57 | overflow: auto; 58 | margin-top: 10px; 59 | line-height: 44px; 60 | } 61 | `; 62 | } 63 | hashchange() { 64 | this.menu.update(depot.scheme && depot.scheme.title); 65 | } 66 | init() { 67 | addEventListener('hashchange', () => this.hashchange()); 68 | let { session, schemes } = depot; 69 | let { permissions = [] } = session; 70 | schemes.forEach(scheme => { 71 | if (/(?:^|\/):/.test(scheme.key)) return; 72 | if (scheme.hidden) return; 73 | if (!scheme.title) return; 74 | if (scheme.require && !scheme.require.some((dep => ~permissions.indexOf(dep)))) return; 75 | this.menu.add(scheme); 76 | }); 77 | this.menu.update(depot.scheme && depot.scheme.title); 78 | } 79 | } 80 | 81 | class Aside extends Jinkela { 82 | beforeParse() { 83 | this.noToggle = !depot.config.noToggle; 84 | } 85 | get Toggle() { return Toggle; } 86 | get Container() { return Container; } 87 | click(event) { 88 | event.xIsFromAside = true; 89 | } 90 | get template() { 91 | return ` 92 |
    93 | 94 | 95 |
    96 | `; 97 | } 98 | get styleSheet() { 99 | return ` 100 | .folded :scope { width: 50px; } 101 | :scope { 102 | background: #324057; 103 | color: #fff; 104 | width: 230px; 105 | transition: width 200ms ease; 106 | flex: 1; 107 | display: flex; 108 | flex-direction: column; 109 | } 110 | @media (max-width: 600px) { 111 | .show-as-slide :scope { margin-left: 0; } 112 | :scope { 113 | transition: margin-left 200ms ease; 114 | margin-left: -250px; 115 | padding-top: 50px; 116 | height: 100%; 117 | box-shadow: 0 0 12px rgba(0,0,0,0.7); 118 | } 119 | } 120 | `; 121 | } 122 | } 123 | 124 | return Aside; 125 | 126 | }); 127 | -------------------------------------------------------------------------------- /src/components/ErrorDisplay.js: -------------------------------------------------------------------------------- 1 | def(() => { 2 | 3 | const UNIQ = 'error_display_' + Array.from({ length: 16 }, () => (36 * Math.random() | 0).toString(36)).join(''); 4 | 5 | class NormalMessage extends Jinkela { 6 | init() { 7 | this.content.innerHTML = this.message; 8 | } 9 | get template() { 10 | return ` 11 |
    12 |
    13 |
    14 |
    15 | `; 16 | } 17 | get styleSheet() { 18 | return ` 19 | @keyframes ${UNIQ}_a { 20 | from { opacity: 0; transform: scaleY(0); } 21 | to { opacity: 1; transform: scaleY(1); } 22 | } 23 | @keyframes ${UNIQ}_b { 24 | from { opacity: 0; width: 37px; } 25 | 50% { width: 57px; } 26 | to { opacity: 1; width: 47px; } 27 | } 28 | :scope { 29 | position: relative; 30 | display: flex; 31 | align-items: center; 32 | min-height: 80px; 33 | > .icon { 34 | animation: ${UNIQ}_a 300ms ease forwards; 35 | border: 4px solid #f27474; 36 | width: 80px; 37 | height: 80px; 38 | border-radius: 100%; 39 | position: relative; 40 | margin: 2em; 41 | &::before, &::after { 42 | content: ''; 43 | animation: ${UNIQ}_b 300ms ease forwards; 44 | animation-delay: 200ms; 45 | position: absolute; 46 | opacity: 0; 47 | height: 5px; 48 | width: 37px; 49 | margin: auto; 50 | top: 0; 51 | bottom: 0; 52 | left: 0; 53 | right: 0; 54 | background-color: #f27474; 55 | border-radius: 2px; 56 | transform-origin: center; 57 | } 58 | &::before { transform: rotate(-45deg); } 59 | &::after { transform: rotate(45deg); } 60 | } 61 | > .content { 62 | text-align: left; 63 | font-size: 16px; 64 | white-space: pre; 65 | margin: 0 2em; 66 | border-radius: 4px; 67 | flex: 1; 68 | } 69 | } 70 | `; 71 | } 72 | } 73 | 74 | class FrameMessage extends Jinkela { 75 | get tagName() { return 'iframe'; } 76 | init() { 77 | let url = URL.createObjectURL(new Blob([ this.message ], { type: 'text/html' })); 78 | this.element.frameBorder = '0'; 79 | this.element.src = url; 80 | this.element.style.display = 'none'; 81 | this.element.addEventListener('load', () => { 82 | this.element.style.display = 'block'; 83 | this.element.style.height = this.element.contentDocument.documentElement.scrollHeight + 'px'; 84 | }); 85 | } 86 | get styleSheet() { 87 | return ` 88 | :scope { 89 | width: 100%; 90 | display: none; 91 | } 92 | `; 93 | } 94 | } 95 | 96 | return class extends Jinkela { 97 | init() { 98 | let { error, noIcon } = this; 99 | if (typeof error === 'object') { 100 | console.error(error); 101 | let message = error.message || error.name || JSON.stringify(error); 102 | new NormalMessage({ message, noIcon }).to(this); 103 | } else if (typeof error === 'string') { 104 | new FrameMessage({ message: error }).to(this); 105 | } 106 | } 107 | get styleSheet() { 108 | return ` 109 | :scope { 110 | width: 100%; 111 | } 112 | `; 113 | } 114 | }; 115 | 116 | }); 117 | -------------------------------------------------------------------------------- /src/components/InputImageSelector.js: -------------------------------------------------------------------------------- 1 | def((Item, Value) => { 2 | 3 | const CORNER = 'data:image/svg+xml;base64,' + btoa(` 4 | 5 | 6 | 7 | 8 | `); 9 | 10 | class TheImage extends Jinkela { 11 | init() { 12 | this.img = new Image(); 13 | this.img.src = this.src; 14 | this.element.appendChild(this.img); 15 | this.element.addEventListener('click', event => this.click(event)); 16 | } 17 | click() { 18 | if (this.readonly) return; 19 | this.element.dispatchEvent(new CustomEvent('select', { bubbles: true, detail: this })); 20 | } 21 | set readonly(readonly) { 22 | this.element.classList[readonly ? 'add' : 'remove']('readonly'); 23 | } 24 | get readonly() { 25 | return this.element.classList.contains('readonly'); 26 | } 27 | set checked(checked) { 28 | this.element.classList[checked ? 'add' : 'remove']('checked'); 29 | } 30 | get checked() { 31 | return this.element.classList.contains('checked'); 32 | } 33 | get styleSheet() { 34 | return ` 35 | :scope { 36 | border: 1px solid #bfcbd9; 37 | padding: 1px; 38 | display: inline-block; 39 | margin: .25em .5em .25em 0; 40 | position: relative; 41 | > img { 42 | max-height: 32px; 43 | } 44 | &.readonly { 45 | opacity: .5; 46 | } 47 | &:not(.readonly) { 48 | cursor: pointer; 49 | &:hover { 50 | padding: 0; 51 | border: 2px solid #20a0ff; 52 | } 53 | } 54 | &.checked { 55 | opacity: 1; 56 | padding: 0; 57 | border: 2px solid #20a0ff; 58 | &::after { 59 | content: ''; 60 | background: url('${CORNER}'); 61 | width: 16px; 62 | height: 16px; 63 | position: absolute; 64 | right: 0; 65 | bottom: 0; 66 | } 67 | } 68 | } 69 | `; 70 | } 71 | } 72 | 73 | return class extends Value { 74 | get styleSheet() { 75 | return ` 76 | :scope { 77 | display: inline-block; 78 | vertical-align: middle; 79 | > * { 80 | vertical-align: top; 81 | } 82 | } 83 | `; 84 | } 85 | select(event) { 86 | event.stopPropagation(); 87 | let { detail } = event; 88 | this.list.forEach(item => (item.checked = false)); 89 | detail.checked = true; 90 | } 91 | init() { 92 | this.element.addEventListener('select', event => this.select(event)); 93 | let { options, readonly } = this; 94 | let list = []; 95 | options.forEach && options.forEach(item => { 96 | if (typeof item === 'string') { 97 | list.push({ src: item, value: item }); 98 | } else if (item && typeof item === 'object') { 99 | list.push(item); 100 | } 101 | }); 102 | list = list.map(raw => Object.assign({}, raw, { readonly })); 103 | this.list = TheImage.from(list).to(this); 104 | if (!this.$hasValue) this.value = void 0; 105 | } 106 | set value(value = this.defaultValue) { 107 | this.$hasValue = true; 108 | if (this.list.length === 0) return; 109 | if (!this.list.some(item => (item.checked = item.value === value))) this.list[0].checked = true; 110 | } 111 | get value() { 112 | let found = this.list.find(item => item.checked); 113 | return found && found.value; 114 | } 115 | }; 116 | 117 | }); 118 | -------------------------------------------------------------------------------- /demo/schemes/config/list.yaml: -------------------------------------------------------------------------------- 1 | - title: "配置 - 列表 - 最简单的列表" 2 | key: "normal-list" 3 | fields: 4 | - key: "id" 5 | title: "ID" 6 | - key: "type" 7 | title: "类型" 8 | - key: "title" 9 | title: "名称" 10 | - key: "description" 11 | title: "描述" 12 | 13 | - title: "配置 - 列表 - 列表中使用组件" 14 | key: "components-list" 15 | fields: 16 | - key: "id" 17 | title: "ID" 18 | - key: "title" 19 | title: "加特技" 20 | - key: "value" 21 | title: "排序" 22 | sortable: true 23 | - key: "description" 24 | title: "悬停提示控件" 25 | component: "TextTip" 26 | args: 27 | tip: "无提示文字" 28 | text: "悬停显示描述" 29 | - key: "img" 30 | title: "图片控件" 31 | component: "Image" 32 | 33 | - title: "配置 - 列表 - 相当复杂的列表" 34 | key: "complex-list" 35 | gentleRefreshing: true 36 | listSelector: true 37 | actions: 38 | - title: www.ele.me 39 | method: open 40 | href: https://www.ele.me 41 | - title: The-Action 42 | method: post 43 | api: theAction 44 | - title: read 45 | method: read 46 | target: dialog 47 | api: theAction 48 | inputs: 49 | - title: 名称 50 | key: title 51 | - title: 描述 52 | component: Text 53 | key: description 54 | caption: 55 | - component: "HTML" 56 | args: 57 | html: "数据加载时间 {value}" 58 | '@defaultValue': "locale-datetime" 59 | fields: 60 | - key: "id" 61 | title: "ID" 62 | - key: "type" 63 | title: "类型" 64 | - key: "title" 65 | title: "名称" 66 | - key: "description" 67 | title: "描述" 68 | headers: 69 | - component: "AutoRefresh" 70 | args: 71 | interval: 3000 72 | operations: 73 | - title: "批量删除" 74 | method: "delete" 75 | query: true 76 | 77 | - title: "配置 - 列表 - 可全选" 78 | key: "the-list-data?2" 79 | listSelector: true 80 | actions: 81 | - title: "www.ele.me" 82 | method: "open" 83 | href: "https://www.ele.me" 84 | - title: "The-Action" 85 | method: "post" 86 | api: "theAction" 87 | fields: 88 | - key: "id" 89 | title: "ID" 90 | sortable: true 91 | - key: "a" 92 | title: "A" 93 | component: "TextTip" 94 | - key: "img" 95 | title: "图片" 96 | component: "Image" 97 | - key: "c" 98 | component: "QRCode" 99 | args: 100 | defaultValue: "hehe" 101 | title: "QRCode" 102 | operations: 103 | - title: "批量删除" 104 | method: "delete" 105 | query: true 106 | 107 | - title: "配置 - 列表 - 分页" 108 | key: "pager-list-data" 109 | countable: 5 110 | pageSize: 10 111 | module: "list" 112 | fields: 113 | - key: "id" 114 | title: "ID" 115 | - key: "a" 116 | title: "A" 117 | - key: "b" 118 | title: "B" 119 | component: "Clipboard" 120 | 121 | - title: "配置 - 列表 - 合并单元格" 122 | key: "mergable-list" 123 | module: "list" 124 | groupBy: 125 | - "type" 126 | actions: 127 | - method: "post" 128 | title: "处理" 129 | fields: 130 | - key: "type" 131 | title: "Type" 132 | - key: "name" 133 | title: "Name" 134 | - key: "tag" 135 | title: "Tag" 136 | labelText: "自定义文本" 137 | - key: "value" 138 | title: "Value" 139 | aggregate: "sum" 140 | 141 | - title: "配置 - 列表 - 链接到其它页面" 142 | key: "link-list" 143 | fields: 144 | - key: "id" 145 | title: "ID" 146 | - key: "type" 147 | title: "类型" 148 | - key: "title" 149 | title: "名称" 150 | - key: "link" 151 | title: "链接" 152 | component: Link2 153 | args: 154 | title: 链接 155 | module: list 156 | key: 'the-filter?_=1' 157 | 158 | - title: "配置 - 列表 - 空列表" 159 | key: "empty-list" 160 | module: "list" 161 | fields: 162 | - key: "id" 163 | title: "ID" 164 | - key: "a" 165 | title: "A" 166 | -------------------------------------------------------------------------------- /src/components/InputList.js: -------------------------------------------------------------------------------- 1 | def((Input, Button) => { 2 | 3 | class InternalListItem extends Jinkela { 4 | get tagName() { return 'li'; } 5 | beforeParse(params) { 6 | let { component, args, readonly, depot } = params; 7 | args = Object.assign({ readonly }, args); 8 | this.input = new Input({ depot, component, args, onReady: () => this.ready() }); 9 | } 10 | ready() { 11 | this.element.style.display = 'block'; 12 | } 13 | init() { 14 | this.input.to(this); 15 | if (!this.noDelete && !this.readonly) new Button({ text: '-', onClick: () => this.dispatchRemoveEvent() }).to(this); 16 | } 17 | dispatchRemoveEvent() { 18 | this.element.dispatchEvent(new CustomEvent('remove', { detail: this, bubbles: true })); 19 | } 20 | remove() { this.element.remove(); } 21 | get styleSheet() { 22 | return ` 23 | :scope { 24 | display: none; 25 | margin: 0; 26 | margin-top: 1em; 27 | &:first-child { margin-top: 0; } 28 | > * { 29 | display: inline-block; 30 | margin-right: .5em; 31 | } 32 | > button { 33 | vertical-align: bottom; 34 | } 35 | } 36 | `; 37 | } 38 | get value() { return this.input.value; } 39 | set value(value) { this.input.value = value; } 40 | } 41 | 42 | class InternalList extends Jinkela { 43 | get tagName() { return 'ul'; } 44 | init() { 45 | this.items = []; 46 | this.element.addEventListener('remove', event => this.removeChild(event)); 47 | } 48 | removeChild(event) { 49 | event.stopPropagation(); 50 | let item = event.detail; 51 | let index = this.items.indexOf(item); 52 | if (~index) this.items.splice(index, 1); 53 | item.remove(); 54 | this.countChange(); 55 | } 56 | add(value) { 57 | let params = { readonly: this.readonly }; 58 | if (value !== void 0) params.value = value; 59 | this.items.push(new InternalListItem(this, params).to(this)); 60 | this.countChange(); 61 | } 62 | countChange() { 63 | this.element.dispatchEvent(new CustomEvent('countchange', { detail: this.items.length, bubbles: true })); 64 | } 65 | clear() { 66 | this.items.splice(0).forEach(item => item.remove()); 67 | } 68 | get styleSheet() { 69 | return ` 70 | :scope { 71 | &:empty { 72 | &::before { 73 | content: '无数据'; 74 | display: inline-block; 75 | margin-right: .5em; 76 | } 77 | } 78 | display: inline-block; 79 | margin: 0; 80 | padding: 0; 81 | list-style: none; 82 | } 83 | `; 84 | } 85 | get value() { return this.items.map(item => item.value); } 86 | set value(value) { 87 | if (!(value instanceof Array)) value = []; 88 | this.clear(); 89 | value.forEach(item => this.add(item)); 90 | } 91 | } 92 | 93 | class InputList extends Jinkela { 94 | init() { 95 | this.list = new InternalList(this).to(this); 96 | if (!this.noAdd && !this.readonly) this.button = new Button({ text: '+', onClick: () => this.add() }).to(this); 97 | this.element.addEventListener('countchange', event => this.countChange(event)); 98 | if (!this.$hasValue) this.value = void 0; 99 | } 100 | countChange(event) { 101 | event.stopPropagation(); 102 | if (this.button) this.button.element.style.display = event.detail >= this.max ? 'none' : 'inline-block'; 103 | } 104 | add() { this.list.add(); } 105 | get value() { return this.list.value; } 106 | set value(value = this.defaultValue) { 107 | this.$hasValue = true; 108 | this.list.value = value; 109 | } 110 | get styleSheet() { 111 | return ` 112 | :scope { 113 | > button { 114 | vertical-align: bottom; 115 | } 116 | } 117 | `; 118 | } 119 | } 120 | 121 | return InputList; 122 | 123 | }); 124 | -------------------------------------------------------------------------------- /demo/schemes/inputs/combines.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "key": "Input::组合", 5 | "title": "组件 - 组合", 6 | "module": "editor", 7 | "inputs": [ 8 | { 9 | "key": "grouping1", "title": "神奇的组合", "component": "GroupingSelect", 10 | "args": { 11 | "options": { 12 | "a": "两个搜索建议组件", 13 | "b": "两个时期时间组件" 14 | }, 15 | "mode": "line", 16 | "subGroupMap": { 17 | "a": [ 18 | { "title": "hehe1", "component": "Suggestion", "args": { "api": "value-list" } }, 19 | { "title": "hehe2", "component": "Suggestion", "args": { "api": "value-list" } } 20 | ], 21 | "b": [ 22 | { "component": "DateTime" }, 23 | { "component": "DateTime" } 24 | ] 25 | } 26 | } 27 | }, 28 | { 29 | "title": "列表嵌搜索建议", 30 | "component": "List", 31 | "args": { 32 | "defaultValue": [ "hehe" ], 33 | "component": "Suggestion", 34 | "args": { 35 | "api": "value-list", 36 | "width": 100 37 | } 38 | } 39 | }, 40 | { 41 | "component": "List", 42 | "args": { 43 | "defaultValue": [ "hehe" ], 44 | "component": "Grouping", 45 | "args": { 46 | "mode": "line", 47 | "style": { 48 | "padding": "1em", 49 | "background": "#f0f0f0" 50 | }, 51 | "inputs": [ 52 | { 53 | "component": "Grouping", 54 | "args": { 55 | "mode": "line", 56 | "inputs": [ 57 | { "args": { "html": "hehe" }, "component": "OutputHTML" }, 58 | { "args": { "width": 100 }, "component": "String" }, 59 | { "args": { "width": 100 }, "component": "String" } 60 | ] 61 | } 62 | }, 63 | { "args": { "width": 100 }, "component": "String", "title": "String" } 64 | ] 65 | } 66 | } 67 | }, 68 | { 69 | "title": "列表嵌组合选择", 70 | "component": "List", 71 | "args": { 72 | "defaultValue": [ "hehe" ], 73 | "component": "GroupingSelect", 74 | "args": { 75 | "options": { 76 | "a": "两个搜索建议组件", 77 | "b": "两个时期时间组件", 78 | "c": "xxx" 79 | }, 80 | "mode": "line", 81 | "subGroupMap": { 82 | "a": [ 83 | { "title": "hehe1", "component": "Suggestion", "args": { "api": "value-list" } }, 84 | { "title": "hehe2", "component": "Suggestion", "args": { "api": "value-list" } }, 85 | { "component": "String", "args": { "width": 500 } } 86 | ], 87 | "b": [ 88 | { "component": "DateTime" }, 89 | { "component": "DateTime" } 90 | ], 91 | "c": [ 92 | ] 93 | } 94 | } 95 | } 96 | }, 97 | 98 | { 99 | "title": "列表嵌组合选择", 100 | "component": "Grouping", 101 | "args": { 102 | "mode": "line", 103 | "inputs": [ 104 | { "component": "OutputHTML", "args": { "html": "呵呵" } }, 105 | { 106 | "component": "Forest", 107 | "key": "cascader4", 108 | "title": "树林", 109 | "args": { 110 | "options": [ 111 | { "a": 1, "c": "item 1" }, 112 | { "a": 2, "b": 1, "c": "item 2" }, 113 | { "a": 1, "c": "item 1" }, 114 | { "a": 2, "c": "item 2" }, 115 | { "a": 3, "c": "item 3" }, 116 | { "a": 4, "c": "item 4" }, 117 | { "a": 5, "c": "item 5" } 118 | ], 119 | "defaultValue": [ 1 ], 120 | "idAlias": "a", 121 | "parentIdAlias": "b", 122 | "textAlias": "c" 123 | } 124 | } 125 | ] 126 | } 127 | } 128 | 129 | ] 130 | } 131 | 132 | ] 133 | -------------------------------------------------------------------------------- /src/components/Form.js: -------------------------------------------------------------------------------- 1 | def((FormSubmit, FormItemWithTable, Alert) => class extends Jinkela { 2 | 3 | beforeParse(params) { 4 | this.depot = params.depot; 5 | } 6 | 7 | get FormSubmit() { return FormSubmit; } 8 | 9 | get listLength() { return this.depot.scheme.inputs && this.depot.scheme.inputs.length; } 10 | get nosubmit() { return this.depot.scheme.noSubmit || this.depot.params.readonly; } 11 | get form() { return this; } 12 | 13 | get list() { 14 | let { depot } = this; 15 | let { scheme, formMode } = depot; 16 | let { inputs = [] } = scheme; 17 | inputs = JSON.parse(JSON.stringify(inputs)); // inputs 消除引用 18 | inputs = inputs.filter(item => item[formMode] !== 'none'); // 过滤隐藏项 19 | inputs = inputs.filter(item => this.checkPermissions(item)); // 过滤权限 20 | inputs.forEach((item) => { // read 方式默认是只读的 21 | if (formMode === 'read' && item[formMode] === void 0) item[formMode] = 'readonly'; 22 | if (item[formMode] === 'readonly') { 23 | if (!item.args) item.args = {}; 24 | item.args.readonly = true; 25 | } 26 | if (item[formMode] === 'hidden') item.hidden = true; 27 | }); 28 | let value = FormItemWithTable.cast(inputs, { depot }); 29 | Object.defineProperty(this, 'list', { configurable: true, value }); 30 | return value; 31 | } 32 | 33 | get $promise() { 34 | let value = Promise.all(this.list.map(item => item.$promise)); 35 | Object.defineProperty(this, '$promise', { configurable: true, value }); 36 | return value; 37 | } 38 | 39 | checkPermissions(item) { 40 | if (!item.require) return true; 41 | let requireList = [].concat(item.require); 42 | if (requireList.length === 0) return true; 43 | let { session } = this.depot || depot; 44 | let { permissions } = session; 45 | return requireList.some(code => permissions.includes(code)); 46 | } 47 | 48 | ready() { 49 | this.list.forEach(item => item.to(this.table)); 50 | this.hasReady = true; 51 | } 52 | 53 | init() { 54 | this.$promise.then(() => this.ready()); 55 | // 处理多列样式 56 | if (this.columns > 1) { 57 | this.columns.dataset.columns = this.columns; 58 | this.columns.style.columns = this.columns; 59 | } 60 | // 这里 Alert(什么鬼)??? 61 | if (depot.scheme.alert) { 62 | this.$promise.then(() => { 63 | new Alert(Object.assign({ form: this }, depot.scheme.alert)).to(this.notice); 64 | }); 65 | } 66 | } 67 | 68 | set value(data) { 69 | if (!data) return; 70 | this.list.forEach(item => { 71 | switch (item.squash) { 72 | case 'direct': 73 | item.value = Object.assign({ '': data[item.key] }, data); 74 | break; 75 | default: 76 | item.value = data[item.key]; 77 | } 78 | }); 79 | } 80 | 81 | get value() { 82 | return this.list.reduce((result, item) => { 83 | let value; 84 | try { 85 | value = item.value; 86 | } catch (error) { 87 | throw new Error(item.text.textContent + error.message); 88 | } 89 | switch (item.squash) { 90 | case 'direct': 91 | result[item.key] = value['']; 92 | Object.keys(value).filter(key => key).forEach(key => (result[key] = value[key])); 93 | break; 94 | default: 95 | result[item.key] = value; 96 | } 97 | return result; 98 | }, Object.create(null)); 99 | } 100 | 101 | get styleSheet() { 102 | return ` 103 | :scope { 104 | padding: 2em; 105 | > [ref=columns] { 106 | > .table { 107 | border-spacing: var(--spacing); 108 | margin: -1em 0; 109 | width: 100%; 110 | font-size: inherit; 111 | } 112 | } 113 | } 114 | `; 115 | } 116 | 117 | get template() { 118 | return ` 119 |
    120 |
    121 |
    122 |
    123 |
    124 |
    加载中...
    125 |

    并没有什么东西可以编辑

    126 | 127 |
    128 | `; 129 | } 130 | 131 | }); 132 | -------------------------------------------------------------------------------- /src/utils/api.js: -------------------------------------------------------------------------------- 1 | { 2 | 3 | let cache = {}; 4 | 5 | const parseResponseBody = response => { 6 | let mime = response.headers.get('Content-Type'); 7 | switch (true) { 8 | case /\bjson\b/.test(mime) || /\.json([?#]|$)/.test(response.url): 9 | return response.text().then(result => result && JSON.parse(result)); 10 | case /\byaml\b/.test(mime) || /\.ya?ml([?#]|$)/.test(response.url): 11 | return Promise.all([ response.text(), req('./jsyaml') ]).then(([ text, yml ]) => yml.load(text)); 12 | case /\btext\b/.test(mime): 13 | return response.text(); 14 | default: 15 | return response.blob(); 16 | } 17 | }; 18 | 19 | window.api = new class extends Function { 20 | constructor() { 21 | super('...args', 'return this(...args)'); 22 | return this.bind(this.launch); 23 | } 24 | resolvePath(path) { 25 | let result = this.basePath.concat(path).reduce((base, item) => { 26 | if (item === void 0) return base; 27 | item = new Function('return `' + item + '`')(); 28 | // 外链直接使用,不做额外处理 29 | if (/^(?:blob:)?(?:https?:)?\/\//.test(item)) return item; 30 | // 斜杆开头的接到 base 所在域名后面作为路径 31 | if (/^\//.test(item)) return base.replace(/^((?:https?:)?\/\/[^/]*)?(.*)/, `$1${item}`); 32 | // 其它情况,处理掉 qs,然后计算出相对路径 33 | if (item) { 34 | base = base.replace(/\?.*/, ''); 35 | if (base[base.length - 1] !== '/') base += '/'; 36 | } 37 | return base + item; 38 | }); 39 | // 处理 QS 叠加 40 | let isFirst = true; 41 | result = result.replace(/\?/g, () => { 42 | if (!isFirst) return '&'; 43 | isFirst = false; 44 | return '?'; 45 | }); 46 | return result; 47 | } 48 | extendOptions(options) { 49 | options = Object.assign({ credentials: 'include' }, options); 50 | if (options.body) { 51 | if (!options.headers) options.headers = {}; 52 | options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json'; 53 | } 54 | return options; 55 | } 56 | cache(path, options = {}, resolver) { 57 | let key = JSON.stringify([ path, options ]); 58 | let { expires } = options; 59 | if (key in cache) return cache[key]; 60 | let $result = resolver(); 61 | if (expires) { 62 | cache[key] = $result; 63 | setTimeout(() => delete cache[key], expires); 64 | } 65 | return $result; 66 | } 67 | get basePath() { 68 | let value = [ location.origin + location.pathname.replace(/[^/]*$/, 'api') ]; 69 | let baseElement = document.querySelector('script[base]'); 70 | let base = baseElement && baseElement.getAttribute('base'); 71 | if (!base) { 72 | let configElement = document.querySelector('script[config]'); 73 | let config = configElement && configElement.getAttribute('config') || ''; 74 | base = config.replace(/[^/]*$/, ''); 75 | } 76 | if (base) value.push(base); 77 | Object.defineProperty(this, 'basePath', { value }); 78 | return value; 79 | } 80 | get launch() { 81 | return (path, options) => { 82 | let url = this.resolvePath(path); 83 | if (options && 'query' in options) { 84 | url += ~url.indexOf('?') ? '&' : '?'; 85 | url += Object.keys(options.query).map(key => { 86 | return `${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(options.query[key]))}`; 87 | }).join('&'); 88 | } 89 | const resolver = () => fetch(url, this.extendOptions(options)).then(response => { 90 | return parseResponseBody(response).then(result => { 91 | if (result && typeof result === 'object') result[Symbol.for('response')] = response; 92 | if (response.status < 400) { 93 | return result; 94 | } else { 95 | throw result; 96 | } 97 | }); 98 | }).catch(error => { 99 | if (error instanceof SyntaxError) error.message += ` in ${url}`; 100 | if (error.message === 'Failed to fetch') error.message = '网络不给力,请稍后重试'; 101 | throw error; 102 | }); 103 | return this.cache(path, options, resolver); 104 | }; 105 | } 106 | }(); 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/components/MainWithList.js: -------------------------------------------------------------------------------- 1 | def((FatalError, Output, ListFlex, ListOperations, ListHeaders, ListFilters, Table, Pagination) => { 2 | 3 | return class extends Jinkela { 4 | get ListFlex() { return ListFlex; } 5 | get ListOperations() { return ListOperations; } 6 | get ListFilters() { return ListFilters; } 7 | get ListHeaders() { return ListHeaders; } 8 | get Table() { return Table; } 9 | get FatalError() { return FatalError; } 10 | get Pagination() { return Pagination; } 11 | 12 | get template() { 13 | return ` 14 |
    15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
    24 | 25 | 26 |
    27 | `; 28 | } 29 | 30 | get styleSheet() { 31 | return ` 32 | :scope { 33 | width: 100%; 34 | > .tip { 35 | text-align: center; 36 | font-size: 14px; 37 | padding: 2em; 38 | } 39 | } 40 | `; 41 | } 42 | 43 | beforeParse(params) { 44 | this.depot = params.depot || depot; 45 | this.isFilterHiddenDefault = this.depot.params.filterState === 'folded'; 46 | } 47 | 48 | filterToggle() { 49 | this.filterContainer.toggle(); 50 | } 51 | 52 | loadData() { 53 | let { queryParams, resolvedKey } = this.depot; 54 | return api(resolvedKey + '?' + queryParams); 55 | } 56 | 57 | loadCount() { 58 | if (!this.depot.scheme.countable) return Promise.resolve(0 / 0); 59 | let { queryParams, resolvedKey } = this.depot; 60 | return api(resolvedKey + '/count' + '?' + queryParams).catch(() => 0 / 0); 61 | } 62 | 63 | initData() { 64 | let { depot } = this; 65 | return Promise.all([ 66 | this.loadData(), 67 | this.loadCount() 68 | ]).then(([ list, count ]) => { 69 | if (!(list instanceof Array)) throw new Error('返回结果必须是数组'); 70 | this.list = list; 71 | count = typeof count === 'number' ? count : count.count; 72 | this.count = count; 73 | // 如果设置了 pageSize,那么初始化分页控件 74 | if ('pageSize' in depot.scheme) this.pagination = new Pagination({ depot, list, count }); 75 | return list.length; 76 | }); 77 | } 78 | 79 | get tip() { return this.tipContent; } 80 | set tip(value) { 81 | this.hasTip = !!value; 82 | this.tipContent = value; 83 | } 84 | 85 | init() { 86 | Object.defineProperty(this.depot, 'main', { configurable: true, value: this }); 87 | 88 | let { depot } = this; 89 | let { scheme, where } = depot; 90 | let { noWhere, fields = [] } = scheme; 91 | 92 | // 检查字段完整性 93 | if (!fields || !fields.length) return this.errorHandler(new Error('字段未配置')); 94 | 95 | // 如果设置了 noWhere,那么在 where 为空时将不发起数据加载 96 | if (noWhere && Object.keys(where).length === 0) { 97 | this.tip = noWhere === true ? '请提供查询条件' : Output.createAny(noWhere); 98 | return; 99 | } 100 | 101 | // 加载数据 102 | this.tip = '加载中 ···'; 103 | this.promise = this.initData().then(length => { 104 | this.tip = length ? '' : '查不到匹配条件的数据'; 105 | this.loading = false; 106 | }, this.errorHandler); 107 | } 108 | 109 | get errorHandler() { 110 | let value = error => { 111 | if (typeof error === 'string') error = { message: '错误信息是一个字符串' }; 112 | let message = error.message || '未知错误'; 113 | this.fatal = new FatalError({ depot: this.depot, message }); 114 | this.tip = ''; 115 | setTimeout(() => { throw error; }); 116 | }; 117 | Object.defineProperty(this, 'errorHandler', { value, configurable: true }); 118 | return value; 119 | } 120 | 121 | }; 122 | 123 | }); 124 | -------------------------------------------------------------------------------- /src/components/TableRowActions.js: -------------------------------------------------------------------------------- 1 | def((Output, Item, Confirm, ErrorDialog) => { 2 | 3 | class TableRowActionsItem extends Item { 4 | init() { 5 | Output.createAny(this.title || this.method).to(this); 6 | if (!this.checkPermissions()) this.element.style.display = 'none'; 7 | } 8 | 9 | checkPermissions() { 10 | return this.checkRequire() && this.checkRequireField(); 11 | } 12 | 13 | checkRequire() { 14 | if (!this.require) return true; 15 | let requireList = [].concat(this.require); 16 | if (requireList.length === 0) return true; 17 | let { session } = this.depot; 18 | let { permissions } = session; 19 | return requireList.some(code => permissions.includes(code)); 20 | } 21 | 22 | checkRequireField() { 23 | if (!this.requireField) return true; 24 | let requireFieldList = [].concat(this.requireField); 25 | if (requireFieldList.length === 0) return true; 26 | return requireFieldList.some(fieldName => this.fieldMap[fieldName]); 27 | } 28 | 29 | onClick() { 30 | if (this.confirm) { 31 | return Confirm.popup(this.confirm, this.depot).then(result => result && this.exec()); 32 | } else { 33 | return this.exec(); 34 | } 35 | } 36 | get exec() { return this[this.method + 'Action'] || this.defaultAction; } 37 | 38 | goAction() { 39 | let { module, key, params, _blank, target, title, where, depot } = this; 40 | let data = Object.create(depot); 41 | for (let key in this.fieldMap) { 42 | Object.defineProperty(data, key, { configurable: true, value: this.fieldMap[key] }); 43 | } 44 | params = refactor(params || {}, data); 45 | where = refactor(where || {}, data); 46 | if (_blank) target = '_blank'; 47 | return depot.go({ args: { module, key, params, where }, target, title }); 48 | } 49 | 50 | editAction() { 51 | let { depot } = this; 52 | this.module = 'editor'; 53 | this.params = Object.assign({ '@id': '$.id' }, depot.params); 54 | this.key = depot.key; 55 | return this.goAction(); 56 | } 57 | 58 | readAction() { 59 | let { depot } = this; 60 | this.module = 'editor'; 61 | this.params = Object.assign({ '@id': '$.id' }, depot.params, { readonly: true }); 62 | this.key = depot.key; 63 | return this.goAction(); 64 | } 65 | 66 | copyAction() { 67 | let { depot } = this; 68 | this.module = 'editor'; 69 | this.params = Object.assign({}, depot.params, { '@copy': '$.id' }); 70 | this.key = depot.key; 71 | return this.goAction(); 72 | } 73 | 74 | openAction() { 75 | let { depot } = this; 76 | let { queryParams, resolvedKey } = depot; 77 | let url = api.resolvePath([ resolvedKey, this.href ]); 78 | open(`${url}?${queryParams}`); 79 | } 80 | 81 | defaultAction() { 82 | let { depot } = this; 83 | let path = [ depot.resolvedKey, this.fieldMap.id ]; 84 | if ('api' in this) path.push(this.api); 85 | return api(path, { method: this.method || 'POST' }).then(result => doAction(result, depot)).then(() => { 86 | depot.refresh(); 87 | }, error => { 88 | ErrorDialog.popup({ error }); 89 | }); 90 | } 91 | 92 | get template() { 93 | return ` 94 |
  • 95 | `; 96 | } 97 | 98 | get styleSheet() { 99 | return ` 100 | :scope { 101 | cursor: pointer; 102 | margin-left: .5em; 103 | display: inline-block; 104 | vertical-align: middle; 105 | color: #20a0ff; 106 | font-size: 12px; 107 | &:hover { 108 | color: #1d8ce0; 109 | } 110 | } 111 | `; 112 | } 113 | } 114 | 115 | return class extends Jinkela { 116 | init() { 117 | let { fieldMap } = this; 118 | TableRowActionsItem.cast(this.actions.filter(action => { 119 | return condition(action.conditions, fieldMap); 120 | }), this).to(this); 121 | } 122 | get tagName() { return 'ul'; } 123 | get styleSheet() { 124 | return ` 125 | :scope { 126 | margin: 0; 127 | padding: 0; 128 | text-align: right; 129 | list-style: none; 130 | } 131 | `; 132 | } 133 | }; 134 | 135 | }); 136 | -------------------------------------------------------------------------------- /src/components/InputFileBase64.js: -------------------------------------------------------------------------------- 1 | def((Button) => { 2 | 3 | class SpanButton extends Button { 4 | get tagName() { return 'span'; } 5 | get styleSheet() { 6 | return ` 7 | :scope { 8 | margin-right: 1em; 9 | display: inline-block; 10 | } 11 | `; 12 | } 13 | } 14 | 15 | class DownloadLink extends Jinkela { 16 | init() { this.downloadText = this.downloadText || '下载'; } 17 | set value(value) { 18 | this.$value = value; 19 | this.visible = !!value; 20 | // 黑科技,如果检测到不是 base64 就作为文本下载 21 | if (/^[A-Za-z0-9/+=]*$/.test(value)) { 22 | this.link = `data:application/octet-stream;base64,${value}`; 23 | } else { 24 | this.link = `data:application/octet-stream,${value}`; 25 | } 26 | } 27 | get value() { return this.$value === null ? void 0 : this.$value; } 28 | get template() { return '{downloadText}'; } 29 | get styleSheet() { 30 | return ` 31 | :scope { 32 | display: inline-block; 33 | margin-right: 15px; 34 | text-decoration: underline; 35 | color: #20A0FF; 36 | } 37 | `; 38 | } 39 | } 40 | 41 | class FileInfo extends Jinkela { 42 | get tagName() { return 'span'; } 43 | get styleSheet() { 44 | return ` 45 | :scope { 46 | color: #999; 47 | vertical-align: middle; 48 | } 49 | `; 50 | } 51 | } 52 | 53 | class CancelButton extends Jinkela { 54 | init() { 55 | this.element.addEventListener('click', this.onClick); 56 | } 57 | get template() { return ''; } 58 | get styleSheet() { 59 | return ` 60 | :scope { 61 | vertical-align: middle; 62 | margin-left: 1em; 63 | } 64 | `; 65 | } 66 | } 67 | 68 | return class extends Jinkela { 69 | get SpanButton() { return SpanButton; } 70 | get DownloadLink() { return DownloadLink; } 71 | get value() { 72 | let value = this.$value === void 0 ? null : this.$value; 73 | if (this.notEmpty && !value) throw new Error('不能为空'); 74 | return value; 75 | } 76 | set value(value = this.defaultValue) { 77 | this.$hasValue = true; 78 | this.$value = value; 79 | this.base64 = value; 80 | if (value) { 81 | this.fileInfo.element.textContent = Math.floor(String(value).replace(/=*$/, '').length * 3 / 4).toLocaleString() + ' Bytes'; 82 | } else { 83 | this.fileInfo.element.textContent = '未选择'; 84 | } 85 | this.label.setAttribute('notEmpty', !!value); 86 | } 87 | get template() { 88 | return ` 89 |
    90 | 95 |
    96 | `; 97 | } 98 | init() { 99 | this.fileInfo = new FileInfo().to(this); 100 | if (this.readonly) { 101 | this.element.classList.add('readonly'); 102 | } else { 103 | if (!this.text) this.text = '请选择文件'; 104 | new CancelButton({ onClick: () => (this.value = null) }).to(this); 105 | this.input.addEventListener('change', event => this.change(event)); 106 | } 107 | if (!this.$hasValue) this.value = void 0; 108 | } 109 | change(event) { 110 | let { target } = event; 111 | let file = target.files[0]; 112 | if (!file) return; 113 | let fr = new FileReader(); 114 | this.button.element.classList.add('busy'); 115 | fr.addEventListener('load', () => { 116 | let { result } = fr; 117 | let base64 = result.slice(result.indexOf(',') + 1); 118 | this.value = base64; 119 | this.button.element.classList.remove('busy'); 120 | }); 121 | fr.readAsDataURL(file); 122 | } 123 | get styleSheet() { 124 | return ` 125 | :scope { 126 | label { 127 | display: inline-block; 128 | } 129 | label ~ a { display: none; } 130 | label[notEmpty=true] ~ a { display: inline-block; } 131 | input { display: none; } 132 | } 133 | `; 134 | } 135 | }; 136 | 137 | }); 138 | -------------------------------------------------------------------------------- /demo/schemes/inputs/keyboards.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "key": "Input::String", 5 | "title": "组件 - 键盘输入类 - String", 6 | "module": "editor", 7 | "inputs": [ 8 | { "key": "string1", "component": "String", "title": "常规" }, 9 | { "key": "string2", "component": "String", "title": "默认值", "args": { "defaultValue": "hehe" } }, 10 | { "key": "string2", "component": "String", "title": "默认值2", "args": { "defaultValue": "$.depot.key" } }, 11 | { "key": "string3", "component": "String", "title": "最大限制", "args": { "maxLength": 3 } }, 12 | { "key": "string4", "component": "String", "title": "最小限制", "args": { "minLength": 3 } }, 13 | { "key": "string5", "component": "String", "title": "非空", "args": { "notEmpty": true } }, 14 | { "key": "string6", "component": "String", "title": "自动清空头尾空格", "args": { "autoTrim": true } } 15 | ] 16 | }, 17 | 18 | { 19 | "key": "Input::Text", 20 | "title": "组件 - 键盘输入类 - Text", 21 | "module": "editor", 22 | "inputs": [ 23 | { "key": "text1", "component": "Text", "title": "常规" }, 24 | { "key": "text2", "component": "Text", "title": "默认值", "args": { "defaultValue": "hehe\nhehe" } }, 25 | { "key": "text3", "component": "Text", "title": "最大限制", "args": { "maxLength": 3 } }, 26 | { "key": "text4", "component": "Text", "title": "最小限制", "args": { "minLength": 3 } }, 27 | { "key": "text5", "component": "Text", "title": "非空", "args": { "notEmpty": true } }, 28 | { "key": "text6", "component": "Text", "title": "自动清空头尾空格", "args": { "autoTrim": true } } 29 | ] 30 | }, 31 | 32 | { 33 | "key": "Input::TextAround", 34 | "title": "组件 - 键盘输入类 - TextAround", 35 | "module": "editor", 36 | "inputs": [ 37 | { 38 | "key": "textAround1", "component": "TextAround", "title": "双标签", 39 | "args": { "component": "String", "before": "总计", "after": "吨" } 40 | }, 41 | { 42 | "key": "textAround2", "component": "TextAround", "title": "前标记,带宽度", 43 | "args": { "component": "String", "args": { "width": 50 }, "before": "总计" } 44 | }, 45 | { 46 | "key": "textAround3", "component": "TextAround", "title": "后标记", 47 | "args": { "component": "String", "after": "公斤" } 48 | }, 49 | { 50 | "key": "textAround3", "component": "TextAround", "title": "数字", 51 | "args": { "component": "Number", "after": "克" } 52 | } 53 | ] 54 | }, 55 | 56 | { 57 | "key": "Input::Number", 58 | "title": "组件 - 键盘输入类 - Number", 59 | "module": "editor", 60 | "inputs": [ 61 | { "key": "number1", "component": "Number", "title": "正常" }, 62 | { "key": "number2", "component": "Number", "title": "默认值", "args": { "defaultValue": "123.456" } }, 63 | { "key": "number3", "component": "Number", "title": "非数字", "args": { "defaultValue": "a" } }, 64 | { "key": "number4", "component": "Number", "title": "空默认值", "args": { "defaultValue": "" } }, 65 | { 66 | "key": "number5", "component": "Number", "title": "保留两位", 67 | "args": { "defaultValue": "3.1415926", "decimal": 2 } 68 | }, 69 | { 70 | "key": "number6", "component": "Number", "title": "保留整数", 71 | "args": { "defaultValue": "3.1415926", "decimal": 0 } 72 | } 73 | ] 74 | }, 75 | 76 | { 77 | "key": "Input::Password", 78 | "title": "组件 - 键盘输入类 - Password", 79 | "module": "editor", 80 | "inputs": [ 81 | { "key": "password1", "component": "Password", "title": "常规" }, 82 | { "key": "password2", "component": "Password", "title": "placeholder", "args": { "placeholder": "请输入密码" } }, 83 | { "key": "password3", "component": "Password", "title": "width 60", "args": { "width": 60 } }, 84 | { "key": "password4", "component": "Password", "title": "默认值", "args": { "defaultValue": "hehe" } }, 85 | { "key": "password5", "component": "Password", "title": "最大限制", "args": { "maxLength": 3 } }, 86 | { "key": "password6", "component": "Password", "title": "最小限制", "args": { "minLength": 3 } }, 87 | { "key": "password7", "component": "Password", "title": "非空", "args": { "notEmpty": true } }, 88 | { "key": "password8", "component": "Password", "title": "自动清空头尾空格", "args": { "autoTrim": true } } 89 | ] 90 | }, 91 | 92 | { 93 | "key": "Input::Markdown", 94 | "title": "组件 - 键盘输入类 - Markdown", 95 | "module": "editor", 96 | "inputs": [ 97 | { "key": "md1", "component": "Markdown", "title": "正常" }, 98 | { "key": "md2", "component": "Markdown", "title": "默认值", "args": { "defaultValue": "# hehe" } } 99 | ] 100 | }, 101 | 102 | { 103 | "key": "Input::Code", 104 | "title": "组件 - 键盘输入类 - Code", 105 | "module": "editor", 106 | "inputs": [ 107 | { "key": "code1", "component": "Code", "title": "正常" }, 108 | { 109 | "key": "code2", "component": "Code", "title": "默认值", 110 | "args": { "defaultValue": "console.log('hehe');", "mode": "javascript" } 111 | } 112 | ] 113 | } 114 | 115 | ] 116 | -------------------------------------------------------------------------------- /src/components/InputCode.js: -------------------------------------------------------------------------------- 1 | def(() => class extends Jinkela { 2 | 3 | init() { 4 | if (!this.mode) { 5 | this.mode = 'markdown'; 6 | } 7 | const modelib = this.modelib ? this.modelib : this.mode; 8 | this.$editor = null; 9 | 10 | this.task = new Promise((resolve) => { 11 | require([ 12 | 'codemirror/lib/codemirror', 13 | `codemirror/mode/${modelib}/${modelib}` 14 | ], CodeMirror => { 15 | this.$editor = CodeMirror(this.element, Object.assign({ 16 | theme: 'neo', 17 | lineNumbers: true, 18 | tabSize: 2, 19 | scrollPastEnd: true, 20 | showMatchesOnScrollbar: true, 21 | autoRefresh: true 22 | }, this)); 23 | this.refresh(); 24 | this.$editor.on('focus', () => this.element.classList.add('focus')); 25 | this.$editor.on('blur', () => this.element.classList.remove('focus')); 26 | resolve(this.$editor); 27 | }); 28 | }); 29 | 30 | if (this.readonly) { 31 | this.disable(); 32 | } else { 33 | this.enable(); 34 | } 35 | 36 | this.element.addEventListener('click', () => this.focus()); 37 | if (!this.$hasValue) this.value = void 0; 38 | } 39 | 40 | enable() { 41 | return this.task.then(editor => { 42 | editor.setOption('readOnly', false); 43 | this.element.classList.remove('readonly'); 44 | }); 45 | } 46 | 47 | disable() { 48 | return this.task.then(editor => { 49 | editor.setOption('readOnly', true); 50 | this.element.classList.add('readonly'); 51 | }); 52 | } 53 | 54 | on(...args) { 55 | return this.task.then(editor => editor.on(...args)); 56 | } 57 | 58 | execCommand(...args) { 59 | return this.task.then(editor => editor.execCommand(...args)); 60 | } 61 | 62 | focus() { 63 | return this.task.then(editor => editor.focus()); 64 | } 65 | 66 | refresh() { 67 | if (!document.body.contains(this.element)) return setTimeout(() => this.refresh(), 16); 68 | return Promise.resolve(this.task).then(obj => obj.refresh()); 69 | } 70 | 71 | get value() { 72 | return this.$editor ? this.$editor.getValue() : ''; 73 | } 74 | 75 | set value(value = this.defaultValue) { 76 | if (value == null || value === '') return; // eslint-disable-line eqeqeq 77 | if (value instanceof Object) { 78 | value = JSON.stringify(value); 79 | } 80 | return this.task.then(editor => { 81 | editor.setValue(value); 82 | this.refresh(); 83 | }); 84 | } 85 | 86 | get styleSheet() { 87 | return ` 88 | :scope { 89 | position: relative; 90 | border: 1px solid #c0ccda; 91 | border-radius: 5px; 92 | overflow: hidden; 93 | &:hover { border-color: #8492a6; } 94 | &.focus { border-color: #20a0ff; } 95 | --width: 600px; 96 | --height: auto; 97 | --min-height: 200px; 98 | --monospace: 'Roboto Mono', Monaco, courier, monospace; 99 | .CodeMirror { 100 | border-radius: 5px; 101 | font-family: var(--monospace); 102 | font-size: 12px; 103 | width: var(--width); 104 | height: var(--height); 105 | min-height: var(--min-height); 106 | } 107 | &.readonly { 108 | .CodeMirror-cursor { 109 | display: none; 110 | } 111 | .CodeMirror-lines { 112 | cursor: not-allowed; 113 | } 114 | .CodeMirror { 115 | background-color: #eff2f7; 116 | } 117 | border-color: #d3dce6; 118 | color: #bbb; 119 | cursor: not-allowed; 120 | } 121 | .CodeMirror-linenumber { 122 | padding-left: 10px; 123 | padding-right: 10px; 124 | } 125 | .CodeMirror-cursor { 126 | border-left: 1px solid black; 127 | border-right: none; 128 | width: 0; 129 | } 130 | .CodeMirror-search-match { 131 | background: gold; 132 | border-top: 1px solid orange; 133 | border-bottom: 1px solid orange; 134 | box-sizing: border-box; 135 | opacity: .5; 136 | } 137 | &:empty::before { 138 | display: block; 139 | padding: .5em 1em; 140 | content: '代码渲染中 ···'; 141 | } 142 | } 143 | `; 144 | } 145 | 146 | get minHeight() { return this.$minHeight; } 147 | set minHeight(value) { 148 | Object.defineProperty(this, '$minHeight', { configurable: true, value }); 149 | this.element.style.setProperty('--min-height', value); 150 | } 151 | 152 | get height() { return this.$height; } 153 | set height(value) { 154 | Object.defineProperty(this, '$height', { configurable: true, value }); 155 | this.element.style.setProperty('--height', value); 156 | } 157 | 158 | get width() { return this.$width; } 159 | set width(value) { 160 | Object.defineProperty(this, '$width', { configurable: true, value }); 161 | this.element.style.setProperty('--width', value); 162 | } 163 | 164 | }); 165 | -------------------------------------------------------------------------------- /src/components/InputMarkdown.js: -------------------------------------------------------------------------------- 1 | def((ButtonHollow) => { 2 | 3 | class Preview extends ButtonHollow { 4 | beforeParse() { 5 | this.text = '预览'; 6 | } 7 | get styleSheet() { 8 | return ` 9 | :scope { 10 | z-index: 10; 11 | position: absolute; 12 | right: 1em; 13 | top: 1em; 14 | } 15 | `; 16 | } 17 | } 18 | 19 | return new Promise(resolve => { 20 | require([ 21 | 'marked', 22 | 'codemirror/lib/codemirror', 23 | 'codemirror/mode/markdown/markdown' 24 | ], (marked, CodeMirror) => { 25 | 26 | class InputMarkdown extends Jinkela { 27 | init() { 28 | this.initEditor(); 29 | this.initPreviewButton(); 30 | if (this.readonly) { 31 | this.disable(); 32 | } else { 33 | this.enable(); 34 | } 35 | this.element.addEventListener('click', () => this.focus()); 36 | if (!this.$hasValue) this.value = void 0; 37 | } 38 | initPreviewButton() { 39 | new Preview({ onClick: () => this.preview() }).to(this); 40 | } 41 | preview() { 42 | let css = 'https://github.elemecdn.com/sindresorhus/github-markdown-css/gh-pages/github-markdown.css'; 43 | let md = marked(this.value); 44 | let html = ` 45 | 46 | 47 | ${md} 48 | `; 49 | let url = URL.createObjectURL(new Blob([ html ], { type: 'text/html' })); 50 | open(url); 51 | } 52 | initEditor() { 53 | this.editor = CodeMirror(this.element, Object.assign({ 54 | theme: 'neo', 55 | lineNumbers: true, 56 | mode: 'markdown', 57 | tabSize: 2, 58 | scrollPastEnd: true, 59 | showMatchesOnScrollbar: true, 60 | autoRefresh: true 61 | }, this.config)); 62 | this.refresh(); 63 | this.editor.on('focus', () => this.element.classList.add('focus')); 64 | this.editor.on('blur', () => this.element.classList.remove('focus')); 65 | } 66 | enable() { 67 | this.editor.setOption('readOnly', false); 68 | this.element.classList.remove('readonly'); 69 | } 70 | disable() { 71 | this.editor.setOption('readOnly', 'nocursor'); 72 | this.element.classList.add('readonly'); 73 | } 74 | on(...args) { 75 | return this.editor.on(...args); 76 | } 77 | execCommand(...args) { 78 | return this.editor.execCommand(...args); 79 | } 80 | focus() { 81 | return this.editor.focus(); 82 | } 83 | refresh() { 84 | if (!document.body.contains(this.element)) return setTimeout(() => this.refresh(), 16); 85 | this.editor.refresh(); 86 | } 87 | get value() { 88 | return this.editor.getValue(); 89 | } 90 | set value(value = this.defaultValue) { 91 | this.$hasValue = true; 92 | if (value instanceof Object) value = JSON.stringify(value, 0, 2); 93 | this.editor.setValue(value === void 0 ? '' : String(value)); 94 | this.refresh(); 95 | } 96 | get styleSheet() { 97 | return ` 98 | :scope { 99 | position: relative; 100 | border: 1px solid #c0ccda; 101 | border-radius: 5px; 102 | overflow: hidden; 103 | &:hover { border-color: #8492a6; } 104 | &.focus { border-color: #20a0ff; } 105 | --width: 600px; 106 | --height: auto; 107 | .CodeMirror { 108 | border-radius: 5px; 109 | font-family: var(--monospace); 110 | font-size: 12px; 111 | width: var(--width); 112 | height: var(--height); 113 | min-height: 200px; 114 | } 115 | &.readonly { 116 | .CodeMirror-cursor { 117 | display: none; 118 | } 119 | .CodeMirror { 120 | background-color: #eff2f7; 121 | } 122 | border-color: #d3dce6; 123 | color: #bbb; 124 | cursor: not-allowed; 125 | } 126 | .CodeMirror-linenumber { 127 | padding-left: 10px; 128 | padding-right: 10px; 129 | } 130 | .CodeMirror-cursor { 131 | border-left: 1px solid black; 132 | border-right: none; 133 | width: 0; 134 | } 135 | .CodeMirror-search-match { 136 | background: gold; 137 | border-top: 1px solid orange; 138 | border-bottom: 1px solid orange; 139 | box-sizing: border-box; 140 | opacity: .5; 141 | } 142 | } 143 | `; 144 | } 145 | } 146 | 147 | resolve(InputMarkdown); 148 | 149 | }); 150 | }); 151 | 152 | }); 153 | 154 | -------------------------------------------------------------------------------- /src/components/MainWithDefault.js: -------------------------------------------------------------------------------- 1 | def((Item) => { 2 | 3 | class MainWithDefaultItem extends Item { 4 | get template() { 5 | return ` 6 |
    7 |
    {text}
    8 |
    {subText}
    9 |
    10 | `; 11 | } 12 | init() { 13 | if (this.currentKey === this.key) this.element.classList.add('active'); 14 | this.text = this.title || this.key.replace(/([^/]{2})[^/]{3,}\//g, '$1../'); 15 | this.subText = this.key || this.href; 16 | if (this.icon && this.icon !== 'about:blank') { 17 | this.element.style.setProperty('--icon', `url("${this.icon}")`); 18 | this.element.dataset.char = ''; 19 | } else { 20 | this.element.dataset.char = this.text ? this.text.match(/[^-]*$/g)[0].trimLeft()[0] : 'X'; 21 | } 22 | } 23 | onClick() { 24 | let { href, target, module = 'list', key, where = {}, params = {} } = this; 25 | let tasks = []; 26 | if (this['@where']) tasks.push(api([ this.key, this['@where'] ]).then(result => (where = result))); 27 | if (this['@params']) tasks.push(api([ this.key, this['@params'] ]).then(result => (params = result))); 28 | return Promise.all(tasks).then(() => { 29 | where = JSON.stringify(where); 30 | params = JSON.stringify(params); 31 | if (href) return open(href, target); 32 | return this.depot.go({ args: { module, key, params, where }, target }); 33 | }); 34 | } 35 | get styleSheet() { 36 | let radius = 40; 37 | let padding = 12; 38 | return ` 39 | :scope { 40 | position: relative; 41 | border: 1px solid #e6e6e6; 42 | min-width: 300px; 43 | flex: auto; 44 | border-radius: 6px; 45 | display: inline-block; 46 | box-sizing: border-box; 47 | margin: 1em; 48 | line-height: 20px; 49 | font-size: 14px; 50 | list-style: none; 51 | white-space: nowrap; 52 | padding: ${padding}px; 53 | padding-left: ${radius + padding * 2}px; 54 | transition: all 200ms ease; 55 | color: #324057; 56 | overflow: hidden; 57 | cursor: pointer; 58 | box-shadow: 0 1px 3px rgba(0,37,55,.05); 59 | background: #fff; 60 | &:hover { 61 | transform: translateY(-4px); 62 | border-color: transparent; 63 | box-shadow: 0 9px 18px 0 rgba(0,0,0,.24); 64 | } 65 | &::before { 66 | position: absolute; 67 | content: attr(data-char); 68 | border-radius: 100%; 69 | background-color: #58B7FF; 70 | width: ${radius}px; 71 | height: ${radius}px; 72 | line-height: ${radius}px; 73 | font-size: 16px; 74 | color: #fff; 75 | text-align: center; 76 | top: ${padding}px; 77 | left: ${padding}px; 78 | } 79 | &::after { 80 | position: absolute; 81 | content: ''; 82 | filter: brightness(2); 83 | background-image: var(--icon); 84 | background-position: center; 85 | background-repeat: no-repeat; 86 | background-size: 24px 24px; 87 | width: ${radius}px; 88 | height: ${radius}px; 89 | color: #fff; 90 | text-align: center; 91 | top: ${padding}px; 92 | left: ${padding}px; 93 | } 94 | dd { 95 | margin: 0; 96 | color: #c0ccda; 97 | transform: scale(.8); 98 | transform-origin: left; 99 | } 100 | a { 101 | font-weight: 500; 102 | color: #1F2D3D; 103 | display: inline-block; 104 | vertical-align: middle; 105 | text-overflow: ellipsis; 106 | overflow: hidden; 107 | } 108 | } 109 | @media (max-width: 600px) { 110 | :scope { 111 | margin: 0 0 1em 0; 112 | display: block; 113 | width: auto; 114 | } 115 | } 116 | `; 117 | } 118 | } 119 | 120 | return class extends Jinkela { 121 | init() { 122 | Object.defineProperty(this.depot, 'main', { configurable: true, value: this }); 123 | while (this.element.firstChild) this.element.firstChild.remove(); 124 | let { key, session, schemes } = this.depot; 125 | let { permissions = [] } = session; 126 | schemes = schemes.filter(scheme => { 127 | if (/(?:^|\/):/.test(scheme.key)) return false; 128 | if (scheme.hidden) return false; 129 | if (!scheme.title) return; 130 | if (!scheme.require) return true; 131 | return scheme.require.some((dep => ~permissions.indexOf(dep))); 132 | }); 133 | MainWithDefaultItem.cast(schemes, { currentKey: key, depot: this.depot }).to(this); 134 | } 135 | get styleSheet() { 136 | return ` 137 | :scope { 138 | background: #f9f9f9; 139 | list-style: none; 140 | padding: 1em; 141 | height: 100%; 142 | display: flex; 143 | flex-flow: wrap; 144 | box-sizing: border-box; 145 | } 146 | `; 147 | } 148 | }; 149 | 150 | }); 151 | -------------------------------------------------------------------------------- /src/components/InputAutoComplete.js: -------------------------------------------------------------------------------- 1 | def((InputString, Item) => { 2 | class ListItem extends Item { 3 | init() { 4 | this.element.addEventListener('mousedown', e => this.onMousedown(e)); 5 | this.element.addEventListener('mouseup', e => this.onMouseup(e)); 6 | } 7 | onMousedown(e) { e.preventDefault(); } 8 | onMouseup() { 9 | this.element.dispatchEvent(new CustomEvent('item:select', { 10 | bubbles: true, 11 | detail: this 12 | })); 13 | } 14 | get template() { 15 | return '
  • {value}
  • '; 16 | } 17 | } 18 | 19 | class NoResult extends Jinkela { 20 | get template() { 21 | return ` 22 |

    没有任何搜索结果

    23 | `; 24 | } 25 | get styleSheet() { 26 | return ` 27 | :scope { 28 | text-align: center; 29 | padding: .4em .5em; 30 | margin: 0; 31 | } 32 | `; 33 | } 34 | } 35 | 36 | return class extends Jinkela { 37 | get InputString() { return InputString; } 38 | get NoResult() { return NoResult; } 39 | init() { 40 | this.noResult = false; 41 | this.element.addEventListener('item:select', e => { 42 | this.input.value = e.detail.value; 43 | this.value = e.detail.key; 44 | this.onBlur(); 45 | }); 46 | this.onInput = debounce(() => { 47 | this.list.innerHTML = ''; 48 | this.noResult = false; 49 | let { resolvedKey } = depot; 50 | api([resolvedKey, this.api], { query: { q: this.input.value } }).then(raw => { 51 | if (!(raw instanceof Array)) throw new Error(`返回必须是数组:${raw}`); 52 | if (!raw.length) { 53 | this.noResult = true; 54 | } else { 55 | raw.forEach(item => new ListItem(item).to(this.list)); 56 | } 57 | this.element.setAttribute('popup', ''); 58 | }); 59 | }, 1000, true); 60 | } 61 | move(step) { 62 | if (!step) return; 63 | let $current = this.list.querySelector('.active'); 64 | if ($current) { 65 | $current.classList.remove('active'); 66 | if (step > 0) { 67 | while (step--) { 68 | $current = $current.nextElementSibling 69 | ? $current.nextElementSibling 70 | : this.list.firstElementChild; 71 | } 72 | } else { 73 | while (step++) { 74 | $current = $current.previousElementSibling 75 | ? $current.previousElementSibling 76 | : this.list.lastElementChild; 77 | } 78 | } 79 | } 80 | if (!$current) $current = this.list.firstElementChild; 81 | $current.classList.add('active'); 82 | this.ensureVisible(); 83 | } 84 | onKeydown(e) { 85 | switch (e.keyCode) { 86 | case 40: return this.move(1); 87 | case 38: return this.move(-1); 88 | case 13: return this.enter(e); 89 | } 90 | } 91 | onFocus() { this.onInput(); } 92 | onBlur() { 93 | let $current = this.list.querySelector('active'); 94 | if ($current) $current.classList.remove('active'); 95 | this.onInput.cancel(); 96 | this.element.removeAttribute('popup'); 97 | } 98 | ensureVisible() { 99 | let $current = this.list.querySelector('.active'); 100 | if ($current) { 101 | let viewTop = this.list.scrollTop; 102 | let viewHeight = this.list.getBoundingClientRect().height; 103 | let viewBottom = viewHeight + viewTop; 104 | let itemTop = $current.offsetTop; 105 | let itemHeight = $current.getBoundingClientRect().height; 106 | let itemBottom = itemTop + itemHeight; 107 | if (itemTop < viewTop) this.list.scrollTop = itemTop; 108 | if (itemBottom > viewBottom) this.list.scrollTop = itemTop - viewHeight + itemHeight; 109 | } 110 | } 111 | get template() { 112 | return ` 113 |
    114 | 121 |
    122 |
      123 | 124 |
      125 |
      126 | `; 127 | } 128 | get styleSheet() { 129 | return ` 130 | :scope { 131 | position: relative; 132 | > div { 133 | visibility: hidden; 134 | opacity: 0; 135 | position: absolute; 136 | top: 100%; 137 | right: 0; 138 | left: 0; 139 | z-index: 99; 140 | background: #fff; 141 | border: 1px solid #20a0ff; 142 | border-top: none; 143 | border-radius: 5px; 144 | border-top-left-radius: 0; 145 | border-top-right-radius: 0; 146 | margin: 0; 147 | padding: 0; 148 | list-style: none; 149 | transition: border-color ease-in-out .15s, opacity .15s ease, visibility .15s ease; 150 | max-height: 200px; 151 | overflow: scroll; 152 | > ul li { 153 | padding: .4em .5em; 154 | cursor: pointer; 155 | &:hover { 156 | background-color: rgba(25,137,250,.08); 157 | } 158 | &.active { 159 | background-color: rgba(25,137,250,.08); 160 | } 161 | } 162 | } 163 | &[popup] { 164 | > div { 165 | visibility: visible; 166 | opacity: 1; 167 | } 168 | > input { 169 | border-bottom: none; 170 | border-bottom-left-radius: 0; 171 | border-bottom-right-radius: 0; 172 | } 173 | } 174 | } 175 | `; 176 | } 177 | }; 178 | }); 179 | 180 | -------------------------------------------------------------------------------- /src/components/Frame.js: -------------------------------------------------------------------------------- 1 | def((Output, FrameNav, FrameAside, FrameMain) => { 2 | 3 | class FrameLogo extends Jinkela { 4 | init() { 5 | let { logo = 'Duang' } = config; 6 | if (typeof logo === 'string') logo = { component: 'HTML', args: { html: logo } }; 7 | new Output(logo).to(this); 8 | this.element.addEventListener('click', () => depot.update({}, true)); 9 | } 10 | get styleSheet() { 11 | return ` 12 | .folded :scope { 13 | width: 50px; 14 | > * { 15 | transform: scale(0); 16 | } 17 | } 18 | @media (max-width: 600px) { :scope { display: none; } } 19 | :scope { 20 | white-space: nowrap; 21 | overflow: hidden; 22 | transition: width 200ms ease; 23 | height: 50px; 24 | width: 230px; 25 | line-height: 50px; 26 | text-align: center; 27 | background: #1d8ce0; 28 | color: #fff; 29 | font-weight: 500; 30 | cursor: pointer; 31 | > * { 32 | display: inline-block; 33 | transition: transform 200ms ease; 34 | } 35 | &:hover { 36 | opacity: .95; 37 | } 38 | } 39 | `; 40 | } 41 | } 42 | 43 | class MenuEntry extends Jinkela { 44 | click(event) { 45 | event.xIsFromAside = true; 46 | this.element.dispatchEvent(new CustomEvent('menutoggleclick', { bubbles: true })); 47 | } 48 | get template() { 49 | return ` 50 |
      51 | 52 | 53 | 54 | 55 | 56 |
      57 | `; 58 | } 59 | get styleSheet() { 60 | return ` 61 | @media (min-width: 600px) { :scope { display: none; } } 62 | :scope { 63 | height: 50px; 64 | position: absolute; 65 | z-index: 1; 66 | > svg { 67 | cursor: pointer; 68 | position: absolute; 69 | margin: auto; 70 | left: 1em; 71 | top: 0; 72 | bottom: 0; 73 | } 74 | } 75 | `; 76 | } 77 | } 78 | 79 | return class extends Jinkela { 80 | get FrameMain() { return FrameMain; } 81 | static get frameLogo() { 82 | let value = [ new FrameLogo(), new MenuEntry() ]; 83 | Object.defineProperty(this, 'frameLogo', { value, configurable: true }); 84 | return value; 85 | } 86 | static get frameNav() { 87 | let value = new FrameNav(); 88 | Object.defineProperty(this, 'frameNav', { value, configurable: true }); 89 | return value; 90 | } 91 | static get frameAside() { 92 | let value = new FrameAside(); 93 | Object.defineProperty(this, 'frameAside', { value, configurable: true }); 94 | return value; 95 | } 96 | init() { 97 | if (!depot.config.noFrame) { 98 | this.frameLogo = this.constructor.frameLogo; 99 | this.frameNav = this.constructor.frameNav; 100 | this.frameAside = this.constructor.frameAside; 101 | } 102 | } 103 | get main() { return this.$value; } 104 | set main(value) { 105 | if (this.$value) this.$value.element.remove(); 106 | value.to(this.frameMain); 107 | this.$value = value; 108 | } 109 | menuToggleClick() { 110 | this.element.classList.toggle('show-as-slide'); 111 | } 112 | click(event) { 113 | if (event.xIsFromAside) return; 114 | this.element.classList.remove('show-as-slide'); 115 | } 116 | get template() { 117 | return ` 118 |
      119 |
      120 | 121 | 122 |
      123 |
      124 | 125 | 126 |
      127 |
      128 | `; 129 | } 130 | get styleSheet() { 131 | let value = ` 132 | html { height: 100%; } 133 | body { 134 | height: 100%; 135 | margin: 0; 136 | color: #5e6d82; 137 | font-size: 14px; 138 | font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; 139 | -webkit-font-smoothing: antialiased; 140 | --spacing: 1.5em; 141 | } 142 | a { 143 | text-decoration: none; 144 | color: inherit; 145 | } 146 | select { outline: none; } 147 | input { outline: none; } 148 | :scope { 149 | display: flex; 150 | > section { 151 | display: flex; 152 | overflow: hidden; 153 | flex-direction: column; 154 | } 155 | height: 100%; 156 | } 157 | @media (max-width: 600px) { 158 | :scope > .aside { 159 | height: 50px; 160 | min-width: 50px; 161 | position: absolute; 162 | overflow: visible; 163 | z-index: 2; 164 | transition: height 200ms ease; 165 | } 166 | :scope.show-as-slide > .aside { 167 | height: 100%; 168 | } 169 | } 170 | `; 171 | if (/\bWindows\b/i.test(navigator.userAgent)) { 172 | value += ` 173 | ::selection { background-color: rgba(0,0,0,.2); } 174 | ::-moz-selection { background-color: rgba(0,0,0,.2); } 175 | ::-webkit-scrollbar { width:10px; height: 10px; background: rgba(0,0,0,.2); } 176 | ::-webkit-scrollbar-thumb { background: rgba(0,0,0,.5); border-radius: 5px; } 177 | ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.8); } 178 | ::-webkit-scrollbar-corner { background: rgba(0,0,0,.2); } 179 | ::-webkit-color-swatch { border:none; } 180 | `; 181 | } 182 | return value; 183 | } 184 | }; 185 | 186 | }); 187 | -------------------------------------------------------------------------------- /src/components/InputSuggestion.js: -------------------------------------------------------------------------------- 1 | def((InputString, OutputHTML, Item) => { 2 | 3 | class ListItem extends Item { 4 | 5 | onClick() { 6 | this.element.dispatchEvent(new CustomEvent('item:select', { 7 | bubbles: true, 8 | detail: this 9 | })); 10 | } 11 | 12 | init() { 13 | new OutputHTML(this).to(this); 14 | } 15 | 16 | get template() { 17 | return '
    • '; 18 | } 19 | 20 | } 21 | 22 | return class extends Jinkela { 23 | 24 | get InputString() { return InputString; } 25 | 26 | selectItem(event) { 27 | this.value = event.detail.value; 28 | this.input.element.blur(); 29 | } 30 | 31 | beforeParse(params) { 32 | this.inputHandler = debounce(this.inputHandler, 300, true); 33 | this.width = params.width = params.width || 300; 34 | 35 | this.input = new InputString(params); 36 | this.input.element.addEventListener('keydown', event => (event.shouldNotSubmit = true)); 37 | this.input.element.addEventListener('focus', event => this.inputHandler(event)); 38 | this.input.element.addEventListener('blur', event => this.blur(event)); 39 | this.input.element.addEventListener('input', event => this.inputHandler(event)); 40 | } 41 | 42 | init() { 43 | this.element.insertBefore(this.input.element, this.element.firstChild); 44 | if (this.width !== void 0) this.element.style.width = this.width; 45 | if (this.emptyTip) this.list.dataset.tip = this.emptyTip; 46 | this.element.addEventListener('item:select', this.selectItem.bind(this)); 47 | if (!this.$hasValue) this.value = void 0; 48 | } 49 | 50 | inputHandler() { 51 | if (this.readonly) return; 52 | let { resolvedKey } = depot; 53 | return api([resolvedKey, this.api], { query: { q: this.value } }).then(raw => { 54 | if (!(raw instanceof Array)) throw new Error(`返回必须是数组,然而却是 ${raw}`); 55 | this.list.innerHTML = ''; 56 | if (!raw.length) { 57 | if (this.emptyTip) { 58 | this.element.setAttribute('popup', ''); 59 | } else { 60 | this.element.removeAttribute('popup'); 61 | } 62 | } else { 63 | raw.forEach(item => new ListItem(item).to(this.list)); 64 | this.element.setAttribute('popup', ''); 65 | } 66 | }); 67 | } 68 | 69 | get value() { return this.input.value; } 70 | set value(value = this.defaultValue) { 71 | if (typeof value !== 'string') value = ''; 72 | this.$hasValue = true; 73 | this.$value = value; 74 | if (this.input) this.input.value = value; 75 | } 76 | 77 | move(step) { 78 | if (!step) return; 79 | let $current = this.list.querySelector('.active'); 80 | if ($current) { 81 | $current.classList.remove('active'); 82 | if (step > 0) { 83 | while (step--) { 84 | $current = $current.nextElementSibling 85 | ? $current.nextElementSibling 86 | : this.list.firstElementChild; 87 | } 88 | } else { 89 | while (step++) { 90 | $current = $current.previousElementSibling 91 | ? $current.previousElementSibling 92 | : this.list.lastElementChild; 93 | } 94 | } 95 | } 96 | if (!$current) $current = this.list.firstElementChild; 97 | $current.classList.add('active'); 98 | this.ensureVisible(); 99 | } 100 | 101 | enter() { 102 | let active = this.list.querySelector('.active'); 103 | if (active) active.click(); 104 | } 105 | 106 | onKeydown(e) { 107 | switch (e.keyCode) { 108 | case 40: return this.move(1); 109 | case 38: return this.move(-1); 110 | case 13: return this.enter(); 111 | } 112 | } 113 | 114 | blur() { 115 | let $current = this.list.querySelector('active'); 116 | if ($current) $current.classList.remove('active'); 117 | this.inputHandler.cancel(); 118 | this.element.removeAttribute('popup'); 119 | } 120 | 121 | ensureVisible() { 122 | let $current = this.list.querySelector('.active'); 123 | if ($current) { 124 | let viewTop = this.list.scrollTop; 125 | let viewHeight = this.list.getBoundingClientRect().height; 126 | let viewBottom = viewHeight + viewTop; 127 | let itemTop = $current.offsetTop; 128 | let itemHeight = $current.getBoundingClientRect().height; 129 | let itemBottom = itemTop + itemHeight; 130 | if (itemTop < viewTop) this.list.scrollTop = itemTop; 131 | if (itemBottom > viewBottom) this.list.scrollTop = itemTop - viewHeight + itemHeight; 132 | } 133 | } 134 | 135 | // TODO: ul 弹层挂到 body 上,防止被 overflow hidden 136 | get template() { 137 | return ` 138 |
      139 |
      140 |
        141 |
        142 |
        143 | `; 144 | } 145 | 146 | get styleSheet() { 147 | return ` 148 | :scope { 149 | position: relative; 150 | > div { 151 | visibility: hidden; 152 | opacity: 0; 153 | position: absolute; 154 | top: 100%; 155 | right: 0; 156 | left: 0; 157 | z-index: 99; 158 | background: #fff; 159 | border: 1px solid #20a0ff; 160 | border-top: none; 161 | border-radius: 0 0 5px 5px; 162 | transition: border-color ease .15s, opacity .15s ease, visibility .15s ease; 163 | max-height: 200px; 164 | overflow: scroll; 165 | > ul { 166 | margin: 0; 167 | padding: 0; 168 | font-size: 12px; 169 | li { 170 | padding: .2em .5em; 171 | cursor: pointer; 172 | list-style: none; 173 | &:hover { 174 | background-color: rgba(25,137,250,.08); 175 | } 176 | &.active { 177 | background-color: rgba(25,137,250,.08); 178 | } 179 | } 180 | &:empty::before { 181 | content: attr(data-tip); 182 | display: block; 183 | opacity: .5; 184 | text-align: center; 185 | padding: .5em; 186 | } 187 | } 188 | } 189 | &[popup] { 190 | > div { 191 | visibility: visible; 192 | opacity: 1; 193 | } 194 | > input { 195 | border-bottom: none; 196 | border-bottom-left-radius: 0; 197 | border-bottom-right-radius: 0; 198 | } 199 | } 200 | } 201 | `; 202 | } 203 | }; 204 | 205 | }); 206 | --------------------------------------------------------------------------------