├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
123 |
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 |
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 |
--------------------------------------------------------------------------------