feat: 完成系统管理模块功能迭代

新增用户、菜单、日志、问题帮助等业务模块,优化角色权限分配功能,新增依赖包与全局组件
This commit is contained in:
sheng
2026-05-29 18:12:54 +08:00
parent a61036e5dc
commit 20a821ba32
28 changed files with 5512 additions and 39984 deletions

View File

@@ -0,0 +1,682 @@
<template>
<d2-container>
<template #header>
<el-form :inline="true" :model="searchForm" ref="searchFormRef" size="mini">
<el-form-item :label="$t(key('link_type_module'))" prop="module">
<el-radio-group v-model="searchForm.module" @change="handleSearch" size="small">
<el-radio-button :label="$t(key('admin'))"></el-radio-button>
<el-radio-button :label="$t(key('pda'))"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="$t(key('status'))" prop="status">
<el-select
v-model="searchForm.status"
:placeholder="$t(key('please_select'))"
style="width: 120px;"
clearable
>
<el-option :label="$t(key('enable'))" :value="1" />
<el-option :label="$t(key('disable'))" :value="0" />
</el-select>
</el-form-item>
<el-form-item :label="$t(key('navigation_property'))" prop="is_navi">
<el-select
v-model="searchForm.is_navi"
:placeholder="$t(key('please_select'))"
style="width: 120px;"
clearable
>
<el-option :label="$t(key('navigation_visible'))" :value="1" />
<el-option :label="$t(key('navigation_hidden'))" :value="0" />
</el-select>
</el-form-item>
<el-form-item :label="$t(key('menu_depth'))" prop="level">
<el-input-number
v-model="searchForm.level"
controls-position="right"
:min="0"
style="width: 100px;"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
icon="el-icon-search"
:disabled="loading"
@click="handleSearch"
>
{{ $t(key('query')) }}
</el-button>
</el-form-item>
<el-form-item>
<el-button icon="el-icon-refresh" @click="handleReset">
{{ $t(key('reset')) }}
</el-button>
</el-form-item>
</el-form>
</template>
<div class="menu-config">
<el-form :inline="true" size="small" @submit.native.prevent>
<el-form-item v-if="auth.create">
<el-button
icon="el-icon-plus"
:disabled="loading"
@click="handleCreateTop"
>
{{ $t(key('add_top_menu')) }}
</el-button>
</el-form-item>
<el-form-item>
<el-button-group>
<el-button icon="el-icon-circle-plus-outline" :disabled="loading" @click="toggleExpand(true)">
{{ $t(key('expand')) }}
</el-button>
<el-button icon="el-icon-remove-outline" :disabled="loading" @click="toggleExpand(false)">
{{ $t(key('collapse')) }}
</el-button>
</el-button-group>
</el-form-item>
<el-form-item :label="$t(key('filter'))">
<el-input
v-model="filterText"
:disabled="loading"
:placeholder="$t(key('filter_placeholder'))"
prefix-icon="el-icon-search"
style="width: 240px;"
clearable
/>
</el-form-item>
</el-form>
<el-row :gutter="20">
<el-col :span="10">
<el-tree
v-if="hackReset"
ref="tree"
class="tree-scroll"
node-key="menu_id"
:data="treeData"
:props="treeProps"
:filter-node-method="filterNode"
highlight-current
:default-expand-all="isExpandAll"
:default-expanded-keys="expanded"
draggable
:allow-drag="allowDrag"
@node-click="handleNodeClick"
@node-drop="handleDrop"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
>
<span class="custom-tree-node" slot-scope="{ node, data }">
<span class="tree-label" :class="{ 'status-disabled': !data.status }">
<i v-if="auth.move" class="el-icon-sort move-tree cs-mr-5" />
<i v-if="data.icon" :class="'fa fa-' + data.icon" />
<i v-else-if="data.children" :class="'el-icon-' + (node.expanded ? 'folder-opened' : 'folder')" />
<i v-else class="el-icon-document" />
{{ node.label }}
</span>
<span class="node-actions">
<el-button
v-if="auth.create"
type="text"
size="mini"
@click.stop="handleAppend(data)"
>
{{ $t(key('add')) }}
</el-button>
<el-button
v-if="auth.disabled_enable"
type="text"
size="mini"
@click.stop="handleToggleStatus(data)"
>
{{ data.status ? $t(key('disable')) : $t(key('enable')) }}
</el-button>
<el-button
v-if="auth.delete"
type="text"
size="mini"
@click.stop="handleRemove(data.menu_id)"
>
{{ $t(key('delete')) }}
</el-button>
</span>
</span>
</el-tree>
</el-col>
<el-col :span="14">
<el-card v-show="auth.create || auth.edit" class="box-card" shadow="never">
<div slot="header">
<span>{{ $t(key(formStatus === 'create' ? 'add_menu' : 'edit_menu')) }}</span>
<el-button
v-if="formStatus === 'create' && auth.create"
type="text"
:loading="formLoading"
style="float: right; padding: 3px 0;"
@click="handleCreateSubmit"
>
{{ $t(key('confirm')) }}
</el-button>
<el-button
v-else-if="formStatus === 'update' && auth.edit"
type="text"
:loading="formLoading"
style="float: right; padding: 3px 0;"
@click="handleUpdateSubmit"
>
{{ $t(key('modify')) }}
</el-button>
</div>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item :label="$t(key('parent_menu'))" prop="parent_id">
<el-cascader
v-model="form.parent_id"
:placeholder="$t(key('parent_menu_placeholder'))"
:key="form.menu_id"
:options="treeData"
:props="cascaderProps"
style="width: 100%;"
filterable
clearable
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t(key('menu_name'))" prop="name">
<el-input
v-model="form.name"
:placeholder="$t(key('menu_name_placeholder'))"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t(key('menu_alias'))" prop="alias">
<el-input
v-model="form.alias"
:placeholder="$t(key('menu_alias_placeholder'))"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t(key('icon'))" prop="icon">
<d2-icon-select
v-model="form.icon"
:user-input="true"
:placeholder="$t(key('select_menu_icon'))"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t(key('sort'))" prop="sort">
<el-input-number
v-model="form.sort"
:min="0"
:max="255"
style="width: 120px;"
controls-position="right"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t(key('navigation'))" prop="is_navi">
<el-switch v-model="form.is_navi" active-value="1" inactive-value="0" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t(key('link_type'))" prop="type">
<el-radio-group v-model="form.type">
<el-radio :label="0">{{ $t(key('link_type_module')) }}</el-radio>
<el-radio :label="1">{{ $t(key('link_type_external')) }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t(key('open_type'))" prop="target">
<el-radio-group v-model="form.target">
<el-radio label="_self">{{ $t(key('open_self')) }}</el-radio>
<el-radio label="_blank">{{ $t(key('open_blank')) }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="URL" prop="url">
<el-input
v-model="form.url"
:placeholder="$t(key('url_placeholder'))"
clearable
/>
</el-form-item>
<el-form-item :label="$t(key('params'))" prop="params">
<el-input
v-model="form.params"
:placeholder="$t(key('params_placeholder'))"
clearable
/>
</el-form-item>
<el-form-item :label="$t(key('remark'))" prop="remark">
<el-input
v-model="form.remark"
:placeholder="$t(key('remark_placeholder'))"
type="textarea"
:rows="3"
/>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
</div>
</d2-container>
</template>
<script>
import util from '@/libs/util'
import { i18nMixin } from '@/composables/useI18n'
import { confirmMixin } from '@/composables/useConfirmHandle'
import {
getMenuList,
createMenu,
editMenu,
deleteMenu,
updateMenuStatus,
sortMenu
} from '@/api/system-administration/menu-configuration'
export default {
name: 'system-administration-menu-configuration',
mixins: [i18nMixin('page.system_administration.menu_management.menu_configuration'), confirmMixin],
data () {
const $t = this.$t.bind(this)
return {
loading: false,
formLoading: false,
treeData: [],
module: 'admin',
hackReset: true,
isExpandAll: false,
expanded: [],
oldExpanded: [],
filterText: '',
formStatus: 'create',
appendData: {},
appendType: '',
updateNode: {},
auth: {
create: true,
delete: true,
edit: true,
disabled_enable: true,
move: true
},
treeProps: {
label: 'name',
children: 'children'
},
cascaderProps: {
value: 'menu_id',
label: 'name',
children: 'children',
checkStrictly: true,
emitPath: false
},
searchForm: {
module: 'admin',
status: undefined,
is_navi: undefined,
level: 0
},
form: this.getDefaultForm(),
rules: {
name: [
{ required: true, message: $t(this.key('name_required')), trigger: 'blur' },
{ max: 32, message: $t(this.key('name_max_length')), trigger: 'blur' }
],
alias: [
{ max: 16, message: $t(this.key('alias_max_length')), trigger: 'blur' }
],
sort: [
{ type: 'number', message: $t(this.key('must_be_number')), trigger: 'blur' }
],
url: [
{ max: 255, message: $t(this.key('max_255_length')), trigger: 'blur' }
],
params: [
{ max: 255, message: $t(this.key('max_255_length')), trigger: 'blur' }
],
remark: [
{ max: 255, message: $t(this.key('max_255_length')), trigger: 'blur' }
]
}
}
},
watch: {
filterText (val) {
this.$refs.tree && this.$refs.tree.filter(val)
this.expanded = this.oldExpanded
}
},
mounted () {
this._validationAuth()
this.handleSearch()
},
methods: {
getDefaultForm () {
return {
parent_id: 0,
name: '',
alias: '',
icon: '',
remark: '',
type: 0,
url: '',
params: '',
target: '_self',
is_navi: '0',
sort: 50
}
},
_validationAuth () {
this.auth.create = this.$permission('/system_settings/menu_configuration/menu/create')
this.auth.edit = this.$permission('/system_settings/menu_configuration/menu/edit')
this.auth.delete = this.$permission('/system_settings/menu_configuration/menu/delete')
this.auth.disabled_enable = this.$permission('/system_settings/menu_configuration/menu/disabled_enable')
this.auth.move = this.$permission('/system_settings/menu_configuration/menu/sort')
},
filterNode (value, data) {
if (!value) return true
return data.name.indexOf(value) !== -1
},
toggleExpand (isExpand) {
this.filterText = ''
this.expanded = []
this.hackReset = false
this.$nextTick(() => {
this.isExpandAll = isExpand
this.hackReset = true
})
},
resetForm () {
this.form = this.getDefaultForm()
},
resetElements (status = 'create') {
this.$nextTick(() => {
this.$refs.formRef && this.$refs.formRef.clearValidate()
})
this.formStatus = status
this.formLoading = false
},
handleSearch () {
const form = { ...this.searchForm }
form.level = form.level <= 0 ? undefined : form.level - 1
this.loading = true
getMenuList(form)
.then(res => {
this.module = form.module
const menuKey = 'menu_' + form.module
const flatList = Array.isArray(res)
? res
: (res[menuKey] || (res.data && res.data[menuKey]) || [])
this.treeData = util.formatDataToTree(flatList)
this.filterText = ''
this.resetForm()
this.resetElements('create')
if (this.$refs.tree && this.$refs.tree.getCurrentKey()) {
this.$refs.tree.setCurrentKey(null)
}
})
.finally(() => {
this.loading = false
})
},
handleReset () {
this.$refs.searchFormRef.resetFields()
this.searchForm.module = 'admin'
this.searchForm.level = 0
this.handleSearch()
},
handleNodeClick (data) {
if (!this.auth.create && !this.auth.edit) return
this.updateNode = data
this.resetForm()
this.resetElements('update')
this.form = { ...data, is_navi: String(data.is_navi) }
},
handleCreateTop () {
this.appendType = 'newTopMenu'
this.resetForm()
this.resetElements('create')
if (this.$refs.tree && this.$refs.tree.getCurrentKey()) {
this.$refs.tree.setCurrentKey(null)
}
},
handleAppend (data) {
const key = data.menu_id
this.handleCreateTop()
this.$refs.tree.setCurrentKey(key)
this.form.parent_id = key
this.appendType = 'newChildMenu'
this.appendData = data
},
appendChildToTree (newNode) {
if (!this.appendData.children) {
this.$set(this.appendData, 'children', [])
}
this.appendData.children.push(newNode)
},
appendTopToTree (newNode) {
if (!newNode.children) {
this.$set(newNode, 'children', [])
}
this.treeData.push(newNode)
},
updateNodeInTree (data) {
if (!this.updateNode) return
Object.keys(data).forEach(key => {
if (Object.prototype.hasOwnProperty.call(this.updateNode, key)) {
this.updateNode[key] = data[key]
}
})
this.updateNode = {}
},
handleCreateSubmit () {
this.$refs.formRef.validate(valid => {
if (!valid) return
this.oldExpanded = this.expanded
this.formLoading = true
createMenu({ ...this.form, module: this.module })
.then(res => {
let data = res
if (data && data.data) data = data.data
if (Array.isArray(data) && data.length > 0) data = data[0]
if (!this.isExpandAll) {
this.expanded = [data.parent_id || data.menu_id]
}
if (this.appendType === 'newChildMenu') {
this.appendChildToTree(data)
} else if (this.appendType === 'newTopMenu') {
this.appendTopToTree(data)
}
this.appendType = ''
this.$message.success(this.$t(this.key('operation_success')))
})
.finally(() => {
this.formLoading = false
})
})
},
handleUpdateSubmit () {
this.$refs.formRef.validate(valid => {
if (!valid) return
this.oldExpanded = this.expanded
this.formLoading = true
editMenu(this.form)
.then(() => {
this.updateNodeInTree(this.form)
this.$message.success(this.$t(this.key('operation_success')))
})
.finally(() => {
this.formLoading = false
})
})
},
async handleRemove (key) {
const cancelled = await this.$confirmAction(
{
message: this.key('delete_confirm'),
title: this.key('prompt'),
confirmButtonText: this.key('confirm'),
cancelButtonText: this.key('cancel')
},
() => deleteMenu({ menu_id: key })
)
if (cancelled) return
this.$refs.tree.remove(this.$refs.tree.getNode(key))
this.$message.success(this.$t(this.key('operation_success')))
},
async handleToggleStatus (data) {
const cancelled = await this.$confirmAction(
{
message: this.key('status_change_confirm'),
title: this.key('prompt'),
confirmButtonText: this.key('confirm'),
cancelButtonText: this.key('cancel')
},
() => updateMenuStatus({ menu_id: data.menu_id, status: data.status ? 0 : 1 })
)
if (cancelled) return
if (!this.isExpandAll) {
const node = this.$refs.tree.getNode(data.menu_id)
this.expanded = [node && node.data ? (node.data.parent_id || data.menu_id) : data.menu_id]
}
this.handleSearch()
},
handleDrop (draggingNode, dropNode, dropType) {
const setMenu = {
menu_id: draggingNode.data.menu_id,
parent_id: draggingNode.data.parent_id
}
const indexMenu = []
if (dropType === 'inner') {
setMenu.parent_id = dropNode.key
} else {
setMenu.parent_id = dropNode.data.parent_id
dropNode.parent.childNodes.forEach((value, index) => {
indexMenu.push(value.key)
value.data.sort = index + 1
})
}
if (indexMenu.length > 0) {
sortMenu({ menu_sort: indexMenu, ...setMenu })
.finally(() => { this.handleSearch() })
.catch(() => { this.handleSearch() })
}
},
allowDrag () {
return true
},
handleNodeExpand (data) {
const exists = this.expanded.some(item => item === data.menu_id)
if (!exists) {
this.expanded.push(data.menu_id)
}
},
handleNodeCollapse (data) {
this.expanded = this.expanded.filter(item => item !== data.menu_id)
this._removeChildrenIds(data)
},
_removeChildrenIds (data) {
if (!data.children) return
data.children.forEach(item => {
const index = this.expanded.indexOf(item.menu_id)
if (index >= 0) {
this.expanded.splice(index, 1)
}
this._removeChildrenIds(item)
})
}
}
}
</script>
<style lang="scss" scoped>
.tree-scroll {
max-height: 615px;
overflow: auto;
padding-bottom: 1px;
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
.tree-label {
i {
width: 16px;
}
.fa {
font-size: 16px;
vertical-align: baseline;
}
}
.node-actions {
display: none;
}
.custom-tree-node:hover .node-actions {
display: block;
}
.move-tree {
color: #c0c4cc;
cursor: move;
}
.status-disabled {
color: #c0c4cc;
text-decoration: line-through;
}
.box-card {
border-radius: 0;
border: 1px solid #dcdfe6;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<el-dialog
:title="$t(`${prefix}.response`)"
:visible.sync="visibleProxy"
width="700px"
:close-on-click-modal="false"
@close="onClose"
>
<div>
<el-row :gutter="20">
<el-col :span="4">
<el-button type="primary" plain size="mini" @click="copyParam">
{{ $t(`${prefix}.copy_request_content`) }}
</el-button>
</el-col>
<el-col :span="4">
<el-button type="primary" plain size="mini" @click="copyResult">
{{ $t(`${prefix}.copy_response_content`) }}
</el-button>
</el-col>
</el-row>
<el-divider content-position="left">
{{ $t(`${prefix}.request_body`) }}
</el-divider>
<tree-view
v-if="paramData"
:data="paramData"
:options="{ maxDepth: 2 }"
style="line-height: 20px; text-align: left;"
/>
<el-divider content-position="left">
{{ $t(`${prefix}.response_content`) }}
</el-divider>
<tree-view
v-if="resultData"
:data="resultData"
:options="{ maxDepth: 2 }"
style="line-height: 20px; text-align: left;"
/>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'ResponseDialog',
props: {
visible: { type: Boolean, default: false },
paramData: { type: Object, default: null },
resultData: { type: Object, default: null },
prefix: { type: String, required: true }
},
computed: {
visibleProxy: {
get () { return this.visible },
set (val) { this.$emit('update:visible', val) }
}
},
methods: {
onClose () {
this.$emit('update:visible', false)
},
copyToClipboard (data) {
const input = document.createElement('input')
input.value = JSON.stringify(data)
document.body.appendChild(input)
input.select()
document.execCommand('Copy')
input.style.display = 'none'
this.$message.success(this.$t(`${this.prefix}.operation_success`))
},
copyParam () {
this.copyToClipboard(this.paramData)
},
copyResult () {
this.copyToClipboard(this.resultData)
}
}
}
</script>

View File

@@ -0,0 +1,277 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" ref="searchFormRef" size="mini" @submit.native.prevent>
<el-form-item :label="$t(key('ip'))">
<el-input
v-model="search.ip"
:placeholder="$t(key('placeholder_ip'))"
clearable
style="width:160px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('interface_name'))">
<el-input
v-model="search.unit"
:placeholder="$t(key('placeholder_interface_name'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('status'))">
<el-select
v-model="search.status"
:placeholder="$t(key('placeholder_status'))"
clearable
style="width:120px"
>
<el-option :value="200" :label="$t(key('success'))" />
<el-option :value="4001" :label="$t(key('failure'))" />
</el-select>
</el-form-item>
<el-form-item :label="$t(key('batch'))">
<el-input
v-model="search.batch"
:placeholder="$t(key('placeholder_batch'))"
clearable
style="width:160px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="onSearch">
{{ $t(key('search')) }}
</el-button>
<el-button icon="el-icon-refresh" @click="onReset">
{{ $t(key('reset')) }}
</el-button>
<el-button
v-if="!searchExpanded"
type="text"
icon="el-icon-arrow-down"
@click="searchExpanded = true"
>
{{ $t(key('expand')) }}
</el-button>
<el-button
v-else
type="text"
icon="el-icon-arrow-up"
@click="searchExpanded = false"
>
{{ $t(key('collapse')) }}
</el-button>
</el-form-item>
<div v-show="searchExpanded" class="search-bar__extra">
<el-form-item :label="$t(key('tray_number'))">
<el-input
v-model="search.tray"
:placeholder="$t(key('placeholder_tray_no'))"
clearable
style="width:160px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('process_code'))">
<el-input
v-model="search.process_code"
:placeholder="$t(key('placeholder_process_code'))"
clearable
style="width:160px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('battery_id'))">
<el-input
v-model="search.battery_id"
:placeholder="$t(key('placeholder_battery_barcode'))"
clearable
style="width:220px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('create_time'))">
<el-date-picker
v-model="search.time"
type="datetimerange"
:placeholder="$t(key('placeholder_create_time'))"
range-separator="-"
start-placeholder=""
end-placeholder=""
value-format="yyyy-MM-dd HH:mm:ss"
style="width:340px"
/>
</el-form-item>
</div>
</el-form>
</div>
</template>
<page-table
ref="pageTable"
:columns="columns"
:data="tableData"
:loading="loading"
:row-buttons="rowButtons"
:pagination="pagination"
auto-height
@page-change="onPageChange"
>
<template #col-status="{ row }">
<span v-if="row.status === 0" style="color: #67c23a;">
<i class="el-icon-circle-check" />
{{ $t(key('success')) }}
</span>
<span v-else style="color: #F56C6C;">
<i class="el-icon-circle-close" />
{{ $t(key('failure')) }}
</span>
</template>
</page-table>
<response-dialog
:visible.sync="respVisible"
:param-data="respParam"
:result-data="respResult"
:prefix="prefix"
/>
</d2-container>
</template>
<script>
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { getInterfaceLogList } from '@/api/system-administration/api-logs'
import PageTable from '@/components/page-table'
import ResponseDialog from './components/ResponseDialog/index.vue'
export default {
name: 'system-administration-api-logs',
components: { PageTable, ResponseDialog },
mixins: [i18nMixin('page.system_administration.system_utilities.api_logs')],
data () {
return {
loading: false,
tableData: [],
columns: [],
rowButtons: [],
pagination: { current: 1, size: 10, total: 0 },
search: {
ip: '',
unit: '',
status: undefined,
batch: '',
tray: '',
process_code: '',
battery_id: '',
time: undefined
},
respVisible: false,
respParam: null,
respResult: null,
searchExpanded: false
}
},
computed: {
prefix () {
return 'page.system_administration.system_utilities.api_logs'
}
},
created () {
this.columns = useTableColumns([
{ prop: 'id', label: this.key('id'), width: 65 },
{ prop: 'client_ip', label: this.key('ip'), width: 140 },
{ prop: 'unit', label: this.key('request_method'), minWidth: 150 },
{ prop: 'status', label: this.key('response_status'), slot: 'status', width: 100 },
{ prop: 'insterface_time', label: this.key('response_time_ms'), width: 130 },
{ prop: 'data1', label: this.key('process_code'), showOverflowTooltip: true, minWidth: 120 },
{ prop: 'data2', label: this.key('tray_number'), showOverflowTooltip: true, minWidth: 120 },
{ prop: 'data3', label: this.key('battery_id'), showOverflowTooltip: true, minWidth: 130 },
{ prop: 'data4', label: this.key('batch_number'), showOverflowTooltip: true, minWidth: 120 },
{ prop: 'data5', label: this.key('process_id'), showOverflowTooltip: true, minWidth: 120 },
{ prop: 'create_time', label: this.key('create_date'), width: 170 },
{ prop: '_actions', label: this.key('operation'), width: 100, fixed: 'right' }
], { selectionWidth: 0 })
const btns = useTableButtons({
row: [
{
key: 'view',
label: this.key('view_response'),
icon: 'el-icon-view',
auth: '/system_settings/system_assistant/interface_log/view',
onClick: this.handleViewResponse
}
]
}, this.$permission)
this.rowButtons = btns.rowButtons
this.fetchData()
},
methods: {
async fetchData () {
this.loading = true
try {
const params = {
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
}
const res = await getInterfaceLogList(params)
const data = Array.isArray(res) ? res : (res.data || [])
this.tableData = data
this.pagination.total = res.count || data.length
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.$refs.searchFormRef.resetFields()
this.search.ip = ''
this.search.unit = ''
this.search.status = undefined
this.search.batch = ''
this.search.tray = ''
this.search.process_code = ''
this.search.battery_id = ''
this.search.time = undefined
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
handleViewResponse (row) {
try {
this.respParam = JSON.parse(row.params || '{}')
this.respResult = JSON.parse(row.result || '{}')
} catch {
this.respParam = row.params || {}
this.respResult = row.result || {}
}
this.respVisible = true
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
.search-bar .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
.search-bar__extra {
display: inline-block;
width: 100%;
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" ref="searchFormRef" size="mini" @submit.native.prevent>
<el-form-item :label="$t(key('ip'))">
<el-input
v-model="search.ip"
:placeholder="$t(key('placeholder_ip'))"
clearable
style="width:160px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('operator'))">
<el-select
v-model="search.user_id"
:placeholder="$t(key('placeholder_operator'))"
clearable
filterable
style="width:200px"
>
<el-option
v-for="u in userList"
:key="u.user_id"
:value="u.user_id"
:label="u.username"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="onSearch">
{{ $t(key('search')) }}
</el-button>
<el-button icon="el-icon-refresh" @click="onReset">
{{ $t(key('reset')) }}
</el-button>
<el-button
v-if="!searchExpanded"
type="text"
icon="el-icon-arrow-down"
@click="searchExpanded = true"
>
{{ $t(key('expand')) }}
</el-button>
<el-button
v-else
type="text"
icon="el-icon-arrow-up"
@click="searchExpanded = false"
>
{{ $t(key('collapse')) }}
</el-button>
</el-form-item>
<div v-show="searchExpanded" class="search-bar__extra">
<el-form-item :label="$t(key('batch'))">
<el-input
v-model="search.batch"
:placeholder="$t(key('placeholder_batch'))"
clearable
style="width:160px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('tray_no'))">
<el-input
v-model="search.tray"
:placeholder="$t(key('placeholder_tray_no'))"
clearable
style="width:160px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('create_time'))">
<el-date-picker
v-model="search.time"
type="datetimerange"
:placeholder="$t(key('placeholder_time'))"
range-separator="-"
start-placeholder=""
end-placeholder=""
value-format="yyyy-MM-dd HH:mm:ss"
style="width:340px"
/>
</el-form-item>
</div>
</el-form>
</div>
</template>
<page-table
ref="pageTable"
:columns="columns"
:data="tableData"
:loading="loading"
:row-buttons="rowButtons"
:pagination="pagination"
auto-height
@page-change="onPageChange"
>
<template #col-status="{ row }">
<span v-if="row.status === 0" style="color: #67c23a;">
<i class="el-icon-circle-check" />
{{ $t(key('success')) }}
</span>
<span v-else style="color: #F56C6C;">
<i class="el-icon-circle-close" />
{{ $t(key('failure')) }}
</span>
</template>
</page-table>
<response-dialog
:visible.sync="respVisible"
:param-data="respParam"
:result-data="respResult"
:prefix="prefix"
/>
</d2-container>
</template>
<script>
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { getOperateLogList } from '@/api/system-administration/operation-logs'
import { getUserList } from '@/api/system-administration/user'
import PageTable from '@/components/page-table'
import ResponseDialog from '../api-logs/components/ResponseDialog/index.vue'
export default {
name: 'system-administration-operation-logs',
components: { PageTable, ResponseDialog },
mixins: [i18nMixin('page.system_administration.system_utilities.operation_logs')],
data () {
return {
loading: false,
tableData: [],
columns: [],
rowButtons: [],
userList: [],
pagination: { current: 1, size: 10, total: 0 },
search: {
ip: '',
user_id: undefined,
batch: '',
tray: '',
time: undefined
},
respVisible: false,
respParam: null,
respResult: null,
searchExpanded: false
}
},
computed: {
prefix () {
return 'page.system_administration.system_utilities.operation_logs'
}
},
created () {
this.columns = useTableColumns([
{ prop: 'id', label: this.key('id'), width: 65 },
{ prop: 'username', label: this.key('operator'), width: 120 },
{ prop: 'ip', label: this.key('ip'), width: 140 },
{ prop: 'status', label: this.key('status'), slot: 'status', width: 90 },
{ prop: 'action_name', label: this.key('action_name'), showOverflowTooltip: true, minWidth: 130 },
{ prop: 'action', label: this.key('action_code'), showOverflowTooltip: true, minWidth: 130 },
{ prop: 'path', label: this.key('request_path'), showOverflowTooltip: true, minWidth: 180 },
{ prop: 'batch', label: this.key('batch'), showOverflowTooltip: true, minWidth: 120 },
{ prop: 'tray', label: this.key('tray_no'), showOverflowTooltip: true, minWidth: 120 },
{ prop: 'create_time', label: this.key('create_date'), width: 170 },
{ prop: '_actions', label: this.key('operation'), width: 100, fixed: 'right' }
], { selectionWidth: 0 })
const btns = useTableButtons({
row: [
{
key: 'view',
label: this.key('view_response'),
icon: 'el-icon-view',
auth: '/system_settings/system_assistant/operate_log/view',
onClick: this.handleViewResponse
}
]
}, this.$permission)
this.rowButtons = btns.rowButtons
this.fetchUsers()
this.fetchData()
},
methods: {
async fetchUsers () {
try {
const res = await getUserList({ page_no: 1, page_size: 9999 })
this.userList = Array.isArray(res) ? res : (res.data || [])
} catch { /* 用户列表加载失败不影响主流程 */ }
},
async fetchData () {
this.loading = true
try {
const params = {
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
}
const res = await getOperateLogList(params)
const data = Array.isArray(res) ? res : (res.data || [])
this.tableData = data
this.pagination.total = res.count || data.length
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.$refs.searchFormRef.resetFields()
this.search = {
ip: '',
user_id: undefined,
batch: '',
tray: '',
time: undefined
}
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
handleViewResponse (row) {
try {
this.respParam = JSON.parse(row.params || '{}')
this.respResult = JSON.parse(row.result || '{}')
} catch {
this.respParam = row.params || {}
this.respResult = row.result || {}
}
this.respVisible = true
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
.search-bar .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
.search-bar__extra {
display: inline-block;
width: 100%;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<el-dialog
:title="textMap[dialogStatus]"
:visible.sync="visibleProxy"
:append-to-body="true"
:close-on-click-modal="false"
width="600px"
@closed="onClosed"
>
<el-form :model="form" :rules="rules" ref="form" label-width="110px">
<el-form-item :label="$t(`${pre}.category_name`)" prop="name">
<el-input
v-model="form.name"
:placeholder="$t(`${pre}.placeholder_category_name`)"
clearable
/>
</el-form-item>
<el-form-item :label="$t(`${pre}.parent_category`)" prop="parent_id">
<el-cascader
v-model="form.parent_id"
:options="cascaderOptions"
:props="{
value: 'id',
label: 'name',
expandTrigger: 'hover',
checkStrictly: true
}"
/>
</el-form-item>
<el-form-item :label="$t(`${pre}.view_permission`)" prop="role_ids">
<el-select v-model="form.role_ids" multiple :placeholder="$t(`${pre}.please_select`)">
<el-option
v-for="item in roleList"
:key="item.id"
:label="item.name"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(`${pre}.sort`)" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="10" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button size="small" @click="close">{{ $t(`${pre}.cancel`) }}</el-button>
<el-button type="primary" size="small" :loading="loading" @click="handleSubmit">
{{ dialogStatus === 'create' ? $t(`${pre}.confirm`) : $t(`${pre}.update`) }}
</el-button>
</div>
</el-dialog>
</template>
<script>
const TOP_CATEGORY = { id: 0, name: '' }
export default {
name: 'CategoryDialog',
props: {
visible: { type: Boolean, default: false },
categoryTree: { type: Array, default: () => [] },
roleList: { type: Array, default: () => [] },
pre: { type: String, required: true }
},
data () {
return {
dialogStatus: 'create',
loading: false,
editId: null,
form: this.getDefaultForm(),
rules: {
name: [{ required: true, message: this.$t(`${this.pre}.category_name_required`), trigger: 'blur' }]
}
}
},
computed: {
visibleProxy: {
get () { return this.visible },
set (val) { this.$emit('update:visible', val) }
},
textMap () {
return {
create: this.$t(`${this.pre}.dialog_create_title`),
update: this.$t(`${this.pre}.dialog_edit_title`)
}
},
cascaderOptions () {
const top = { ...TOP_CATEGORY, name: this.$t(`${this.pre}.top_category`) }
return [top].concat(this.categoryTree)
}
},
methods: {
getDefaultForm () {
return { name: undefined, parent_id: undefined, sort: 0, role_ids: [] }
},
openCreate () {
this.dialogStatus = 'create'
this.form = this.getDefaultForm()
this.$nextTick(() => {
if (this.$refs.form) this.$refs.form.clearValidate()
})
},
openEdit (row) {
this.dialogStatus = 'update'
this.editId = row.id
this.form = {
name: row.name,
parent_id: row.parent_id != null ? [row.parent_id] : [],
role_ids: row.role_ids || [],
sort: row.sort || 0
}
this.$nextTick(() => {
if (this.$refs.form) this.$refs.form.clearValidate()
})
},
handleSubmit () {
this.$refs.form.validate(valid => {
if (!valid) return
this.loading = true
this.$emit('submit', {
status: this.dialogStatus,
id: this.editId,
form: { ...this.form }
})
this.loading = false
})
},
onClosed () {
this.form = this.getDefaultForm()
},
close () {
this.$emit('update:visible', false)
}
}
}
</script>

View File

@@ -0,0 +1,202 @@
<template>
<el-drawer
:title="drawerTitle"
:visible.sync="visibleProxy"
direction="rtl"
size="80%"
:before-close="handleBeforeClose"
>
<div v-loading="loading" class="editor-body">
<el-form
ref="form"
label-position="right"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item :label="$t(`${pre}.title`)" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item :label="$t(`${pre}.describe`)" prop="describe">
<el-input type="textarea" :maxlength="30" show-word-limit v-model="form.describe" />
</el-form-item>
<el-form-item :label="$t(`${pre}.parent_category`)" prop="category_id">
<el-cascader
v-model="form.category_id"
:options="categoryTreeData"
:props="{
value: 'id',
label: 'name',
expandTrigger: 'hover',
checkStrictly: true
}"
/>
</el-form-item>
<el-form-item :label="$t(`${pre}.view_permission`)" prop="role_ids">
<el-select v-model="form.role_ids" multiple :placeholder="$t(`${pre}.please_select`)">
<el-option
v-for="item in roleList"
:key="item.id"
:label="item.name"
:value="String(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(`${pre}.sort`)" prop="sort">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
<el-form-item :label="$t(`${pre}.type`)">
<el-radio-group v-model="form.type">
<el-radio label="md">{{ $t(`${pre}.document`) }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-show="form.type === 'md'" style="width:100%">
<mavon-editor
ref="mavon"
style="height:500px"
v-model="form.markdown"
@imgAdd="handleUploadImages"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="submitting" @click="handleAdd">
{{ $t(`${pre}.add`) }}
</el-button>
<el-button @click="closeDrawer">{{ $t(`${pre}.cancel`) }}</el-button>
</el-form-item>
</el-form>
</div>
</el-drawer>
</template>
<script>
import { mavonEditor } from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
import axios from 'axios'
export default {
name: 'MarkdownEditor',
components: { mavonEditor },
props: {
visible: { type: Boolean, default: false },
editData: { type: Object, default: null },
categoryTreeData: { type: Array, default: () => [] },
roleList: { type: Array, default: () => [] },
pre: { type: String, required: true }
},
data () {
return {
loading: false,
submitting: false,
isEdit: false,
editId: null,
form: this.getDefaultForm(),
rules: {
title: [{ required: true, message: this.$t(`${this.pre}.enter_title`), trigger: 'blur' }],
describe: [{ required: true, message: this.$t(`${this.pre}.enter_describe`), trigger: 'blur' }],
category_id: [{ required: true, message: this.$t(`${this.pre}.select_parent_menu`), trigger: 'blur' }],
role_ids: [{ required: true, message: this.$t(`${this.pre}.select_role_group`), trigger: 'blur' }]
}
}
},
computed: {
visibleProxy: {
get () { return this.visible },
set (val) { this.$emit('update:visible', val) }
},
drawerTitle () {
return this.isEdit ? this.$t(`${this.pre}.edit_document`) : this.$t(`${this.pre}.add_document`)
}
},
watch: {
visible (val) {
if (val && this.editData) {
this.isEdit = true
this.editId = this.editData.id
this.form = {
title: this.editData.title,
category_id: this.editData.category_id,
describe: this.editData.describe,
markdown: this.editData.markdown || '',
type: this.editData.type || 'md',
role_ids: this.editData.role_ids ? this.editData.role_ids.map(String) : [],
url: this.editData.url || '',
sort: this.editData.sort || 0
}
this.$nextTick(() => {
if (this.$refs.form) this.$refs.form.clearValidate()
})
} else if (val && !this.editData) {
this.isEdit = false
this.editId = null
this.resetForm()
}
}
},
methods: {
getDefaultForm () {
return {
title: undefined,
category_id: undefined,
describe: undefined,
markdown: '',
type: 'md',
role_ids: [],
url: '',
sort: 0
}
},
resetForm () {
this.form = this.getDefaultForm()
this.submitting = false
this.isEdit = false
this.editId = null
},
handleAdd () {
this.$refs.form.validate(valid => {
if (!valid) return
if (this.form.type === 'md' && !this.form.markdown) {
this.$message.warning(this.$t(`${this.pre}.content_required`))
return
}
this.submitting = true
this.$emit('submit', this.isEdit
? { id: this.editId, ...this.form }
: { ...this.form }
)
})
},
handleBeforeClose (done) {
this.$confirm(this.$t(`${this.pre}.confirm_close`))
.then(() => {
this.resetForm()
done()
})
.catch(() => {})
},
closeDrawer () {
this.resetForm()
this.$emit('update:visible', false)
},
handleUploadImages (pos, file) {
const formdata = new FormData()
formdata.append('file', file)
formdata.append('type', 'image')
axios({
url: process.env.VUE_APP_UPLOAD_PATH,
method: 'post',
data: formdata,
headers: { 'Content-Type': 'multipart/form-data' }
}).then(url => {
this.$refs.mavon.$img2Url(pos, process.env.VUE_APP_PRO_PUBLIC_URL + url.data.url)
})
}
}
}
</script>
<style scoped>
.editor-body {
padding: 10px 20px;
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="custom-menu-tree">
<template v-for="item in dataList">
<el-menu-item v-if="!item.children" :key="item.id" :index="String(item.id)">
<span class="tree-icon">
<i v-if="item.type === 'md'" class="el-icon-folder-opened" style="color: rgba(144, 198, 252, 1)" />
<i v-else class="el-icon-files" style="color: rgba(144, 198, 252, 1)" />
</span>
<span class="tree-name" :title="item.name">{{ item.name }}</span>
<slot name="menu" :menu-id="item.id" :type="item.type" :file-list="{ url: item.url, name: item.url, file_type: item.file_type }" />
</el-menu-item>
<el-submenu v-else :key="item.id" :index="String(item.id)">
<template slot="title">
<i :class="[item.icon]" />
<span>{{ item.name }}</span>
<slot name="submenu" :menu-data="item" :menu-id="item.id" />
</template>
<MenuTree :data-list="item.children">
<template slot="menu" scope="ctx">
<slot name="menu" :menu-id="ctx.menuId" :type="ctx.type" :file-list="ctx.fileList" />
</template>
<template slot="submenu" scope="ctx">
<slot name="submenu" :menu-data="ctx.menuData" :menu-id="ctx.menuId" />
</template>
</MenuTree>
</el-submenu>
</template>
</div>
</template>
<script>
export default {
name: 'MenuTree',
props: {
dataList: { type: Array, default: () => [] }
}
}
</script>
<style scoped>
::v-deep .is-opened > .el-submenu__title {
border-left: 3px solid #409EFF;
}
.tree-name {
display: inline-block;
width: 58%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 5px;
}
.tree-icon {
background: rgba(232, 239, 248, 1);
width: 14px;
height: 14px;
padding: 0 0 2px 2px;
border-radius: 2px;
}
</style>

View File

@@ -0,0 +1,370 @@
<template>
<d2-container>
<template #header>
<div class="problem-header">
<el-input
:placeholder="$t(key('search_placeholder'))"
v-model="searchTitle"
size="mini"
clearable
style="width:220px"
@clear="onClearSearch"
@keyup.enter.native="onSearch"
>
<el-button slot="append" icon="el-icon-search" @click="onSearch" />
</el-input>
<el-button
type="primary"
size="mini"
icon="el-icon-plus"
style="margin-left:10px"
@click="openCategoryCreate"
>
{{ $t(key('add_directory')) }}
</el-button>
</div>
</template>
<el-container class="problem-content">
<el-aside width="300px" class="problem-aside">
<div v-if="searchShow" class="search-result">
<el-card
v-for="item in searchData"
:key="item.id"
:body-style="{ padding: '10px' }"
shadow="never"
>
<div class="search-item-title" @click="handleSelect(item.id)">
<span class="tree-icon">
<i
v-if="item.type === 'md'"
class="el-icon-folder-opened"
style="color: rgba(144, 198, 252, 1)"
/>
<i v-else class="el-icon-files" style="color: rgba(144, 198, 252, 1)" />
</span>
{{ item.title }}
</div>
<div class="search-item-desc">{{ item.describe }}</div>
</el-card>
</div>
<div class="aside-toolbar">
<el-button type="primary" size="mini" icon="el-icon-plus" @click="openEditor">
{{ $t(key('add_document')) }}
</el-button>
<el-button type="success" size="mini" icon="el-icon-edit-outline" @click="openCategoryEdit" v-permission="'/setting/aide/problem/set'">
{{ $t(key('edit')) }}
</el-button>
<el-button type="danger" size="mini" icon="el-icon-delete" @click="handleDeleteCategory" v-permission="'/setting/aide/problem/del'">
{{ $t(key('delete')) }}
</el-button>
</div>
<el-menu
class="problem-menu"
:unique-opened="true"
@select="handleSelect"
@open="handleOpen"
>
<MenuTree :data-list="problemTree">
<template slot="menu" scope="ctx">
<div class="menu-actions">
<el-button
v-if="ctx.type === 'file'"
type="text"
size="mini"
@click.stop="handleDownload(ctx.fileList.url, ctx.fileList.file_type)"
>
{{ $t(key('download')) }}
</el-button>
<el-button v-if="ctx.type === 'md'" type="text" size="mini" @click.stop="handleEditMarkdown(ctx.menuId)">
{{ $t(key('edit')) }}
</el-button>
<el-button type="text" size="mini" style="margin-left:0" @click.stop="handleDeleteMarkdown(ctx.menuId)">
{{ $t(key('delete')) }}
</el-button>
</div>
</template>
</MenuTree>
</el-menu>
</el-aside>
<el-main class="problem-main">
<el-card v-if="docVisible" class="doc-card" shadow="never">
<div slot="header" class="doc-header">
<h1 style="margin:0">{{ docDetail.title }}</h1>
<h6 style="margin:0">
{{ docDetail.create_time }}
{{ $t(key('submitter')) }}{{ docDetail.admin && docDetail.admin.name }}
</h6>
</div>
<d2-markdown :key="markdownKey" :source="docDetail.markdown" />
</el-card>
<div v-else class="doc-empty">
<i class="el-icon-document" style="font-size:64px;color:#dcdfe6" />
<p style="color:#909399">{{ $t(key('select_document_tip')) }}</p>
</div>
</el-main>
</el-container>
<category-dialog
ref="categoryDialog"
:visible.sync="categoryVisible"
:category-tree="categoryTree"
:role-list="roleList"
:pre="i18nPrefix"
@submit="onCategorySubmit"
/>
<markdown-editor
ref="editorDrawer"
:visible.sync="editorVisible"
:edit-data="editDocData"
:category-tree-data="categoryTree"
:role-list="roleList"
:pre="i18nPrefix"
@submit="onEditorSubmit"
/>
</d2-container>
</template>
<script>
import { i18nMixin } from '@/composables/useI18n'
import {
getCategoryTree,
setCategoryAdd,
setCategoryUpdate,
delCategory,
getProblemTree,
getMarkdownDetails,
setMarkdownAdd,
setMarkdownEdit,
delMarkdownDetails,
searchMarkdown,
getRoleAll
} from '@/api/system-administration/problem-help'
import MenuTree from './components/MenuTree/index.vue'
import CategoryDialog from './components/CategoryDialog/index.vue'
import MarkdownEditor from './components/MarkdownEditor/index.vue'
export default {
name: 'system-administration-problem-help',
components: { MenuTree, CategoryDialog, MarkdownEditor },
mixins: [i18nMixin('page.system_administration.system_utilities.problem_help')],
data () {
return {
loading: false,
searchTitle: '',
searchData: [],
searchShow: false,
categoryTree: [],
problemTree: [],
roleList: [],
selectedMenuId: null,
docVisible: false,
docDetail: { title: '', create_time: '', admin: {}, markdown: '' },
markdownKey: false,
categoryVisible: false,
editorVisible: false,
editDocData: null
}
},
computed: {
i18nPrefix () {
return 'page.system_administration.system_utilities.problem_help'
}
},
mounted () {
this.fetchTree()
},
methods: {
async fetchTree () {
try {
const [catRes, probRes, roleRes] = await Promise.all([
getCategoryTree(),
getProblemTree(),
getRoleAll()
])
this.categoryTree = catRes.data || []
this.problemTree = probRes.data || []
this.roleList = Array.isArray(roleRes) ? roleRes : (roleRes.data || [])
} catch { /* ignore */ }
},
onSearch () {
if (!this.searchTitle) { this.onClearSearch(); return }
searchMarkdown({ title: this.searchTitle }).then(res => {
this.searchData = res.data || []
this.searchShow = this.searchData.length > 0
})
},
onClearSearch () {
this.searchShow = false
this.searchData = []
},
handleOpen (key) {
this.selectedMenuId = key
},
handleSelect (id) {
getMarkdownDetails({ id }).then(res => {
if (res.data && res.data.type !== 'file') {
this.docDetail = res.data
this.markdownKey = !this.markdownKey
this.docVisible = true
}
})
},
handleDeleteCategory () {
if (!this.selectedMenuId) {
this.$message.error(this.$t(this.key('select_delete_directory')))
return
}
this.$confirm(this.$t(this.key('confirm_message')), this.$t(this.key('prompt')), { type: 'warning' })
.then(() => delCategory({ id: this.selectedMenuId }))
.then(() => {
this.$message.success(this.$t(this.key('delete_success')))
this.fetchTree()
})
.catch(() => {})
},
handleEditMarkdown (menuId) {
getMarkdownDetails({ id: menuId }).then(res => {
if (res.data && res.data.type !== 'file') {
this.editDocData = res.data
this.editorVisible = true
}
})
},
handleDeleteMarkdown (menuId) {
this.$confirm(this.$t(this.key('confirm_message')), this.$t(this.key('prompt')), { type: 'warning' })
.then(() => delMarkdownDetails({ id: menuId }))
.then(() => {
this.$message.success(this.$t(this.key('delete_success')))
this.fetchTree()
})
.catch(() => {})
},
handleDownload (url, fileType) {
window.open(url, '_blank')
},
openCategoryCreate () {
this.$refs.categoryDialog && this.$refs.categoryDialog.openCreate()
this.categoryVisible = true
},
openCategoryEdit () {
if (!this.selectedMenuId) {
this.$message.warning(this.$t(this.key('select_edit_directory')))
return
}
const findNode = (nodes) => {
for (const n of nodes) {
if (n.id === this.selectedMenuId) return n
if (n.children) {
const found = findNode(n.children)
if (found) return found
}
}
return null
}
const node = findNode(this.categoryTree)
if (node) {
this.$refs.categoryDialog && this.$refs.categoryDialog.openEdit(node)
this.categoryVisible = true
}
},
onCategorySubmit ({ status, id, form }) {
const api = status === 'create' ? setCategoryAdd : setCategoryUpdate
const data = status === 'create' ? form : { id, ...form }
api(data).then(() => {
this.$message.success(status === 'create'
? this.$t(this.key('add_success'))
: this.$t(this.key('update_success')))
this.categoryVisible = false
this.fetchTree()
})
},
openEditor () {
this.editDocData = null
this.editorVisible = true
},
onEditorSubmit (form) {
const promise = form.id
? setMarkdownEdit({ id: form.id, ...form })
: setMarkdownAdd(form)
promise.then(() => {
this.$message.success(this.$t(this.key(form.id ? 'edit_success' : 'create_success')))
this.editorVisible = false
this.editDocData = null
this.fetchTree()
})
}
}
}
</script>
<style scoped>
.problem-header {
padding: 10px 0;
}
.problem-content {
height: calc(100vh - 160px);
}
.problem-aside {
border: solid 1px rgba(240, 240, 240, 1);
border-radius: 4px 0 0 4px;
overflow-y: auto;
}
.problem-menu {
border-top: solid 1px rgba(240, 240, 240, 1);
}
.aside-toolbar {
padding: 10px;
margin-bottom: 10px;
}
.aside-toolbar .el-button {
margin-bottom: 4px;
}
.search-result {
max-height: 400px;
overflow: auto;
margin-bottom: 10px;
}
.search-item-title {
font-size: 14px;
padding: 3px 0;
cursor: pointer;
}
.search-item-desc {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.menu-actions {
position: absolute;
right: 40px;
top: 0;
}
.tree-icon {
background: rgba(232, 239, 248, 1);
width: 14px;
height: 14px;
padding: 0 0 2px 2px;
border-radius: 2px;
display: inline-block;
}
.problem-main {
border: solid 1px rgba(240, 240, 240, 1);
border-left: none;
border-radius: 0 4px 4px 0;
}
.doc-card {
min-height: 100%;
}
.doc-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
}
</style>

View File

@@ -14,10 +14,11 @@
node-key="menu_id"
:data="treeData"
:props="{ label: 'name', children: 'children' }"
:default-expand-all="false"
:expand-on-click-node="false"
:check-strictly="true"
:default-expand-all="true"
:expand-on-click-node="true"
:default-checked-keys="checkedMenuIds"
show-checkbox
:draggable="true"
@check="onTreeCheck"
>
<span slot-scope="{ node, data }" class="perm-drawer__tree-node">
@@ -46,14 +47,14 @@
import { i18nMixin } from '@/composables/useI18n'
import util from '@/libs/util'
import { getMenuAll } from '@/api/menu'
import { giveRoleMenu, getRoleMenu } from '@/api/system-administration/role'
import { giveRoleMenu } from '@/api/system-administration/role'
export default {
name: 'RolePermDrawer',
mixins: [i18nMixin('page.system_administration.user_management.role')],
props: {
visible: { type: Boolean, default: false },
roleId: { type: Number, default: 0 },
role: { type: Object, default: () => ({}) },
title: { type: String, default: '' },
confirmText: { type: String, default: '' },
cancelText: { type: String, default: '' },
@@ -84,18 +85,15 @@ export default {
this.treeReady = false
this.treeLoading = true
try {
const [menuRes, roleMenuRes] = await Promise.all([
getMenuAll()
])
const menuRes = await getMenuAll()
const menuData = Array.isArray(menuRes) ? menuRes : (menuRes.data || [])
const roleData = Array.isArray(roleMenuRes) ? roleMenuRes : (roleMenuRes.data || [])
this.checkedMenuIds = roleData.map(item => item.menu_id)
this.checkedMenuIds = JSON.parse(this.role.menu_admin || '[]')
this.treeData = util.formatDataToTree(menuData)
this.treeReady = true
await this.$nextTick()
await new Promise(resolve => setTimeout(resolve, 50))
this.$refs.permTree.setCheckedKeys(this.checkedMenuIds)
} finally {
setTimeout(() => {
this.treeLoading = false
}, 1000)
} catch {
this.treeLoading = false
}
},
@@ -112,7 +110,7 @@ export default {
this.submitting = true
try {
const menuIds = this.$refs.permTree.getCheckedKeys()
await giveRoleMenu({ role_id: this.roleId, role_menu: menuIds })
await giveRoleMenu({ role_id: this.role.id, role_menu: menuIds })
this.$message.success(this.$t(this.key('operation_success')))
this.visibleProxy = false
this.$emit('saved')

View File

@@ -78,7 +78,7 @@
<role-perm-drawer
:visible.sync="permVisible"
:role-id="permRole.id"
:role="permRole"
:title="key('assign_permissions')"
:confirm-text="key('confirm')"
:cancel-text="key('cancel')"
@@ -322,7 +322,7 @@ export default {
}
try {
await updateRoleStatus({
ids: rows.map(row => row.id),
id: rows.map(row => row.id),
status
})
this.$message.success(this.$t(this.key('operation_success')))

View File

@@ -0,0 +1,443 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('username'))">
<el-input
v-model="search.username"
:placeholder="$t(key('enter_username'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('full_name'))">
<el-input
v-model="search.nickname"
:placeholder="$t(key('enter_name'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="onSearch">
{{ $t(key('search')) }}
</el-button>
<el-button icon="el-icon-refresh" @click="onReset">
{{ $t(key('reset')) }}
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<page-table
ref="pageTable"
:columns="columns"
:data="tableData"
:loading="loading"
:toolbar-buttons="toolbarButtons"
:row-buttons="rowButtons"
:pagination="pagination"
auto-height
@page-change="onPageChange"
@selection-change="onSelect"
>
<template #col-status="{ row }">
<span v-if="row.status === 1" style="color: #67c23a;">
<i class="el-icon-circle-check" />
{{ $t(key('enable')) }}
</span>
<span v-else style="color: #909399;">
<i class="el-icon-circle-close" />
{{ $t(key('disable')) }}
</span>
</template>
</page-table>
<page-dialog-form
ref="dialogForm"
:visible.sync="dialogVisible"
:title="dialogTitle"
width="35%"
:form-cols="dialogFormCols"
:form-data="formData"
:rules="dialogRules"
label-width="100px"
:submitting="submitting"
:confirm-text="key('confirm')"
:cancel-text="key('cancel')"
@submit="onDialogSubmit"
@close="onDialogClose"
/>
</d2-container>
</template>
<script>
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { confirmMixin } from '@/composables/useConfirmHandle'
import { getRoleAll } from '@/api/system-administration/role'
import {
getUserList,
createUser,
editUser,
deleteUser,
batchDeleteUser,
enableUser,
disableUser,
resetUserPwd
} from '@/api/system-administration/user'
import PageTable from '@/components/page-table'
import PageDialogForm from '@/components/page-dialog-form'
const ownUserId = () => localStorage.getItem('user_id')
export default {
name: 'system-administration-user',
components: { PageTable, PageDialogForm },
mixins: [i18nMixin('page.system_administration.user_management.user'), confirmMixin],
data () {
const key = this.key.bind(this)
const $t = this.$t.bind(this)
return {
loading: false,
submitting: false,
tableData: [],
selectedRows: [],
dialogVisible: false,
dialogTitle: '',
editId: '',
handleType: 'create',
search: { username: '', nickname: '' },
pagination: { current: 1, size: 10, total: 0 },
roleOptions: [],
columns: [],
toolbarButtons: [],
rowButtons: [],
baseFormCols: {
create: [
[{ type: 'input', prop: 'username', label: key('username'), placeholder: key('enter_username'), clearable: true, style: { width: '90%' } }],
[{ type: 'input', prop: 'password', inputType: 'password', label: key('password'), placeholder: key('enter_password'), clearable: true, showPassword: true, style: { width: '90%' } }],
[{ type: 'input', prop: 'password_confirm', inputType: 'password', label: key('confirm_password'), placeholder: key('enter_confirm_password'), clearable: true, showPassword: true, style: { width: '90%' } }],
[{ type: 'select', prop: 'role_id', label: key('user_group'), placeholder: key('select_user_group'), clearable: true, style: { width: '90%' }, options: [] }],
[{ type: 'input', prop: 'nickname', label: key('full_name'), placeholder: key('enter_name'), clearable: true, style: { width: '90%' } }],
[{ type: 'input', prop: 'pass_number', label: key('pass_number'), placeholder: key('enter_pass_number'), clearable: true, style: { width: '90%' } }],
[{ type: 'select', prop: 'status', label: key('status'), clearable: false, style: { width: '90%' }, options: [{ value: '1', label: $t(key('enable')) }, { value: '0', label: $t(key('disable')) }] }]
],
edit: [
[{ type: 'input', prop: 'username', label: key('username'), placeholder: key('enter_username'), clearable: true, style: { width: '90%' } }],
[{ type: 'select', prop: 'role_id', label: key('user_group'), placeholder: key('select_user_group'), clearable: true, style: { width: '90%' }, options: [] }],
[{ type: 'input', prop: 'nickname', label: key('full_name'), placeholder: key('enter_name'), clearable: true, style: { width: '90%' } }],
[{ type: 'input', prop: 'pass_number', label: key('pass_number'), placeholder: key('enter_pass_number'), clearable: true, style: { width: '90%' } }],
[{ type: 'select', prop: 'status', label: key('status'), clearable: false, style: { width: '90%' }, options: [{ value: '1', label: $t(key('enable')) }, { value: '0', label: $t(key('disable')) }] }]
]
},
baseRules: {
username: [
{ required: true, message: key('enter_username'), trigger: 'blur' },
{ min: 3, max: 20, message: key('username_length'), trigger: 'blur' }
],
password: [
{ required: true, message: key('enter_password'), trigger: 'blur' },
{ min: 6, max: 64, message: key('password_length'), trigger: 'blur' }
],
password_confirm: [
{ required: true, message: key('enter_confirm_password'), trigger: 'blur' },
{ min: 6, max: 64, message: key('password_length'), trigger: 'blur' }
],
role_id: [
{ required: true, message: key('select_user_group'), trigger: 'change' }
]
}
}
},
computed: {
dialogFormCols () {
const cols = this.baseFormCols[this.handleType] || this.baseFormCols.create
const roleIdx = this.handleType === 'create' ? 3 : 1
if (cols[roleIdx] && cols[roleIdx][0]) {
cols[roleIdx][0].options = this.roleOptions
}
return cols
},
dialogRules () {
if (this.handleType === 'edit') {
return {
username: this.baseRules.username,
role_id: this.baseRules.role_id
}
}
return this.baseRules
}
},
created () {
this.initRoleOptions()
this.columns = useTableColumns([
{ prop: 'sort', label: this.key('index'), width: 80 },
{ prop: 'username', label: this.key('username'), minWidth: 120 },
{ prop: 'nickname', label: this.key('full_name'), minWidth: 100 },
{ prop: 'pass_number', label: this.key('pass_number'), minWidth: 120 },
{ prop: 'status', label: this.key('status'), slot: 'status', width: 100 },
{ prop: 'role_name', label: this.key('user_group'), minWidth: 100 },
{ prop: 'last_ip', label: this.key('login_ip'), width: 130 },
{ prop: 'create_time', label: this.key('last_login_time'), width: 160 },
{ prop: '_actions', label: this.key('actions'), width: 240, fixed: 'right' }
])
const btns = useTableButtons({
toolbar: [
{
key: 'add',
label: this.key('add'),
icon: 'el-icon-plus',
type: 'primary',
auth: '/system_settings/user_management/member/create',
onClick: this.openAdd
},
{
key: 'enable',
label: this.key('enable'),
icon: 'el-icon-check',
type: 'success',
auth: '/system_settings/user_management/member/enable',
onClick: () => this.batchUpdateStatus(1)
},
{
key: 'disable',
label: this.key('disable'),
icon: 'el-icon-close',
type: 'warning',
auth: '/system_settings/user_management/member/disable',
onClick: () => this.batchUpdateStatus(0)
},
{
key: 'batch_delete',
label: this.key('batch_delete'),
icon: 'el-icon-delete',
type: 'danger',
auth: '/system_settings/user_management/member/batch-delete',
onClick: this.handleBatchDelete
}
],
row: [
{
key: 'edit',
label: this.key('edit'),
icon: 'el-icon-edit',
auth: '/system_settings/user_management/member/edit',
onClick: this.openEdit
},
{
key: 'reset_pwd',
label: this.key('reset_password'),
icon: 'el-icon-refresh',
auth: '/system_settings/user_management/member/reset-pwd',
onClick: this.handleResetPwd
},
{
key: 'delete',
label: this.key('delete'),
icon: 'el-icon-delete',
color: 'danger',
auth: '/system_settings/user_management/member/delete',
onClick: this.handleDelete
}
]
}, this.$permission)
this.toolbarButtons = btns.toolbarButtons
this.rowButtons = btns.rowButtons
this.fetchData()
},
methods: {
async initRoleOptions () {
try {
const res = await getRoleAll()
const data = Array.isArray(res) ? res : (res.data || [])
this.roleOptions = data.map(item => ({ value: item.id, label: item.name }))
} catch { /* 忽略 */ }
},
async fetchData () {
this.loading = true
try {
const res = await getUserList({
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
})
const list = Array.isArray(res) ? res : (res.data || [])
const total = Array.isArray(res) ? res.length : (res.count || 0)
this.tableData = list
this.pagination.total = total
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.search = { username: '', nickname: '' }
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
onSelect (rows) {
this.selectedRows = rows
},
resetForm () {
this.formData = {
username: '',
password: '',
password_confirm: '',
role_id: '',
nickname: '',
pass_number: '',
status: '1'
}
this.editId = ''
},
openAdd () {
this.handleType = 'create'
this.dialogTitle = this.key('add_title')
this.$nextTick(() => {
this.$refs.dialogForm && this.$refs.dialogForm.reset()
this.resetForm()
this.dialogVisible = true
})
},
openEdit (row) {
this.handleType = 'edit'
this.dialogTitle = this.key('edit_title')
this.editId = row.user_id
this.formData = {
username: row.username,
role_id: row.role_id,
nickname: row.nickname || '',
pass_number: row.pass_number || '',
status: String(row.status)
}
this.dialogVisible = true
},
async onDialogSubmit () {
this.submitting = true
try {
if (this.handleType === 'create') {
if (this.formData.password !== this.formData.password_confirm) {
this.$message.error(this.$t(this.key('password_not_match')))
return
}
await createUser(this.formData)
} else {
await editUser({ ...this.formData, id: this.editId })
}
this.$message.success(this.$t(this.key('operation_success')))
this.dialogVisible = false
this.fetchData()
} finally {
this.submitting = false
}
},
onDialogClose () {
this.resetForm()
},
batchUpdateStatus (status) {
const uid = ownUserId()
const rows = this.selectedRows.filter(row => String(row.user_id) !== uid)
if (rows.length === 0 && this.selectedRows.length > 0) {
this.$message.warning(this.$t(this.key('cannot_operate_self')))
return
}
if (rows.length === 0) {
this.$message.warning(this.$t(this.key('select_rows_first')))
return
}
const ids = rows.map(row => row.user_id)
this.$confirm(
this.$t(this.key('confirm_execute')),
this.$t(this.key('prompt')),
{
confirmButtonText: this.$t(this.key('confirm')),
cancelButtonText: this.$t(this.key('cancel')),
type: 'warning',
closeOnClickModal: false
}
).then(async () => {
try {
await (status === 1 ? enableUser({ id: ids, status }) : disableUser({ id: ids, status: 0 }))
this.$message.success(this.$t(this.key('operation_success')))
this.fetchData()
} catch { /* 拦截器已处理 */ }
}).catch(() => {})
},
async handleDelete (row) {
if (String(row.user_id) === ownUserId()) {
this.$message.warning(this.$t(this.key('cannot_delete_self')))
return
}
const cancelled = await this.$confirmAction(
{ message: this.key('confirm_delete'), title: this.key('prompt') },
() => deleteUser({ id: [row.user_id] })
)
if (cancelled) return
this.$message.success(this.$t(this.key('operation_success')))
this.pagination.current = Math.min(
this.pagination.current,
Math.ceil((this.pagination.total - 1) / this.pagination.size) || 1
)
this.fetchData()
},
async handleBatchDelete () {
const uid = ownUserId()
const rows = this.selectedRows.filter(row => String(row.user_id) !== uid)
if (rows.length === 0 && this.selectedRows.length > 0) {
this.$message.warning(this.$t(this.key('cannot_delete_self')))
return
}
if (rows.length === 0) {
this.$message.warning(this.$t(this.key('select_rows_first')))
return
}
const cancelled = await this.$confirmAction(
{ message: this.key('confirm_batch_delete'), title: this.key('prompt') },
() => batchDeleteUser({ id: rows.map(row => row.user_id) })
)
if (cancelled) return
this.$message.success(this.$t(this.key('operation_success')))
this.fetchData()
},
async handleResetPwd (row) {
try {
await this.$confirm(
this.$t(this.key('confirm_reset_pwd')),
this.$t(this.key('prompt')),
{
confirmButtonText: this.$t(this.key('confirm')),
cancelButtonText: this.$t(this.key('cancel')),
type: 'warning',
closeOnClickModal: false
}
)
await resetUserPwd({ id: row.user_id })
this.$message.success(this.$t(this.key('operation_success')))
} catch { /* 取消或失败 */ }
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
/deep/ .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
</style>

View File

@@ -73,8 +73,8 @@ export default {
return this.aside[key].children.filter(item => {
const title = item.title || ''
if (title.indexOf('首页') !== -1) return false
if (item.icon === 'home') return false
if (this.$route.path === item.path) return false
// if (item.icon === 'home') return false
// if (this.$route.path === item.path) return false
return true
})
}