项目初始化

This commit is contained in:
2026-03-18 15:54:43 +08:00
commit dfcd762e23
601 changed files with 57883 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.admin.username') + '/' + t('auth.admin.nickname') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'auth/admin',
})
const { t } = useI18n()
const adminInfo = useAdminInfo()
const optButtons = defaultOptButtons(['edit', 'delete'])
optButtons[1].display = (row) => {
return row.id != adminInfo.id
}
const baTable = new baTableClass(
new baTableApi('/admin/auth.Admin/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', operator: false, render: 'tags' },
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('auth.admin.Last login'),
prop: 'last_login_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') },
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
status: 'enable',
},
}
)
provide('baTable', baTable)
baTable.mount()
baTable.getData()
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,198 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
:label="t('auth.admin.username')"
v-model="baTable.form.items!.username"
type="string"
prop="username"
:placeholder="t('auth.admin.Administrator login')"
/>
<FormItem
:label="t('auth.admin.nickname')"
v-model="baTable.form.items!.nickname"
type="string"
prop="nickname"
:placeholder="t('Please input field', { field: t('auth.admin.nickname') })"
/>
<FormItem
:label="t('auth.admin.group')"
v-model="baTable.form.items!.group_arr"
prop="group_arr"
type="remoteSelect"
:key="'group-' + baTable.form.items!.id"
:input-attr="{
multiple: true,
params: { isTree: true, absoluteAuth: adminInfo.id == baTable.form.items!.id ? 0 : 1 },
field: 'name',
remoteUrl: '/admin/auth.Group/index',
placeholder: t('Click select'),
}"
/>
<FormItem :label="t('auth.admin.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<FormItem
:label="t('auth.admin.email')"
prop="email"
v-model="baTable.form.items!.email"
type="string"
:placeholder="t('Please input field', { field: t('auth.admin.email') })"
/>
<FormItem
:label="t('auth.admin.mobile')"
prop="mobile"
v-model="baTable.form.items!.mobile"
type="string"
:placeholder="t('Please input field', { field: t('auth.admin.mobile') })"
/>
<FormItem
:label="t('auth.admin.Password')"
prop="password"
v-model="baTable.form.items!.password"
type="password"
:input-attr="{ autocomplete: 'new-password' }"
:placeholder="
baTable.form.operate == 'Add'
? t('Please input field', { field: t('auth.admin.Password') })
: t('auth.admin.Please leave blank if not modified')
"
/>
<el-form-item prop="motto" :label="t('auth.admin.Personal signature')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.motto"
type="textarea"
:placeholder="t('Please input field', { field: t('auth.admin.Personal signature') })"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { disable: t('Disable'), enable: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { regularPassword, buildValidatorData } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const adminInfo = useAdminInfo()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
group_arr: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('auth.admin.group') }) })],
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],
password: [
{
validator: (rule: any, val: string, callback: Function) => {
if (baTable.form.operate == 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('auth.admin.Password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
})
watch(
() => baTable.form.operate,
(newVal) => {
rules.password![0].required = newVal == 'Add'
}
)
</script>
<style scoped lang="scss">
.avatar-uploader {
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: var(--el-border-radius-small);
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
cursor: pointer;
overflow: hidden;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.adminLog.title') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<Info />
</div>
</template>
<script setup lang="ts">
import { concat, cloneDeep } from 'lodash-es'
import { provide } from 'vue'
import baTableClass from '/@/utils/baTable'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import Info from './info.vue'
import { buildJsonToElTreeData } from '/@/utils/common'
defineOptions({
name: 'auth/adminLog',
})
const { t } = useI18n()
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'info',
title: 'Info',
text: '',
type: 'primary',
icon: 'fa fa-search-plus',
class: 'table-row-edit',
disabledTip: false,
click: (row: TableRow) => {
infoButtonClick(row)
},
},
]
optButtons = concat(optButtons, defaultOptButtons(['delete']))
const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{
label: t('auth.adminLog.admin_id'),
prop: 'admin_id',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 70,
},
{
label: t('auth.adminLog.username'),
prop: 'username',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 160,
},
{ label: t('auth.adminLog.title'), prop: 'title', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
show: false,
label: t('auth.adminLog.data'),
prop: 'data',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('auth.adminLog.url'),
prop: 'url',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
render: 'url',
},
{ label: t('auth.adminLog.ip'), prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tag' },
{
label: t('auth.adminLog.useragent'),
prop: 'useragent',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('Create time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined],
})
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
infoButtonClick(row)
return false
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
/** 点击查看详情按钮响应 */
const infoButtonClick = (row: TableRow) => {
if (!row) return
// 数据来自表格数据,未重新请求api,深克隆,不然可能会影响表格
let rowClone = cloneDeep(row)
rowClone.data = rowClone.data ? [{ label: '点击展开', children: buildJsonToElTreeData(JSON.parse(rowClone.data)) }] : []
baTable.form.extend!['info'] = rowClone
baTable.form.operate = 'Info'
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,62 @@
<template>
<!-- 查看详情 -->
<el-dialog class="ba-operate-dialog" :model-value="baTable.form.operate ? true : false" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ t('Info') }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'">
<el-descriptions :column="2" border>
<el-descriptions-item :label="t('Id')">
{{ baTable.form.extend!.info.id }}
</el-descriptions-item>
<el-descriptions-item :label="t('auth.adminLog.Operation administrator')">
{{ baTable.form.extend!.info.username }}
</el-descriptions-item>
<el-descriptions-item :label="t('auth.adminLog.title')">
{{ baTable.form.extend!.info.title }}
</el-descriptions-item>
<el-descriptions-item :label="t('auth.adminLog.Operator IP')">
{{ baTable.form.extend!.info.ip }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="URL">
{{ baTable.form.extend!.info.url }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="User Agent">
{{ baTable.form.extend!.info.useragent }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" :label="t('Create time')">
{{ timeFormat(baTable.form.extend!.info.create_time) }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" :label="t('auth.adminLog.Request data')">
<el-tree class="table-el-tree" :data="baTable.form.extend!.info.data" :props="{ label: 'label', children: 'children' }" />
</el-descriptions-item>
</el-descriptions>
</div>
</el-scrollbar>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type BaTable from '/@/utils/baTable'
import { timeFormat } from '/@/utils/common'
const baTable = inject('baTable') as BaTable
const { t } = useI18n()
</script>
<style scoped lang="scss">
.table-el-tree {
:deep(.el-tree-node) {
white-space: unset;
}
:deep(.el-tree-node__content) {
display: block;
align-items: unset;
height: unset;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="default-main ba-table-box">
<el-alert
class="ba-table-alert group-super-alert"
v-if="!adminInfo.super"
:title="t('auth.group.Manage subordinate role groups here')"
type="info"
show-icon
/>
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.group.GroupName') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" :pagination="false" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { getAdminRules } from '/@/api/backend/auth/group'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import { useAdminInfo } from '/@/stores/adminInfo'
import baTableClass from '/@/utils/baTable'
import { getArrayKey } from '/@/utils/common'
import { uuid } from '/@/utils/random'
defineOptions({
name: 'auth/group',
})
const { t } = useI18n()
const adminInfo = useAdminInfo()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable: baTableClass = new baTableClass(
new baTableApi('/admin/auth.Group/'),
{
expandAll: true,
dblClickNotEditColumn: [undefined],
column: [
{ type: 'selection', align: 'center' },
{ label: t('auth.group.Group name'), prop: 'name', align: 'left', width: '200' },
{ label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons(['edit', 'delete']) },
],
},
{
defaultItems: {
status: 1,
},
}
)
// 利用提交前钩子重写提交操作
baTable.before.onSubmit = ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
baTable.api
.postData(operate, {
...items,
rules: formRef.value?.getCheckeds(),
})
.then((res) => {
baTable.onTableHeaderAction('refresh', {})
baTable.form.submitLoading = false
baTable.form.operateIds?.shift()
if (baTable.form.operateIds!.length > 0) {
baTable.toggleForm('Edit', baTable.form.operateIds)
} else {
baTable.toggleForm()
}
baTable.runAfter('onSubmit', { res })
})
.catch(() => {
baTable.form.submitLoading = false
})
}
if (formEl) {
baTable.form.ref = formEl
formEl.validate((valid) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
return false
}
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
return baTable.table.extend!.adminGroup.indexOf(row.id) === -1
}
// 获取到数据后钩子
baTable.after.getData = ({ res }) => {
baTable.table.extend!.adminGroup = res.data.group
let buttonsKey = getArrayKey(baTable.table.column, 'render', 'buttons')
baTable.table.column[buttonsKey].buttons!.forEach((value: OptButton) => {
value.display = (row) => {
return res.data.group.indexOf(row.id) === -1
}
})
}
// 切换表单后钩子
baTable.after.toggleForm = ({ operate }) => {
if (operate == 'Add') {
menuRuleTreeUpdate()
}
}
// 编辑请求完成后钩子
baTable.after.getEditData = () => {
menuRuleTreeUpdate()
}
const menuRuleTreeUpdate = () => {
getAdminRules().then((res) => {
baTable.form.extend!.menuRules = res.data.list
if (baTable.form.items!.rules && baTable.form.items!.rules.length) {
if (baTable.form.items!.rules.includes('*')) {
let arr: number[] = []
for (const key in baTable.form.extend!.menuRules) {
arr.push(baTable.form.extend!.menuRules[key].id)
}
baTable.form.extend!.defaultCheckedKeys = arr
} else {
baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules
}
} else {
baTable.form.extend!.defaultCheckedKeys = []
}
baTable.form.extend!.treeKey = uuid()
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss">
.group-super-alert {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
:label="t('auth.group.Parent group')"
v-model="baTable.form.items!.pid"
type="remoteSelect"
prop="pid"
:input-attr="{
params: { isTree: true },
field: 'name',
remoteUrl: baTable.api.actionUrl.get('index'),
placeholder: t('Click select'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<el-form-item prop="name" :label="t('auth.group.Group name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('Please input field', { field: t('auth.group.Group name') })"
></el-input>
</el-form-item>
<el-form-item prop="auth" :label="t('auth.group.jurisdiction')">
<el-tree
ref="treeRef"
:key="baTable.form.extend!.treeKey"
:default-checked-keys="baTable.form.extend!.defaultCheckedKeys"
:default-expand-all="true"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'title', class: treeNodeClass }"
:data="baTable.form.extend!.menuRules"
class="w100"
/>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { ElTree, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })],
auth: [
{
required: true,
validator: (rule: any, val: string, callback: Function) => {
let ids = getCheckeds()
if (ids.length <= 0) {
return callback(new Error(t('Please select field', { field: t('auth.group.jurisdiction') })))
}
return callback()
},
},
],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.group.The parent group cannot be the group itself')))
}
return callback()
},
trigger: 'blur',
},
],
})
const getCheckeds = () => {
return treeRef.value!.getCheckedKeys().concat(treeRef.value!.getHalfCheckedKeys())
}
const treeNodeClass = (data: anyObj, node: Node) => {
if (node.isLeaf) return ''
let addClass = true
for (const key in node.childNodes) {
if (!node.childNodes[key].isLeaf) {
addClass = false
}
}
return addClass ? 'penultimate-node' : ''
}
defineExpose({
getCheckeds,
})
</script>
<style scoped lang="scss">
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;
white-space: pre-wrap;
line-height: 12px;
.el-tree-node {
display: inline-block;
}
.el-tree-node__content {
padding-left: 5px !important;
padding-right: 5px;
.el-tree-node__expand-icon {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.rule.title') })"
/>
<!-- 设置合适的 max-height 实现隐藏布局主体部分本身的滚动条这样就可以监听表格的 @scroll -->
<!-- max-height = 100vh - (当前布局顶栏高度 + 表头栏高度 + 表格上边距 + 预留的底部下边距) -->
<Table
ref="tableRef"
:max-height="`calc(-${adminLayoutHeaderBarHeight[config.layout.layoutMode as keyof typeof adminLayoutHeaderBarHeight] + 75 + 16}px + 100vh)`"
:pagination="false"
@expand-change="onExpandChange"
@scroll="onScroll"
/>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { cloneDeep, debounce } from 'lodash-es'
import { nextTick, onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import { useConfig } from '/@/stores/config'
import baTableClass from '/@/utils/baTable'
import { adminLayoutHeaderBarHeight } from '/@/utils/layout'
defineOptions({
name: 'auth/rule',
})
const { t } = useI18n()
const config = useConfig()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/auth.Rule/'),
{
expandAll: false,
dblClickNotEditColumn: [undefined, 'keepalive', 'status'],
column: [
{ type: 'selection', align: 'center' },
{ label: t('auth.rule.title'), prop: 'title', align: 'left', width: '200' },
{ label: t('auth.rule.Icon'), prop: 'icon', align: 'center', width: '60', render: 'icon', default: 'fa fa-circle-o' },
{ label: t('auth.rule.name'), prop: 'name', align: 'center', showOverflowTooltip: true },
{
label: t('auth.rule.type'),
prop: 'type',
align: 'center',
render: 'tag',
custom: { menu: 'danger', menu_dir: 'success', button: 'info' },
replaceValue: { menu: t('auth.rule.type menu'), menu_dir: t('auth.rule.type menu_dir'), button: t('auth.rule.type button') },
},
{ label: t('auth.rule.cache'), prop: 'keepalive', align: 'center', width: '80', render: 'switch' },
{ label: t('State'), prop: 'status', align: 'center', width: '80', render: 'switch' },
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(),
},
],
dragSortLimitField: 'pid',
},
{
defaultItems: {
type: 'menu',
menu_type: 'tab',
extend: 'none',
keepalive: 0,
status: 1,
icon: 'fa fa-circle-o',
buttons: ['index', 'add', 'edit', 'del'],
},
}
)
/**
* 内存缓存表格的一些状态数据,供数据刷新后恢复
*/
const sessionStateDefault = {
expanded: [] as any[],
scrollTop: 0,
scrollLeft: 0,
expandAll: false,
}
let sessionState = sessionStateDefault
/**
* 记录表格行展开状态
*/
const onExpandChange = (row: any, expanded: boolean) => {
if (expanded) {
sessionState.expanded.push(row)
} else {
sessionState.expanded = sessionState.expanded.filter((item: any) => item.id !== row.id)
}
}
/**
* 记录表格滚动条位置
*/
const onScroll = debounce(({ scrollLeft, scrollTop }: { scrollLeft: number; scrollTop: number }) => {
sessionState.scrollTop = scrollTop
sessionState.scrollLeft = scrollLeft
}, 500)
/**
* 记录表格行展开折叠状态
*/
const onUnfoldAll = (state: boolean) => {
sessionState.expandAll = state
}
/**
* 恢复已记录的表格状态
*/
const restoreState = () => {
nextTick(() => {
const sessionStateTemp = sessionState
// 重置 sessionState 为默认值,恢复缓存的记录时将自动重设
sessionState = cloneDeep(sessionStateDefault)
for (const key in sessionStateTemp.expanded) {
tableRef.value?.getRef()?.toggleRowExpansion(sessionStateTemp.expanded[key], true)
}
nextTick(() => {
if (sessionStateTemp.scrollTop || sessionStateTemp.scrollLeft) {
tableRef.value?.getRef()?.scrollTo({ top: sessionStateTemp.scrollTop || 0, left: sessionStateTemp.scrollLeft || 0 })
}
/**
* expandAll 为 “是否默认展开所有行”
* 此处表格数据已渲染,仅做顶部按钮状态标记用,不会实际上的执行展开折叠操作
* 展开全部行之后再只对某一行进行折叠时expandAll 不会改变,所以此处并不根据 expandAll 值执行折叠展开所有行的操作
*/
baTable.table.expandAll = sessionStateTemp.expandAll
onUnfoldAll(sessionStateTemp.expandAll)
})
})
}
// 获取数据前钩子
baTable.before.getData = () => {
baTable.table.expandAll = baTable.table.filter?.quickSearch ? true : false
}
// 获取到编辑行数据后的钩子
baTable.after.getEditData = () => {
if (baTable.form.items && !baTable.form.items.icon) {
baTable.form.items.icon = 'fa fa-circle-o'
}
}
// 表格顶部按钮事件触发后的钩子
baTable.after.onTableHeaderAction = ({ event, data }) => {
if (event == 'unfold') {
onUnfoldAll(data.unfold)
}
}
// 获取到表格数据后的钩子
baTable.after.getData = () => {
restoreState()
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.dragSort()
})
})
</script>
<style scoped lang="scss">
.default-main {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="pid"
:label="t('auth.rule.Superior menu rule')"
v-model="baTable.form.items!.pid"
:placeholder="t('Click select')"
:input-attr="{
params: { isTree: true },
field: 'title',
remoteUrl: baTable.api.actionUrl.get('index'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<FormItem
:label="t('auth.rule.Rule type')"
v-model="baTable.form.items!.type"
type="radio"
:input-attr="{
border: true,
content: { menu_dir: t('auth.rule.type menu_dir'), menu: t('auth.rule.type menu'), button: t('auth.rule.type button') },
}"
/>
<el-form-item prop="title" :label="t('auth.rule.Rule title')">
<el-input
v-model="baTable.form.items!.title"
type="string"
:placeholder="t('Please input field', { field: t('auth.rule.Rule title') })"
></el-input>
</el-form-item>
<el-form-item prop="name" :label="t('auth.rule.Rule name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('auth.rule.English name, which does not need to start with `/admin`, such as auth/menu')"
></el-input>
<div class="block-help">
{{ t('auth.rule.It will be registered as the web side routing name and used as the server side API authentication') }}
</div>
</el-form-item>
<el-form-item prop="path" v-if="baTable.form.items!.type != 'button'" :label="t('auth.rule.Routing path')">
<el-input
v-model="baTable.form.items!.path"
type="string"
:placeholder="t('auth.rule.The web side routing path (path) does not need to start with `/admin`, such as auth/menu')"
></el-input>
</el-form-item>
<FormItem
v-if="baTable.form.operate && baTable.form.items!.type != 'button'"
type="icon"
:label="t('auth.rule.Rule Icon')"
v-model="baTable.form.items!.icon"
:input-attr="{
showIconName: true,
}"
/>
<FormItem
v-if="baTable.form.items!.type == 'menu'"
:label="t('auth.rule.Menu type')"
v-model="baTable.form.items!.menu_type"
type="radio"
:input-attr="{
border: true,
content: { tab: t('auth.rule.Menu type tab'), link: t('auth.rule.Menu type link (offsite)'), iframe: 'Iframe' },
}"
/>
<el-form-item
prop="url"
v-if="baTable.form.items!.menu_type != 'tab' && baTable.form.items!.type == 'menu'"
:label="t('auth.rule.Link address')"
>
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('auth.rule.Please enter the URL address of the link or iframe')"
></el-input>
</el-form-item>
<el-form-item
prop="component"
v-if="baTable.form.items!.type == 'menu' && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Component path')"
>
<el-input
v-model="baTable.form.items!.component"
type="string"
:placeholder="t('auth.rule.Web side component path, please start with /src, such as: /src/views/backend/dashboard')"
></el-input>
</el-form-item>
<el-form-item
v-if="baTable.form.items!.type == 'menu' && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Extended properties')"
>
<el-select
class="w100"
v-model="baTable.form.items!.extend"
:placeholder="t('Please select field', { field: t('auth.rule.Extended properties') })"
>
<el-option :label="t('auth.rule.none')" value="none"></el-option>
<el-option :label="t('auth.rule.Add as route only')" value="add_rules_only"></el-option>
<el-option :label="t('auth.rule.Add as menu only')" value="add_menu_only"></el-option>
</el-select>
<div class="block-help">{{ t('auth.rule.extend Title') }}</div>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule comments')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="
t(
'auth.rule.Use in controller `get_ route_ Remark()` function, which can obtain the value of this field for your own use, such as the banner file of the console'
)
"
></el-input>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule weight')">
<el-input
v-model="baTable.form.items!.weigh"
type="number"
:placeholder="t('auth.rule.Please enter the weight of menu rule (sort by)')"
></el-input>
</el-form-item>
<FormItem
v-if="baTable.form.operate == 'Add' && baTable.form.items!.type == 'menu'"
:label="t('auth.rule.Create Page Button')"
v-model="baTable.form.items!.buttons"
type="selects"
:input-attr="{
content: {
index: t('auth.rule.Create Page Button index'),
add: t('auth.rule.Create Page Button add'),
edit: t('auth.rule.Create Page Button edit'),
del: t('auth.rule.Create Page Button del'),
sortable: t('auth.rule.Create Page Button sortable'),
},
}"
:placeholder="t('auth.rule.Please select the button for automatically creating the desired page')"
:block-help="t('auth.rule.Create Page Button tips')"
/>
<FormItem
:label="t('auth.rule.cache')"
v-model="baTable.form.items!.keepalive"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import { buildValidatorData } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule title') })],
name: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule name') })],
path: [buildValidatorData({ name: 'required', title: t('auth.rule.Routing path') })],
url: [
buildValidatorData({ name: 'required', title: t('auth.rule.Link address') }),
buildValidatorData({ name: 'url', message: t('auth.rule.Please enter the correct URL') }),
],
component: [buildValidatorData({ name: 'required', message: t('auth.rule.Component path') })],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.rule.The superior menu rule cannot be the rule itself')))
}
return callback()
},
trigger: 'blur',
},
],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,2094 @@
<template>
<div class="default-main">
<div class="header-config-box">
<el-row class="header-box">
<div class="header">
<div class="header-item-box">
<FormItem
class="mr-20 table-name-item"
:label="t('crud.log.table_name')"
v-model="state.table.name"
type="string"
:placeholder="t('crud.crud.Name of the data table')"
:input-attr="{
onChange: onTableNameChange,
}"
:error="state.error.tableName"
/>
<FormItem
class="table-comment-item"
:label="t('crud.crud.Data Table Notes')"
v-model="state.table.comment"
type="string"
:placeholder="t('crud.crud.For example: `user table` will be generated into `user management`')"
/>
</div>
<div class="header-right">
<el-link v-if="crudState.type != 'create'" @click="state.showDesignChangeLog = true" class="design-change-log" type="primary">
{{ t('crud.crud.Table design change') }}
</el-link>
<el-button type="primary" :loading="state.loading.generate" @click="onGenerate" v-blur>
{{ t('crud.crud.Generate CRUD code') }}
</el-button>
<el-button @click="onAbandonDesign" type="danger" v-blur>{{ t('crud.crud.give up') }}</el-button>
</div>
</div>
</el-row>
<transition :name="state.showHeaderSeniorConfig ? 'el-zoom-in-top' : 'el-zoom-in-bottom'">
<div v-if="state.showHeaderSeniorConfig" class="header-senior-config-box">
<div class="header-senior-config-form">
<el-form-item :label-width="140" :label="t('crud.crud.Table Quick Search Fields')">
<el-select :clearable="true" :multiple="true" class="w100" v-model="state.table.quickSearchField" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<div class="default-sort-field-box">
<el-form-item :label-width="140" class="default-sort-field mr-20" :label="t('crud.crud.Table Default Sort Fields')">
<el-select :clearable="true" v-model="state.table.defaultSortField" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<FormItem
class="default-sort-field-type"
:label="t('crud.crud.sort order')"
v-model="state.table.defaultSortType"
type="select"
:input-attr="{
content: { desc: t('crud.crud.sort order desc'), asc: t('crud.crud.sort order asc') },
}"
/>
</div>
<el-form-item :label-width="140" :label="t('crud.crud.Fields as Table Columns')">
<el-select :clearable="true" :multiple="true" class="w100" v-model="state.table.columnFields" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<el-form-item :label-width="140" :label="t('crud.crud.Fields as form items')">
<el-select :clearable="true" :multiple="true" class="w100" v-model="state.table.formFields" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<FormItem
:label="t('crud.crud.The relative path to the generated code')"
v-model="state.table.generateRelativePath"
type="string"
:label-width="140"
:block-help="t('crud.crud.For quick combination code generation location, please fill in the relative path')"
:input-attr="{
onChange: onTableChange,
onInput: debouncedOnRelativePathInput,
}"
/>
<FormItem
:label="t('crud.crud.Generated Controller Location')"
v-model="state.table.controllerFile"
type="string"
:label-width="140"
/>
<el-form-item :label="t('crud.crud.Generated Data Model Location')" :label-width="140">
<el-input v-model="state.table.modelFile" type="string">
<template #append>
<el-checkbox
@change="onChangeCommonModel"
v-model="state.table.isCommonModel"
:label="t('crud.crud.Common model')"
size="small"
:true-value="1"
:false-value="0"
/>
</template>
</el-input>
</el-form-item>
<FormItem
:label="t('crud.crud.Generated Validator Location')"
v-model="state.table.validateFile"
type="string"
:label-width="140"
/>
<FormItem :label="t('crud.crud.WEB end view directory')" v-model="state.table.webViewsDir" type="string" :label-width="140" />
<FormItem
:label="t('Database connection')"
v-model="state.table.databaseConnection"
type="remoteSelect"
:label-width="140"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
}"
/>
</div>
</div>
</transition>
<div @click="state.showHeaderSeniorConfig = !state.showHeaderSeniorConfig" class="header-senior-config">
<span>{{ t('crud.crud.Advanced Configuration') }}</span>
<Icon
class="senior-config-arrow-icon"
size="14"
color="var(--el-text-color-primary)"
:name="state.showHeaderSeniorConfig ? 'el-icon-ArrowUp' : 'el-icon-ArrowDown'"
/>
</div>
</div>
<el-row v-loading="state.loading.init" class="fields-box" :gutter="20">
<el-col :xs="24" :span="6">
<el-collapse class="field-collapse" v-model="state.fieldCollapseName">
<el-collapse-item :title="t('crud.crud.Common Fields')" name="common">
<div class="field-box" :ref="tabsRefs.set">
<div v-for="(field, index) in fieldItem.common" :key="index" class="field-item">
<span>{{ field.title }}</span>
</div>
</div>
</el-collapse-item>
<el-collapse-item :title="t('crud.crud.Base Fields')" name="base">
<div class="field-box" :ref="tabsRefs.set">
<div v-for="(field, index) in fieldItem.base" :key="index" class="field-item">
<span>{{ field.title }}</span>
</div>
</div>
</el-collapse-item>
<el-collapse-item :title="t('crud.crud.Advanced Fields')" name="senior">
<div class="field-box" :ref="tabsRefs.set">
<div v-for="(field, index) in fieldItem.senior" :key="index" class="field-item">
<span>{{ field.title }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-col>
<el-col :xs="24" :span="12">
<div ref="designWindowRef" class="design-window ba-scroll-style">
<div
v-for="(field, index) in state.fields"
:key="index"
:class="index === state.activateField ? 'activate' : ''"
@click="onActivateField(index)"
class="design-field-box"
:data-id="index"
>
<div class="design-field">
<span>{{ t('crud.crud.Field Name') }}</span>
<BaInput
@pointerdown.stop
class="design-field-name-input"
:model-value="field.name"
type="string"
:attr="{
size: 'small',
onInput: ($event: string) => onFieldNameChange($event, index),
}"
/>
</div>
<div class="design-field">
<span>{{ t('crud.crud.field comment') }}</span>
<BaInput
@pointerdown.stop
class="design-field-name-comment"
v-model="field.comment"
type="string"
:attr="{
size: 'small',
onChange: onFieldCommentChange,
}"
/>
</div>
<div class="design-field-right">
<el-button
v-if="['remoteSelect', 'remoteSelects'].includes(field.designType)"
@click.stop="onEditField(index, field)"
type="primary"
size="small"
v-blur
circle
>
<Icon color="var(--el-color-white)" size="15" name="fa fa-pencil icon" />
</el-button>
<el-button @click.stop="onDelField(index)" type="danger" size="small" v-blur circle>
<Icon color="var(--el-color-white)" size="15" name="fa fa-trash" />
</el-button>
</div>
</div>
<div class="design-field-empty" v-if="!state.fields.length && !state.draggingField">
{{ t('crud.crud.Drag the left element here to start designing CRUD') }}
</div>
</div>
</el-col>
<el-col :xs="24" :span="6">
<div class="field-config ba-scroll-style">
<div v-if="state.activateField === -1" class="design-field-empty">
{{ t('crud.crud.Please select a field from the left first') }}
</div>
<div v-else :key="'activate-field-' + state.activateField">
<el-form label-position="top">
<el-divider content-position="left">{{ t('crud.crud.Common') }}</el-divider>
<el-form-item :label="t('crud.crud.Generate type')">
<el-select
@change="onFieldDesignTypeChange($event)"
class="w100"
:model-value="state.fields[state.activateField].designType"
placement="bottom"
>
<el-option v-for="(item, idx) in designTypes" :key="idx" :label="item.name" :value="idx" />
</el-select>
</el-form-item>
<FormItem
:label="t('crud.crud.Field comments (CRUD dictionary)')"
type="textarea"
:input-attr="{
rows: 2,
onChange: onFieldCommentChange,
}"
:placeholder="
t(
'crud.crud.The field comment will be used as the CRUD dictionary, and will be identified as the field title before the colon, and as the data dictionary after the colon'
)
"
v-model="state.fields[state.activateField].comment"
/>
<el-divider content-position="left">{{ t('crud.crud.Field Properties') }}</el-divider>
<FormItem
:label="t('crud.crud.Field Name')"
type="string"
:model-value="state.fields[state.activateField].name"
:input-attr="{
onInput: ($event: string) => onFieldNameChange($event, state.activateField),
}"
/>
<template v-if="state.fields[state.activateField].dataType">
<FormItem
:label="t('crud.crud.Field Type')"
:input-attr="{
onChange: onFieldAttrChange,
}"
type="textarea"
v-model="state.fields[state.activateField].dataType"
/>
</template>
<template v-else>
<FormItem
:label="t('crud.crud.Field Type')"
:input-attr="{
onChange: onFieldAttrChange,
}"
type="string"
v-model="state.fields[state.activateField].type"
/>
<div class="field-inline">
<FormItem
:label="t('crud.crud.length')"
type="number"
v-model="state.fields[state.activateField].length"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
<FormItem
:label="t('crud.crud.decimal point')"
type="number"
v-model="state.fields[state.activateField].precision"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
</div>
</template>
<el-form-item :label="t('crud.crud.Field Defaults')">
<el-select v-model="state.fields[state.activateField].defaultType">
<el-option label="手动输入" value="INPUT" />
<el-option label="EMPTY STRING空字符串" value="EMPTY STRING" />
<el-option label="NULL" value="NULL" />
<el-option label="不设默认值" value="NONE" />
</el-select>
<el-input
v-if="state.fields[state.activateField].defaultType == 'INPUT'"
:placeholder="t('crud.crud.Please input the default value')"
type="text"
v-model="state.fields[state.activateField].default"
@change="onFieldAttrChange"
class="default-input"
/>
</el-form-item>
<div class="field-inline">
<FormItem
class="form-item-position-right"
:label="t('crud.state.Primary key')"
type="switch"
v-model="state.fields[state.activateField].primaryKey"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
<FormItem
class="form-item-position-right"
:label="t('crud.crud.Auto increment')"
type="switch"
v-model="state.fields[state.activateField].autoIncrement"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
</div>
<div class="field-inline">
<FormItem
class="form-item-position-right"
:label="t('crud.crud.Unsigned')"
type="switch"
v-model="state.fields[state.activateField].unsigned"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
<FormItem
class="form-item-position-right"
:label="t('crud.crud.Allow NULL')"
type="switch"
v-model="state.fields[state.activateField].null"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
</div>
<template v-if="!isEmpty(state.fields[state.activateField].table)">
<el-divider content-position="left">{{ t('crud.crud.Field Table Properties') }}</el-divider>
<template v-for="(item, idx) in state.fields[state.activateField].table" :key="idx">
<FormItem
:label="$t('crud.crud.' + idx)"
:type="item.type"
v-model="state.fields[state.activateField].table[idx].value"
:placeholder="state.fields[state.activateField].table[idx].placeholder ?? ''"
:input-attr="{
content: state.fields[state.activateField].table[idx].options ?? {},
...(state.fields[state.activateField].table[idx].attr ?? {}),
}"
/>
</template>
</template>
<template v-if="!isEmpty(state.fields[state.activateField].form)">
<el-divider content-position="left">{{ t('crud.crud.Field Form Properties') }}</el-divider>
<template v-for="(item, idx) in state.fields[state.activateField].form" :key="idx">
<FormItem
v-if="item.type != 'hidden'"
:label="$t('crud.crud.' + idx)"
:type="item.type"
v-model="state.fields[state.activateField].form[idx].value"
:placeholder="state.fields[state.activateField].form[idx].placeholder ?? ''"
:input-attr="{
content: state.fields[state.activateField].form[idx].options ?? {},
...(state.fields[state.activateField].form[idx].attr ?? {}),
}"
/>
</template>
</template>
</el-form>
</div>
</div>
</el-col>
</el-row>
<el-dialog
@close="onCancelRemoteSelect"
class="ba-operate-dialog"
:model-value="state.remoteSelectPre.show"
:title="t('crud.crud.Remote drop-down association information')"
:close-on-click-modal="false"
:destroy-on-close="true"
@keyup.enter="onSaveRemoteSelect"
>
<el-scrollbar max-height="60vh">
<div class="ba-operate-form" :style="'width: calc(100% - 80px)'">
<el-form
ref="formRef"
:model="state.remoteSelectPre.form"
:rules="remoteSelectPreFormRules"
v-loading="state.remoteSelectPre.loading"
label-position="right"
label-width="160px"
v-if="state.remoteSelectPre.index != -1 && state.fields[state.remoteSelectPre.index]"
>
<FormItem
:label="t('crud.crud.Associated Data Table')"
v-model="state.remoteSelectPre.form.table"
type="remoteSelect"
:key="state.table.databaseConnection"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: state.table.databaseConnection,
samePrefix: 1,
excludeTable: [
'area',
'token',
'captcha',
'admin_group_access',
'config',
'admin_log',
'user_money_log',
'user_score_log',
],
},
remoteUrl: getTableListUrl,
onChange: onJoinTableChange,
}"
prop="table"
/>
<div v-loading="state.loading.remoteSelect">
<FormItem
prop="pk"
type="select"
:label="t('crud.crud.Drop down value field')"
v-model="state.remoteSelectPre.form.pk"
:placeholder="t('crud.crud.Please select the value field of the select component')"
:key="'select-value' + JSON.stringify(state.remoteSelectPre.fieldList)"
:input-attr="{
content: state.remoteSelectPre.fieldList,
}"
/>
<FormItem
prop="label"
type="select"
:label="t('crud.crud.Drop down label field')"
v-model="state.remoteSelectPre.form.label"
:placeholder="t('crud.crud.Please select the label field of the select component')"
:key="'select-label' + JSON.stringify(state.remoteSelectPre.fieldList)"
:input-attr="{
content: state.remoteSelectPre.fieldList,
}"
/>
<FormItem
v-if="state.fields[state.remoteSelectPre.index].designType == 'remoteSelect'"
prop="joinField"
type="selects"
:label="t('crud.crud.Fields displayed in the table')"
v-model="state.remoteSelectPre.form.joinField"
:placeholder="t('crud.crud.Please select the fields displayed in the table')"
:key="'join-field' + JSON.stringify(state.remoteSelectPre.fieldList)"
:input-attr="{
content: state.remoteSelectPre.fieldList,
}"
/>
<FormItem
:label="t('crud.crud.Data source configuration type')"
v-model="state.remoteSelectPre.form.sourceConfigType"
type="radio"
:input-attr="{
border: true,
content: {
crud: t('crud.crud.Fast configuration with generated controllers and models'),
custom: t('crud.crud.Custom configuration'),
},
}"
/>
<FormItem
v-if="state.remoteSelectPre.form.sourceConfigType == 'crud'"
prop="controllerFile"
type="select"
:label="t('crud.crud.Controller position')"
v-model="state.remoteSelectPre.form.controllerFile"
:placeholder="t('crud.crud.Please select the controller of the data table')"
:key="'controller-file' + JSON.stringify(state.remoteSelectPre.controllerFileList)"
:input-attr="{
content: state.remoteSelectPre.controllerFileList,
}"
:block-help="
t(
'crud.crud.The remote pull-down will request the corresponding controller to obtain data, so it is recommended that you create the CRUD of the associated table'
)
"
/>
<!-- 数据源配置类型为CRUD时模型位置必填 -->
<FormItem
:prop="state.remoteSelectPre.form.sourceConfigType == 'crud' ? 'modelFile' : ''"
type="select"
:label="t('crud.crud.Data Model Location')"
v-model="state.remoteSelectPre.form.modelFile"
:placeholder="t('crud.crud.Please select the data model location of the data table')"
:key="'model-file' + JSON.stringify(state.remoteSelectPre.modelFileList)"
:input-attr="{
content: state.remoteSelectPre.modelFileList,
}"
:block-help="
state.remoteSelectPre.form.sourceConfigType == 'crud'
? ''
: t(
'crud.crud.If it is left blank, the model of the associated table will be generated automatically If the table already has a model, it is recommended to select it to avoid repeated generation'
)
"
/>
<el-form-item
v-if="state.table.databaseConnection && state.remoteSelectPre.form.modelFile"
:label="t('Database connection')"
>
<el-text size="large" type="danger">{{ state.table.databaseConnection }}</el-text>
<div class="block-help">
<div>{{ t('crud.crud.Check model class', { connection: state.table.databaseConnection }) }}</div>
<div>{{ t('crud.crud.There is no connection attribute in model class') }}</div>
</div>
</el-form-item>
<FormItem
v-if="state.remoteSelectPre.form.sourceConfigType == 'custom'"
prop="remoteUrl"
:label="t('crud.crud.api url')"
type="string"
v-model="state.remoteSelectPre.form.remoteUrl"
:placeholder="t('crud.crud.api url example')"
/>
<FormItem
v-if="state.remoteSelectPre.form.sourceConfigType == 'custom'"
:label="t('crud.crud.remote-primary-table-alias')"
type="string"
v-model="state.remoteSelectPre.form.primaryTableAlias"
:block-help="
t(
'crud.crud.If the remote interface query involves associated query of multiple tables, enter the alias of the primary data table here'
)
"
>
<template #append>.{{ state.remoteSelectPre.form.pk }}</template>
</FormItem>
<el-form-item :label="t('Reminder')">
<div class="block-help">
{{ t('crud.crud.Design remote select tips') }}
</div>
</el-form-item>
</div>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - 88px)'">
<el-button @click="onCancelRemoteSelect">{{ $t('Cancel') }}</el-button>
<el-button v-blur @click="onSaveRemoteSelect" type="primary">
{{ $t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
@close="closeConfirmGenerate"
class="ba-operate-dialog confirm-generate-dialog"
:model-value="state.confirmGenerate.show"
:title="t('crud.crud.Confirm CRUD code generation')"
>
<div class="confirm-generate-dialog-body">
<el-alert
v-if="state.confirmGenerate.controller"
:title="t('crud.crud.The controller already exists Continuing to generate will automatically overwrite the existing code!')"
center
type="error"
/>
<el-alert
v-if="showTableConflictConfirmGenerate()"
:title="
t(
'crud.crud.The data table already exists Continuing to generate will automatically delete the original table and create a new one!'
)
"
class="mt-10"
center
type="error"
/>
<el-alert
v-if="state.confirmGenerate.menu"
:title="
t(
'crud.crud.The menu rule with the same name already exists The menu and permission node will not be created in this generation'
)
"
class="mt-10"
center
type="error"
/>
</div>
<template #footer>
<div class="confirm-generate-dialog-footer">
<el-button @click="closeConfirmGenerate">{{ $t('Cancel') }}</el-button>
<el-button :loading="state.loading.generate" v-blur @click="startGenerate" type="primary">
{{ t('crud.crud.Continue building') }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog class="ba-operate-dialog design-change-log-dialog" width="20%" v-model="state.showDesignChangeLog">
<template #header>
<div v-drag="['.design-change-log-dialog', '.el-dialog__header']">
{{ t('crud.crud.Data table design changes preview') }}
</div>
</template>
<el-scrollbar max-height="400px">
<template v-if="state.table.designChange.length">
<el-timeline class="design-change-log-timeline">
<el-timeline-item
v-for="(item, idx) in state.table.designChange"
:key="idx"
:type="getTableDesignTimelineType(item.type)"
:hollow="true"
:hide-timestamp="true"
>
<div class="design-timeline-box">
<el-checkbox v-model="item.sync" :label="getTableDesignChangeContent(item)" size="small" />
</div>
</el-timeline-item>
</el-timeline>
<span class="design-change-tips">{{ t('crud.crud.designChangeTips') }}</span>
</template>
<div class="design-change-tips" v-else>暂无表设计变更</div>
<FormItem
:label="t('crud.crud.tableReBuild')"
class="rebuild-form-item"
v-model="state.table.rebuild"
type="radio"
:input-attr="{
border: true,
content: { No: t('crud.crud.No'), Yes: t('crud.crud.Yes') },
}"
:block-help="t('crud.crud.tableReBuildBlockHelp')"
/>
</el-scrollbar>
<template #footer>
<div class="confirm-generate-dialog-footer">
<el-button @click="state.showDesignChangeLog = false">
{{ t('Confirm') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { useTemplateRefsList } from '@vueuse/core'
import type { FormItemRule, MessageHandler, TimelineItemProps } from 'element-plus'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { cloneDeep, debounce, isEmpty, range } from 'lodash-es'
import type { SortableEvent } from 'sortablejs'
import Sortable from 'sortablejs'
import { nextTick, onMounted, reactive, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { generate, generateCheck, getFileData, parseFieldData, postLogStart, uploadCompleted, uploadLog } from '/@/api/backend/crud'
import { getDatabaseConnectionListUrl, getTableFieldList, getTableListUrl } from '/@/api/common'
import BaInput from '/@/components/baInput/index.vue'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useTerminal } from '/@/stores/terminal'
import { getArrayKey } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { buildValidatorData, regularVarName } from '/@/utils/validate'
import { reloadServer } from '/@/utils/vite'
import type { FieldItem, TableDesignChange, TableDesignChangeType } from '/@/views/backend/crud/index'
import { changeStep, state as crudState, designTypes, fieldItem, getTableAttr, tableFieldsKey } from '/@/views/backend/crud/index'
let nameRepeatCount = 1
const { t } = useI18n()
const config = useConfig()
const terminal = useTerminal()
const formRef = useTemplateRef('formRef')
const tabsRefs = useTemplateRefsList<HTMLElement>()
const designWindowRef = useTemplateRef('designWindowRef')
const state: {
loading: {
init: boolean
generate: boolean
remoteSelect: boolean
}
sync: number
table: {
name: string
comment: string
quickSearchField: string[]
defaultSortField: string
formFields: string[]
columnFields: string[]
defaultSortType: string
generateRelativePath: string
isCommonModel: number
modelFile: string
controllerFile: string
validateFile: string
webViewsDir: string
databaseConnection: string
designChange: TableDesignChange[]
rebuild: string
}
fields: FieldItem[]
activateField: number
fieldCollapseName: string[]
remoteSelectPre: {
show: boolean
index: number
fieldList: anyObj
modelFileList: anyObj
controllerFileList: anyObj
loading: boolean
hideDelField: boolean
form: {
table: string
pk: string
label: string
joinField: string[]
sourceConfigType: 'crud' | 'custom'
remoteUrl: string
modelFile: string
controllerFile: string
primaryTableAlias: string
}
}
showHeaderSeniorConfig: boolean
confirmGenerate: {
show: boolean
menu: boolean
table: boolean
controller: boolean
}
draggingField: boolean
showDesignChangeLog: boolean
error: {
tableName: string
fieldName: MessageHandler | null
fieldNameDuplication: MessageHandler | null
}
} = reactive({
loading: {
init: false,
generate: false,
remoteSelect: false,
},
sync: 0,
table: {
name: '',
comment: '',
quickSearchField: [],
defaultSortField: '',
formFields: [],
columnFields: [],
defaultSortType: 'desc',
generateRelativePath: '',
isCommonModel: 0,
modelFile: '',
controllerFile: '',
validateFile: '',
webViewsDir: '',
databaseConnection: '',
designChange: [],
rebuild: 'No',
},
fields: [],
activateField: -1,
fieldCollapseName: ['common', 'base', 'senior'],
remoteSelectPre: {
show: false,
index: -1,
fieldList: [],
modelFileList: [],
controllerFileList: [],
loading: false,
hideDelField: false,
form: {
table: '',
pk: '',
label: '',
joinField: [],
sourceConfigType: 'crud',
remoteUrl: '',
modelFile: '',
controllerFile: '',
primaryTableAlias: '',
},
},
showHeaderSeniorConfig: true,
confirmGenerate: {
show: false,
menu: false,
table: false,
controller: false,
},
draggingField: false,
showDesignChangeLog: false,
error: {
tableName: '',
fieldName: null,
fieldNameDuplication: null,
},
})
type TableKey = keyof typeof state.table
const onActivateField = (idx: number) => {
state.activateField = idx
}
const onFieldDesignTypeChange = (designType: string) => {
// 获取新的类型的数据
let fieldDesignData: FieldItem | null = null
for (const key in fieldItem) {
const fieldItemIndex = getArrayKey(fieldItem[key as keyof typeof fieldItem], 'designType', designType)
if (fieldItemIndex !== false) {
fieldDesignData = cloneDeep(fieldItem[key as keyof typeof fieldItem][fieldItemIndex])
break
}
}
if (!fieldDesignData) return false
// 主键重复检查
if (!primaryKeyRepeatCheck(fieldDesignData, state.activateField)) {
return false
}
// 选中字段数据
const field = cloneDeep(state.fields[state.activateField])
// 赋值新类型
field.designType = designType
// 保留字段的 table 和 form 数据,此处额外处理以便交付给 handleFieldAttr 函数
for (const tKey in field.table) {
field.table[tKey] = field.table[tKey].value
}
for (const tKey in field.form) {
field.form[tKey] = field.form[tKey].value
}
state.fields[state.activateField] = handleFieldAttr(field)
// 保留字段的 uuid
state.fields[state.activateField].uuid = field.uuid
// 询问是否切换至预设方案(除了字段名的属性全部重置)
ElMessageBox.confirm(t('crud.crud.Reset generate type attr'), t('Reminder'), {
confirmButtonText: t('Confirm') + t('Reset'),
cancelButtonText: t('crud.crud.Design efficiency'),
type: 'warning',
closeOnClickModal: false,
})
.then(() => {
// 记录字段属性更新
onFieldAttrChange()
// 删除快速搜索和排序,根据新类型重新赋值
clearFieldTableData(state.fields[state.activateField].uuid!)
// 重置属性,除了 name
const oldName = state.fields[state.activateField].name
state.fields[state.activateField] = handleFieldAttr(fieldDesignData)
state.fields[state.activateField].name = oldName
if (fieldDesignData.primaryKey) {
// 设置为默认排序字段、快速搜索字段
state.table.quickSearchField.push(state.fields[state.activateField].uuid!)
if (!state.table.defaultSortField) {
state.table.defaultSortField = state.fields[state.activateField].uuid!
}
}
if (fieldDesignData.designType == 'weigh') {
state.table.defaultSortField = state.fields[state.activateField].uuid!
}
// 远程下拉参数预填
if (['remoteSelect', 'remoteSelects'].includes(fieldDesignData.designType)) {
showRemoteSelectPre(state.activateField, true)
}
// 表单表格字段预定义
if (!fieldDesignData.formBuildExclude) {
state.table.formFields.push(state.fields[state.activateField].uuid!)
}
if (!fieldDesignData.tableBuildExclude) {
state.table.columnFields.push(state.fields[state.activateField].uuid!)
}
})
.catch(() => {})
}
/**
* 字段名修改
*/
const onFieldNameChange = (val: string, index: number) => {
const oldName = state.fields[index].name
state.fields[index].name = val
logTableDesignChange({
type: 'change-field-name',
index: state.activateField,
oldName: oldName,
newName: val,
})
}
/**
* 主键字段重复检测
*/
const primaryKeyRepeatCheck = (field: FieldItem, excludeIndex: number = -1) => {
if (field.primaryKey === true) {
const primaryKeyField = state.fields.find((item, index) => {
if (excludeIndex > -1 && index == excludeIndex) {
return false
}
return item.primaryKey
})
if (primaryKeyField) {
ElNotification({
type: 'error',
message: t('crud.crud.There can only be one primary key field'),
})
return false
}
}
return true
}
/**
* 全部字段的名称命名规则检测
*/
const fieldNameCheck = (showErrorType: 'ElNotification' | 'ElMessage') => {
if (state.error.fieldName) {
state.error.fieldName.close()
state.error.fieldName = null
}
for (const key in state.fields) {
if (!regularVarName(state.fields[key].name)) {
let msg = t(
'crud.crud.Field name is invalid It starts with a letter or underscore and cannot contain any character other than letters, digits, or underscores',
{ field: state.fields[key].name }
)
if (showErrorType == 'ElMessage') {
state.error.fieldName = ElMessage({
message: msg,
type: 'error',
duration: 0,
})
} else {
ElNotification({
type: 'error',
message: msg,
})
}
return false
}
}
return true
}
/**
* 全部字段的名称重复检测
*/
const fieldNameDuplicationCheck = (showErrorType: 'ElNotification' | 'ElMessage') => {
if (state.error.fieldNameDuplication) {
state.error.fieldNameDuplication.close()
state.error.fieldNameDuplication = null
}
for (const key in state.fields) {
let count = 0
for (const checkKey in state.fields) {
if (state.fields[key].name == state.fields[checkKey].name) {
count++
}
if (count > 1) {
let msg = t('crud.crud.Field name duplication', { field: state.fields[key].name })
if (showErrorType == 'ElMessage') {
state.error.fieldNameDuplication = ElMessage({
message: msg,
type: 'error',
duration: 0,
})
} else {
ElNotification({
type: 'error',
message: msg,
})
}
return false
}
}
}
return true
}
const onFieldAttrChange = () => {
logTableDesignChange({
type: 'change-field-attr',
index: state.activateField,
oldName: state.fields[state.activateField].name,
newName: '',
})
}
/**
* 从 state.table.* 清理某个字段的数据
*/
const clearFieldTableData = (uuid: string) => {
if (uuid == state.table.defaultSortField) {
state.table.defaultSortField = ''
}
for (const key in tableFieldsKey) {
const delIdx = (state.table[tableFieldsKey[key] as TableKey] as string[]).findIndex((item) => {
return item == uuid
})
if (delIdx != -1) {
;(state.table[tableFieldsKey[key] as TableKey] as string[]).splice(delIdx, 1)
}
}
}
const onDelField = (index: number) => {
if (!state.fields[index]) return
state.activateField = -1
clearFieldTableData(state.fields[index].uuid!)
logTableDesignChange({
type: 'del-field',
oldName: state.fields[index].name,
newName: '',
})
// 删除权重字段时,重设默认排序字段
if (state.fields[index].designType == 'weigh') {
const pkField = state.fields.find((item) => {
return ['pk', 'spk'].includes(item.designType)
})
if (pkField) {
state.table.defaultSortField = pkField.uuid!
}
}
state.fields.splice(index, 1)
}
const showRemoteSelectPre = (index: number, hideDelField = false) => {
state.remoteSelectPre.show = true
state.remoteSelectPre.loading = true
state.remoteSelectPre.index = index
state.remoteSelectPre.hideDelField = hideDelField
if (state.fields[index] && state.fields[index].form['remote-table'].value) {
state.remoteSelectPre.form.table = state.fields[index].form['remote-table'].value
state.remoteSelectPre.form.pk = state.fields[index].form['remote-pk'].value
state.remoteSelectPre.form.label = state.fields[index].form['remote-field'].value
state.remoteSelectPre.form.controllerFile = state.fields[index].form['remote-controller'].value
state.remoteSelectPre.form.modelFile = state.fields[index].form['remote-model'].value
state.remoteSelectPre.form.remoteUrl = state.fields[index].form['remote-url'].value
state.remoteSelectPre.form.sourceConfigType = state.fields[index].form['remote-source-config-type'].value
state.remoteSelectPre.form.primaryTableAlias = state.fields[index].form['remote-primary-table-alias'].value
state.remoteSelectPre.form.joinField = state.fields[index].form['relation-fields'].value.split(',')
getTableFieldList(state.fields[index].form['remote-table'].value, true, state.table.databaseConnection).then((res) => {
const fieldSelect: anyObj = {}
for (const key in res.data.fieldList) {
fieldSelect[key] = (key ? key + ' - ' : '') + res.data.fieldList[key]
}
state.remoteSelectPre.fieldList = fieldSelect
})
if (isEmpty(state.remoteSelectPre.modelFileList) || isEmpty(state.remoteSelectPre.controllerFileList)) {
getFileData(state.fields[index].form['remote-table'].value).then((res) => {
state.remoteSelectPre.modelFileList = res.data.modelFileList
state.remoteSelectPre.controllerFileList = res.data.controllerFileList
})
}
}
state.remoteSelectPre.loading = false
}
const onEditField = (index: number, field: FieldItem) => {
if (['remoteSelect', 'remoteSelects'].includes(field.designType)) return showRemoteSelectPre(index)
}
const closeConfirmGenerate = () => {
state.confirmGenerate.show = false
}
const startGenerate = () => {
state.loading.generate = true
// 简化设计字段数据
const fields = cloneDeep(state.fields)
for (const key in fields) {
for (const tKey in fields[key].table) {
fields[key].table[tKey] = fields[key].table[tKey].value
}
for (const tKey in fields[key].form) {
fields[key].form[tKey] = fields[key].form[tKey].value
}
}
// 通过 uuid 获取字段 name
const table = cloneDeep(state.table)
if (table.defaultSortField) {
const defaultSortFieldIndex = getArrayKey(state.fields, 'uuid', table.defaultSortField)
if (defaultSortFieldIndex !== false) {
table.defaultSortField = state.fields[defaultSortFieldIndex].name
}
}
for (const key in tableFieldsKey) {
const names: string[] = []
const uuids = table[tableFieldsKey[key] as TableKey] as string[]
for (const uKey in uuids) {
const uuidFieldIndex = getArrayKey(state.fields, 'uuid', uuids[uKey])
if (uuidFieldIndex !== false) {
names.push(state.fields[uuidFieldIndex].name)
}
}
;(table[tableFieldsKey[key] as TableKey] as string[]) = names
}
generate({
type: crudState.type,
table,
fields,
})
.then((res) => {
const callback = () => {
const webViewsDir = state.table.webViewsDir.replace(/^web/, '.')
terminal.toggle(true)
terminal.addTask('npx.prettier', false, webViewsDir, () => {
terminal.toggle(false)
terminal.toggleDot(true)
nextTick(() => {
// 要求 Vite 服务端重启
if (import.meta.hot) {
reloadServer('crud')
} else {
ElNotification({
type: 'error',
message: t('crud.crud.Vite hot warning'),
})
}
})
})
}
if ((state.sync > 0 && config.crud.syncedUpdate === 'yes') || (state.sync == 0 && config.crud.syncType == 'automatic')) {
uploadLog({
logs: [
{
...res.data.crudLog,
public: config.crud.syncAutoPublic === 'yes' ? 1 : 0,
newLog: 1,
},
],
save: 1,
})
.then((res) => {
uploadCompleted({ syncIds: res.data.syncIds }).finally(() => {
callback()
})
})
.catch(() => {
callback()
})
} else {
callback()
}
})
.finally(() => {
state.loading.generate = false
closeConfirmGenerate()
})
}
const onGenerate = () => {
// 字段名称检查
if (!fieldNameCheck('ElNotification')) return
if (!fieldNameDuplicationCheck('ElNotification')) return
let msg = ''
// 主键检查
const pkIndex = state.fields.findIndex((item) => {
return item.primaryKey
})
if (pkIndex === -1) {
msg = t('crud.crud.Please design the primary key field!')
}
// 表名检查
if (!state.table.name) msg = t('crud.crud.Please enter the data table name!')
if (state.error.tableName) msg = t('crud.crud.Please enter the correct table name!')
if (msg) {
ElNotification({
type: 'error',
message: msg,
})
return
}
state.loading.generate = true
generateCheck({
table: state.table.name,
connection: state.table.databaseConnection,
webViewsDir: state.table.webViewsDir,
controllerFile: state.table.controllerFile,
})
.then(() => {
startGenerate()
})
.catch((res) => {
state.loading.generate = false
if (res.code == -1) {
state.confirmGenerate.menu = res.data.menu
state.confirmGenerate.table = res.data.table
state.confirmGenerate.controller = res.data.controller
if (showTableConflictConfirmGenerate() || state.confirmGenerate.controller || state.confirmGenerate.menu) {
state.confirmGenerate.show = true
} else {
startGenerate()
}
} else {
ElNotification({
type: 'error',
message: res.msg,
})
}
})
}
const showTableConflictConfirmGenerate = () => state.confirmGenerate.table && (crudState.type == 'create' || state.table.rebuild == 'Yes')
const onAbandonDesign = () => {
if (!state.table.name && !state.table.comment && !state.fields.length) {
return changeStep('start')
}
ElMessageBox.confirm(t('crud.crud.It is irreversible to give up the design Are you sure you want to give up?'), t('Reminder'), {
confirmButtonText: t('crud.crud.give up'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
changeStep('start')
})
.catch(() => {})
}
interface SortableEvt extends SortableEvent {
originalEvent?: DragEvent
}
/**
* 处理字段的属性
*/
const handleFieldAttr = (field: FieldItem) => {
field = cloneDeep(field)
const designTypeAttr = cloneDeep(designTypes[field.designType])
for (const tKey in field.form) {
if (designTypeAttr.form[tKey]) designTypeAttr.form[tKey].value = field.form[tKey]
if (tKey == 'image-multi' && field.form[tKey]) {
designTypeAttr.table['render'] = getTableAttr('render', 'images')
}
}
for (const tKey in field.table) {
if (designTypeAttr.table[tKey]) designTypeAttr.table[tKey].value = field.table[tKey]
}
field.form = designTypeAttr.form
field.table = designTypeAttr.table
field.uuid = uuid()
return field
}
/**
* 根据字段字典重新生成字段的数据类型
*/
const onFieldCommentChange = (comment: string) => {
onFieldAttrChange()
if (['enum', 'set'].includes(state.fields[state.activateField].type)) {
if (!comment) {
state.fields[state.activateField].dataType = `${state.fields[state.activateField].type}()`
return
}
comment = comment.replaceAll('', ':')
comment = comment.replaceAll('', ',')
let comments = comment.split(':')
if (comments[1]) {
comments = comments[1].split(',')
comments = comments
.map((value) => {
if (!value) return ''
let temp = value.split('=')
if (temp[0] && temp[1]) {
return `'${temp[0]}'`
}
return ''
})
.filter((str: string) => str != '')
// 字段数据类型
state.fields[state.activateField].dataType = `${state.fields[state.activateField].type}(${comments.join(',')})`
}
}
}
const loadData = () => {
tableDesignChangeInit()
if (!['db', 'sql', 'log'].includes(crudState.type)) return
state.loading.init = true
// 从历史记录开始
if (crudState.type == 'log') {
postLogStart(crudState.startData.logId, crudState.startData.logType)
.then((res) => {
// 字段数据
const fields = res.data.fields
for (const key in fields) {
const field = handleFieldAttr(fields[key])
// 默认值和默认值类型分析
if (typeof field.defaultType == 'undefined') {
if (field.default && ['none', 'null', 'empty string'].includes(field.default)) {
field.defaultType = field.default.toUpperCase() as 'EMPTY STRING' | 'NULL' | 'NONE'
field.default = ''
} else {
field.defaultType = 'INPUT'
}
}
state.fields.push(field)
}
// 表数据
if (res.data.table.defaultSortField) {
const defaultSortFieldNameIndex = getArrayKey(state.fields, 'name', res.data.table.defaultSortField)
if (defaultSortFieldNameIndex !== false) {
res.data.table.defaultSortField = state.fields[defaultSortFieldNameIndex].uuid!
}
}
for (const key in tableFieldsKey) {
const uuids: string[] = []
const names = res.data.table[tableFieldsKey[key] as TableKey] as string[]
for (const nKey in names) {
const nameFieldIndex = getArrayKey(state.fields, 'name', names[nKey])
if (nameFieldIndex !== false) {
uuids.push(state.fields[nameFieldIndex].uuid!)
}
}
;(res.data.table[tableFieldsKey[key] as TableKey] as string[]) = uuids
}
state.sync = res.data.sync
state.table = res.data.table
tableDesignChangeInit()
if (res.data.table.empty) {
state.table.rebuild = 'Yes'
}
state.table.isCommonModel = parseInt(res.data.table.isCommonModel)
state.table.databaseConnection = res.data.table.databaseConnection ? res.data.table.databaseConnection : ''
})
.finally(() => {
state.loading.init = false
})
return
}
// 从数据表或sql开始
parseFieldData({
type: crudState.type,
table: crudState.startData.table,
sql: crudState.startData.sql,
connection: crudState.startData.databaseConnection,
})
.then((res) => {
let fields = []
for (const key in res.data.columns) {
const field = handleFieldAttr(res.data.columns[key])
if (!['id', 'update_time', 'create_time', 'updatetime', 'createtime'].includes(field.name)) {
state.table.formFields.push(field.uuid!)
}
if (!['textarea', 'file', 'files', 'editor', 'password', 'array'].includes(field.designType)) {
state.table.columnFields.push(field.uuid!)
}
if (field.designType == 'pk') {
state.table.defaultSortField = field.uuid!
state.table.quickSearchField.push(field.uuid!)
}
if (field.designType == 'weigh') {
state.table.defaultSortField = field.uuid!
}
fields.push(field)
}
state.fields = fields
state.table.comment = res.data.comment
state.table.databaseConnection = crudState.startData.databaseConnection
if (res.data.empty) {
state.table.rebuild = 'Yes'
}
if (crudState.type == 'db' && crudState.startData.table) {
state.table.name = crudState.startData.table
onTableChange(crudState.startData.table)
}
})
.finally(() => {
state.loading.init = false
})
}
/**
* 字段名称重复时自动重命名
*/
const autoRenameRepeatField = (fieldName: string) => {
const nameRepeatKey = getArrayKey(state.fields, 'name', fieldName)
if (nameRepeatKey !== false) {
fieldName += nameRepeatCount
nameRepeatCount++
return autoRenameRepeatField(fieldName)
} else {
return fieldName
}
}
onMounted(() => {
loadData()
const sortable = Sortable.create(designWindowRef.value!, {
group: 'design-field',
animation: 200,
filter: '.design-field-empty',
onAdd: (evt: SortableEvt) => {
const name = evt.originalEvent?.dataTransfer?.getData('name')
const field = fieldItem[name as keyof typeof fieldItem]
if (field && field[evt.oldIndex!]) {
const data = handleFieldAttr(field[evt.oldIndex!])
// 主键重复检测
if (data.primaryKey) {
if (primaryKeyRepeatCheck(data)) {
// 设置为默认排序字段、快速搜索字段
state.table.quickSearchField.push(data.uuid!)
if (!state.table.defaultSortField) {
state.table.defaultSortField = data.uuid!
}
} else {
return evt.item.remove()
}
}
// 出现权重字段则以其排序
if (data.designType == 'weigh') {
state.table.defaultSortField = data.uuid!
}
// name 重复时,自动重命名
data.name = autoRenameRepeatField(data.name)
// 插入字段
state.fields.splice(evt.newIndex!, 0, data)
logTableDesignChange({
type: 'add-field',
index: evt.newIndex!,
newName: data.name,
oldName: '',
after: evt.newIndex === 0 ? 'FIRST FIELD' : state.fields[evt.newIndex! - 1].name,
})
// 远程下拉参数预填
if (['remoteSelect', 'remoteSelects'].includes(data.designType)) {
showRemoteSelectPre(evt.newIndex!, true)
}
// 表单表格字段预定义
if (!data.formBuildExclude) {
state.table.formFields.push(data.uuid!)
}
if (!data.tableBuildExclude) {
state.table.columnFields.push(data.uuid!)
}
}
evt.item.remove()
nextTick(() => {
sortable.sort(range(state.fields.length).map((value) => value.toString()))
})
},
onEnd: (evt) => {
const temp = state.fields[evt.oldIndex!]
state.fields.splice(evt.oldIndex!, 1)
state.fields.splice(evt.newIndex!, 0, temp)
logTableDesignChange({
type: 'change-field-order',
index: evt.newIndex!,
newName: '',
oldName: temp.name,
after: evt.newIndex === 0 ? 'FIRST FIELD' : state.fields[evt.newIndex! - 1].name,
})
nextTick(() => {
sortable.sort(range(state.fields.length).map((value) => value.toString()))
})
},
})
tabsRefs.value.forEach((item, index) => {
Sortable.create(item, {
sort: false,
group: {
name: 'design-field',
pull: 'clone',
put: false,
},
animation: 200,
setData: (dataTransfer) => {
dataTransfer.setData('name', Object.keys(fieldItem)[index])
},
onStart: () => {
state.draggingField = true
},
onEnd: () => {
state.draggingField = false
},
})
})
})
/**
* 修改表名(失焦时校验,路径填充由 watch 统一触发避免重复请求)
*/
const onTableNameChange = (val: string) => {
if (!val) return (state.error.tableName = '')
if (/^[a-z_][a-z0-9_]*$/.test(val)) {
state.error.tableName = ''
} else {
state.error.tableName = t('crud.crud.Use lower case underlined for table names')
}
tableDesignChangeInit()
}
/** 相对路径输入时防抖触发路径填充 */
const debouncedOnRelativePathInput = debounce((val: string) => {
if (val) onTableChange(val)
}, 400)
/** 监听表名变化,统一防抖触发路径填充(唯一入口,避免 watch + onInput 重复请求) */
watch(
() => state.table.name,
debounce((val: string) => {
if (val && /^[a-z_][a-z0-9_]*$/.test(val)) {
onTableChange(val)
}
}, 400)
)
const tableDesignChangeInit = () => {
state.table.rebuild = 'No'
state.table.designChange = []
}
/**
* 预获取一个表的生成数据
* @param val 新表名
*/
const onTableChange = (val: string) => {
if (!val) return
getFileData(val, state.table.isCommonModel)
.then((res) => {
state.table.modelFile = res.data.modelFile
state.table.controllerFile = res.data.controllerFile
state.table.validateFile = res.data.validateFile
state.table.webViewsDir = res.data.webViewsDir
state.table.generateRelativePath = val.replaceAll('/', '\\')
})
.catch(() => {
// 接口失败时静默处理避免重复弹窗axios 已统一处理错误提示)
})
}
const onChangeCommonModel = () => {
const table = state.table.name || state.table.generateRelativePath?.replace(/\\/g, '/')
if (table) onTableChange(table)
}
const onJoinTableChange = () => {
if (!state.remoteSelectPre.form.table) return
// 重置远程下拉信息表单
resetRemoteSelectForm(['table'])
state.loading.remoteSelect = true
getTableFieldList(state.remoteSelectPre.form.table, true, state.table.databaseConnection)
.then((res) => {
state.remoteSelectPre.form.pk = res.data.pk
const preLabel = ['name', 'title', 'username', 'nickname']
for (const key in res.data.fieldList) {
if (preLabel.includes(key)) {
state.remoteSelectPre.form.label = key
state.remoteSelectPre.form.joinField.push(key)
break
}
}
const fieldSelect: anyObj = {}
for (const key in res.data.fieldList) {
fieldSelect[key] = (key ? key + ' - ' : '') + res.data.fieldList[key]
}
state.remoteSelectPre.fieldList = fieldSelect
})
.finally(() => {
state.loading.remoteSelect = false
})
getFileData(state.remoteSelectPre.form.table).then((res) => {
state.remoteSelectPre.modelFileList = res.data.modelFileList
state.remoteSelectPre.controllerFileList = res.data.controllerFileList
if (Object.keys(res.data.modelFileList).includes(res.data.modelFile)) {
state.remoteSelectPre.form.modelFile = res.data.modelFile
}
if (Object.keys(res.data.controllerFileList).includes(res.data.controllerFile)) {
state.remoteSelectPre.form.controllerFile = res.data.controllerFile
}
})
}
const onSaveRemoteSelect = () => {
const submitCallback = () => {
// 修改字段名
if (state.fields[state.remoteSelectPre.index].name == 'remote_select') {
const newName =
state.remoteSelectPre.form.table + (state.fields[state.remoteSelectPre.index].designType == 'remoteSelect' ? '_id' : '_ids')
onFieldNameChange(newName, state.remoteSelectPre.index)
}
state.fields[state.remoteSelectPre.index].form['remote-table'].value = state.remoteSelectPre.form.table
state.fields[state.remoteSelectPre.index].form['remote-pk'].value = state.remoteSelectPre.form.pk
state.fields[state.remoteSelectPre.index].form['remote-field'].value = state.remoteSelectPre.form.label
state.fields[state.remoteSelectPre.index].form['remote-controller'].value = state.remoteSelectPre.form.controllerFile
state.fields[state.remoteSelectPre.index].form['remote-model'].value = state.remoteSelectPre.form.modelFile
state.fields[state.remoteSelectPre.index].form['remote-url'].value = state.remoteSelectPre.form.remoteUrl
state.fields[state.remoteSelectPre.index].form['remote-source-config-type'].value = state.remoteSelectPre.form.sourceConfigType
state.fields[state.remoteSelectPre.index].form['remote-primary-table-alias'].value = state.remoteSelectPre.form.primaryTableAlias
state.fields[state.remoteSelectPre.index].form['relation-fields'].value =
state.fields[state.remoteSelectPre.index].designType == 'remoteSelect'
? state.remoteSelectPre.form.joinField.join(',')
: state.remoteSelectPre.form.label
state.remoteSelectPre.index = -1
state.remoteSelectPre.show = false
resetRemoteSelectForm()
}
if (formRef.value) {
formRef.value.validate((valid) => {
if (valid) {
submitCallback()
}
})
}
}
const onCancelRemoteSelect = () => {
state.remoteSelectPre.show = false
resetRemoteSelectForm()
if (state.remoteSelectPre.index !== -1 && state.remoteSelectPre.hideDelField) {
onDelField(state.remoteSelectPre.index)
}
}
const resetRemoteSelectForm = (excludes: string[] = []) => {
for (const key in state.remoteSelectPre.form) {
if (excludes.includes(key)) continue
if (key == 'joinField') {
state.remoteSelectPre.form[key] = []
} else if (key == 'sourceConfigType') {
state.remoteSelectPre.form[key] = 'crud'
} else {
;(state.remoteSelectPre.form[key as keyof typeof state.remoteSelectPre.form] as string) = ''
}
}
}
const remoteSelectPreFormRules: Partial<Record<string, FormItemRule[]>> = reactive({
table: [buildValidatorData({ name: 'required', title: t('crud.crud.remote-table') })],
pk: [buildValidatorData({ name: 'required', title: t('crud.crud.Drop down value field') })],
label: [buildValidatorData({ name: 'required', title: t('crud.crud.Drop down label field') })],
joinField: [buildValidatorData({ name: 'required', title: t('crud.crud.Fields displayed in the table') })],
controllerFile: [buildValidatorData({ name: 'required', title: t('crud.crud.Controller position') })],
modelFile: [buildValidatorData({ name: 'required', title: t('crud.crud.Data Model Location') })],
remoteUrl: [buildValidatorData({ name: 'required', title: t('crud.crud.remote-url') })],
})
const logTableDesignChange = (data: TableDesignChange) => {
if (crudState.type == 'create') return
let push = true
if (data.type == 'change-field-name') {
for (const key in state.table.designChange) {
// 有属性修改记录的字段被改名-单独循环防止字段再次改名后造成找不到属性修改记录
if (state.table.designChange[key].type == 'change-field-attr' && data.oldName == state.table.designChange[key].oldName) {
state.table.designChange[key].oldName = data.newName
}
// 有排序记录的字段被改名
if (state.table.designChange[key].type == 'change-field-order' && data.oldName == state.table.designChange[key].oldName) {
state.table.designChange[key].oldName = data.newName
}
if (state.table.designChange[key].after == data.oldName) {
state.table.designChange[key].after = data.newName
}
}
for (const key in state.table.designChange) {
// 新增字段改名
if (state.table.designChange[key].type == 'add-field' && state.table.designChange[key].newName == data.oldName) {
state.table.designChange[key].newName = data.newName
push = false
// 同一字段不会有两条新增记录
break
}
// 字段再次改名
if (state.table.designChange[key].type == 'change-field-name' && state.table.designChange[key].newName == data.oldName) {
data.oldName = state.table.designChange[key].oldName
state.table.designChange[key] = data
// 取消字段改名
if (state.table.designChange[key].newName == state.table.designChange[key].oldName) {
state.table.designChange.splice(key as any, 1)
}
push = false
break
}
}
} else if (data.type == 'del-field') {
let add = false
state.table.designChange = state.table.designChange.filter((item) => {
// 新增的字段被删除
add = item.type == 'add-field' && item.newName == data.oldName
// 有属性修改记录的字段被删除
const attr = item.type == 'change-field-attr' && item.oldName == data.oldName
// 有排序记录的字段被删除
const order = item.type == 'change-field-order' && item.oldName == data.oldName
return !add && !attr && !order
})
// 有改名记录的字段被删除(延后单独处理避免先改名再改属性的情况)
state.table.designChange = state.table.designChange.filter((item) => {
const name = item.type == 'change-field-name' && item.newName == data.oldName
if (name) data.oldName = item.oldName
return !name
})
// 添加的字段需要过滤掉记录同时不记录删除操作
if (add) push = false
for (const key in state.table.designChange) {
// 同一字段名称多次删除(删除后添加再删除)
if (state.table.designChange[key].type == 'del-field' && state.table.designChange[key].oldName == data.oldName) {
push = false
break
}
}
} else if (data.type == 'change-field-attr') {
// 先改名再改属性无需处理
for (const key in state.table.designChange) {
// 重复修改属性只记录一次
if (state.table.designChange[key].type == 'change-field-attr' && state.table.designChange[key].oldName == data.oldName) {
push = false
break
}
// 新增的字段无需记录属性修改
if (state.table.designChange[key].type == 'add-field' && state.table.designChange[key].newName == data.oldName) {
push = false
break
}
}
} else if (data.type == 'change-field-order') {
for (const key in state.table.designChange) {
// 新增的字段
if (state.table.designChange[key].type == 'add-field' && state.table.designChange[key].newName == data.oldName) {
// 更新排序设定
state.table.designChange[key].after = data.after
push = false
break
}
// 重复的排序记录
if (state.table.designChange[key].type == 'change-field-order' && state.table.designChange[key].oldName == data.oldName) {
state.table.designChange[key] = data
push = false
break
}
}
}
data.sync = true
if (push) state.table.designChange.push(data)
}
const getTableDesignChangeContent = (data: TableDesignChange): string => {
switch (data.type) {
case 'add-field':
return t('crud.crud.Add field') + ' ' + data.newName
case 'change-field-attr':
return t('crud.crud.Modify field properties') + ' ' + data.oldName
case 'change-field-name':
return t('crud.crud.Modify field name') + ' ' + data.oldName + ' => ' + data.newName
case 'del-field':
return t('crud.crud.Delete field') + ' ' + data.oldName
case 'change-field-order':
return (
t('crud.crud.Modify field order') +
' ' +
data.oldName +
' => ' +
(data.after == 'FIRST FIELD' ? t('crud.crud.First field') : data.after + ' ' + t('crud.crud.After'))
)
default:
return t('Unknown')
}
}
const getTableDesignTimelineType = (type: TableDesignChangeType): TimelineItemProps['type'] => {
let timeline = ''
switch (type) {
case 'change-field-name':
timeline = 'warning'
break
case 'del-field':
timeline = 'danger'
break
case 'add-field':
timeline = 'primary'
break
case 'change-field-attr':
timeline = 'success'
break
case 'change-field-order':
timeline = 'info'
break
default:
timeline = 'success'
break
}
return timeline as TimelineItemProps['type']
}
</script>
<style scoped lang="scss">
.form-item-position-right {
display: flex !important;
align-items: center;
:deep(.el-form-item__label) {
margin-bottom: 0 !important;
}
}
.default-main {
margin-bottom: 0;
}
.mt-10 {
margin-top: 10px;
}
.mr-20 {
margin-right: 20px;
}
.field-collapse :deep(.el-collapse-item__header) {
padding-left: 10px;
user-select: none;
}
.field-box {
padding: 10px;
}
.field-item {
display: inline-block;
padding: 3px 16px;
border: 1px dashed var(--el-border-color);
border-radius: var(--el-border-radius-base);
margin: 6px;
cursor: pointer;
user-select: none;
&:hover {
border-color: var(--el-color-primary);
}
}
.header-config-box {
position: relative;
.header-senior-config {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
height: 24px;
bottom: -24px;
padding: 4px 20px;
padding-top: 0;
left: calc(50% - 10px);
font-size: var(--el-font-size-small);
border-bottom-left-radius: 50px;
border-bottom-right-radius: 50px;
background-color: var(--ba-bg-color-overlay);
color: var(--el-text-color-primary);
cursor: pointer;
user-select: none;
.senior-config-arrow-icon {
margin-left: 4px;
}
}
}
.header-senior-config-box {
width: 100%;
padding: 10px;
background-color: var(--ba-bg-color-overlay);
}
.header-senior-config-form {
width: 50%;
:deep(.el-form-item__label) {
justify-content: flex-start;
}
}
.header-box {
display: flex;
align-items: center;
height: v-bind("state.error.tableName ? '70px':'60px'");
padding: 10px;
background-color: var(--ba-bg-color-overlay);
border-radius: var(--el-border-radius-base);
transition: 0.1s;
.header,
.header-item-box {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
white-space: nowrap;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.header-item-box {
width: 50%;
}
.table-name-item {
flex: 3;
}
.table-comment-item {
flex: 4;
}
.header-right {
margin-left: auto;
.design-change-log {
margin-right: 10px;
}
}
}
.default-sort-field-box {
display: flex;
.default-sort-field {
flex: 6;
}
.default-sort-field-type {
flex: 3;
}
}
.fields-box {
margin-top: 36px;
}
.design-field-empty {
display: flex;
height: 100%;
color: var(--el-color-info);
font-size: var(--el-font-size-medium);
align-items: center;
justify-content: center;
}
.design-window {
overflow-x: auto;
height: calc(100vh - 200px);
border-radius: var(--el-border-radius-base);
background-color: var(--ba-bg-color-overlay);
border: v-bind('state.draggingField ? "1px dashed var(--el-color-primary)":(state.fields.length ? "none":"1px dashed var(--el-border-color)")');
.design-field-box {
display: flex;
padding: 10px;
align-items: center;
border: 1px dashed var(--el-border-color);
border-radius: var(--el-border-radius-base);
margin-bottom: 2px;
cursor: pointer;
user-select: none;
.design-field {
padding-right: 10px;
}
.design-field-name-input {
width: 200px;
}
.design-field-name-comment {
width: 100px;
}
.design-field-right {
margin-left: auto;
}
&:hover {
border-color: var(--el-color-primary);
}
}
.design-field-box.activate {
border-color: var(--el-color-primary);
}
}
.field-inline {
display: flex;
:deep(.el-form-item) {
width: 46%;
margin-right: 2%;
}
}
.default-input {
margin-top: 10px;
}
.field-config {
overflow-x: auto;
height: calc(100vh - 200px);
padding: 20px;
background-color: var(--ba-bg-color-overlay);
}
:deep(.confirm-generate-dialog) .el-dialog__body {
height: unset;
}
.confirm-generate-dialog-body {
padding: 30px;
}
.confirm-generate-dialog-footer {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.design-change-log-dialog) .el-dialog__body {
height: unset;
padding-top: 20px;
.design-change-log-timeline {
padding-left: 10px;
.el-timeline-item .el-timeline-item__node {
top: 3px;
}
}
.design-change-tips {
display: block;
margin-bottom: 20px;
color: var(--el-color-info);
font-size: var(--el-font-size-small);
}
.rebuild-form-item {
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>

View File

@@ -0,0 +1,970 @@
import { reactive } from 'vue'
import { fieldData, npuaFalse } from '/@/components/baInput/helper'
import { i18n } from '/@/lang/index'
import { validatorType } from '/@/utils/validate'
/**
* 字段修改类型标识
* 改排序需要在表结构变更完成之后再单独处理所以标识独立
*/
export type TableDesignChangeType = 'change-field-name' | 'del-field' | 'add-field' | 'change-field-attr' | 'change-field-order'
export interface TableDesignChange {
type: TableDesignChangeType
// 字段在设计器中的数组 index
index?: number
// 字段旧名称(重命名、修改属性、删除)
oldName: string
// 字段新名称(重命名、添加)
newName: string
// 是否同步到数据表
sync?: boolean
// 此字段在 after 字段之后,值为`FIRST FIELD`表示它是第一个字段
after?: string
}
export const state: {
step: 'Start' | 'Design'
type: string
startData: {
sql: string
table: string
logId: string
logType: string
databaseConnection: string
}
} = reactive({
step: 'Start',
type: '',
startData: {
sql: '',
table: '',
logId: '',
logType: '',
databaseConnection: '',
},
})
export const changeStep = (type: string) => {
state.type = type
if (type == 'start') {
state.step = 'Start'
for (const key in state.startData) {
state.startData[key as keyof typeof state.startData] = ''
}
} else {
state.step = 'Design'
}
}
export interface FieldItem {
index?: number
title: string
name: string
type: string
dataType?: string
length: number
precision: number
default?: string
defaultType: 'INPUT' | 'EMPTY STRING' | 'NULL' | 'NONE'
null: boolean
primaryKey: boolean
unsigned: boolean
autoIncrement: boolean
comment: string
designType: string
formBuildExclude?: boolean
tableBuildExclude?: boolean
table: anyObj
form: anyObj
uuid?: string
}
export const fieldItem: {
common: FieldItem[]
base: FieldItem[]
senior: FieldItem[]
} = {
common: [
{
title: i18n.global.t('crud.state.Primary key'),
name: 'id',
comment: 'ID',
designType: 'pk',
formBuildExclude: true,
table: {},
form: {},
...fieldData.number,
defaultType: 'NONE',
null: false,
primaryKey: true,
unsigned: true,
autoIncrement: true,
},
{
title: i18n.global.t('crud.state.Primary key (Snowflake ID)'),
name: 'id',
comment: 'ID',
designType: 'spk',
formBuildExclude: true,
table: {},
form: {},
...fieldData.number,
type: 'bigint',
length: 20,
defaultType: 'NONE',
null: false,
primaryKey: true,
unsigned: true,
},
{
title: i18n.global.t('State'),
name: 'status',
comment: i18n.global.t('crud.state.Status:0=Disabled,1=Enabled'),
designType: 'switch',
table: {},
form: {},
...fieldData.switch,
default: '1',
defaultType: 'INPUT',
},
{
title: i18n.global.t('crud.state.remarks'),
name: 'remark',
comment: i18n.global.t('crud.state.remarks'),
designType: 'textarea',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.textarea,
},
{
title: i18n.global.t('crud.state.Weight (drag and drop sorting)'),
name: 'weigh',
comment: i18n.global.t('Weigh'),
designType: 'weigh',
table: {},
form: {},
...fieldData.number,
},
{
title: i18n.global.t('Update time'),
name: 'update_time',
comment: i18n.global.t('Update time'),
designType: 'timestamp',
formBuildExclude: true,
table: {},
form: {},
...fieldData.datetime,
},
{
title: i18n.global.t('Create time'),
name: 'create_time',
comment: i18n.global.t('Create time'),
designType: 'timestamp',
formBuildExclude: true,
table: {},
form: {},
...fieldData.datetime,
},
{
title: i18n.global.t('crud.state.Remote Select (association table)'),
name: 'remote_select',
comment: i18n.global.t('utils.remote select'),
designType: 'remoteSelect',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.remoteSelect,
},
],
base: [
{
title: i18n.global.t('utils.string'),
name: 'string',
comment: i18n.global.t('utils.string'),
designType: 'string',
table: {},
form: {},
...fieldData.string,
},
{
title: i18n.global.t('utils.image'),
name: 'image',
comment: i18n.global.t('utils.image'),
designType: 'image',
table: {},
form: {},
...fieldData.image,
},
{
title: i18n.global.t('utils.file'),
name: 'file',
comment: i18n.global.t('utils.file'),
designType: 'file',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.file,
},
{
title: i18n.global.t('utils.radio'),
name: 'radio',
dataType: "enum('opt0','opt1')",
comment: i18n.global.t('crud.state.Radio:opt0=Option1,opt1=Option2'),
designType: 'radio',
table: {},
form: {},
...fieldData.radio,
default: 'opt0',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.checkbox'),
name: 'checkbox',
dataType: "set('opt0','opt1')",
comment: i18n.global.t('crud.state.Checkbox:opt0=Option1,opt1=Option2'),
designType: 'checkbox',
table: {},
form: {},
...fieldData.checkbox,
default: 'opt0,opt1',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.select'),
name: 'select',
dataType: "enum('opt0','opt1')",
comment: i18n.global.t('crud.state.Select:opt0=Option1,opt1=Option2'),
designType: 'select',
table: {},
form: {},
...fieldData.select,
default: 'opt0',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.switch'),
name: 'switch',
comment: i18n.global.t('crud.state.Switch:0=off,1=on'),
designType: 'switch',
table: {},
form: {},
...fieldData.switch,
default: '1',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.rich Text'),
name: 'editor',
comment: i18n.global.t('utils.rich Text'),
designType: 'editor',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.editor,
},
{
title: i18n.global.t('utils.textarea'),
name: 'textarea',
comment: i18n.global.t('utils.textarea'),
designType: 'textarea',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.textarea,
},
{
title: i18n.global.t('utils.number'),
name: 'number',
comment: i18n.global.t('utils.number'),
designType: 'number',
table: {},
form: {},
...fieldData.number,
},
{
title: i18n.global.t('utils.float'),
name: 'float',
type: 'decimal',
length: 5,
precision: 2,
defaultType: 'NULL',
...npuaFalse(),
null: true,
comment: i18n.global.t('utils.float'),
designType: 'float',
table: {},
form: {},
},
{
title: i18n.global.t('utils.password'),
name: 'password',
comment: i18n.global.t('utils.password'),
designType: 'password',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.password,
},
{
title: i18n.global.t('utils.date'),
name: 'date',
comment: i18n.global.t('utils.date'),
designType: 'date',
table: {},
form: {},
...fieldData.date,
},
{
title: i18n.global.t('utils.time'),
name: 'time',
comment: i18n.global.t('utils.time'),
designType: 'time',
table: {},
form: {},
...fieldData.time,
},
{
title: i18n.global.t('utils.time date'),
name: 'datetime',
type: 'datetime',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
comment: i18n.global.t('utils.time date'),
designType: 'datetime',
table: {},
form: {},
},
{
title: i18n.global.t('utils.year'),
name: 'year',
comment: i18n.global.t('utils.year'),
designType: 'year',
table: {},
form: {},
...fieldData.year,
},
{
title: i18n.global.t('crud.state.Time date (timestamp storage)'),
name: 'timestamp',
comment: i18n.global.t('utils.time date'),
designType: 'timestamp',
table: {},
form: {},
...fieldData.datetime,
},
],
senior: [
{
title: i18n.global.t('utils.array'),
name: 'array',
comment: i18n.global.t('utils.array'),
designType: 'array',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.array,
},
{
title: i18n.global.t('utils.city select'),
name: 'city',
comment: i18n.global.t('utils.city select'),
designType: 'city',
table: {},
form: {},
...fieldData.city,
},
{
title: i18n.global.t('utils.icon select'),
name: 'icon',
comment: i18n.global.t('utils.icon select'),
designType: 'icon',
table: {},
form: {},
...fieldData.icon,
},
{
title: i18n.global.t('utils.color picker'),
name: 'color',
comment: i18n.global.t('utils.color picker'),
designType: 'color',
table: {},
form: {},
...fieldData.color,
},
{
title: i18n.global.t('utils.image') + i18n.global.t('crud.state.Multi'),
name: 'images',
comment: i18n.global.t('utils.image'),
designType: 'images',
table: {},
form: {},
...fieldData.images,
},
{
title: i18n.global.t('utils.file') + i18n.global.t('crud.state.Multi'),
name: 'files',
comment: i18n.global.t('utils.file'),
designType: 'files',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.files,
},
{
title: i18n.global.t('utils.select') + i18n.global.t('crud.state.Multi'),
name: 'selects',
comment: i18n.global.t('crud.state.Select:opt0=Option1,opt1=Option2'),
designType: 'selects',
table: {},
form: {},
...fieldData.selects,
},
{
title: i18n.global.t('crud.state.Remote Select (Multi)'),
name: 'remote_select',
comment: i18n.global.t('utils.remote select'),
designType: 'remoteSelects',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.remoteSelects,
},
],
}
const tableBaseAttr = {
render: {
type: 'select',
value: 'none',
options: {
none: i18n.global.t('None'),
icon: 'Icon',
switch: i18n.global.t('utils.switch'),
image: i18n.global.t('utils.image'),
images: i18n.global.t('utils.multi image'),
tag: 'Tag',
tags: 'Tags',
url: 'URL',
datetime: i18n.global.t('utils.time date'),
color: i18n.global.t('utils.color'),
},
},
operator: {
type: 'select',
value: 'eq',
options: {
false: i18n.global.t('crud.state.Disable Search'),
eq: 'eq =',
ne: 'ne !=',
gt: 'gt >',
egt: 'egt >=',
lt: 'lt <',
elt: 'elt <=',
LIKE: 'LIKE',
'NOT LIKE': 'NOT LIKE',
IN: 'IN',
'NOT IN': 'NOT IN',
RANGE: 'RANGE',
'NOT RANGE': 'NOT RANGE',
NULL: 'NULL',
'NOT NULL': 'NOT NULL',
FIND_IN_SET: 'FIND_IN_SET',
},
},
comSearchRender: {
type: 'select',
value: 'string',
options: {
string: i18n.global.t('utils.string'),
select: i18n.global.t('utils.select'),
remoteSelect: i18n.global.t('utils.remote select'),
time: i18n.global.t('utils.time') + i18n.global.t('utils.choice'),
date: i18n.global.t('utils.date') + i18n.global.t('utils.choice'),
datetime: i18n.global.t('utils.time date') + i18n.global.t('utils.choice'),
},
},
comSearchInputAttr: {
type: 'textarea',
value: '',
placeholder: i18n.global.t('crud.crud.comSearchInputAttrTip'),
attr: {
rows: 3,
},
},
sortable: {
type: 'select',
value: 'false',
options: {
false: i18n.global.t('Disable'),
custom: i18n.global.t('Enable'),
},
},
}
const formBaseAttr = {
validator: {
type: 'selects',
value: [],
options: validatorType,
},
validatorMsg: {
type: 'textarea',
value: '',
placeholder: i18n.global.t('crud.state.If left blank, the verifier title attribute will be filled in automatically'),
attr: {
rows: 3,
},
},
}
export const getTableAttr = (type: keyof typeof tableBaseAttr, val: string) => {
return {
...tableBaseAttr[type],
value: val,
}
}
const getFormAttr = (type: keyof typeof formBaseAttr, val: string[]) => {
return {
...formBaseAttr[type],
value: val,
}
}
export const designTypes: anyObj = {
pk: {
name: i18n.global.t('crud.state.Primary key'),
table: {
width: {
type: 'number',
value: 70,
},
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: {},
},
spk: {
name: i18n.global.t('crud.state.Primary key (Snowflake ID)'),
table: {
width: {
type: 'number',
value: 180,
},
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: {},
},
weigh: {
name: i18n.global.t('crud.state.Weight (automatically generate drag sort button)'),
table: {
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: formBaseAttr,
},
timestamp: {
name: i18n.global.t('crud.state.Time date (timestamp storage)'),
table: {
render: getTableAttr('render', 'datetime'),
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'datetime'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
width: {
type: 'number',
value: 160,
},
timeFormat: {
type: 'string',
value: 'yyyy-mm-dd hh:MM:ss',
},
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
string: {
name: i18n.global.t('utils.string'),
table: {
render: getTableAttr('render', 'none'),
sortable: getTableAttr('sortable', 'false'),
operator: getTableAttr('operator', 'LIKE'),
},
form: formBaseAttr,
},
password: {
name: i18n.global.t('utils.password'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['password']),
},
},
number: {
name: i18n.global.t('utils.number'),
table: {
render: getTableAttr('render', 'none'),
sortable: getTableAttr('sortable', 'false'),
operator: getTableAttr('operator', 'RANGE'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['number']),
step: {
type: 'number',
value: 1,
},
},
},
float: {
name: i18n.global.t('utils.float'),
table: {
render: getTableAttr('render', 'none'),
sortable: getTableAttr('sortable', 'false'),
operator: getTableAttr('operator', 'RANGE'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['float']),
step: {
type: 'number',
value: 1,
},
},
},
radio: {
name: i18n.global.t('utils.radio'),
table: {
operator: getTableAttr('operator', 'eq'),
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tag'),
},
form: formBaseAttr,
},
checkbox: {
name: i18n.global.t('utils.checkbox'),
table: {
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'FIND_IN_SET'),
},
form: formBaseAttr,
},
switch: {
name: i18n.global.t('utils.switch'),
table: {
operator: getTableAttr('operator', 'eq'),
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'switch'),
},
form: formBaseAttr,
},
textarea: {
name: i18n.global.t('utils.textarea'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
rows: {
type: 'number',
value: 3,
},
},
},
array: {
name: i18n.global.t('utils.array'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
datetime: {
name: i18n.global.t('utils.time date') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'datetime'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
width: {
type: 'number',
value: 160,
},
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
year: {
name: i18n.global.t('utils.year') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
date: {
name: i18n.global.t('utils.date') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'date'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
time: {
name: i18n.global.t('utils.time') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'time'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
},
form: formBaseAttr,
},
select: {
name: i18n.global.t('utils.select'),
table: {
operator: getTableAttr('operator', 'eq'),
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tag'),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: false,
},
},
},
selects: {
name: i18n.global.t('utils.select') + i18n.global.t('crud.state.Multi'),
table: {
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'FIND_IN_SET'),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: true,
},
},
},
remoteSelect: {
name: i18n.global.t('utils.remote select') + i18n.global.t('utils.choice'),
table: {
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'LIKE'),
comSearchRender: getTableAttr('comSearchRender', 'string'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: false,
},
'remote-pk': {
type: 'string',
value: 'id',
},
'remote-field': {
type: 'string',
value: 'name',
},
'remote-table': {
type: 'string',
value: '',
},
'remote-controller': {
type: 'string',
value: '',
},
'remote-model': {
type: 'string',
value: '',
},
'relation-fields': {
type: 'string',
value: '',
},
'remote-url': {
type: 'string',
value: '',
placeholder: i18n.global.t('crud.state.If it is not input, it will be automatically analyzed by the controller'),
},
'remote-primary-table-alias': {
type: 'string',
value: '',
},
'remote-source-config-type': {
type: 'hidden',
value: '',
},
},
},
remoteSelects: {
name: i18n.global.t('utils.remote select') + i18n.global.t('utils.choice') + i18n.global.t('crud.state.Multi'),
table: {
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'FIND_IN_SET'),
comSearchRender: getTableAttr('comSearchRender', 'remoteSelect'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: true,
},
'remote-pk': {
type: 'string',
value: 'id',
},
'remote-field': {
type: 'string',
value: 'name',
},
'remote-table': {
type: 'string',
value: '',
},
'remote-controller': {
type: 'string',
value: '',
},
'remote-model': {
type: 'string',
value: '',
},
'relation-fields': {
type: 'string',
value: '',
},
'remote-url': {
type: 'string',
value: '',
placeholder: i18n.global.t('crud.state.If it is not input, it will be automatically analyzed by the controller'),
},
'remote-primary-table-alias': {
type: 'string',
value: '',
},
'remote-source-config-type': {
type: 'hidden',
value: '',
},
},
},
editor: {
name: i18n.global.t('utils.rich Text'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['editorRequired']),
},
},
city: {
name: i18n.global.t('utils.city select'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
image: {
name: i18n.global.t('utils.image') + i18n.global.t('Upload'),
table: {
render: getTableAttr('render', 'image'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'image-multi': {
type: 'switch',
value: false,
},
},
},
images: {
name: i18n.global.t('utils.image') + i18n.global.t('Upload') + i18n.global.t('crud.state.Multi'),
table: {
render: getTableAttr('render', 'images'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'image-multi': {
type: 'switch',
value: true,
},
},
},
file: {
name: i18n.global.t('utils.file') + i18n.global.t('Upload'),
table: {
render: getTableAttr('render', 'none'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'file-multi': {
type: 'switch',
value: false,
},
},
},
files: {
name: i18n.global.t('utils.file') + i18n.global.t('Upload') + i18n.global.t('crud.state.Multi'),
table: {
render: getTableAttr('render', 'none'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'file-multi': {
type: 'switch',
value: true,
},
},
},
icon: {
name: i18n.global.t('utils.icon select'),
table: {
render: getTableAttr('render', 'icon'),
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
color: {
name: i18n.global.t('utils.color picker'),
table: {
render: getTableAttr('render', 'color'),
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
}
export const tableFieldsKey = ['quickSearchField', 'formFields', 'columnFields']

View File

@@ -0,0 +1,36 @@
<template>
<div>
<component :is="state.step"></component>
</div>
</template>
<script setup lang="ts">
import { onActivated, onDeactivated, onUnmounted, onMounted } from 'vue'
import Start from '/@/views/backend/crud/start.vue'
import Design from '/@/views/backend/crud/design.vue'
import { state } from '/@/views/backend/crud/index'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
defineOptions({
name: 'crud/crud',
components: { Start, Design },
})
onMounted(() => {
closeHotUpdate('crud')
})
onActivated(() => {
closeHotUpdate('crud')
})
onDeactivated(() => {
openHotUpdate('crud')
})
onUnmounted(() => {
openHotUpdate('crud')
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,578 @@
<template>
<div>
<el-dialog
@close="emits('update:modelValue', false)"
width="70%"
:model-value="modelValue"
class="ba-crud-log-dialog"
:title="t('crud.crud.CRUD record')"
:append-to-body="true"
:destroy-on-close="true"
>
<TableHeader :buttons="['refresh', 'quickSearch', 'columnDisplay']" :quick-search-placeholder="t('crud.log.quick Search Fields')">
<template v-if="baAccount.token">
<el-tooltip :content="t('crud.log.Upload the selected design records to the cloud for cross-device use')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
@click="onUpload"
class="table-header-operate"
type="success"
>
<Icon color="#ffffff" name="fa fa-cloud-upload" />
<span class="table-header-operate-text">{{ t('Upload') }}</span>
</el-button>
</el-tooltip>
<el-tooltip :content="t('crud.log.Design records that have been synchronized to the cloud')" placement="top">
<el-button v-blur class="table-header-operate" @click="onLoadLogs" type="success">
<Icon color="#ffffff" name="fa fa-cloud-download" />
<span class="table-header-operate-text">{{ t('crud.log.Cloud record') }}</span>
</el-button>
</el-tooltip>
<el-button v-blur @click="toggleShowConfig(true)" class="table-header-operate" type="primary">
<Icon name="fa fa-gear" />
<span class="table-header-operate-text">{{ t('crud.log.Settings') }}</span>
</el-button>
<el-button v-blur @click="toggleShowBaAccount(true)" class="table-header-operate" type="primary">
<Icon name="fa fa-user-o" />
<span class="table-header-operate-text">{{ t('layouts.Member information') }}</span>
</el-button>
</template>
<template v-else>
<el-button v-blur @click="toggleShowBaAccount(true)" class="table-header-operate" type="primary">
<Icon name="fa fa-chain" />
<span class="table-header-operate-text">{{ t('crud.log.Login for backup design') }}</span>
</el-button>
</template>
</TableHeader>
<Table ref="tableRef">
<template #tableName>
<el-table-column :show-overflow-tooltip="true" prop="table_name" align="center" :label="t('crud.log.table_name')">
<template #default="scope">
{{ (scope.row.table.databaseConnection ? scope.row.table.databaseConnection + '.' : '') + scope.row.table.name }}
</template>
</el-table-column>
</template>
<template #sync>
<el-table-column prop="sync" align="center" :label="t('crud.log.sync')">
<template #default="scope">
<el-tag :type="scope.row.sync > 0 ? 'primary' : 'info'">
{{ scope.row.sync > 0 ? t('crud.log.sync yes') : t('crud.log.sync no') }}
</el-tag>
</template>
</el-table-column>
</template>
</Table>
</el-dialog>
<el-dialog v-model="state.showConfig" :title="t('crud.log.Settings')">
<div class="ba-operate-form" :style="config.layout.shrink ? '' : 'width: calc(100% - 90px)'">
<el-form @keyup.enter="onConfigSubmit" :model="state.configForm" label-position="top">
<FormItem
:label="t('crud.log.CRUD design record synchronization scheme')"
v-model="state.configForm.syncType"
type="radio"
:input-attr="{
border: true,
content: { manual: t('crud.log.Manual'), automatic: t('crud.log.automatic') },
}"
:block-help="t('crud.log.You can use the synchronized design records across devices')"
/>
<FormItem
:key="state.configForm.syncType"
v-if="state.configForm.syncType == 'automatic'"
:label="t('crud.log.When automatically synchronizing records, share them to the open source community')"
v-model="state.configForm.syncAutoPublic"
type="radio"
:input-attr="{
border: true,
content: { no: t('crud.log.Not to share'), yes: t('crud.log.Share') },
}"
:block-help="t('crud.log.Enabling sharing can automatically earn community points during development')"
/>
<FormItem
:label="t('crud.log.The synchronized CRUD records are automatically resynchronized when they are updated')"
v-model="state.configForm.syncedUpdate"
type="radio"
:input-attr="{
border: true,
content: { no: t('crud.log.Do not resynchronize'), yes: t('crud.log.Automatic resynchronization') },
}"
/>
</el-form>
</div>
<template #footer>
<div :style="'width: calc(100% - 90px)'">
<el-button @click="toggleShowConfig(false)">{{ t('Cancel') }}</el-button>
<el-button v-blur @click="onConfigSubmit" type="primary"> {{ t('Save') }} </el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="state.showUpload" :title="t('Upload')" width="60%">
<div class="ba-operate-form" v-loading="state.uploadValidLoading">
<el-table :empty-text="t('crud.log.No effective design')" :data="state.uploadValidData" stripe class="w100">
<el-table-column prop="table_name" :label="t('crud.log.table_name')" align="center" />
<el-table-column prop="comment" :label="t('crud.log.comment')" align="center" show-overflow-tooltip />
<el-table-column prop="fieldCount" :label="t('crud.log.Number of fields')" align="center" />
<el-table-column :label="t('crud.log.Upload type')" align="center">
<template #default="scope">
<el-tag :type="scope.row.id > 0 ? 'primary' : 'success'">
{{ scope.row.id > 0 ? t('crud.log.Update') : t('crud.log.New added') }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="score" :label="t('crud.log.Share to earn points')" align="center">
<template #default="scope">
<el-text :type="scope.row.score <= 0 ? 'info' : 'success'">{{ scope.row.score }}</el-text>
</template>
</el-table-column>
<el-table-column :label="t('crud.log.Share to the open source community')" align="center">
<template #default="scope">
<el-switch v-model="scope.row.public" />
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div :style="'width: calc(100% - 90px)'">
<el-button @click="toggleShowUpload(false)">{{ t('Cancel') }}</el-button>
<el-button v-blur @click="onSaveUpload" type="primary"> {{ t('Upload') }} </el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="state.showDownload" :title="t('crud.log.Cloud record')">
<div class="download-table-header">
<el-button v-blur @click="onLoadLogs" color="#40485b" class="download-table-header-operate" type="info">
<Icon color="#fff" size="14" name="fa fa-refresh" />
</el-button>
<el-popconfirm
@confirm="onBatchDelLog"
:confirm-button-text="t('Delete')"
:cancel-button-text="t('Cancel')"
confirmButtonType="danger"
:title="t('Are you sure to delete the selected record?')"
:disabled="state.downloadSelection.length > 0 ? false : true"
>
<template #reference>
<el-button
v-blur
:disabled="state.downloadSelection.length > 0 ? false : true"
class="download-table-header-operate"
type="danger"
>
<Icon color="#fff" size="14" name="fa fa-trash" />
<span class="download-table-header-operate-text">{{ t('Delete') }}</span>
</el-button>
</template>
</el-popconfirm>
<div class="download-table-search">
<el-input
v-model="state.downloadQuickSearch"
class="xs-hidden download-quick-search"
@input="onSearchDownloadInput"
:placeholder="t('Search')"
clearable
/>
</div>
</div>
<el-table
v-loading="state.downloadLoading"
@selection-change="onSelectionChange"
:empty-text="t('crud.log.No design record')"
:data="state.downloadData"
stripe
class="w100"
>
<el-table-column type="selection" align="center" />
<el-table-column :show-overflow-tooltip="true" align="center" :label="t('crud.log.table_name')">
<template #default="scope">
{{ (scope.row.table.databaseConnection ? scope.row.table.databaseConnection + '.' : '') + scope.row.table.name }}
</template>
</el-table-column>
<el-table-column prop="comment" :label="t('crud.log.comment')" align="center" show-overflow-tooltip />
<el-table-column :label="t('crud.log.Field')" align="center">
<template #default="scope">
<el-popover :width="460" class="box-item" :title="t('crud.log.Field information')" placement="left">
<template #reference>
<el-text class="cp" type="primary">{{ scope.row.fieldCount }}</el-text>
</template>
<el-table :empty-text="t('crud.log.No field')" :data="scope.row.fields" stripe class="w100">
<el-table-column prop="name" :label="t('crud.log.Field name')" align="center" />
<el-table-column prop="comment" :label="t('crud.log.Note')" align="center" show-overflow-tooltip />
<el-table-column :label="t('crud.log.Type')" align="center" show-overflow-tooltip>
<template #default="typeScope">
<el-text>{{ typeScope.row.dataType ?? typeScope.row.type }}</el-text>
</template>
</el-table-column>
</el-table>
</el-popover>
</template>
</el-table-column>
<el-table-column :label="t('Operate')" align="center">
<template #default="scope">
<el-popconfirm :title="t('crud.crud.Start CRUD design with this record?')" @confirm="onLoadLog(scope.row.id)">
<template #reference>
<el-button type="primary" link>
<div>{{ t('crud.log.Load') }}</div>
</el-button>
</template>
</el-popconfirm>
<el-popconfirm :title="t('crud.log.Delete cloud records?')" @confirm="onDelLog([scope.row.id])">
<template #reference>
<el-button type="danger" link>
<div>{{ t('Delete') }}</div>
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<div class="log-pagination">
<el-pagination
:currentPage="state.downloadPage"
:page-size="10"
background
:layout="config.layout.shrink ? 'prev, next, jumper' : 'total, ->, prev, pager, next, jumper'"
:total="state.downloadTotal"
@current-change="onDownloadCurrentChange"
></el-pagination>
</div>
</el-dialog>
<BaAccountDialog v-model="state.showBaAccount" :login-callback="onBaAccountLoginSuccess" />
</div>
</template>
<script setup lang="ts">
import { ElNotification } from 'element-plus'
import { debounce } from 'lodash-es'
import { nextTick, onMounted, provide, reactive, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { delLog, logs, postDel, uploadCompleted, uploadLog } from '/@/api/backend/crud'
import { baTableApi } from '/@/api/common'
import FormItem from '/@/components/formItem/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import BaAccountDialog from '/@/layouts/backend/components/baAccount.vue'
import { useBaAccount } from '/@/stores/baAccount'
import { useConfig } from '/@/stores/config'
import baTableClass from '/@/utils/baTable'
import { auth, getArrayKey } from '/@/utils/common'
import { changeStep, state as crudState } from '/@/views/backend/crud/index'
interface Props {
modelValue: boolean
}
const config = useConfig()
const baAccount = useBaAccount()
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
})
const emits = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const state = reactive({
ready: false,
configForm: {
syncType: config.crud.syncType,
syncedUpdate: config.crud.syncedUpdate,
syncAutoPublic: config.crud.syncAutoPublic,
},
showUpload: false,
showConfig: false,
showDownload: false,
showBaAccount: false,
uploadScoreSum: 0,
uploadValidData: [] as anyObj[],
uploadValidLoading: false,
downloadPage: 1,
downloadData: [] as anyObj[],
downloadTotal: 0,
downloadLoading: false,
downloadSelection: [] as anyObj[],
downloadQuickSearch: '',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = [
{
render: 'confirmButton',
name: 'copy',
title: 'crud.crud.copy',
text: '',
type: 'primary',
icon: 'fa fa-copy',
class: 'table-row-copy',
popconfirm: {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'primary',
title: t('crud.crud.Start CRUD design with this record?'),
width: '220px',
},
disabledTip: false,
click: (row) => {
crudState.startData.logId = row[baTable.table.pk!]
changeStep('log')
emits('update:modelValue', false)
},
},
{
render: 'confirmButton',
name: 'del',
title: 'crud.log.delete',
text: '',
type: 'danger',
icon: 'fa fa-trash',
class: 'table-row-delete',
popconfirm: {
confirmButtonText: t('crud.crud.Delete Code'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'danger',
title: t('crud.crud.Are you sure to delete the generated CRUD code?'),
width: '248px',
},
disabledTip: false,
click: (row) => {
postDel(row[baTable.table.pk!]).then(() => {
baTable.onTableHeaderAction('refresh', {})
})
},
display: (row) => {
return row['status'] != 'delete' && auth('delete')
},
},
]
const baTable = new baTableClass(
new baTableApi('/admin/crud.Log/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('crud.log.id'), prop: 'id', align: 'center', width: 70, operator: '=', sortable: 'custom' },
{
label: t('crud.log.table_name'),
operator: 'LIKE',
render: 'slot',
slotName: 'tableName',
},
{
label: t('crud.log.comment'),
prop: 'comment',
align: 'center',
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('crud.log.sync'),
prop: 'sync',
align: 'center',
render: 'slot',
slotName: 'sync',
},
{
label: t('crud.log.status'),
prop: 'status',
align: 'center',
render: 'tag',
sortable: false,
replaceValue: {
delete: t('crud.log.status delete'),
success: t('crud.log.status success'),
error: t('crud.log.status error'),
start: t('crud.log.status start'),
},
custom: { delete: 'danger', success: 'success', error: 'warning', start: '' },
},
{
label: t('crud.log.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { status: 'start' },
}
)
provide('baTable', baTable)
const getData = () => {
baTable.getData()?.then(() => {
state.ready = true
})
}
const toggleShowConfig = (status: boolean) => {
state.showConfig = status
}
const toggleShowBaAccount = (status: boolean) => {
state.showBaAccount = status
}
const toggleShowUpload = (status: boolean) => {
state.showUpload = status
}
const toggleShowDownload = (status: boolean) => {
state.showDownload = status
}
const onLoadLog = (id: string) => {
crudState.startData.logId = id
crudState.startData.logType = 'Cloud history'
changeStep('log')
emits('update:modelValue', false)
}
const onBatchDelLog = () => {
let ids: number[] = []
for (const key in state.downloadSelection) {
ids.push(state.downloadSelection[key].id)
}
onDelLog(ids)
}
const onDelLog = (ids: number[]) => {
delLog({ ids }).then((res) => {
uploadCompleted({ syncIds: res.data.syncs, cancelSync: 1 }).finally(() => {
onLoadLogs()
baTable.onTableHeaderAction('refresh', {})
})
})
}
const onConfigSubmit = () => {
toggleShowConfig(false)
config.setCrud('syncType', state.configForm.syncType)
config.setCrud('syncedUpdate', state.configForm.syncedUpdate)
config.setCrud('syncAutoPublic', state.configForm.syncAutoPublic)
ElNotification({
type: 'success',
message: t('axios.Operation successful'),
})
}
const onUpload = () => {
toggleShowUpload(true)
state.uploadValidLoading = true
uploadLog({ logs: baTable.table.selection, save: 0 })
.then((res) => {
state.uploadScoreSum = res.data.scoreSum
state.uploadValidData = res.data.validData
})
.finally(() => {
state.uploadValidLoading = false
})
}
const onSaveUpload = () => {
state.uploadValidLoading = true
const selection = baTable.table.selection
for (const key in selection) {
const s = selection[key as keyof typeof selection] as any
const validDataKey = getArrayKey(state.uploadValidData, 'sync', s.id.toString())
if (validDataKey !== false) {
s['public'] = state.uploadValidData[validDataKey].public ? 1 : 0
}
}
uploadLog({ logs: selection, save: 1 }).then((res) => {
uploadCompleted({ syncIds: res.data.syncIds }).finally(() => {
baTable.onTableHeaderAction('refresh', {})
toggleShowUpload(false)
ElNotification({
type: 'success',
message: res.msg,
})
state.uploadValidLoading = false
})
})
}
const onBaAccountLoginSuccess = () => {
toggleShowBaAccount(false)
}
const onDownloadCurrentChange = (val: number) => {
state.downloadPage = val
onLoadLogs()
}
const onSelectionChange = (newSelection: any[]) => {
state.downloadSelection = newSelection
}
const onSearchDownloadInput = debounce(() => onLoadLogs(), 500)
const onLoadLogs = () => {
toggleShowDownload(true)
state.downloadLoading = true
logs({ page: state.downloadPage, quickSearch: state.downloadQuickSearch })
.then((res) => {
state.downloadData = res.data.list
state.downloadTotal = res.data.total
})
.finally(() => {
state.downloadLoading = false
})
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
})
watch(
() => props.modelValue,
(newVal) => {
if (newVal && !state.ready) {
nextTick(() => {
getData()
})
}
}
)
</script>
<style lang="scss">
.ba-crud-log-dialog .el-dialog__body {
padding: 10px 20px;
}
.log-pagination {
padding: 13px 15px;
}
.cp {
cursor: pointer;
}
.download-table-header {
display: flex;
padding: 10px;
padding-top: 0;
.download-table-header-operate-text {
margin-left: 6px;
font-size: 14px;
}
.download-table-search {
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,319 @@
<template>
<div class="default-main">
<div class="crud-title">{{ t('crud.crud.start') }}</div>
<div class="start-opt">
<el-row :gutter="20">
<el-col :xs="24" :span="8">
<div @click="changeStep('create')" class="start-item suspension">
<div class="start-item-title">{{ t('crud.crud.create') }}</div>
<div class="start-item-remark">{{ t('crud.crud.New background CRUD from zero') }}</div>
</div>
</el-col>
<el-col @click="onShowDialog('db')" :xs="24" :span="8">
<div class="start-item suspension">
<div class="start-item-title">{{ t('crud.crud.Select Data Table') }}</div>
<div class="start-item-remark">{{ t('crud.crud.Select a designed data table from the database') }}</div>
</div>
</el-col>
<el-col @click="state.showLog = true" :xs="24" :span="8">
<div class="start-item suspension">
<div class="start-item-title">{{ t('crud.crud.CRUD record') }}</div>
<div class="start-item-remark">{{ t('crud.crud.Start with previously generated CRUD code') }}</div>
</div>
</el-col>
</el-row>
<el-row justify="center">
<el-col :span="20" class="ba-markdown crud-tips suspension">
<b>{{ t('crud.crud.Fast experience') }}</b>
<ol>
<li>
{{ t('crud.crud.experience 1 1') }}
<a target="_blank" href="https://doc.buildadmin.com/guide/other/developerMustSee.html" rel="noopener noreferrer">
{{ t('crud.crud.experience 1 2') }}
</a>
{{ t('crud.crud.experience 1 3') }}
</li>
<li>
{{ t('crud.crud.experience 2 1') }}
<code>{{ t('crud.crud.create') }}</code>
{{ t('crud.crud.or') }}
<code> {{ t('crud.crud.experience 2 2') }}{{ t('crud.crud.experience 2 3') }} </code>
</li>
<li>
{{ t('crud.crud.experience 3 1') }} <code>{{ t('crud.crud.experience 3 2') }}</code>
{{ t('crud.crud.experience 3 3') }}
<code>{{ t('crud.crud.experience 3 4') }}</code>
</li>
</ol>
<el-alert v-if="!isDev()" class="no-dev" type="warning" :show-icon="true" :closable="false">
<template #title>
<span>{{ t('crud.crud.experience 4 1') }}</span>
<a target="_blank" href="https://doc.buildadmin.com/guide/other/developerMustSee.html" rel="noopener noreferrer">
{{ t('crud.crud.experience 4 2') }}
</a>
<span>
{{ t('crud.crud.experience 4 3') }}<code>{{ t('crud.crud.experience 4 4') }}</code>
</span>
</template>
</el-alert>
</el-col>
</el-row>
<el-dialog
class="ba-operate-dialog select-table-dialog"
v-model="state.dialog.visible"
:title="state.dialog.type == 'sql' ? t('crud.crud.Please enter SQL') : t('crud.crud.Please select a data table')"
:destroy-on-close="true"
>
<el-form
:label-width="140"
@keyup.enter="onSubmit()"
class="select-table-form"
ref="formRef"
:model="crudState.startData"
:rules="rules"
>
<template v-if="state.dialog.type == 'sql'">
<el-input
class="sql-input"
prop="sql"
ref="sqlInputRef"
v-model="crudState.startData.sql"
type="textarea"
:placeholder="t('crud.crud.table create SQL')"
:rows="10"
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
/>
</template>
<template v-else-if="state.dialog.type == 'db'">
<FormItem
:label="t('Database connection')"
v-model="crudState.startData.databaseConnection"
type="remoteSelect"
:label-width="140"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
onChange: onDatabaseChange,
}"
:placeholder="t('Please select field', { field: t('Database connection') })"
/>
<FormItem
:label="t('crud.crud.data sheet')"
v-model="crudState.startData.table"
type="remoteSelect"
:key="crudState.startData.databaseConnection"
:placeholder="t('crud.crud.Please select a data table')"
:label-width="140"
:block-help="t('crud.crud.data sheet help')"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: crudState.startData.databaseConnection,
samePrefix: 1,
excludeTable: [
'area',
'token',
'captcha',
'admin_group_access',
'config',
'admin_log',
'user_money_log',
'user_score_log',
],
},
remoteUrl: getTableListUrl,
onRow: onTableStartChange,
}"
prop="table"
/>
<el-alert
v-if="state.successRecord"
class="success-record-alert"
:title="t('crud.crud.The selected table has already generated records You are advised to start with historical records')"
:show-icon="true"
:closable="false"
type="warning"
/>
</template>
</el-form>
<template #footer>
<div :style="{ width: 'calc(100% * 0.9)' }">
<el-button @click="state.dialog.visible = false">{{ $t('Cancel') }}</el-button>
<el-button :loading="state.loading" @click="onSubmit()" v-blur type="primary">{{ t('Confirm') }}</el-button>
<el-button v-if="state.successRecord" @click="onLogStart" v-blur type="success">
{{ t('crud.crud.Start with the historical record') }}
</el-button>
</div>
</template>
</el-dialog>
<CrudLog v-model="state.showLog" />
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import { checkCrudLog } from '/@/api/backend/crud'
import FormItem from '/@/components/formItem/index.vue'
import { changeStep, state as crudState } from '/@/views/backend/crud/index'
import { ElNotification } from 'element-plus'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import CrudLog from '/@/views/backend/crud/log.vue'
import { useI18n } from 'vue-i18n'
import { getDatabaseConnectionListUrl, getTableListUrl } from '/@/api/common'
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const sqlInputRef = useTemplateRef('sqlInputRef')
const state = reactive({
dialog: {
type: '',
visible: false,
},
showLog: false,
loading: false,
successRecord: 0,
})
const onShowDialog = (type: string) => {
state.dialog.type = type
state.dialog.visible = true
if (type == 'sql') {
setTimeout(() => {
sqlInputRef.value?.focus()
}, 200)
} else if (type == 'db') {
state.successRecord = 0
crudState.startData.table = ''
}
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
table: [buildValidatorData({ name: 'required', message: t('crud.crud.Please select a data table') })],
})
const onSubmit = () => {
if (state.dialog.type == 'sql' && !crudState.startData.sql) {
ElNotification({
type: 'error',
message: t('crud.crud.Please enter the table creation SQL'),
})
return
}
formRef.value?.validate((valid) => {
if (valid) {
changeStep(state.dialog.type)
}
})
}
const onDatabaseChange = () => {
state.successRecord = 0
crudState.startData.table = ''
}
const onTableStartChange = () => {
if (crudState.startData.table) {
// 检查是否有CRUD记录
state.loading = true
checkCrudLog(crudState.startData.table, crudState.startData.databaseConnection)
.then((res) => {
state.successRecord = res.data.id
})
.finally(() => {
state.loading = false
})
}
}
const onLogStart = () => {
if (state.successRecord) {
crudState.startData.logId = state.successRecord.toString()
changeStep('log')
}
}
const isDev = () => {
return import.meta.env.DEV
}
</script>
<style scoped lang="scss">
:deep(.select-table-dialog) .el-dialog__body {
height: unset;
.select-table-form {
width: 88%;
padding: 40px 0;
}
.success-record-alert {
width: calc(100% - 140px);
margin-left: 140px;
margin-bottom: 30px;
margin-top: -10px;
}
}
.crud-title {
display: flex;
align-items: center;
justify-content: center;
font-size: var(--el-font-size-extra-large);
font-weight: bold;
padding-top: 120px;
}
.start-opt {
display: block;
width: 60%;
margin: 40px auto;
}
.start-item {
background-color: #e1eaf9;
border-radius: var(--el-border-radius-base);
padding: 25px;
margin-bottom: 20px;
cursor: pointer;
}
.start-item-title {
font-size: var(--el-font-size-large);
color: var(--ba-color-primary-light);
}
.start-item-remark {
display: block;
line-height: 18px;
min-height: 48px;
padding-top: 12px;
color: #92969a;
}
.sql-input {
margin: 20px 0;
}
.crud-tips {
margin-top: 60px;
padding: 20px;
background-color: rgba($color: #ffffff, $alpha: 0.6);
border-radius: var(--el-border-radius-base);
color: var(--el-color-info);
b {
font-size: 15px;
padding-left: 10px;
}
.no-dev {
margin-top: 10px;
}
}
@at-root .dark {
.start-item {
background-color: #1d1e1f;
}
.crud-tips {
background-color: rgba($color: #1d1e1f, $alpha: 0.4);
}
}
</style>

View File

@@ -0,0 +1,826 @@
<template>
<div class="default-main">
<div class="banner">
<el-row :gutter="10">
<el-col :md="24" :lg="18">
<div class="welcome suspension">
<img class="welcome-img" :src="headerSvg" alt="" />
<div class="welcome-text">
<div class="welcome-title">{{ adminInfo.nickname + t('utils.comma') + getGreet() }}</div>
<div class="welcome-note">{{ state.remark }}</div>
</div>
</div>
</el-col>
<el-col :lg="6" class="hidden-md-and-down">
<div class="working">
<img class="working-coffee" :src="coffeeSvg" alt="" />
<div class="working-text">
{{ t('dashboard.You have worked today') }}<span class="time">{{ state.workingTimeFormat }}</span>
</div>
<div @click="onChangeWorkState()" class="working-opt working-rest">
{{ state.pauseWork ? t('dashboard.Continue to work') : t('dashboard.have a bit of rest') }}
</div>
</div>
</el-col>
</el-row>
</div>
<div class="small-panel-box">
<el-row :gutter="20">
<el-col :sm="12" :lg="6">
<div class="small-panel user-reg suspension">
<div class="small-panel-title">{{ t('dashboard.Member registration') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#8595F4" size="20" name="fa fa-line-chart" />
<el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+14%</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel file suspension">
<div class="small-panel-title">{{ t('dashboard.Number of attachments Uploaded') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#AD85F4" size="20" name="fa fa-file-text" />
<el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+50%</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel users suspension">
<div class="small-panel-title">{{ t('dashboard.Total number of members') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#74A8B5" size="20" name="fa fa-users" />
<el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+28%</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel addons suspension">
<div class="small-panel-title">{{ t('dashboard.Number of installed plug-ins') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#F48595" size="20" name="fa fa-object-group" />
<el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+88%</div>
</div>
</div>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Membership growth')">
<div class="user-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Annex growth')">
<div class="file-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="6">
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.New member')">
<div class="new-user-growth">
<el-scrollbar>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">妙码生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">码上生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">Admin</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
<div class="new-user-base">
<div class="new-user-name">纯属虚构</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
</el-scrollbar>
</div>
</el-card>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member source')">
<div class="user-source-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member last name')">
<div class="user-surname-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener, useTemplateRefsList, useTransition } from '@vueuse/core'
import * as echarts from 'echarts'
import { CSSProperties, nextTick, onActivated, onBeforeMount, onMounted, onUnmounted, reactive, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { index } from '/@/api/backend/dashboard'
import coffeeSvg from '/@/assets/dashboard/coffee.svg'
import headerSvg from '/@/assets/dashboard/header-1.svg'
import { useAdminInfo } from '/@/stores/adminInfo'
import { WORKING_TIME } from '/@/stores/constant/cacheKey'
import { useNavTabs } from '/@/stores/navTabs'
import { fullUrl, getGreet } from '/@/utils/common'
import { Local } from '/@/utils/storage'
let workTimer: number
defineOptions({
name: 'dashboard',
})
const d = new Date()
const { t } = useI18n()
const navTabs = useNavTabs()
const adminInfo = useAdminInfo()
const chartRefs = useTemplateRefsList<HTMLDivElement>()
const state: {
charts: any[]
remark: string
workingTimeFormat: string
pauseWork: boolean
} = reactive({
charts: [],
remark: 'dashboard.Loading',
workingTimeFormat: '',
pauseWork: false,
})
/**
* 带有数字向上变化特效的数据
*/
const countUp = reactive({
userRegNumber: 0,
fileNumber: 0,
usersNumber: 0,
addonsNumber: 0,
})
const countUpRefs = toRefs(countUp)
const userRegNumberOutput = useTransition(countUpRefs.userRegNumber, { duration: 1500 })
const fileNumberOutput = useTransition(countUpRefs.fileNumber, { duration: 1500 })
const usersNumberOutput = useTransition(countUpRefs.usersNumber, { duration: 1500 })
const addonsNumberOutput = useTransition(countUpRefs.addonsNumber, { duration: 1500 })
const statisticValueStyle: CSSProperties = {
fontSize: '28px',
}
index().then((res) => {
state.remark = res.data.remark
})
const initCountUp = () => {
// 虚拟数据
countUpRefs.userRegNumber.value = 5456
countUpRefs.fileNumber.value = 1234
countUpRefs.usersNumber.value = 9486
countUpRefs.addonsNumber.value = 875
}
const initUserGrowthChart = () => {
const userGrowthChart = echarts.init(chartRefs.value[0] as HTMLElement)
const option = {
grid: {
top: 40,
right: 0,
bottom: 20,
left: 40,
},
xAxis: {
data: [
t('dashboard.Monday'),
t('dashboard.Tuesday'),
t('dashboard.Wednesday'),
t('dashboard.Thursday'),
t('dashboard.Friday'),
t('dashboard.Saturday'),
t('dashboard.Sunday'),
],
},
yAxis: {},
legend: {
data: [t('dashboard.Visits'), t('dashboard.Registration volume')],
textStyle: {
color: '#73767a',
},
top: 0,
},
series: [
{
name: t('dashboard.Visits'),
data: [100, 160, 280, 230, 190, 200, 480],
type: 'line',
smooth: true,
areaStyle: {
color: '#8595F4',
},
},
{
name: t('dashboard.Registration volume'),
data: [45, 180, 146, 99, 210, 127, 288],
type: 'line',
smooth: true,
areaStyle: {
color: '#F48595',
opacity: 0.5,
},
},
],
}
userGrowthChart.setOption(option)
state.charts.push(userGrowthChart)
}
const initFileGrowthChart = () => {
const fileGrowthChart = echarts.init(chartRefs.value[1] as HTMLElement)
const option = {
grid: {
top: 30,
right: 0,
bottom: 20,
left: 0,
},
tooltip: {
trigger: 'item',
},
legend: {
type: 'scroll',
bottom: 0,
data: (function () {
var list = []
for (var i = 1; i <= 28; i++) {
list.push(i + 2000 + '')
}
return list
})(),
textStyle: {
color: '#73767a',
},
},
visualMap: {
top: 'middle',
right: 10,
color: ['red', 'yellow'],
calculable: true,
},
radar: {
indicator: [
{ name: t('dashboard.picture') },
{ name: t('dashboard.file') },
{ name: t('dashboard.table') },
{ name: t('dashboard.Compressed package') },
{ name: t('dashboard.other') },
],
},
series: (function () {
var series = []
for (var i = 1; i <= 28; i++) {
series.push({
type: 'radar',
symbol: 'none',
lineStyle: {
width: 1,
},
emphasis: {
areaStyle: {
color: 'rgba(0,250,0,0.3)',
},
},
data: [
{
value: [(40 - i) * 10, (38 - i) * 4 + 60, i * 5 + 10, i * 9, (i * i) / 2],
name: i + 2000 + '',
},
],
})
}
return series
})(),
}
fileGrowthChart.setOption(option)
state.charts.push(fileGrowthChart)
}
const initUserSourceChart = () => {
const UserSourceChart = echarts.init(chartRefs.value[2] as HTMLElement)
const pathSymbols = {
reindeer:
'path://M-22.788,24.521c2.08-0.986,3.611-3.905,4.984-5.892 c-2.686,2.782-5.047,5.884-9.102,7.312c-0.992,0.005-0.25-2.016,0.34-2.362l1.852-0.41c0.564-0.218,0.785-0.842,0.902-1.347 c2.133-0.727,4.91-4.129,6.031-6.194c1.748-0.7,4.443-0.679,5.734-2.293c1.176-1.468,0.393-3.992,1.215-6.557 c0.24-0.754,0.574-1.581,1.008-2.293c-0.611,0.011-1.348-0.061-1.959-0.608c-1.391-1.245-0.785-2.086-1.297-3.313 c1.684,0.744,2.5,2.584,4.426,2.586C-8.46,3.012-8.255,2.901-8.04,2.824c6.031-1.952,15.182-0.165,19.498-3.937 c1.15-3.933-1.24-9.846-1.229-9.938c0.008-0.062-1.314-0.004-1.803-0.258c-1.119-0.771-6.531-3.75-0.17-3.33 c0.314-0.045,0.943,0.259,1.439,0.435c-0.289-1.694-0.92-0.144-3.311-1.946c0,0-1.1-0.855-1.764-1.98 c-0.836-1.09-2.01-2.825-2.992-4.031c-1.523-2.476,1.367,0.709,1.816,1.108c1.768,1.704,1.844,3.281,3.232,3.983 c0.195,0.203,1.453,0.164,0.926-0.468c-0.525-0.632-1.367-1.278-1.775-2.341c-0.293-0.703-1.311-2.326-1.566-2.711 c-0.256-0.384-0.959-1.718-1.67-2.351c-1.047-1.187-0.268-0.902,0.521-0.07c0.789,0.834,1.537,1.821,1.672,2.023 c0.135,0.203,1.584,2.521,1.725,2.387c0.102-0.259-0.035-0.428-0.158-0.852c-0.125-0.423-0.912-2.032-0.961-2.083 c-0.357-0.852-0.566-1.908-0.598-3.333c0.4-2.375,0.648-2.486,0.549-0.705c0.014,1.143,0.031,2.215,0.602,3.247 c0.807,1.496,1.764,4.064,1.836,4.474c0.561,3.176,2.904,1.749,2.281-0.126c-0.068-0.446-0.109-2.014-0.287-2.862 c-0.18-0.849-0.219-1.688-0.113-3.056c0.066-1.389,0.232-2.055,0.277-2.299c0.285-1.023,0.4-1.088,0.408,0.135 c-0.059,0.399-0.131,1.687-0.125,2.655c0.064,0.642-0.043,1.768,0.172,2.486c0.654,1.928-0.027,3.496,1,3.514 c1.805-0.424,2.428-1.218,2.428-2.346c-0.086-0.704-0.121-0.843-0.031-1.193c0.221-0.568,0.359-0.67,0.312-0.076 c-0.055,0.287,0.031,0.533,0.082,0.794c0.264,1.197,0.912,0.114,1.283-0.782c0.15-0.238,0.539-2.154,0.545-2.522 c-0.023-0.617,0.285-0.645,0.309,0.01c0.064,0.422-0.248,2.646-0.205,2.334c-0.338,1.24-1.105,3.402-3.379,4.712 c-0.389,0.12-1.186,1.286-3.328,2.178c0,0,1.729,0.321,3.156,0.246c1.102-0.19,3.707-0.027,4.654,0.269 c1.752,0.494,1.531-0.053,4.084,0.164c2.26-0.4,2.154,2.391-1.496,3.68c-2.549,1.405-3.107,1.475-2.293,2.984 c3.484,7.906,2.865,13.183,2.193,16.466c2.41,0.271,5.732-0.62,7.301,0.725c0.506,0.333,0.648,1.866-0.457,2.86 c-4.105,2.745-9.283,7.022-13.904,7.662c-0.977-0.194,0.156-2.025,0.803-2.247l1.898-0.03c0.596-0.101,0.936-0.669,1.152-1.139 c3.16-0.404,5.045-3.775,8.246-4.818c-4.035-0.718-9.588,3.981-12.162,1.051c-5.043,1.423-11.449,1.84-15.895,1.111 c-3.105,2.687-7.934,4.021-12.115,5.866c-3.271,3.511-5.188,8.086-9.967,10.414c-0.986,0.119-0.48-1.974,0.066-2.385l1.795-0.618 C-22.995,25.682-22.849,25.035-22.788,24.521z',
plane: 'path://M1.112,32.559l2.998,1.205l-2.882,2.268l-2.215-0.012L1.112,32.559z M37.803,23.96 c0.158-0.838,0.5-1.509,0.961-1.904c-0.096-0.037-0.205-0.071-0.344-0.071c-0.777-0.005-2.068-0.009-3.047-0.009 c-0.633,0-1.217,0.066-1.754,0.18l2.199,1.804H37.803z M39.738,23.036c-0.111,0-0.377,0.325-0.537,0.924h1.076 C40.115,23.361,39.854,23.036,39.738,23.036z M39.934,39.867c-0.166,0-0.674,0.705-0.674,1.986s0.506,1.986,0.674,1.986 s0.672-0.705,0.672-1.986S40.102,39.867,39.934,39.867z M38.963,38.889c-0.098-0.038-0.209-0.07-0.348-0.073 c-0.082,0-0.174,0-0.268-0.001l-7.127,4.671c0.879,0.821,2.42,1.417,4.348,1.417c0.979,0,2.27-0.006,3.047-0.01 c0.139,0,0.25-0.034,0.348-0.072c-0.646-0.555-1.07-1.643-1.07-2.967C37.891,40.529,38.316,39.441,38.963,38.889z M32.713,23.96 l-12.37-10.116l-4.693-0.004c0,0,4,8.222,4.827,10.121H32.713z M59.311,32.374c-0.248,2.104-5.305,3.172-8.018,3.172H39.629 l-25.325,16.61L9.607,52.16c0,0,6.687-8.479,7.95-10.207c1.17-1.6,3.019-3.699,3.027-6.407h-2.138 c-5.839,0-13.816-3.789-18.472-5.583c-2.818-1.085-2.396-4.04-0.031-4.04h0.039l-3.299-11.371h3.617c0,0,4.352,5.696,5.846,7.5 c2,2.416,4.503,3.678,8.228,3.87h30.727c2.17,0,4.311,0.417,6.252,1.046c3.49,1.175,5.863,2.7,7.199,4.027 C59.145,31.584,59.352,32.025,59.311,32.374z M22.069,30.408c0-0.815-0.661-1.475-1.469-1.475c-0.812,0-1.471,0.66-1.471,1.475 s0.658,1.475,1.471,1.475C21.408,31.883,22.069,31.224,22.069,30.408z M27.06,30.408c0-0.815-0.656-1.478-1.466-1.478 c-0.812,0-1.471,0.662-1.471,1.478s0.658,1.477,1.471,1.477C26.404,31.885,27.06,31.224,27.06,30.408z M32.055,30.408 c0-0.815-0.66-1.475-1.469-1.475c-0.808,0-1.466,0.66-1.466,1.475s0.658,1.475,1.466,1.475 C31.398,31.883,32.055,31.224,32.055,30.408z M37.049,30.408c0-0.815-0.658-1.478-1.467-1.478c-0.812,0-1.469,0.662-1.469,1.478 s0.656,1.477,1.469,1.477C36.389,31.885,37.049,31.224,37.049,30.408z M42.039,30.408c0-0.815-0.656-1.478-1.465-1.478 c-0.811,0-1.469,0.662-1.469,1.478s0.658,1.477,1.469,1.477C41.383,31.885,42.039,31.224,42.039,30.408z M55.479,30.565 c-0.701-0.436-1.568-0.896-2.627-1.347c-0.613,0.289-1.551,0.476-2.73,0.476c-1.527,0-1.639,2.263,0.164,2.316 C52.389,32.074,54.627,31.373,55.479,30.565z',
rocket: 'path://M-244.396,44.399c0,0,0.47-2.931-2.427-6.512c2.819-8.221,3.21-15.709,3.21-15.709s5.795,1.383,5.795,7.325C-237.818,39.679-244.396,44.399-244.396,44.399z M-260.371,40.827c0,0-3.881-12.946-3.881-18.319c0-2.416,0.262-4.566,0.669-6.517h17.684c0.411,1.952,0.675,4.104,0.675,6.519c0,5.291-3.87,18.317-3.87,18.317H-260.371z M-254.745,18.951c-1.99,0-3.603,1.676-3.603,3.744c0,2.068,1.612,3.744,3.603,3.744c1.988,0,3.602-1.676,3.602-3.744S-252.757,18.951-254.745,18.951z M-255.521,2.228v-5.098h1.402v4.969c1.603,1.213,5.941,5.069,7.901,12.5h-17.05C-261.373,7.373-257.245,3.558-255.521,2.228zM-265.07,44.399c0,0-6.577-4.721-6.577-14.896c0-5.942,5.794-7.325,5.794-7.325s0.393,7.488,3.211,15.708C-265.539,41.469-265.07,44.399-265.07,44.399z M-252.36,45.15l-1.176-1.22L-254.789,48l-1.487-4.069l-1.019,2.116l-1.488-3.826h8.067L-252.36,45.15z',
train: 'path://M67.335,33.596L67.335,33.596c-0.002-1.39-1.153-3.183-3.328-4.218h-9.096v-2.07h5.371 c-4.939-2.07-11.199-4.141-14.89-4.141H19.72v12.421v5.176h38.373c4.033,0,8.457-1.035,9.142-5.176h-0.027 c0.076-0.367,0.129-0.751,0.129-1.165L67.335,33.596L67.335,33.596z M27.999,30.413h-3.105v-4.141h3.105V30.413z M35.245,30.413 h-3.104v-4.141h3.104V30.413z M42.491,30.413h-3.104v-4.141h3.104V30.413z M49.736,30.413h-3.104v-4.141h3.104V30.413z M14.544,40.764c1.143,0,2.07-0.927,2.07-2.07V35.59V25.237c0-1.145-0.928-2.07-2.07-2.07H-9.265c-1.143,0-2.068,0.926-2.068,2.07 v10.351v3.105c0,1.144,0.926,2.07,2.068,2.07H14.544L14.544,40.764z M8.333,26.272h3.105v4.141H8.333V26.272z M1.087,26.272h3.105 v4.141H1.087V26.272z M-6.159,26.272h3.105v4.141h-3.105V26.272z M-9.265,41.798h69.352v1.035H-9.265V41.798z',
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'none',
},
formatter: function (params: any) {
return params[0].name + ': ' + params[0].value
},
},
xAxis: {
data: [t('dashboard.Baidu'), t('dashboard.Direct access'), t('dashboard.take a plane'), t('dashboard.Take the high-speed railway')],
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#e54035',
},
},
yAxis: {
splitLine: { show: false },
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { show: false },
},
color: ['#e54035'],
series: [
{
name: 'hill',
type: 'pictorialBar',
barCategoryGap: '-130%',
symbol: 'path://M0,10 L10,10 C5.5,10 5.5,5 5,0 C4.5,5 4.5,10 0,10 z',
itemStyle: {
opacity: 0.5,
},
emphasis: {
itemStyle: {
opacity: 1,
},
},
data: [123, 60, 25, 80],
z: 10,
},
{
name: 'glyph',
type: 'pictorialBar',
barGap: '-100%',
symbolPosition: 'end',
symbolSize: 50,
symbolOffset: [0, '-120%'],
data: [
{
value: 123,
symbol: pathSymbols.reindeer,
symbolSize: [60, 60],
},
{
value: 60,
symbol: pathSymbols.rocket,
symbolSize: [50, 60],
},
{
value: 25,
symbol: pathSymbols.plane,
symbolSize: [65, 35],
},
{
value: 80,
symbol: pathSymbols.train,
symbolSize: [50, 30],
},
],
},
],
}
UserSourceChart.setOption(option)
state.charts.push(UserSourceChart)
}
const initUserSurnameChart = () => {
const userSurnameChart = echarts.init(chartRefs.value[3] as HTMLElement)
const data = genData(20)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 20,
bottom: 20,
data: data.legendData,
textStyle: {
color: '#73767a',
},
},
series: [
{
name: t('dashboard.full name'),
type: 'pie',
radius: '55%',
center: ['40%', '50%'],
data: data.seriesData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
function genData(count: any) {
// prettier-ignore
const nameList = [
'赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨', '朱', '秦', '尤', '许', '何', '吕', '施', '张', '孔', '曹', '严', '华', '金', '魏', '陶', '姜', '戚', '谢', '邹', '喻', '柏', '水', '窦', '章', '云', '苏', '潘', '葛', '奚', '范', '彭', '郎', '鲁', '韦', '昌', '马', '苗', '凤', '花', '方', '俞', '任', '袁', '柳', '酆', '鲍', '史', '唐', '费', '廉', '岑', '薛', '雷', '贺', '倪', '汤', '滕', '殷', '罗', '毕', '郝', '邬', '安', '常', '乐', '于', '时', '傅', '皮', '卞', '齐', '康', '伍', '余', '元', '卜', '顾', '孟', '平', '黄', '和', '穆', '萧', '尹', '姚', '邵', '湛', '汪', '祁', '毛', '禹', '狄', '米', '贝', '明', '臧', '计', '伏', '成', '戴', '谈', '宋', '茅', '庞', '熊', '纪', '舒', '屈', '项', '祝', '董', '梁', '杜', '阮', '蓝', '闵', '席', '季', '麻', '强', '贾', '路', '娄', '危'
];
const legendData = []
const seriesData = []
for (var i = 0; i < count; i++) {
var name = Math.random() > 0.85 ? makeWord(2, 1) + '·' + makeWord(2, 0) : makeWord(2, 1)
legendData.push(name)
seriesData.push({
name: name,
value: Math.round(Math.random() * 100000),
})
}
return {
legendData: legendData,
seriesData: seriesData,
}
function makeWord(max: any, min: any) {
const nameLen = Math.ceil(Math.random() * max + min)
const name = []
for (var i = 0; i < nameLen; i++) {
name.push(nameList[Math.round(Math.random() * nameList.length - 1)])
}
return name.join('')
}
}
userSurnameChart.setOption(option)
state.charts.push(userSurnameChart)
}
const echartsResize = () => {
nextTick(() => {
for (const key in state.charts) {
state.charts[key].resize()
}
})
}
const onChangeWorkState = () => {
const time = parseInt((new Date().getTime() / 1000).toString())
const workingTime = Local.get(WORKING_TIME)
if (state.pauseWork) {
// 继续工作
workingTime.pauseTime += time - workingTime.startPauseTime
workingTime.startPauseTime = 0
Local.set(WORKING_TIME, workingTime)
state.pauseWork = false
startWork()
} else {
// 暂停工作
workingTime.startPauseTime = time
Local.set(WORKING_TIME, workingTime)
clearInterval(workTimer)
state.pauseWork = true
}
}
const startWork = () => {
const workingTime = Local.get(WORKING_TIME) || { date: '', startTime: 0, pauseTime: 0, startPauseTime: 0 }
const currentDate = d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
const time = parseInt((new Date().getTime() / 1000).toString())
if (workingTime.date != currentDate) {
workingTime.date = currentDate
workingTime.startTime = time
workingTime.pauseTime = workingTime.startPauseTime = 0
Local.set(WORKING_TIME, workingTime)
}
let startPauseTime = 0
if (workingTime.startPauseTime <= 0) {
state.pauseWork = false
startPauseTime = 0
} else {
state.pauseWork = true
startPauseTime = time - workingTime.startPauseTime // 已暂停时间
}
let workingSeconds = time - workingTime.startTime - workingTime.pauseTime - startPauseTime
state.workingTimeFormat = formatSeconds(workingSeconds)
if (!state.pauseWork) {
workTimer = window.setInterval(() => {
workingSeconds++
state.workingTimeFormat = formatSeconds(workingSeconds)
}, 1000)
}
}
const formatSeconds = (seconds: number) => {
var secondTime = 0 // 秒
var minuteTime = 0 // 分
var hourTime = 0 // 小时
var dayTime = 0 // 天
var result = ''
if (seconds < 60) {
secondTime = seconds
} else {
// 获取分钟除以60取整数得到整数分钟
minuteTime = Math.floor(seconds / 60)
// 获取秒数,秒数取佘,得到整数秒数
secondTime = Math.floor(seconds % 60)
// 如果分钟大于60将分钟转换成小时
if (minuteTime >= 60) {
// 获取小时获取分钟除以60得到整数小时
hourTime = Math.floor(minuteTime / 60)
// 获取小时后取佘的分获取分钟除以60取佘的分
minuteTime = Math.floor(minuteTime % 60)
if (hourTime >= 24) {
// 获取天数, 获取小时除以24得到整数天
dayTime = Math.floor(hourTime / 24)
// 获取小时后取余小时获取分钟除以24取余的分
hourTime = Math.floor(hourTime % 24)
}
}
}
result =
hourTime +
t('dashboard.hour') +
((minuteTime >= 10 ? minuteTime : '0' + minuteTime) + t('dashboard.minute')) +
((secondTime >= 10 ? secondTime : '0' + secondTime) + t('dashboard.second'))
if (dayTime > 0) {
result = dayTime + t('dashboard.day') + result
}
return result
}
onActivated(() => {
echartsResize()
})
onMounted(() => {
startWork()
initCountUp()
initUserGrowthChart()
initFileGrowthChart()
initUserSourceChart()
initUserSurnameChart()
useEventListener(window, 'resize', echartsResize)
})
onBeforeMount(() => {
for (const key in state.charts) {
state.charts[key].dispose()
}
})
onUnmounted(() => {
clearInterval(workTimer)
})
watch(
() => navTabs.state.tabFullScreen,
() => {
echartsResize()
}
)
</script>
<style scoped lang="scss">
.welcome {
background: #e1eaf9;
border-radius: 6px;
display: flex;
align-items: center;
padding: 15px 20px !important;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
.welcome-img {
height: 100px;
margin-right: 10px;
user-select: none;
}
.welcome-title {
font-size: 1.5rem;
line-height: 30px;
color: var(--ba-color-primary-light);
}
.welcome-note {
padding-top: 6px;
font-size: 15px;
color: var(--el-text-color-primary);
}
}
.working {
height: 130px;
display: flex;
justify-content: center;
flex-wrap: wrap;
height: 100%;
position: relative;
&:hover {
.working-coffee {
-webkit-transform: translateY(-4px) scale(1.02);
-moz-transform: translateY(-4px) scale(1.02);
-ms-transform: translateY(-4px) scale(1.02);
-o-transform: translateY(-4px) scale(1.02);
transform: translateY(-4px) scale(1.02);
z-index: 999;
}
}
.working-coffee {
transition: all 0.3s ease;
width: 80px;
}
.working-text {
display: block;
width: 100%;
font-size: 15px;
text-align: center;
color: var(--el-text-color-primary);
}
.working-opt {
position: absolute;
top: -40px;
right: 10px;
background-color: rgba($color: #000000, $alpha: 0.3);
padding: 10px 20px;
border-radius: 20px;
color: var(--ba-bg-color-overlay);
transition: all 0.3s ease;
cursor: pointer;
opacity: 0;
z-index: 999;
&:active {
background-color: rgba($color: #000000, $alpha: 0.6);
}
}
&:hover {
.working-opt {
opacity: 1;
top: 0;
}
.working-done {
opacity: 1;
top: 50px;
}
}
}
.small-panel-box {
margin-top: 20px;
}
.small-panel {
background-color: #e9edf2;
border-radius: var(--el-border-radius-base);
padding: 25px;
margin-bottom: 20px;
.small-panel-title {
color: #92969a;
font-size: 15px;
}
.small-panel-content {
display: flex;
align-items: flex-end;
margin-top: 20px;
color: #2c3f5d;
.content-left {
display: flex;
align-items: center;
font-size: 24px;
.icon {
margin-right: 10px;
}
}
.content-right {
font-size: 18px;
margin-left: auto;
}
.color-success {
color: var(--el-color-success);
}
.color-warning {
color: var(--el-color-warning);
}
.color-danger {
color: var(--el-color-danger);
}
.color-info {
color: var(--el-text-color-secondary);
}
}
}
.growth-chart {
margin-bottom: 20px;
}
.user-growth-chart,
.file-growth-chart {
height: 260px;
}
.new-user-growth {
height: 300px;
}
.user-source-chart,
.user-surname-chart {
height: 400px;
}
.new-user-item {
display: flex;
align-items: center;
padding: 20px;
margin: 10px 15px;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
background-color: var(--ba-bg-color-overlay);
.new-user-avatar {
height: 48px;
width: 48px;
border-radius: 50%;
}
.new-user-base {
margin-left: 10px;
color: #2c3f5d;
.new-user-name {
font-size: 15px;
}
.new-user-time {
font-size: 13px;
}
}
.new-user-arrow {
margin-left: auto;
}
}
.new-user-card :deep(.el-card__body) {
padding: 0;
}
@media screen and (max-width: 425px) {
.welcome-img {
display: none;
}
}
@media screen and (max-width: 1200px) {
.lg-mb-20 {
margin-bottom: 20px;
}
}
html.dark {
.welcome {
background-color: var(--ba-bg-color-overlay);
}
.working-opt {
color: var(--el-text-color-primary);
background-color: var(--ba-border-color);
}
.small-panel {
background-color: var(--ba-bg-color-overlay);
.small-panel-content {
color: var(--el-text-color-regular);
}
}
.new-user-item {
.new-user-base {
color: var(--el-text-color-regular);
}
}
}
</style>

View File

@@ -0,0 +1,293 @@
<template>
<div>
<div class="switch-language">
<el-dropdown size="large" :hide-timeout="50" placement="bottom-end" :hide-on-click="true">
<Icon name="fa fa-globe" color="var(--el-text-color-secondary)" size="28" />
<template #dropdown>
<el-dropdown-menu class="chang-lang">
<el-dropdown-item v-for="item in config.lang.langArray" :key="item.name" @click="editDefaultLang(item.name)">
{{ item.value }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div @contextmenu.stop="" id="bubble" class="bubble">
<canvas id="bubble-canvas" class="bubble-canvas"></canvas>
</div>
<div class="login">
<div class="login-box">
<div class="head">
<img src="~assets/login-header.png" alt="" />
</div>
<div class="form">
<img class="profile-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
<div class="content">
<el-form @keyup.enter="onSubmitPre()" ref="formRef" :rules="rules" size="large" :model="form">
<el-form-item prop="username">
<el-input
ref="usernameRef"
type="text"
clearable
v-model="form.username"
:placeholder="t('login.Please enter an account')"
>
<template #prefix>
<Icon name="fa fa-user" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
ref="passwordRef"
v-model="form.password"
type="password"
:placeholder="t('login.Please input a password')"
show-password
>
<template #prefix>
<Icon name="fa fa-unlock-alt" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-checkbox v-model="form.keep" :label="t('login.Hold session')" size="default"></el-checkbox>
<el-form-item>
<el-button
:loading="state.submitLoading"
class="submit-button"
round
type="primary"
size="large"
@click="onSubmitPre()"
>
{{ t('login.Sign in') }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, reactive, nextTick, useTemplateRef } from 'vue'
import * as pageBubble from '/@/utils/pageBubble'
import { useI18n } from 'vue-i18n'
import { editDefaultLang } from '/@/lang/index'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import { login } from '/@/api/backend'
import { uuid } from '/@/utils/random'
import { buildValidatorData } from '/@/utils/validate'
import router from '/@/router'
import clickCaptcha from '/@/components/clickCaptcha'
import toggleDark from '/@/utils/useDark'
import { fullUrl } from '/@/utils/common'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
let timer: number
const config = useConfig()
const adminInfo = useAdminInfo()
toggleDark(config.layout.isDark)
const formRef = useTemplateRef('formRef')
const usernameRef = useTemplateRef('usernameRef')
const passwordRef = useTemplateRef('passwordRef')
const state = reactive({
showCaptcha: false,
submitLoading: false,
})
const form = reactive({
username: '',
password: '',
keep: false,
captchaId: uuid(),
captchaInfo: '',
})
const { t } = useI18n()
// 表单验证规则
const rules = reactive({
username: [buildValidatorData({ name: 'required', message: t('login.Please enter an account') }), buildValidatorData({ name: 'account' })],
password: [buildValidatorData({ name: 'required', message: t('login.Please input a password') }), buildValidatorData({ name: 'password' })],
})
const focusInput = () => {
if (form.username === '') {
usernameRef.value?.focus()
} else if (form.password === '') {
passwordRef.value?.focus()
}
}
onMounted(() => {
timer = window.setTimeout(() => {
pageBubble.init()
}, 1000)
login('get')
.then((res) => {
state.showCaptcha = res.data.captcha
nextTick(() => focusInput())
})
.catch((err) => {
console.log(err)
})
})
onBeforeUnmount(() => {
clearTimeout(timer)
pageBubble.removeListeners()
})
const onSubmitPre = () => {
formRef.value?.validate((valid) => {
if (valid) {
if (state.showCaptcha) {
clickCaptcha(form.captchaId, (captchaInfo: string) => onSubmit(captchaInfo))
} else {
onSubmit()
}
}
})
}
const onSubmit = (captchaInfo = '') => {
state.submitLoading = true
form.captchaInfo = captchaInfo
login('post', form)
.then((res) => {
const userInfo = res?.data?.userInfo
if (!userInfo?.token) {
return
}
adminInfo.dataFill(userInfo, false)
nextTick(() => {
router.push({ path: adminBaseRoutePath })
})
})
.catch(() => {
// 接口错误由 axios 拦截器处理
})
.finally(() => {
state.submitLoading = false
})
}
</script>
<style scoped lang="scss">
.switch-language {
position: fixed;
top: 20px;
right: 20px;
z-index: 1;
}
.bubble {
overflow: hidden;
background: url(/@/assets/bg.jpg) repeat;
}
.form-item-icon {
height: auto;
}
.login {
position: absolute;
top: 0;
display: flex;
width: 100vw;
height: 100vh;
align-items: center;
justify-content: center;
.login-box {
overflow: hidden;
width: 430px;
padding: 0;
background: var(--ba-bg-color-overlay);
margin-bottom: 80px;
}
.head {
background: #ccccff;
img {
display: block;
width: 430px;
margin: 0 auto;
user-select: none;
}
}
.form {
position: relative;
.profile-avatar {
display: block;
position: absolute;
height: 100px;
width: 100px;
border-radius: 50%;
border: 4px solid var(--ba-bg-color-overlay);
top: -50px;
right: calc(50% - 50px);
z-index: 2;
user-select: none;
}
.content {
padding: 100px 40px 40px 40px;
}
.submit-button {
width: 100%;
letter-spacing: 2px;
font-weight: 300;
margin-top: 15px;
--el-button-bg-color: var(--el-color-primary);
}
}
}
@media screen and (max-width: 720px) {
.login {
display: flex;
align-items: center;
justify-content: center;
.login-box {
width: 340px;
margin-top: 0;
}
}
}
.chang-lang :deep(.el-dropdown-menu__item) {
justify-content: center;
}
.content :deep(.el-input__prefix) {
display: flex;
align-items: center;
}
// 暗黑样式
@at-root .dark {
.bubble {
background: url(/@/assets/bg-dark.jpg) repeat;
}
.login {
.login-box {
background: #161b22;
}
.head {
img {
filter: brightness(61%);
}
}
.form {
.submit-button {
--el-button-bg-color: var(--el-color-primary-light-5);
--el-button-border-color: rgba(240, 252, 241, 0.1);
}
}
}
}
@media screen and (max-height: 800px) {
.login .login-box {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.player.quickSearchFields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'mall/player',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/mall.Player/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.player.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.player.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.player.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('mall.player.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('mall.player.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,109 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('mall.player.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('mall.player.username') })"
/>
<FormItem
:label="t('mall.player.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('mall.player.password') })"
/>
<FormItem
:label="t('mall.player.score')"
type="number"
v-model="baTable.form.items!.score"
prop="score"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.player.score') })"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('mall.player.username') })],
password: [
{
validator: (_rule: unknown, val: string, callback: (error?: Error) => void) => {
if (baTable.form.operate === 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('mall.player.password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
score: [buildValidatorData({ name: 'number', title: t('mall.player.score') })],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,126 @@
<template>
<div>
<el-dialog v-model="state.dialog.buy" class="buy-dialog" :title="t('module.Confirm order info')" top="20vh" width="28%">
<div v-loading="state.loading.buy">
<el-alert :title="t('module.Module installation warning')" type="error" :center="true" :closable="false" />
<div v-if="!isEmpty(state.buy.info)" class="order-info">
<div class="order-info-item">{{ t('module.Order title') }}{{ state.buy.info.title }}</div>
<div class="order-info-item">{{ t('module.Order No') }}{{ state.buy.info.sn }}</div>
<div class="order-info-item">{{ t('module.Purchase user') }}{{ specificUserName(baAccount) }}</div>
<div class="order-info-item">
{{ t('module.Order price') }}
<span v-if="!state.buy.info.purchased" class="order-price">
{{ currency(state.buy.info.amount, state.buy.info.pay.money ? 1 : 0) }}
</span>
<span v-else class="order-price">{{ t('module.Purchased, can be installed directly') }}</span>
</div>
<div class="order-footer">
<div class="order-agreement">
<el-checkbox v-model="state.buy.agreement" size="small" label="" />
<span>
{{ t('module.Understand and agree') }}
<a
href="https://doc.buildadmin.com/guide/other/appendix/templateAgreement.html"
target="_blank"
rel="noopener noreferrer"
>
{{ t('module.Module purchase and use agreement') }}
</a>
</span>
</div>
<div class="order-info-buttons">
<template v-if="!state.buy.info.purchased">
<el-button
v-if="state.buy.info.pay.score"
:loading="state.loading.common"
@click="onPay('score')"
v-blur
type="warning"
>
{{ t('module.Point payment') }}
</el-button>
<template v-if="state.buy.info.pay.money">
<el-button :loading="state.loading.common" @click="onPay('balance')" v-blur type="warning">
{{ t('module.Balance payment') }}
</el-button>
<el-button :loading="state.loading.common" @click="onPay('wx')" v-blur type="success">
{{ t('module.Wechat payment') }}
</el-button>
<el-button :loading="state.loading.common" @click="onPay('zfb')" v-blur type="primary">
{{ t('module.Alipay payment') }}
</el-button>
</template>
</template>
<el-button
v-else
:loading="state.loading.common"
@click="onPreInstallModule(state.buy.info.uid, state.buy.info.id, true)"
v-blur
type="warning"
>
{{ t('module.Install now') }}
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { isEmpty } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { currency, onPay, onPreInstallModule, specificUserName } from '../index'
import { state } from '../store'
import { useBaAccount } from '/@/stores/baAccount'
const { t } = useI18n()
const baAccount = useBaAccount()
</script>
<style scoped lang="scss">
.order-info {
padding: 10px 0;
.order-info-item {
padding-top: 6px;
}
.order-footer {
padding-top: 20px;
.order-agreement {
display: flex;
align-items: center;
font-size: 12px;
span {
padding-left: 4px;
}
a {
text-decoration: none;
color: var(--el-color-primary);
}
}
.order-info-buttons {
padding-top: 15px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
@media screen and (max-width: 1440px) {
:deep(.buy-dialog) {
--el-dialog-width: 26% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.buy-dialog) {
--el-dialog-width: 32% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.buy-dialog) {
--el-dialog-width: 70% !important;
}
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div>
<el-dialog
:close-on-press-escape="state.common.quickClose"
:title="state.common.dialogTitle"
:close-on-click-modal="state.common.quickClose"
v-model="state.dialog.common"
class="common-dialog"
>
<el-scrollbar :height="500">
<!-- 公共dialog形式的loading -->
<div
v-if="state.common.type == 'loading'"
v-loading="true"
:element-loading-text="state.common.loadingTitle ? $t('module.stateTitle ' + state.common.loadingTitle) : ''"
:key="state.common.loadingComponentKey"
class="common-loading"
></div>
<!-- 选择安装版本 -->
<SelectVersion v-if="state.common.type == 'selectVersion'" />
<!-- 安装冲突 -->
<InstallConflict v-if="state.common.type == 'installConflict'" />
<!-- 禁用冲突 -->
<ConfirmFileConflict v-if="state.common.type == 'disableConfirmConflict'" />
<!-- 安装/禁用结束 -->
<CommonDone v-if="state.common.type == 'done'" />
<!-- 上传安装 -->
<UploadInstall v-if="state.common.type == 'uploadInstall'" />
</el-scrollbar>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import CommonDone from './commonDone.vue'
import SelectVersion from './commonSelectVersion.vue'
import ConfirmFileConflict from './confirmFileConflict.vue'
import InstallConflict from './installConflict.vue'
import UploadInstall from './uploadInstall.vue'
</script>
<style scoped lang="scss">
:deep(.common-dialog) .el-dialog__body {
padding: 10px 20px;
}
.common-dialog {
height: 500px;
}
.common-loading {
height: 400px;
}
@media screen and (max-width: 1440px) {
:deep(.common-dialog) {
--el-dialog-width: 60% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.common-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.common-dialog) {
--el-dialog-width: 92% !important;
}
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="install-done">
<div class="install-done-title">
<span v-if="state.common.moduleState == moduleInstallState.INSTALLED">
{{ t('module.Congratulations, module installation is complete') }}
</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DISABLE">{{ t('module.Module is disabled') }}</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DEPENDENT_WAIT_INSTALL">
{{ t('module.Congratulations, the code of the module is ready') }}
</span>
<span v-else>{{ t('module.Unknown state') }}</span>
</div>
<div class="install-tis-box">
<div v-if="state.common.dependInstallState != 'none'" class="depend-box">
<div class="depend-loading" v-if="state.common.dependInstallState == 'executing'" v-loading="true"></div>
<div class="depend-tis">
<div v-if="state.common.dependInstallState == 'executing'">
<span class="color-red">{{ t('module.Do not refresh the page!') }}</span>
<span v-if="state.common.moduleState == moduleInstallState.DISABLE">
{{ t('module.New adjustment of dependency detected') }}
</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DEPENDENT_WAIT_INSTALL">
{{ t('module.This module adds new dependencies') }}
</span>
<span></span>
<span>
{{ t('module.The built-in terminal of the system is automatically installing these dependencies, please wait~') }}
</span>
<span class="span-a" @click="showTerminal">{{ t('module.View progress') }}</span>
</div>
<div v-if="state.common.dependInstallState == 'success'" class="color-green">
{{ t('module.Dependency installation completed~') }}
</div>
<div v-if="state.common.dependInstallState == 'fail'" class="exec-fail color-red">
{{ t('module.Dependency installation fail 1') }}
<span class="span-a" @click="showTerminal">{{ t('module.Dependency installation fail 2') }}</span>
{{ t('module.Dependency installation fail 3') }}
<el-link target="_blank" type="primary" href="https://doc.buildadmin.com/guide/install/manualOperation.html">
{{ t('module.Dependency installation fail 4') }}
</el-link>
</div>
</div>
</div>
<div v-else-if="state.common.moduleState == moduleInstallState.INSTALLED" class="depend-tis">
{{ t('module.This module does not add new dependencies') }}
</div>
<div v-else>{{ t('module.There is no adjustment for system dependency') }}</div>
</div>
<div v-if="state.common.dependInstallState == 'fail'" class="install-tis-box text-align-center">
<div class="install-tis">
{{ t('module.Dependency installation fail 5') }}
<span class="span-a" @click="onConfirmDepend">
{{ t('module.Dependency installation fail 6') }}
</span>
{{ t('module.Dependency installation fail 7') }}
<span class="dependency-installation-fail-tips">
{{ t('module.dependency-installation-fail-tips') }}
</span>
</div>
</div>
<div class="install-tis-box">
<div class="install-tis">
{{ t('module.please') }}
{{ state.common.moduleState == moduleInstallState.DISABLE ? '' : t('module.After installation 1') }}
{{ t('module.Manually clean up the system and browser cache') }}
</div>
</div>
<div class="install-tis-box">
<div class="install-form">
<FormItem
:label="
(state.common.moduleState == moduleInstallState.DISABLE ? '' : t('module.After installation 2')) +
t('module.Automatically execute reissue command?')
"
v-model="form.rebuild"
type="radio"
:input-attr="{
border: true,
content: { 0: t('module.no'), 1: t('module.yes') },
}"
/>
</div>
</div>
<div class="install-tis-box" v-if="hotUpdateState.dirtyFile && state.common.moduleState != moduleInstallState.DISABLE">
<div class="install-form">
<el-form-item :label="t('module.After installation 2') + t('module.Restart Vite hot server')">
<BaInput
v-model="form.reloadHotServer"
type="radio"
:attr="{
class: 'hot-server-input',
border: true,
content: {
0: t('vite.Later') + t('module.Manual restart'),
1: t('module.Restart Now'),
},
}"
/>
<el-popover :width="360" placement="top">
<div>
<div class="el-popover__title">{{ t('vite.Reload hot server title') }}</div>
<div class="reload-hot-server-content">
<p>
<span>{{ t('vite.Reload hot server tips 1') }}</span>
<span>{{ t(`vite.Close type ${hotUpdateState.closeType}`) }}</span>
<span>{{ t('vite.Reload hot server tips 2') }}</span>
</p>
<p>{{ t('vite.Reload hot server tips 3') }}</p>
<p>{{ t('module.Restart Vite hot server tips') }}</p>
</div>
</div>
<template #reference>
<div class="block-help hot-server-tips">{{ t('module.detailed information') }}</div>
</template>
</el-popover>
</el-form-item>
</div>
</div>
<div class="install-done-button-box">
<el-button
v-blur
:disabled="state.common.dependInstallState != 'executing' || state.common.moduleState == moduleInstallState.INSTALLED ? false : true"
size="large"
class="install-done-button"
type="primary"
:loading="state.loading.common"
@click="onSubmitInstallDone"
>
{{ state.common.moduleState == moduleInstallState.DISABLE ? t('Complete') : t('module.End of installation') }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { onRefreshTableData } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import { dependentInstallComplete } from '/@/api/backend/module'
import BaInput from '/@/components/baInput/index.vue'
import FormItem from '/@/components/formItem/index.vue'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import { useTerminal } from '/@/stores/terminal'
import { hotUpdateState, reloadServer } from '/@/utils/vite'
const { t } = useI18n()
const terminal = useTerminal()
const form = reactive({
rebuild: 0,
reloadHotServer: 0,
})
const showTerminal = () => {
terminal.toggle(true)
}
const onSubmitInstallDone = () => {
state.dialog.common = false
if (form.rebuild == 1) {
terminal.toggle(true)
terminal.addTaskPM('web-build', false, '', (res: number) => {
if (res == taskStatus.Success) {
terminal.toggle(false)
if (form.reloadHotServer == 1 && state.common.moduleState != moduleInstallState.DISABLE) {
reloadServer('modules')
}
}
})
} else if (form.reloadHotServer == 1 && state.common.moduleState != moduleInstallState.DISABLE) {
reloadServer('modules')
}
}
const onConfirmDepend = () => {
ElMessageBox.confirm(t('module.Is the command that failed on the WEB terminal executed manually or in other ways successfully?'), t('Reminder'), {
confirmButtonText: t('module.yes'),
cancelButtonText: t('Cancel'),
type: 'warning',
}).then(() => {
state.loading.common = true
dependentInstallComplete(state.common.uid).then(() => {
onRefreshTableData()
state.loading.common = false
state.common.dependInstallState = 'success'
})
})
}
</script>
<style scoped lang="scss">
.install-done-title {
font-size: var(--el-font-size-extra-large);
color: var(--el-color-success);
text-align: center;
}
.text-align-center {
text-align: center;
}
.install-tis-box {
padding: 20px;
margin: 20px auto;
width: 70%;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
display: flex;
align-items: center;
justify-content: center;
.dependency-installation-fail-tips {
display: block;
font-size: var(--el-font-size-extra-small);
text-align: center;
padding-top: 5px;
color: var(--el-text-color-regular);
}
}
.depend-box {
display: flex;
align-items: center;
justify-content: center;
}
.install-tis {
color: var(--el-color-warning);
}
.depend-loading {
width: 30px;
height: 30px;
margin-right: 36px;
}
.span-a {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-5);
}
}
.install-form :deep(.ba-input-item-radio) {
margin-bottom: 0;
}
.exec-fail {
display: flex;
}
.color-red {
color: var(--el-color-danger);
}
.color-green {
color: var(--el-color-success);
}
.install-done-button-box {
display: flex;
align-items: center;
justify-content: center;
.install-done-button {
width: 120px;
}
}
.reload-hot-server-content {
font-size: var(--el-font-size-small);
p {
margin-bottom: 6px;
}
}
.hot-server-input {
width: 100%;
}
.hot-server-tips {
width: auto;
cursor: pointer;
}
@media screen and (max-width: 1600px) {
:deep(.install-tis-box) {
width: 76%;
}
}
@media screen and (max-width: 1280px) {
:deep(.install-tis-box) {
width: 80%;
}
}
@media screen and (max-width: 900px) {
:deep(.install-tis-box) {
width: 96%;
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div>
<el-table :data="state.common.versions" class="w100" stripe>
<el-table-column property="version" align="center" :label="t('module.Version')" />
<el-table-column property="short_describe" :show-overflow-tooltip="true" align="center" :label="t('module.Description')" />
<el-table-column property="available_system_version_text" align="center" :label="t('module.Available system version')">
<template #default="scope">
<div v-if="scope.row.available_system_version && state.sysVersion">
<div class="available-system-version">
<Icon
v-if="compareVersion(scope.row.available_system_version)"
name="el-icon-CircleCheckFilled"
color="var(--el-color-success)"
size="14"
/>
<Icon v-else name="el-icon-CircleCloseFilled" size="14" color="var(--el-color-danger)" />
<div class="available-system-version-text">{{ scope.row.available_system_version_text }}</div>
</div>
</div>
<div v-else>-</div>
</template>
</el-table-column>
<el-table-column property="createtime_text" align="center" :label="t('Create time')" />
<el-table-column :label="t('module.Install')" align="center" :min-width="140">
<template #default="scope">
<div v-if="scope.row.downloadable">
<div v-if="isLocalModuleVersion(scope.row.version)" class="renewal-text">{{ t('module.Current installed version') }}</div>
<div v-else-if="!compareVersion(scope.row.available_system_version)">{{ t('module.Insufficient system version') }}</div>
<div v-else>
<el-button type="primary" @click="onInstall(scope.row.uid, scope.row.order_id, scope.row.version)">
{{ t('module.Click to install') }}
</el-button>
</div>
</div>
<el-tooltip
v-else
effect="dark"
:content="
t('module.Order expiration time', {
expiration_time: timeFormat(scope.row.order_expiration_time),
create_time: timeFormat(scope.row.createtime),
})
"
placement="top"
>
<div class="renewal">
<div class="renewal-text">{{ t('module.Versions released beyond the authorization period') }}</div>
<el-button @click="onBuy(true)" type="danger">{{ t('module.Renewal') }}</el-button>
</div>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { memoize } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { execInstall, onBuy, showCommonLoading } from '../index'
import { state } from '../store'
import { timeFormat } from '/@/utils/common'
const { t } = useI18n()
const formatSysVersion = memoize((sysVersion: string) => {
// 去掉 sysVersion 开头的 v
sysVersion = sysVersion.replace(/^v/, '')
// 以 . 分割,不足两位的补 0
sysVersion = sysVersion
.split('.')
.map((item) => {
return item.padStart(2, '0')
})
.join('')
return parseInt(sysVersion)
})
const isLocalModuleVersion = (version: string) => {
const localModule = state.installedModule.find((item) => {
return item.uid == state.common.uid
})
if (localModule) {
version = version.replace(/^v/, '')
localModule.version = localModule.version.replace(/^v/, '')
if (version == localModule.version) {
return true
}
}
return false
}
const compareVersion = memoize((version: string): boolean => {
const sysVersion = formatSysVersion(state.sysVersion)
return sysVersion > parseInt(version)
})
const onInstall = (uid: string, id: number, version: string) => {
state.dialog.common = true
state.common.dialogTitle = t('module.Install')
showCommonLoading('download')
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
execInstall(uid, id, version, state.common.update)
}
</script>
<style scoped lang="scss">
.renewal {
display: flex;
align-items: center;
justify-content: center;
.renewal-text {
font-size: 12px;
margin-right: 6px;
}
}
.available-system-version {
display: flex;
align-items: center;
justify-content: center;
.available-system-version-text {
margin-left: 4px;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div>
<div class="confirm-file-conflict">
<template v-if="state.common.disableConflictFile.length">
<div class="conflict-title">{{ $t('module.File conflict') }}</div>
<el-alert :closable="false" :center="true" :title="$t('module.Update warning')" class="alert-warning" type="warning"></el-alert>
<el-table :data="state.common.disableConflictFile" stripe border :style="{ width: '100%', marginBottom: '20px' }">
<el-table-column prop="file" :label="$t('module.Conflict file')" />
</el-table>
</template>
<template v-if="state.common.disableDependConflict.length > 0">
<div class="conflict-title">{{ $t('module.The module declares the added dependencies') }}</div>
<el-table :data="state.common.disableDependConflict" stripe border style="width: 100%">
<el-table-column prop="env" :label="$t('module.environment')">
<template #default="scope">
<span v-if="scope.row.env">{{ $t('module.env ' + scope.row.env) }}</span>
</template>
</el-table-column>
<el-table-column prop="dependTitle" :label="$t('module.Dependencies')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('Delete')" value="delete"></el-option>
<el-option :label="$t('module.retain')" value="retain"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
</div>
<div class="center-buttons">
<el-button
v-blur
class="center-button"
:loading="state.loading.common"
:disabled="state.loading.common"
size="large"
type="primary"
@click="onDisable(true)"
>
{{ $t('module.Confirm to disable the module') }}
</el-button>
<el-button v-blur class="center-button" size="large" @click="cancelDisable()"> {{ $t('Cancel') }} </el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import { onDisable } from '../index'
const cancelDisable = () => {
state.dialog.common = false
state.goodsInfo.enable = true
}
</script>
<style scoped lang="scss">
.confirm-file-conflict {
min-height: 400px;
}
.conflict-alert {
width: 500px;
margin: 0 auto;
}
.alert-warning {
margin: 20px auto;
width: 500px;
}
.depend-conflict-tips {
text-align: center;
}
.text-bold {
font-weight: bold;
}
.conflict-title {
font-size: var(--el-font-size-large);
text-align: center;
margin-bottom: 20px;
}
.center-buttons {
display: flex;
justify-content: center;
margin: 20px auto;
}
.center-button {
width: 120px;
}
</style>

View File

@@ -0,0 +1,610 @@
<template>
<div>
<el-dialog v-model="state.dialog.goodsInfo" class="goods-info-dialog" :title="t('module.detailed information')" width="60%">
<el-scrollbar v-loading="state.loading.goodsInfo" :key="state.goodsInfo.uid" :height="500">
<div class="goods-info">
<div class="goods-images">
<el-carousel height="300" v-if="state.goodsInfo.images" indicator-position="outside">
<el-carousel-item class="goods-image-item" v-for="(image, idx) in state.goodsInfo.images" :key="idx">
<el-image fit="contain" :preview-src-list="state.goodsInfo.images" :preview-teleported="true" :src="image"></el-image>
</el-carousel-item>
</el-carousel>
</div>
<div class="goods-basic">
<h4 class="goods-basic-title">{{ state.goodsInfo.title }}</h4>
<div class="goods-tag">
<el-tag v-for="(tag, idx) in state.goodsInfo.tags" :key="idx" :type="tag.type ? tag.type : 'primary'">
{{ tag.name }}
</el-tag>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Price') }}</div>
<div class="basic-item-price">
{{
typeof state.goodsInfo.currency_select != 'undefined'
? currency(state.goodsInfo.present_price, state.goodsInfo.currency_select)
: '-'
}}
</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Last updated') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.updatetime ? timeFormat(state.goodsInfo.updatetime) : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Published on') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.createtime ? timeFormat(state.goodsInfo.createtime) : '-' }}</div>
</div>
<div v-if="!installButtonState.stateSwitch.includes(state.goodsInfo.state)" class="basic-item">
<div class="basic-item-title">{{ t('module.amount of downloads') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.downloads ? state.goodsInfo.downloads : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Module classification') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.category ? state.goodsInfo.category.name : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Module documentation') }}</div>
<div class="basic-item-content">
<el-link
type="primary"
class="basic-item-link"
v-if="state.goodsInfo.docs"
target="_blank"
:href="`https://doc.buildadmin.com/md/${state.goodsInfo.docs.name ? state.goodsInfo.docs.name : state.goodsInfo.docs.id}`"
rel="noopener noreferrer"
>
{{ t('module.Click to access') }}
</el-link>
<span v-else>-</span>
</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Developer Homepage') }}</div>
<div class="basic-item-content">
<el-link
type="primary"
class="basic-item-link"
v-if="state.goodsInfo.author_url"
target="_blank"
:href="state.goodsInfo.author_url"
rel="noopener noreferrer"
>
{{ t('module.Click to access') }}
</el-link>
<span v-else>-</span>
</div>
</div>
<div v-if="installButtonState.stateSwitch.includes(state.goodsInfo.state)" class="basic-item">
<div class="basic-item-title">{{ t('module.Module status') }}</div>
<div class="basic-item-content">
<el-switch
@change="onChangeState"
:loading="state.loading.common"
:disabled="state.loading.common"
v-model="state.goodsInfo.enable"
/>
</div>
</div>
<div class="basic-buttons">
<el-dropdown
v-if="
(!state.goodsInfo.purchased || installButtonState.InstallNow.includes(state.goodsInfo.state)) &&
state.goodsInfo.demo &&
state.goodsInfo.demo.length > 0
"
>
<el-button class="basic-button-demo" type="primary">
<span class="basic-button-dropdown-span">{{ t('module.View demo') }}</span>
<Icon color="#ffffff" size="16" name="el-icon-ArrowDown" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(demo, idx) in state.goodsInfo.demo"
:key="idx"
@click="openDemo(demo.link, demo.image ? false : true)"
class="basic-button-dropdown-item"
>
<el-popover
placement="right"
:title="t('module.Code scanning Preview')"
trigger="hover"
:disabled="demo.image ? false : true"
:width="174"
>
<template #reference>
<div class="demo-item-title">
<Icon :name="demo.icon" size="14" color="var(--el-color-primary)" />{{ demo.title }}
</div>
</template>
<div class="demo-image">
<img :src="demo.image" alt="" />
</div>
</el-popover>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="
!state.goodsInfo.purchased &&
installButtonState.buy.includes(state.goodsInfo.state) &&
state.goodsInfo.type == 'online'
"
@click="onBuy(false)"
v-blur
class="basic-button-item"
type="danger"
>
{{ t('module.Buy now') }}
</el-button>
<el-button
v-if="
(state.goodsInfo.state == moduleInstallState.UNINSTALLED && state.goodsInfo.purchased) ||
state.goodsInfo.state == moduleInstallState.WAIT_INSTALL
"
@click="
onPreInstallModule(
state.goodsInfo.uid,
state.goodsInfo.purchased,
state.goodsInfo.state == moduleInstallState.WAIT_INSTALL ? false : true
)
"
:loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.Install now') }}
</el-button>
<el-button
v-if="installButtonState.continueInstallation.includes(state.goodsInfo.state)"
@click="onPreInstallModule(state.goodsInfo.uid, state.goodsInfo.purchased, false)"
:loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.continue installation') }}
</el-button>
<el-button
v-if="installButtonState.alreadyInstalled.includes(state.goodsInfo.state)"
v-blur
:disabled="true"
class="basic-button-item"
>
{{ t('module.installed') }} v{{ state.goodsInfo.version }}
</el-button>
<el-button
v-if="state.goodsInfo.type == 'local' && !installButtonState.alreadyInstalled.includes(state.goodsInfo.state)"
v-blur
:disabled="true"
class="basic-button-item"
>
{{ t('module.Local module') }} v{{ state.goodsInfo.version }}
</el-button>
<el-button
v-if="state.goodsInfo.new_version && installButtonState.updateButton.includes(state.goodsInfo.state)"
@click="onUpdate(state.goodsInfo.uid, state.goodsInfo.purchased)"
v-loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.to update') }}
</el-button>
<el-button
v-if="installButtonState.stateSwitch.includes(state.goodsInfo.state)"
v-loading="state.loading.common"
@click="unInstall(state.goodsInfo.uid)"
v-blur
class="basic-button-item"
type="danger"
>
{{ t('module.uninstall') }}
</el-button>
</div>
</div>
<div v-if="!isEmpty(state.goodsInfo.developer)" class="goods-developer">
<div class="developer-header">
<el-avatar :size="60" :src="state.goodsInfo.developer.avatar" />
<div class="developer-name">
<h3 class="developer-nickname">{{ state.goodsInfo.developer.nickname }}</h3>
<div class="developer-group">
{{ state.goodsInfo.developer.group ? state.goodsInfo.developer.group : '-' }}
</div>
</div>
</div>
<div v-if="state.goodsInfo.qq" class="developer-contact">
<h4 class="developer-info-title">{{ t('module.Contact developer') }}</h4>
<div class="contact-item">
<a
rel="noopener noreferrer"
target="_blank"
:href="'http://wpa.qq.com/msgrd?v=3&uin=' + state.goodsInfo.qq + '&site=qq&menu=yes'"
>
<span>QQ{{ state.goodsInfo.qq }}</span>
</a>
</div>
</div>
<div class="developer-recommend">
<h4 class="developer-info-title">{{ t('module.Other works of developers') }}</h4>
<div v-if="state.goodsInfo.developer.goods.length > 0" class="recommend-goods">
<div
v-for="(goods_item, idx) in state.goodsInfo.developer.goods"
:key="idx"
@click="showInfo(goods_item.uid)"
class="recommend-goods-item"
>
<el-image fit="contain" class="recommend-goods-logo" :src="goods_item.logo"> </el-image>
<div class="recommend-goods-title">{{ goods_item.title }}</div>
</div>
</div>
<div v-else class="data-empty">{{ t('module.There are no more works') }}</div>
</div>
</div>
</div>
<div class="goods-detail ba-markdown" v-html="state.goodsInfo.detail_editor"></div>
<div class="goods-version">
<h1>{{ t('module.Update Log') }}</h1>
<div class="version-timeline" v-if="state.goodsInfo.version_log">
<el-timeline>
<el-timeline-item
v-for="(version, idx) in state.goodsInfo.version_log"
:key="idx"
:timestamp="timeFormat(version.createtime)"
placement="top"
:color="idx == 0 ? 'var(--el-color-success)' : ''"
>
<el-card class="version-card" shadow="hover">
<template #header>
<div class="version-card-header">
<h2>{{ version.title }}</h2>
<span class="version-short-describe">{{ version.short_describe }}</span>
</div>
</template>
<div
class="version-detail ba-markdown"
v-html="version.describe ? version.describe : t('module.No detailed update log')"
></div>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
<div v-else class="empty-update-log">{{ $t('module.No detailed update log') }}</div>
</div>
</el-scrollbar>
</el-dialog>
<Buy />
<Pay />
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { currency, onBuy, onDisable, onEnable, onPreInstallModule, onRefreshTableData, showInfo } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import Buy from './buy.vue'
import Pay from './pay.vue'
import { getInstallState, postUninstall } from '/@/api/backend/module'
import { useBaAccount } from '/@/stores/baAccount'
import { timeFormat } from '/@/utils/common'
const installButtonState = {
InstallNow: [moduleInstallState.UNINSTALLED, moduleInstallState.WAIT_INSTALL],
continueInstallation: [moduleInstallState.CONFLICT_PENDING, moduleInstallState.DEPENDENT_WAIT_INSTALL],
alreadyInstalled: [moduleInstallState.INSTALLED],
stateSwitch: [
moduleInstallState.INSTALLED,
moduleInstallState.CONFLICT_PENDING,
moduleInstallState.DEPENDENT_WAIT_INSTALL,
moduleInstallState.DISABLE,
],
updateButton: [moduleInstallState.WAIT_INSTALL, moduleInstallState.INSTALLED, moduleInstallState.DISABLE],
buy: [moduleInstallState.UNINSTALLED],
}
const { t } = useI18n()
const openDemo = (url: string, open: boolean) => {
if (!open || !url) return
window.open(url)
}
const onChangeState = () => {
if (state.goodsInfo.enable) {
onEnable(state.goodsInfo.uid)
} else {
state.common.disableParams = {
uid: state.goodsInfo.uid,
state: 0,
}
onDisable()
}
}
const unInstall = (uid: string) => {
state.loading.common = true
postUninstall(uid)
.then(() => {
onRefreshTableData()
state.dialog.goodsInfo = false
})
.finally(() => {
state.loading.common = false
})
}
const onUpdate = (uid: string, order: number) => {
// 无有效订单
if (!order) {
ElMessageBox.confirm(t('module.No module purchase order was found'), t('Reminder'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
onBuy(true)
})
.catch(() => {})
return
}
// 未登录
const baAccount = useBaAccount()
if (!baAccount.token) {
state.dialog.baAccount = true
return
}
state.loading.common = true
getInstallState(uid)
.then((res) => {
if (res.data.state == moduleInstallState.DISABLE) {
onPreInstallModule(uid, order, true, true)
} else {
ElMessageBox.confirm(t('module.You need to disable this module before updating Do you want to disable it now?'), t('Reminder'), {
confirmButtonText: t('module.Disable and update'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
state.common.disableParams = {
uid: uid,
state: 0,
update: 1,
}
onDisable()
})
.catch(() => {})
}
})
.finally(() => {
state.loading.common = false
})
}
</script>
<style scoped lang="scss">
:deep(.goods-info-dialog) .el-dialog__body {
padding: 0px 20px;
}
.demo-image,
.demo-image img {
width: 150px;
height: 150px;
}
.demo-item-title {
display: flex;
align-items: center;
.icon {
margin-right: 6px;
}
}
.goods-info {
display: flex;
position: relative;
.goods-images {
max-width: 41%;
width: 300px;
.goods-image-item {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.el-carousel__indicators) {
line-height: 10px;
.el-carousel__indicator {
padding: 0 var(--el-carousel-indicator-padding-horizontal);
}
}
}
.goods-basic {
position: relative;
.goods-basic-title {
padding-bottom: 20px;
}
flex: 1;
padding: 0 10px;
.basic-item {
display: flex;
align-items: center;
padding: 4px 0;
.basic-item-title {
font-size: var(--el-font-size-base);
color: var(--el-text-color-secondary);
width: 80px;
}
.basic-item-price {
font-size: 16px;
color: var(--el-color-danger);
}
.basic-item-content {
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
}
}
.basic-button-dropdown-span {
padding-right: 6px;
}
.basic-buttons {
padding-top: 6px;
}
.basic-button-demo {
margin-right: 10px;
}
}
.goods-developer {
width: 20%;
border-left: 1px solid var(--ba-bg-color);
padding: 10px;
position: absolute;
right: 0;
.developer-header {
display: flex;
align-items: center;
justify-content: center;
.developer-name {
padding-left: 10px;
flex: 1;
.developer-group {
padding-top: 5px;
font-size: var(--el-font-size-extra-small);
color: var(--el-text-color-secondary);
}
}
}
.developer-info-title {
color: var(--el-text-color-secondary);
padding-top: 15px;
line-height: 20px;
text-align: center;
}
.contact-item {
cursor: pointer;
padding-left: 10px;
line-height: 30px;
text-align: center;
a {
color: var(--el-color-primary);
text-decoration: none;
}
}
.recommend-goods-item {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
padding: 6px;
&:hover {
background-color: var(--ba-bg-color);
}
.recommend-goods-logo {
width: 42px;
border-radius: var(--el-border-radius-base);
}
.recommend-goods-title {
flex: 1;
margin-left: 6px;
font-size: var(--el-font-size-small);
display: block;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 15px;
height: 28px;
}
}
.developer-recommend {
.data-empty {
font-size: var(--el-font-size-extra-small);
color: var(--el-text-color-secondary);
text-align: center;
padding: 6px;
}
}
}
.el-carousel__item:nth-child(2n) {
background-color: #99a9bf;
}
.basic-item-link {
font-size: var(--el-font-size-small);
}
}
.basic-button-item {
--el-loading-spinner-size: 22px;
}
.goods-detail {
width: 80%;
}
.goods-version {
width: 80%;
h1 {
margin: 1.4em 0 0.8em;
font-weight: 700;
font-size: var(--el-font-size-large);
text-transform: uppercase;
color: var(--el-color-primary);
}
.version-timeline {
padding-left: 2px;
:deep(.el-card__body) {
padding: 10px 20px 20px 20px;
}
}
.version-card {
border: 1px solid var(--el-color-info-light-9);
}
.version-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.empty-update-log {
display: flex;
justify-content: center;
color: var(--el-color-info);
}
/* 商品详情弹窗-s */
@media screen and (max-width: 1440px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 65% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 92% !important;
}
}
/* 商品详情弹窗-e */
@media screen and (max-width: 865px) {
.goods-info .goods-developer {
display: none;
}
}
@media screen and (max-width: 540px) {
.goods-info {
flex-wrap: wrap;
.goods-images {
max-width: 100%;
width: 100%;
}
}
.goods-detail {
padding-top: 15px;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div>
<div class="install-conflict">
<template v-if="state.common.fileConflict.length > 0">
<div class="install-title">{{ $t('module.File conflict') }}</div>
<el-table :data="state.common.fileConflict" stripe border :style="{ width: '100%' }">
<el-table-column prop="newFile" :label="$t('module.new file')" />
<el-table-column prop="oldFile" :label="$t('module.Existing files')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('module.Backup and overwrite existing files')" value="cover"></el-option>
<el-option :label="$t('module.Discard new file')" value="discard"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="state.common.dependConflict.length > 0">
<div class="install-title">{{ $t('module.Dependency conflict') }}</div>
<el-table :data="state.common.dependConflict" stripe border style="width: 100%">
<el-table-column prop="env" :label="$t('module.environment')">
<template #default="scope">
<span v-if="scope.row.env">{{ $t('module.env ' + scope.row.env) }}</span>
</template>
</el-table-column>
<el-table-column prop="newDepend" :label="$t('module.New dependency')" />
<el-table-column prop="oldDepend" :label="$t('module.Existing dependencies')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('module.Overwrite existing dependencies')" value="cover"></el-option>
<el-option :label="$t('module.Do not use new dependencies')" value="discard"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
</div>
<el-button
v-blur
class="install-done-button"
:loading="state.loading.common"
:disabled="state.loading.common"
size="large"
type="primary"
@click="onSubmitConflictHandle"
>
{{ $t('Confirm') }}
</el-button>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import { execInstall } from '../index'
const onSubmitConflictHandle = () => {
state.loading.common = true
let fileConflict: anyObj = {},
dependConflict: anyObj = {}
for (const key in state.common.fileConflict) {
fileConflict[state.common.fileConflict[key].oldFile] = state.common.fileConflict[key]['solution']
}
for (const key in state.common.dependConflict) {
if (typeof dependConflict[state.common.dependConflict[key].env] == 'undefined') {
dependConflict[state.common.dependConflict[key].env] = {}
}
dependConflict[state.common.dependConflict[key].env][state.common.dependConflict[key].depend] = state.common.dependConflict[key]['solution']
}
execInstall(state.common.uid, 0, '', false, {
dependConflict: dependConflict,
fileConflict: fileConflict,
conflictHandle: true,
})
}
</script>
<style scoped lang="scss">
.install-conflict {
min-height: 400px;
}
.install-title {
font-size: var(--el-font-size-large);
text-align: center;
padding: 20px;
}
.install-done-button {
display: block;
margin: 20px auto;
width: 120px;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div>
<el-dialog
v-model="state.dialog.pay"
:close-on-press-escape="false"
:close-on-click-modal="false"
:destroy-on-close="true"
class="pay-dialog"
top="20vh"
width="680px"
>
<div>
<div class="header-box">
<img
class="pay-logo"
:src="'https://buildadmin.com/static/images/' + (state.common.payType == 'wx' ? 'wechat-pay.png' : 'alipay.png')"
alt=""
/>
</div>
<div class="pay-box">
<div class="left">
<div class="order-info">
<div class="order-info-items">{{ t('module.Order title') }}{{ state.payInfo.info.title }}</div>
<div class="order-info-items">{{ t('module.Order No') }}{{ state.payInfo.info.sn }}</div>
<div class="order-info-items">{{ t('module.Purchase user') }}{{ specificUserName(baAccount) }}</div>
<div class="order-info-items">
<span>{{ t('module.Order price') }}</span>
<span class="rmb-symbol">
<span class="amount">{{ state.payInfo.info.amount }}</span>
</span>
</div>
</div>
<div class="pay_qr">
<QrcodeVue v-if="state.common.payType == 'wx'" :value="state.payInfo.pay.code_url" :size="220" :margin="0" level="H" />
<iframe
v-if="state.common.payType == 'zfb'"
:srcdoc="state.payInfo.pay.code_url"
frameborder="no"
border="0"
marginwidth="0"
marginheight="0"
scrolling="no"
width="220"
height="220"
style="overflow: hidden"
>
</iframe>
<div v-if="state.payInfo.pay.status == 'success'" class="pay-success">
<Icon name="fa fa-check" color="var(--el-color-success)" size="30" />
</div>
</div>
<el-alert class="qr-tips" :closable="false" type="success" center>
<div class="qr-tips-content">
<Icon color="var(--el-color-success)" :name="state.common.payType == 'wx' ? 'fa fa-wechat' : 'fa fa-buysellads'" />
<span v-if="state.common.payType == 'wx'">{{ t('module.Use WeChat to scan QR code for payment') }}</span>
<span v-if="state.common.payType == 'zfb'">{{ t('module.Use Alipay to scan QR code for payment') }}</span>
</div>
</el-alert>
</div>
<div class="right">
<img
class="pay-logo"
:src="'https://buildadmin.com/static/images/screenshot-' + (state.common.payType == 'wx' ? 'wechat.png' : 'alipay.png')"
alt=""
/>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import QrcodeVue from 'qrcode.vue'
import { useI18n } from 'vue-i18n'
import { specificUserName } from '../index'
import { state } from '../store'
import { useBaAccount } from '/@/stores/baAccount'
const { t } = useI18n()
const baAccount = useBaAccount()
</script>
<style scoped lang="scss">
:deep(.pay-dialog) .el-dialog__body {
padding: var(--el-dialog-padding-primary);
padding-top: 0;
}
.header-box {
.pay-logo {
height: 30px;
user-select: none;
}
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.pay-box {
display: flex;
.right {
margin-left: auto;
}
}
.order-info {
padding: 15px 0;
.order-info-items {
line-height: 24px;
.rmb-symbol {
color: var(--el-color-danger);
font-size: 13px;
}
.amount {
color: var(--el-color-danger);
font-size: 16px;
}
}
}
.pay_qr {
display: flex;
margin-bottom: 25px;
justify-content: center;
position: relative;
.pay-success {
border-radius: 50%;
border: 3px solid rgba($color: #67c23a, $alpha: 0.8);
padding: 5px;
position: absolute;
left: calc(50% - 15px);
top: calc(50% - 15px);
}
}
.qr-tips {
margin-top: 15px;
.qr-tips-content {
.icon {
margin-right: 5px;
}
display: flex;
align-items: center;
justify-content: center;
}
}
@media screen and (max-width: 700px) {
:deep(.pay-dialog) {
--el-dialog-width: 96% !important;
}
.pay-box .right {
display: none;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div>
<el-alert class="ba-table-alert" v-if="state.table.remark" :title="state.table.remark" type="info" show-icon />
<div class="modules-header">
<div class="table-header-buttons">
<el-button :title="$t('Refresh')" @click="onRefreshTableData" v-blur color="#40485b" type="info">
<Icon name="fa fa-refresh" color="#ffffff" size="14" />
</el-button>
<el-button-group class="ml10">
<el-button @click="uploadInstall" :title="t('module.Upload zip package for installation')" v-blur type="primary">
<Icon name="fa fa-upload" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Upload installation') }}</span>
</el-button>
<el-button
@click="localModules"
:class="state.table.onlyLocal ? 'local-active' : ''"
:title="t('module.Uploaded / installed modules')"
v-blur
type="primary"
>
<Icon name="fa fa-desktop" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Local module') }}</span>
</el-button>
</el-button-group>
<el-button-group class="ml10 publish-module-button-group">
<el-button @click="navigateTo('https://doc.buildadmin.com/senior/module/start.html')" v-blur type="success">
<Icon name="fa fa-cloud-upload" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Publishing module') }}</span>
</el-button>
<el-button @click="navigateTo('https://doc.buildadmin.com/guide/other/appendix/getPoints.html')" v-blur type="success">
<Icon name="fa fa-rocket" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Get points') }}</span>
</el-button>
</el-button-group>
<el-button v-blur class="ml10 ba-account-button" @click="onShowBaAccount" type="success">
<Icon name="fa fa-user-o" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('layouts.Member information') }}</span>
</el-button>
</div>
<div class="table-search">
<el-input
v-model="state.table.params.quickSearch"
class="xs-hidden"
@input="onSearchInput"
:placeholder="t('module.Search is actually very simple')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { loadData, onRefreshTableData } from '../index'
import { state } from '../store'
const { t } = useI18n()
const localModules = () => {
state.table.onlyLocal = !state.table.onlyLocal
loadData()
}
const onShowBaAccount = () => {
state.dialog.baAccount = true
}
const onSearchInput = debounce(() => {
state.table.modulesEbak[state.table.params.activeTab] = undefined
loadData()
}, 500)
const navigateTo = (url: string) => {
window.open(url, '_blank')
}
const uploadInstall = () => {
state.dialog.common = true
state.common.quickClose = true
state.common.dialogTitle = t('module.Upload installation')
state.common.type = 'uploadInstall'
}
</script>
<style scoped lang="scss">
.ml10 {
margin-left: 10px;
}
.ba-table-alert {
border: none;
}
.modules-header {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 10px;
background-color: var(--ba-bg-color-overlay);
border-radius: var(--el-border-radius-base);
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.table-header-operate-text {
padding-left: 6px;
}
.table-search {
margin-left: auto;
}
.local-active {
border-color: var(--el-button-active-border-color);
background-color: var(--el-button-active-bg-color);
}
@media screen and (max-width: 1300px) {
.ba-account-button {
display: block;
margin: 10px 0 0 0;
}
}
@media screen and (max-width: 1100px) {
.publish-module-button-group {
display: block;
margin: 10px 0 0 0;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div>
<el-tabs
v-loading="state.loading.table"
:element-loading-text="$t('module.Loading')"
v-model="state.table.params.activeTab"
type="border-card"
class="store-tabs"
@tab-change="onTabChange"
>
<el-tab-pane v-for="cat in state.table.category" :name="cat.id.toString()" :key="cat.id" :label="cat.name" class="store-tab-pane">
<template v-if="state.table.modules[state.table.params.activeTab] && state.table.modules[state.table.params.activeTab].length > 0">
<el-row :gutter="15" class="goods">
<el-col
:xs="12"
:sm="8"
:md="8"
:lg="6"
:xl="4"
v-for="item in state.table.modules[state.table.params.activeTab]"
:key="item.uid"
class="goods-col"
>
<div @click="showInfo(item.uid)" class="goods-item suspension">
<el-image
loading="lazy"
fit="cover"
class="goods-img"
:src="item.logo ? item.logo : fullUrl('/static/images/local-module-logo.png')"
/>
<div class="goods-footer">
<div class="goods-tag" v-if="item.tags && item.tags.length > 0">
<el-tag v-for="(tag, idx) in item.tags" :type="tag.type ? tag.type : 'primary'" :key="idx">
{{ tag.name }}
</el-tag>
</div>
<div class="goods-title">
{{ item.title }}
</div>
<div class="goods-data">
<span class="download-count">
<Icon name="fa fa-download" color="#c0c4cc" size="13" /> {{ item.downloads ? item.downloads : '-' }}
</span>
<span v-if="item.state === moduleInstallState.UNINSTALLED" class="goods-price">
<span class="original-price">{{ currency(item.original_price, item.currency_select) }}</span>
<span class="current-price">{{ currency(item.present_price, item.currency_select) }}</span>
</span>
<div v-else class="goods-price">
<el-tag effect="dark" :type="item.stateTag.type ? item.stateTag.type : 'primary'">
{{ item.stateTag.text }}
</el-tag>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
</template>
<el-empty v-else class="modules-empty" :description="$t('module.No more')" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { currency, loadData, showInfo } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import { fullUrl } from '/@/utils/common'
const onTabChange = () => {
loadData()
}
</script>
<style scoped lang="scss">
.suspension:hover {
z-index: 1;
}
.goods-item {
display: block;
margin-bottom: 15px;
padding-bottom: 40px;
position: relative;
border-radius: var(--el-border-radius-base);
background-color: var(--el-fill-color-extra-light);
box-shadow: var(--el-box-shadow-light);
cursor: pointer;
}
.goods-img {
display: block;
border-radius: var(--el-border-radius-base);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.modules-empty {
width: 100%;
}
.goods-footer {
display: block;
overflow: hidden;
padding: 10px 10px 0 10px;
.goods-tag {
min-height: 60px;
}
.goods-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 6px;
font-size: 14px;
line-height: 18px;
}
.goods-data {
display: flex;
width: calc(100% - 20px);
position: absolute;
bottom: 0;
align-items: baseline;
padding: 10px 0;
.download-count {
color: var(--el-text-color-placeholder);
}
.goods-price {
margin-left: auto;
}
.original-price {
font-size: 13px;
color: var(--el-text-color-placeholder);
text-decoration: line-through;
}
.current-price {
font-size: 16px;
color: var(--el-color-danger);
padding-left: 6px;
}
}
}
.el-tabs--border-card {
border: none;
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__header) {
background-color: var(--ba-bg-color);
border-bottom: none;
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__item.is-active) {
border: 1px solid transparent;
}
.el-tabs--border-card :deep(.el-tabs__nav-wrap) {
border-radius: var(--el-border-radius-base);
}
:deep(.store-tabs) .el-tabs__content {
padding: 15px 15px 0 15px;
min-height: 350px;
}
@media screen and (max-width: 520px) {
.goods {
.goods-col {
max-width: 100%;
flex-basis: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div class="upload-install">
<div class="tips">
<div class="title">{{ $t('module.Local upload warning') }}</div>
<div class="tip-item">1. {{ $t('module.The module can modify and add system files') }}</div>
<div class="tip-item">2. {{ $t('module.The module can execute sql commands and codes') }}</div>
<div class="tip-item">3. {{ $t('module.The module can install new front and rear dependencies') }}</div>
</div>
<el-upload class="upload-module" :show-file-list="false" accept=".zip" drag :auto-upload="false" @change="uploadModule">
<template v-if="state.uploadState == 'wait-file'">
<Icon size="50px" color="#909399" name="el-icon-UploadFilled" />
<div class="el-upload__text">
{{ $t('module.Drag the module package file here') }} <em>{{ $t('module.Click me to upload') }}</em>
</div>
</template>
<el-result v-else icon="success" :sub-title="$t('module.Uploaded, installation is about to start, please wait')"></el-result>
</el-upload>
</div>
</template>
<script setup lang="ts">
import type { UploadFile } from 'element-plus'
import { reactive } from 'vue'
import { onPreInstallModule } from '../index'
import { upload } from '/@/api/backend/module'
import { fileUpload } from '/@/api/common'
const state = reactive({
uploadState: 'wait-file',
})
const uploadModule = (file: UploadFile) => {
if (!file || !file.raw) return
let fd = new FormData()
fd.append('file', file.raw!)
fileUpload(fd, {}, true).then((res) => {
if (res.code == 1) {
upload(res.data.file.url)
.then((res) => {
state.uploadState = 'success'
onPreInstallModule(res.data.info.uid, 0, false, res.data.info.update ? true : false)
})
.catch(() => {
state.uploadState = 'wait-file'
})
}
})
}
</script>
<style scoped lang="scss">
.tips {
padding: 20px;
background-color: var(--el-bg-color-page);
border-radius: var(--el-border-radius-base);
max-width: 400px;
margin: 0 auto;
color: var(--el-color-danger);
.title {
font-size: var(--el-font-size-medium);
padding-bottom: 6px;
}
.tip-item {
font-size: var(--el-font-size-base);
}
}
.upload-module {
max-width: 460px;
margin: 40px auto;
}
</style>

View File

@@ -0,0 +1,604 @@
import { ElNotification } from 'element-plus'
import { isArray } from 'lodash-es'
import { state } from './store'
import { moduleInstallState, type moduleState } from './types'
import {
changeState,
createOrder,
getInstallState,
index,
info,
modules,
payCheck,
payOrder,
postInstallModule,
preDownload,
} from '/@/api/backend/module'
import { i18n } from '/@/lang/index'
import router from '/@/router/index'
import { useBaAccount } from '/@/stores/baAccount'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import type { UserInfo } from '/@/stores/interface'
import { useTerminal } from '/@/stores/terminal'
import { fullUrl } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { changeListenDirtyFileSwitch, closeHotUpdate } from '/@/utils/vite'
export const loadData = () => {
state.loading.table = true
if (!state.table.indexLoaded) {
loadIndex().then(() => {
getModules()
})
} else {
getModules()
}
}
export const onRefreshTableData = () => {
state.table.indexLoaded = false
for (const key in state.table.modulesEbak) {
state.table.modulesEbak[key] = undefined
}
loadData()
}
const loadIndex = () => {
return index().then((res) => {
state.table.indexLoaded = true
state.sysVersion = res.data.sysVersion
state.nuxtVersion = res.data.nuxtVersion
state.installedModule = res.data.installed
const installedModuleUids: string[] = []
const installedModuleVersions: { uid: string; version: string }[] = []
if (res.data.installed) {
state.installedModule.forEach((item) => {
installedModuleUids.push(item.uid)
installedModuleVersions.push({
uid: item.uid,
version: item.version,
})
})
state.installedModuleUids = installedModuleUids
state.installedModuleVersions = installedModuleVersions
}
})
}
const getModules = () => {
if (typeof state.table.modulesEbak[state.table.params.activeTab] != 'undefined') {
state.table.modules[state.table.params.activeTab] = modulesOnlyLocalHandle(state.table.modulesEbak[state.table.params.activeTab])
state.loading.table = false
return
}
const params: anyObj = {}
for (const key in state.table.params) {
if (state.table.params[key] != '') {
params[key] = state.table.params[key]
}
}
const moduleUids: string[] = []
params['installed'] = state.installedModuleVersions
params['sysVersion'] = state.sysVersion
modules(params)
.then((res) => {
if (params.activeTab == 'all') {
res.data.rows.forEach((item: anyObj) => {
moduleUids.push(item.uid)
})
state.installedModule.forEach((item) => {
if (moduleUids.indexOf(item.uid) === -1) {
if (state.table.params.quickSearch) {
if (item.title.includes(state.table.params.quickSearch)) res.data.rows.push(item)
} else {
res.data.rows.push(item)
}
}
})
}
state.table.remark = res.data.remark
state.table.modulesEbak[params.activeTab] = res.data.rows.map((item: anyObj) => {
const idx = state.installedModuleUids.indexOf(item.uid)
if (idx !== -1) {
item.state = state.installedModule[idx].state
item.title = state.installedModule[idx].title
item.version = state.installedModule[idx].version
item.website = state.installedModule[idx].website
item.stateTag = moduleStatus(item.state)
if (!isArray(item.tags)) item.tags = []
item.tags.push({
name: `${i18n.global.t('module.installed')} v${state.installedModule[idx].version}`,
type: 'primary',
})
} else {
item.state = 0
}
if (item.new_version && item.tags) {
item.tags.push({
name: i18n.global.t('module.New version'),
type: 'danger',
})
}
return item
})
state.table.modules[params.activeTab] = modulesOnlyLocalHandle(state.table.modulesEbak[params.activeTab])
state.table.category = res.data.category
})
.finally(() => {
state.loading.table = false
})
}
export const showInfo = (uid: string) => {
state.dialog.goodsInfo = true
state.loading.goodsInfo = true
const localItem = state.installedModule.find((item) => {
return item.uid == uid
})
info({
uid: uid,
localVersion: localItem?.version,
sysVersion: state.sysVersion,
})
.then((res) => {
if (localItem) {
if (res.data.info.type == 'local') {
res.data.info = localItem
res.data.info.images = [fullUrl('/static/images/local-module-logo.png')]
res.data.info.type = 'local' // 纯本地模块
} else {
res.data.info.type = 'online'
res.data.info.state = localItem.state
res.data.info.version = localItem.version
}
res.data.info.enable = localItem.state === moduleInstallState.DISABLE ? false : true
} else {
res.data.info.state = 0
res.data.info.type = 'online'
}
state.goodsInfo = res.data.info
})
.catch((err) => {
if (loginExpired(err)) {
state.dialog.goodsInfo = false
}
})
.finally(() => {
state.loading.goodsInfo = false
})
}
/**
* 支付订单
* @param renew 是否是续费订单
*/
export const onBuy = (renew = false) => {
state.dialog.buy = true
state.loading.buy = true
createOrder({
goods_id: state.goodsInfo.id,
})
.then((res) => {
state.loading.buy = false
state.buy.renew = renew
state.buy.info = res.data.info
})
.catch((err) => {
state.dialog.buy = false
state.loading.buy = false
loginExpired(err)
})
}
export const onPay = (payType: 'score' | 'wx' | 'balance' | 'zfb') => {
state.common.payType = payType
state.loading.common = true
payOrder(state.buy.info.id, payType)
.then((res) => {
// 关闭其他弹窗
state.dialog.buy = false
state.dialog.goodsInfo = false
if (payType == 'wx' || payType == 'zfb') {
// 显示支付二维码
state.dialog.pay = true
state.payInfo = res.data
// 轮询获取支付状态
const timer = setInterval(() => {
payCheck(state.payInfo.info.sn)
.then(() => {
state.payInfo.pay.status = 'success'
clearInterval(timer)
if (state.buy.renew) {
showInfo(res.data.info.uid)
} else {
onPreInstallModule(res.data.info.uid, res.data.info.id, true)
}
state.dialog.pay = false
})
.catch(() => {})
}, 3000)
} else {
if (state.buy.renew) {
showInfo(res.data.info.uid)
} else {
onPreInstallModule(res.data.info.uid, res.data.info.id, true)
}
}
})
.catch((err) => {
loginExpired(err)
})
.finally(() => {
state.loading.common = false
})
}
export const showCommonLoading = (loadingTitle: moduleState['common']['loadingTitle']) => {
state.common.type = 'loading'
state.common.loadingTitle = loadingTitle
state.common.loadingComponentKey = uuid()
}
/**
* 模块预安装
*/
export const onPreInstallModule = (uid: string, id: number, needGetInstallableVersion: boolean, update: boolean = false) => {
state.dialog.common = true
showCommonLoading('init')
state.common.dialogTitle = i18n.global.t('module.Install')
const nextStep = (moduleState: number) => {
if (needGetInstallableVersion) {
// 获取模块版本列表
showCommonLoading('getInstallableVersion')
preDownload({
uid,
orderId: id,
sysVersion: state.sysVersion,
nuxtVersion: state.nuxtVersion,
installed: state.installedModuleUids,
})
.then((res) => {
state.common.uid = uid
state.common.update = update
state.common.type = 'selectVersion'
state.common.dialogTitle = i18n.global.t('module.Select Version')
state.common.versions = res.data.versions
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
})
.catch((res) => {
if (loginExpired(res)) return
state.dialog.common = false
})
} else {
// 立即安装(上传安装、继续安装)
showCommonLoading(moduleState === moduleInstallState.UNINSTALLED ? 'download' : 'install')
execInstall(uid, id, '', update)
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
}
}
if (update) {
nextStep(moduleInstallState.DISABLE)
} else {
// 获取安装状态
getInstallState(uid).then((res) => {
if (
res.data.state === moduleInstallState.INSTALLED ||
res.data.state === moduleInstallState.DISABLE ||
res.data.state === moduleInstallState.DIRECTORY_OCCUPIED
) {
ElNotification({
type: 'error',
message:
res.data.state === moduleInstallState.INSTALLED || res.data.state === moduleInstallState.DISABLE
? i18n.global.t('module.Installation cancelled because module already exists!')
: i18n.global.t('module.Installation cancelled because the directory required by the module is occupied!'),
})
state.dialog.common = false
return
}
nextStep(res.data.state)
})
}
}
/**
* 执行安装请求,还包含启用、安装时的冲突处理
*/
export const execInstall = (uid: string, id: number, version: string = '', update: boolean = false, extend: anyObj = {}) => {
postInstallModule(uid, id, version, update, extend)
.then(() => {
state.common.dialogTitle = i18n.global.t('module.Installation complete')
state.common.moduleState = moduleInstallState.INSTALLED
state.common.type = 'done'
onRefreshTableData()
})
.catch((res) => {
if (loginExpired(res)) return
if (res.code == -1) {
state.common.uid = res.data.uid
state.common.type = 'installConflict'
state.common.dialogTitle = i18n.global.t('module.A conflict is found Please handle it manually')
state.common.fileConflict = res.data.fileConflict
state.common.dependConflict = res.data.dependConflict
} else if (res.code == -2) {
state.common.type = 'done'
state.common.uid = res.data.uid
state.common.dialogTitle = i18n.global.t('module.Wait for dependent installation')
state.common.moduleState = moduleInstallState.DEPENDENT_WAIT_INSTALL
state.common.waitInstallDepend = res.data.wait_install
state.common.dependInstallState = 'executing'
const terminal = useTerminal()
if (res.data.wait_install.includes('npm_dependent_wait_install')) {
terminal.addTaskPM('web-install', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'npm_dependent_wait_install')
})
}
if (res.data.wait_install.includes('nuxt_npm_dependent_wait_install')) {
terminal.addTaskPM('nuxt-install', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'nuxt_npm_dependent_wait_install')
})
}
if (res.data.wait_install.includes('composer_dependent_wait_install')) {
terminal.addTask('composer.update', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'composer_dependent_wait_install')
})
}
} else if (res.code == 0) {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
state.dialog.common = false
onRefreshTableData()
}
})
.finally(() => {
state.loading.common = false
})
}
const terminalTaskExecComplete = (res: number, type: string) => {
if (res == taskStatus.Success) {
state.common.waitInstallDepend = state.common.waitInstallDepend.filter((depend: string) => {
return depend != type
})
if (state.common.waitInstallDepend.length == 0) {
state.common.dependInstallState = 'success'
// 仅在命令全部执行完毕才刷新数据
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
onRefreshTableData()
}
}
} else {
const terminal = useTerminal()
terminal.toggle(true)
state.common.dependInstallState = 'fail'
// 有命令执行失败了,刷新一次数据
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
onRefreshTableData()
}
}
// 连续安装模块的情况中,首个模块的命令执行完毕时,自动启动了热更新
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
closeHotUpdate('modules')
}
}
export const onDisable = (confirmConflict = false) => {
state.loading.common = true
// 拼装依赖处理方案
if (confirmConflict) {
const dependConflict: anyObj = {}
for (const key in state.common.disableDependConflict) {
if (state.common.disableDependConflict[key]['solution'] != 'delete') {
continue
}
if (typeof dependConflict[state.common.disableDependConflict[key].env] == 'undefined') {
dependConflict[state.common.disableDependConflict[key].env] = []
}
dependConflict[state.common.disableDependConflict[key].env].push(state.common.disableDependConflict[key].depend)
}
state.common.disableParams['confirmConflict'] = 1
state.common.disableParams['dependConflictSolution'] = dependConflict
}
changeState(state.common.disableParams)
.then(() => {
ElNotification({
type: 'success',
message: i18n.global.t('module.The operation succeeds Please clear the system cache and refresh the browser ~'),
zIndex: SYSTEM_ZINDEX,
})
state.dialog.common = false
onRefreshTableData()
})
.catch((res) => {
if (res.code == -1) {
state.dialog.common = true
state.common.dialogTitle = i18n.global.t('module.Deal with conflict')
state.common.type = 'disableConfirmConflict'
state.common.disableDependConflict = res.data.dependConflict
if (res.data.conflictFile && res.data.conflictFile.length) {
const conflictFile = []
for (const key in res.data.conflictFile) {
conflictFile.push({
file: res.data.conflictFile[key],
})
}
state.common.disableConflictFile = conflictFile
}
} else if (res.code == -2) {
state.dialog.common = true
const commandsData = {
type: 'disable',
commands: res.data.wait_install,
}
state.common.uid = state.goodsInfo.uid
execCommand(commandsData)
} else if (res.code == -3) {
// 更新
onPreInstallModule(state.goodsInfo.uid, state.goodsInfo.purchased, true, true)
} else {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
if (state.common.disableParams && state.common.disableParams.uid) {
showInfo(state.common.disableParams.uid)
} else {
onRefreshTableData()
}
}
})
.finally(() => {
state.loading.common = false
})
}
export const onEnable = (uid: string) => {
state.loading.common = true
changeState({
uid: uid,
state: 1,
})
.then(() => {
state.dialog.common = true
showCommonLoading('init')
state.common.dialogTitle = i18n.global.t('Enable')
execInstall(uid, 0)
state.dialog.goodsInfo = false
})
.catch((res) => {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
state.loading.common = false
})
}
export const loginExpired = (res: ApiResponse) => {
const baAccount = useBaAccount()
if (res.code == 301 || res.code == 408) {
baAccount.removeToken()
state.dialog.baAccount = true
return true
}
return false
}
const modulesOnlyLocalHandle = (modules: anyObj) => {
if (!state.table.onlyLocal) return modules
return modules.filter((item: anyObj) => {
return item.installed
})
}
export const execCommand = (data: anyObj) => {
if (data.type == 'disable') {
state.dialog.common = true
state.common.type = 'done'
state.common.dialogTitle = i18n.global.t('module.Wait for dependent installation')
state.common.moduleState = moduleInstallState.DISABLE
state.common.dependInstallState = 'executing'
const terminal = useTerminal()
data.commands.forEach((item: anyObj) => {
state.common.waitInstallDepend.push(item.type)
if (item.pm) {
if (item.command == 'web-install') {
changeListenDirtyFileSwitch(false)
}
terminal.addTaskPM(item.command, true, '', (res: number) => {
terminalTaskExecComplete(res, item.type)
if (item.command == 'web-install') {
changeListenDirtyFileSwitch(true)
}
})
} else {
terminal.addTask(item.command, true, '', (res: number) => {
terminalTaskExecComplete(res, item.type)
})
}
})
}
}
export const specificUserName = (userInfo: Partial<UserInfo>) => {
return userInfo.nickname + '' + (userInfo.email || userInfo.mobile || 'ID:' + userInfo.id) + ''
}
export const currency = (price: number, val: number) => {
if (typeof price == 'undefined' || typeof val == 'undefined') {
return '-'
}
if (val == 0) {
return parseInt(price.toString()) + i18n.global.t('Integral')
} else {
return '¥' + price
}
}
export const moduleStatus = (state: number) => {
switch (state) {
case moduleInstallState.INSTALLED:
return {
type: '',
text: i18n.global.t('module.installed'),
}
case moduleInstallState.WAIT_INSTALL:
return {
type: 'success',
text: i18n.global.t('module.Wait for installation'),
}
case moduleInstallState.CONFLICT_PENDING:
return {
type: 'danger',
text: i18n.global.t('module.Conflict pending'),
}
case moduleInstallState.DEPENDENT_WAIT_INSTALL:
return {
type: 'warning',
text: i18n.global.t('module.Dependency to be installed'),
}
case moduleInstallState.DISABLE:
return {
type: 'warning',
text: i18n.global.t('Disable'),
}
default:
return {
type: 'info',
text: i18n.global.t('Unknown'),
}
}
}

View File

@@ -0,0 +1,45 @@
<template>
<div class="default-main ba-table-box">
<TableHeader />
<Tabs />
<GoodsInfo />
<CommonDialog />
<BaAccountDialog v-model="state.dialog.baAccount" :login-callback="() => (state.dialog.baAccount = false)" />
</div>
</template>
<script setup lang="ts">
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
import CommonDialog from './components/commonDialog.vue'
import GoodsInfo from './components/goodsInfo.vue'
import TableHeader from './components/tableHeader.vue'
import Tabs from './components/tabs.vue'
import { loadData } from './index'
import { state } from './store'
import BaAccountDialog from '/@/layouts/backend/components/baAccount.vue'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
defineOptions({
name: 'moduleStore/moduleStore',
})
onMounted(() => {
loadData()
closeHotUpdate('modules')
})
onActivated(() => {
closeHotUpdate('modules')
})
onDeactivated(() => {
openHotUpdate('modules')
})
onUnmounted(() => {
openHotUpdate('modules')
})
</script>
<style scoped lang="scss">
:deep(.goods-tag) .el-tag {
margin: 0 6px 6px 0;
}
</style>

View File

@@ -0,0 +1,63 @@
import { reactive } from 'vue'
import { uuid } from '/@/utils/random'
import type { moduleState } from './types'
export const state = reactive<moduleState>({
loading: {
buy: false,
table: true,
common: false,
install: false,
goodsInfo: false,
},
dialog: {
buy: false,
pay: false,
common: false,
goodsInfo: false,
baAccount: false,
},
table: {
remark: '',
modules: [],
modulesEbak: [],
category: [],
onlyLocal: false,
indexLoaded: false,
params: {
quickSearch: '',
activeTab: 'all',
},
},
payInfo: {},
goodsInfo: {},
buy: {
info: {},
renew: false,
agreement: true,
},
common: {
uid: '',
moduleState: 0,
quickClose: false,
type: 'loading',
dialogTitle: '',
fileConflict: [],
dependConflict: [],
loadingTitle: 'init',
loadingComponentKey: uuid(),
waitInstallDepend: [],
dependInstallState: 'none',
disableConflictFile: [],
disableDependConflict: [],
disableParams: {},
payType: 'wx',
update: false,
versions: [],
},
sysVersion: '',
nuxtVersion: '',
installedModule: [],
installedModuleUids: [],
installedModuleVersions: [],
})

View File

@@ -0,0 +1,78 @@
export enum moduleInstallState {
UNINSTALLED,
INSTALLED,
WAIT_INSTALL,
CONFLICT_PENDING,
DEPENDENT_WAIT_INSTALL,
DIRECTORY_OCCUPIED,
DISABLE,
}
export interface moduleInfo {
uid: string
title: string
version: string
state: number
website: string
stateTag: {
type: string
text: string
}
}
export interface moduleState {
loading: {
buy: boolean
table: boolean
common: boolean
install: boolean
goodsInfo: boolean
}
dialog: {
buy: boolean
pay: boolean
common: boolean
goodsInfo: boolean
baAccount: boolean
}
table: {
remark: string
modules: anyObj
modulesEbak: anyObj
category: anyObj
onlyLocal: boolean
indexLoaded: boolean
params: anyObj
}
payInfo: anyObj
goodsInfo: anyObj
buy: {
info: anyObj
renew: boolean
agreement: boolean
}
common: {
uid: string
moduleState: number
quickClose: boolean
type: 'loading' | 'installConflict' | 'done' | 'disableConfirmConflict' | 'uploadInstall' | 'selectVersion'
dialogTitle: string
fileConflict: anyObj[]
dependConflict: anyObj[]
loadingTitle: 'init' | 'download' | 'install' | 'getInstallableVersion'
loadingComponentKey: string
waitInstallDepend: string[]
dependInstallState: 'none' | 'executing' | 'success' | 'fail'
disableConflictFile: { file: string }[]
disableDependConflict: anyObj[]
disableParams: anyObj
payType: 'score' | 'wx' | 'balance' | 'zfb'
update: boolean
versions: anyObj[]
}
sysVersion: string
nuxtVersion: string
installedModule: moduleInfo[]
installedModuleUids: string[]
installedModuleVersions: { uid: string; version: string }[]
}

View File

@@ -0,0 +1,297 @@
<template>
<div class="default-main">
<el-row :gutter="30">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="10">
<div class="admin-info">
<el-upload
class="avatar-uploader"
action=""
:show-file-list="false"
@change="onAvatarBeforeUpload"
:auto-upload="false"
accept="image/gif, image/jpg, image/jpeg, image/bmp, image/png, image/webp"
v-if="!isEmpty(state.adminInfo)"
>
<el-image fit="cover" :src="fullUrl(state.adminInfo.avatar)" class="avatar">
<template #error>
<div class="image-slot">
<Icon size="30" color="#c0c4cc" name="el-icon-Picture" />
</div>
</template>
</el-image>
</el-upload>
<div class="admin-info-base">
<div class="admin-nickname">{{ state.adminInfo.nickname }}</div>
<div class="admin-other">
<div>{{ t('routine.adminInfo.Last logged in on') }} {{ timeFormat(state.adminInfo.last_login_time) }}</div>
</div>
</div>
<div class="admin-info-form">
<el-form
@keyup.enter="onSubmit()"
:key="state.formKey"
label-position="top"
:rules="rules"
ref="formRef"
:model="state.adminInfo"
>
<el-form-item :label="t('routine.adminInfo.user name')">
<el-input disabled v-model="state.adminInfo.username"></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.User nickname')" prop="nickname">
<el-input :placeholder="t('routine.adminInfo.Please enter a nickname')" v-model="state.adminInfo.nickname"></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.e-mail address')" prop="email">
<el-input
:placeholder="t('Please input field', { field: t('routine.adminInfo.e-mail address') })"
v-model="state.adminInfo.email"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.phone number')" prop="mobile">
<el-input
:placeholder="t('Please input field', { field: t('routine.adminInfo.phone number') })"
v-model="state.adminInfo.mobile"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.autograph')" prop="motto">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
:placeholder="t('routine.adminInfo.This guy is lazy and doesn write anything')"
type="textarea"
v-model="state.adminInfo.motto"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.New password')" prop="password">
<el-input
type="password"
:placeholder="t('routine.adminInfo.Please leave blank if not modified')"
v-model="state.adminInfo.password"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="state.buttonLoading" @click="onSubmit()">
{{ t('routine.adminInfo.Save changes') }}
</el-button>
<el-button @click="onResetForm(formRef)">{{ t('Reset') }}</el-button>
</el-form-item>
</el-form>
</div>
</div>
</el-col>
<el-col v-loading="state.logLoading" :xs="24" :sm="24" :md="24" :lg="12">
<el-card :header="t('routine.adminInfo.Operation log')" shadow="never">
<el-timeline>
<el-timeline-item v-for="(item, idx) in state.log" :key="idx" size="large" :timestamp="timeFormat(item.create_time)">
{{ item.title }}
</el-timeline-item>
</el-timeline>
<el-pagination
:currentPage="state.logCurrentPage"
:page-size="state.logPageSize"
:page-sizes="[12, 22, 52, 100]"
background
layout="prev, next, jumper"
:total="state.logTotal"
@size-change="onLogSizeChange"
@current-change="onLogCurrentChange"
></el-pagination>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { index, log, postData } from '/@/api/backend/routine/AdminInfo'
import type { FormItemRule } from 'element-plus'
import { fullUrl, onResetForm, timeFormat } from '/@/utils/common'
import { uuid } from '../../../utils/random'
import { buildValidatorData } from '/@/utils/validate'
import { fileUpload } from '/@/api/common'
import { useAdminInfo } from '/@/stores/adminInfo'
import { isEmpty } from 'lodash-es'
defineOptions({
name: 'routine/adminInfo',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const adminInfoStore = useAdminInfo()
const state: {
adminInfo: anyObj
formKey: string
buttonLoading: boolean
log: {
title: string
create_time: string
url: string
}[]
logFilter: anyObj
logCurrentPage: number
logPageSize: number
logTotal: number
logLoading: boolean
} = reactive({
adminInfo: {},
formKey: uuid(),
buttonLoading: false,
log: [],
logFilter: {
limit: 12,
},
logCurrentPage: 1,
logPageSize: 12,
logTotal: 100,
logLoading: true,
})
index().then((res) => {
state.adminInfo = res.data.info
// 重新渲染表单以记录初始值
state.formKey = uuid()
// 管理员日志加载,加筛选防止超管读取到其他管理员的日志记录
state.logFilter.search = [
{
field: 'admin_id',
val: res.data.info.id,
operator: 'eq',
},
]
getLog()
})
const getLog = () => {
log(state.logFilter)
.then((res) => {
state.log = res.data.list
state.logTotal = res.data.total
state.logLoading = false
})
.catch(() => {
state.logLoading = false
})
}
const onLogSizeChange = (limit: number) => {
state.logPageSize = limit
state.logFilter.limit = limit
getLog()
}
const onLogCurrentChange = (page: number) => {
state.logCurrentPage = page
state.logFilter.page = page
getLog()
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
nickname: [buildValidatorData({ name: 'required', title: t('routine.adminInfo.User nickname') })],
email: [buildValidatorData({ name: 'email', title: t('routine.adminInfo.e-mail address') })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('routine.adminInfo.phone number') }) })],
password: [buildValidatorData({ name: 'password' })],
})
const onAvatarBeforeUpload = (file: any) => {
let fd = new FormData()
fd.append('file', file.raw)
fileUpload(fd).then((res) => {
if (res.code == 1) {
postData({
id: state.adminInfo.id,
avatar: res.data.file.url,
}).then(() => {
adminInfoStore.dataFill({ avatar: res.data.file.full_url })
state.adminInfo.avatar = res.data.file.full_url
})
}
})
}
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
let data = { ...state.adminInfo }
delete data.last_login_time
delete data.username
delete data.avatar
state.buttonLoading = true
postData(data)
.then(() => {
adminInfoStore.dataFill({ nickname: state.adminInfo.nickname })
state.buttonLoading = false
})
.catch(() => {
state.buttonLoading = false
})
}
})
}
</script>
<style scoped lang="scss">
.admin-info {
background-color: var(--ba-bg-color-overlay);
border-radius: var(--el-border-radius-base);
border-top: 3px solid #409eff;
.avatar-uploader {
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 60px auto 10px auto;
border-radius: 50%;
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
cursor: pointer;
overflow: hidden;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.admin-info-base {
.admin-nickname {
font-size: 22px;
color: var(--el-text-color-primary);
text-align: center;
padding: 8px 0;
}
.admin-other {
color: var(--el-text-color-regular);
font-size: 14px;
text-align: center;
line-height: 20px;
}
}
.admin-info-form {
padding: 30px;
}
}
.el-card :deep(.el-timeline-item__icon) {
font-size: 10px;
}
@media screen and (max-width: 1200px) {
.lg-mb-20 {
margin-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
import { buildSuffixSvgUrl } from '/@/api/common'
/**
* 表格和表单中文件预览图的生成
*/
export const previewRenderFormatter = (row: TableRow, column: TableColumn, cellValue: string) => {
const imgSuffix = ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp']
if (imgSuffix.includes(cellValue)) {
return row.full_url
}
return buildSuffixSvgUrl(cellValue)
}

View File

@@ -0,0 +1,184 @@
<template>
<div class="default-main">
<div class="ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'edit', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('utils.Original name') })"
>
<el-popconfirm
v-if="auth('del')"
@confirm="baTable.onTableHeaderAction('delete', {})"
:confirm-button-text="t('Delete')"
:cancel-button-text="t('Cancel')"
confirmButtonType="danger"
:title="t('routine.attachment.Files and records will be deleted at the same time Are you sure?')"
:disabled="baTable.table.selection!.length > 0 ? false : true"
>
<template #reference>
<div class="mlr-12">
<el-tooltip :content="t('Delete selected row')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
class="table-header-operate"
type="danger"
>
<Icon color="#ffffff" name="fa fa-trash" />
<span class="table-header-operate-text">{{ t('Delete') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 编辑和新增表单 -->
<PopupForm />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import baTableClass from '/@/utils/baTable'
import { defaultOptButtons } from '/@/components/table'
import { previewRenderFormatter } from './index'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import { auth } from '/@/utils/common'
defineOptions({
name: 'routine/attachment',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optBtn = defaultOptButtons(['edit', 'delete'])
optBtn[1].popconfirm = {
...optBtn[1].popconfirm,
title: t('routine.attachment.Files and records will be deleted at the same time Are you sure?'),
}
const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('utils.Breakdown'), prop: 'topic', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('routine.attachment.Upload administrator'),
prop: 'admin.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('routine.attachment.Upload user'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.size'),
prop: 'size',
align: 'center',
formatter: (row: TableRow, column: TableColumn, cellValue: string) => {
const size = parseFloat(cellValue)
const i = Math.floor(Math.log(size) / Math.log(1024))
return (size / Math.pow(1024, i)).toFixed(i < 1 ? 0 : 2) + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i]
},
operator: 'RANGE',
sortable: 'custom',
operatorPlaceholder: 'bytes',
},
{
label: t('utils.type'),
prop: 'mimetype',
align: 'center',
operator: 'LIKE',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.preview'),
prop: 'suffix',
align: 'center',
formatter: previewRenderFormatter,
render: 'image',
operator: false,
},
{
label: t('utils.Upload (Reference) times'),
prop: 'quote',
align: 'center',
width: 150,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('utils.Original name'),
prop: 'name',
align: 'center',
showOverflowTooltip: true,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('routine.attachment.Storage mode'),
prop: 'storage',
align: 'center',
width: 100,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.Last upload time'),
prop: 'last_upload_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
width: 160,
sortable: 'custom',
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: optBtn,
operator: false,
},
],
defaultOrder: { prop: 'last_upload_time', order: 'desc' },
})
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
})
})
</script>
<style scoped lang="scss">
.table-header-operate {
margin-left: 12px;
}
.table-header-operate-text {
margin-left: 6px;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
@keyup.enter="baTable.onSubmit()"
v-model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
>
<el-form-item :label="t('utils.preview')">
<el-image
class="preview-img"
:preview-src-list="[baTable.form.items!.full_url]"
:src="previewRenderFormatter(baTable.form.items!, {}, baTable.form.items!.suffix)"
></el-image>
</el-form-item>
<el-form-item :label="t('utils.Breakdown')">
<el-input
v-model="baTable.form.items!.topic"
type="string"
:placeholder="
t(
'routine.attachment.The file is saved in the directory, and the file will not be automatically transferred if the record is modified'
)
"
readonly
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.Physical path')">
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('routine.attachment.File saving path Modifying records will not automatically transfer files')"
readonly
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.image width')">
<el-input
v-model="baTable.form.items!.width"
type="number"
:placeholder="t('routine.attachment.Width of picture file')"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.Picture height')">
<el-input
v-model="baTable.form.items!.height"
type="number"
:placeholder="t('routine.attachment.Height of picture file')"
></el-input>
</el-form-item>
<el-form-item :label="t('utils.Original name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('routine.attachment.Original file name')"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.file size')">
<el-input
v-model="baTable.form.items!.size"
type="number"
:placeholder="t('routine.attachment.File size (bytes)')"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.mime type')">
<el-input
v-model="baTable.form.items!.mimetype"
type="string"
:placeholder="t('routine.attachment.File MIME type')"
></el-input>
</el-form-item>
<el-form-item :label="t('utils.Upload (Reference) times')">
<el-input
v-model="baTable.form.items!.quote"
type="number"
:placeholder="t('routine.attachment.Upload (Reference) times of this file')"
></el-input>
<span class="block-help">
{{
t(
'routine.attachment.When the same file is uploaded multiple times, only one attachment record will be saved and added'
)
}}
</span>
</el-form-item>
<el-form-item :label="t('routine.attachment.Storage mode')">
<el-input
v-model="baTable.form.items!.storage"
type="string"
:placeholder="t('routine.attachment.Storage mode')"
readonly
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.SHA1 code')">
<el-input
v-model="baTable.form.items!.sha1"
type="string"
:placeholder="t('routine.attachment.SHA1 encoding of file')"
readonly
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit()" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { previewRenderFormatter } from './index'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="props.modelValue" @close="closeForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ t('routine.config.Add configuration item') }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div class="ba-operate-form ba-add-form" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + state.labelWidth / 2 + 'px)'">
<el-form
ref="formRef"
@keyup.enter="onAddSubmit()"
:rules="rules"
:model="{ ...state.addConfig, ...state.formItemData }"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="160"
>
<FormItem
:label="t('routine.config.Variable group')"
type="select"
v-model="state.addConfig.group"
prop="group"
:input-attr="{ content: configGroup }"
:placeholder="t('Please select field', { field: t('routine.config.Variable group') })"
/>
<CreateFormItemData v-model="state.formItemData" />
<FormItem :label="t('Weigh')" type="number" v-model="state.addConfig.weigh" prop="weigh" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + state.labelWidth / 1.8 + 'px)'">
<el-button @click="closeForm">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="state.submitLoading" @click="onAddSubmit()" type="primary"> {{ t('Add') }} </el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import FormItem from '/@/components/formItem/index.vue'
import type { FormRules } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { postData } from '/@/api/backend/routine/config'
import CreateFormItemData from '/@/components/formItem/createData.vue'
import { useI18n } from 'vue-i18n'
import { useConfig } from '/@/stores/config'
const config = useConfig()
interface Props {
modelValue: boolean
configGroup: anyObj
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
configGroup: () => {
return {}
},
})
const emits = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const closeForm = () => {
emits('update:modelValue', false)
}
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const state: {
inputTypes: anyObj
labelWidth: number
submitLoading: boolean
addConfig: {
group: string
weigh: number
content: string
}
formItemData: anyObj
} = reactive({
inputTypes: {},
labelWidth: 180,
submitLoading: false,
addConfig: {
group: '',
weigh: 0,
content: '',
},
formItemData: {
dict: `key1=value1
key2=value2`,
},
})
const rules = reactive<FormRules>({
group: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('routine.config.Variable group') }),
}),
],
name: [
buildValidatorData({ name: 'required', title: t('routine.config.Variable name') }),
buildValidatorData({ name: 'varName', message: t('Please enter the correct field', { field: t('routine.config.Variable name') }) }),
],
title: [buildValidatorData({ name: 'required', title: t('routine.config.Variable title') })],
type: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('routine.config.Variable type') }),
}),
],
weigh: [buildValidatorData({ name: 'integer', title: t('routine.config.number') })],
})
const onAddSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
state.addConfig.content = state.formItemData.dict
delete state.formItemData.dict
postData('add', { ...state.addConfig, ...state.formItemData }).then(() => {
emits('update:modelValue', false)
})
}
})
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,369 @@
<template>
<div class="default-main">
<el-row v-loading="state.loading" :gutter="20">
<el-col class="xs-mb-20" :xs="24" :sm="16">
<el-form
v-if="!state.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="onSubmit()"
:model="state.form"
:rules="state.rules"
:label-position="'top'"
:key="state.formKey"
>
<el-tabs v-model="state.activeTab" type="border-card" :before-leave="onBeforeLeave">
<el-tab-pane class="config-tab-pane" v-for="(group, key) in state.config" :key="key" :name="key" :label="group.title">
<div class="config-form-item" v-for="(item, idx) in group.list" :key="idx">
<template v-if="item.group == state.activeTab">
<FormItem
v-if="item.type == 'number'"
:label="item.title"
:type="item.type"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="{ ...item.input_extend }"
:tip="item.tip"
:key="'number-' + item.id"
/>
<!-- 富文本在dialog内全屏编辑器时必须拥有很高的z-index此处选择单独为editor设定较小的z-index -->
<FormItem
v-else-if="item.type == 'editor'"
:label="item.title"
:type="item.type"
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="{
style: {
zIndex: 99,
},
...item.input_extend,
}"
:tip="item.tip"
:key="'editor-' + item.id"
/>
<FormItem
v-else-if="item.type == 'textarea'"
:label="item.title"
:type="item.type"
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="{ rows: 3, ...item.input_extend }"
:tip="item.tip"
:key="'textarea-' + item.id"
/>
<FormItem
v-else
:label="item.title"
:type="item.type"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="!isEmpty(item.content) ? { content: item.content, ...item.input_extend } : item.input_extend"
:tip="item.tip"
:key="'other-' + item.id"
/>
<div class="config-form-item-name">${{ item.name }}</div>
<div class="del-config-form-item">
<el-popconfirm
@confirm="onDelConfig(item)"
v-if="item.allow_del"
:confirmButtonText="t('Delete')"
:title="t('routine.config.Are you sure to delete the configuration item?')"
>
<template #reference>
<Icon class="close-icon" size="15" name="el-icon-Close" />
</template>
</el-popconfirm>
</div>
</template>
</div>
<div v-if="group.name == 'mail'" class="send-test-mail">
<el-button @click="onTestSendMail()">{{ t('routine.config.Test mail sending') }}</el-button>
</div>
<el-button type="primary" @click="onSubmit()">{{ t('Save') }}</el-button>
</el-tab-pane>
<el-tab-pane
name="add_config"
class="config-tab-pane config-tab-pane-add"
:label="t('routine.config.Add configuration item')"
></el-tab-pane>
</el-tabs>
</el-form>
</el-col>
<el-col :xs="24" :sm="8">
<el-card :header="t('routine.config.Quick configuration entry')">
<el-button v-for="(item, idx) in state.quickEntrance" class="config_quick_entrance" :key="idx">
<div @click="routePush({ name: item['value'] })">{{ item['key'] }}</div>
</el-button>
</el-card>
</el-col>
</el-row>
<AddFrom v-if="!state.loading" v-model="state.showAddForm" :config-group="state.configGroup" />
</div>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { ElMessageBox, ElNotification } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { onActivated, onDeactivated, onMounted, onUnmounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AddFrom from './add.vue'
import { del, index, postData, postSendTestMail } from '/@/api/backend/routine/config'
import FormItem from '/@/components/formItem/index.vue'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import type { SiteConfig } from '/@/stores/interface'
import { useSiteConfig } from '/@/stores/siteConfig'
import { uuid } from '/@/utils/random'
import { routePush } from '/@/utils/router'
import { buildValidatorData, type buildValidatorParams } from '/@/utils/validate'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
defineOptions({
name: 'routine/config',
})
const { t } = useI18n()
const siteConfig = useSiteConfig()
const formRef = useTemplateRef('formRef')
const state: {
loading: boolean
config: anyObj
remark: string
configGroup: anyObj
activeTab: string
showAddForm: boolean
rules: Partial<Record<string, FormItemRule[]>>
form: anyObj
quickEntrance: anyObj
formKey: string
} = reactive({
loading: true,
config: [],
remark: '',
configGroup: {},
activeTab: '',
showAddForm: false,
rules: {},
form: {},
quickEntrance: {},
formKey: uuid(),
})
const getData = () => {
index()
.then((res) => {
state.config = res.data.list
state.remark = res.data.remark
state.configGroup = res.data.configGroup
state.quickEntrance = res.data.quickEntrance
state.loading = false
for (const key in state.configGroup) {
state.activeTab = key
break
}
let formNames: anyObj = {}
let rules: Partial<Record<string, FormItemRule[]>> = {}
for (const key in state.config) {
for (const lKey in state.config[key].list) {
if (state.config[key].list[lKey].rule) {
let ruleStr = state.config[key].list[lKey].rule.split(',')
let ruleArr: anyObj = []
ruleStr.forEach((item: string) => {
ruleArr.push(
buildValidatorData({ name: item as buildValidatorParams['name'], title: state.config[key].list[lKey].title })
)
})
rules = Object.assign(rules, {
[state.config[key].list[lKey].name]: ruleArr,
})
}
formNames[state.config[key].list[lKey].name] =
state.config[key].list[lKey].type == 'number'
? parseFloat(state.config[key].list[lKey].value)
: state.config[key].list[lKey].value
}
}
state.form = formNames
state.rules = rules
state.formKey = uuid()
})
.catch(() => {
state.loading = false
})
}
const onBeforeLeave = (newTabName: string | number) => {
if (newTabName == 'add_config') {
state.showAddForm = true
return false
}
}
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
// 只提交当前tab的表单数据
const formData: anyObj = {}
for (const key in state.config) {
if (key != state.activeTab) {
continue
}
for (const lKey in state.config[key].list) {
formData[state.config[key].list[lKey].name] = state.form[state.config[key].list[lKey].name] ?? ''
}
}
postData('edit', formData).then(() => {
for (const key in siteConfig.$state) {
if (formData[key] && siteConfig.$state[key as keyof SiteConfig] != formData[key]) {
;(siteConfig.$state[key as keyof SiteConfig] as any) = formData[key]
}
}
if (formData.backend_entrance && formData.backend_entrance != adminBaseRoutePath) {
window.open(window.location.href.replace(adminBaseRoutePath, formData.backend_entrance))
window.close()
}
})
}
})
}
const onDelConfig = (config: anyObj) => {
del([config.id]).then(() => {
getData()
})
}
const onTestSendMail = () => {
if (!state.form.smtp_server || !state.form.smtp_port || !state.form.smtp_user || !state.form.smtp_pass || !state.form.smtp_sender_mail) {
ElNotification({
type: 'error',
message: t('routine.config.Please enter the correct mail configuration'),
})
return false
}
ElMessageBox.prompt(t('routine.config.Please enter the recipient email address'), t('routine.config.Test mail sending'), {
confirmButtonText: t('routine.config.send out'),
cancelButtonText: t('Cancel'),
inputPattern: /[\w!#$%&'*+/=?^_`{|}~-]+(?:\.[\w!#$%&'*+/=?^_`{|}~-]+)*@(?:[\w](?:[\w-]*[\w])?\.)+[\w](?:[\w-]*[\w])?/,
inputErrorMessage: t('routine.config.Please enter the correct email address'),
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true
instance.confirmButtonText = t('routine.config.Sending')
postSendTestMail(state.form, instance.inputValue)
.then(() => {
done()
})
.catch(() => {
done()
})
} else {
done()
}
},
})
}
onMounted(() => {
getData()
closeHotUpdate('config')
})
onActivated(() => {
closeHotUpdate('config')
})
onDeactivated(() => {
openHotUpdate('config')
})
onUnmounted(() => {
openHotUpdate('config')
})
</script>
<style scoped lang="scss">
.send-test-mail {
padding-bottom: 20px;
}
.el-tabs--border-card {
border: none;
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__header) {
background-color: var(--ba-bg-color);
border-bottom: none;
border-top-left-radius: var(--el-border-radius-base);
border-top-right-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__item.is-active) {
border: 1px solid transparent;
}
.el-tabs--border-card :deep(.el-tabs__nav-wrap) {
border-top-left-radius: var(--el-border-radius-base);
border-top-right-radius: var(--el-border-radius-base);
}
.el-card :deep(.el-card__header) {
height: 40px;
padding: 0;
line-height: 40px;
border: none;
padding-left: 20px;
background-color: var(--ba-bg-color);
}
.config-tab-pane {
padding: 5px;
}
.config-tab-pane-add {
width: 80%;
}
.config-form-item {
display: flex;
align-items: center;
.el-form-item {
flex: 13;
}
.config-form-item-name {
opacity: 0;
flex: 3;
font-size: 13px;
color: var(--el-text-color-disabled);
padding-left: 20px;
}
.del-config-form-item {
cursor: pointer;
flex: 1;
padding-left: 10px;
}
.close-icon {
display: none;
}
&:hover {
.config-form-item-name {
opacity: 1;
}
.close-icon {
display: block;
color: var(--el-text-color-disabled) !important;
}
}
}
.config_quick_entrance {
margin-left: 10px;
margin-bottom: 10px;
}
@media screen and (max-width: 768px) {
.xs-mb-20 {
margin-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.dataRecycle.Rule name') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import baTableClass from '/@/utils/baTable'
import { add, url } from '/@/api/backend/security/dataRecycle'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/dataRecycle',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: 'ID', prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('security.dataRecycle.Rule name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('security.dataRecycle.controller'),
prop: 'controller',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycle.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycle.Data table primary key'),
prop: 'primary_key',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 100,
},
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.dataRecycle.Deleting monitoring') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
status: 1,
},
}
)
// 获取控制器和数据表数据
baTable.before.toggleForm = ({ operate }) => {
if (operate == 'Add' || operate == 'Edit') {
baTable.form.loading = true
add().then((res) => {
baTable.form.extend!.controllerList = res.data.controllers
baTable.form.loading = false
})
}
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,152 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('security.dataRecycle.Rule name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('security.dataRecycle.The rule name helps to identify deleted data later')"
/>
<FormItem
:label="t('security.dataRecycle.controller')"
type="select"
v-model="baTable.form.items!.controller"
prop="controller"
:input-attr="{ content: baTable.form.extend!.controllerList }"
:placeholder="t('security.dataRecycle.The data collection mechanism will monitor delete operations under this controller')"
/>
<FormItem
:label="t('Database connection')"
v-model="baTable.form.items!.connection"
type="remoteSelect"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
valueOnClear: '',
}"
/>
<FormItem
:label="t('security.dataRecycle.Corresponding data sheet')"
type="remoteSelect"
v-model="baTable.form.items!.data_table"
:key="baTable.form.items!.connection"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: baTable.form.items!.connection,
samePrefix: 1,
excludeTable: ['area', 'token', 'captcha', 'admin_group_access', 'user_money_log', 'user_score_log'],
},
remoteUrl: getTableListUrl,
onRow: onTableChange,
}"
prop="data_table"
/>
<FormItem
:label="t('security.dataRecycle.Data table primary key')"
type="string"
v-model="baTable.form.items!.primary_key"
prop="primary_key"
/>
<FormItem
:label="t('State')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { getTablePk, getTableListUrl, getDatabaseConnectionListUrl } from '/@/api/common'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('security.dataRecycle.Rule name') })],
controller: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.dataRecycle.controller') }),
}),
],
data_table: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.dataRecycle.data sheet') }),
}),
],
primary_key: [buildValidatorData({ name: 'required', trigger: 'change', title: t('security.dataRecycle.Data table primary key') })],
})
const onTableChange = () => {
if (!baTable.form.items!.data_table) return
getTablePk(baTable.form.items!.data_table, baTable.form.items!.connection).then((res) => {
baTable.form.items!.primary_key = res.data.pk
baTable.form.defaultItems!.primary_key = res.data.pk
})
}
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.dataRecycleLog.Rule name') })"
>
<el-popconfirm
@confirm="onRestoreAction"
:confirm-button-text="t('security.dataRecycleLog.restore')"
:cancel-button-text="t('Cancel')"
confirmButtonType="success"
:title="t('security.dataRecycleLog.Are you sure to restore the selected records?')"
:disabled="baTable.table.selection!.length > 0 ? false : true"
>
<template #reference>
<div class="mlr-12">
<el-tooltip :content="t('security.dataRecycleLog.Restore the selected record to the original data table')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
class="table-header-operate"
type="success"
>
<Icon color="#ffffff" name="el-icon-RefreshRight" />
<span class="table-header-operate-text">{{ t('security.dataRecycleLog.restore') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<InfoDialog />
</div>
</template>
<script setup lang="ts">
import { provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { info, restore, url } from '/@/api/backend/security/dataRecycleLog'
import InfoDialog from './info.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { buildJsonToElTreeData } from '/@/utils/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/dataRecycleLog',
})
const { t } = useI18n()
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'info',
title: 'Info',
text: '',
type: 'primary',
icon: 'fa fa-search-plus',
class: 'table-row-info',
disabledTip: false,
click: (row: TableRow) => {
infoButtonClick(row[baTable.table.pk!])
},
},
{
render: 'confirmButton',
name: 'restore',
title: 'security.dataRecycleLog.restore',
text: '',
type: 'success',
icon: 'el-icon-RefreshRight',
class: 'table-row-edit',
popconfirm: {
confirmButtonText: t('security.dataRecycleLog.restore'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'success',
title: t('security.dataRecycleLog.Are you sure to restore the selected records?'),
},
disabledTip: false,
click: (row: TableRow) => {
onRestore([row[baTable.table.pk!]])
},
},
]
optButtons = optButtons.concat(defaultOptButtons(['delete']))
const baTable = new baTableClass(new baTableApi(url), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{
label: t('security.dataRecycleLog.Operation administrator'),
prop: 'admin.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.Recycling rule name'),
prop: 'recycle.name',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.controller'),
prop: 'recycle.controller_as',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.DeletedData'),
prop: 'data',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('security.dataRecycleLog.Arbitrary fragment fuzzy query'),
showOverflowTooltip: true,
},
{ label: 'IP', prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
show: false,
label: 'User Agent',
prop: 'useragent',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('security.dataRecycleLog.Delete time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{
label: t('Operate'),
align: 'center',
width: 120,
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined],
})
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
infoButtonClick(row[baTable.table.pk!])
return false
}
const onRestore = (ids: string[]) => {
restore(ids).then(() => {
baTable.onTableHeaderAction('refresh', {})
})
}
const onRestoreAction = () => {
onRestore(baTable.getSelectionIds())
}
const infoButtonClick = (id: string) => {
baTable.form.extend!['info'] = {}
baTable.form.operate = 'Info'
baTable.form.loading = true
info(id).then((res) => {
res.data.row.data = res.data.row.data
? [{ label: t('security.dataRecycleLog.Click to expand'), children: buildJsonToElTreeData(res.data.row.data) }]
: []
baTable.form.extend!['info'] = res.data.row
baTable.form.loading = false
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss">
.table-header-operate {
margin-left: 12px;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<el-dialog class="ba-operate-dialog" :model-value="baTable.form.operate ? true : false" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ t('Info') }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'">
<el-descriptions v-if="!isEmpty(baTable.form.extend!.info)" :column="2" border>
<el-descriptions-item :label="t('Id')">
{{ baTable.form.extend!.info.id }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Operation administrator')">
{{ baTable.form.extend!.info.admin?.nickname + '(' + baTable.form.extend!.info.admin?.username + ')' }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Recycling rule name')">
{{ baTable.form.extend!.info.recycle?.name }}
</el-descriptions-item>
<el-descriptions-item :label="t('Connection')">
{{ baTable.form.extend!.info.connection }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.data sheet')">
{{ baTable.form.extend!.info.data_table }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Data table primary key')">
{{ baTable.form.extend!.info.primary_key }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Operator IP')">
{{ baTable.form.extend!.info.ip }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Delete time')">
{{ timeFormat(baTable.form.extend!.info.create_time) }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="User Agent">
{{ baTable.form.extend!.info.useragent }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" :label="t('security.dataRecycleLog.Deleted data')" label-class-name="color-red">
<el-tree class="table-el-tree" :data="baTable.form.extend!.info.data" :props="{ label: 'label', children: 'children' }" />
</el-descriptions-item>
</el-descriptions>
</div>
</el-scrollbar>
<template #footer>
<el-button v-blur @click="onRestore(baTable.form.extend!.info.id)" type="success">
<Icon color="#ffffff" name="el-icon-RefreshRight" />
<span class="table-header-operate-text">{{ t('security.dataRecycleLog.restore') }}</span>
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type BaTable from '/@/utils/baTable'
import { timeFormat } from '/@/utils/common'
import { isEmpty } from 'lodash-es'
import { ElMessageBox } from 'element-plus'
import { restore } from '/@/api/backend/security/dataRecycleLog'
const baTable = inject('baTable') as BaTable
const { t } = useI18n()
const onRestore = (id: string) => {
ElMessageBox.confirm(t('security.dataRecycleLog.Are you sure to restore the selected records?'), '', {
confirmButtonText: t('security.dataRecycleLog.restore'),
cancelButtonText: t('Cancel'),
})
.then(() => {
restore([id]).then(() => {
baTable.toggleForm()
baTable.onTableHeaderAction('refresh', {})
})
})
.catch(() => {})
}
</script>
<style scoped lang="scss">
:deep(.color-red) {
color: var(--el-color-danger) !important;
}
.table-el-tree {
:deep(.el-tree-node) {
white-space: unset;
}
:deep(.el-tree-node__content) {
display: block;
align-items: unset;
height: unset;
}
}
</style>

View File

@@ -0,0 +1,113 @@
import baTableClass from '/@/utils/baTable'
import type { baTableApi } from '/@/api/common'
import { getTableFieldList } from '/@/api/common'
import { add } from '/@/api/backend/security/sensitiveData'
import { uuid } from '/@/utils/random'
export interface DataFields {
name: string
value: string
}
export class sensitiveDataClass extends baTableClass {
constructor(api: baTableApi, table: BaTable, form: BaTableForm = {}, before: BaTableBefore = {}, after: BaTableAfter = {}) {
super(api, table, form, before, after)
}
// 重写编辑
getEditData = (id: string) => {
this.form.loading = true
this.form.items = {}
return this.api.edit({ id: id }).then((res) => {
const fields: string[] = []
const dataFields: DataFields[] = []
for (const key in res.data.row.data_fields) {
fields.push(key)
dataFields.push({
name: key,
value: res.data.row.data_fields[key] ?? '',
})
}
this.form.items!.connection = res.data.row.connection ? res.data.row.connection : ''
this.form.extend!.controllerList = res.data.controllers
if (res.data.row.data_table) {
this.onTableChange(res.data.row.data_table)
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields(dataFields)
}
res.data.row.data_fields = fields
this.form.loading = false
this.form.items = res.data.row
})
}
onConnectionChange = () => {
this.form.extend!.fieldList = {}
this.form.extend!.fieldSelect = {}
this.form.extend!.fieldSelectKey = uuid()
this.form.items!.data_table = ''
this.form.items!.data_fields = []
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields([])
}
// 数据表改变事件
onTableChange = (table: string) => {
this.form.extend = Object.assign(this.form.extend!, {
fieldLoading: true,
fieldList: {},
fieldSelect: {},
fieldSelectKey: uuid(),
})
this.form.items!.data_fields = []
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields([])
getTableFieldList(table, true, this.form.items!.connection).then((res) => {
this.form.items!.primary_key = res.data.pk
this.form.defaultItems!.primary_key = res.data.pk
const fieldSelect: anyObj = {}
for (const key in res.data.fieldList) {
fieldSelect[key] = (key ? key + ' - ' : '') + res.data.fieldList[key]
}
this.form.extend = Object.assign(this.form.extend!, {
fieldLoading: false,
fieldList: res.data.fieldList,
fieldSelect: fieldSelect,
fieldSelectKey: uuid(),
})
})
}
/**
* 重写打开表单方法
*/
toggleForm = (operate = '', operateIds: string[] = []) => {
if (this.form.ref) {
this.form.ref.resetFields()
}
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields([])
if (operate == 'Edit') {
if (!operateIds.length) {
return false
}
this.getEditData(operateIds[0])
} else if (operate == 'Add') {
this.form.loading = true
add().then((res) => {
this.form.extend!.controllerList = res.data.controllers
this.form.items = Object.assign({}, this.form.defaultItems)
this.form.loading = false
})
}
this.form.operate = operate
this.form.operateIds = operateIds
}
}

View File

@@ -0,0 +1,125 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.sensitiveData.controller') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { sensitiveDataClass } from './index'
import { url } from '/@/api/backend/security/sensitiveData'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/dataRecycle',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable = new sensitiveDataClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: 'ID', prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('security.sensitiveData.Rule name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('security.sensitiveData.controller'),
prop: 'controller',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveData.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveData.Data table primary key'),
prop: 'primary_key',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 100,
},
{
label: t('security.sensitiveData.Sensitive fields'),
prop: 'data_fields',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.sensitiveData.Modifying monitoring') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 1,
},
}
)
baTable.before.onSubmit = () => {
baTable.form.items!.fields = formRef.value?.getDataFields()
}
provide('baTable', baTable)
onMounted(() => {
baTable.form.extend!.parentRef = formRef.value
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,229 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('security.sensitiveData.Rule name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('security.sensitiveData.The rule name helps to identify the modified data later')"
/>
<FormItem
:label="t('security.sensitiveData.controller')"
type="select"
v-model="baTable.form.items!.controller"
prop="controller"
:input-attr="{ content: baTable.form.extend!.controllerList }"
:placeholder="
t('security.sensitiveData.The data listening mechanism will monitor the modification operations under this controller')
"
/>
<FormItem
:label="t('Database connection')"
v-model="baTable.form.items!.connection"
type="remoteSelect"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
onChange: baTable.onConnectionChange,
valueOnClear: '',
}"
/>
<FormItem
:label="t('security.sensitiveData.Corresponding data sheet')"
type="remoteSelect"
v-model="baTable.form.items!.data_table"
:key="baTable.form.items!.connection"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: baTable.form.items!.connection,
samePrefix: 1,
excludeTable: ['area', 'token', 'captcha', 'admin_group_access', 'admin_log', 'user_money_log', 'user_score_log'],
},
remoteUrl: getTableListUrl,
onChange: baTable.onTableChange,
}"
prop="data_table"
/>
<FormItem
:label="t('security.sensitiveData.Data table primary key')"
type="string"
v-model="baTable.form.items!.primary_key"
prop="primary_key"
/>
<template v-if="!isEmpty(baTable.form.extend!.fieldSelect)">
<hr class="form-hr" />
<FormItem
:label="t('security.sensitiveData.Sensitive fields')"
type="selects"
v-model="baTable.form.items!.data_fields"
:key="baTable.form.extend!.fieldSelectKey"
prop="data_fields"
:input-attr="{
onChange: onFieldChange,
content: baTable.form.extend!.fieldSelect,
}"
v-loading="baTable.form.extend!.fieldLoading"
/>
<FormItem
v-for="(item, idx) in state.dataFields"
:key="idx"
:label="item.name"
type="string"
v-model="item.value"
:tip="t('security.sensitiveData.Filling in field notes helps you quickly identify fields later')"
/>
<hr class="form-hr" />
</template>
<FormItem
:label="t('State')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { sensitiveDataClass, DataFields } from './index'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
import { getTableListUrl, getDatabaseConnectionListUrl } from '/@/api/common'
import { isEmpty } from 'lodash-es'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as sensitiveDataClass
const { t } = useI18n()
const state: {
dataFields: DataFields[]
} = reactive({
dataFields: [],
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('security.sensitiveData.Rule name') })],
controller: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.sensitiveData.controller') }),
}),
],
data_table: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.sensitiveData.data sheet') }),
}),
],
primary_key: [
buildValidatorData({
name: 'required',
trigger: 'change',
title: t('security.sensitiveData.Data table primary key'),
}),
],
data_fields: [
buildValidatorData({
name: 'required',
message: t('Please select field', { field: t('security.sensitiveData.Sensitive fields') }),
}),
],
})
/**
* 敏感数据字段更新
* 保留原始输入,而又需要去掉已删除的字段
*/
const onFieldChange = (val: string[]) => {
let dataFields: DataFields[] = []
for (const key in val) {
let exist: boolean | DataFields = false
for (const k in state.dataFields) {
if (state.dataFields[k].name == val[key]) {
exist = state.dataFields[k]
}
}
dataFields[key] = exist ? exist : { name: val[key], value: baTable.form.extend!.fieldList[val[key]] ?? '' }
}
state.dataFields = dataFields
}
const getDataFields = () => {
return state.dataFields
}
const setDataFields = (dataFields: DataFields[]) => {
state.dataFields = dataFields
}
defineExpose({
getDataFields,
setDataFields,
})
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
.form-hr {
border-color: #dcdfe6;
border-style: solid;
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.sensitiveDataLog.Rule name') })"
>
<el-popconfirm
@confirm="onRollbackAction"
:confirm-button-text="t('security.sensitiveDataLog.RollBACK')"
:cancel-button-text="t('Cancel')"
confirmButtonType="success"
:title="t('security.sensitiveDataLog.Are you sure you want to rollback the record?')"
:disabled="baTable.table.selection!.length > 0 ? false : true"
>
<template #reference>
<div class="mlr-12">
<el-tooltip :content="t('security.sensitiveDataLog.Rollback the selected record to the original data table')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
class="table-header-operate"
type="success"
>
<Icon size="16" color="#ffffff" name="fa fa-sign-in" />
<span class="table-header-operate-text">{{ t('security.sensitiveDataLog.RollBACK') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<InfoDialog />
</div>
</template>
<script setup lang="ts">
import { provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { info, rollback, url } from '/@/api/backend/security/sensitiveDataLog'
import InfoDialog from './info.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/sensitiveDataLog',
})
const { t } = useI18n()
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'info',
title: 'Info',
text: '',
type: 'primary',
icon: 'fa fa-search-plus',
class: 'table-row-info',
disabledTip: false,
click: (row: TableRow) => {
infoButtonClick(row[baTable.table.pk!])
},
},
{
render: 'confirmButton',
name: 'rollback',
title: 'security.sensitiveDataLog.RollBACK',
text: '',
type: 'success',
icon: 'fa fa-sign-in',
class: 'table-row-edit',
popconfirm: {
confirmButtonText: t('security.sensitiveDataLog.RollBACK'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'success',
title: t('security.sensitiveDataLog.Are you sure you want to rollback the record?'),
},
disabledTip: false,
click: (row: TableRow) => {
onRollback([row[baTable.table.pk!]])
},
},
]
optButtons = optButtons.concat(defaultOptButtons(['delete']))
const baTable = new baTableClass(new baTableApi(url), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{
label: t('security.sensitiveDataLog.Operation administrator'),
prop: 'admin.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Rule name'),
prop: 'sensitive.name',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.controller'),
prop: 'sensitive.controller_as',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Modify line'),
prop: 'id_value',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Modification'),
prop: 'data_comment',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Before modification'),
prop: 'before',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('security.sensitiveDataLog.After modification'),
prop: 'after',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: 'IP', prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('security.sensitiveDataLog.Modification time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{
label: t('Operate'),
align: 'center',
width: 120,
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined],
})
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
infoButtonClick(row[baTable.table.pk!])
return false
}
const onRollback = (ids: string[]) => {
rollback(ids).then(() => {
baTable.onTableHeaderAction('refresh', {})
})
}
const onRollbackAction = () => {
onRollback(baTable.getSelectionIds())
}
const infoButtonClick = (id: string) => {
baTable.form.extend!['info'] = {}
baTable.form.operate = 'Info'
baTable.form.loading = true
info(id).then((res) => {
baTable.form.extend!['info'] = res.data.row
baTable.form.loading = false
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss">
.table-header-operate {
margin-left: 12px;
}
.table-header-operate-text {
margin-left: 6px;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<el-dialog class="ba-operate-dialog" :model-value="baTable.form.operate ? true : false" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ t('Info') }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'">
<el-descriptions v-if="!isEmpty(baTable.form.extend!.info)" :column="2" border>
<el-descriptions-item :width="120" :span="2" :label="t('security.sensitiveDataLog.Rule name')">
{{ baTable.form.extend!.info.sensitive?.name }}
</el-descriptions-item>
<el-descriptions-item :label="t('Id')">
{{ baTable.form.extend!.info.id }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Operation administrator')">
{{ baTable.form.extend!.info.admin?.nickname + '(' + baTable.form.extend!.info.admin?.username + ')' }}
</el-descriptions-item>
<el-descriptions-item :label="t('Connection')">
{{ baTable.form.extend!.info.connection }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.data sheet')">
{{ baTable.form.extend!.info.data_table }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Modification time')">
{{ timeFormat(baTable.form.extend!.info.create_time) }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Operator IP')">
{{ baTable.form.extend!.info.ip }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Data table primary key')">
{{ baTable.form.extend!.info.primary_key + '=' + baTable.form.extend!.info.id_value }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Modified item')">
{{
baTable.form.extend!.info.data_field +
(baTable.form.extend!.info.data_comment ? '(' + baTable.form.extend!.info.data_comment + ')' : '')
}}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Before modification')" label-class-name="color-red">
<div class="info-content">{{ baTable.form.extend!.info.before }}</div>
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.After modification')" label-class-name="color-red">
<div class="info-content">{{ baTable.form.extend!.info.after }}</div>
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="User Agent">
{{ baTable.form.extend!.info.useragent }}
</el-descriptions-item>
</el-descriptions>
<div class="diff-box">
<div class="diff-box-title">{{ t('security.sensitiveDataLog.Modification comparison') }}</div>
<code-diff
diffStyle="char"
:old-string="baTable.form.extend!.info.before ?? ''"
:new-string="baTable.form.extend!.info.after ?? ''"
/>
</div>
</div>
</el-scrollbar>
<template #footer>
<el-button v-blur @click="onRollback(baTable.form.extend!.info.id)" type="success">
<Icon size="16" color="#ffffff" name="fa fa-sign-in" />
<span class="table-header-operate-text">{{ t('security.sensitiveDataLog.RollBACK') }}</span>
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type BaTable from '/@/utils/baTable'
import { timeFormat } from '/@/utils/common'
import { isEmpty } from 'lodash-es'
import { ElMessageBox } from 'element-plus'
import { rollback } from '/@/api/backend/security/sensitiveDataLog'
import { CodeDiff } from 'v-code-diff'
const baTable = inject('baTable') as BaTable
const { t } = useI18n()
const onRollback = (id: string) => {
ElMessageBox.confirm(t('security.sensitiveDataLog.Are you sure you want to rollback the record?'), '', {
confirmButtonText: t('security.sensitiveDataLog.RollBACK'),
cancelButtonText: t('Cancel'),
})
.then(() => {
rollback([id]).then(() => {
baTable.toggleForm()
baTable.onTableHeaderAction('refresh', {})
})
})
.catch(() => {})
}
</script>
<style scoped lang="scss">
:deep(.color-red) {
color: var(--el-color-danger) !important;
}
.table-el-tree {
:deep(.el-tree-node) {
white-space: unset;
}
:deep(.el-tree-node__content) {
display: block;
align-items: unset;
height: unset;
}
}
.info-content {
word-wrap: break-word;
word-break: break-all;
}
.table-header-operate-text {
margin-left: 6px;
}
.diff-box :deep(.d2h-file-wrapper) {
border: 1px solid #ebeef5;
}
.diff-box-title {
display: flex;
font-weight: bold;
line-height: 40px;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.group.GroupName') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { getUserRules } from '/@/api/backend/user/group'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import { uuid } from '/@/utils/random'
defineOptions({
name: 'user/group',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Group/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.group.Group name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 1,
},
}
)
// 利用提交前钩子重写提交操作
baTable.before.onSubmit = ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
baTable.api
.postData(operate, {
...items,
rules: formRef.value?.getCheckeds(),
})
.then((res) => {
baTable.onTableHeaderAction('refresh', {})
baTable.form.submitLoading = false
baTable.form.operateIds?.shift()
if (baTable.form.operateIds!.length > 0) {
baTable.toggleForm('Edit', baTable.form.operateIds)
} else {
baTable.toggleForm()
}
baTable.runAfter('onSubmit', { res })
})
.catch(() => {
baTable.form.submitLoading = false
})
}
if (formEl) {
baTable.form.ref = formEl
formEl.validate((valid) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
return false
}
// 打开表单后
baTable.after.toggleForm = ({ operate }) => {
if (operate == 'Add') {
menuRuleTreeUpdate()
}
}
// 获取到编辑数据后
baTable.after.getEditData = () => {
menuRuleTreeUpdate()
}
const menuRuleTreeUpdate = () => {
getUserRules().then((res) => {
baTable.form.extend!.menuRules = res.data.list
if (baTable.form.items!.rules && baTable.form.items!.rules.length) {
if (baTable.form.items!.rules.includes('*')) {
let arr: number[] = []
for (const key in baTable.form.extend!.menuRules) {
arr.push(baTable.form.extend!.menuRules[key].id)
}
baTable.form.extend!.defaultCheckedKeys = arr
} else {
baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules
}
} else {
baTable.form.extend!.defaultCheckedKeys = []
}
baTable.form.extend!.treeKey = uuid()
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,145 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="10vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<el-form-item prop="name" :label="t('user.group.Group name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('Please input field', { field: t('user.group.Group name') })"
></el-input>
</el-form-item>
<el-form-item prop="auth" :label="t('user.group.jurisdiction')">
<el-tree
ref="treeRef"
:key="baTable.form.extend!.treeKey"
:default-checked-keys="baTable.form.extend!.defaultCheckedKeys"
:default-expand-all="true"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'title', class: treeNodeClass }"
:data="baTable.form.extend!.menuRules"
class="w100"
/>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import type { ElTree, FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('user.group.Group name') })],
auth: [
{
required: true,
validator: (rule: any, val: string, callback: Function) => {
let ids = getCheckeds()
if (ids.length <= 0) {
return callback(new Error(t('Please select field', { field: t('user.group.jurisdiction') })))
}
return callback()
},
},
],
})
const getCheckeds = () => {
return treeRef.value!.getCheckedKeys().concat(treeRef.value!.getHalfCheckedKeys())
}
const treeNodeClass = (data: anyObj, node: Node) => {
if (node.isLeaf) return ''
let addClass = true
for (const key in node.childNodes) {
if (!node.childNodes[key].isLeaf) {
addClass = false
}
}
return addClass ? 'penultimate-node' : ''
}
defineExpose({
getCheckeds,
})
</script>
<style scoped lang="scss">
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;
white-space: pre-wrap;
line-height: 12px;
.el-tree-node {
display: inline-block;
}
.el-tree-node__content {
padding-left: 5px !important;
padding-right: 5px;
.el-tree-node__expand-icon {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.moneyLog.balance') + ':' + state.userInfo.money }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/moneyLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/moneyLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.moneyLog.Change balance'), prop: 'money', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.before.onTableAction = ({ event }) => {
// 公共搜索
if (event === 'com-search') {
baTable.table.filter!.search = baTable.getComSearchData()
for (const key in baTable.table.filter!.search) {
if (['money', 'before', 'after'].includes(baTable.table.filter!.search[key].field)) {
const val = (baTable.table.filter!.search[key].val as string).split(',')
const newVal: (string | number)[] = []
for (const k in val) {
newVal.push(isNaN(parseFloat(val[k])) ? '' : parseFloat(val[k]) * 100)
}
baTable.table.filter!.search[key].val = newVal.join(',')
}
}
baTable.onTableHeaderAction('refresh', { event: 'com-search', data: baTable.table.filter!.search })
return false
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,160 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Current balance')">
<el-input v-model="state.userInfo.money" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="money" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeMoney"
v-model="baTable.form.items!.money"
type="number"
:placeholder="t('user.moneyLog.Please enter the balance change amount')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Balance after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.moneyLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/moneyLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
money: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseFloat(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.money
})
}
const changeMoney = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat((parseFloat(state.userInfo.money) + newValue).toFixed(2))
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.rule.Rule title') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" :pagination="false" />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'user/rule',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Rule/'),
{
expandAll: false,
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('auth.rule.title'), prop: 'title', align: 'left', width: '200' },
{ label: t('auth.rule.Icon'), prop: 'icon', align: 'center', width: '60', render: 'icon', default: 'fa fa-circle-o' },
{ label: t('auth.rule.name'), prop: 'name', align: 'center', showOverflowTooltip: true },
{
label: t('auth.rule.type'),
prop: 'type',
align: 'center',
render: 'tag',
custom: { menu: 'danger', menu_dir: 'success', route: 'info' },
replaceValue: {
menu: t('user.rule.Member center menu items'),
menu_dir: t('user.rule.Member center menu contents'),
route: t('user.rule.Normal routing'),
nav: t('user.rule.Top bar menu items'),
button: t('user.rule.Page button'),
nav_user_menu: t('user.rule.Top bar user dropdown'),
},
},
{ label: t('State'), prop: 'status', align: 'center', width: '80', render: 'switch' },
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons() },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
type: 'route',
menu_type: 'tab',
extend: 'none',
no_login_valid: '0',
keepalive: 0,
status: 1,
icon: 'fa fa-circle-o',
},
}
)
// 表单提交前
baTable.before.onSubmit = () => {
if (baTable.form.items!.type == 'route') {
baTable.form.items!.menu_type = 'tab'
} else if (['menu', 'menu_dir', 'nav_user_menu'].includes(baTable.form.items!.type)) {
baTable.form.items!.no_login_valid = '0'
}
}
// 取得编辑行的数据后
baTable.after.getEditData = () => {
if (baTable.form.items && !baTable.form.items.icon) {
baTable.form.items.icon = 'fa fa-circle-o'
}
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,237 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="5vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="pid"
:label="t('auth.rule.Superior menu rule')"
v-model="baTable.form.items!.pid"
:placeholder="t('Click select')"
:input-attr="{
params: { isTree: true },
field: 'title',
remoteUrl: baTable.api.actionUrl.get('index'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<el-form-item :label="t('auth.rule.Rule type')">
<el-radio-group v-model="baTable.form.items!.type">
<el-radio class="ba-el-radio" value="route" :border="true">{{ t('user.rule.Normal routing') }}</el-radio>
<el-radio class="ba-el-radio" value="menu_dir" :border="true">{{ t('user.rule.Member center menu contents') }}</el-radio>
<el-radio class="ba-el-radio" value="menu" :border="true">{{ t('user.rule.Member center menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="nav" :border="true">{{ t('user.rule.Top bar menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="button" :border="true">{{ t('user.rule.Page button') }}</el-radio>
<el-radio class="ba-el-radio" value="nav_user_menu" :border="true">{{ t('user.rule.Top bar user dropdown') }}</el-radio>
</el-radio-group>
<div class="block-help">{{ t('user.rule.Type ' + baTable.form.items!.type + ' tips') }}</div>
</el-form-item>
<el-form-item prop="title" :label="t('auth.rule.Rule title')">
<el-input
v-model="baTable.form.items!.title"
type="string"
:placeholder="t('Please input field', { field: t('auth.rule.Rule title') })"
></el-input>
</el-form-item>
<el-form-item prop="name" :label="t('auth.rule.Rule name')">
<el-input v-model="baTable.form.items!.name" type="string" :placeholder="t('user.rule.English name')"></el-input>
<div class="block-help">
{{ t('auth.rule.It will be registered as the web side routing name and used as the server side API authentication') }}
</div>
</el-form-item>
<el-form-item v-if="baTable.form.items!.type != 'button'" prop="path" :label="t('auth.rule.Routing path')">
<el-input v-model="baTable.form.items!.path" type="string" :placeholder="t('user.rule.Web side routing path')"></el-input>
</el-form-item>
<!-- 规则图标 -->
<FormItem
v-if="baTable.form.items!.type != 'button'"
type="icon"
:label="t('auth.rule.Rule Icon')"
v-model="baTable.form.items!.icon"
:input-attr="{ showIconName: true }"
/>
<!-- 菜单类型tablinkiframe -->
<FormItem
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type)"
:label="t('auth.rule.Menu type')"
v-model="baTable.form.items!.menu_type"
type="radio"
:input-attr="{
border: true,
content: { tab: t('auth.rule.Menu type tab'), link: t('auth.rule.Menu type link (offsite)'), iframe: 'Iframe' },
}"
/>
<!-- URL -->
<el-form-item
prop="url"
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type != 'tab'"
:label="t('auth.rule.Link address')"
>
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('auth.rule.Please enter the URL address of the link or iframe')"
></el-input>
</el-form-item>
<!-- 组件路径 -->
<el-form-item
v-if="
baTable.form.items!.type == 'route' ||
(!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab')
"
:label="t('auth.rule.Component path')"
>
<el-input
v-model="baTable.form.items!.component"
type="string"
:placeholder="t('user.rule.For example, if you add account/overview as a route only')"
></el-input>
<div class="block-help component-path-tips">
{{ t('user.rule.Component path tips') }}
</div>
</el-form-item>
<!-- 扩展属性 -->
<el-form-item
v-if="!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Extended properties')"
>
<el-select
class="w100"
v-model="baTable.form.items!.extend"
:placeholder="t('Please select field', { field: t('auth.rule.Extended properties') })"
>
<el-option :label="t('auth.rule.none')" value="none"></el-option>
<el-option :label="t('auth.rule.Add as route only')" value="add_rules_only"></el-option>
<el-option :label="t('auth.rule.Add as menu only')" value="add_menu_only"></el-option>
</el-select>
<div class="block-help">
{{ t('user.rule.Web side component path, please start with /src, such as: /src/views/frontend/index') }}
</div>
</el-form-item>
<FormItem
v-if="!['menu_dir', 'menu', 'nav_user_menu'].includes(baTable.form.items!.type)"
:label="t('user.rule.no_login_valid')"
v-model="baTable.form.items!.no_login_valid"
type="radio"
:input-attr="{
border: true,
content: { '0': t('user.rule.no_login_valid 0'), '1': t('user.rule.no_login_valid 1') },
}"
:block-help="t('user.rule.no_login_valid tips')"
/>
<el-form-item :label="t('auth.rule.Rule comments')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="t('Please input field', { field: t('auth.rule.Rule comments') })"
></el-input>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule weight')">
<el-input
v-model="baTable.form.items!.weigh"
type="number"
:placeholder="t('auth.rule.Please enter the weight of menu rule (sort by)')"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule title') })],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.rule.The superior menu rule cannot be the rule itself')))
}
return callback()
},
trigger: 'blur',
},
],
name: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule name') })],
path: [buildValidatorData({ name: 'required', title: t('auth.rule.Routing path') })],
url: [
buildValidatorData({ name: 'required', message: t('auth.rule.Link address') }),
buildValidatorData({ name: 'url', message: t('auth.rule.Please enter the correct URL') }),
],
})
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
.component-path-tips {
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.scoreLog.integral') + ':' + state.userInfo.score }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/scoreLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/scoreLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.scoreLog.Change points'), prop: 'score', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,160 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Current points')">
<el-input v-model="state.userInfo.score" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="score" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeScore"
v-model="baTable.form.items!.score"
type="number"
:placeholder="t('user.scoreLog.Please enter the change amount of points')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Points after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.scoreLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/scoreLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
score: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseInt(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.score
})
}
const changeScore = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat(state.userInfo.score) + newValue
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.User name') + '/' + t('user.user.nickname') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'user/user',
})
const { t } = useI18n()
const baTable = new baTableClass(
new baTableApi('/admin/user.User/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.user.User name'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('user.user.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.user.group'),
prop: 'userGroup.name',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
},
{ label: t('user.user.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{
label: t('user.user.Gender'),
prop: 'gender',
align: 'center',
render: 'tag',
custom: { '0': 'info', '1': '', '2': 'success' },
replaceValue: { '0': t('Unknown'), '1': t('user.user.male'), '2': t('user.user.female') },
},
{ label: t('user.user.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.user.Last login IP'),
prop: 'last_login_ip',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
},
{
label: t('user.user.Last login'),
prop: 'last_login_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') },
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
gender: 0,
money: '0',
score: '0',
status: 'enable',
},
}
)
baTable.mount()
baTable.getData()
provide('baTable', baTable)
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,238 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<el-form-item prop="username" :label="t('user.user.User name')">
<el-input
v-model="baTable.form.items!.username"
type="string"
:placeholder="t('Please input field', { field: t('user.user.User name') + '(' + t('user.user.Login account') + ')' })"
></el-input>
</el-form-item>
<el-form-item prop="nickname" :label="t('user.user.nickname')">
<el-input
v-model="baTable.form.items!.nickname"
type="string"
:placeholder="t('Please input field', { field: t('user.user.nickname') })"
></el-input>
</el-form-item>
<FormItem
type="remoteSelect"
:label="t('user.user.group')"
v-model="baTable.form.items!.group_id"
prop="group_id"
:placeholder="t('user.user.group')"
:input-attr="{
params: { isTree: true, search: [{ field: 'status', val: '1', operator: 'eq' }] },
field: 'name',
remoteUrl: '/admin/user.Group/index',
}"
/>
<FormItem :label="t('user.user.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<el-form-item prop="email" :label="t('user.user.email')">
<el-input
v-model="baTable.form.items!.email"
type="string"
:placeholder="t('Please input field', { field: t('user.user.email') })"
></el-input>
</el-form-item>
<el-form-item prop="mobile" :label="t('user.user.mobile')">
<el-input
v-model="baTable.form.items!.mobile"
type="string"
:placeholder="t('Please input field', { field: t('user.user.mobile') })"
></el-input>
</el-form-item>
<FormItem
:label="t('user.user.Gender')"
v-model="baTable.form.items!.gender"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Unknown'), 1: t('user.user.male'), 2: t('user.user.female') },
}"
/>
<el-form-item :label="t('user.user.birthday')">
<el-date-picker
class="w100"
value-format="YYYY-MM-DD"
v-model="baTable.form.items!.birthday"
type="date"
:placeholder="t('Please select field', { field: t('user.user.birthday') })"
/>
</el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.balance')">
<el-input v-model="baTable.form.items!.money" readonly>
<template #append>
<el-button @click="changeAccount('money')">{{ t('user.user.Adjustment balance') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.integral')">
<el-input v-model="baTable.form.items!.score" readonly>
<template #append>
<el-button @click="changeAccount('score')">{{ t('user.user.Adjust integral') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password" :label="t('user.user.password')">
<el-input
v-model="baTable.form.items!.password"
type="password"
autocomplete="new-password"
:placeholder="
baTable.form.operate == 'Add'
? t('Please input field', { field: t('user.user.password') })
: t('user.user.Please leave blank if not modified')
"
></el-input>
</el-form-item>
<el-form-item prop="motto" :label="t('user.user.Personal signature')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.motto"
type="textarea"
:placeholder="t('Please input field', { field: t('user.user.Personal signature') })"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { disable: t('Disable'), enable: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { regularPassword } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import router from '/@/router/index'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('user.user.User name') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('user.user.nickname') })],
group_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.user.group') }) })],
email: [buildValidatorData({ name: 'email', title: t('user.user.email') })],
mobile: [buildValidatorData({ name: 'mobile' })],
password: [
{
validator: (rule: any, val: string, callback: Function) => {
if (baTable.form.operate == 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('user.user.password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
})
const changeAccount = (type: string) => {
baTable.toggleForm()
router.push({
name: type == 'money' ? 'user/moneyLog' : 'user/scoreLog',
query: {
user_id: baTable.form.items!.id,
},
})
}
watch(
() => baTable.form.operate,
(newVal) => {
rules.password![0].required = newVal == 'Add'
}
)
</script>
<style scoped lang="scss">
.avatar-uploader {
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: var(--el-border-radius-small);
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
cursor: pointer;
overflow: hidden;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>