最近包工頭喊農民工小鄭搬磚,小鄭搬完磚後沉思片刻,決定寫篇小作文分享下,作為一個初學者的全棧專案,去學習它的搭建,到落地,再到部署維護,是非常好的。
------題記
寫在前面
通過本文的學習,你可以學到
- vue2、element ui、vue-element-admin在前端的使用
- 元件設計
- echarts在前端中的使用
- eggjs在後端node專案中的使用
- docker一鍵化部署
需求分析
背景
近些年,網路詐騙案頻發,有假扮家裡茶葉滯銷的茶花女,有假扮和男朋友分手去山區支教的女教師,有告知你中了非常6+1的大獎主持人,有假扮越南那邊過來結婚的妹子,各類案件層出不窮。作為公民,我們應該在社會主義新時代下積極學習組織上宣傳反詐騙知識,提高防範意識。除此之外,對於種種詐騙案件,是網站的我們就應該封其網站,是電話的我們就應該封其電話,是銀行的我們就該封其銀行賬號,是虛擬賬號的我們就應該封其虛擬賬號。我相信,在我們的不懈努力之下,我們的社會將會更和諧更美好!
需求
長話短說,需求大致是這樣子的:有管理員、市局接警員、縣區局接警員、電話追查專員、網站追查專員、銀行追查專員、虛擬賬號專員這幾類角色, 相關的角色可以進入相關的頁面進行相關的操作,其中市局和管理員的警情錄入是不需要稽核,直接派單下去,而縣區局的警情錄入需要進行稽核。當稽核通過後,會進行相應的派單。各類追查員將結果反饋給該警單。系統管理員這邊還可以進行人員、機構、警情類別,銀行卡、資料統計、匯出等功能。希望是越快越好,越簡單越好,領導要看的。
部分效果如圖:
技術預研
這個專案不是很大,複雜度也不是很高,併發量也不會太大,畢竟是部署在public police network下的。所以我這邊選用vue2,結合花褲衩大佬的vue-element-admin,前端這邊就差不多了,後端這邊用的是阿里開源的eggjs,因為它使用起來很方便。資料庫用的是mysql。部署這邊提供了兩套方案,一套是傳統的nginx、mysql、node、一個一個單獨安裝配置。另一種是docker部署的方式。
功能實現
前端
vue程式碼規範
參見:https://www.yuque.com/ng46ql/tynary
vue工程目錄結構
參見:https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/#目錄結構
vue元件設計與封裝
這裡我選了幾個有代表性的典型的元件來講解,我們先來看一張圖找找元件設計和封裝的感覺。
通過觀察我們發現,在後臺管理介面中,蠻多的頁面是長這樣子的,我們不可能來一個頁面我們就再寫一次佈局,這樣人都要搞沒掉。所以我們會有想法地把它封裝成一個container.vue
,它主要包含頭部的標題和右邊的新增按鈕、中間的過濾皮膚以及下方的表格。
container.vue
是一個佈局元件,它主要是框定了你一個頁面大致的佈局, 在適當的位置,我們加入插槽slot
去表示這塊未知的區域,container.vue
程式碼實現如下:
<template>
<div>
<el-row class="top">
<el-col :span="24">
<el-row>
<el-col :span="12">
<div
v-if="title"
class="title"
>
{{ title }}
</div>
</el-col>
<el-col
:span="12"
class="btn-group"
>
<slot name="topExtra" />
<el-col />
</el-col>
</el-row>
</el-col>
<el-col :span="24">
<slot name="tab" />
</el-col>
</el-row>
<div class="content">
<slot name="content" />
</div>
</div>
</template>
<script>
export default {
name: 'CommonContainer',
props: {
title: { type: String, default: '' }
}
}
</script>
<style lang="scss" scoped>
.top {
padding: 15px;
min-height: 100px;
background-color: #fff;
box-shadow: 0 3px 5px -3px rgba(0, 0, 0, 0.1);
}
.title-box {
height: 100px;
line-height: 100px;
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 30px;
font-weight: 700;
}
.content {
margin: 20px 5px 0;
padding: 20px 10px;
background: #fff;
}
.btn-group {
text-align: right;
padding: 0 10px;
}
</style>
往下走,我們會想到怎麼去設計表格這個元件,在設計這個元件的時候,我們需要清楚的知道,這個元件的輸入以及輸出是什麼?比如說table-query.vue
這個元件,從名字我們能夠看出,它是有查詢請求的,那麼對於請求,很容易抽象出的一些東西是,請求地址,請求引數,請求方法等等,所以這邊的props大致可以這麼敲定。
props: {
// 請求表格資料的url地址
url: { type: String, required: true },
// 預設分頁數
pageSize: { type: Number, default: 10 },
// 是否展示序號
index: { type: Boolean, default: true },
// 表格的列的結構
columns: { type: Array, required: true },
orgId: { type: String, required: false, default: '' },
// 請求表格資料的方法
method: { type: String, default: 'post' },
// 請求表格資料的引數
params: { type: Object, default: () => ({}) },
// 是否支援高亮選中
isHighlightRow: { type: Boolean, default: false },
// 是否顯示分頁
isShowPagination: { type: Boolean, default: true },
// 是否顯示迷你分頁
isPaginationSizeSmall: { type: Boolean, default: false }
},
這裡的輸出,我們期望的是,當使用者點選詳情、檢視、刪除的時候,我要知道這一行的具體資料,那麼大致可以這麼敲定。
handleClick(row, type, title) {
this.$emit('click-action', row, type, title)
},
這邊作為元件的資料通訊已經敲定了,剩下的也就是一些封裝請求的邏輯,頁面互動的邏輯,具體地可以看一下table-query.vue
的實現
<template>
<div>
<el-table
ref="table"
border
:data="data"
:loading="isLoading"
:highlight-row="isHighlightRow"
:row-class-name="tableRowClassName"
>
<template v-for="column in columns">
<template v-if="column.key === 'actions'">
<el-table-column
:key="column.key"
align="center"
:width="column.width"
:label="column.title"
>
<template slot-scope="scope">
<el-button
v-for="action in filterOperate(
column.actions,
scope.row.btnList
)"
:key="action.type"
type="text"
size="small"
@click="handleClick(scope.row, action.type, action.title)"
>{{ action.title }}</el-button>
</template>
</el-table-column>
</template>
<template v-else-if="column.key === 'NO'">
<el-table-column
:key="column.key"
type="index"
width="80"
align="center"
:label="column.title"
/>
</template>
<template v-else>
<el-table-column
:key="column.key"
align="center"
:prop="column.key"
:width="column.width"
:label="column.title"
:formatter="column.formatter"
:show-overflow-tooltip="true"
/>
</template>
</template>
</el-table>
<el-row type="flex" justify="center" style="margin-top: 10px;">
<el-col :span="24">
<el-pagination
v-if="isShowPagination"
:small="true"
:total="total"
:background="true"
:page-sizes="pageSizeOptions"
:current-page="pagination.page"
:page-size="pagination.pageSize"
@current-change="changePage"
@size-change="changePageSize"
/>
</el-col>
</el-row>
</div>
</template>
<script>
import request from '@/utils/request'
import { getLength } from '@/utils/tools'
export default {
name: 'CommonTableQuery',
props: {
// 請求表格資料的url地址
url: { type: String, required: true },
// 預設分頁數
pageSize: { type: Number, default: 10 },
// 是否展示序號
index: { type: Boolean, default: true },
// 表格的列的結構
columns: { type: Array, required: true },
orgId: { type: String, required: false, default: '' },
// 請求表格資料的方法
method: { type: String, default: 'post' },
// 請求表格資料的引數
params: { type: Object, default: () => ({}) },
// 是否支援高亮選中
isHighlightRow: { type: Boolean, default: false },
// 是否顯示分頁
isShowPagination: { type: Boolean, default: true },
// 是否顯示迷你分頁
isPaginationSizeSmall: { type: Boolean, default: false }
},
data() {
return {
// 表格的行
data: [],
// 分頁總數
total: 0,
// 表格資料是否載入
isLoading: false,
// 是否全選
isSelectAll: false,
// 渲染後的列資料欄位
renderColumns: [],
// 分頁
pagination: {
page: 1,
pageSize: this.pageSize
}
}
},
computed: {
// 是否有資料
hasData() {
return getLength(this.data) > 0
},
// 分頁條數
pageSizeOptions() {
return this.isPaginationSizeSmall ? [10, 20, 30] : [10, 20, 30, 50, 100]
}
},
created() {
this.getTableData()
},
methods: {
tableRowClassName({ row, rowIndex }) {
// if (rowIndex === 1) {
// return 'warning-row'
// } else if (rowIndex === 3) {
// return 'success-row'
// }
if (row.alarmNo && row.alarmNo.startsWith('FZYG')) {
return 'warning-row'
}
return ''
},
// 改變分頁
changePage(page) {
this.pagination.page = page
this.getTableData()
},
// 改變分頁大小
changePageSize(pageSize) {
this.pagination.pageSize = pageSize
this.getTableData()
},
// 獲取表格的資料
getTableData() {
if (!this.url) {
return
}
const {
url,
params,
orgId,
pagination: { page, pageSize },
isShowPagination,
method
} = this
this.isLoading = true
this.isSelectAll = false
const parameter = isShowPagination
? { page, pageSize, orgId, ...params }
: { orgId, ...params }
request({
method,
url,
[method === 'post' ? 'data' : 'params']: parameter
})
.then(res => {
const {
data: { list = [], total, page, pageSize }
} = res || {}
this.isLoading = false
this.data = list
if (this.isShowPagination) {
this.total = total === null ? 0 : total
this.pagination = {
page,
pageSize
}
}
})
.catch(err => {
this.isLoading = false
console.log(err)
})
},
// 手動擋分頁查詢
query(page = 1, pageSize = 10) {
this.pagination = { page, pageSize }
this.getTableData()
},
handleClick(row, type, title) {
this.$emit('click-action', row, type, title)
},
filterOperate(actions, btnList) {
return actions.filter(action => btnList.includes(action.type))
}
}
}
</script>
<style>
.el-table .warning-row {
background: oldlace;
}
.el-table .success-row {
background: #f0f9eb;
}
.el-tooltip__popper {
max-width: 80%;
}
.el-tooltip__popper,
.el-tooltip__popper.is-dark {
background: #f5f5f5 !important;
color: #303133 !important;
}
</style>
element-table: https://element.eleme.cn/#/zh-CN/component/table
element-pagination: https://element.eleme.cn/#/zh-CN/component/pagination
檔案上傳與下載,這個是點開警情、追查的相關頁面進去的功能,大體上和樓上的表格類似,就是在原來的基礎上,去掉了分頁,加上了檔案上傳的元件。
“DO NOT REPEAT"原則, 我們期望的是寫一次核心程式碼就好,剩下的我們每次只需要在用到的地方引入table-file.vue
就好了,這樣子維護起來也方便,這就有個這個元件的想法。
我們還是想一下,對於檔案我們不外乎有這些操作,上傳、下載、刪除、修改、預覽等等,所以這邊元件的輸入大致可以這麼敲定。
props: {
canUpload: { type: Boolean, default: true },
canDelete: { type: Boolean, default: true },
canDownload: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
affix: { type: String, default: '' }
},
輸出的話,跟樓上的table-query.vue
差不多
handleClick(row, type, title) {
this.$emit('click-action', row, type, title)
},
具體地可以看下table-file.vue
的實現
<template>
<el-row>
<el-col v-if="canUpload" :span="24">
<el-upload
ref="upload"
:action="url"
drag
:limit="9"
name="affix"
:multiple="true"
:auto-upload="false"
:with-credentials="true"
:on-error="onError"
:file-list="fileList"
:on-remove="onRemove"
:on-change="onChange"
:on-exceed="onExceed"
:on-success="onSuccess"
:on-preview="onPreview"
:before-upload="beforeUpload"
:before-remove="beforeRemove"
:on-progress="onProgress"
:headers="headers"
>
<!-- <el-button size="small" type="primary">選擇檔案</el-button> -->
<i class="el-icon-upload" />
<div class="el-upload__text">將檔案拖到此處,或<em>選擇檔案</em></div>
<div slot="tip" class="el-upload__tip">
檔案格式不限,一次最多隻能上傳9個檔案,單個檔案允許最大100MB
</div>
</el-upload>
</el-col>
<el-col v-if="canUpload" style="margin: 10px auto;">
<el-button
size="small"
type="primary"
@click="upload"
>確認上傳</el-button>
</el-col>
<el-col :span="24">
<el-table
ref="table"
border
:data="data"
style="width: 100%; margin: 20px auto;"
>
<template v-for="column in mapColumns">
<template v-if="column.key === 'actions'">
<el-table-column
:key="column.key"
align="center"
:label="column.title"
>
<template slot-scope="scope">
<el-button
v-for="action in column.actions"
:key="action.type"
type="text"
size="small"
@click="handleClick(scope.row, action.type, action.title)"
>{{ action.title }}</el-button>
</template>
</el-table-column>
</template>
<template v-else-if="column.key === 'NO'">
<el-table-column
:key="column.key"
type="index"
width="80"
align="center"
:label="column.title"
/>
</template>
<template v-else>
<el-table-column
:key="column.key"
:prop="column.key"
align="center"
:label="column.title"
/>
</template>
</template>
</el-table>
</el-col>
</el-row>
</template>
<script>
import Cookies from 'js-cookie'
import { getByIds } from '@/api/file'
import { formatDate } from '@/utils/tools'
export default {
name: 'TableFile',
props: {
canUpload: { type: Boolean, default: true },
canDelete: { type: Boolean, default: true },
canDownload: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
affix: { type: String, default: '' }
},
data() {
return {
fileList: [],
data: [],
ids: [],
headers: {
'x-csrf-token': Cookies.get('csrfToken')
},
mapColumns: [],
url: process.env.VUE_APP_UPLOAD_API
}
},
watch: {
affix: {
async handler(newAffix) {
this.data = []
this.ids = []
if (newAffix) {
this.ids = newAffix.split(',').map(id => Number(id))
if (this.ids.length > 0) {
const { data } = await getByIds({ ids: this.ids })
this.data = data.map(item => {
const { createTime, ...rest } = item
return {
createTime: formatDate(
'YYYY-MM-DD HH:mm:ss',
createTime * 1000
),
...rest
}
})
}
}
},
immediate: true
},
canDelete: {
handler(newVal) {
if (newVal) {
this.mapColumns = JSON.parse(JSON.stringify(this.columns))
} else {
if (this.mapColumns[this.mapColumns.length - 1]) {
this.mapColumns[this.mapColumns.length - 1].actions = [
{
title: '下載',
type: 'download'
}
]
}
}
},
immediate: true
}
},
created() {
this.mapColumns = JSON.parse(JSON.stringify(this.columns))
if (!this.canDelete) {
if (this.mapColumns[this.mapColumns.length - 1]) {
this.mapColumns[this.mapColumns.length - 1].actions = [
{
title: '下載',
type: 'download'
}
]
}
}
},
methods: {
beforeUpload(file, fileList) {
console.log('beforeUpload: ', file, fileList)
},
onSuccess(response, file, fileList) {
const {
data: { id, createTime, ...rest }
} = response
this.data.push({
id,
createTime: formatDate('YYYY-MM-DD HH:mm:ss', createTime * 1000),
...rest
})
this.ids.push(id)
this.clear()
},
onError(err, file, fileList) {
console.log(err, file, fileList)
},
onPreview(file, fileList) {
console.log('onPreview: ', file, fileList)
},
beforeRemove(file, fileList) {
console.log('beforeRemove: ', file, fileList)
},
onExceed(files, fileList) {
console.log('onExceed: ', files, fileList)
// this.$message.warning(`當前限制選擇 3 個檔案,本次選擇了 ${files.length} 個檔案,共選擇了 ${files.length + fileList.length} 個檔案`)
},
onRemove(file, fileList) {
console.log('onRemove: ', file, fileList)
},
onChange(file, fileList) {
console.log('onChange: ', file, fileList)
},
onProgress(file, fileList) {
console.log('onProgress: ', file, fileList)
},
upload() {
this.$refs.upload.submit()
},
clear() {
this.$refs.upload.clearFiles()
this.fileList = []
},
handleClick(row, type, title) {
this.$emit('click-action', row, type, title)
},
deleteData(id) {
const index = this.ids.indexOf(id)
this.ids.splice(index, 1)
this.data.splice(index, 1)
}
}
}
</script>
<style scoped>
.center {
display: flex;
justify-content: center;
}
</style>
element-upload: https://element.eleme.cn/#/zh-CN/component/upload
功能實現-檔案匯出
資料的匯出也是這種後臺管理系統比較常見的場景,這件事情可以前端做,也可以後端做。那麼在這裡結合xlsx
、file-saver
這兩個包,在src下新建一個excel資料夾, 然後新建一個js檔案export2Excel.js
/* eslint-disable */
import { saveAs } from 'file-saver'
import XLSX from 'xlsx'
function generateArray(table) {
var out = [];
var rows = table.querySelectorAll('tr');
var ranges = [];
for (var R = 0; R < rows.length; ++R) {
var outRow = [];
var row = rows[R];
var columns = row.querySelectorAll('td');
for (var C = 0; C < columns.length; ++C) {
var cell = columns[C];
var colspan = cell.getAttribute('colspan');
var rowspan = cell.getAttribute('rowspan');
var cellValue = cell.innerText;
if (cellValue !== "" && cellValue == +cellValue) cellValue = +cellValue;
//Skip ranges
ranges.forEach(function (range) {
if (R >= range.s.r && R <= range.e.r && outRow.length >= range.s.c && outRow.length <= range.e.c) {
for (var i = 0; i <= range.e.c - range.s.c; ++i) outRow.push(null);
}
});
//Handle Row Span
if (rowspan || colspan) {
rowspan = rowspan || 1;
colspan = colspan || 1;
ranges.push({
s: {
r: R,
c: outRow.length
},
e: {
r: R + rowspan - 1,
c: outRow.length + colspan - 1
}
});
};
//Handle Value
outRow.push(cellValue !== "" ? cellValue : null);
//Handle Colspan
if (colspan)
for (var k = 0; k < colspan - 1; ++k) outRow.push(null);
}
out.push(outRow);
}
return [out, ranges];
};
function datenum(v, date1904) {
if (date1904) v += 1462;
var epoch = Date.parse(v);
return (epoch - new Date(Date.UTC(1899, 11, 30))) / (24 * 60 * 60 * 1000);
}
function sheet_from_array_of_arrays(data, opts) {
var ws = {};
var range = {
s: {
c: 10000000,
r: 10000000
},
e: {
c: 0,
r: 0
}
};
for (var R = 0; R != data.length; ++R) {
for (var C = 0; C != data[R].length; ++C) {
if (range.s.r > R) range.s.r = R;
if (range.s.c > C) range.s.c = C;
if (range.e.r < R) range.e.r = R;
if (range.e.c < C) range.e.c = C;
var cell = {
v: data[R][C]
};
if (cell.v == null) continue;
var cell_ref = XLSX.utils.encode_cell({
c: C,
r: R
});
if (typeof cell.v === 'number') cell.t = 'n';
else if (typeof cell.v === 'boolean') cell.t = 'b';
else if (cell.v instanceof Date) {
cell.t = 'n';
cell.z = XLSX.SSF._table[14];
cell.v = datenum(cell.v);
} else cell.t = 's';
ws[cell_ref] = cell;
}
}
if (range.s.c < 10000000) ws['!ref'] = XLSX.utils.encode_range(range);
return ws;
}
function Workbook() {
if (!(this instanceof Workbook)) return new Workbook();
this.SheetNames = [];
this.Sheets = {};
}
function s2ab(s) {
var buf = new ArrayBuffer(s.length);
var view = new Uint8Array(buf);
for (var i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
return buf;
}
export function export_table_to_excel(id) {
var theTable = document.getElementById(id);
var oo = generateArray(theTable);
var ranges = oo[1];
/* original data */
var data = oo[0];
var ws_name = "SheetJS";
var wb = new Workbook(),
ws = sheet_from_array_of_arrays(data);
/* add ranges to worksheet */
// ws['!cols'] = ['apple', 'banan'];
ws['!merges'] = ranges;
/* add worksheet to workbook */
wb.SheetNames.push(ws_name);
wb.Sheets[ws_name] = ws;
var wbout = XLSX.write(wb, {
bookType: 'xlsx',
bookSST: false,
type: 'binary'
});
saveAs(new Blob([s2ab(wbout)], {
type: "application/octet-stream"
}), "test.xlsx")
}
export function export_json_to_excel({
multiHeader = [],
header,
data,
filename,
merges = [],
autoWidth = true,
bookType = 'xlsx'
} = {}) {
/* original data */
filename = filename || 'excel-list'
data = [...data]
data.unshift(header);
for (let i = multiHeader.length - 1; i > -1; i--) {
data.unshift(multiHeader[i])
}
var ws_name = "SheetJS";
var wb = new Workbook(),
ws = sheet_from_array_of_arrays(data);
if (merges.length > 0) {
if (!ws['!merges']) ws['!merges'] = [];
merges.forEach(item => {
ws['!merges'].push(XLSX.utils.decode_range(item))
})
}
if (autoWidth) {
/*設定worksheet每列的最大寬度*/
const colWidth = data.map(row => row.map(val => {
/*先判斷是否為null/undefined*/
if (val == null) {
return {
'wch': 10
};
}
/*再判斷是否為中文*/
else if (val.toString().charCodeAt(0) > 255) {
return {
'wch': val.toString().length * 2
};
} else {
return {
'wch': val.toString().length
};
}
}))
/*以第一行為初始值*/
let result = colWidth[0];
for (let i = 1; i < colWidth.length; i++) {
for (let j = 0; j < colWidth[i].length; j++) {
if (result[j]['wch'] < colWidth[i][j]['wch']) {
result[j]['wch'] = colWidth[i][j]['wch'];
}
}
}
ws['!cols'] = result;
}
/* add worksheet to workbook */
wb.SheetNames.push(ws_name);
wb.Sheets[ws_name] = ws;
var wbout = XLSX.write(wb, {
bookType: bookType,
bookSST: false,
type: 'binary'
});
saveAs(new Blob([s2ab(wbout)], {
type: "application/octet-stream"
}), `${filename}.${bookType}`);
}
邏輯程式碼如下
downloadExcel() {
this.$confirm('將匯出為excel檔案,確認匯出?', '提示', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
this.export2Excel()
})
.catch((e) => {
this.$Message.error(e);
})
},
// 資料寫入excel
export2Excel() {
import('@/excel/export2Excel').then(excel => {
const tHeader = [
'警情編號',
'警情性質',
'受害人姓名',
'受害人賬號',
'嫌疑人賬號',
'嫌疑人電話',
'涉案總金額',
'案發時間',
'警情狀態'
] // 匯出的excel的表頭欄位
const filterVal = [
'alarmNo',
'alarmProp',
'informantName',
'informantBankAccount',
'suspectsAccount',
'suspectsMobile',
'fraudAmount',
'crimeTime',
'alarmStatus'
] // 物件屬性,對應於tHeader
const list = this.$refs.inputTable.data
const data = this.formatJson(filterVal, list)
excel.export_json_to_excel({
header: tHeader,
data,
filename: this.filename,
autoWidth: this.autoWidth,
bookType: this.bookType
})
this.downloadLoading = false
})
},
// 格式轉換,直接複製即可
formatJson(filterVal, jsonData) {
return jsonData.map(v =>
filterVal.map(j => {
if (j === 'crimeTime') {
return formatDate('YYYY-MM-DD HH:mm:ss', v[j] * 1000)
} else if (j === 'alarmProp') {
return this.alarmPropOptionsArr[v[j]]
} else if (j === 'alarmStatus') {
return this.alarmStatusOptionsArr[v[j]]
} else {
return v[j]
}
})
)
}
參見:https://panjiachen.gitee.io/vue-element-admin-site/zh/feature/component/excel.html
功能實現-資料統計與展示
單純的資料只有儲存的價值,而對儲存下來的資料進行相應的分析,並加以圖表的形式輸出,可以更直觀地看到資料的變化,體現資料的價值,實現新生代農民工的勞動價值。這邊結合echarts對某一個時間段的警情中各部分追查的佔比進行了一個統計,除此之外,對該時間段的每月的止付金額進行了一個統計,最終結合扇形和柱形對其進行展示。
翻一翻npm包,筆者物色到了兩位包包可以做這件事,考慮到針對本專案對於圖表的需求量不是特別大,我也懶得看兩套API,就還是用了echarts。
vue-echarts: https://www.npmjs.com/package/vue-echarts
echarts: https://www.npmjs.com/package/echarts
我們會有一個資料介面,前端帶上相關的請求引數通過請求/prod-api/statistics/calculate
這個介面就能夠拿到後端的從資料庫處理出來的相關資料,這裡因為前後端都是我寫的,所以我制定的規則就是,所有的計算都有後端去完成,前端只負責展示,並且約定了相關的引數格式。這樣做的一個好處是,省去了前端這邊對資料的封裝處理。返回的格式如下:
{
"status": 200,
"message": "success",
"data": {
"pieData": [
{
"name": "銀行查控",
"count": 13
},
{
"name": "電話查控",
"count": 10
},
{
"name": "虛擬賬號查控",
"count": 3
},
{
"name": "網站查控",
"count": 5
}
],
"barData": [
{
"name": "2021年1月",
"amount": 0
},
{
"name": "2021年2月",
"amount": 0
},
{
"name": "2021年3月",
"amount": 0
},
{
"name": "2021年4月",
"amount": 0
},
{
"name": "2021年5月",
"amount": 0
},
{
"name": "2021年6月",
"amount": 0
},
{
"name": "2021年7月",
"amount": 0
},
{
"name": "2021年8月",
"amount": 1311601
}
],
"totalAmount": 1311601
}
}
這裡以畫餅圖和柱形圖為例,其他的也是類似的,可以參考https://echarts.apache.org/examples/zh/index.html
公共部分
npm i echarts -S
安裝echarts的npm包,然後在相應的檔案引入它。
import echarts from 'echarts'
畫餅圖
在template中我們搞一個餅圖的div
<div ref="pieChart" class="chart" />
在vue的方法裡面,我們定義一個畫餅的方法,這裡定義的輸入就是請求後端返回的資料,其他的看echarts的配置項,這邊都配好了(如果寫成單個元件,需要根據業務考慮相關的配置項,目前這邊就care資料項)。邏輯是這樣子的,定義了一個基於資料項變動的配置項options
,然後當執行drawPie
方法的時候,如果沒有初始化echarts,那麼我們這邊就初始化一個echarts的餅,如果有,那麼我們就只有更新相關的options
就好了。
drawPie(source) {
const options = {
title: {
text: '各追查型別佔比統計'
},
tooltip: {
trigger: 'item',
formatter: '{b} : ({d}%)'
},
legend: {
orient: 'vertical',
x: 'left',
y: 'bottom',
data: ['銀行查控', '電話查控', '虛擬賬號查控', '網站查控']
},
dataset: {
source
},
series: {
type: 'pie',
label: {
position: 'outer',
alignTo: 'edge',
margin: 10,
formatter: '{@name}: {@count} ({d}%)'
},
encode: {
itemName: 'name',
value: 'count'
}
}
}
if (this.pieChart) {
this.pieChart.setOption(options, true)
} else {
this.pieChart = echarts.init(this.$refs.pieChart)
this.pieChart.setOption(options, true)
}
}
畫柱形圖
跟樓上的類似的,畫柱子如樓下所示:
drawBar(source) {
const options = {
title: {
text: `各月份止付金額之和統計, 合計: ${this.totalAmount}元`
},
dataset: {
source
},
xAxis: {
type: 'category',
name: '時間'
},
yAxis: [
{
type: 'value',
name: '止付金額'
}
],
series: [
{
type: 'bar',
encode: {
x: 'name',
y: 'amount'
},
label: {
normal: {
show: true,
position: 'top'
}
}
}
]
}
if (this.barChart) {
this.barChart.setOption(options, true)
} else {
this.barChart = echarts.init(this.$refs.barChart)
this.barChart.setOption(options, true)
}
},
備註:考慮到需求量不大,這裡筆者是為了趕進度偷懶寫成這樣的,學習的話,建議封裝成一個個元件,例如pie.vue
,bar.vue
這樣子去搞。
功能實現-頁面許可權控制和頁面許可權的按鈕許可權粒度控制
因為這個專案涉及到多個角色,這就涉及到對多個角色的頁面控制了,每個角色分配的頁面許可權是不一樣的,第二個就是進入到頁面後,針對某一條記錄,該登入使用者按鈕的許可權控制。
頁面許可權控制
頁面的許可權這邊有兩種做法,分別是控制權在前端,和控制權在後端兩種,在前端的話是通過獲取使用者資訊的角色,根據角色去匹配,匹配中了就加到路由裡面。在後端的話,就是登入的時候後端就把相應的路由返回給你,前端這邊註冊路由。
藉著vue-element-admin
的東風,筆者這邊是將控制權放在前端,在路由的meta中加入roles角色去做頁面的許可權控制的。
參見 vue-element-admin - 路由和側邊欄:https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/essentials/router-and-nav.html#配置項
參見 vue-element-admin - 許可權驗證: https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/essentials/permission.html#邏輯修改
按鈕許可權控制
首先我們來分析下,針對我們這個系統,不外乎刪除、修改、詳情、稽核、追查等等按鈕許可權,不是特別多,所以我們可以用detail
、modify
、delete
、audit
、check
等去表示這些按鈕,後端在service層進行相關業務處理,把它們這些包到一個陣列btnList
裡面返回給前端,跟前端這邊做對比,如果命中那麼我們就展示按鈕。
核心程式碼如下:
template
<template v-if="column.key === 'actions'">
<el-table-column
:key="column.key"
align="center"
:width="column.width"
:label="column.title"
>
<template slot-scope="scope">
<el-button
v-for="action in filterOperate(
column.actions,
scope.row.btnList
)"
:key="action.type"
type="text"
size="small"
@click="handleClick(scope.row, action.type, action.title)"
>{{ action.title }}</el-button>
</template>
</el-table-column>
</template>
filterOperate(actions, btnList) {
return actions.filter(action => btnList.includes(action.type))
}
那麼我們就可以這麼使用了
columns: [
......
{
title: '操作',
key: 'actions',
align: 'center',
actions: [
{
title: '詳情',
type: 'detail'
},
{
title: '修改',
type: 'modify'
}
]
}
]
關於許可權校驗這塊,筆者所在的供應鏈金融團隊是這麼去實現的,在保理業務中,會有很多個部門,比如市場部、財務部、風控部、董事會等等。每個部門裡又有經辦、稽核、複核等等角色。所以在處理這類業務中的許可權控制,需要將使用者身上繫結一個按鈕許可權,比如說他是市場的經辦角色,那麼他就可以繫結市場經辦這個角色的按鈕碼子上。前端這邊除了要在我們樓上的基礎上對列表返回的做對比之外,還有對使用者的做進一步對比。這裡的按鈕也不能夠像上面一樣detail
、modify
這樣去寫,因為角色多了每個角色這麼叫不好,更科學的應該是,整理成一份excel表,然後按照相應的按鈕許可權去配置相應的code(比如說 20001, 20002),然後根據這個去處理業務。
後端
eggjs中的三層模型(model-service-controller)
model層是對資料庫的相關表進行相應的對映和CRUD,service層是處理相關的業務邏輯,controller層是為相關的業務邏輯暴露介面。這三者層序漸進,一環扣一環。
Model
一些約定
- 原則上,不允許對Model層SQL語句返回的結果進行相關操作,返回什麼就是什麼。
- 統一下資料返回的格式
- 語法錯誤 null
- 查不到資料 false
- 查到資料 JSON | Number
- 統一下model層檔案類的通用方法
- add: 新增
- set: 更新
- del: 刪除(本系統由於資料需要,所以不會真的刪除這條資料,而是取一哥isDelete欄位去軟刪除它)
- get: 獲取單條資料,
getById
可簡寫成get
, 若有查詢條件, 按getByCondition
- getAll: 獲取多條記錄,若有查詢條件 按
getAllByCondition
- getAllLimit: 分頁獲取 若有查詢條件 按
getAllLimitByCondition
- has: 若有查詢條件, 按
hasAttributes
目前本系統業務就用到這麼多,其他的參見sequelize文件: http://sequelize.org/
這樣做的好處是,一些概念和語義更加清晰了,比如有個user.js
,裡面用add
表示新增還是addUser
表示新增好,我認為是前者,在user.js
裡面, 除了新增user
使用者,難不成還有別的新增,還能新增個鬼啊。除此之外,還方便了新生代農民工的複製貼上,提高編碼效率。
抄表欄位真的好累啊
試想一下這樣一個場景,這個資料庫有一百張表,每張表有100個欄位,難道你真的要人肉去一個一個敲出來對應的資料庫對映嗎?那要敲到什麼時候啊,人都快搞沒了,我們可是新生代農民工唉,當然要跟上時代。 這裡介紹一下egg-sequelize-auto
, 它可以快速的將資料庫的欄位對映到你的程式碼中,減少很多工作量。
安裝
npm i egg-sequelize-auto -g
npm i mysql2 -g
使用
egg-sequelize-auto -h 'your ip' -d 'your database' -u 'db user' -x 'db password' -e mysql -o 'project model path' -t 'table name'
egg-sequelize-auto: https://www.npmjs.com/package/egg-sequelize-auto
sequelize連表查詢的應用
在表的關係中,有一對一,一對多,多對多。本系統一對多用的比較多,這裡就以銀行卡結合銀行的的連表做個演示。
主要是三個地方,一個是引入相關表的Model, 第二個是欄位初始化,第三個是通過associate
方法建立聯絡,閹割後的示例程式碼如下:
'use strict';
const OrganizationModel = require('./organization');
module.exports = app => {
const { logger, Sequelize, utils } = app;
const { DataTypes, Model, Op } = Sequelize;
class BankcardModel extends Model {
static associate() {
const { Organization } = app.model;
BankcardModel.belongsTo(Organization, {
foreignKey: 'bankId',
targetKey: 'id',
as: 'bank',
});
}
static async getAllLimit(name, prefix, bankId, { page = 0, limit = 10 }) {
let where = {};
if (name) {
where = { name: { [Op.like]: `%${name}%` } };
}
if (prefix) {
where.prefix = { [Op.like]: `%${prefix}%` };
}
if (bankId) {
where.bankId = bankId;
}
where.isDelete = 0;
try {
const offset = page < 1 ? 1 : (page - 1) * limit;
const total = await this.count({ where });
const last = Math.ceil(total / limit);
const list =
total === 0
? []
: await this.findAll({
raw: true,
where,
order: [
['createTime', 'DESC'],
['updateTime', 'DESC'],
],
offset,
limit,
attributes: [
'id',
'name',
'prefix',
'bankId',
[Sequelize.col('bank.name'), 'bankName'],
],
include: {
model: app.model.Organization,
as: 'bank',
attributes: [],
},
});
logger.info(this.getAllLimit, page, limit, where, list);
return {
page,
pageSize: limit,
list,
total,
last,
};
} catch (e) {
logger.error(e);
return false;
}
}
}
BankcardModel.init(
{
id: {
type: DataTypes.UUID,
defaultValue() {
return utils.generator.generateUUID();
},
allowNull: false,
primaryKey: true,
},
name: {
type: DataTypes.STRING(255),
allowNull: true,
},
prefix: {
type: DataTypes.STRING(255),
allowNull: true,
},
bankId: {
type: DataTypes.STRING(255),
allowNull: false,
references: {
model: OrganizationModel,
key: 'id',
},
},
isDelete: {
type: DataTypes.INTEGER(1),
allowNull: true,
defaultValue: 0,
},
createTime: {
type: DataTypes.INTEGER(10),
allowNull: true,
},
updateTime: {
type: DataTypes.INTEGER(10),
allowNull: true,
},
},
{
sequelize: app.model,
tableName: 't_bankcard',
}
);
return BankcardModel;
};
sequelize中的表關係: https://sequelize.org/master/manual/assocs.html
Service
這裡就是引入相關的model層寫好的,然後根據業務邏輯去呼叫下,還是以銀行卡為例
'use strict';
const { Service } = require('egg');
class BankcardService extends Service {
constructor(ctx) {
super(ctx);
this.Bankcard = this.ctx.model.Bankcard;
}
async add(name, prefix, bankId) {
const { ctx, Bankcard } = this;
let result = await Bankcard.hasPrefix(prefix);
if (result) {
ctx.throw('卡號字首已存在');
}
result = await Bankcard.add(name, prefix, bankId);
if (!result) {
ctx.throw('新增卡號失敗');
}
return result;
}
async getAllLimit(name, prefix, bankId, page, limit) {
const { ctx, Bankcard } = this;
const result = await Bankcard.getAllLimit(name, prefix, bankId, {
page,
limit,
});
if (!result) {
ctx.throw('暫無資料');
}
return result;
}
async set(id, name, prefix, bankId, isDelete) {
const { ctx, Bankcard } = this;
const result = await Bankcard.set(id, name, prefix, bankId, isDelete);
if (result === null) {
ctx.throw('更新失敗');
}
return result;
}
}
module.exports = BankcardService;
Controller
restful API介面
只要在相應的controller層定義相關的方法,egg程式就能夠根據restful api去解析。
Method | Path | Route Name | Controller.Action |
---|---|---|---|
GET | /posts | posts | app.controllers.posts.index |
GET | /posts/new | new_post | app.controllers.posts.new |
GET | /posts/:id | post | app.controllers.posts.show |
GET | /posts/:id/edit | edit_post | app.controllers.posts.edit |
POST | /posts | posts | app.controllers.posts.create |
PUT | /posts/:id | post | app.controllers.posts.update |
DELETE | /posts/:id | post | app.controllers.posts.destroy |
參見:https://eggjs.org/zh-cn/basics/router.html
非restful API介面
這裡主要是針對於樓上的情況,進行一個補充,比如說使用者,除了這些,他還有登入,登出等等操作,那這個就需要單獨在router中制定了, 這裡筆者封裝了一個resource
方法,來解析restful api的函式介面,具體如下:
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
router.post('/user/login', controller.user.login);
router.post('/user/logout', controller.user.logout);
router.post('/user/info', controller.user.getUserInfo);
router.post('/file/upload', controller.file.upload);
router.post('/file/getall', controller.file.getAllByIds);
router.post('/organization/by-type', controller.organization.getAllByType);
router.post('/statistics/calculate', controller.statistics.calculate);
function resource(path) {
const pathArr = path.split('/');
// 刪掉第一個空白的
pathArr.shift();
let controllers = controller;
for (const val of pathArr) {
controllers = controllers[val];
}
router.resources(path, path, controllers);
}
resource('/alarm');
resource('/bank');
resource('/bankcard');
resource('/mobile');
resource('/organization');
resource('/user');
resource('/virtual');
resource('/website');
resource('/file');
resource('/alarmCategory');
};
這裡還是以銀行卡為例
'use strict';
const { Controller } = require('egg');
class BankCardController extends Controller {
async index() {
const { ctx, service } = this;
const { name, prefix, bankId, page, pageSize } = ctx.request.query;
const { list, ...rest } = await service.bankcard.getAllLimit(
name,
prefix,
bankId,
Number(page),
Number(pageSize)
);
const data = list.map(item => {
const { role } = ctx.session.userinfo;
let btnList = [];
if (role === 'admin') {
btnList = ['detail', 'modify', 'delete'];
}
return {
btnList,
...item,
};
});
ctx.success({ list: data, ...rest });
}
async create() {
const { ctx, service } = this;
const { name, prefix, bankId } = ctx.request.body;
ctx.validate(
{
name: { type: 'string', required: true },
prefix: { type: 'string', required: true },
bankId: { type: 'string', required: true },
},
{ name, prefix, bankId }
);
const result = await service.bankcard.add(name, prefix, bankId);
ctx.success(result);
}
// async destory() {
// const { ctx, service } = this;
// const { method } = ctx;
// this.ctx.body = '刪除';
// }
async update() {
const { ctx, service } = this;
const { id } = ctx.params;
const { name, prefix, bankId, isDelete } = ctx.request.body;
const result = await service.bankcard.set(
id,
name,
prefix,
bankId,
isDelete
);
ctx.success(result);
}
async show() {
const { ctx, service } = this;
const { method } = ctx;
this.ctx.body = '查詢';
}
async new() {
const { ctx, service } = this;
const { method } = ctx;
this.ctx.body = '建立頁面';
}
async edit() {
const { ctx, service } = this;
const { method } = ctx;
this.ctx.body = '修改頁面';
}
}
module.exports = BankCardController;
至此,打通這樣一個從model到service再到controller的流程,
eggjs中的定時任務schedule
原系統是接入了第三方的資料來源去定時讀取更新資料,再將資料清洗更新到我們自己的t_alarm
表,一些原因這裡我不方便做演示,所以筆者又新建了一張天氣表,來向大家介紹eggjs中的定時任務。
在這裡,我相中了萬年曆的介面,準備嫖一嫖給大家做一個演示的例子,它返回的資料格式如下
{
"data": {
"yesterday": {
"date": "19日星期四",
"high": "高溫 33℃",
"fx": "東風",
"low": "低溫 24℃",
"fl": "<![CDATA[1級]]>",
"type": "小雨"
},
"city": "杭州",
"forecast": [
{
"date": "20日星期五",
"high": "高溫 34℃",
"fengli": "<![CDATA[2級]]>",
"low": "低溫 25℃",
"fengxiang": "西南風",
"type": "小雨"
},
{
"date": "21日星期六",
"high": "高溫 33℃",
"fengli": "<![CDATA[2級]]>",
"low": "低溫 25℃",
"fengxiang": "西南風",
"type": "中雨"
},
{
"date": "22日星期天",
"high": "高溫 33℃",
"fengli": "<![CDATA[1級]]>",
"low": "低溫 26℃",
"fengxiang": "東風",
"type": "小雨"
},
{
"date": "23日星期一",
"high": "高溫 32℃",
"fengli": "<![CDATA[1級]]>",
"low": "低溫 26℃",
"fengxiang": "南風",
"type": "中雨"
},
{
"date": "24日星期二",
"high": "高溫 33℃",
"fengli": "<![CDATA[1級]]>",
"low": "低溫 25℃",
"fengxiang": "西南風",
"type": "小雨"
}
],
"ganmao": "感冒低發期,天氣舒適,請注意多吃蔬菜水果,多喝水哦。",
"wendu": "31"
},
"status": 1000,
"desc": "OK"
}
我分別選取了天朝的一線城市和一些地域性比較強的城市去搞資料(等跑個兩三個月,存了點資料,俺又可以寫一篇基於echarts的天氣視覺化展示了,233333333),最後的效果如圖
首先我們建立一個類,繼承了egg的Subscription
類, 然後有一個schedule
方法
static get schedule() {
return {
interval: '12h',
type: 'worker',
};
}
interval
表示時間間隔,從樓上可以看出是每12小時去執行一次,type
表示執行這個定時任務的程式,可以選all
和worker
,這邊表示只在一個worker
程式中執行該任務。
核心的業務邏輯,寫在subscribe
方法中,這裡表示去請求萬年曆的資料,然後進行相應的資料清洗
async subscribe() {
try {
const result = [];
for (const city of CITYS) {
result.push(this.fetchData(city));
}
await Promise.all(result);
} catch (e) {
this.ctx.app.logger.error(e);
}
}
最終實現程式碼如下:
const { Subscription } = require('egg');
const URL_PREFIX = 'http://wthrcdn.etouch.cn/weather_mini?city=';
const CITYS = [
'杭州',
'北京',
'南京',
'上海',
'廣州',
'深圳',
'成都',
'武漢',
'鄭州',
'哈爾濱',
'海口',
'三亞',
'烏魯木齊',
'呼和浩特',
'拉薩',
'大理',
'麗江',
];
const DAY_TIMESTAMP = 86400000;
class WeatherSchedule extends Subscription {
static get schedule() {
return {
interval: '12h',
type: 'worker',
};
}
async refreshWeatherData(
date,
high,
low,
wendu = null,
fengli,
fengxiang,
type,
ganmao = null,
city,
weatherDate
) {
const weather = await this.service.weather.getWeather(weatherDate, city);
if (weather) {
const { id, wendu: oldWendu, ganmao: oldGanmao } = weather;
const newWendu = oldWendu || wendu;
const newGanmao = oldGanmao || ganmao;
await this.service.weather.set(
id,
date,
high,
low,
newWendu,
fengli,
fengxiang,
type,
newGanmao,
city,
weatherDate
);
} else {
await this.service.weather.add(
date,
high,
low,
wendu,
fengli,
fengxiang,
type,
ganmao,
city,
weatherDate
);
}
}
async fetchData(queryCity) {
const res = await this.ctx.curl(`${URL_PREFIX}${queryCity}`, {
dataType: 'json',
});
const {
data: { city, forecast = [], ganmao, wendu },
} = res.data;
const result = [];
const now = this.ctx.app.utils.date.now() * 1000;
for (let i = 0; i < forecast.length; i++) {
const { date, high, fengli, low, fengxiang, type } = forecast[i];
const weatherDate = this.ctx.app.utils.date.format2Date(
now + i * DAY_TIMESTAMP
);
if (i === 0) {
result.push(
this.refreshWeatherData(
date,
high,
low,
wendu,
fengli,
fengxiang,
type,
ganmao,
city,
weatherDate
)
);
} else {
result.push(
this.refreshWeatherData(
date,
high,
low,
null,
fengli,
fengxiang,
type,
null,
city,
weatherDate
)
);
}
}
await Promise.all(result);
}
async subscribe() {
try {
const result = [];
for (const city of CITYS) {
result.push(this.fetchData(city));
}
await Promise.all(result);
} catch (e) {
this.ctx.app.logger.error(e);
}
}
}
module.exports = WeatherSchedule;
egg中的schedule: https://eggjs.org/zh-cn/basics/schedule.html
eggjs中的配置項config
eggjs提供了根據開發、生產、測試環境的配置檔案,具體的以config.env.js
表示,因為專案不是很複雜,而且都是我一個人寫的,這裡就簡單點都寫在了一個檔案config.default.js
裡面。
在這裡面可以對中介軟體、安全、資料庫、日誌、檔案上傳、session、loader等進行配置,具體的如下:
/* eslint valid-jsdoc: "off" */
'use strict';
/**
* @param {Egg.EggAppInfo} appInfo app info
*/
module.exports = appInfo => {
/**
* built-in config
* @type {Egg.EggAppConfig}
* */
const config = (exports = {});
// use for cookie sign key, should change to your own and keep security
config.keys = `${appInfo.name}_ataola`;
// add your middleware config here
config.middleware = ['cost', 'errorHandler'];
// add your user config here
const userConfig = {
myAppName: 'egg',
};
config.security = {
xframe: {
enable: true,
},
csrf: {
enable: true,
ignore: '/user/login',
// queryName: '_csrf',
// bodyName: '_csrf',
headerName: 'x-csrf-token',
},
domainWhiteList: [
'http://localhost:7001',
'http://127.0.0.1:7001',
'http://localhost:9528',
'http://localhost',
'http://127.0.0.1',
],
};
// https://github.com/eggjs/egg-sequelize
config.sequelize = {
dialect: 'mysql', // support: mysql, mariadb, postgres, mssql
database: 'anti-fraud',
host: 'hzga-mysql',
port: 3306,
username: 'root',
password: 'ataola',
// delegate: 'myModel', // load all models to `app[delegate]` and `ctx[delegate]`, default to `model`
// baseDir: 'my_model', // load all files in `app/${baseDir}` as models, default to `model`
// exclude: 'index.js', // ignore `app/${baseDir}/index.js` when load models, support glob and array
// more sequelize options
define: {
timestamps: false,
underscored: false,
},
};
exports.multipart = {
mode: 'file',
fileSize: '100mb',
whitelist: [
// images
'.jpg',
'.jpeg', // image/jpeg
'.png', // image/png, image/x-png
'.gif', // image/gif
'.bmp', // image/bmp
'.wbmp', // image/vnd.wap.wbmp
'.webp',
'.tif',
'.psd',
// text
'.svg',
'.js',
'.jsx',
'.json',
'.css',
'.less',
'.html',
'.htm',
'.xml',
'.xlsx',
'.xls',
'.doc',
'.docx',
'.ppt',
'.pptx',
'.pdf',
// tar
'.zip',
'.rar',
'.gz',
'.tgz',
'.gzip',
// video
'.mp3',
'.mp4',
'.avi',
],
};
config.session = {
key: 'SESSION_ID', // 設定session key,cookie裡面的key
maxAge: 24 * 3600 * 1000, // 1 天
httpOnly: true, // 是否允許js訪問session,預設為true,表示不允許js訪問
encrypt: true, // 是否加密
renew: true, // 重置session的過期時間,延長session過期時間
};
config.logger = {
level: 'NONE',
consoleLevel: 'DEBUG',
disableConsoleAfterReady: false,
};
config.errorHandler = {
match: '/',
};
config.customLoader = {
enum: {
directory: 'app/enum',
inject: 'app',
loadunit: true,
},
utils: {
directory: 'app/utils',
inject: 'app',
loadunit: true,
},
};
config.cluster = {
listen: {
path: '',
port: 7001,
hostname: '0.0.0.0',
},
};
return {
...config,
...userConfig,
};
};
eggjs中的配置:https://eggjs.org/zh-cn/basics/config.html
eggjs中的外掛
這裡主要是針對一些egg整合的外掛進行配置,比如sequelize
, cors
等等
plugin.js
具體的如下:
'use strict';
/** @type Egg.EggPlugin */
module.exports = {
// had enabled by egg
static: {
enable: true,
},
sequelize: {
enable: true,
package: 'egg-sequelize',
},
cors: {
enable: true,
package: 'egg-cors',
},
validate: {
enable: true,
package: 'egg-validate',
},
};
eggjs中的外掛: https://eggjs.org/zh-cn/basics/plugin.html
eggjs中的擴充套件extend
在app資料夾下新建extend資料夾,它可以對egg的agent,application,context,helper,request,response,validator內建物件進行擴充套件。
這裡以context.js
為例,我想封裝一下上下文返回的格式,可以這麼寫:
'use strict';
module.exports = {
success(data, message = 'success') {
const res = {
status: 200,
message,
data,
};
this.app.logger.info(JSON.stringify(res));
this.body = res;
},
};
呼叫的時候ctx.success(data)
。
eggjs中的擴充套件:https://eggjs.org/zh-cn/basics/extend.html
eggjs中的中介軟體
比如說我想編寫一個請求響應時間的中介軟體,那麼可以在app資料夾下新建middleware
資料夾,然後新建cost.js
檔案
// app/middleware/cost.js
module.exports = options => {
const header = options.header || 'X-Response-Time';
return async function cost(ctx, next) {
const now = Date.now();
await next();
ctx.set(header, `${Date.now() - now}ms`);
};
};
在config/config.default.js
檔案中,我們註冊它
// add your middleware config here
config.middleware = ['cost', 'errorHandler'];
這樣在請求響應的時候就會帶上一個x-Response-Time
eggjs中的中介軟體:https://eggjs.org/zh-cn/basics/middleware.html
eggjs中的通用工具包
比如你想寫一些通用的工具類, 那麼可以這麼去做,在app
目錄下新建utils
資料夾,然後建立一個generator.js
(這裡以生成id舉例), 程式碼如下:
const { v4: uuidv4 } = require('uuid');
function generateUUID() {
return uuidv4().replace(/-/g, '');
}
function getNo(num) {
const numStr = `000${(num % 1000).toString()}`;
return numStr.slice(-3);
}
module.exports = {
generateUUID,
getNo,
};
然後再config/config.default.js
中配置
config.customLoader = {
utils: {
directory: 'app/utils',
inject: 'app',
loadunit: true,
},
};
它表示載入app/utils
下面的檔案,注入到application
物件中。呼叫的時候就可以直接app.utils.generateUUID()
功能實現-檔案上傳與下載
egg內建了multipart外掛,通過這個外掛我們很容易實現檔案上傳
async upload() {
const { ctx, service } = this;
const file = ctx.request.files[0];
if (!file) return ctx.throw(404);
// const filename = path.extname(file.filename).toLowerCase();
const { filename } = file;
const type = path.extname(filename).toLowerCase();
const { username, nickname } = ctx.session.userinfo;
const createBy = nickname || username;
const uuid = ctx.app.utils.generator.generateUUID();
const targetPathPrefix = path.join(this.config.baseDir, 'app/public', uuid);
const targetPath = path.join(
this.config.baseDir,
'app/public',
uuid,
filename
);
const source = fs.createReadStream(file.filepath);
await mkdirp(targetPathPrefix);
const target = fs.createWriteStream(targetPath);
let result = '';
try {
await pump(source, target);
const stats = fs.statSync(targetPath);
const size = ctx.app.utils.compute.bytesToSize(stats.size);
const url = `public/${uuid}/${filename}`;
result = await service.file.add(filename, type, size, url, createBy);
ctx.logger.info('save %s to %s', file.filepath, targetPath, stats, size);
} finally {
// delete those request tmp files
await ctx.cleanupRequestFiles();
}
ctx.success(result);
}
上面的程式碼就是在讀取前端傳過來的檔案後,在app/public
資料夾下建立檔案,並將記錄寫到資料庫中
在config/config.default.js
中可以對檔案進行相關配置,比如說模式是流還是檔案,相關檔案的大小,相關檔案的格式等。
exports.multipart = {
mode: 'file',
fileSize: '100mb',
whitelist: [
// images
'.jpg',
'.jpeg', // image/jpeg
'.png', // image/png, image/x-png
'.gif', // image/gif
'.bmp', // image/bmp
'.wbmp', // image/vnd.wap.wbmp
'.webp',
'.tif',
'.psd',
// text
'.svg',
'.js',
'.jsx',
'.json',
'.css',
'.less',
'.html',
'.htm',
'.xml',
'.xlsx',
'.xls',
'.doc',
'.docx',
'.ppt',
'.pptx',
'.pdf',
// tar
'.zip',
'.rar',
'.gz',
'.tgz',
'.gzip',
// video
'.mp3',
'.mp4',
'.avi',
],
};
eggjs中的檔案上傳: https://eggjs.github.io/zh/guide/upload.html
egg-multipart: https://github.com/eggjs/egg-multipart
功能實現-資料統計
在Model層,需要對進行一定範圍的查詢,如下:
static async getTotal(start, end) {
const where = {
crimeTime: {
[Op.between]: [start, end],
},
};
try {
const ret = await this.count({ where });
logger.info(this.getTotal, start, end, ret);
return ret;
} catch (e) {
logger.error(e);
return false;
}
}
可能細心的讀者發現了,我這邊儲存資料用的是時間戳,一個月的時間戳是2592000
。所以這邊想要取一個月的資料就很簡單了框定範圍[start, start + 2592000]
就好了。
以餅圖的為例, service
層程式碼如下:
async calculateCount(start, end) {
const { Bank, Mobile, Virtual, Website } = this;
const bankCount = await Bank.getTotal(start, end + 2592000);
const mobileCount = await Mobile.getTotal(start, end + 2592000);
const virtualCount = await Virtual.getTotal(start, end + 2592000);
const websiteCount = await Website.getTotal(start, end + 2592000);
return [
{ name: '銀行查控', count: bankCount || 0 },
{ name: '電話查控', count: mobileCount || 0 },
{ name: '虛擬賬號查控', count: virtualCount || 0 },
{ name: '網站查控', count: websiteCount || 0 },
];
}
controller
層的程式碼如下(這邊其實可以用Promise.all進行優化,參考schedule那個例子,讀者試著改一下吧,因為它觸發了eslint的這個規則,/* eslint-disable no-await-in-loop */
, 不建議在迴圈中用await):
async calculate() {
const { ctx, service } = this;
const { start, end } = ctx.request.body;
const startUinx = this.ctx.app.utils.date.transformDate(start);
const endUnix = this.ctx.app.utils.date.transformDate(end);
const pieData = await service.statistics.calculateCount(startUinx, endUnix);
const monthArr = this.ctx.app.utils.date.getMonthArr(start, end);
const barData = [];
let totalAmount = 0;
for (const month of monthArr) {
const { name, value } = month;
const unix = this.ctx.app.utils.date.transformDate(value);
const amount = await service.statistics.calculateAmount(unix);
totalAmount += amount;
barData.push({ name, amount });
}
ctx.success({ pieData, barData, totalAmount });
}
功能實現-按鈕許可權控制
這裡以銀行查控為例,主要是根據相關的使用者角色和記錄的狀態去判斷它有什麼按鈕許可權,具體的程式碼如下:
async index() {
const { ctx, service } = this;
const { alarmNo, account, bankId, accountType, page, pageSize } =
ctx.request.query;
const { list, ...rest } = await service.bank.getAllLimit(
alarmNo,
account,
bankId,
accountType,
Number(page),
Number(pageSize)
);
const data = list.map(item => {
const { role } = ctx.session.userinfo;
const { status } = item;
let btnList = [];
if (role === 'operator') {
if (status === 0) {
btnList = ['check', 'detail'];
} else if (status === 1) {
btnList = ['detail'];
}
} else {
btnList = ['detail'];
}
return {
btnList,
...item,
};
});
ctx.success({ list: data, ...rest });
}
部署實施
傳統方式一個一個來(以ubuntu21.04舉例)
能聯網
第一步:
mysql的安裝: apt-get install mysql-server
nginx的安裝: apt-get install nginx
nodejs的安裝 : apt-get install nodejs
第二步:
配置nignx、mysql開機自啟動(略),nodejs這邊的程式建議用pm2管理,具體的步驟如下
安裝pm2
npm i pm2 -g
egg程式設定pm2管理,根目錄新增server.js
// server.js
const egg = require('egg');
// eslint-disable-next-line global-require
const workers = Number(process.argv[2] || require('os').cpus().length);
egg.startCluster({
workers,
baseDir: __dirname,
port: 7001,
});
對pm2程式進行配置,根目錄新增ecosystem.config.js
module.exports = {
apps: [
{
name: 'server',
script: './server.js',
instances: '1',
exec_mode: 'cluster',
env: {
COMMON_VARIABLE: 'true',
NODE_ENV: 'production',
PORT: 7001,
},
},
],
};
在package.json
的scripts中中新增指令碼
"pm2": "pm2 start server.js --env ecosystem.config.js"
設定pm2開機自啟動
pm2 startup
pm2 save
pm2文件: https://pm2.keymetrics.io/docs/usage/quick-start/
eggjs程式用pm2管理: https://eggjs.org/zh-cn/faq.html#程式管理為什麼沒有選型-pm2
這裡寫的eggjs程式官網是不推薦用pm2管理的,在開發環境有egg-bin
,生產環境有egg-script
,我這邊主要是考慮到,伺服器那邊環境沒有我們阿里雲或者騰訊雲上面操作方便,需要考慮當機後系統的重啟,而PM2剛好具備這些特性(管理node程式,開機自啟動管理的node程式),俺也懶得寫啟動指令碼,就在選型上用pm2去管理,後面上了docker以後,那就沒這麼多雜事了。
不能聯網
阿西吧,這個就比較頭大了。這邊就提供兩個思路,第一個是組網,就是用你的電腦拉一根網線跟伺服器組一個區域網,然後共享你的網路,這裡需要注意的是,伺服器可能 會有多個網路卡,你需要確保你所配置的那張網路卡是對的,這邊有兩個辦法,第一個是眼睛睜大看網口上有沒有標號, 第二個就是暴力組網,在你的宿主機上,使用ping 伺服器內網配置ip -t
去測,直到發現那個正確的網路卡。組完網,參照樓上的就跑一遍唄。
第二個就異常痛苦啦,實在連不上網,就需要提前下載相關的原始碼包(這裡比較頭大的是,一些不確定的依賴,比如編譯安裝的時候,可能需要下c++的庫),掛載到伺服器上一個一個解壓編譯安裝,emmmmmmmm,太痛苦了,放棄治療吧,我選docker。
優劣勢
沒看出有啥優勢,頭皮發麻,2333333333。
docker
一把梭
Dockerfile的編寫
通過docker build
命令執行Dockerfile
,我們可以得到相應的映象,然後通過docker run
相應的映象我們可以得到相應的容器,注意這裡run
命令要慎用,因為每執行一次都會建立一層映象,你可以把多條命令用&&
放到一起,或者放到CMD命令中,CMD是容器跑起來的時候執行的命令。
前端(以hzga-fe為例)
這裡表示是基於node14.8.0的映象,建立人是ataola,以/app
為工作目錄,拷貝相關的檔案到工作目錄,然後執行相關的命令構建映象,在構建完以後,基於nginx1.17.2的映象,將打包好後的檔案拷貝到nginx中,暴露80埠,在容器跑起來的時候執行CMD的命令。
# build stage
FROM node:14.8.0 as build-stage
MAINTAINER ataola <zjt613@gmail.com>
WORKDIR /app
COPY package.json ./
COPY nginx ./nginx/
COPY public ./public/
COPY .editorconfig .env.* .eslintrc.js .eslintignore .prettierrc jsconfig.json *.config.js ./
COPY src ./src/
COPY build ./build/
RUN npm install --registry=https://registry.npm.taobao.org cnpm -g \
&& SASS_BINARY_SITE=https://npm.taobao.org/mirrors/node-sass/ cnpm install --registry=https://registry.npm.taobao.org \
&& npm rebuild node-sass \
&& npm run build:prod
# production stage
FROM nginx:1.17.2-alpine-perl as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY --from=build-stage /app/nginx /etc/nginx/
VOLUME /app
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
nginx.conf
配置檔案如下
#user nobody;
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
#gzip on;
upstream eggServer {
server hzga-be:7001;
}
server {
listen 80;
server_name hzga-fe;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ =404;
}
location /prod-api {
rewrite /prod-api/(.*) /$1 break;
client_max_body_size 100M;
proxy_pass http://eggServer;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;
# location / {
# root html;
# index index.html index.htm;
# }
#}
# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;
# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# location / {
# root html;
# index index.html index.htm;
# }
#}
}
後端 (以hzga-be為例)
參考樓上hzga-fe的釋義。
FROM node:14.8.0
MAINTAINER ataola <zjt613@gmail.com>
WORKDIR /app
COPY package.json ./
RUN npm install --registry=https://registry.npm.taobao.org --production
COPY app ./app
COPY config ./config
COPY .eslintrc .eslintignore .prettierrc .autod.conf.js .editorconfig app.js jsconfig.json ./
VOLUME /app
EXPOSE 7001
CMD ["npm", "run", "docker"]
MySQL資料庫(以hzga-mysql為例)
參考樓上的樓上hzga-fe的釋義, 與之不同的是,這裡通過配置設定了建庫指令碼,使用者名稱密碼。
FROM mysql:8.0.16
MAINTAINER ataola<zjt613@gmail.com>
ENV MYSQL_DATABASE anti-fraud
ENV MYSQL_ROOT_PASSWORD ataola
ENV MYSQL_ROOT_HOST '%'
ENV AUTO_RUN_DIR ./docker-entrypoint-initdb.d
ENV INIT_SQL anti-fraud.sql
COPY ./$INIT_SQL $AUTO_RUN_DIR/
RUN chmod a+x $AUTO_RUN_DIR/$INIT_SQL
VOLUME /app
EXPOSE 3306
docker-compose.yml的編寫
我們開發會涉及到前端、後端、資料庫。docker-compose
可以把多個容器放在一起管理,預設會建立一個網路,通過相關的服務名就可以訪問,比如說,hzga-be的後端服務想要訪問hzga-mysql的資料庫,那麼就可以直接在配置檔案中,將ip改成hzga-mysql。同理,前端nginx這邊的代理,如果要訪問後端,那麼可以在代理的位置直接寫haga-be。
docker-compose.yml
檔案如下:
這裡表示是基於docker-compose 3.3版本的, 然後有三個service,分別是hzga-fe(前端),hzga-be(後端),hzga-mysql(MYSQL資料庫),然後指定了Dockerfile的位置,製作成映象後的名字,暴露了相應的埠,然後容器的名字,失敗後的重啟策略,以及建立的網路的名字,其中後端的服務hzga-be基於資料庫hzga-mysql
version: '3.3'
services:
hzga-fe:
build:
context: ./anti-fraud-system-fe
dockerfile: Dockerfile
image: ataola/hzga-fe:0.0.1
ports:
- "80:80"
networks:
- net-hzga
container_name: hzga-fe
restart: on-failure
hzga-be:
build:
context: ./anti-fraud-system-be
dockerfile: Dockerfile
image: ataola/hzga-be:0.0.1
ports:
- "7001:7001"
depends_on:
- hzga-mysql
networks:
- net-hzga
container_name: hzga-be
restart: on-failure
hzga-mysql:
build:
context: ./database
dockerfile: Dockerfile
image: ataola/hzga-mysql:0.0.1
ports:
- "3306:3306"
networks:
- net-hzga
container_name: hzga-mysql
restart: on-failure
networks:
net-hzga:
driver: bridge
下面介紹下通過docker-compose
管理
部署這套服務: docker-compose up -d
暫停這套服務: docker-compose pause
下線這套服務: docker-compose down
檢視相關的日誌: docker-compose logs
, 後面可以跟容器名字
如果是docker的命令 可以用docker help
檢視,如果是docker-compose
的命令可以用docker-compose help
檢視
docker-compose的介紹: https://docs.docker.com/compose/
優勢
部署很爽啊,配置檔案一寫,命令一敲,起! 包括後續的一些維護,重啟啊、暫停啊等等很方便,方便搭建相關的叢集,相關的環境(開發、測試、釋出)
劣勢
增加了學習成本。
心得感悟
這個專案到這裡,第一個初代版本算上OK了。我這邊也羅列了一些思考和問題供讀者們交流
網路安全
- 讓使用者不能同時線上,儘可以在一端登入
- 對於敏感資料,比如說身份證、手機號等等前後端的互動肯定是不能明文的,怎麼處理?
- 對使用者多次密碼錯誤嘗試鎖定使用者
效能優化
- SPA應用一個是白屏、一個是對SEO不友好,假設這邊要處理的話,怎麼去做?
- 假如資料量很大了,資料庫怎麼優化,或者查詢怎麼優化?
- 加入使用者量很大的話,如何接入redis做持久化?
業務優化
- 前端的相關表單加入詳細的格式校驗
- 後端的引數加入詳細的格式校驗
- 整理完備的電話號碼段資料庫、銀行卡號資料庫等等,進一步優化,各機構角色使用者只能看自己的(參考警情)
- 相關的定時任務能不能手動停掉去,或者做出可配置化
- 前端的相關下拉框做成可搜尋,優化使用者體驗
- 前端的表格做的花裡胡哨,紅黃綠啥的安排下,讓使用者更直觀地看到狀態和操作
程式碼優化
- 前端針對圖表封裝相應的元件
- 文中所示的表格其實並不是通用性的,還是具有特定場景的,在這個基礎上改造一個通用性的元件
寫在最後
docker映象地址
反欺詐系統前端: https://hub.docker.com/repository/docker/ataola/hzga-fe
反欺詐系統後端: https://hub.docker.com/repository/docker/ataola/hzga-be
反欺詐系統MySQL: https://hub.docker.com/repository/docker/ataola/hzga-mysql
原始碼地址
github: https://github.com/cnroadbridge/anti-fraud-system
gitee: https://gitee.com/taoge2021/anti-fraud-system
假如人與人之間多一點真誠,少一些欺騙,那麼就不需要我們新生代農民工開發什麼反欺詐系統了,世界將會變得更美好,烏拉!烏拉!烏拉!
以上就是今天的全部內容,因為內容比較多,寫的也比較倉促,所以有些地方我也是潦草地一筆帶過,如有問題,歡迎與我交流探討,謝謝!