├── .gitignore ├── Makefile ├── README.md ├── backend ├── .babelrc ├── Makefile ├── app.js ├── config.js ├── controllers │ ├── changelog.js │ ├── domain.js │ ├── domains.js │ ├── key.js │ ├── privilege.js │ ├── privilegelist.js │ ├── publish.js │ ├── publishdata.js │ ├── publishlog.js │ ├── republish.js │ ├── searchuser.js │ ├── squash.js │ └── user.js ├── lib │ ├── api.js │ ├── authorize.js │ ├── crayfish.js │ ├── createby.js │ ├── database.js │ ├── errorlog.js │ └── publishProvider │ │ ├── index.js │ │ ├── qiniu.js │ │ └── storage.js ├── package.json ├── schemes.sql └── users.js ├── docs ├── image │ ├── create.png │ ├── privilege.png │ └── screen.png ├── index.html ├── intro │ ├── README.md │ └── index.html ├── manual │ ├── README.md │ └── index.html └── package.json ├── frontend ├── .eslintrc ├── Makefile ├── package.json └── src │ ├── changelog │ ├── controlbar.js │ ├── filterbar.js │ ├── index.html.php │ ├── index.js │ ├── list.js │ └── main.js │ ├── components │ ├── button.js │ ├── caption.js │ ├── confirmpanel.js │ ├── container.js │ ├── datatable.js │ ├── errorpanel.js │ ├── form │ │ ├── formpanel.js │ │ └── formtextfield.js │ ├── frame │ │ ├── frame.js │ │ ├── framemenu.js │ │ ├── framepanel.js │ │ └── header.js │ ├── item.js │ ├── listitem.js │ ├── page.js │ ├── successpanel.js │ └── textfield.js │ ├── default │ ├── default.js │ ├── index.html.php │ └── index.js │ ├── domain │ ├── actionbar │ │ └── actionbar.js │ ├── controlbar.js │ ├── ctrls │ │ ├── boolean.js │ │ ├── datepicker.js │ │ ├── json.js │ │ ├── number.js │ │ ├── percent.js │ │ ├── textarea.js │ │ └── textfield.js │ ├── domainpanel.js │ ├── filterbar.js │ ├── index.html.php │ ├── index.js │ ├── list.js │ ├── main.js │ ├── publishingpanel.js │ └── typeselector.js │ ├── domains │ ├── actionbar.js │ ├── controlbar.js │ ├── filterbar.js │ ├── index.html.php │ ├── index.js │ ├── list.js │ ├── main.js │ └── panel.js │ ├── favicon.ico │ ├── index │ ├── index.html.php │ ├── index.js │ └── welcome.js │ ├── metas.inc │ ├── privilege │ ├── actionbar.js │ ├── controlbar.js │ ├── filterbar.js │ ├── index.html.php │ ├── index.js │ ├── list.js │ ├── main.js │ └── panel.js │ ├── publishlog │ ├── controlbar.js │ ├── filterbar.js │ ├── index.html.php │ ├── index.js │ ├── list.js │ └── main.js │ └── services │ ├── dialog.js │ ├── getonce.js │ └── user.js ├── package.json └── service.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.swp 3 | *.log 4 | node_modules 5 | dist 6 | /fe.crayfish 7 | !.gitignore 8 | !.eslintrc 9 | !.babelrc 10 | /tempdata -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | @cd frontend && make install && make build as dist 3 | @cd backend && make node_modules && make init-database 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crayfish 2 | 3 | > Crayfish 是一个将配置发布到 CDN的前端配置管理系统。前端程序直接从 CDN 加载,对后端服务器不会造成任何压力,可被广泛应用于文案、开关管理等。 4 | 5 | [文档](https://elemefe.github.io/crayfish/) 6 | 7 | ## 特点 8 | * 配置在 CDN 上,没有服务器性能瓶颈 9 | * 健壮的权限机制,可以从角色和项目两个维度管理权限 10 | * 配置是有数据类型的,让前端程序可以更准确地使用 11 | 12 | ## 快速开始 13 | 14 | ### 1. 依赖 15 | 16 | * Node Version: >= 5 17 | * MySQL Version: >= 5.6 18 | 19 | **注: MySQL 5.7 请关闭严格模式:。** 20 | 21 | ### 2. 安装与运行 22 | 23 | 首先为你的 MySQL 准备好一个 `crayfish` 数据库。可以在 `config.js` 和 `MAKEFILE` 中修改数据库。 24 | 25 | 安装并启动后端,如果没有任何错误,则为运行成功: 26 | 27 | ```shell 28 | cd backend 29 | make node_modules 30 | make init-database 31 | make run 32 | ``` 33 | 34 | Crayfish 的前端使用 [jinkela](https://github.com/YanagiEiichi/jinkela) 开发,并使用 [webspoon](https://github.com/ElemeFE/webspoon) 构建。 35 | 36 | 启动前端,前端会被后端的 `koa-static` 中间件加载: 37 | 38 | ```shell 39 | cd frontend 40 | make install 41 | make dev 42 | ``` 43 | 44 | 之后访问: 即可看到运行成功的 Crayfish。 45 | 46 | 如果需要在生产环境使用,在根目录 `make build` 即可。 47 | 48 | ## 截图 49 | ![Screenshot](docs/image/screen.png) 50 | 51 | -------------------------------------------------------------------------------- /backend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": ["transform-decorators-legacy"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | node_modules: package.json 2 | @npm install 3 | 4 | init-database: 5 | @cat schemes.sql | mysql -uroot crayfish 6 | 7 | run: 8 | @ \ 9 | lsof -i:8100 -sTCP:LISTEN | awk 'NR>1{print $$2}' | xargs kill -9; \ 10 | ./node_modules/.bin/babel-node app.js -r 0; \ 11 | 12 | dev: node_modules 13 | @nodemon --exec make run -------------------------------------------------------------------------------- /backend/app.js: -------------------------------------------------------------------------------- 1 | const koa = new (require('koa'))(); 2 | 3 | koa.use(require('koa-bodyparser')()); 4 | koa.use(require('./lib/errorlog')); 5 | koa.use(require('./lib/crayfish')); 6 | koa.use(require('./lib/api')); 7 | koa.use(require('koa-static')('../frontend/dist')); 8 | 9 | koa.listen(8100); -------------------------------------------------------------------------------- /backend/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mysql: { 3 | connectionLimit: 10, 4 | host: 'localhost', 5 | port: '3306', 6 | user: 'root', 7 | password: '', 8 | database: 'crayfish' 9 | }, 10 | publishMockRoot: '../tempdata' 11 | } -------------------------------------------------------------------------------- /backend/controllers/changelog.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | const createby = require('../lib/createby.js'); 3 | 4 | module.exports = class { 5 | static url = '/changelog/:keyId'; 6 | 7 | @authorize([ 'EDIT' ]) 8 | static async get(ctx) { 9 | let { keyId } = ctx.params; 10 | let [ key ] = await ctx.sql('SELECT `is_delete` FROM `keys` WHERE `id` = ?', [ keyId ]); 11 | if (!key || key.is_delete) throw { status: 404, name: 'KEY_NOT_FOUND', message: 'key is not found' }; 12 | let result = await ctx.sql(' \ 13 | SELECT value, create_at, create_by \ 14 | FROM `changelog` \ 15 | WHERE `key_id` = ? \ 16 | ORDER BY `create_at` DESC \ 17 | LIMIT 30 \ 18 | ', [ keyId ]); 19 | 20 | await createby(result); 21 | 22 | result.forEach(item => { 23 | try { item.value = JSON.parse(item.value); } catch(error) {}; 24 | }); 25 | 26 | ctx.body = result; 27 | } 28 | 29 | }; 30 | -------------------------------------------------------------------------------- /backend/controllers/domain.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | module.exports = class { 4 | static url = '/domains/:domain'; 5 | 6 | static async getKeysByDomain(ctx) { 7 | let { domain } = ctx.params; 8 | let result = await ctx.sql(' \ 9 | SELECT `id`, `domain`, `path`, `name`, `comment`, `value`, `type`, `create_by`, `create_at`, `update_at`, `publish_at` \ 10 | FROM `keys` WHERE `is_delete` = 0 AND `domain` = ? \ 11 | ', [domain]); 12 | result.forEach(item => { 13 | try { item.value = JSON.parse(item.value); } catch(error) {} 14 | }); 15 | return result; 16 | } 17 | 18 | @authorize(['EDIT']) 19 | static async get(ctx) { 20 | ctx.body = await this.getKeysByDomain(ctx); 21 | } 22 | 23 | @authorize(['CHANGE']) 24 | static async post(ctx) { 25 | let { domain } = ctx.params; 26 | let { path = '', name = '', comment = '', value = '', type = 1 } = ctx.request.body; 27 | path = path.trim(); 28 | name = name.trim(); 29 | if (!path) throw { status: 400, name: 'ERROR_PARAMS', message: 'Path 不能为空' }; 30 | if (path[0] !== '/') throw { status: 400, name: 'ERROR_PARAMS', message: 'Path 必须以「/」开头' }; 31 | if (/\/\.*(\/|$)/.test(path)) throw { status: 400, name: 'ERROR_PARAMS', message: 'Path 的每个部分不能为空或者只有「.」' }; 32 | if (!name) throw { status: 400, name: 'ERROR_PARAMS', message: 'Name 不能为空' }; 33 | value = JSON.stringify(value); 34 | if (value.length > 10240) throw { status: 400, message: 'Value 不能大于 10240 个字符', name: 'VALUE_TOO_LONG' }; 35 | 36 | await ctx.sql.commit(async () => { 37 | 38 | let keys = await ctx.sql( 39 | 'SELECT 1 FROM `keys` WHERE `is_delete` = 0 AND `domain` = ? AND `path` = ? AND `name` = ? FOR UPDATE', 40 | [ domain, path, name ] 41 | ); 42 | if (keys.length) throw { status: 400, name: 'DUP', message: '记录已存在' }; 43 | 44 | await ctx.sql( 45 | 'INSERT INTO `keys` (`domain`, `path`, `name`, `comment`, `value`, `type`, `create_by`) VALUES (?)', 46 | [ [ domain, path, name, comment, value, type, ctx.user.id ] ] 47 | ); 48 | await ctx.sql( 49 | 'INSERT INTO `changelog` (`key_id`, `value`, `create_by`) VALUES (LAST_INSERT_ID(), ?, ?)', 50 | [ value, ctx.user.id ] 51 | ); 52 | 53 | }); 54 | 55 | ctx.status = 204; 56 | ctx.body = ''; 57 | } 58 | 59 | @authorize(['ADMIN']) 60 | static async delete(ctx) { 61 | let { domain } = ctx.params; 62 | let list = await this.getKeysByDomain(ctx); 63 | if (list.length) throw { status: 400, name: 'NOT_NULL', message: '请先删除该域名下的所有数据后再删除域名' }; 64 | try { 65 | await ctx.sql.commit('DELETE FROM `domains` WHERE `domain` = ?', [ domain ]); 66 | ctx.status = 204; 67 | ctx.body = ''; 68 | } catch (error) { 69 | throw error; 70 | } 71 | } 72 | 73 | }; 74 | -------------------------------------------------------------------------------- /backend/controllers/domains.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | module.exports = class { 4 | static url = '/domains'; 5 | 6 | @authorize() 7 | static async get(ctx) { 8 | let result = await ctx.sql('SELECT `id`, `domain`, `create_by`, `update_at`, `create_at` FROM `domains`'); 9 | result = result.filter(item => ctx.user.hasDomain(item.domain)); 10 | ctx.body = JSON.stringify(result); 11 | } 12 | 13 | @authorize(['ADMIN']) 14 | static async post(ctx) { 15 | let { domain } = ctx.request.body; 16 | try { 17 | await ctx.sql.commit({ 18 | 'INSERT INTO `domains` (`domain`, `create_by`) VALUES (?)': [ [ domain, ctx.user.id ] ] 19 | }); 20 | ctx.status = 204; 21 | ctx.body = ''; 22 | } catch (error) { 23 | if (error.code === 'ER_DUP_ENTRY') { 24 | throw { status: 400, name: 'DUP', message: '记录已存在' }; 25 | } 26 | } 27 | } 28 | 29 | }; 30 | -------------------------------------------------------------------------------- /backend/controllers/key.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | module.exports = class { 4 | static url = '/domains/:domain/:id'; 5 | 6 | @authorize([ 'CHANGE' ]) 7 | static async delete(ctx) { 8 | let { id, domain } = ctx.params; 9 | await ctx.sql.commit('UPDATE `keys` SET `is_delete` = 1 WHERE `id` = ?', [ id ]); 10 | ctx.status = 204; 11 | ctx.body = ''; 12 | } 13 | 14 | @authorize([ 'EDIT' ]) 15 | static async get(ctx) { 16 | let { id } = ctx.params; 17 | let result = await ctx.sql(' \ 18 | SELECT `id`, `domain`, `path`, `name`, `comment`, `value`, `type`, `create_by`, `create_at` \ 19 | FROM `keys` WHERE `id` = ? AND `is_delete` = 0 \ 20 | ', [ id ]); 21 | let key = result[0]; 22 | if (!key) throw { status: 404, name: 'KEY_NOT_FOUND', message: 'key is not found' }; 23 | try { key.value = JSON.parse(key.value); } catch(error) {}; 24 | ctx.body = key; 25 | } 26 | 27 | @authorize([ 'EDIT' ]) 28 | static async patch(ctx) { 29 | let { id } = ctx.params; 30 | let { body } = ctx.request; 31 | let change = Object.create(null); 32 | let count = [ 'value', 'comment' ].reduce((count, name) => { 33 | if (!(name in body)) return count; 34 | change[name] = body[name]; 35 | return count + 1; 36 | }, 0); 37 | if (count === 0) throw { status: 400, name: 'ERR', message: 'require `value` or/and `comment` in request body' }; 38 | if ('value' in change) { 39 | change.value = JSON.stringify(change.value); 40 | if (change.value.length > 10240) throw { 41 | status: 400, 42 | message: 'Value 不能大于 10240 个字符', 43 | name: 'VALUE_TOO_LONG' 44 | }; 45 | } 46 | await ctx.sql.commit(async () => { 47 | let [ key ] = await ctx.sql('SELECT `is_delete`, `value` FROM `keys` WHERE `id` = ?', [ id ]); 48 | if (!key || key.is_delete) throw { status: 404, name: 'KEY_NOT_FOUND', message: 'key is not found' }; 49 | await ctx.sql('UPDATE `keys` SET ? WHERE `id` = ?', [ change, id ]); 50 | if (key.value !== change.value) { 51 | await ctx.sql( 52 | 'INSERT INTO `changelog` (`key_id`, `value`, `create_by`) VALUES (?)', 53 | [ [ id, change.value, ctx.user.id ] ] 54 | ); 55 | } 56 | }); 57 | ctx.status = 204; 58 | ctx.body = ''; 59 | } 60 | 61 | }; 62 | -------------------------------------------------------------------------------- /backend/controllers/privilege.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | module.exports = class { 4 | static url = '/privilege/:domain/:id'; 5 | 6 | @authorize([ 'ADMIN' ]) 7 | static async delete(ctx) { 8 | let { domain, id } = ctx.params; 9 | await ctx.sql.commit({ 'DELETE FROM `privilege` WHERE `id` = ?': [ id ] }); 10 | ctx.body = null; 11 | } 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /backend/controllers/privilegelist.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | const createby = require('../lib/createby.js'); 3 | const users = require('../users'); 4 | 5 | module.exports = class { 6 | static url = '/privilege/:domain'; 7 | 8 | @authorize([ 'ADMIN' ]) 9 | static async get(ctx) { 10 | let { domain } = ctx.params; 11 | let result = await ctx.sql('SELECT `id`, `user_id`, `create_at` FROM `privilege` WHERE `domain` = ?', [ domain ]); 12 | await createby(result); 13 | ctx.body = JSON.stringify(result); 14 | } 15 | 16 | @authorize([ 'ADMIN' ]) 17 | static async post(ctx) { 18 | let { domain } = ctx.params; 19 | let { name } = ctx.request.body; 20 | let user = users.find((user) => user.name === name); 21 | if (!user) throw { status: 400, name: 'EMAIL_NOT_FOUND', message: '用户不存在' }; 22 | try { 23 | await ctx.sql.commit({ 24 | 'INSERT INTO `privilege` (`domain`, `user_id`, `create_by`) VALUES (?)': [ [ domain, user.id, ctx.user.id ] ] 25 | }); 26 | ctx.body = null; 27 | } catch (error) { 28 | if (error.code === 'ER_DUP_ENTRY') { 29 | throw { status: 400, name: 'DUP', message: '记录已存在' }; 30 | } 31 | } 32 | } 33 | 34 | }; 35 | -------------------------------------------------------------------------------- /backend/controllers/publish.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | const squash = require('./squash').squash; 3 | 4 | const publishProviders = require('../lib/publishProvider'); 5 | 6 | module.exports = class { 7 | static url = '/cdn/:domain/publish'; 8 | 9 | static escape(str) { 10 | return str.replace(/\u2028/g, '\\u2028') 11 | .replace(/\u2029/g, '\\u2029') 12 | .replace(/<\/script>/g, '<\\/script>'); 13 | } 14 | 15 | static log(ctx, list) { 16 | // Save to publishlog, Ignore publishing error 17 | return ctx.sql.commit(async () => { 18 | let { insertId: publishId } = await ctx.sql( 19 | 'INSERT INTO `publishlog` (`domain`, `create_by`) VALUES (?)', 20 | [ [ ctx.params.domain, ctx.user.id ] ] 21 | ); 22 | let publishedData = list.map(({ path, name, value }) => [ publishId, path, name, value ]); 23 | await ctx.sql('INSERT INTO `publishdata` (`publish_id`, `path`, `name`, `value`) VALUES ?', [ publishedData ]); 24 | }); 25 | } 26 | 27 | static async pushToCDN(domain, squashedList) { 28 | let contents = [ 29 | // JS format 30 | ...squashedList.map(({ path, value }) => { 31 | let key = domain + path; 32 | let contents = this.escape(JSON.stringify(value)); 33 | contents = `var crayfish=${contents};`; 34 | let mime = 'application/javascript; charset=utf-8'; 35 | return { key, contents, mime }; 36 | }), 37 | // JS format with ref support 38 | ...squashedList.map(({ path, value }) => { 39 | let key = domain + '@ref' + path; 40 | let contents = this.escape(JSON.stringify(value)); 41 | contents = `!function(){var t,e=document.currentScript||function(){var t=document.getElementsByTagName("script");return t.length?t[t.length-1]:void 0}();e&&(t=e.getAttribute("data-ref")),window[t||"crayfish"]=${contents}}();`; 42 | let mime = 'application/javascript; charset=utf-8'; 43 | return { key, contents, mime }; 44 | }), 45 | // JSON format 46 | ...squashedList.map(({ path, value }) => { 47 | let key = domain + '@json' + path; 48 | let contents = JSON.stringify(value); 49 | let mime = 'application/json'; 50 | return { key, contents, mime }; 51 | }) 52 | ]; 53 | let tasks = contents.map(({ key, contents, mime }) => 54 | Promise.all(publishProviders.map(provider => provider.put(key, contents, mime)))); 55 | let responses = await Promise.all(tasks); 56 | } 57 | 58 | static async publish(ctx, domain, list) { 59 | let $log = this.log(ctx, list); 60 | let result = ctx.sql.commit(async () => { 61 | let result = await this.pushToCDN(domain, squash(list)); 62 | await ctx.sql(' \ 63 | UPDATE `keys` SET `publish_at` = CURRENT_TIMESTAMP \ 64 | WHERE `is_delete` = 0 AND `domain` = ? AND `path` IN (?) \ 65 | ', [ domain, ctx.request.body ]); 66 | return result; 67 | }); 68 | await $log; 69 | return result; 70 | } 71 | 72 | @authorize([ 'PUBLISH' ]) 73 | static async post(ctx) { 74 | let { domain } = ctx.params; 75 | if (!(ctx.request.body instanceof Array)) throw { status: 400, message: '需要一个数组' }; 76 | if (ctx.request.body.length === 0) throw { status: 400, message: '请选择要发布的路径' }; 77 | let list = await ctx.sql(' \ 78 | SELECT `path`, `name`, `value` FROM `keys` WHERE `is_delete` = 0 AND `domain` = ? AND `path` IN (?) \ 79 | ', [ domain, ctx.request.body ]); 80 | ctx.body = await this.publish(ctx, domain, list); 81 | } 82 | 83 | }; 84 | -------------------------------------------------------------------------------- /backend/controllers/publishdata.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | module.exports = class { 4 | 5 | static url = '/publishdata/:id'; 6 | 7 | @authorize(['EDIT']) 8 | static async get(ctx) { 9 | let { id } = ctx.params; 10 | let result = await ctx.sql('SELECT `path`, `name`, `value` FROM `publishdata` WHERE `publish_id` = ?', [ id ]); 11 | ctx.body = result; 12 | } 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /backend/controllers/publishlog.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | const createby = require('../lib/createby.js'); 3 | 4 | module.exports = class { 5 | 6 | static url = '/publishlog/:domain'; 7 | 8 | @authorize(['EDIT']) 9 | static async get(ctx) { 10 | let { domain } = ctx.params; 11 | let result = await ctx.sql(' \ 12 | SELECT `id`, `domain`, `create_by`, `create_at` \ 13 | FROM `publishlog` \ 14 | WHERE `domain` = ? \ 15 | ORDER BY `create_at` DESC \ 16 | LIMIT 20 \ 17 | ', [ domain ]); 18 | await createby(result); 19 | ctx.body = result; 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /backend/controllers/republish.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | const PublishController = require('./publish'); 4 | 5 | module.exports = class { 6 | static url = '/publishlog/:domain/:id/republish'; 7 | 8 | @authorize(['PUBLISH']) 9 | static async post(ctx) { 10 | let { id } = ctx.params; 11 | let publishlog = await ctx.sql('SELECT `domain` FROM `publishlog` WHERE `id` = ?', [ id ]); 12 | if (!publishlog.length) throw { status: 404, name: 'NOT_FOUND', message: '无效的发布 id' }; 13 | let { domain } = ctx.params; 14 | if (publishlog[0].domain !== domain) throw { status: 400, name: 'DOMAIN_NOT_MATCH', message: '域名不匹配' }; 15 | let list = await ctx.sql('SELECT `path`, `name`, `value` FROM `publishdata` WHERE `publish_id` = ?', [ id ]); 16 | ctx.body = await PublishController.publish(ctx, domain, list); 17 | } 18 | 19 | }; -------------------------------------------------------------------------------- /backend/controllers/searchuser.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | const users = require('../users'); 3 | 4 | module.exports = class { 5 | static url = '/searchuser'; 6 | 7 | @authorize(['ADMIN']) 8 | static async get(ctx) { 9 | ctx.body = users; 10 | } 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /backend/controllers/squash.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | module.exports = class { 4 | static url = '/squash/:domain'; 5 | 6 | static squash(list) { 7 | // Build result map 8 | let result = Object.create(null); 9 | list.forEach(({ path, name, value, publish_at, update_at }) => { 10 | if (!(path in result)) result[path] = { path, value: {}, updateAt: new Date(0), publishAt: new Date(0) }; 11 | let obj = result[path]; 12 | if (update_at > obj.updateAt) obj.updateAt = update_at; 13 | if (publish_at > obj.publishAt) obj.publishAt = publish_at; 14 | try { 15 | obj.value[name] = JSON.parse(value); 16 | } catch (error) { 17 | obj.value[name] = value; 18 | } 19 | }); 20 | return Object.keys(result).reduce((reciever, path) => { 21 | reciever.push(result[path]); 22 | return reciever; 23 | }, []); 24 | } 25 | 26 | @authorize([]) 27 | static async get(ctx) { 28 | let { domain } = ctx.params; 29 | let list = await ctx.sql(' \ 30 | SELECT `path`, `name`, `value`, `publish_at`, `update_at` FROM `keys` WHERE `is_delete` = 0 AND `domain` = ? \ 31 | ', [ domain ]); 32 | ctx.body = this.squash(list); 33 | } 34 | 35 | }; 36 | -------------------------------------------------------------------------------- /backend/controllers/user.js: -------------------------------------------------------------------------------- 1 | const authorize = require('../lib/authorize'); 2 | 3 | module.exports = class { 4 | static url = '/user'; 5 | 6 | @authorize([ 7 | 'EDIT', 8 | 'CHANGE', 9 | 'PUBLISH', 10 | 'ADMIN' 11 | ]) 12 | static async get(ctx) { 13 | ctx.body = ctx.user; 14 | } 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /backend/lib/api.js: -------------------------------------------------------------------------------- 1 | const KoaRouter = require('koa-router'); 2 | const apiRouter = new KoaRouter({ prefix: '/api' }); 3 | const glob = require('glob'); 4 | const path = require('path'); 5 | // const config = require('../config'); 6 | const database = require('./database'); 7 | 8 | glob.sync(path.join(__dirname, '../controllers/*.js')).forEach(dep => { 9 | let controller = require(dep); 10 | for (let method of [ 'options', 'get', 'post', 'delete', 'put', 'patch' ]) { 11 | if (method in controller) { 12 | apiRouter[method](controller.url, database, async (ctx, next) => { 13 | return controller[method](ctx).then(next); 14 | }); 15 | } 16 | } 17 | }); 18 | 19 | module.exports = apiRouter.routes(); 20 | -------------------------------------------------------------------------------- /backend/lib/authorize.js: -------------------------------------------------------------------------------- 1 | const users = require('../users'); 2 | const _ = require('lodash'); 3 | 4 | const getUserByCtx = ctx => { 5 | let access_token = ctx.cookies.get('COFFEE_TOKEN'); 6 | return users[0] 7 | }; 8 | 9 | const decorator = (permissions) => { 10 | return function(base, name, desc) { 11 | let value = desc.value; 12 | desc.value = function(ctx, next) { 13 | let user = ctx.user = getUserByCtx(ctx); 14 | if (permissions && permissions.length && _.intersection(permissions, user.permissions).length < 0) { 15 | throw { status: 403, message: '并没有相应的权限', name: 'PERMISSION_FORBIDDEN' }; 16 | } 17 | let isAdmin = user.permissions.indexOf('ADMIN') >= 0; 18 | if (isAdmin) { 19 | ctx.user.hasDomain = (domain) => true; 20 | } else { 21 | let myDomains = user.domains 22 | ctx.user.hasDomain = (domain) => user.permissions.indexOf('ADMIN') >= 0 || user.domains.indexOf(domain) >= 0; 23 | } 24 | if (ctx.params.domain && !ctx.user.hasDomain(ctx.params.domain)) { 25 | throw { status: 403, message: '并没有该域名的权限', name: 'DOMAIN_FORBIDDEN' }; 26 | } 27 | return value.call(this, ctx, next); 28 | }; 29 | }; 30 | }; 31 | 32 | module.exports = decorator; 33 | -------------------------------------------------------------------------------- /backend/lib/crayfish.js: -------------------------------------------------------------------------------- 1 | const send = require('koa-send'); 2 | const path = require('path'); 3 | const config = require('../config'); 4 | 5 | module.exports = async (ctx, next) => { 6 | if (ctx.path.startsWith('/crayfish')) { 7 | let filePath = ctx.path.replace(/^\/crayfish/, ''); 8 | // console.log(this.response); 9 | await send(ctx, filePath, { root: config.publishMockRoot }); 10 | ctx.set('Content-Type', 'text/plain'); 11 | } else { 12 | await next(); 13 | } 14 | }; -------------------------------------------------------------------------------- /backend/lib/createby.js: -------------------------------------------------------------------------------- 1 | const users = require('../users'); 2 | 3 | module.exports = list => { 4 | let userSet = new Set(); 5 | let lookfor = { 'create_by': 'create_by', 'user_id': 'user' }; 6 | let lookforKeys = Object.keys(lookfor); 7 | list.forEach(item => { 8 | lookforKeys.filter(key => key in item).forEach(key => { 9 | userSet.add(item[key]); 10 | }); 11 | }); 12 | let userIdList = [...userSet]; 13 | let userList = users; 14 | let userMap = Object.create(null); 15 | userList.forEach(user => { 16 | if (!user) return; 17 | let { id, email, name } = user; 18 | userMap[user.id] = { id, email, name }; 19 | }); 20 | list.forEach(item => { 21 | lookforKeys.filter(key => key in item).forEach(key => { 22 | item[lookfor[key]] = userMap[item[key]] || null; 23 | }); 24 | }); 25 | } -------------------------------------------------------------------------------- /backend/lib/database.js: -------------------------------------------------------------------------------- 1 | const mysql = require('mysql'); 2 | const config = require('../config.js'); 3 | const pool = mysql.createPool(config.mysql, (err) => { 4 | console.log(err); 5 | }); 6 | const promisify = require('es6-promisify'); 7 | 8 | module.exports = async (ctx, next) => { 9 | let connection = await promisify(pool.getConnection, { thisArg: pool })(); 10 | let query = promisify(connection.query, { thisArg: connection }); 11 | ctx.sql = query; 12 | ctx.sql.commit = async (what, ...args) => { 13 | await query('START TRANSACTION'); 14 | try { 15 | switch (typeof what) { 16 | case 'function': 17 | await what(...args); 18 | break; 19 | case 'object': 20 | for (let str in what) await query(str, what[str]); 21 | break; 22 | default: 23 | await query(what, ...args); 24 | } 25 | await query('COMMIT'); 26 | } catch (error) { 27 | await query('ROLLBACK'); 28 | throw error; 29 | } 30 | }; 31 | try { 32 | return next(); 33 | } finally { 34 | connection.release(); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /backend/lib/errorlog.js: -------------------------------------------------------------------------------- 1 | module.exports = async (ctx, next) => { 2 | try { 3 | return await next(); 4 | } catch (error) { 5 | let { status , name = 'UNKNOWN_ERROR', message = '' } = error; 6 | if (!status) console.error(error.stack || error); 7 | ctx.status = status || 500; 8 | ctx.body = { name, message }; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /backend/lib/publishProvider/index.js: -------------------------------------------------------------------------------- 1 | const storage = require('./storage'); 2 | 3 | module.exports = [storage]; -------------------------------------------------------------------------------- /backend/lib/publishProvider/qiniu.js: -------------------------------------------------------------------------------- 1 | const qiniu = require('qiniu'); 2 | 3 | Object.assign(qiniu.conf, require('../config.js').qiniu); 4 | qiniu.conf.ACCESS_KEY = 'your qiniu access key'; 5 | qiniu.conf.SECRET_KEY = 'your qiniu secret key'; 6 | 7 | exports.put = (key, data, mime) => { 8 | let extra = new qiniu.io.PutExtra(); 9 | extra.mimeType = mime; 10 | let token = new qiniu.rs.PutPolicy('your bucket' + ':' + key).token(); 11 | return new Promise((resolve, reject) => { 12 | qiniu.io.put(token, key, data, extra, (err, ret) => err ? reject(err) : resolve(ret)); 13 | }).catch(({ error }) => { 14 | throw { status: 500, message: error }; 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /backend/lib/publishProvider/storage.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const promisify = require('es6-promisify'); 3 | const mkdirp = promisify(require('mkdirp')); 4 | const fs = require('fs'); 5 | const writeFile = promisify(fs.writeFile); 6 | 7 | const publishMockRoot = require('../../config').publishMockRoot || ''; 8 | 9 | exports.put = (key, data, mime) => { 10 | let filePath = path.join(publishMockRoot, key) + '.crayfish'; 11 | let parentPath = path.dirname(filePath); 12 | return mkdirp(parentPath) 13 | .then(() => writeFile(filePath, data)); 14 | }; 15 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "author": "", 7 | "license": "ISC", 8 | "dependencies": { 9 | "co": "^4.6.0", 10 | "es6-promisify": "^4.0.0", 11 | "glob": "^7.0.3", 12 | "koa": "^2.0.0", 13 | "koa-bodyparser": "^3.0.0", 14 | "koa-mount": "^1.3.0", 15 | "koa-router": "^7.0.1", 16 | "koa-static": "^3.0.0", 17 | "lodash": "^4.16.4", 18 | "mkdirp": "^0.5.1", 19 | "mysql": "^2.10.2", 20 | "request": "^2.72.0", 21 | "babel-cli": "^6.18.0" 22 | }, 23 | "devDependencies": { 24 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 25 | "babel-preset-es2015": "^6.18.0", 26 | "babel-preset-stage-2": "^6.18.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/schemes.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `domains` ( 2 | `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'physical primary key', 3 | `domain` varchar(64) NOT NULL COMMENT 'url domain', 4 | `create_by` int(11) NOT NULL COMMENT 'sso user_id', 5 | `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last updated time', 6 | `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `uk_domain` (`domain`), 9 | KEY `ix_create_at` (`create_at`), 10 | KEY `ix_update_at` (`update_at`) 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'domain list'; 12 | 13 | CREATE TABLE `keys` ( 14 | `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'physical primary key', 15 | `domain` varchar(64) NOT NULL COMMENT 'url domain', 16 | `path` varchar(64) NOT NULL COMMENT 'url path', 17 | `name` varchar(64) NOT NULL COMMENT 'field name', 18 | `comment` varchar(128) NOT NULL DEFAULT '' COMMENT 'comment', 19 | `value` varchar(10240) NOT NULL COMMENT 'json value', 20 | `type` tinyint(4) NOT NULL COMMENT 'the type of value', 21 | `create_by` int(11) NOT NULL COMMENT 'sso user_id', 22 | `is_delete` tinyint(4) NOT NULL DEFAULT 0 COMMENT 'delete flag', 23 | `publish_at` timestamp NOT NULL DEFAULT 0 COMMENT 'published time', 24 | `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last updated time', 25 | `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', 26 | PRIMARY KEY (`id`), 27 | KEY `ix_is_delete_domain_path_name` (`is_delete`, `domain`, `path`, `name`), 28 | KEY `ix_create_at` (`create_at`), 29 | KEY `ix_update_at` (`update_at`) 30 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'all keys and values'; 31 | 32 | CREATE TABLE `changelog` ( 33 | `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'physical primary key', 34 | `key_id` int(11) NOT NULL COMMENT 'which key is changed', 35 | `value` varchar(10240) NOT NULL COMMENT 'changed value', 36 | `create_by` int(11) NOT NULL COMMENT 'sso user_id', 37 | `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last updated time', 38 | `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', 39 | PRIMARY KEY (`id`), 40 | KEY `ix_key_id_create_at` (`key_id`, `create_at` DESC), 41 | KEY `ix_create_at` (`create_at`), 42 | KEY `ix_update_at` (`update_at`) 43 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'the change log of keys table'; 44 | 45 | CREATE TABLE `publishlog` ( 46 | `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'physical primary key', 47 | `domain` varchar(64) NOT NULL COMMENT 'associate with domains.doman', 48 | `create_by` int(11) NOT NULL COMMENT 'sso user_id', 49 | `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last updated time', 50 | `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', 51 | PRIMARY KEY (`id`), 52 | KEY `ix_domain` (`domain`), 53 | KEY `ix_create_by` (`create_by`), 54 | KEY `ix_create_at` (`create_at`), 55 | KEY `ix_update_at` (`update_at`) 56 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'publish log'; 57 | 58 | CREATE TABLE `publishdata` ( 59 | `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'physical primary key', 60 | `publish_id` int(11) NOT NULL COMMENT 'associate with publishlog.id', 61 | `path` varchar(64) NOT NULL COMMENT 'published url path', 62 | `name` varchar(64) NOT NULL COMMENT 'published field name', 63 | `value` varchar(10240) NOT NULL COMMENT 'published value', 64 | `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last updated time', 65 | `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', 66 | PRIMARY KEY (`id`), 67 | KEY `ix_publish_id` (`publish_id`), 68 | KEY `ix_create_at` (`create_at`), 69 | KEY `ix_update_at` (`update_at`) 70 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'the data of publish log'; 71 | 72 | CREATE TABLE `privilege` ( 73 | `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'physical primary key', 74 | `domain` varchar(64) NOT NULL COMMENT 'url domain', 75 | `user_id` int(11) NOT NULL COMMENT 'sso user_id', 76 | `create_by` int(11) NOT NULL COMMENT 'creator (sso user_id) of current record', 77 | `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last updated time', 78 | `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', 79 | PRIMARY KEY (`id`), 80 | KEY `ix_user_id` (`user_id`), 81 | UNIQUE KEY `uk_domain_user_id` (`domain`, `user_id`), 82 | KEY `ix_create_at` (`create_at`), 83 | KEY `ix_update_at` (`update_at`) 84 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'privilege for domain'; 85 | -------------------------------------------------------------------------------- /backend/users.js: -------------------------------------------------------------------------------- 1 | module.exports = [{ 2 | "name": "root", 3 | "id": 1, 4 | "email": "root@ele.me", 5 | "password": "password", 6 | "permissions": [ 7 | "EDIT", 8 | "CHANGE", 9 | "PUBLISH", 10 | "ADMIN" 11 | ], 12 | "domains": [ 13 | "hsite", 14 | "desktop", 15 | "msite" 16 | ] 17 | },{ 18 | "name": "sofish", 19 | "id": 2, 20 | "email": "sofish@ele.me", 21 | "password": "password", 22 | "permissions": [ 23 | "EDIT", 24 | "CHANGE", 25 | "PUBLISH", 26 | "ADMIN" 27 | ], 28 | "domains": [ 29 | "hsite", 30 | "desktop", 31 | "msite" 32 | ] 33 | }] -------------------------------------------------------------------------------- /docs/image/create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElemeFE/crayfish/599ddff22a5b2e329b36711e5ade064265070cf1/docs/image/create.png -------------------------------------------------------------------------------- /docs/image/privilege.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElemeFE/crayfish/599ddff22a5b2e329b36711e5ade064265070cf1/docs/image/privilege.png -------------------------------------------------------------------------------- /docs/image/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElemeFE/crayfish/599ddff22a5b2e329b36711e5ade064265070cf1/docs/image/screen.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/intro/README.md: -------------------------------------------------------------------------------- 1 | ## Crayfish 2 | 3 | Crayfish 是一个前端配置管理系统。与一般的配置管理系统不同的是 Crayfish 是将配置发布到 CDN,前端程序直接从 CDN 加载,对后端服务器不会造成任何压力。 4 | 5 | ## 应用场景 6 | 7 | ### 1. 文案管理 8 | 9 | 很多前端的文案都会频繁修改,最典型的就是用户交易平台的用户协议和帮助中心。以前都是产品经理提出文案修改需求,然后再让开发人员排期修改,最后还要发布到代码。 10 | 11 | 后来我们将文案部分抽取到 Crayfish 中,并且把编辑权限直接交给产品经理。这样不仅节省了开发的时间,还可以让产品经理更加及时地修改文案。 12 | 13 | ### 2. 开关管理 14 | 15 | 以前涉及到需要动态控制的地方我们都会让后端开发接口,把后端作为配置管理器。 16 | 17 | 后来遇到了限时抢购这个业务,由于并发非常高,这么搞会把后端打挂。 18 | 19 | 于是我们把开关放到了 Crayfish 上,由于是纯 CDN 的,没有服务器性能瓶颈,轻松扛下秒杀配置开关的任务。 20 | 21 | ### 3. 更多玩法等你来折腾! 22 | 23 | Crayfish 的作用远不止上面列举的,我们正在把它推向 App、推向后端…… 24 | 25 | ## 特点 26 | 27 | * 配置在 CDN 上,没有服务器性能瓶颈 28 | * 健壮的权限机制,可以从角色和项目两个维度管理权限 29 | * 配置是有数据类型的,让前端程序可以更准确地使用 30 | -------------------------------------------------------------------------------- /docs/intro/index.html: -------------------------------------------------------------------------------- 1 | ../index.html -------------------------------------------------------------------------------- /docs/manual/README.md: -------------------------------------------------------------------------------- 1 | ## 日常操作 2 | 3 | ### 1. 进入 Crayfish 控制台 4 | 5 | 所有的操作都在 Crayfish 控制台上完成的。考虑到每个公司都有一套自己的用户系统,因此开源版本并没有实现用户认证机制,默认是以超级管理员身份进入。 6 | 7 | ### 2. 数据列表 8 | 9 | 进入项目后大概能看到这个界面: 10 | 11 | 12 | 13 | 中间这个最大的表格就是这个项目所有配置数据的列表了。 14 | 15 | 这个数据列表的操作权限是根据用户角色区分的。 16 | 17 | 如果你是开发人员,那你应该可以看到每条记录后面的「Edit」、「Remove」、「History」按钮,以及右上角的「Create」和「Publish」按钮。 18 | 19 | 如果你是产品经理或运营人员(对应「CHANGE」权限),你就看不到「Remove」和「Create」按钮。 20 | 21 | 之所以没有非开发人员开添加和删除的权限是因为每条记录都是和程序关联的。记录中的 `Name` 会被程序引用,而随便删除项可能导致某些容错没做好的程序崩掉。 22 | 23 | ### 3. 数据的编辑与发布 24 | 25 | 每条记录由 `Path`、`Name`、`Type`、`Value`、`Comment` 这三部分组成。 26 | 27 | * `Path` 表示最终这些数据会在 CDN 上的哪个 URL Path。 28 | * `Name` 表示这条记录在程序中的引用名称。 29 | * `Type` 表示这条记录的数据类型。 30 | * `Value` 表示这条记录的值。 31 | * `Comment` 表示这条记录的注释,并不会对记录有实际影响。 32 | 33 | 发布到 CDN 时这些记录会根据 `Path` 聚合起来。 34 | 35 | 示例: 36 | 37 | 假如现在我们有个叫做 test 的项目,在这个项目中有这三条记录: 38 | 39 | | Path | Name | Value | 40 | | ---- | ---- | ----- | 41 | | /a | x | 1 | 42 | | /b | y | 2 | 43 | | /a | z | 3 | 44 | 45 | 点击「Pubish」发布后,Crayfish 会将上面的三条记录聚合成两个文件,每个文件有三个版本可供使用。 46 | 47 | #### 3.1. js 版本 48 | 49 | js 版本不是直接的 json,而是一个将 json 赋值给名为 `crayfish` 的全局变量的 js 文件。 50 | 51 | http://localhost:8100/test/a 52 | 53 | ```js 54 | var crayfish = { 55 | "x": 1, 56 | "z": 3 57 | }; 58 | ``` 59 | 60 | http://localhost:8100/test/b 61 | 62 | ```js 63 | var crayfish = { 64 | "y": 2 65 | }; 66 | ``` 67 | 68 | #### 3.2. json 版本 69 | 70 | 如果想要直接使用 json,可以在域名后面加上 `@json`。 71 | 72 | http://localhost:8100/crayfish/test@json/a 73 | 74 | ```js 75 | { 76 | "x": 1, 77 | "z": 3 78 | } 79 | ``` 80 | 81 | http://localhost:8100/crayfish/test@json/b 82 | 83 | ```js 84 | { 85 | "y": 2 86 | } 87 | ``` 88 | 89 | #### 3.3. ref 版本 90 | 91 | ref 版本类似于 js 版本,但是可以设置相应 ` 96 | 97 | 98 | 102 | ``` 103 | 104 | ### 4. 操作历史 105 | 106 | 每条记录上都会有一个「History」的按钮,点击可以看到这条记录谁在什么时候编辑过,编辑后的值是什么。在编辑记录最后还有个「Reuse」按钮,可以将当时的值重新设置到这条记录上。 107 | 108 | 如果谁手贱把编辑好的数据弄坏了可以通过「History」找回来。 109 | 110 | 处理「History」外,顶部导航栏还有一个「Publish Log」页面,这个页面可以看到谁在什么时候做了「Publish」操作。 111 | 112 | ## 管理员操作 113 | 114 | ### 1. 给小伙伴加权限 115 | 116 | 无论是开发还是产品甚至是运营,他们都可能需要某个项目的权限。 117 | 118 | 点击左侧项目列表中需要编辑权限的项目和顶部导航的「Privilege」即可进入对应项目的权限编辑页。 119 | 120 | 在这个页面上可以点击「Create」按钮来授予某个用户当前项目的权限。 121 | 122 | 123 | 124 | 注意此处配置的是项目权限而不是用户角色。只需要把用户加进来即可,具体的增、删、改、查、发的权限由这个用户本身的职位类型决定。 125 | 126 | 比如同时将一个产品经理和一个开发加入到某个项目中,产品经理的权限是编辑和发布的权限,而开发不仅拥有编辑和发布的权限,还有创建与删除的权限。 127 | 128 | ### 2. 创建项目 129 | 130 | 管理员可以通过控制台顶部的「Domains」按钮查看所有项目的列表。 131 | 132 | 点击这个页面上的「Create」按钮即可创新项目。 133 | 134 | 135 | 136 | 创建项目唯一需要提供的信息是域名。Crayfish 最终是作为 Web 前端配置管理工具被开发出来,而 Web 前端项目最好的区分方式就是域名,因此这里以域名作为管理维度来划分配置。大家在编辑时也尽可能地使用和项目一致的域名作为 Crayfish 中的项目名。 137 | 138 | 当然,后来我们也接入了一些非 Web 前端的项目,使用了一些域名之外的东西作为 Domain。所以将来 Domain 的概念可能外延到「领域」,而不是狭义上的「域名」。 139 | 140 | ## CDN 操作 141 | 142 | ### 1. 回源地址 143 | 144 | http://localhost:8100/crayfish//.crayfish 145 | 146 | ### 2. 推送到 CDN 147 | 148 | 在 backend/lib/publishProvider 目录下提供了 qiniu.js 的示例。 149 | 150 | ## Contributing 151 | 152 | ### dependencies 153 | 154 | MySQL 5.6 155 | 156 | Node 5 157 | 158 | ### backend 159 | cd backend 160 | 161 | make init-database 162 | 163 | make run 164 | 165 | ### frontend 166 | Crayfish 的前端使用 [jinkela](https://github.com/YanagiEiichi/jinkela) 开发,并使用 [webspoon](https://github.com/ElemeFE/webspoon) 构建 167 | 168 | cd frontend 169 | 170 | make dev 171 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /docs/manual/index.html: -------------------------------------------------------------------------------- 1 | ../index.html -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | ../package.json -------------------------------------------------------------------------------- /frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "no-with": [ 2 ], 5 | "no-eval": [ 2 ], 6 | "no-octal-escape": [ 2 ], 7 | "no-shadow-restricted-names": [ 2 ], 8 | "valid-typeof": [ 2 ], 9 | "no-caller": [ 2 ], 10 | "no-extend-native": [ 2 ], 11 | "no-console": [ 1 ], 12 | "no-debugger": [ 1 ], 13 | "indent": [ 1, 2, { "SwitchCase": 1 } ], 14 | "quotes": [ 1, "single" ], 15 | "linebreak-style": [ 1, "unix" ], 16 | "semi": [ 1, "always" ], 17 | "semi-spacing": [ 1 ], 18 | "one-var": [ 1, { "initialized": "never" } ], 19 | "no-multi-spaces": [ 1 ], 20 | "no-empty": [ 1 ], 21 | "space-infix-ops": [ 1 ], 22 | "comma-spacing": [ 1 ], 23 | "spaced-comment": [ 1, "always", { "exceptions": [ "*" ] } ], 24 | "no-unneeded-ternary": [ 1 ], 25 | "no-useless-call": [ 1 ], 26 | "block-scoped-var": [ 1 ], 27 | "comma-dangle": [ 1, "never" ], 28 | "comma-style": [ 1, "last" ], 29 | "space-in-parens": [ 1 ], 30 | "space-before-keywords": [ 1 ], 31 | "space-after-keywords": [ 1 ], 32 | "arrow-spacing": [ 1 ], 33 | "space-unary-ops": [ 1 ], 34 | "space-return-throw-case": [ 1 ], 35 | "new-parens": [ 1 ], 36 | "key-spacing": [ 1 ], 37 | "eqeqeq": [ 1 ], 38 | "eol-last": [ 1 ], 39 | "dot-location": [ 1, "property" ], 40 | "func-style": [ 1, "expression" ], 41 | "no-lone-blocks": [ 1 ], 42 | "no-native-reassign": [ 1 ], 43 | "no-nested-ternary": [ 1 ], 44 | "no-fallthrough": [ 1 ], 45 | "no-undef": [ 0 ], 46 | "no-unused-vars": [ 0 ] 47 | }, 48 | "env": { 49 | "es6": true, 50 | "browser": true 51 | }, 52 | "globals": { 53 | "$": true 54 | }, 55 | "extends": "eslint:recommended" 56 | } 57 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | ############################## Global Definations ############################## 2 | 3 | .PHONY: dist 4 | 5 | default: help 6 | 7 | .help: 8 | @echo "# common tasks" 9 | @echo "$$ make # help" 10 | @echo "$$ make install # install dependencies" 11 | @echo "$$ make build # build only" 12 | @echo "$$ make build as dist # build as dist" 13 | @echo "$$ make dev # build and watch" 14 | @echo "$$ make lint # check coding style" 15 | @echo "" 16 | 17 | 18 | # require finite@~1.0.24 19 | include $(shell test -x "$$(which finite)" || sudo npm install finite -g > /dev/null; finite lib) 20 | 21 | 22 | 23 | ############################## Dependencies ############################## 24 | 25 | install: global-dependencies $(if $(fucking),cache-clean,) node_modules 26 | 27 | ############################## Unit Loader ############################## 28 | 29 | babel := $(abspath ./node_modules/.bin/babel) -e 1 30 | 31 | .js.loader := $(babel) 32 | .html.loader := $(wildcard) 33 | 34 | 35 | 36 | ############################## Building Tools ############################## 37 | 38 | .build-static: 39 | @echo "Build static files ... \c" 40 | @rsync -Laz src/* dist --exclude '*.php' --exclude '.*' 41 | @echo "OK" 42 | 43 | .build-html: 44 | @echo "Build html files ... \c" 45 | @$(wildcard) 'dist/**/*.html' 46 | @echo " OK" 47 | 48 | .build-js: 49 | @echo "build js files ... \c" 50 | @echo "$$($(babel) src -d dist | wc -l | awk '{print $$1}') files generated" 51 | 52 | .dist: usemin rev 53 | 54 | .link: 55 | @cd dist && ln -s index/index.html 56 | @ln -s '.' dist/dist 57 | 58 | build: clean install $(if $(dist),lint,) php .build-static .build-html .build-js $(if $(dist),.dist,) .link 59 | 60 | dev: install build watch 61 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fe.crayfish", 3 | "private": true, 4 | "version": "1.0.0", 5 | "dependencies": { 6 | "babel": "^5.8.35", 7 | "finite-cssfinal": "^0.1.0" 8 | }, 9 | "devDependencies": { 10 | "babel-eslint": "^7.1.0", 11 | "eslint": "^1.10.3", 12 | "eslint-plugin-babel": "^3.1.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/changelog/controlbar.js: -------------------------------------------------------------------------------- 1 | class ControlBar extends Jinkela { 2 | init() { 3 | let { onFilterChange } = this; 4 | new FilterBar({ onFilterChange }).renderTo(this); 5 | } 6 | get styleSheet() { 7 | return ` 8 | :scope { 9 | height: 50px; 10 | line-height: 50px; 11 | overflow: hidden; 12 | } 13 | `; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/changelog/filterbar.js: -------------------------------------------------------------------------------- 1 | class FilterBarTextField extends TextField { 2 | get styleSheet() { 3 | return ` 4 | :scope { 5 | margin-right: 1em; 6 | transition: width 200ms ease; 7 | &:focus { 8 | border: 1px solid #dbc6c1; 9 | width: 200px; 10 | } 11 | } 12 | `; 13 | } 14 | 15 | } 16 | 17 | class FilterBar extends Jinkela { 18 | init() { 19 | new FilterBarTextField({ placeholder: 'value filter', name: 'value', onInput: this.onInput }).renderTo(this); 20 | } 21 | @autobind onInput(event) { 22 | if (typeof this.onFilterChange === 'function') this.onFilterChange(event); 23 | } 24 | get styleSheet() { 25 | return ` 26 | :scope { 27 | float: left; 28 | } 29 | `; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/changelog/index.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/changelog/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('DOMContentLoaded', () => { 2 | new Page({ Content: Main }).renderTo(document.body); 3 | }); 4 | -------------------------------------------------------------------------------- /frontend/src/changelog/list.js: -------------------------------------------------------------------------------- 1 | class LogListItemAction extends Jinkela { 2 | get template() { return ``; } 3 | init() { 4 | this.element.textContent = this.text; 5 | this.element.addEventListener('click', this.onClick); 6 | } 7 | } 8 | 9 | class LogListItem extends Jinkela { 10 | static cast(arr) { 11 | let result = arr.map(raw => new this(raw)); 12 | result.renderTo = parent => { 13 | result.forEach(item => item.renderTo(parent)); 14 | return result; 15 | }; 16 | return result; 17 | } 18 | init() { 19 | this.userName = this.create_by.name; 20 | this.email = this.create_by.email; 21 | this.emailLink = 'mailto:' + this.email; 22 | new LogListItemAction({ 23 | text: 'Reuse', 24 | onClick: async () => { 25 | let { id, domain } = new UParams(); 26 | let { value } = this; 27 | let response = await fetch(`/api/domains/${domain}/${id}`, { 28 | method: 'PATCH', 29 | headers: { 'Content-Type': 'application/json' }, 30 | body: JSON.stringify({ value }), 31 | credentials: 'include' 32 | }); 33 | if (response.status < 400) { 34 | this.parent.update(); 35 | } else { 36 | let error = await response.json(); 37 | alert(error.message); 38 | } 39 | } 40 | }).renderTo(this.actions); 41 | } 42 | get isVisible() { return this.$isVisible; } 43 | set isVisible(value) { 44 | if (this.$isVisible === value) return; 45 | this.$isVisible = value; 46 | this.element.style.display = value ? 'table-row' : 'none'; 47 | } 48 | get template() { 49 | return ` 50 | 51 | {value} 52 | {userName}{email}) 53 | {time} 54 | 55 | 56 | `; 57 | } 58 | get styleSheet() { 59 | return ` 60 | :scope { 61 | a { 62 | color: inherit; 63 | text-decoration: none; 64 | } 65 | } 66 | `; 67 | } 68 | } 69 | 70 | class LogListHead extends Jinkela { 71 | get template() { 72 | return ` 73 | 74 | Value 75 | By 76 | Time 77 | Actions 78 | 79 | `; 80 | } 81 | } 82 | 83 | class LogList extends DataTable { 84 | init() { 85 | new LogListHead().renderTo(this.thead); 86 | this.filters = {}; 87 | this.update(); 88 | } 89 | async update() { 90 | let data = await (await fetch('/api/changelog/' + new UParams().id, { credentials: 'include' })).json(); 91 | data.forEach(item => { 92 | item.time = new Date(Date.parse(item.create_at)) 93 | .toLocaleString('zh-CN', { hour12: false }) 94 | .replace(/\//g, '-').replace(/\b\d\b/g, '0$&'); 95 | item.parent = this; 96 | }); 97 | this.tbody.innerHTML = ''; 98 | this.data = LogListItem.cast(data).renderTo(this.tbody); 99 | this.applyFilter(); 100 | } 101 | get styleSheet() { 102 | return ` 103 | :scope { 104 | tr > *:nth-child(4) { 105 | width: 60px; 106 | text-align: center; 107 | white-space: nowrap; 108 | } 109 | } 110 | `; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/changelog/main.js: -------------------------------------------------------------------------------- 1 | class Main extends Jinkela { 2 | @autobind onFilterChange({ target: { name, value } }) { 3 | try { 4 | value = new RegExp(value); 5 | } catch (e) { 6 | value = new RegExp(); 7 | } 8 | this.dataTable.setFilter(name, value); 9 | } 10 | async init() { 11 | new ControlBar({ onFilterChange: this.onFilterChange, parent: this }).renderTo(this); 12 | this.dataTable = new LogList({ parent: this }).renderTo(this); 13 | let { id, domain } = new UParams(); 14 | let response = await fetch(`/api/domains/${domain}/${id}`, { credentials: 'include' }); 15 | if (response.status < 400) { 16 | let data = await response.json(); 17 | this.h1.textContent = '/' + data.domain + data.path + ':' + data.name; 18 | } 19 | } 20 | get template() { 21 | return ` 22 |
23 |

24 |
25 | `; 26 | } 27 | get styleSheet() { 28 | return ` 29 | :scope { 30 | display: flex; 31 | height: 100%; 32 | flex-direction: column; 33 | } 34 | `; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/button.js: -------------------------------------------------------------------------------- 1 | class Button extends Jinkela { 2 | get template() { return ``; } 3 | init() { 4 | if (this.class) this.element.setAttribute('class', this.class); 5 | if (this.text) this.element.setAttribute('text', this.text); 6 | this.element.addEventListener('click', this.click); 7 | } 8 | @autobind async click() { 9 | if (this.element.classList.contains('busy')) return; 10 | if (typeof this.onClick !== 'function') return; 11 | this.element.classList.add('busy'); 12 | try { 13 | let result = this.onClick(); 14 | if (typeof result.then === 'function') await result; 15 | } catch (error) { 16 | console.error(error); // eslint-disable-line no-console 17 | // Do nothing 18 | } 19 | this.element.classList.remove('busy'); 20 | } 21 | get styleSheet() { 22 | return ` 23 | :scope { 24 | box-sizing: border-box; 25 | display: inline-block; 26 | border: 1px solid #ddd; 27 | font-size: 12px; 28 | line-height: 14px; 29 | border-radius: 0px; 30 | padding: 8px 16px; 31 | background: #f7f7f7; 32 | outline: none; 33 | cursor: pointer; 34 | position: relative; 35 | &::before { 36 | content: attr(text); 37 | } 38 | &:hover { 39 | background: #fff; 40 | } 41 | &.primary { 42 | border-color: #cc3400; 43 | background: #cc3400; 44 | color: #fff; 45 | } 46 | &.primary:hover { 47 | background: #cc3400; 48 | opacity: 0.8; 49 | } 50 | &::after { 51 | position: absolute; 52 | left: 16px; 53 | right: 16px; 54 | top: 8px; 55 | bottom: 8px; 56 | } 57 | &.busy.busy { 58 | &::before { 59 | visibility: hidden; 60 | } 61 | &::after { 62 | content: ''; 63 | animation: button-busy 1000ms infinite; 64 | } 65 | opacity: 0.5; 66 | } 67 | } 68 | @keyframes button-busy { 69 | 0% { 70 | content: '·'; 71 | } 72 | 25% { 73 | content: '··'; 74 | } 75 | 50% { 76 | content: '···'; 77 | } 78 | 75% { 79 | content: '····'; 80 | } 81 | 100% { 82 | content: '·'; 83 | } 84 | } 85 | `; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/components/caption.js: -------------------------------------------------------------------------------- 1 | class Caption extends Jinkela { 2 | init() { 3 | this.element.textContent = this.text; 4 | } 5 | get template() { 6 | return `

`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/confirmpanel.js: -------------------------------------------------------------------------------- 1 | class ConfirmPanel extends Jinkela { 2 | init() { 3 | this.title = this.title || 'Confirm'; 4 | this.text = this.text || 'Are you sure?'; 5 | this.yesButton = new Button({ text: 'Yes', onClick: () => this.onYes(), 'class': 'primary' }); 6 | this.cancelButton = new Button({ text: 'Cancel', onClick: dialog.cancel }); 7 | } 8 | get template() { 9 | return ` 10 |
11 |

{text}

12 |
13 | 14 | 15 |
16 |
17 | `; 18 | } 19 | get styleSheet() { 20 | return ` 21 | :scope { 22 | h3 { 23 | margin: 0 0 2em 0; 24 | } 25 | button { 26 | margin: 0 1em; 27 | color: inherit; 28 | } 29 | padding: 2em; 30 | } 31 | `; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/container.js: -------------------------------------------------------------------------------- 1 | class Container extends Jinkela { 2 | get styleSheet() { 3 | return ` 4 | j-container:scope, 5 | :scope j-container { 6 | display: block; 7 | width: 990px; 8 | margin: auto; 9 | } 10 | `; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/datatable.js: -------------------------------------------------------------------------------- 1 | class DataTable extends Jinkela { 2 | get template() { 3 | return ` 4 |
5 | 6 | 7 | 8 |
9 |
10 | `; 11 | } 12 | get styleSheet() { 13 | return ` 14 | :scope { 15 | flex: 1; 16 | overflow: auto; 17 | margin-bottom: 2em; 18 | table { 19 | width: 100%; 20 | font-size: 12px; 21 | border: 1px solid #ebe6e1; 22 | border-collapse: collapse; 23 | tr { 24 | &:hover td { 25 | transition: background-color 200ms ease; 26 | background-color: #f6f6f6; 27 | } 28 | } 29 | th, td { 30 | text-align: left; 31 | border: solid #ebe6e1; 32 | border-width: 1px 0; 33 | } 34 | td { 35 | color: #666; 36 | padding: 12px 8px; 37 | } 38 | th { 39 | font-weight: normal; 40 | background: #faf5f5; 41 | padding: 8px 8px; 42 | color: #999; 43 | } 44 | a + a { 45 | margin-left: 1em; 46 | } 47 | } 48 | } 49 | `; 50 | } 51 | applyFilter() { 52 | let keys = Object.keys(this.filters); 53 | this.data.forEach(item => { 54 | item.isVisible = keys.every(key => { 55 | return this.filters[key].test(item[key]); 56 | }); 57 | }); 58 | } 59 | setFilter(filedName, value) { 60 | this.filters[filedName] = value; 61 | this.applyFilter(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/errorpanel.js: -------------------------------------------------------------------------------- 1 | class ErrorPanel extends Jinkela { 2 | static popup(...args) { dialog.popup(new this(...args)); } 3 | init() { 4 | this.title = this.title || 'Error'; 5 | this.text = this.text || 'Error'; 6 | this.cancelButton = new Button({ text: 'Cancel', onClick: dialog.cancel, 'class': 'primary' }); 7 | } 8 | get template() { 9 | return ` 10 |
11 |

{text}

12 |
13 | 14 |
15 |
16 | `; 17 | } 18 | get styleSheet() { 19 | return ` 20 | :scope { 21 | h3 { 22 | margin: 0 0 2em 0; 23 | } 24 | button { 25 | margin: 0 1em; 26 | color: inherit; 27 | } 28 | padding: 2em; 29 | } 30 | `; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/components/form/formpanel.js: -------------------------------------------------------------------------------- 1 | class FormPanel extends Jinkela { 2 | init() { 3 | this.element.addEventListener('keyup', this.keyup); 4 | } 5 | @autobind keyup({ keyCode, target }) { 6 | if (keyCode !== 13) return; 7 | if (target.tagName === 'TEXTAREA') return; 8 | if (typeof this.onSave === 'function') this.onSave(); 9 | } 10 | get styleSheet() { 11 | return ` 12 | :scope { 13 | font-size: 12px; 14 | margin: 0 auto; 15 | padding: 1em; 16 | th, td { 17 | padding: .5em; 18 | } 19 | th { 20 | font-weight: normal; 21 | text-align: right; 22 | } 23 | td { 24 | text-align: left; 25 | } 26 | button { 27 | margin-right: 1em; 28 | } 29 | } 30 | `; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/components/form/formtextfield.js: -------------------------------------------------------------------------------- 1 | class FormTextField extends TextField { 2 | init() { 3 | if ('text' in this) this.element.value = this.text; 4 | if ('readonly' in this) this.element.setAttribute('readonly', 'readonly'); 5 | } 6 | get styleSheet() { 7 | return ` 8 | :scope { 9 | width: 300px; 10 | &[readonly] { 11 | background: #faf5f5; 12 | } 13 | } 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/frame/frame.js: -------------------------------------------------------------------------------- 1 | class Frame extends Jinkela { 2 | init() { 3 | this.update(); 4 | } 5 | update() { 6 | this.element.innerHTML = ''; 7 | this.menu = new FrameMenu().renderTo(this); 8 | let { Content } = this.page; 9 | let content = this.content = new Content({ page: this.page }); 10 | this.panel = new FramePanel({ content }).renderTo(this); 11 | } 12 | set content(value) { 13 | this.$content = value; 14 | if (this.panel) this.panel.content = this.content; 15 | } 16 | get content() { return this.$content || new Jinkela(); } 17 | get styleSheet() { 18 | return ` 19 | :scope { 20 | height: calc(100vh - 50px); 21 | display: flex; 22 | font-size: 12px; 23 | line-height: 14px; 24 | color: #484848; 25 | min-height: 400px; 26 | overflow: hidden; 27 | box-sizing: border-box; 28 | } 29 | `; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/components/frame/framemenu.js: -------------------------------------------------------------------------------- 1 | class FrameMenuItem extends ListItem { 2 | init() { 3 | this.$isVisible = true; 4 | if (this.text === this.domain) this.element.classList.add('active'); 5 | this.element.addEventListener('click', this.click); 6 | } 7 | @autobind click(event) { 8 | let { element } = this; 9 | let old = element.parentNode.querySelector('.active'); 10 | if (old) old.classList.remove('active'); 11 | element.classList.add('active'); 12 | let params = UParams(); 13 | params.domain = this.text; 14 | location.hash = params; 15 | } 16 | get isVisible() { return this.$isVisible; } 17 | set isVisible(value) { 18 | if (this.$isVisible === value) return; 19 | this.$isVisible = value; 20 | this.element.style.display = value ? 'block' : 'none'; 21 | } 22 | get styleSheet() { 23 | let height = 26; 24 | return ` 25 | :scope { 26 | a { 27 | padding-left: 18px; 28 | height: ${height}px; 29 | line-height: ${height}px; 30 | font-size: 12px; 31 | display: block; 32 | text-decoration: none; 33 | color: inherit; 34 | border-left: 0 solid #cc3400; 35 | transition: 36 | background 200ms ease, 37 | border-left-width 200ms linear; 38 | &:hover { 39 | background: #483B2B; 40 | } 41 | } 42 | &.active a { 43 | background: #554939; 44 | border-left-width: 5px; 45 | } 46 | } 47 | `; 48 | } 49 | } 50 | 51 | class FrameMenuList extends Jinkela { 52 | async init() { 53 | this.$filter = new RegExp(); 54 | let response = await fetch('/api/domains', { credentials: 'include' }); 55 | let rawList = (await response.json()).map(({ domain: text }) => ({ text })); 56 | this.list = FrameMenuItem.cast(rawList, { domain: new UParams().domain }).renderTo(this); 57 | this.filter = this.filter; 58 | } 59 | get template() { return '
    '; } 60 | get filter() { return this.$filter; } 61 | set filter(re) { 62 | this.$filter = re; 63 | if (this.list) this.list.forEach(item => item.isVisible = re.test(item.text)); 64 | } 65 | get styleSheet() { 66 | return ` 67 | :scope { 68 | flex: 1; 69 | overflow: auto; 70 | padding: 0; 71 | margin: 0; 72 | list-style: none; 73 | font-size: 16px; 74 | } 75 | `; 76 | } 77 | } 78 | 79 | class FrameMenuFilter extends Jinkela { 80 | get template() { return '
    '; } 81 | get styleSheet() { 82 | return ` 83 | :scope { 84 | input { 85 | border-radius: 5px; 86 | border: 1px solid rgba(255,255,255,0.5); 87 | background: rgba(255,255,255,0.1); 88 | color: #fff; 89 | padding: 5px; 90 | outline: none; 91 | box-sizing: border-box; 92 | width: 80%; 93 | margin: 1em 10%; 94 | &:focus { 95 | background: rgba(255,255,255,0.2); 96 | } 97 | } 98 | } 99 | `; 100 | } 101 | } 102 | 103 | class FrameMenu extends Jinkela { 104 | @autobind onFilterChange({ target }) { 105 | try { 106 | this.frameMenuList.filter = new RegExp(target.value); 107 | } catch (error) { 108 | this.frameMenuList.filter = new RegExp(); 109 | } 110 | } 111 | async init() { 112 | this.frameMenuFilter = new FrameMenuFilter({ onChange: this.onFilterChange }).renderTo(this); 113 | this.frameMenuList = new FrameMenuList().renderTo(this); 114 | } 115 | get template() { return '
    '; } 116 | get styleSheet() { 117 | return ` 118 | :scope { 119 | width: 180px; 120 | display: flex; 121 | flex-direction: column; 122 | background: #383129; 123 | overflow: hidden; 124 | height: 100%; 125 | color: #fff; 126 | a { 127 | text-decoration: none; 128 | color: inherit; 129 | } 130 | } 131 | `; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /frontend/src/components/frame/framepanel.js: -------------------------------------------------------------------------------- 1 | class FramePanel extends Jinkela { 2 | get template() { return `
    `; } 3 | get styleSheet() { 4 | return ` 5 | :scope { 6 | flex: 1; 7 | overflow: auto; 8 | height: 100%; 9 | padding: 0 2em; 10 | box-sizing: border-box; 11 | } 12 | `; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/frame/header.js: -------------------------------------------------------------------------------- 1 | class HeaderLogo extends Jinkela { 2 | init() { 3 | if (location.pathname === '/') this.element.firstElementChild.style.background = 'rgba(0,0,0,0.1)'; 4 | } 5 | get template() { 6 | return ` 7 |
    8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
    16 | `; 17 | } 18 | get styleSheet() { 19 | return ` 20 | :scope { 21 | display: inline-block; 22 | svg { 23 | fill: #fff; 24 | display: block; 25 | width: 40px; 26 | height: 40px; 27 | } 28 | a { 29 | display: inline-block; 30 | vertical-align: top; 31 | padding: 5px; 32 | } 33 | } 34 | `; 35 | } 36 | } 37 | 38 | class HeaderName extends Jinkela { 39 | get template() { return `Crayfish 2.0`; } 40 | get styleSheet() { 41 | return ` 42 | :scope { 43 | display: inline-block; 44 | vertical-align: top; 45 | fill: #fff; 46 | width: 130px; 47 | text-align: center; 48 | font-size: 14px; 49 | margin: 0; 50 | text-decoration: none; 51 | color: inherit; 52 | &:hover { 53 | opacity: .8; 54 | } 55 | } 56 | `; 57 | } 58 | } 59 | 60 | class HeaderNavItem extends ListItem { 61 | get active() { return location.pathname === this.path; } 62 | onClick() { 63 | location.pathname = this.path; 64 | } 65 | get styleSheet() { 66 | return ` 67 | :scope { 68 | display: inline-block; 69 | > a { 70 | color: inherit; 71 | text-decoration: none; 72 | display: inline-block; 73 | padding: 0 2em; 74 | &:hover { 75 | opacity: .8; 76 | } 77 | } 78 | } 79 | `; 80 | } 81 | } 82 | 83 | class HeaderNav extends Jinkela { 84 | init() { 85 | let { permissions } = this.user; 86 | new HeaderNavItem({ text: 'Data', path: '/domain/' }).renderTo(this); 87 | new HeaderNavItem({ text: 'Publish Log', path: '/publishlog/' }).renderTo(this); 88 | if (~permissions.indexOf('ADMIN')) { 89 | new HeaderNavItem({ text: 'Privilege', path: '/privilege/' }).renderTo(this); 90 | new HeaderNavItem({ text: 'Domains', path: '/domains/' }).renderTo(this); 91 | } 92 | } 93 | get template() { return `
      `; } 94 | get styleSheet() { 95 | return ` 96 | :scope { 97 | display: inline-block; 98 | vertical-align: top; 99 | fill: #fff; 100 | font-size: 14px; 101 | margin: 0; 102 | padding: 0; 103 | list-style: none; 104 | } 105 | `; 106 | } 107 | } 108 | 109 | class HeaderAside extends Jinkela { 110 | async init() { 111 | this.name = this.user.name; 112 | this.SSOURL = $user.SSOURL; 113 | } 114 | get template() { 115 | return ` 116 |
      117 | {name} 118 |
      119 | `; 120 | } 121 | get styleSheet() { 122 | return ` 123 | :scope { 124 | margin-right: 2em; 125 | float: right; 126 | font-size: 12px; 127 | a { 128 | color: inherit; 129 | text-decoration: none; 130 | margin-left: 2em; 131 | &:hover { 132 | opacity: .8; 133 | } 134 | } 135 | } 136 | `; 137 | } 138 | } 139 | 140 | class Header extends Jinkela { 141 | async init() { 142 | let user = await $user; 143 | new HeaderLogo().renderTo(this); 144 | new HeaderName().renderTo(this); 145 | new HeaderNav({ user }).renderTo(this); 146 | new HeaderAside({ user }).renderTo(this); 147 | } 148 | get styleSheet() { 149 | return ` 150 | :scope { 151 | height: 50px; 152 | line-height: 50px; 153 | background: #cc3400; 154 | color: #fff; 155 | } 156 | `; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /frontend/src/components/item.js: -------------------------------------------------------------------------------- 1 | class Item extends Jinkela { 2 | static cast(arr, common) { 3 | if (common) arr = arr.map(item => Object.assign({}, item, common)); 4 | let result = arr.map(raw => new this(raw)); 5 | result.renderTo = parent => { 6 | result.forEach(item => item.renderTo(parent)); 7 | return result; 8 | }; 9 | return result; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/components/listitem.js: -------------------------------------------------------------------------------- 1 | class ListItem extends Item { 2 | 3 | init() { 4 | if (this.active) this.element.firstChild.style.background = 'rgba(0,0,0,.1)'; 5 | if (!this.href) this.href = 'JavaScript:'; 6 | } 7 | 8 | get template() { return `
    • {text}
    • `; } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/components/page.js: -------------------------------------------------------------------------------- 1 | class Page extends Jinkela { 2 | async init() { 3 | let arg = { page: this }; 4 | new Header(arg).renderTo(this); 5 | this.frame = new Frame(arg).renderTo(this); 6 | } 7 | get styleSheet() { 8 | return ` 9 | html, body { height: 100%; margin: 0; } 10 | :scope { 11 | font-family: 'Helvetica Neue', 'Luxi Sans', 'DejaVu Sans', Tahoma, 12 | 'Hiragino Sans GB', STHeiti, 'Microsoft YaHei'; 13 | } 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/successpanel.js: -------------------------------------------------------------------------------- 1 | class SuccessPanel extends Jinkela { 2 | static popup(...args) { dialog.popup(new this(...args)); } 3 | init() { 4 | this.title = this.title || 'Success'; 5 | this.text = this.text || 'Success'; 6 | this.btn = new Button({ text: 'OK', onClick: dialog.cancel, 'class': 'primary' }); 7 | } 8 | get template() { 9 | return ` 10 |
      11 |

      {text}

      12 |
      13 | 14 |
      15 |
      16 | `; 17 | } 18 | get styleSheet() { 19 | return ` 20 | :scope { 21 | h3 { 22 | margin: 0 0 2em 0; 23 | } 24 | button { 25 | margin: 0 1em; 26 | color: inherit; 27 | } 28 | padding: 2em; 29 | } 30 | `; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/components/textfield.js: -------------------------------------------------------------------------------- 1 | class TextField extends Jinkela { 2 | init() { 3 | [ 'placeholder', 'name', 'max', 'min' ] 4 | .filter(name => name in this).forEach(name => { 5 | this.element.setAttribute(name, this[name]); 6 | }); 7 | this.element.addEventListener('input', this.input); 8 | } 9 | get template() { return ``; } 10 | @autobind input(event) { 11 | if (typeof this.onInput === 'function') this.onInput(event); 12 | } 13 | get styleSheet() { 14 | return ` 15 | :scope { 16 | display: inline-block; 17 | box-sizing: border-box; 18 | border: 1px solid #ccc; 19 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 20 | color: #4c4c4c; 21 | height: 32px; 22 | padding: 5px; 23 | outline: none; 24 | width: 120px; 25 | &:focus { 26 | border: 1px solid #dbc6c1; 27 | } 28 | } 29 | `; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/default/default.js: -------------------------------------------------------------------------------- 1 | class Default extends Jinkela { 2 | init() { 3 | this.referrer = function () { 4 | let parser = document.createElement('a'); 5 | parser.href = document.referrer; 6 | let { host, pathname } = parser; 7 | if (host === location.host && pathname !== '/default/') { 8 | return pathname; 9 | } 10 | return '/domain/'; 11 | }(); 12 | this.update(); 13 | } 14 | update() { 15 | let { domain } = new UParams(); 16 | if (domain && location.pathname === '/default/') { 17 | location.pathname = this.referrer; 18 | } else { 19 | this.element.innerHTML = typeof crayfish !== 'undefined' && crayfish.default || '

      Crayfish

      '; 20 | } 21 | } 22 | get styleSheet() { 23 | return ` 24 | :scope { 25 | margin: 2em 0; 26 | line-height: initial; 27 | } 28 | `; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/default/index.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/default/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('DOMContentLoaded', () => { 2 | let page = new Page({ Content: Default }).renderTo(document.body); 3 | addEventListener('hashchange', () => page.frame.content.update()); 4 | }); 5 | -------------------------------------------------------------------------------- /frontend/src/domain/actionbar/actionbar.js: -------------------------------------------------------------------------------- 1 | class ActionBarCreatingButton extends Button { 2 | get text() { return 'Create'; } 3 | get class() { return 'primary'; } 4 | } 5 | 6 | class ActionBarPublishingButton extends Button { 7 | get text() { return 'Publish'; } 8 | get class() { return 'primary'; } 9 | } 10 | 11 | class ActionBarCheckbox extends Item { 12 | get template() { 13 | return ` 14 | 18 | `; 19 | } 20 | get styleSheet() { 21 | return ` 22 | :scope { 23 | margin-left: 1em; 24 | } 25 | `; 26 | } 27 | } 28 | 29 | class ActionBar extends Jinkela { 30 | async init() { 31 | ActionBarCheckbox.cast([ 32 | { text: 'Detail', onChange: ({ target }) => this.dataTable.isShowDetail = target.checked }, 33 | { text: 'Comment', onChange: ({ target }) => this.dataTable.isShowComment = target.checked } 34 | ]).renderTo(this); 35 | let { permissions } = await $user; 36 | if (~permissions.indexOf('PUBLISH')) { 37 | new ActionBarPublishingButton({ onClick: this.publish }).renderTo(this); 38 | } 39 | if (~permissions.indexOf('CHANGE')) { 40 | new ActionBarCreatingButton({ onClick: this.create }).renderTo(this); 41 | } 42 | } 43 | @autobind publish(event) { 44 | let { dataTable } = this; 45 | dialog.popup(new PublishingPanel({ dataTable })); 46 | } 47 | @autobind create(event) { 48 | let { dataTable } = this.parent.parent; 49 | dialog.popup(new DomainPanelWithCreating({ 50 | async onSave() { 51 | let { domain } = new UParams(); 52 | let form; 53 | try { 54 | form = this.value; 55 | } catch (error) { 56 | return alert(error.message); 57 | } 58 | let { path, name, value, comment, type } = form; 59 | let response = await fetch('/api/domains/' + domain, { 60 | method: 'POST', 61 | headers: { 'Content-Type': 'application/json' }, 62 | body: JSON.stringify({ path, name, value, comment, type }), 63 | credentials: 'include' 64 | }); 65 | if (response.status < 400) { 66 | dialog.cancel(); 67 | dataTable.update(); 68 | } else { 69 | let error = await response.json(); 70 | alert(error.message); 71 | } 72 | } 73 | })); 74 | } 75 | get styleSheet() { 76 | return ` 77 | :scope { 78 | float: right; 79 | button { 80 | margin-left: 1em; 81 | } 82 | } 83 | `; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/domain/controlbar.js: -------------------------------------------------------------------------------- 1 | class ControlBar extends Jinkela { 2 | init() { 3 | let { onFilterChange, dataTable } = this; 4 | new FilterBar({ onFilterChange }).renderTo(this); 5 | new ActionBar({ dataTable, parent: this }).renderTo(this); 6 | } 7 | get styleSheet() { 8 | return ` 9 | :scope { 10 | height: 50px; 11 | line-height: 50px; 12 | overflow: hidden; 13 | } 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/domain/ctrls/boolean.js: -------------------------------------------------------------------------------- 1 | class DomainPanelBoolean extends Jinkela { 2 | get value() { return this.$value; } 3 | set value(value) { 4 | if (typeof value === 'string') { 5 | try { 6 | value = JSON.parse(value); 7 | } catch (error) { 8 | setTimeout(() => { throw error; }); 9 | } 10 | } 11 | this.element.dataset.value = this.$value = !!value; 12 | } 13 | init() { 14 | Object.defineProperty(this.element, 'value', { 15 | get: () => this.value, 16 | set: value => this.value = value 17 | }); 18 | this.value = !!this.$value; 19 | this.element.addEventListener('click', () => this.value = !this.$value); 20 | } 21 | get styleSheet() { 22 | return ` 23 | :scope { 24 | width: 80px; 25 | height: 24px; 26 | line-height: 24px; 27 | border-radius: 12px; 28 | border: 1px solid #ccc; 29 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 30 | display: inline-block; 31 | cursor: pointer; 32 | &:before { 33 | content: attr(data-value); 34 | display: inline-block; 35 | text-align: center; 36 | color: #fff; 37 | width: 60px; 38 | height: 24px; 39 | border-radius: 12px; 40 | background: #999; 41 | transition: transform 200ms ease; 42 | padding: 1px; 43 | margin: -1px; 44 | } 45 | &[data-value=true]:before { 46 | content: 'true'; 47 | background: #cc3400; 48 | transform: translateX(20px); 49 | } 50 | } 51 | `; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/domain/ctrls/datepicker.js: -------------------------------------------------------------------------------- 1 | class DomainPanelDatePicker extends Jinkela { 2 | get template() { 3 | let starting = new Date().getFullYear() - 8; 4 | return ``; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/domain/ctrls/json.js: -------------------------------------------------------------------------------- 1 | class DomainPanelJSON extends Jinkela { 2 | init() { 3 | let textarea = new DomainPanelTextArea().renderTo(this).element; 4 | Object.defineProperty(this.element, 'value', { 5 | get: () => { 6 | try { 7 | return JSON.parse(textarea.value); 8 | } catch (error) { 9 | throw new Error('数据必须是一个 JSON'); 10 | } 11 | }, 12 | set: value => textarea.value = JSON.stringify(value, null, 2) 13 | }); 14 | } 15 | get styleSheet() { 16 | return ` 17 | :scope { 18 | font-family: monospace; 19 | } 20 | `; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/domain/ctrls/number.js: -------------------------------------------------------------------------------- 1 | class DomainPanelNumberInput extends TextField { 2 | get type() { return 'number'; } 3 | get styleSheet() { 4 | return ` 5 | :scope { 6 | width: 100px; 7 | } 8 | `; 9 | } 10 | } 11 | 12 | class DomainPanelNumber extends Jinkela { 13 | init() { 14 | let input = new DomainPanelNumberInput().renderTo(this); 15 | Object.defineProperty(this.element, 'value', { 16 | configurable: true, 17 | get() { return +input.element.value; }, 18 | set(value) { input.element.value = value; } 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/domain/ctrls/percent.js: -------------------------------------------------------------------------------- 1 | class DomainPanelPercentBlood extends Jinkela { 2 | get value() { 3 | return this.$value; 4 | } 5 | set value(value) { 6 | this.$value = value; 7 | this.element.style.width = value * 100 + '%'; 8 | } 9 | get styleSheet() { 10 | return ` 11 | :scope { 12 | height: 100%; 13 | background: #cc3400; 14 | position: relative; 15 | &::after { 16 | content: ''; 17 | position: absolute; 18 | right: -6px; 19 | left: calc(100% - 6px); 20 | top: -3px; 21 | bottom: -3px; 22 | border: 2px solid #cc3400; 23 | border-radius: 100%; 24 | background: #fff; 25 | } 26 | } 27 | `; 28 | } 29 | } 30 | 31 | class DomainPanelPercentTube extends Jinkela { 32 | get value() { return +Number(this.blood.value).toFixed(4); } 33 | set value(value) { 34 | if (/%$/.test(value)) value = parseFloat(value) / 100; 35 | this.blood.value = value; 36 | } 37 | @autobind mouseup(event) { 38 | document.body.removeEventListener('mousemove', this.mousemove); 39 | document.body.removeEventListener('mouseup', this.mouseup); 40 | this.mousemove(event); 41 | // Unlock dialog 42 | setTimeout(() => dialog.dontCancel = false); 43 | document.body.style.WebkitUserSelect = ''; 44 | } 45 | @autobind mousemove(event) { 46 | let { left } = this.element.getBoundingClientRect(); 47 | this.value = Math.min(Math.max(event.clientX - left, 0), this.width) / this.width; 48 | if (typeof this.onChange === 'function') this.onChange(); 49 | } 50 | @autobind mousedown(event) { 51 | document.body.addEventListener('mousemove', this.mousemove); 52 | document.body.addEventListener('mouseup', this.mouseup); 53 | this.mousemove(event); 54 | // Lock dialog 55 | dialog.dontCancel = true; 56 | document.body.style.WebkitUserSelect = 'none'; 57 | } 58 | init() { 59 | Object.defineProperty(this.element, 'value', { 60 | get: () => this.value, 61 | set: value => this.value = value 62 | }); 63 | this.element.addEventListener('mousedown', this.mousedown); 64 | this.blood = new DomainPanelPercentBlood().renderTo(this); 65 | this.value = Number(this.value) || 0; 66 | } 67 | get width() { return 300 - 80; /* The 80 is width of TextField */ } 68 | get styleSheet() { 69 | let height = 6; 70 | return ` 71 | :scope { 72 | vertical-align: middle; 73 | width: ${this.width}px; 74 | display: inline-block; 75 | height: ${height}px; 76 | line-height: ${height}px; 77 | text-align: center; 78 | font-size: 12px; 79 | color: #fff; 80 | background: #ccc; 81 | cursor: pointer; 82 | position: relative; 83 | } 84 | `; 85 | } 86 | } 87 | 88 | class DomainPanelPercentText extends TextField { 89 | get min() { return 0; } 90 | get max() { return 100; } 91 | get type() { return 'number'; } 92 | get styleSheet() { 93 | return ` 94 | :scope { 95 | vertical-align: middle; 96 | margin-right: 16px; 97 | width: 64px; 98 | } 99 | `; 100 | } 101 | } 102 | 103 | class DomainPanelPercent extends Jinkela { 104 | init() { 105 | 106 | let input = new DomainPanelPercentText({ 107 | onInput() { 108 | if (this.element.value > 100) this.element.value = 100; 109 | if (this.element.value < 0) this.element.value = 0; 110 | tube.element.value = this.element.value / 100; 111 | } 112 | }).renderTo(this); 113 | 114 | let tube = new DomainPanelPercentTube({ 115 | onChange() { input.element.value = (this.element.value * 100).toFixed(2); } 116 | }).renderTo(this); 117 | 118 | Object.defineProperty(this.element, 'value', { 119 | get: () => tube.element.value, 120 | set: value => { 121 | tube.element.value = value; 122 | input.element.value = (value * 100).toFixed(2); 123 | } 124 | }); 125 | 126 | } 127 | get styleSheet() { 128 | return ` 129 | :scope { 130 | white-space: nowrap; 131 | } 132 | `; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /frontend/src/domain/ctrls/textarea.js: -------------------------------------------------------------------------------- 1 | class DomainPanelTextArea extends TextField { 2 | get template() { return ''; } 3 | get styleSheet() { 4 | return ` 5 | :scope { 6 | width: 300px; 7 | min-height: 80px; 8 | } 9 | `; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/domain/ctrls/textfield.js: -------------------------------------------------------------------------------- 1 | class DomainPanelTextField extends TextField { 2 | init() { 3 | if ('text' in this) this.element.value = this.text; 4 | if ('readonly' in this) this.element.setAttribute('readonly', 'readonly'); 5 | } 6 | get styleSheet() { 7 | return ` 8 | :scope { 9 | width: 300px; 10 | &[readonly] { 11 | background: #faf5f5; 12 | } 13 | } 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/domain/domainpanel.js: -------------------------------------------------------------------------------- 1 | class DomainPanel extends FormPanel { 2 | get template() { 3 | return ` 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 |
      Path
      Name
      Type
      Value
      Comment
      28 | 29 | 30 |
      33 | `; 34 | } 35 | get value() { 36 | return { 37 | path: this.pathField.value, 38 | name: this.nameField.value, 39 | value: this.valueField.value, 40 | type: this.typeField.value, 41 | comment: this.commentField.value 42 | }; 43 | } 44 | @getonce static get typeMap() { 45 | return { 46 | 2: DomainPanelTextArea, 47 | 3: DomainPanelNumber, 48 | 4: DomainPanelBoolean, 49 | 5: DomainPanelPercent, 50 | 6: DomainPanelDatePicker, 51 | 9: DomainPanelJSON 52 | }; 53 | } 54 | @getonce setValueType(type) { 55 | let { typeMap } = this.constructor; 56 | this.valueField = new (typeMap[type] || FormTextField)(); 57 | } 58 | } 59 | 60 | class DomainPanelWithCreating extends DomainPanel { 61 | @getonce get title() { return 'Creating'; } 62 | init() { 63 | this.pathField = new FormTextField(); 64 | this.nameField = new FormTextField(); 65 | this.commentField = new FormTextField(); 66 | this.typeField = new TypeSelector({ onValueSet: type => this.setValueType(type) }); 67 | this.savingButton = new Button({ text: 'Save', onClick: () => this.onSave(), 'class': 'primary' }); 68 | this.cancelButton = new Button({ text: 'Cancel', onClick: dialog.cancel }); 69 | } 70 | } 71 | 72 | class DomainPanelWithEditing extends DomainPanel { 73 | @getonce get title() { return 'Editing'; } 74 | init() { 75 | if (!this.parent) throw new Error('require a parent in edit'); 76 | this.pathField = new FormTextField({ text: this.parent.path, readonly: true }); 77 | this.nameField = new FormTextField({ text: this.parent.name, readonly: true }); 78 | this.commentField = new FormTextField({ text: this.parent.comment }); 79 | this.typeField = new TypeSelector({ value: this.parent.type }); 80 | this.setValueType(this.parent.type); 81 | this.valueField.value = this.parent.value; 82 | this.savingButton = new Button({ text: 'Save', onClick: () => this.onSave(), 'class': 'primary' }); 83 | this.cancelButton = new Button({ text: 'Cancel', onClick: dialog.cancel }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/domain/filterbar.js: -------------------------------------------------------------------------------- 1 | class FilterBarTextField extends TextField { 2 | get styleSheet() { 3 | return ` 4 | :scope { 5 | margin-right: 1em; 6 | transition: width 200ms ease; 7 | &:focus { 8 | border: 1px solid #dbc6c1; 9 | width: 200px; 10 | } 11 | } 12 | `; 13 | } 14 | 15 | } 16 | 17 | class FilterBar extends Jinkela { 18 | init() { 19 | new FilterBarTextField({ placeholder: 'path filter', name: 'path', onInput: this.onInput }).renderTo(this); 20 | new FilterBarTextField({ placeholder: 'name filter', name: 'name', onInput: this.onInput }).renderTo(this); 21 | new FilterBarTextField({ placeholder: 'value filter', name: 'value', onInput: this.onInput }).renderTo(this); 22 | } 23 | @autobind onInput(event) { 24 | if (typeof this.onFilterChange === 'function') this.onFilterChange(event); 25 | } 26 | get styleSheet() { 27 | return ` 28 | :scope { 29 | float: left; 30 | } 31 | `; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/domain/index.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/domain/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('DOMContentLoaded', () => { 2 | let page = new Page({ Content: Main }).renderTo(document.body); 3 | addEventListener('hashchange', () => page.frame.content.update()); 4 | }); 5 | -------------------------------------------------------------------------------- /frontend/src/domain/list.js: -------------------------------------------------------------------------------- 1 | class ClientListItemAction extends Jinkela { 2 | get template() { return ``; } 3 | init() { 4 | this.element.textContent = this.text; 5 | this.element.addEventListener('click', this.onClick); 6 | } 7 | get styleSheet() { 8 | return ` 9 | a:scope { 10 | border: 1px solid rgb(225, 149, 123); 11 | color: rgb(225, 149, 123); 12 | padding: 2px 4px; 13 | background: #fff; 14 | &:hover { 15 | border-color: #cc3400; 16 | background: #cc3400; 17 | color: #fff; 18 | } 19 | } 20 | `; 21 | } 22 | } 23 | 24 | class ClientListItem extends Item { 25 | init() { 26 | switch (this.type) { 27 | case 5: 28 | this.valueView = (this.value * 100).toFixed(2) + '%'; 29 | break; 30 | case 6: 31 | this.valueView = new Date(this.value).toLocaleDateString('zh-CN').replace(/\//g, '-').replace(/\b\d\b/g, '0$&'); 32 | break; 33 | case 9: 34 | this.valueView = JSON.stringify(this.value); 35 | break; 36 | default: 37 | this.valueView = this.value; 38 | } 39 | this.pathLink = `/crayfish/${this.domain}${this.path}.crayfish`; 40 | if (this.canEdit) new ClientListItemAction({ text: 'Edit', onClick: this.edit }).renderTo(this.actions); 41 | if (this.canChange) new ClientListItemAction({ text: 'Remove', onClick: this.remove }).renderTo(this.actions); 42 | new ClientListItemAction({ text: 'History', onClick: this.viewHistory }).renderTo(this.actions); 43 | this.isUnpublished = this.update_at > this.publish_at; 44 | } 45 | @autobind viewHistory() { 46 | open(`/changelog/#id=${this.id}&domain=${this.domain}`); 47 | } 48 | @autobind remove() { 49 | let { id, parent } = this; 50 | dialog.popup(new ConfirmPanel({ 51 | text: 'Are you sure to REMOVE this record?', 52 | async onYes() { 53 | let { domain } = new UParams(); 54 | let response = await fetch(`/api/domains/${domain}/${id}`, { 55 | method: 'DELETE', 56 | credentials: 'include' 57 | }); 58 | if (response.status < 400) { 59 | dialog.cancel(); 60 | parent.update(); 61 | } else { 62 | let error = await response.json(); 63 | alert(error.message); 64 | } 65 | } 66 | }, { parent: this })); 67 | } 68 | @autobind edit() { 69 | let { id, parent } = this; 70 | dialog.popup(new DomainPanelWithEditing({ 71 | async onSave() { 72 | let form; 73 | try { 74 | form = this.value; 75 | } catch (error) { 76 | return alert(error.message); 77 | } 78 | let { value, comment } = form; 79 | let { domain } = new UParams(); 80 | let response = await fetch(`/api/domains/${domain}/${id}`, { 81 | method: 'PATCH', 82 | headers: { 'Content-Type': 'application/json' }, 83 | body: JSON.stringify({ value, comment }), 84 | credentials: 'include' 85 | }); 86 | if (response.status < 400) { 87 | dialog.cancel(); 88 | parent.update(); 89 | } else { 90 | let error = await response.json(); 91 | alert(error.message); 92 | } 93 | } 94 | }, { parent: this })); 95 | } 96 | get isVisible() { return this.$isVisible; } 97 | set isVisible(value) { 98 | if (this.$isVisible === value) return; 99 | this.$isVisible = value; 100 | this.element.style.display = value ? 'table-row' : 'none'; 101 | } 102 | get isUnpublished() { return this.$isUnpublished; } 103 | set isUnpublished(value) { 104 | if (this.$isUnpublished === value) return; 105 | if ((this.$isUnpublished = value)) { 106 | this.element.setAttribute('unpublished', ''); 107 | } else { 108 | this.element.removeAttribute('unpublished'); 109 | } 110 | } 111 | get template() { 112 | return ` 113 | 114 | {path} 115 | {name} 116 | 117 | 118 | {valueView} 119 | 120 | {comment} 121 | 122 | 123 | `; 124 | } 125 | get styleSheet() { 126 | return ` 127 | :scope { 128 | &.weak-top td { 129 | border-top-color: #fbf6f1; 130 | border-top-style: dashed; 131 | } 132 | &.weak-bottom td { 133 | border-bottom-color: #f8f3ee; 134 | border-bottom-style: dashed; 135 | } 136 | a { 137 | color: inherit; 138 | text-decoration: none; 139 | } 140 | [ref="actions"] a + a { 141 | margin-left: 1em; 142 | } 143 | &[unpublished] { 144 | .unpublished-badge:after { 145 | margin-right: 4px; 146 | color: rgb(225, 149, 123); 147 | content: 'UPDATED'; 148 | } 149 | } 150 | } 151 | `; 152 | } 153 | } 154 | 155 | class ClientListHead extends Jinkela { 156 | get template() { 157 | return ` 158 | 159 | Path 160 | Name 161 | Value 162 | Comment 163 | Action 164 | 165 | `; 166 | } 167 | } 168 | 169 | class ClientList extends DataTable { 170 | init() { 171 | new ClientListHead().renderTo(this.thead); 172 | this.filters = {}; 173 | this.update(); 174 | } 175 | async update() { 176 | let { domain } = new UParams(); 177 | let data = await (await fetch('/api/domains/' + domain, { credentials: 'include' })).json(); 178 | let { permissions } = await $user; 179 | let canChange = ~permissions.indexOf('CHANGE'); 180 | let canEdit = ~permissions.indexOf('EDIT'); 181 | data.forEach(item => { 182 | item.parent = this; 183 | item.domain = domain; 184 | item.canChange = canChange; 185 | item.canEdit = canEdit; 186 | }); 187 | this.tbody.innerHTML = ''; 188 | this.data = ClientListItem.cast(data).renderTo(this.tbody); 189 | if (this.data.length) this.data.reduce((last, item) => { 190 | if (last.path === item.path) { 191 | last.element.classList.add('weak-bottom'); 192 | item.element.classList.add('weak-top'); 193 | } 194 | return item; 195 | }); 196 | this.applyFilter(); 197 | } 198 | set isShowComment(state) { 199 | if (state) { 200 | this.element.setAttribute('show-comment', ''); 201 | } else { 202 | this.element.removeAttribute('show-comment'); 203 | } 204 | } 205 | set isShowDetail(state) { 206 | if (state) { 207 | this.element.setAttribute('show-detail', ''); 208 | } else { 209 | this.element.removeAttribute('show-detail'); 210 | } 211 | } 212 | get styleSheet() { 213 | return ` 214 | :scope { 215 | tr > *:nth-child(5) { 216 | text-align: right; 217 | white-space: nowrap; 218 | } 219 | &:not([show-detail]) { 220 | td { 221 | max-width: 500px; 222 | white-space: nowrap; 223 | overflow: hidden; 224 | text-overflow: ellipsis; 225 | } 226 | } 227 | &:not([show-comment]) { 228 | tr > *:nth-child(4) { 229 | display: none; 230 | } 231 | } 232 | } 233 | `; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /frontend/src/domain/main.js: -------------------------------------------------------------------------------- 1 | class Main extends Jinkela { 2 | @autobind onFilterChange({ target: { name, value } }) { 3 | try { 4 | value = new RegExp(value); 5 | } catch (e) { 6 | value = new RegExp(); 7 | } 8 | this.dataTable.setFilter(name, value); 9 | } 10 | init() { 11 | this.update(); 12 | } 13 | update() { 14 | this.element.innerHTML = ''; 15 | let { domain } = new UParams(); 16 | if (!domain) return location.pathname = '/default/'; 17 | new Caption({ text: domain }).renderTo(this); 18 | let dataTable = this.dataTable = new ClientList(); 19 | let { onFilterChange } = this; 20 | new ControlBar({ onFilterChange, dataTable, parent: this }).renderTo(this); 21 | this.dataTable.renderTo(this); 22 | } 23 | get styleSheet() { 24 | return ` 25 | :scope { 26 | display: flex; 27 | height: 100%; 28 | flex-direction: column; 29 | } 30 | `; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/domain/publishingpanel.js: -------------------------------------------------------------------------------- 1 | class PublishingPanelPath extends Item { 2 | init() { 3 | let { publishAt, updateAt } = this; 4 | this.checked = publishAt < updateAt; 5 | this.title = `updateAt: ${updateAt}\npublishAt: ${publishAt}`; 6 | } 7 | get checked() { return this.checkbox.checked; } 8 | set checked(value) { 9 | this.checkbox.checked = value; 10 | this.change(); 11 | } 12 | change() { 13 | this.element.setAttribute('checked', !!this.checked); 14 | } 15 | get template() { 16 | return ` 17 | 21 | `; 22 | } 23 | get styleSheet() { 24 | return ` 25 | :scope { 26 | display: inline-block; 27 | border: 1px solid #eee; 28 | background: #fafafa; 29 | padding: 3px 6px; 30 | margin: .5em; 31 | cursor: pointer; 32 | vertical-align: top; 33 | &[checked=true] { 34 | padding: 2px 5px; 35 | border-width: 2px; 36 | border-color: #cc3400; 37 | color: #cc3400; 38 | } 39 | > input { display: none; } 40 | } 41 | `; 42 | } 43 | } 44 | 45 | class PublishingPanelPathList extends Jinkela { 46 | init() { 47 | this.list = PublishingPanelPath.cast(this.squashedList).renderTo(this); 48 | } 49 | get value() { 50 | return this.list.filter(item => item.checked).map(item => item.path); 51 | } 52 | get styleSheet() { 53 | return ` 54 | :scope { 55 | font-size: 12px; 56 | font-family: Monospace; 57 | text-align: left; 58 | margin-bottom: 2em; 59 | padding-bottom: 2em; 60 | border-bottom: 1px solid #ebe6e1; 61 | } 62 | `; 63 | } 64 | } 65 | 66 | class PublishingPanelButtons extends Jinkela { 67 | init() { 68 | this.yesButton = new Button({ text: 'Publish', onClick: () => this.publish(), 'class': 'primary' }).renderTo(this); 69 | this.cancelButton = new Button({ text: 'Cancel', onClick: dialog.cancel }).renderTo(this); 70 | } 71 | get styleSheet() { 72 | return ` 73 | :scope { 74 | font-size: 12px; 75 | margin: 2em; 76 | } 77 | `; 78 | } 79 | async publish() { 80 | if (this.publishing) return this.publishing; 81 | this.publishing = fetch(`/api/cdn/${this.domain}/publish`, { 82 | method: 'POST', 83 | credentials: 'include', 84 | headers: { 'Content-Type': 'application/json' }, 85 | body: JSON.stringify(this.parent.list.value) 86 | }); 87 | let response = await this.publishing; 88 | if (response.status < 400) { 89 | SuccessPanel.popup({ text: 'Published Successfully' }); 90 | this.dataTable.update(); 91 | } else { 92 | let error = await response.json(); 93 | ErrorPanel.popup({ text: error.message }); 94 | } 95 | this.publishing = null; 96 | } 97 | } 98 | 99 | class PublishingPanel extends Jinkela { 100 | 101 | async init() { 102 | this.title = 'Confirm to PUBLISH'; 103 | this.domain = new UParams().domain; 104 | let squashedListResponse = await fetch(`/api/squash/${this.domain}`, { credentials: 'include' }); 105 | let squashedList = await squashedListResponse.json(); 106 | this.list = new PublishingPanelPathList({ squashedList }).renderTo(this); 107 | new PublishingPanelButtons(this).renderTo(this); 108 | } 109 | 110 | get styleSheet() { 111 | return ` 112 | :scope { 113 | h3 { 114 | margin: 0 0 2em 0; 115 | } 116 | button { 117 | margin: 0 1em; 118 | color: inherit; 119 | } 120 | padding: 2em; 121 | } 122 | `; 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /frontend/src/domain/typeselector.js: -------------------------------------------------------------------------------- 1 | class TypeSelectorItem extends ListItem { 2 | get template() { return ``; } 3 | init() { 4 | this.element.textContent = this.text; 5 | this.element.value = this.value; 6 | } 7 | } 8 | 9 | class TypeSelectorList extends Jinkela { 10 | get template() { return ``; } 11 | init() { 12 | TypeSelectorItem.cast(this.data).renderTo(this); 13 | } 14 | } 15 | 16 | class TypeSelector extends Jinkela { 17 | @getonce get data() { 18 | return [ 19 | { text: 'String', value: 1 }, 20 | { text: 'Text', value: 2 }, 21 | { text: 'Number', value: 3 }, 22 | { text: 'Boolean', value: 4 }, 23 | { text: 'Percent', value: 5 }, 24 | { text: 'Date', value: 6 }, 25 | { text: 'JSON', value: 9 } 26 | ]; 27 | } 28 | set value(value) { 29 | this.$value = value; 30 | if (typeof this.onValueSet === 'function') this.onValueSet(value); 31 | } 32 | get value() { 33 | return this.$value; 34 | } 35 | init() { 36 | Object.defineProperty(this.element, 'value', { 37 | get: () => this.value, 38 | set: value => this.value = value 39 | }); 40 | if (this.value) { 41 | this.data.some(item => { 42 | if (item.value === this.value) { 43 | this.element.textContent = item.text; 44 | return true; 45 | } 46 | }); 47 | } else { 48 | new TypeSelectorList({ 49 | data: this.data, 50 | onChange: ({ target }) => { 51 | this.value = +target.value; 52 | } 53 | }).renderTo(this); 54 | this.value = this.data[0].value; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/domains/actionbar.js: -------------------------------------------------------------------------------- 1 | class ActionBarCreatingButton extends Button { 2 | get text() { return 'Create'; } 3 | get class() { return 'primary'; } 4 | } 5 | 6 | class ActionBar extends Jinkela { 7 | init() { 8 | new ActionBarCreatingButton({ onClick: this.onClick }).renderTo(this); 9 | } 10 | @autobind onClick(event) { 11 | let { dataTable } = this.parent.parent; 12 | let { frame } = this.parent.parent.page; 13 | dialog.popup(new ClientPanelWithCreating({ 14 | async onSave() { 15 | let { domain } = this.value; 16 | let response = await fetch('/api/domains', { 17 | method: 'POST', 18 | headers: { 'Content-Type': 'application/json' }, 19 | body: JSON.stringify({ domain }), 20 | credentials: 'include' 21 | }); 22 | if (response.status < 400) { 23 | dialog.cancel(); 24 | frame.update(); 25 | } else { 26 | let error = await response.json(); 27 | alert(error.message); 28 | } 29 | } 30 | })); 31 | } 32 | get styleSheet() { 33 | return ` 34 | :scope { 35 | float: right; 36 | } 37 | `; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/domains/controlbar.js: -------------------------------------------------------------------------------- 1 | class ControlBar extends Jinkela { 2 | init() { 3 | let { onFilterChange } = this; 4 | new FilterBar({ onFilterChange }).renderTo(this); 5 | new ActionBar({ parent: this }).renderTo(this); 6 | } 7 | get styleSheet() { 8 | return ` 9 | :scope { 10 | height: 50px; 11 | line-height: 50px; 12 | overflow: hidden; 13 | } 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/domains/filterbar.js: -------------------------------------------------------------------------------- 1 | class FilterBarTextField extends TextField { 2 | get styleSheet() { 3 | return ` 4 | :scope { 5 | margin-right: 1em; 6 | transition: width 200ms ease; 7 | &:focus { 8 | border: 1px solid #dbc6c1; 9 | width: 200px; 10 | } 11 | } 12 | `; 13 | } 14 | 15 | } 16 | 17 | class FilterBar extends Jinkela { 18 | init() { 19 | new FilterBarTextField({ placeholder: 'domain filter', name: 'domain', onInput: this.onInput }).renderTo(this); 20 | } 21 | @autobind onInput(event) { 22 | if (typeof this.onFilterChange === 'function') this.onFilterChange(event); 23 | } 24 | get styleSheet() { 25 | return ` 26 | :scope { 27 | float: left; 28 | } 29 | `; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/domains/index.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/domains/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('DOMContentLoaded', () => { 2 | new Page({ Content: Main }).renderTo(document.body); 3 | }); 4 | -------------------------------------------------------------------------------- /frontend/src/domains/list.js: -------------------------------------------------------------------------------- 1 | class ClientListItem extends Jinkela { 2 | static cast(arr) { 3 | let result = arr.map(raw => new this(raw)); 4 | result.renderTo = parent => { 5 | result.forEach(item => item.renderTo(parent)); 6 | return result; 7 | }; 8 | return result; 9 | } 10 | init() { 11 | this.privilegeLink = `/privilege/#domain=${this.domain}`; 12 | this.publishlogLink = `/publishlog/#domain=${this.domain}`; 13 | } 14 | @autobind onRemove() { 15 | let { domain, parent } = this; 16 | dialog.popup(new ConfirmPanel({ 17 | text: 'Are you sure to REMOVE this record?', 18 | async onYes() { 19 | let response = await fetch(`/api/domains/${domain}`, { 20 | method: 'DELETE', 21 | credentials: 'include' 22 | }); 23 | if (response.status < 400) { 24 | dialog.cancel(); 25 | parent.parent.page.frame.update(); 26 | } else { 27 | let error = await response.json(); 28 | alert(error.message); 29 | } 30 | } 31 | }, { parent: this })); 32 | } 33 | get isVisible() { return this.$isVisible; } 34 | set isVisible(value) { 35 | if (this.$isVisible === value) return; 36 | this.$isVisible = value; 37 | this.element.style.display = value ? 'table-row' : 'none'; 38 | } 39 | get template() { 40 | return ` 41 | 42 | {domain} 43 | 44 | Privilege 45 | Publish Log 46 | 47 | {time} 48 | 49 | Remove 50 | 51 | 52 | `; 53 | } 54 | get styleSheet() { 55 | return ` 56 | :scope { 57 | a { 58 | color: inherit; 59 | text-decoration: none; 60 | } 61 | } 62 | `; 63 | } 64 | } 65 | 66 | class ClientListHead extends Jinkela { 67 | get template() { 68 | return ` 69 | 70 | Domain 71 | Links 72 | Time 73 | Action 74 | 75 | `; 76 | } 77 | } 78 | 79 | class ClientList extends DataTable { 80 | init() { 81 | new ClientListHead().renderTo(this.thead); 82 | this.filters = {}; 83 | this.update(); 84 | } 85 | async update() { 86 | let data = await (await fetch('/api/domains', { credentials: 'include' })).json(); 87 | data.forEach(item => { 88 | item.time = new Date(Date.parse(item.create_at)) 89 | .toLocaleString('zh-CN', { hour12: false }) 90 | .replace(/\//g, '-').replace(/\b\d\b/g, '0$&'); 91 | item.parent = this; 92 | }); 93 | this.tbody.innerHTML = ''; 94 | this.data = ClientListItem.cast(data).renderTo(this.tbody); 95 | this.applyFilter(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /frontend/src/domains/main.js: -------------------------------------------------------------------------------- 1 | class Main extends Jinkela { 2 | @autobind onFilterChange({ target: { name, value } }) { 3 | try { 4 | value = new RegExp(value); 5 | } catch (e) { 6 | value = new RegExp(); 7 | } 8 | this.dataTable.setFilter(name, value); 9 | } 10 | init() { 11 | let { domain } = new UParams(); 12 | this.domain = domain; 13 | new ControlBar({ onFilterChange: this.onFilterChange, parent: this }).renderTo(this); 14 | this.dataTable = new ClientList({ parent: this }).renderTo(this); 15 | } 16 | get template() { 17 | return ` 18 |
      19 |

      Domains

      20 |
      21 | `; 22 | } 23 | get styleSheet() { 24 | return ` 25 | :scope { 26 | display: flex; 27 | height: 100%; 28 | flex-direction: column; 29 | } 30 | `; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/domains/panel.js: -------------------------------------------------------------------------------- 1 | class ClientPanel extends FormPanel { 2 | get template() { 3 | return ` 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 |
      Domain
      12 | 13 | 14 |
      17 | `; 18 | } 19 | get value() { 20 | return { 21 | domain: this.domainField.value 22 | }; 23 | } 24 | } 25 | 26 | class ClientPanelWithCreating extends ClientPanel { 27 | @getonce get title() { return 'Creating'; } 28 | init() { 29 | this.domainField = new FormTextField(); 30 | this.savingButton = new Button({ text: 'Save', onClick: () => this.onSave(), 'class': 'primary' }); 31 | this.cancelButton = new Button({ text: 'Cancel', onClick: dialog.cancel }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElemeFE/crayfish/599ddff22a5b2e329b36711e5ade064265070cf1/frontend/src/favicon.ico -------------------------------------------------------------------------------- /frontend/src/index/index.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/index/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('DOMContentLoaded', () => { 2 | let page = new Page({ Content: Welcome }).renderTo(document.body); 3 | addEventListener('hashchange', () => page.frame.content.update()); 4 | }); 5 | -------------------------------------------------------------------------------- /frontend/src/index/welcome.js: -------------------------------------------------------------------------------- 1 | class Welcome extends Jinkela { 2 | init() { 3 | this.update(); 4 | } 5 | update() { 6 | let { domain = '' } = new UParams(); 7 | switch (true) { 8 | case domain && location.pathname === '/': 9 | return location.pathname = '/domain/'; 10 | default: 11 | this.element.innerHTML = '

      Crayfish

      '; 12 | } 13 | } 14 | get styleSheet() { 15 | return ` 16 | :scope { 17 | margin: 2em 0; 18 | line-height: initial; 19 | } 20 | `; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/metas.inc: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Crayfish 2 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /frontend/src/privilege/actionbar.js: -------------------------------------------------------------------------------- 1 | class ActionBarCreatingButton extends Button { 2 | get text() { return 'Create'; } 3 | get class() { return 'primary'; } 4 | } 5 | 6 | class ActionBar extends Jinkela { 7 | init() { 8 | new ActionBarCreatingButton({ onClick: this.onClick }).renderTo(this); 9 | } 10 | @autobind onClick(event) { 11 | let { dataTable } = this.parent.parent; 12 | let { frame } = this.parent.parent.page; 13 | dialog.popup(new ClientPanelWithCreating({ 14 | async onSave() { 15 | let { username } = this.value; 16 | let { domain } = new UParams(); 17 | let response = await fetch(`/api/privilege/${domain}`, { 18 | method: 'POST', 19 | headers: { 'Content-Type': 'application/json' }, 20 | body: JSON.stringify({ name: username }), 21 | credentials: 'include' 22 | }); 23 | if (response.status < 400) { 24 | dialog.cancel(); 25 | frame.update(); 26 | } else { 27 | let error = await response.json(); 28 | alert(error.message); 29 | } 30 | } 31 | })); 32 | } 33 | get styleSheet() { 34 | return ` 35 | :scope { 36 | float: right; 37 | } 38 | `; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/privilege/controlbar.js: -------------------------------------------------------------------------------- 1 | class ControlBar extends Jinkela { 2 | init() { 3 | let { onFilterChange } = this; 4 | new FilterBar({ onFilterChange }).renderTo(this); 5 | new ActionBar({ parent: this }).renderTo(this); 6 | } 7 | get styleSheet() { 8 | return ` 9 | :scope { 10 | height: 50px; 11 | line-height: 50px; 12 | overflow: hidden; 13 | } 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/privilege/filterbar.js: -------------------------------------------------------------------------------- 1 | class FilterBarTextField extends TextField { 2 | get styleSheet() { 3 | return ` 4 | :scope { 5 | margin-right: 1em; 6 | transition: width 200ms ease; 7 | &:focus { 8 | border: 1px solid #dbc6c1; 9 | width: 200px; 10 | } 11 | } 12 | `; 13 | } 14 | 15 | } 16 | 17 | class FilterBar extends Jinkela { 18 | init() { 19 | new FilterBarTextField({ placeholder: 'user filter', name: 'userName', onInput: this.onInput }).renderTo(this); 20 | } 21 | @autobind onInput(event) { 22 | if (typeof this.onFilterChange === 'function') this.onFilterChange(event); 23 | } 24 | get styleSheet() { 25 | return ` 26 | :scope { 27 | float: left; 28 | } 29 | `; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/privilege/index.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/privilege/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('DOMContentLoaded', () => { 2 | let page = new Page({ Content: Main }).renderTo(document.body); 3 | addEventListener('hashchange', () => page.frame.content.update()); 4 | }); 5 | -------------------------------------------------------------------------------- /frontend/src/privilege/list.js: -------------------------------------------------------------------------------- 1 | class ClientListItem extends Jinkela { 2 | init() { 3 | this.userName = this.user.name + '(' + this.user.email + ')'; 4 | } 5 | static cast(arr) { 6 | let result = arr.map(raw => new this(raw)); 7 | result.renderTo = parent => { 8 | result.forEach(item => item.renderTo(parent)); 9 | return result; 10 | }; 11 | return result; 12 | } 13 | @autobind onRemove() { 14 | let { id, parent } = this; 15 | let { domain } = new UParams(); 16 | dialog.popup(new ConfirmPanel({ 17 | text: 'Are you sure to REMOVE this record?', 18 | async onYes() { 19 | let response = await fetch(`/api/privilege/${domain}/${id}`, { 20 | method: 'DELETE', 21 | credentials: 'include' 22 | }); 23 | if (response.status < 400) { 24 | dialog.cancel(); 25 | parent.parent.page.frame.update(); 26 | } else { 27 | let error = await response.json(); 28 | alert(error.message); 29 | } 30 | } 31 | }, { parent: this })); 32 | } 33 | get isVisible() { return this.$isVisible; } 34 | set isVisible(value) { 35 | if (this.$isVisible === value) return; 36 | this.$isVisible = value; 37 | this.element.style.display = value ? 'table-row' : 'none'; 38 | } 39 | get template() { 40 | return ` 41 | 42 | {userName} 43 | {time} 44 | 45 | Remove 46 | 47 | 48 | `; 49 | } 50 | get styleSheet() { 51 | return ` 52 | :scope { 53 | a { 54 | color: inherit; 55 | text-decoration: none; 56 | } 57 | } 58 | `; 59 | } 60 | } 61 | 62 | class ClientListHead extends Jinkela { 63 | get template() { 64 | return ` 65 | 66 | User Name 67 | Time 68 | Action 69 | 70 | `; 71 | } 72 | } 73 | 74 | class ClientList extends DataTable { 75 | init() { 76 | new ClientListHead().renderTo(this.thead); 77 | this.filters = {}; 78 | this.update(); 79 | } 80 | async update() { 81 | let { domain } = new UParams(); 82 | let data = await (await fetch(`/api/privilege/${domain}`, { credentials: 'include' })).json(); 83 | data.forEach(item => { 84 | item.time = new Date(Date.parse(item.create_at)) 85 | .toLocaleString('zh-CN', { hour12: false }) 86 | .replace(/\//g, '-').replace(/\b\d\b/g, '0$&'); 87 | item.parent = this; 88 | }); 89 | this.tbody.innerHTML = ''; 90 | this.data = ClientListItem.cast(data).renderTo(this.tbody); 91 | this.applyFilter(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /frontend/src/privilege/main.js: -------------------------------------------------------------------------------- 1 | class Main extends Jinkela { 2 | @autobind onFilterChange({ target: { name, value } }) { 3 | try { 4 | value = new RegExp(value); 5 | } catch (e) { 6 | value = new RegExp(); 7 | } 8 | this.dataTable.setFilter(name, value); 9 | } 10 | init() { 11 | this.update(); 12 | } 13 | update() { 14 | let { domain } = new UParams(); 15 | if (!domain) return location.pathname = '/default/'; 16 | this.domain = domain; 17 | this.element.innerHTML = ''; 18 | new Caption({ text: `Privilege of ${domain}` }).renderTo(this); 19 | new ControlBar({ onFilterChange: this.onFilterChange, parent: this }).renderTo(this); 20 | this.dataTable = new ClientList({ parent: this }).renderTo(this); 21 | } 22 | get styleSheet() { 23 | return ` 24 | :scope { 25 | display: flex; 26 | height: 100%; 27 | flex-direction: column; 28 | } 29 | `; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/privilege/panel.js: -------------------------------------------------------------------------------- 1 | class ClientPanelUserSearch extends Jinkela { 2 | init() { 3 | this.textField = new FormTextField({ onInput: () => this.input() }).renderTo(this); 4 | this.textField.element.addEventListener('focus', () => this.focus()); 5 | this.textField.element.addEventListener('blur', () => this.blur()); 6 | Object.defineProperty(this.element, 'value', { 7 | get: () => this.textField.element.value, 8 | set: value => this.textField.element.value = value 9 | }); 10 | } 11 | async input() { 12 | let { value } = this.element; 13 | if (!value.length) return this.close(); 14 | let response = await fetch(`/api/searchuser?kw=${encodeURIComponent(value)}`, { credentials: 'include' }); 15 | let result = await response.json(); 16 | let data = result.map(({ name, email }) => ({ text: `${name}(${email})` })); 17 | if (data.length) { 18 | this.ul.innerHTML = ''; 19 | ListItem.cast(data).renderTo(this.ul); 20 | this.ul.classList.add('active'); 21 | } else { 22 | this.ul.classList.remove('active'); 23 | } 24 | } 25 | async focus() { 26 | this.input(); 27 | } 28 | async blur() { 29 | setTimeout(() => { 30 | this.ul.classList.remove('active'); 31 | }, 60); 32 | } 33 | click(event) { 34 | let { target } = event; 35 | if (target.tagName !== 'A') return; 36 | let mailName = target.textContent.match(/(([^@]*?)@[^@]*?)$|$/)[1]; 37 | this.element.value = mailName; 38 | this.close(); 39 | } 40 | close() { 41 | this.ul.classList.remove('active'); 42 | } 43 | get template() { 44 | return ` 45 | 46 |
        47 |
        48 | `; 49 | } 50 | get styleSheet() { 51 | let tipWidth = 70; 52 | return ` 53 | :scope { 54 | position: relative; 55 | a { 56 | padding: .25em .5em; 57 | color: inherit; 58 | text-decoration: none; 59 | display: block; 60 | white-space: nowrap; 61 | } 62 | li { 63 | margin: 0; 64 | line-height: 1.5; 65 | transition: background 200ms ease; 66 | &:hover { 67 | background: #f0f0f0; 68 | } 69 | } 70 | ul.active { display: block; } 71 | ul { 72 | display: none; 73 | border: 1px solid #dbc6c1; 74 | padding: 0; 75 | list-style: none; 76 | background: #fff; 77 | position: absolute; 78 | bottom: 100%; 79 | } 80 | input[type=text] { 81 | width: 200px; 82 | padding-right: ${tipWidth}px; 83 | } 84 | &::after { 85 | margin-left: -${tipWidth}px; 86 | width: ${tipWidth}px; 87 | text-align: center; 88 | display: inline-block; 89 | line-height: 32px; 90 | color: rgba(0, 0, 0, .6); 91 | font-size: 14px; 92 | font-weight: bold; 93 | font-family: Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif; 94 | vertical-align: top; 95 | position: relative; 96 | } 97 | } 98 | `; 99 | } 100 | } 101 | 102 | class ClientPanel extends FormPanel { 103 | get template() { 104 | return ` 105 | 106 | 107 | 108 | 111 | 112 | 113 | 114 | 118 | 119 |
        User Name 109 | 110 |
        115 | 116 | 117 |
        120 | `; 121 | } 122 | get value() { 123 | return { 124 | username: this.useridField.value 125 | }; 126 | } 127 | } 128 | 129 | class ClientPanelWithCreating extends ClientPanel { 130 | @getonce get title() { return 'Creating'; } 131 | init() { 132 | this.useridField = new ClientPanelUserSearch(); 133 | this.savingButton = new Button({ text: 'Save', onClick: () => this.onSave(), 'class': 'primary' }); 134 | this.cancelButton = new Button({ text: 'Cancel', onClick: dialog.cancel }); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /frontend/src/publishlog/controlbar.js: -------------------------------------------------------------------------------- 1 | class ControlBar extends Jinkela { 2 | init() { 3 | let { onFilterChange } = this; 4 | new FilterBar({ onFilterChange }).renderTo(this); 5 | } 6 | get styleSheet() { 7 | return ` 8 | :scope { 9 | display: none; /* no filter here */ 10 | height: 50px; 11 | line-height: 50px; 12 | overflow: hidden; 13 | } 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/publishlog/filterbar.js: -------------------------------------------------------------------------------- 1 | class FilterBarTextField extends TextField { 2 | get styleSheet() { 3 | return ` 4 | :scope { 5 | margin-right: 1em; 6 | transition: width 200ms ease; 7 | &:focus { 8 | border: 1px solid #dbc6c1; 9 | width: 200px; 10 | } 11 | } 12 | `; 13 | } 14 | 15 | } 16 | 17 | class FilterBar extends Jinkela { 18 | init() { 19 | // new FilterBarTextField({ placeholder: 'value filter', name: 'value', onInput: this.onInput }).renderTo(this); 20 | } 21 | @autobind onInput(event) { 22 | if (typeof this.onFilterChange === 'function') this.onFilterChange(event); 23 | } 24 | get styleSheet() { 25 | return ` 26 | :scope { 27 | float: left; 28 | } 29 | `; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/publishlog/index.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/publishlog/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('DOMContentLoaded', () => { 2 | let page = new Page({ Content: Main }).renderTo(document.body); 3 | addEventListener('hashchange', () => page.frame.content.update()); 4 | }); 5 | -------------------------------------------------------------------------------- /frontend/src/publishlog/list.js: -------------------------------------------------------------------------------- 1 | class LogListItemAction extends Jinkela { 2 | get template() { return ``; } 3 | init() { 4 | this.element.textContent = this.text; 5 | this.element.addEventListener('click', this.onClick); 6 | } 7 | } 8 | 9 | class LogListItem extends Jinkela { 10 | static cast(arr) { 11 | let result = arr.map(raw => new this(raw)); 12 | result.renderTo = parent => { 13 | result.forEach(item => item.renderTo(parent)); 14 | return result; 15 | }; 16 | return result; 17 | } 18 | init() { 19 | this.userName = this.create_by.name; 20 | this.email = this.create_by.email; 21 | this.emailLink = 'mailto:' + this.email; 22 | } 23 | get isVisible() { return this.$isVisible; } 24 | set isVisible(value) { 25 | if (this.$isVisible === value) return; 26 | this.$isVisible = value; 27 | this.element.style.display = value ? 'table-row' : 'none'; 28 | } 29 | get template() { 30 | return ` 31 | 32 | {id} 33 | {userName}{email}) 34 | {time} 35 | 36 | `; 37 | } 38 | get styleSheet() { 39 | return ` 40 | :scope { 41 | a { 42 | color: inherit; 43 | text-decoration: none; 44 | } 45 | } 46 | `; 47 | } 48 | } 49 | 50 | class LogListHead extends Jinkela { 51 | get template() { 52 | return ` 53 | 54 | Publish ID 55 | By 56 | Time 57 | 58 | `; 59 | } 60 | } 61 | 62 | class LogList extends DataTable { 63 | async init() { 64 | let { permissions } = await $user; 65 | this.canPublish = !!~permissions.indexOf('PUBLISH'); 66 | this.element.setAttribute('can-publish', this.canPublish); 67 | new LogListHead().renderTo(this.thead); 68 | this.filters = {}; 69 | this.update(); 70 | } 71 | async update() { 72 | let data = await (await fetch('/api/publishlog/' + new UParams().domain, { credentials: 'include' })).json(); 73 | data.forEach(item => { 74 | item.time = new Date(Date.parse(item.create_at)) 75 | .toLocaleString('zh-CN', { hour12: false }) 76 | .replace(/\//g, '-').replace(/\b\d\b/g, '0$&'); 77 | item.parent = this; 78 | }); 79 | this.tbody.innerHTML = ''; 80 | this.data = LogListItem.cast(data).renderTo(this.tbody); 81 | this.applyFilter(); 82 | } 83 | get styleSheet() { 84 | return ` 85 | :scope { 86 | tr > *:nth-child(4) { 87 | width: 60px; 88 | text-align: center; 89 | white-space: nowrap; 90 | display: none; 91 | } 92 | &[can-publish=true] tr > *:nth-child(4) { 93 | display: table-cell; 94 | } 95 | } 96 | `; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /frontend/src/publishlog/main.js: -------------------------------------------------------------------------------- 1 | class Main extends Jinkela { 2 | @autobind onFilterChange({ target: { name, value } }) { 3 | try { 4 | value = new RegExp(value); 5 | } catch (e) { 6 | value = new RegExp(); 7 | } 8 | this.dataTable.setFilter(name, value); 9 | } 10 | init() { 11 | this.update(); 12 | } 13 | async update() { 14 | this.element.innerHTML = ''; 15 | let { domain } = new UParams(); 16 | if (!domain) return location.pathname = '/default/'; 17 | new Caption({ text: `Publish Log of ${domain}` }).renderTo(this); 18 | new ControlBar({ onFilterChange: this.onFilterChange, parent: this }).renderTo(this); 19 | this.dataTable = new LogList({ parent: this }).renderTo(this); 20 | } 21 | get styleSheet() { 22 | return ` 23 | :scope { 24 | display: flex; 25 | height: 100%; 26 | flex-direction: column; 27 | } 28 | `; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/services/dialog.js: -------------------------------------------------------------------------------- 1 | const dialog = new class Dialog extends Jinkela { 2 | init() { 3 | document.body ? this.render() : addEventListener('DOMContentLoaded', this.render.bind(this)); 4 | this.dl.addEventListener('click', event => event.dontCancel = true); 5 | } 6 | render() { this.renderTo(document.body); } 7 | @autobind popup(content) { 8 | this.title = content.title; 9 | this.dd.innerHTML = ''; 10 | content.renderTo(this.dd); 11 | this.element.className = 'active'; 12 | } 13 | @autobind cancel() { 14 | this.element.className = ''; 15 | } 16 | maskClick(e) { 17 | if (e.dontCancel || this.dontCancel) return; 18 | this.cancel(); 19 | } 20 | get template() { 21 | return ` 22 |
        23 |
        24 |
        25 |

        {title}

        26 | 27 | 28 | 29 | 30 | 31 | 32 |
        33 |
        34 |
        35 |
        36 | `; 37 | } 38 | get styleSheet() { 39 | return ` 40 | :scope { 41 | position: fixed; 42 | z-index: 999; 43 | color: #666; 44 | top: 0; 45 | right: 0; 46 | bottom: 0; 47 | left: 0; 48 | transition: visibility 200ms ease, opacity 200ms ease; 49 | visibility: hidden; 50 | opacity: 0; 51 | background: rgba(0,0,0,0.8); 52 | text-align: center; 53 | &.active { 54 | visibility: visible; 55 | opacity: 1; 56 | dl { transform: translateX(-50%) translateY(-60%); } 57 | } 58 | > dl { 59 | box-sizing: border-box; 60 | min-width: 514px; 61 | padding: 0; 62 | margin: auto; 63 | position: absolute; 64 | top: 50%; 65 | left: 50%; 66 | transition: transform 200ms ease; 67 | transform: translateX(-50%) translateY(-80%) scale(0.8); 68 | background: #fff; 69 | display: inline-block; 70 | > dt { 71 | overflow: hidden; 72 | line-height: 48px; 73 | padding: 0px 1em; 74 | border-bottom: 1px solid #ebe6e1; 75 | display: block; 76 | font-size: 16px; 77 | position: relative; 78 | h3 { 79 | float: left; 80 | margin: 0; 81 | font-size: inherit; 82 | line-height: inherit; 83 | } 84 | a { 85 | position: absolute; 86 | margin: auto; 87 | width: 16px; 88 | height: 16px; 89 | top: 0; 90 | bottom: 0; 91 | right: 1em; 92 | color: inherit; 93 | text-decoration: none; 94 | } 95 | svg { 96 | display: block; 97 | } 98 | } 99 | > dd { margin: 0; } 100 | } 101 | } 102 | `; 103 | } 104 | }; 105 | -------------------------------------------------------------------------------- /frontend/src/services/getonce.js: -------------------------------------------------------------------------------- 1 | const getonce = (base, name, desc) => { 2 | let { get } = desc; 3 | if (get) { 4 | desc.get = function() { 5 | delete desc.get; 6 | desc.value = get.call(this); 7 | Object.defineProperty(this, name, desc); 8 | return desc.value; 9 | }; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/services/user.js: -------------------------------------------------------------------------------- 1 | const $user = fetch('/api/user', { credentials: 'include' }).then(async response => { 2 | switch (response.status) { 3 | case 200: 4 | return response.json(); 5 | case 401: 6 | $user.login(); 7 | break; 8 | default: 9 | let error = await response.json(); 10 | alert(error.message); 11 | location.location = this.ssoUrl; 12 | } 13 | return new Promise(() => {}); 14 | }); 15 | 16 | $user.SSOURL = location.origin.replace(/crayfish/, 'sso'); 17 | $user.login = () => location.href = $user.SSOURL + '/sso/login?from=' + encodeURIComponent(location.href); 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crayfish", 3 | "version": "2.0.0", 4 | "description": "前端配置管理系统", 5 | "reciperConfig": { 6 | "darkColor": "#383129", 7 | "normalColor": "#655", 8 | "primaryColor": "#d71518", 9 | "logoUrl": "//fuss10.elemecdn.com/3/99/f5532e2374dcb65a441c71d6111f7jpeg.jpeg", 10 | "languages": [ "json", "javascript", "xml" ], 11 | "items": [ 12 | { "text": "项目简介", "href": "/intro/" }, 13 | { "text": "操作手册", "href": "/manual/" } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /service.sh: -------------------------------------------------------------------------------- 1 | ##### node 2 | 3 | sudo yum install npm 4 | sudo npm install npm -g 5 | sudo n 5.5.0 6 | sudo ln -fs $(n bin 5.5.0) $(which node) 7 | 8 | ##### babel 9 | 10 | sudo npm install babel@5.8.38 -g --registry=https://registry.npm.taobao.org 11 | sudo ln -fs $(sudo npm bin -g 2>/dev/null)/babel-node /usr/bin/babel-node 12 | 13 | ##### supervisor 14 | 15 | sudo echo '[program:crayfish] 16 | command=babel-node --stage 1 /data/fe.crayfish/backend/app.js 17 | directory=/data/fe.crayfish/backend 18 | autostart=true 19 | autorestart=true 20 | stderr_logfile=/data/log/supervisor/fe.crayfish-error.log 21 | stdout_logfile=/data/log/supervisor/fe.crayfish-out.log 22 | environment=BABEL_CACHE_PATH=/tmp/babel-cache 23 | stopasgroup=true 24 | killasgroup=true 25 | user=www-data 26 | ' > /etc/supervisord.d/crayfish.ini 27 | 28 | sudo supervisorctl reread 29 | sudo supervisorctl update 30 | 31 | ##### nginx 32 | 33 | sudo yum install nginx 34 | 35 | sudo echo 'server { 36 | listen crayfish.alpha.elenet.me:80; 37 | location / { 38 | proxy_pass http://127.0.0.1:8100; 39 | } 40 | } 41 | ' > /etc/nginx/conf.d/crayfish.conf 42 | 43 | sudo nginx -s reload 44 | --------------------------------------------------------------------------------