创建项目
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
<template>
|
||||
<div class="color-page">
|
||||
<div class="color-toolbar boxshadow">
|
||||
<div class="color-search-group">
|
||||
<el-form-item label="关键字:" label-width="70px">
|
||||
<el-input
|
||||
style="min-width: 120px"
|
||||
v-model="keyword"
|
||||
placeholder="请输入关键字"
|
||||
clearable
|
||||
@keyup.enter="getMonthlyList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="getMonthlyList" v-ripple>搜索</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxshadow">
|
||||
<ElRow>
|
||||
<div style="width: 12px"></div>
|
||||
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
|
||||
<ElButton
|
||||
@click="showDialog('add')"
|
||||
v-ripple
|
||||
v-if="hasAuth('classify_item@add')"
|
||||
type="primary"
|
||||
>
|
||||
新增
|
||||
</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn label="分类" prop="category_name" min-width="120" />
|
||||
<ElTableColumn label="标题" prop="title" min-width="120" />
|
||||
<ElTableColumn label="描述" prop="description" min-width="120" />
|
||||
<ElTableColumn label="类型" prop="type" min-width="120" />
|
||||
<ElTableColumn label="排序" prop="sort" min-width="120" />
|
||||
<ElTableColumn label="状态" prop="status" width="120">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status ? 'primary' : 'info'">{{
|
||||
scope.row.status ? '启用' : '禁用'
|
||||
}}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn fixed="right" label="操作" width="120">
|
||||
<template #default="scope">
|
||||
<ElRow>
|
||||
<el-button
|
||||
link
|
||||
@click="showDialog('edit', scope.row)"
|
||||
v-if="hasAuth('classify_item@edit')"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="deleteMonthly(scope.row)"
|
||||
v-if="hasAuth('classify_item@del')"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新增' : '编辑'"
|
||||
align-center
|
||||
width="600px"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px" class="form">
|
||||
<ElFormItem label="所属分类" prop="category_id">
|
||||
<el-tree-select
|
||||
v-model="form.category_id"
|
||||
:data="ClassifyList"
|
||||
filterable
|
||||
:props="{
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
}"
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="标题" prop="title">
|
||||
<ElInput v-model="form.title" placeholder="请输入标题" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="描述" prop="description">
|
||||
<ElInput
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
v-model="form.description"
|
||||
placeholder="请输入描述"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<!-- 类型 -->
|
||||
<ElFormItem label="资源类型" prop="type">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio label="link">超链接</el-radio>
|
||||
<el-radio label="file">文件</el-radio>
|
||||
<el-radio label="image">图片</el-radio>
|
||||
</el-radio-group>
|
||||
<!-- <ElSelect v-model="form.type" placeholder="请选择类型" style="width: 100%">
|
||||
<ElOption label="超链接" value="link" />
|
||||
<ElOption label="文件" value="file" />
|
||||
</ElSelect> -->
|
||||
</ElFormItem>
|
||||
<!-- url -->
|
||||
<ElFormItem v-if="form.type === 'link'" label="链接" prop="url">
|
||||
<ElInput v-model="form.url" placeholder="请输入链接" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else-if="form.type === 'file'" label="文件" prop="url">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="upload-demo"
|
||||
:action="actionFileUpload"
|
||||
:headers="uploadHeaders"
|
||||
:on-success="handleFileUploadSuccess"
|
||||
:on-exceed="handleExceed"
|
||||
:on-progress="handleUploadProgress"
|
||||
:show-file-list="false"
|
||||
:multiple="false"
|
||||
:limit="1"
|
||||
:auto-upload="true"
|
||||
>
|
||||
<img :src="filePreviewImage" alt="" class="avatarimg" />
|
||||
<input type="hidden" v-model="form.url" />
|
||||
</el-upload>
|
||||
<div v-if="form.url" style="margin-top: 8px">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<img
|
||||
v-if="isImageUrl(form.url_pull)"
|
||||
:src="form.url_pull"
|
||||
style="
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
"
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
:href="form.url_pull || form.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 200px;
|
||||
"
|
||||
>
|
||||
{{ (form.url_pull || form.url || '').split('?')[0].split('/').pop() }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else-if="form.type === 'image'" label="图片" prop="imageUrls">
|
||||
<el-upload
|
||||
ref="imageUploadRef"
|
||||
class="image-upload"
|
||||
:action="actionFileUpload"
|
||||
:headers="uploadHeaders"
|
||||
:on-success="handleImageUploadSuccess"
|
||||
:on-remove="handleImageRemove"
|
||||
:on-progress="handleUploadProgress"
|
||||
:file-list="imageFileList"
|
||||
:multiple="true"
|
||||
:limit="20"
|
||||
:auto-upload="true"
|
||||
list-type="picture-card"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序" prop="sort">
|
||||
<ElInputNumber v-model="form.sort" :min="0" :max="999999" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="状态" prop="status">
|
||||
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
|
||||
import { genFileId } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { classifyService } from '@/api/classify'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { commonService } from '@/api/commonApi'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
import imageUrl from '@/assets/img/crm/upimg.png'
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
const actionFileUpload = `${import.meta.env.VITE_API_URL}/upload?scene=classify`
|
||||
const uploadLoadingInstance = shallowRef<ReturnType<typeof ElLoading.service> | null>(null)
|
||||
const handleUploadProgress = (event: any) => {
|
||||
console.log('handleUploadProgress', event)
|
||||
uploadLoadingInstance.value = ElLoading.service({
|
||||
lock: true,
|
||||
text: '上传中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
}
|
||||
const handleFileUploadSuccess = (response: any) => {
|
||||
// 常见返回:response.url 或 response.data.file_name / file_url
|
||||
form.url = response?.name || response?.data?.file_name || ''
|
||||
form.url_pull = response?.url || response?.data?.file_url || ''
|
||||
ElMessage.success(response.message || '上传成功')
|
||||
uploadRef.value?.clearFiles?.()
|
||||
uploadLoadingInstance.value?.close() // 关闭 loading
|
||||
}
|
||||
const handleExceed = (files: any[]) => {
|
||||
if (!files || files.length === 0) return
|
||||
const file = files[0]
|
||||
if (!file.uid) file.uid = genFileId()
|
||||
uploadRef.value?.clearFiles?.()
|
||||
uploadRef.value?.handleStart?.(file)
|
||||
uploadRef.value?.submit?.()
|
||||
}
|
||||
const handleImageUploadSuccess = (
|
||||
response: any,
|
||||
uploadFile: UploadFile,
|
||||
uploadFiles: UploadFile[]
|
||||
) => {
|
||||
// 更新文件列表,将上传成功的文件信息保存
|
||||
const fileUrl =
|
||||
response?.url || response?.data?.file_url || response?.data?.storage_path_full || ''
|
||||
const storagePath =
|
||||
response?.data?.file_name || response?.file_name || response?.data?.storage_path || ''
|
||||
const fileName =
|
||||
response?.name ||
|
||||
response?.data?.original_name ||
|
||||
uploadFile.name ||
|
||||
storagePath.split('/').pop() ||
|
||||
''
|
||||
|
||||
uploadFile.url = fileUrl
|
||||
uploadFile.name = fileName
|
||||
uploadFile.response = response
|
||||
;(uploadFile as UploadFileWithStorage).storagePath = storagePath
|
||||
|
||||
// 更新 imageFileList
|
||||
imageFileList.value = uploadFiles.filter(
|
||||
(item) => item.status === 'success' || item.status === 'ready'
|
||||
) as UploadFileWithStorage[]
|
||||
|
||||
// 更新 form.imageUrls 数组
|
||||
form.imageUrls = imageFileList.value
|
||||
.map((file) => file.storagePath || '')
|
||||
.filter((path) => !!path)
|
||||
|
||||
ElMessage.success(response?.message || '图片上传成功')
|
||||
uploadLoadingInstance.value?.close()
|
||||
}
|
||||
const handleImageRemove = (uploadFile: UploadFile, uploadFiles: UploadFile[]) => {
|
||||
imageFileList.value = uploadFiles as UploadFileWithStorage[]
|
||||
form.imageUrls = imageFileList.value
|
||||
.map((file) => file.storagePath || '')
|
||||
.filter((path) => !!path)
|
||||
}
|
||||
const { hasAuth } = useAuth()
|
||||
defineOptions({ name: 'AdminKpiMonthly' })
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
type UploadFileWithStorage = UploadFile & { storagePath?: string }
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref<FormInstance>()
|
||||
const uploadRef = ref()
|
||||
const imageUploadRef = ref()
|
||||
const imageFileList = ref<UploadFileWithStorage[]>([])
|
||||
const keyword = ref<string>('')
|
||||
const ClassifyList = ref<any>()
|
||||
// 获取分类列表
|
||||
const getClassifyList = async () => {
|
||||
const response = await commonService.getClassifySelect()
|
||||
ClassifyList.value = response || []
|
||||
}
|
||||
|
||||
// 表单数据
|
||||
const form = reactive<any>({
|
||||
id: '',
|
||||
category_id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'link',
|
||||
url: '',
|
||||
url_pull: '',
|
||||
imageUrls: [] as string[],
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = reactive<FormRules>({
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
// 分页数据
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<any[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
getMonthlyList()
|
||||
})
|
||||
|
||||
// 监听搜索条件变化
|
||||
watch([keyword], () => {
|
||||
pagination.currentPage = 1
|
||||
getMonthlyList()
|
||||
})
|
||||
|
||||
// 获取列表
|
||||
const getMonthlyList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
const params = {
|
||||
current: currentPage,
|
||||
size: pageSize,
|
||||
keyword: keyword.value
|
||||
}
|
||||
|
||||
const { records, current, size, total } = await classifyService.getclassifycontentList(params)
|
||||
tableData.value = records || []
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
ElMessage.error('获取列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
getMonthlyList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
getMonthlyList()
|
||||
}
|
||||
|
||||
const parseToArray = (value: any): string[] => {
|
||||
if (!value) return []
|
||||
if (Array.isArray(value)) return value.filter(Boolean)
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 显示对话框
|
||||
const showDialog = (type: string, row?: any) => {
|
||||
getClassifyList()
|
||||
dialogVisible.value = true
|
||||
dialogType.value = type
|
||||
// 重置图片列表
|
||||
imageFileList.value = []
|
||||
if (type === 'edit' && row) {
|
||||
// 编辑模式
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
category_id: row.category_id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
type: row.type,
|
||||
url: row.url,
|
||||
url_pull: row.url_pull || row.url,
|
||||
sort: row.sort,
|
||||
status: row.status
|
||||
})
|
||||
// 如果是图片类型,初始化图片列表
|
||||
if (row.type === 'image') {
|
||||
const storageList = parseToArray(row.url)
|
||||
let previewList = parseToArray(row.url_pull)
|
||||
if (previewList.length === 0) {
|
||||
previewList = storageList.map((item) => item)
|
||||
}
|
||||
form.imageUrls = storageList
|
||||
// 初始化 imageFileList
|
||||
imageFileList.value = storageList.map((storagePath: string, index: number) => ({
|
||||
uid: genFileId(),
|
||||
name: storagePath.split('/').pop() || `image-${index + 1}.jpg`,
|
||||
status: 'success',
|
||||
url: previewList[index] || storagePath,
|
||||
response: {
|
||||
data: {
|
||||
file_url: previewList[index] || storagePath,
|
||||
file_name: storagePath
|
||||
}
|
||||
},
|
||||
storagePath
|
||||
}))
|
||||
} else {
|
||||
form.imageUrls = []
|
||||
}
|
||||
} else {
|
||||
// 新增模式
|
||||
Object.assign(form, {
|
||||
id: '',
|
||||
category_id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'link',
|
||||
url: '',
|
||||
url_pull: '',
|
||||
imageUrls: [],
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const deleteMonthly = (row: any) => {
|
||||
ElMessageBox.confirm('确定删除该记录吗?', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const { message } = await classifyService.deleteclassifycontent({ id: row.id })
|
||||
ElMessage.success(message)
|
||||
getMonthlyList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
await formEl.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
// 根据类型决定 url 的值
|
||||
let urlValue = form.url
|
||||
if (form.type === 'image') {
|
||||
// 图片类型:将图片数组转换为 JSON 字符串
|
||||
urlValue = form.imageUrls && form.imageUrls.length > 0 ? form.imageUrls.join(',') : ''
|
||||
}
|
||||
|
||||
if (dialogType.value === 'add') {
|
||||
// 新增
|
||||
const { message } = await classifyService.addclassifycontent({
|
||||
title: form.title,
|
||||
category_id: form.category_id,
|
||||
description: form.description,
|
||||
type: form.type,
|
||||
url: urlValue,
|
||||
sort: form.sort,
|
||||
status: form.status
|
||||
})
|
||||
ElMessage.success(message)
|
||||
} else {
|
||||
// 编辑
|
||||
const { message } = await classifyService.editclassifycontent({
|
||||
id: form.id,
|
||||
title: form.title,
|
||||
category_id: form.category_id,
|
||||
description: form.description,
|
||||
type: form.type,
|
||||
url: urlValue,
|
||||
sort: form.sort,
|
||||
status: form.status
|
||||
})
|
||||
ElMessage.success(message)
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getMonthlyList()
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
const filePreviewImage = computed(() =>
|
||||
form.url_pull && isImageUrl(form.url_pull) ? form.url_pull : imageUrl
|
||||
)
|
||||
const isImageUrl = (u: string) =>
|
||||
/\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test((u || '').split('?')[0])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-table__empty-block) {
|
||||
height: 100% !important;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-page {
|
||||
background: var(--art-main-bg-color);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.color-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 20px;
|
||||
background: var(--art-main-bg-color);
|
||||
}
|
||||
|
||||
.color-search-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.el-col2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.avatarimg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<div class="color-page">
|
||||
<div class="color-toolbar boxshadow">
|
||||
<div class="color-search-group">
|
||||
<el-form-item label="关键字:" label-width="70px">
|
||||
<el-input
|
||||
style="min-width: 120px"
|
||||
v-model="keyword"
|
||||
placeholder="请输入关键字"
|
||||
clearable
|
||||
@keyup.enter="getMonthlyList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="getMonthlyList" v-ripple>搜索</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxshadow">
|
||||
<ElRow>
|
||||
<div style="width: 12px"></div>
|
||||
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
|
||||
<ElButton
|
||||
@click="showDialog('add')"
|
||||
v-ripple
|
||||
v-if="hasAuth('classify_list@add')"
|
||||
type="primary"
|
||||
>
|
||||
新增
|
||||
</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn label="名称" prop="name" min-width="120">
|
||||
<template #default="scope">
|
||||
<p>{{ scope.row.name }}</p>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="icon" prop="icon" min-width="120">
|
||||
<template #default="scope">
|
||||
<i class="iconfont-sys" style="font-size: 20px" v-html="scope.row.icon"> </i>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="排序" prop="sort" min-width="120" />
|
||||
<ElTableColumn label="状态" prop="status" width="120">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status ? 'primary' : 'info'">{{
|
||||
scope.row.status ? '启用' : '禁用'
|
||||
}}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn fixed="right" label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<ElRow>
|
||||
<div :class="['btn-text', 'bg-primary']" @click="addchild(scope.row)"><i class="iconfont-sys"></i></div>
|
||||
<div :class="['btn-text', 'bg-secondary']" @click="showDialog('edit', scope.row)" v-if="hasAuth('classify_list@edit')"><i class="iconfont-sys"></i></div>
|
||||
<div :class="['btn-text', 'bg-error']" @click="deleteMonthly(scope.row)" v-if="hasAuth('classify_list@del')"><i class="iconfont-sys"></i></div>
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新增' : '编辑'"
|
||||
align-center
|
||||
width="600px"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px" class="form">
|
||||
<ElFormItem label="名称" prop="name">
|
||||
<ElInput v-model="form.name" placeholder="请输入名称" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="图标" prop="icon">
|
||||
<ArtIconSelector v-model="form.icon" :iconType="iconType" width="100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序" prop="sort">
|
||||
<ElInputNumber v-model="form.sort" :min="0" :max="999999" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="状态" prop="status">
|
||||
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
<!-- 新增/编辑子级对话框 -->
|
||||
<ElDialog
|
||||
v-model="dialogVisiblechild"
|
||||
:title="dialogTypechild === 'add' ? '新增下级分类' : '编辑分类'"
|
||||
align-center
|
||||
width="600px"
|
||||
>
|
||||
<ElForm ref="formRef" :model="formchild" :rules="rules" label-width="100px" class="form">
|
||||
<ElFormItem label="名称" prop="name">
|
||||
<ElInput v-model="formchild.parent_id" type="hidden" />
|
||||
<ElInput v-model="formchild.name" placeholder="请输入名称" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序" prop="sort">
|
||||
<ElInputNumber v-model="formchild.sort" :min="0" :max="999999" style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="状态" prop="status">
|
||||
<ElSwitch v-model="formchild.status" :active-value="1" :inactive-value="0" />
|
||||
</ElFormItem>
|
||||
<ElFormItem>
|
||||
<el-text class="mx-1" type="danger"
|
||||
>注意:当该分类有子分类时看板将不会展示该分类下内容</el-text
|
||||
>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisiblechild = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handlechildSubmit(formRef)">提交</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { classifyService } from '@/api/classify'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { IconTypeEnum } from '@/enums/appEnum'
|
||||
const { hasAuth } = useAuth()
|
||||
defineOptions({ name: 'AdminKpiMonthly' })
|
||||
const iconType = ref(IconTypeEnum.UNICODE)
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref<FormInstance>()
|
||||
const keyword = ref<string>('')
|
||||
|
||||
const dialogVisiblechild = ref(false)
|
||||
const dialogTypechild = ref('add')
|
||||
const formchild = reactive<any>({
|
||||
id: '',
|
||||
parent_id: '',
|
||||
icon: '',
|
||||
name: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const form = reactive<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
// 分页数据
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<any[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
getMonthlyList()
|
||||
})
|
||||
|
||||
// 监听搜索条件变化
|
||||
watch([keyword], () => {
|
||||
pagination.currentPage = 1
|
||||
getMonthlyList()
|
||||
})
|
||||
|
||||
// 获取月度业绩列表
|
||||
const getMonthlyList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
const params = {
|
||||
current: currentPage,
|
||||
size: pageSize,
|
||||
keyword: keyword.value
|
||||
}
|
||||
|
||||
const data = await classifyService.getclassifytreeList(params)
|
||||
tableData.value = data || []
|
||||
// Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
ElMessage.error('获取列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
getMonthlyList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
getMonthlyList()
|
||||
}
|
||||
|
||||
// 显示对话框
|
||||
const showDialog = (type: string, row?: any) => {
|
||||
if (type === 'edit' && row && row.parent_id != 0) {
|
||||
dialogVisiblechild.value = true
|
||||
dialogTypechild.value = type
|
||||
Object.assign(formchild, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
parent_id: row.parent_id,
|
||||
icon: row.icon,
|
||||
sort: row.sort,
|
||||
status: row.status
|
||||
})
|
||||
} else {
|
||||
dialogVisible.value = true
|
||||
dialogType.value = type
|
||||
if (type === 'edit' && row) {
|
||||
// 编辑模式
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
icon: row.icon,
|
||||
sort: row.sort,
|
||||
status: row.status
|
||||
})
|
||||
} else {
|
||||
// 新增模式
|
||||
Object.assign(form, {
|
||||
id: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
//添加子级
|
||||
const addchild = (row: any) => {
|
||||
dialogVisiblechild.value = true
|
||||
dialogTypechild.value = 'add'
|
||||
Object.assign(formchild, {
|
||||
id: '',
|
||||
parent_id: row.id,
|
||||
icon: '',
|
||||
name: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
const handlechildSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
await formEl.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
if (dialogTypechild.value === 'add') {
|
||||
const { message } = await classifyService.addclassify({
|
||||
name: formchild.name,
|
||||
icon: formchild.icon,
|
||||
sort: formchild.sort,
|
||||
status: formchild.status,
|
||||
parent_id: formchild.parent_id
|
||||
})
|
||||
ElMessage.success(message)
|
||||
dialogVisiblechild.value = false
|
||||
getMonthlyList()
|
||||
} else {
|
||||
const { message } = await classifyService.editclassify({
|
||||
id: formchild.id,
|
||||
name: formchild.name,
|
||||
icon: formchild.icon,
|
||||
sort: formchild.sort,
|
||||
status: formchild.status,
|
||||
parent_id: formchild.parent_id
|
||||
})
|
||||
ElMessage.success(message)
|
||||
dialogVisiblechild.value = false
|
||||
getMonthlyList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 编辑子级
|
||||
// 删除月度业绩
|
||||
const deleteMonthly = (row: any) => {
|
||||
ElMessageBox.confirm('确定删除该记录吗?', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const { message } = await classifyService.deleteclassify({ id: row.id })
|
||||
ElMessage.success(message)
|
||||
getMonthlyList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
await formEl.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
if (dialogType.value === 'add') {
|
||||
// 新增
|
||||
const { message } = await classifyService.addclassify({
|
||||
name: form.name,
|
||||
icon: form.icon,
|
||||
sort: form.sort,
|
||||
status: form.status
|
||||
})
|
||||
ElMessage.success(message)
|
||||
} else {
|
||||
// 编辑
|
||||
const { message } = await classifyService.editclassify({
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
icon: form.icon,
|
||||
sort: form.sort,
|
||||
status: form.status
|
||||
})
|
||||
ElMessage.success(message)
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getMonthlyList()
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-table__empty-block) {
|
||||
height: 100% !important;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-page {
|
||||
background: var(--art-main-bg-color);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.color-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 20px;
|
||||
background: var(--art-main-bg-color);
|
||||
}
|
||||
|
||||
.color-search-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.el-col2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn-text {
|
||||
display: inline-block;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
margin-right: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 34px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,773 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="menu-page" id="table-full-screen">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="formFilters"
|
||||
:items="formItems"
|
||||
:showExpand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
></ArtSearchBar>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader :showZebra="false" v-model:columns="columnChecks" @refresh="handleRefresh">
|
||||
<template #left>
|
||||
<el-segmented
|
||||
v-model="yearVal"
|
||||
size="large"
|
||||
:options="segmentedOptions"
|
||||
@change="searchArticleByYear"
|
||||
>
|
||||
<!-- <template #default="scope">
|
||||
<div class="flex flex-col items-center gap-2 p-2">
|
||||
<div>{{ scope.item.label }}</div>
|
||||
</div>
|
||||
</template> -->
|
||||
</el-segmented>
|
||||
<!-- 按钮权限:后端控制模式,使用自定义指令 -->
|
||||
<!-- <ElButton
|
||||
v-auth="'system@menu_add'"
|
||||
@click="showModel('menu', null)"
|
||||
v-ripple
|
||||
style="margin-left: 20px"
|
||||
>
|
||||
添加菜单
|
||||
</ElButton>
|
||||
<ElButton @click="toggleExpand" v-ripple>
|
||||
{{ isExpanded ? '收起' : '展开' }}
|
||||
</ElButton> -->
|
||||
<!-- 按钮权限:前端控制模式,使用 hasAuth 方法 -->
|
||||
<!-- <ElButton v-if="hasAuth('B_CODE1')" @click="showModel('menu', null, true)" v-ripple>
|
||||
添加菜单
|
||||
</ElButton> -->
|
||||
</template>
|
||||
<template #right>
|
||||
<div class="btnadds" @click="showModel('menu', null)" v-auth="'system@menu_add'">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
rowKey="path"
|
||||
ref="tableRef"
|
||||
:loading="loading"
|
||||
:data="filteredTableData"
|
||||
:marginTop="10"
|
||||
:stripe="false"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ElDialog :title="dialogTitle" v-model="dialogVisible" width="700px" align-center>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="85px">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="资源类型" prop="type">
|
||||
<el-select v-model="form.type">
|
||||
<el-option label="目录" value="directory" />
|
||||
<el-option label="菜单" value="menu" />
|
||||
<el-option label="权限" value="permission" />
|
||||
</el-select>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="资源名称" prop="name">
|
||||
<ElInput v-model="form.name" placeholder="资源名称"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="权限标识" prop="code">
|
||||
<ElInput v-model="form.code" placeholder="权限标识"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="图标" prop="icon">
|
||||
<ArtIconSelector v-model="form.icon" :iconType="iconType" width="100%" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20" v-if="form.type !== 'permission'">
|
||||
<ElCol :span="12" v-if="form.type !== 'permission'">
|
||||
<ElFormItem label="路由地址" prop="url">
|
||||
<ElInput v-model="form.url" placeholder="路由地址"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="打开方式" prop="open_type">
|
||||
<ElSelect v-model="form.open_type" class="account-select">
|
||||
<el-option label="内部" value="internal" />
|
||||
<el-option label="外部" value="external" />
|
||||
<el-option label="嵌入" value="iframe" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20" v-if="form.type !== 'permission'">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="资源排序" prop="sort" style="width: 100%">
|
||||
<ElInputNumber
|
||||
v-model="form.sort"
|
||||
style="width: 100%"
|
||||
@change="handleChange"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="外部链接" prop="link">
|
||||
<ElInput
|
||||
v-model="form.link"
|
||||
placeholder="外部链接/内嵌地址(https://www.baidu.com)"
|
||||
></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="系统" prop="system_id">
|
||||
<ElSelect v-model="form.system_id" class="account-select">
|
||||
<ElOption
|
||||
v-for="account in systemsList"
|
||||
:key="account.id"
|
||||
:label="account.name"
|
||||
:value="account.id"
|
||||
>
|
||||
<span>{{ account.name }}</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="父级" prop="parent_id">
|
||||
<el-tree-select
|
||||
v-model="form.parent_id"
|
||||
:data="parentList"
|
||||
:props="{
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
}"
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
style="width: 240px"
|
||||
/>
|
||||
<!-- <ElSelect v-model="form.parent_id" class="account-select">
|
||||
<ElOption
|
||||
v-for="account in parentList"
|
||||
:key="account.id"
|
||||
:label="account.name"
|
||||
:value="account.id"
|
||||
>
|
||||
<span>{{ account.name }}</span>
|
||||
</ElOption>
|
||||
</ElSelect> -->
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20" v-if="form.type !== 'permission'">
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="是否启用" prop="status">
|
||||
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="页面缓存" prop="keep_alive">
|
||||
<ElSwitch
|
||||
v-model="form.keep_alive"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="是否隐藏" prop="is_hide">
|
||||
<ElSwitch
|
||||
v-model="form.is_hide"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="标签页是否隐藏" prop="is_hide_tab" label-width="120px">
|
||||
<ElSwitch
|
||||
v-model="form.is_hide_tab"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<!-- <ElCol :span="5">
|
||||
<ElFormItem label="是否内嵌" prop="isMenu">
|
||||
<ElSwitch v-model="form.isIframe"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol> -->
|
||||
</ElRow>
|
||||
<ElRow :gutter="20" v-if="form.type !== 'permission'">
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="是否首页" prop="is_home">
|
||||
<ElSwitch v-model="form.is_home" :active-value="1" :inactive-value="0"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="10">
|
||||
<ElFormItem label="不需要认证" prop="no_auth" label-width="120px">
|
||||
<ElSwitch v-model="form.no_auth" :active-value="1" :inactive-value="0"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取 消</ElButton>
|
||||
<ElButton type="primary" @click="submitForm()">确 定</ElButton>
|
||||
</span>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { IconTypeEnum } from '@/enums/appEnum'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { ElPopover, ElButton } from 'element-plus'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { SearchFormItem } from '@/types'
|
||||
import { systemService } from '@/api/systemApi'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
const yearVal = ref<any>('')
|
||||
defineOptions({ name: 'Menus' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
// const { menuList } = storeToRefs(useMenuStore())
|
||||
const menuList = ref<any[]>([])
|
||||
|
||||
const loading = ref(false)
|
||||
const systemsListtab = ref<any>([])
|
||||
const segmentedOptions = computed(() =>
|
||||
Array.isArray(systemsListtab.value)
|
||||
? systemsListtab.value.map((item: any) => ({ label: item.name, value: item.id }))
|
||||
: []
|
||||
)
|
||||
const getsystemsListtab = async () => {
|
||||
const response = await UserService.getsystemsList()
|
||||
const list = (response as any)?.data?.records ?? (response as any)?.records ?? response ?? []
|
||||
systemsListtab.value = Array.isArray(list) ? list : []
|
||||
if (!yearVal.value && systemsListtab.value.length > 0) {
|
||||
yearVal.value = systemsListtab.value[0].id
|
||||
}
|
||||
}
|
||||
//系统
|
||||
const systemsList = ref<any>()
|
||||
const getsystemsList = async () => {
|
||||
const response = await UserService.getsystemsList()
|
||||
systemsList.value = response || []
|
||||
}
|
||||
//父级
|
||||
const parentList = ref<any>()
|
||||
const getparentList = async () => {
|
||||
const response = await systemService.getMenuList()
|
||||
parentList.value = response || []
|
||||
}
|
||||
// 定义表单搜索初始值
|
||||
const initialSearchState = {
|
||||
name: '',
|
||||
route: ''
|
||||
}
|
||||
|
||||
// 响应式表单数据
|
||||
const formFilters = reactive({ ...initialSearchState })
|
||||
|
||||
// 增加实际应用的搜索条件状态
|
||||
const appliedFilters = reactive({ ...initialSearchState })
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
Object.assign(formFilters, { ...initialSearchState })
|
||||
Object.assign(appliedFilters, { ...initialSearchState })
|
||||
getTableData()
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
// 将当前输入的筛选条件应用到实际搜索
|
||||
Object.assign(appliedFilters, { ...formFilters })
|
||||
getTableData()
|
||||
}
|
||||
|
||||
// 表单配置项
|
||||
const formItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '菜单名称',
|
||||
prop: 'name',
|
||||
type: 'input',
|
||||
config: {
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '路由地址',
|
||||
prop: 'route',
|
||||
type: 'input',
|
||||
config: {
|
||||
clearable: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 构建菜单类型标签
|
||||
const buildMenuTypeTag = (row: AppRouteRecord) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
return 'info'
|
||||
} else if (row.meta?.link && row.meta?.isIframe) {
|
||||
return 'success'
|
||||
} else if (row.path) {
|
||||
return 'primary'
|
||||
} else if (row.meta?.link) {
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单类型文本
|
||||
const buildMenuTypeText = (row: AppRouteRecord) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
return '目录'
|
||||
} else if (row.meta?.link && row.meta?.isIframe) {
|
||||
return '内嵌'
|
||||
} else if (row.path) {
|
||||
return '菜单'
|
||||
} else if (row.meta?.link) {
|
||||
return '外链'
|
||||
}
|
||||
}
|
||||
|
||||
// 动态列配置
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'meta.title',
|
||||
label: '菜单名称',
|
||||
minWidth: 120,
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return formatMenuTitle(row.meta?.title)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'type',
|
||||
label: '菜单类型',
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return h(ElTag, { type: buildMenuTypeTag(row) }, () => buildMenuTypeText(row))
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'path',
|
||||
label: '路由',
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return row.meta?.link || row.path || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'meta.authList',
|
||||
label: '可操作权限',
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
row.meta.authList?.map((item: { title: string; auth_mark: string }, index: number) => {
|
||||
return h(
|
||||
ElPopover,
|
||||
{
|
||||
placement: 'top-start',
|
||||
title: '操作',
|
||||
width: 200,
|
||||
trigger: 'click',
|
||||
key: index
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h('div', { style: 'margin: 0; text-align: right' }, [
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => showModel('button', item, false, row)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'danger',
|
||||
onClick: () => deleteAuth(item)
|
||||
},
|
||||
{ default: () => '删除' }
|
||||
)
|
||||
]),
|
||||
reference: () => h(ElButton, { class: 'small-btn' }, { default: () => item.title })
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'date',
|
||||
label: '编辑时间',
|
||||
formatter: () => '2022-3-12 12:00:00'
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '隐藏菜单',
|
||||
formatter: (row) => {
|
||||
return h(ElTag, { type: row.meta.isHide ? 'danger' : 'info' }, () =>
|
||||
row.meta.isHide ? '是' : '否'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'operation',
|
||||
label: '操作',
|
||||
width: 180,
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return h('div', [
|
||||
// 后端模式权限标识
|
||||
hasAuth('system@menu_add') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'add',
|
||||
onClick: () => showModel('menu', row, true)
|
||||
}),
|
||||
hasAuth('system@menu_update') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'edit',
|
||||
onClick: () => showDialog('edit', row)
|
||||
}),
|
||||
hasAuth('system@menu_destroy') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'delete',
|
||||
onClick: () => deleteMenu(row)
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const form = reactive({
|
||||
// 菜单
|
||||
system_id: '',
|
||||
type: 'menu', // 资源类型
|
||||
parent_id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
code: '',
|
||||
icon: '',
|
||||
status: 1,
|
||||
sort: 1,
|
||||
is_home: 0,
|
||||
keep_alive: 1,
|
||||
is_hide: 0,
|
||||
is_hide_tab: 0,
|
||||
no_auth: 0,
|
||||
link: '',
|
||||
open_type: 'internal'
|
||||
})
|
||||
const iconType = ref(IconTypeEnum.UNICODE)
|
||||
|
||||
// const labelPosition = ref('menu')
|
||||
const rules = reactive<FormRules>({
|
||||
name: [
|
||||
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
type: [{ required: true, message: '请选择资源类型', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '输入权限标识', trigger: 'blur' }]
|
||||
})
|
||||
const tableData = ref<AppRouteRecord[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
getsystemsListtab()
|
||||
getTableData()
|
||||
})
|
||||
const searchArticleByYear = () => {
|
||||
getTableData()
|
||||
}
|
||||
// const handleSegmentedClick = (item: any) => {
|
||||
// // 若点击的是当前项也强制刷新
|
||||
// yearVal.value = item?.value ?? yearVal.value
|
||||
// getTableData()
|
||||
// }
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
// 清空旧数据,避免视觉上出现追加
|
||||
menuList.value = []
|
||||
tableData.value = []
|
||||
// 并发安全:本次请求标识
|
||||
requestCounter += 1
|
||||
const reqId = requestCounter
|
||||
const params: any = {
|
||||
system_id: yearVal.value || '1'
|
||||
}
|
||||
try {
|
||||
const res = await systemService.getMenutreeList(params)
|
||||
// 若在等待过程中又发起了新请求,则丢弃本次结果
|
||||
if (reqId !== requestCounter) return
|
||||
menuList.value = Array.isArray(res) ? res : []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
menuList.value = []
|
||||
}
|
||||
// console.log(res)
|
||||
// menuList.value = data
|
||||
|
||||
// 基于 appliedFilters 在这里进行过滤,并将结果赋值给 tableData
|
||||
const searchName = appliedFilters.name?.toLowerCase().trim() || ''
|
||||
const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
|
||||
|
||||
const filterMenus = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
return items.reduce((acc: AppRouteRecord[], item: AppRouteRecord) => {
|
||||
// 浅拷贝节点,避免直接修改原数据
|
||||
const copy: AppRouteRecord = { ...item }
|
||||
if (copy.children && copy.children.length > 0) {
|
||||
copy.children = filterMenus(copy.children)
|
||||
}
|
||||
|
||||
const menuTitle = formatMenuTitle(copy.meta?.title || '').toLowerCase()
|
||||
const menuPath = (copy.path || '').toLowerCase()
|
||||
const nameMatch = !searchName || menuTitle.includes(searchName)
|
||||
const routeMatch = !searchRoute || menuPath.includes(searchRoute)
|
||||
const hasChildren = Array.isArray(copy.children) && copy.children.length > 0
|
||||
|
||||
if (hasChildren || (nameMatch && routeMatch)) {
|
||||
acc.push(copy)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
tableData.value = filterMenus(menuList.value)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 过滤后的表格数据改为来源于 getTableData 的结果
|
||||
const filteredTableData = computed(() => tableData.value)
|
||||
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const dialogTitle = computed(() => {
|
||||
// const type = labelPosition.value === 'menu' ? '菜单' : '权限'
|
||||
return isEdit.value ? `编辑资源` : `新建资源`
|
||||
})
|
||||
|
||||
const showDialog = (type: string, row: AppRouteRecord) => {
|
||||
showModel('menu', row)
|
||||
}
|
||||
|
||||
const handleChange = () => {}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const payload = { ...form, id: itemid.value }
|
||||
await systemService.menuEdit(payload as any)
|
||||
} else {
|
||||
await systemService.menuAdd(form)
|
||||
}
|
||||
|
||||
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
|
||||
dialogVisible.value = false
|
||||
// 刷新表格数据
|
||||
getTableData()
|
||||
} catch {
|
||||
ElMessage.error(`${isEdit.value ? '编辑' : '新增'}失败`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const itemid = ref('')
|
||||
const showModel = (type: string, row?: any, padd: boolean = false, data?: any) => {
|
||||
getsystemsList()
|
||||
getparentList()
|
||||
dialogVisible.value = true
|
||||
// labelPosition.value = type
|
||||
isEdit.value = false
|
||||
// lockMenuType.value = lock
|
||||
resetForm()
|
||||
itemid.value = ''
|
||||
if (data) {
|
||||
isEdit.value = true
|
||||
form.type = 'permission'
|
||||
form.system_id = data.meta.systemId
|
||||
form.parent_id = data.id
|
||||
form.name = row.title
|
||||
form.code = row.authMark
|
||||
itemid.value = row.id
|
||||
} else if (padd) {
|
||||
nextTick(() => {
|
||||
form.parent_id = row.id
|
||||
form.system_id = row.meta.systemId
|
||||
})
|
||||
} else if (row && !padd) {
|
||||
isEdit.value = true
|
||||
nextTick(() => {
|
||||
// 回显数据
|
||||
itemid.value = row.id
|
||||
form.system_id = row.meta.systemId
|
||||
form.parent_id = row.meta.parentId == 0 ? '' : row.meta.parentId
|
||||
form.name = row.name
|
||||
form.type = row.meta.type
|
||||
form.url = row.path
|
||||
form.code = row.meta.code
|
||||
form.icon = row.meta.icon
|
||||
form.sort = row.meta.sort
|
||||
form.is_home = row.meta.isHome
|
||||
form.keep_alive = row.meta.keepAlive
|
||||
form.is_hide = row.meta.isHide
|
||||
form.is_hide_tab = row.meta.isHideTab
|
||||
form.no_auth = row.meta.noAuth
|
||||
form.status = row.meta.status
|
||||
form.link = row.meta.link
|
||||
form.open_type = row.meta.openType
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value?.resetFields()
|
||||
Object.assign(form, {
|
||||
// 菜单
|
||||
system_id: '',
|
||||
type: 'menu', // 资源类型
|
||||
parent_id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
code: '',
|
||||
icon: '',
|
||||
status: 1,
|
||||
sort: 1,
|
||||
is_home: 0,
|
||||
keep_alive: 1,
|
||||
is_hide: 0,
|
||||
is_hide_tab: 0,
|
||||
no_auth: 0,
|
||||
link: '',
|
||||
open_type: 'internal'
|
||||
})
|
||||
}
|
||||
|
||||
const deleteMenu = async (item: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
const res = await systemService.menuDelete({ id: item.id })
|
||||
ElMessage.success(res?.message || '删除成功!')
|
||||
// 刷新表格数据
|
||||
getTableData()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAuth = async (item: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
const res = await systemService.menuDelete({ id: item.id })
|
||||
ElMessage.success(res?.message || '删除成功!')
|
||||
// 刷新表格数据
|
||||
getTableData()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
// 添加一个控制变量
|
||||
// const lockMenuType = ref(false)
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const tableRef = ref()
|
||||
|
||||
// 数据请求并发保护计数器
|
||||
let requestCounter = 0
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
nextTick(() => {
|
||||
if (tableRef.value) {
|
||||
tableRef.value[isExpanded.value ? 'expandAll' : 'collapseAll']()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-page {
|
||||
.svg-icon {
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
vertical-align: -8px;
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
:deep(.small-btn) {
|
||||
height: 22px !important;
|
||||
padding: 0 5px !important;
|
||||
font-size: 10px !important;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
:deep(.el-dialog__footer) {
|
||||
z-index: inherit;
|
||||
.el-button > span {
|
||||
z-index: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
.btnadds {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: 10px;
|
||||
color: var(--art-gray-700);
|
||||
cursor: pointer;
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.8);
|
||||
border-radius: 6px;
|
||||
transition: color 0.3s;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div class="customer-page">
|
||||
<div class="customer-toolbar boxshadow">
|
||||
<div class="customer-search-group">
|
||||
<el-form-item label="关键字:" label-width="70px">
|
||||
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxshadow">
|
||||
<ElRow>
|
||||
<div style="width: 12px"></div>
|
||||
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
|
||||
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('system@notice_add')"
|
||||
>新增</ElButton
|
||||
>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn label="编号" prop="msg_no" width="240px" />
|
||||
<ElTableColumn label="通知标题" prop="title" />
|
||||
<el-table-column prop="id" label="通知内容">
|
||||
<template #default="scope">
|
||||
<span v-html="scope.row.content" style="white-space: pre-line"></span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<ElTableColumn label="推送类型" prop="push_method_name" />
|
||||
<ElTableColumn label="场景" prop="scene_name" />
|
||||
<ElTableColumn label="创建时间" prop="created_at" />
|
||||
<ElTableColumn fixed="right" label="操作" width="200px">
|
||||
<template #default="scope">
|
||||
<ElRow>
|
||||
<el-button link @click="showfn(scope.row)"> 详情 </el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="deleteRole(scope.row)"
|
||||
v-if="hasAuth('system@notice_destroy')"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</div>
|
||||
<ElDialog v-model="dialogVisible" title="新增通知" align-center>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" class="form">
|
||||
<ElFormItem label="通知标题" prop="title">
|
||||
<ElInput v-model="form.title" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="通知内容" prop="content">
|
||||
<ElInput v-model="form.content" type="textarea" :rows="3" placeholder="请输入通知内容" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="通知用户" prop="recipient">
|
||||
<el-select
|
||||
v-model="form.recipient"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="请选择要通知的用户"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in companyUserList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-text class="mx-1" type="warning">不选择用户,代表通知所有用户!</el-text>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
<ElDialog v-model="dialogVisiblexq" title="通知详情" align-center width="640px">
|
||||
<div class="detail-dialog">
|
||||
<div class="section">
|
||||
<div class="info-grid">
|
||||
<div class="label">发送人</div>
|
||||
<div class="value">{{ noticeDetail.sender_name || '-' }}</div>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="label">通知标题</div>
|
||||
<div class="value">{{ noticeDetail.title || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="label" style="margin-bottom: 8px">通知内容</div>
|
||||
<div class="content-box">
|
||||
<div class="message-text" v-html="noticeDetail.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { systemService } from '@/api/systemApi'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
const { hasAuth } = useAuth()
|
||||
defineOptions({ name: 'AdminNotice' })
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogVisiblexq = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const { width } = useWindowSize()
|
||||
const keyword = ref<string>('')
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||
abbr: [{ required: true, message: '请输入公司简称', trigger: 'blur' }]
|
||||
// tenant_key: [{ required: true, message: '请输入飞书标识', trigger: 'blur' }]
|
||||
})
|
||||
const companyUserList = ref<any>()
|
||||
// 获取用户列表
|
||||
const getCompanyUserList = async () => {
|
||||
const response = await manageService.presetusers()
|
||||
companyUserList.value = response || []
|
||||
}
|
||||
const form = reactive<any>({
|
||||
title: '',
|
||||
content: '',
|
||||
recipient: []
|
||||
})
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
// 表格数据
|
||||
const tableData = ref<any[]>([])
|
||||
|
||||
// 表格实例引用
|
||||
const tableRef = ref()
|
||||
|
||||
onMounted(() => {
|
||||
getWorkList()
|
||||
})
|
||||
watch([keyword], () => {
|
||||
pagination.currentPage = 1
|
||||
getWorkList()
|
||||
})
|
||||
const noticeDetail = ref<any>({})
|
||||
const showfn = async (row: any) => {
|
||||
dialogVisiblexq.value = true
|
||||
await systemService.getNoticeDetail({ id: row.id }).then((res: any) => {
|
||||
noticeDetail.value = res
|
||||
console.log(res)
|
||||
})
|
||||
}
|
||||
// 处理表格分页变化
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
getWorkList()
|
||||
}
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
getWorkList()
|
||||
}
|
||||
// 获取列表数据
|
||||
const getWorkList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
|
||||
const { records, current, size, total } = await systemService.getNoticeList({
|
||||
current: currentPage,
|
||||
size: pageSize
|
||||
})
|
||||
tableData.value = records.map((item: any) => ({
|
||||
...item
|
||||
}))
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const dialogType = ref('add')
|
||||
|
||||
const showDialog = (type: string) => {
|
||||
getCompanyUserList()
|
||||
dialogVisible.value = true
|
||||
dialogType.value = type
|
||||
form.title = ''
|
||||
form.content = ''
|
||||
form.recipient = []
|
||||
}
|
||||
const deleteRole = (row?: any) => {
|
||||
ElMessageBox.confirm('确定删除吗?', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}).then(async () => {
|
||||
const { message } = await systemService.delNotice({
|
||||
id: row.id
|
||||
})
|
||||
ElMessage.success(message)
|
||||
getWorkList()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
const { message } = await systemService.sendNotice(form)
|
||||
ElMessage.success(message)
|
||||
dialogVisible.value = false
|
||||
getWorkList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-table__empty-block) {
|
||||
height: 100% !important;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.customer-page {
|
||||
background: var(--art-main-bg-color);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.page-content {
|
||||
.svg-icon {
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
vertical-align: -8px;
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
.customer-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 20px;
|
||||
background: var(--art-main-bg-color);
|
||||
}
|
||||
|
||||
.customer-search-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.message-text {
|
||||
padding: 10px 14px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.section {
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 6px 12px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.content-box {
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,561 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<ElRow>
|
||||
<ElCol :xs="24" :sm="12" :lg="6">
|
||||
<ElInput placeholder="角色名称"></ElInput>
|
||||
</ElCol>
|
||||
<div style="width: 12px"></div>
|
||||
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
|
||||
<ElButton v-ripple>搜索</ElButton>
|
||||
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('system@role_add')"
|
||||
>新增角色</ElButton
|
||||
>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="roleList"
|
||||
:currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn label="角色名称" prop="name" />
|
||||
<ElTableColumn label="描述" prop="desc" />
|
||||
<ElTableColumn label="启用" prop="enable">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status == 1 ? 'primary' : 'info'">
|
||||
{{ scope.row.status == 1 ? '启用' : '禁用' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="创建时间" prop="created_at"></ElTableColumn>
|
||||
<ElTableColumn fixed="right" label="操作" width="200px">
|
||||
<template #default="scope">
|
||||
<ElRow>
|
||||
<el-button link @click="showPermissionDialog(scope.row)">
|
||||
<!-- <el-button
|
||||
link
|
||||
@click="showPermissionDialog(scope.row)"
|
||||
v-if="hasAuth('system@role_perm')"
|
||||
> -->
|
||||
菜单权限
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
link
|
||||
@click="showDialog('edit', scope.row)"
|
||||
v-if="hasAuth('system@role_update')"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="deleteRole(scope.row)"
|
||||
v-if="hasAuth('system@role_destroy')"
|
||||
>删除
|
||||
</el-button>
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
|
||||
width="30%"
|
||||
align-center
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<ElFormItem label="角色名称" prop="name">
|
||||
<ElInput v-model="form.name" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="描述" prop="desc">
|
||||
<ElInput v-model="form.desc" type="textarea" :rows="3" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序" prop="sort">
|
||||
<ElInput type="number" v-model="form.sort" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用">
|
||||
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="permissionDialog" title="菜单权限" align-center>
|
||||
<el-tabs v-model="activeName" class="demo-tabs">
|
||||
<el-tab-pane
|
||||
v-for="sys in systemsList"
|
||||
:key="sys.id"
|
||||
:label="sys.name"
|
||||
:name="String(sys.id)"
|
||||
>
|
||||
<ElScrollbar height="70vh">
|
||||
<ElTree
|
||||
:ref="(el:any) => setTreeRef(sys.id, el)"
|
||||
:data="processedMenuBySystem[sys.id] || []"
|
||||
show-checkbox
|
||||
node-key="id"
|
||||
:default-expand-all="isExpandAll"
|
||||
:props="defaultProps"
|
||||
@check="() => handleTreeCheck(sys.id)"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div style="display: flex; align-items: center">
|
||||
<span v-if="data.isAuth">
|
||||
{{ data.label }}
|
||||
</span>
|
||||
<span v-else>{{ defaultProps.label(data) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</ElScrollbar>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton type="primary" @click="savePermission">保存</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import { systemService } from '@/api/systemApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
const { hasAuth } = useAuth()
|
||||
// import { ButtonMoreItem } from '@/components/core/forms/ArtButtonMore.vue'
|
||||
// import { Role, ROLE_LIST_DATA } from '@/mock/temp/formData'
|
||||
const loading = ref(false)
|
||||
defineOptions({ name: 'Role' })
|
||||
const activeName = ref('1')
|
||||
const dialogVisible = ref(false)
|
||||
const permissionDialog = ref(false)
|
||||
// 权限树数据(来自接口 getPermissionMenuList)
|
||||
const permissionMenuList = ref<any[]>([])
|
||||
const permissionMenuListwork = ref<any[]>([])
|
||||
const permissionMenuListcrm = ref<any[]>([])
|
||||
// 精简后的多系统映射
|
||||
const permissionMenuBySystem = reactive<Record<number, any[]>>({})
|
||||
const processedMenuBySystem = reactive<Record<number, any[]>>({})
|
||||
const treeRefs = reactive<Record<number, any>>({})
|
||||
const setTreeRef = (systemId: number, el: any) => {
|
||||
if (el) treeRefs[systemId] = el
|
||||
}
|
||||
const treeRef = ref()
|
||||
const treeRefwork = ref()
|
||||
const treeRefcrm = ref()
|
||||
const isExpandAll = ref(true)
|
||||
const isSelectAll = ref(false)
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
des: [{ required: true, message: '请输入角色描述', trigger: 'blur' }]
|
||||
})
|
||||
//系统
|
||||
const systemsList = ref<any>()
|
||||
const getsystemsList = async () => {
|
||||
const response = await UserService.getsystemsList()
|
||||
systemsList.value = response || []
|
||||
}
|
||||
const form = reactive<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
desc: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const roleList = ref<any[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
|
||||
// const getTableData = () => {
|
||||
// roleList.value = ROLE_LIST_DATA
|
||||
// }
|
||||
// 获取列表数据
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
|
||||
const { records, current, size, total } = await systemService.getRoleList({
|
||||
current: currentPage,
|
||||
size: pageSize
|
||||
})
|
||||
roleList.value = records.map((item: any) => ({
|
||||
...item
|
||||
}))
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// 处理表格分页变化
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
getTableData()
|
||||
}
|
||||
const dialogType = ref('add')
|
||||
|
||||
const showDialog = (type: string, row?: any) => {
|
||||
dialogVisible.value = true
|
||||
dialogType.value = type
|
||||
|
||||
if (type === 'edit' && row) {
|
||||
form.id = row.id
|
||||
form.name = row.name
|
||||
form.desc = row.desc
|
||||
form.sort = row.sort
|
||||
form.status = row.status
|
||||
} else {
|
||||
form.name = ''
|
||||
form.desc = ''
|
||||
form.sort = 0
|
||||
form.status = 1
|
||||
}
|
||||
}
|
||||
const roleId = ref('')
|
||||
const showPermissionDialog = async (row: any) => {
|
||||
permissionDialog.value = true
|
||||
roleId.value = row.id
|
||||
try {
|
||||
await getsystemsList()
|
||||
// 并行请求所选用户的各系统权限
|
||||
const sysIds = (systemsList.value || []).map((s: any) => s.id)
|
||||
const results = await Promise.all(
|
||||
sysIds.map((id: number) =>
|
||||
systemService.getPermissionMenuList({ role_id: row.id, system_id: id })
|
||||
)
|
||||
)
|
||||
sysIds.forEach((id: number, idx: number) => {
|
||||
const raw = results[idx] as any
|
||||
const list = raw?.data ?? raw?.records ?? raw
|
||||
permissionMenuBySystem[id] = Array.isArray(list) ? list : []
|
||||
processedMenuBySystem[id] = buildProcessedMenu(permissionMenuBySystem[id])
|
||||
})
|
||||
// 兼容旧变量,避免其它逻辑引用报错
|
||||
permissionMenuList.value = permissionMenuBySystem[1] || []
|
||||
permissionMenuListwork.value = permissionMenuBySystem[2] || []
|
||||
permissionMenuListcrm.value = permissionMenuBySystem[3] || []
|
||||
// 设置默认选中
|
||||
nextTick(() => {
|
||||
sysIds.forEach((id: number) => {
|
||||
const keys = buildDefaultCheckedKeys(permissionMenuBySystem[id] || [])
|
||||
treeRefs[id]?.setCheckedKeys?.(keys)
|
||||
handleTreeCheck(id)
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
permissionMenuList.value = []
|
||||
permissionMenuListwork.value = []
|
||||
permissionMenuListcrm.value = []
|
||||
}
|
||||
}
|
||||
// 构建默认选中的 key 列表(与 node-key="id" 一致)
|
||||
const buildDefaultCheckedKeys = (source: any[]): Array<string | number> => {
|
||||
const keys: Array<string | number> = []
|
||||
const walk = (node: any) => {
|
||||
const hasChildren = Array.isArray(node?.children) && node.children.length > 0
|
||||
const hasAuths = Array.isArray(node?.meta?.authList) && node.meta.authList.length > 0
|
||||
// 仅当没有子节点且没有权限子项时,才勾选父节点,避免联动导致子级被全选
|
||||
if (node?.meta?.selected && !hasChildren && !hasAuths) {
|
||||
keys.push(node.id)
|
||||
}
|
||||
if (Array.isArray(node?.meta?.authList)) {
|
||||
node.meta.authList.forEach((auth: any) => {
|
||||
if (auth?.selected) {
|
||||
keys.push(`${node.id}__auth__${auth.authMark}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (hasChildren) node.children.forEach(walk)
|
||||
}
|
||||
source.forEach(walk)
|
||||
return keys
|
||||
}
|
||||
// 处理菜单数据,将 authList 转换为子节点
|
||||
const processedMenuList = computed(() => {
|
||||
const processNode = (node: any) => {
|
||||
// 规范化 id,确保树节点唯一键与 node-key 对应
|
||||
const processed: any = { ...node, id: node.id }
|
||||
// 如果有 authList,将其转换为子节点
|
||||
if (node.meta && node.meta.authList && node.meta.authList.length) {
|
||||
const authNodes = node.meta.authList.map((auth: any) => ({
|
||||
// 为权限子节点提供稳定唯一 id(数字+字符串),与 node-key="id" 匹配
|
||||
id: `${node.id}__auth__${auth.authMark}`,
|
||||
name: `${node.name}_${auth.authMark}`,
|
||||
label: auth.title,
|
||||
auth_mark: auth.authMark,
|
||||
auth_id: auth.id,
|
||||
isAuth: true,
|
||||
checked: auth.selected || false
|
||||
}))
|
||||
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
|
||||
}
|
||||
// 递归处理子节点
|
||||
if (processed.children) {
|
||||
processed.children = processed.children.map(processNode)
|
||||
}
|
||||
return processed
|
||||
}
|
||||
const source = permissionMenuList.value || []
|
||||
return source.map(processNode)
|
||||
})
|
||||
const processedMenuListwork = computed(() => {
|
||||
const processNode = (node: any) => {
|
||||
// 规范化 id,确保树节点唯一键与 node-key 对应
|
||||
const processed: any = { ...node, id: node.id }
|
||||
// 如果有 authList,将其转换为子节点
|
||||
if (node.meta && node.meta.authList && node.meta.authList.length) {
|
||||
const authNodes = node.meta.authList.map((auth: any) => ({
|
||||
// 为权限子节点提供稳定唯一 id(数字+字符串),与 node-key="id" 匹配
|
||||
id: `${node.id}__auth__${auth.authMark}`,
|
||||
name: `${node.name}_${auth.authMark}`,
|
||||
label: auth.title,
|
||||
auth_mark: auth.authMark,
|
||||
auth_id: auth.id,
|
||||
isAuth: true,
|
||||
checked: auth.selected || false
|
||||
}))
|
||||
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
|
||||
}
|
||||
// 递归处理子节点
|
||||
if (processed.children) {
|
||||
processed.children = processed.children.map(processNode)
|
||||
}
|
||||
return processed
|
||||
}
|
||||
const source = permissionMenuListwork.value || []
|
||||
return source.map(processNode)
|
||||
})
|
||||
const processedMenuListcrm = computed(() => {
|
||||
const processNode = (node: any) => {
|
||||
// 规范化 id,确保树节点唯一键与 node-key 对应
|
||||
const processed: any = { ...node, id: node.id }
|
||||
// 如果有 authList,将其转换为子节点
|
||||
if (node.meta && node.meta.authList && node.meta.authList.length) {
|
||||
const authNodes = node.meta.authList.map((auth: any) => ({
|
||||
// 为权限子节点提供稳定唯一 id(数字+字符串),与 node-key="id" 匹配
|
||||
id: `${node.id}__auth__${auth.authMark}`,
|
||||
name: `${node.name}_${auth.authMark}`,
|
||||
label: auth.title,
|
||||
auth_mark: auth.authMark,
|
||||
auth_id: auth.id,
|
||||
isAuth: true,
|
||||
checked: auth.selected || false
|
||||
}))
|
||||
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
|
||||
}
|
||||
// 递归处理子节点
|
||||
if (processed.children) {
|
||||
processed.children = processed.children.map(processNode)
|
||||
}
|
||||
return processed
|
||||
}
|
||||
const source = permissionMenuListcrm.value || []
|
||||
return source.map(processNode)
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
children: 'children',
|
||||
label: (data: any) => formatMenuTitle(data.meta?.title) || ''
|
||||
}
|
||||
|
||||
// 通用:处理节点,展开权限为子节点
|
||||
const processNode = (node: any) => {
|
||||
const processed: any = { ...node, id: node.id }
|
||||
if (node.meta && node.meta.authList && node.meta.authList.length) {
|
||||
const authNodes = node.meta.authList.map((auth: any) => ({
|
||||
id: `${node.id}__auth__${auth.authMark}`,
|
||||
name: `${node.name}_${auth.authMark}`,
|
||||
label: auth.title,
|
||||
auth_mark: auth.authMark,
|
||||
auth_id: auth.id,
|
||||
isAuth: true,
|
||||
checked: auth.selected || false
|
||||
}))
|
||||
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
|
||||
}
|
||||
if (processed.children) processed.children = processed.children.map(processNode)
|
||||
return processed
|
||||
}
|
||||
const buildProcessedMenu = (source: any[]) => (source || []).map(processNode)
|
||||
|
||||
const deleteRole = async (row: any) => {
|
||||
ElMessageBox.confirm('确定删除该角色吗?', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}).then(async () => {
|
||||
const res = await systemService.roleDelete({ id: row.id })
|
||||
ElMessage.success(res?.message || '删除成功!')
|
||||
getTableData()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
|
||||
if (dialogType.value === 'add') {
|
||||
// 新增角色逻辑
|
||||
const res = await systemService.roleAdd(form)
|
||||
ElMessage.success(res?.message || '提交成功!')
|
||||
getTableData()
|
||||
} else {
|
||||
// 编辑角色逻辑
|
||||
const res = await systemService.roleEdit(form)
|
||||
ElMessage.success(res?.message || '提交成功!')
|
||||
getTableData()
|
||||
}
|
||||
dialogVisible.value = false
|
||||
formEl.resetFields()
|
||||
}
|
||||
const getSelectedIdsBySystem = (systemId: number) => {
|
||||
const tree = treeRefs[systemId]
|
||||
if (!tree) return [] as (string | number)[]
|
||||
const checkedNodes = tree.getCheckedNodes?.(false) || []
|
||||
const halfCheckedNodes = tree.getHalfCheckedNodes?.() || []
|
||||
const allNodes = [...checkedNodes, ...halfCheckedNodes]
|
||||
const menuIds = Array.from(
|
||||
new Set(allNodes.filter((n: any) => !n.isAuth && n.id != null).map((n: any) => n.id))
|
||||
)
|
||||
const authIds = Array.from(
|
||||
new Set(
|
||||
checkedNodes.filter((n: any) => n.isAuth && n.auth_id != null).map((n: any) => n.auth_id)
|
||||
)
|
||||
)
|
||||
return Array.from(new Set([...(menuIds as any[]), ...(authIds as any[])]))
|
||||
}
|
||||
const savePermission = async () => {
|
||||
const sysIds = (systemsList.value || []).map((s: any) => s.id)
|
||||
const idall = Array.from(new Set(sysIds.flatMap((id: number) => getSelectedIdsBySystem(id))))
|
||||
console.log('idall:', idall)
|
||||
const res = await systemService.roleAuth({
|
||||
role_id: roleId.value,
|
||||
resources: idall
|
||||
})
|
||||
ElMessage.success(res?.message || '权限保存成功')
|
||||
permissionDialog.value = false
|
||||
}
|
||||
const toggleExpandAll = () => {
|
||||
const tree = treeRef.value
|
||||
if (!tree) return
|
||||
|
||||
// 使用store.nodesMap直接控制所有节点的展开状态
|
||||
const nodes = tree.store.nodesMap
|
||||
for (const node in nodes) {
|
||||
nodes[node].expanded = !isExpandAll.value
|
||||
}
|
||||
|
||||
isExpandAll.value = !isExpandAll.value
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const tree = treeRef.value
|
||||
if (!tree) return
|
||||
|
||||
if (!isSelectAll.value) {
|
||||
// 全选:获取所有节点的key并设置为选中
|
||||
const allKeys = getAllNodeKeys(processedMenuList.value)
|
||||
tree.setCheckedKeys(allKeys)
|
||||
} else {
|
||||
// 取消全选:清空所有选中
|
||||
tree.setCheckedKeys([])
|
||||
}
|
||||
|
||||
isSelectAll.value = !isSelectAll.value
|
||||
}
|
||||
|
||||
const getAllNodeKeys = (nodes: any[]): Array<string | number> => {
|
||||
const keys: Array<string | number> = []
|
||||
const traverse = (nodeList: any[]) => {
|
||||
nodeList.forEach((node) => {
|
||||
if (node.id !== undefined && node.id !== null) {
|
||||
keys.push(node.id)
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
traverse(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
traverse(nodes)
|
||||
return keys
|
||||
}
|
||||
|
||||
const handleTreeCheck = (systemId?: number) => {
|
||||
const sysId = systemId ?? Number(activeName.value)
|
||||
const tree = treeRefs[sysId] || treeRef.value
|
||||
if (!tree) return
|
||||
const checkedKeys = tree.getCheckedKeys?.() || []
|
||||
const allKeys = getAllNodeKeys(processedMenuBySystem[sysId] || [])
|
||||
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
|
||||
}
|
||||
const handleTreeCheckwork = () => {
|
||||
const tree = treeRefwork.value
|
||||
if (!tree) return
|
||||
|
||||
// 使用树组件的getCheckedKeys方法获取选中的节点
|
||||
const checkedKeys = tree.getCheckedKeys()
|
||||
const allKeys = getAllNodeKeys(processedMenuListwork.value)
|
||||
|
||||
// 判断是否全选:选中的节点数量等于总节点数量
|
||||
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
|
||||
}
|
||||
const handleTreeCheckcrm = () => {
|
||||
const tree = treeRefcrm.value
|
||||
if (!tree) return
|
||||
|
||||
// 使用树组件的getCheckedKeys方法获取选中的节点
|
||||
const checkedKeys = tree.getCheckedKeys()
|
||||
const allKeys = getAllNodeKeys(processedMenuListcrm.value)
|
||||
|
||||
// 判断是否全选:选中的节点数量等于总节点数量
|
||||
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-content {
|
||||
.svg-icon {
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
vertical-align: -8px;
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<ElRow>
|
||||
<div style="width: 12px"></div>
|
||||
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
|
||||
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('systemset@add')">
|
||||
新增
|
||||
</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn label="ID" prop="id" />
|
||||
<ElTableColumn label="名称" prop="name" />
|
||||
<ElTableColumn label="图片">
|
||||
<template #default="scope">
|
||||
<div class="thumb-list" v-if="parsePreviewList(scope.row.value_pull).length">
|
||||
<img
|
||||
v-for="(imgUrl, idx) in parsePreviewList(scope.row.value_pull)"
|
||||
:key="idx"
|
||||
:src="imgUrl"
|
||||
alt="图片"
|
||||
class="avatarimg"
|
||||
@click="handleAvatarClick(imgUrl)"
|
||||
/>
|
||||
</div>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<!-- <ElTableColumn label="标识" prop="code" /> -->
|
||||
<ElTableColumn label="状态" prop="status">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status ? 'primary' : 'info'">
|
||||
{{ scope.row.status ? '启用' : '禁用' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<!-- <ElTableColumn label="创建时间" prop="created_at" /> -->
|
||||
<ElTableColumn fixed="right" label="操作" width="200px">
|
||||
<template #default="scope">
|
||||
<ElRow>
|
||||
<el-button
|
||||
link
|
||||
@click="showDialog('edit', scope.row)"
|
||||
v-if="hasAuth('systemset@edit')"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="deleteRole(scope.row)"
|
||||
v-if="hasAuth('systemset@del')"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新增' : '编辑'"
|
||||
width="40%"
|
||||
align-center
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" class="form">
|
||||
<ElFormItem label="名称" prop="name">
|
||||
<ElInput v-model="form.name" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="标识" prop="code" v-if="!isedit">
|
||||
<ElInput v-model="form.code" :disabled="isedit" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="背景图片" prop="tenant_key">
|
||||
<el-upload
|
||||
ref="imageUploadRef"
|
||||
class="image-upload"
|
||||
:action="actionFileUpload"
|
||||
:headers="uploadHeaders"
|
||||
:on-success="handleImageUploadSuccess"
|
||||
:on-remove="handleImageRemove"
|
||||
:on-progress="handleUploadProgress"
|
||||
:file-list="imageFileList"
|
||||
:multiple="true"
|
||||
:limit="20"
|
||||
:auto-upload="true"
|
||||
list-type="picture-card"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="状态">
|
||||
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
<!-- 图片预览组件 -->
|
||||
<ElImageViewer
|
||||
v-if="showImageViewer"
|
||||
:url-list="[currentImageUrl]"
|
||||
:hide-on-click-modal="true"
|
||||
@close="showImageViewer = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
|
||||
import { systemService } from '@/api/systemApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
const actionFileUpload = `${import.meta.env.VITE_API_URL}/upload?scene=classify`
|
||||
const { hasAuth } = useAuth()
|
||||
defineOptions({ name: 'Company' })
|
||||
type UploadFileWithStorage = UploadFile & { storagePath?: string }
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const imageUploadRef = ref()
|
||||
const imageFileList = ref<UploadFileWithStorage[]>([])
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
|
||||
// tenant_key: [{ required: true, message: '请输入飞书标识', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const form = reactive<any>({
|
||||
name: '',
|
||||
id: '',
|
||||
code: '',
|
||||
status: 1,
|
||||
// 背景图片存储路径(多个用逗号分隔)
|
||||
value: ''
|
||||
})
|
||||
|
||||
// 背景图片上传 loading
|
||||
const uploadLoadingInstance = shallowRef<ReturnType<typeof ElLoading.service> | null>(null)
|
||||
|
||||
const handleUploadProgress = () => {
|
||||
uploadLoadingInstance.value = ElLoading.service({
|
||||
lock: true,
|
||||
text: '上传中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
}
|
||||
|
||||
const syncFormValueFromImages = () => {
|
||||
// 将所有图片的 storagePath(file_name)用逗号拼接,保存到 form.value
|
||||
form.value = imageFileList.value
|
||||
.map((file) => file.storagePath || '')
|
||||
.filter((path) => !!path)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
const handleImageUploadSuccess = (
|
||||
response: any,
|
||||
uploadFile: UploadFile,
|
||||
uploadFiles: UploadFile[]
|
||||
) => {
|
||||
// 接口返回结构示例:
|
||||
// {
|
||||
// original_name: 'homgbg.png',
|
||||
// file_name: 'bsh_datax/classify/2025...png',
|
||||
// file_url: 'https://...png?...'
|
||||
// }
|
||||
const fileUrl = response?.file_url || response?.url || response?.data?.file_url || ''
|
||||
const storagePath =
|
||||
response?.file_name || response?.data?.file_name || response?.data?.storage_path || ''
|
||||
|
||||
uploadFile.url = fileUrl
|
||||
uploadFile.name =
|
||||
response?.original_name || uploadFile.name || storagePath.split('/').pop() || ''
|
||||
uploadFile.response = response
|
||||
;(uploadFile as UploadFileWithStorage).storagePath = storagePath
|
||||
|
||||
// 更新文件列表(仅保留成功或准备中的文件)
|
||||
imageFileList.value = uploadFiles.filter(
|
||||
(item) => item.status === 'success' || item.status === 'ready'
|
||||
) as UploadFileWithStorage[]
|
||||
|
||||
syncFormValueFromImages()
|
||||
|
||||
ElMessage.success(response?.message || '图片上传成功')
|
||||
uploadLoadingInstance.value?.close()
|
||||
}
|
||||
|
||||
const handleImageRemove = (_file: UploadFile, uploadFiles: UploadFile[]) => {
|
||||
imageFileList.value = uploadFiles as UploadFileWithStorage[]
|
||||
syncFormValueFromImages()
|
||||
}
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
// 表格数据
|
||||
const tableData = ref<any[]>([])
|
||||
|
||||
// 表格实例引用
|
||||
const tableRef = ref()
|
||||
|
||||
onMounted(() => {
|
||||
getWorkList()
|
||||
})
|
||||
// 图片预览相关
|
||||
const showImageViewer = ref(false)
|
||||
const currentImageUrl = ref('')
|
||||
const handleAvatarClick = (avatarUrl: string) => {
|
||||
if (avatarUrl) {
|
||||
currentImageUrl.value = avatarUrl
|
||||
showImageViewer.value = true
|
||||
}
|
||||
}
|
||||
// 处理表格分页变化
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
getWorkList()
|
||||
}
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
getWorkList()
|
||||
}
|
||||
// 获取列表数据
|
||||
const getWorkList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
|
||||
const { records, current, size, total } = await systemService.getconfigList({
|
||||
current: currentPage,
|
||||
size: pageSize
|
||||
})
|
||||
tableData.value = records.map((item: any) => ({
|
||||
...item
|
||||
}))
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const dialogType = ref('add')
|
||||
const isedit = ref(false)
|
||||
|
||||
const parsePaths = (value: any): string[] => {
|
||||
if (!value) return []
|
||||
if (Array.isArray(value)) return value.filter(Boolean)
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||
} catch {
|
||||
// ignore json parse errors
|
||||
}
|
||||
return value
|
||||
.split(',')
|
||||
.map((p: string) => p.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const parsePreviewList = (value: any): string[] => parsePaths(value)
|
||||
|
||||
const showDialog = (type: string, row?: any) => {
|
||||
dialogVisible.value = true
|
||||
dialogType.value = type
|
||||
// 编辑/新增时重置上传列表
|
||||
imageFileList.value = []
|
||||
|
||||
if (type === 'edit' && row) {
|
||||
isedit.value = true
|
||||
form.name = row.name
|
||||
form.id = row.id
|
||||
form.status = row.status
|
||||
form.code = row.code
|
||||
form.value = row.value
|
||||
const storagePaths = parsePaths(row.value)
|
||||
let previewPaths = parsePaths(row.value_pull)
|
||||
if (previewPaths.length === 0 && storagePaths.length > 0) {
|
||||
previewPaths = storagePaths.map((path) => path)
|
||||
}
|
||||
if (storagePaths.length > 0) {
|
||||
imageFileList.value = storagePaths.map(
|
||||
(storagePath: string, index: number): UploadFileWithStorage => ({
|
||||
uid: (index + 1) as any,
|
||||
name: storagePath.split('/').pop() || `bg-${index + 1}`,
|
||||
status: 'success',
|
||||
url: previewPaths[index] || storagePath,
|
||||
response: {
|
||||
file_name: storagePath,
|
||||
file_url: previewPaths[index] || storagePath
|
||||
},
|
||||
storagePath
|
||||
})
|
||||
)
|
||||
} else {
|
||||
imageFileList.value = []
|
||||
}
|
||||
syncFormValueFromImages()
|
||||
} else {
|
||||
isedit.value = false
|
||||
form.name = ''
|
||||
form.id = ''
|
||||
form.status = 1
|
||||
form.code = ''
|
||||
form.value = ''
|
||||
imageFileList.value = []
|
||||
}
|
||||
}
|
||||
const deleteRole = (row?: any) => {
|
||||
ElMessageBox.confirm('确定删除吗?', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}).then(async () => {
|
||||
const { message } = await systemService.companyDelete({
|
||||
id: row.id
|
||||
})
|
||||
ElMessage.success(message)
|
||||
getWorkList()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
// 提交前确保 form.value 与当前图片列表同步
|
||||
syncFormValueFromImages()
|
||||
if (dialogType.value === 'add') {
|
||||
const { message } = await systemService.configAdd({
|
||||
name: form.name,
|
||||
value: form.value,
|
||||
code: form.code,
|
||||
status: form.status
|
||||
})
|
||||
ElMessage.success(message)
|
||||
dialogVisible.value = false
|
||||
getWorkList()
|
||||
} else {
|
||||
const { message } = await systemService.configEdit({
|
||||
name: form.name,
|
||||
id: form.id,
|
||||
value: form.value,
|
||||
code: form.code,
|
||||
status: form.status
|
||||
})
|
||||
ElMessage.success(message)
|
||||
dialogVisible.value = false
|
||||
getWorkList()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-table__empty-block) {
|
||||
height: 100% !important;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.page-content {
|
||||
.svg-icon {
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
vertical-align: -8px;
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
.thumb-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.avatarimg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.2s;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<div class="page-content user">
|
||||
<div class="content">
|
||||
<div class="left-wrap">
|
||||
<div class="user-wrap box-style">
|
||||
<!-- <img class="bg" src="@imgs/user/bg.webp" /> -->
|
||||
<img
|
||||
class="avatar"
|
||||
:src="
|
||||
userInfo.avatar_pull != '' ? userInfo.avatar_pull : '/src/assets/img/user/avatar.webp'
|
||||
"
|
||||
/>
|
||||
<h2 class="name">{{ userInfo.name }}</h2>
|
||||
<p class="des">SN:{{ userInfo.sn }}</p>
|
||||
<div class="outer-info">
|
||||
<!-- <div class="info-row">
|
||||
<span class="info-label">昵 称:</span>
|
||||
<span class="info-value">{{ userInfo.mobile }}</span>
|
||||
</div> -->
|
||||
<div class="info-row">
|
||||
<span class="info-label">电 话:</span>
|
||||
<span class="info-value">{{ userInfo.mobile }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">邮 箱:</span>
|
||||
<span class="info-value">{{ userInfo.email }}</span>
|
||||
</div>
|
||||
<!-- <div class="info-row">
|
||||
<span class="info-label">公 司:</span>
|
||||
<span class="info-value">{{ userInfo.corp_name }}</span>
|
||||
</div> -->
|
||||
<!-- <div class="info-row">
|
||||
<span class="info-label">国 家:</span>
|
||||
<span class="info-value">{{ userInfo.country_txt }}</span>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
<div class="info box-style">
|
||||
<h1 class="title"><span class="border" style="margin-right: 5px"></span>基本信息</h1>
|
||||
|
||||
<ElForm
|
||||
:model="form"
|
||||
class="form"
|
||||
ref="ruleFormRef"
|
||||
label-width="86px"
|
||||
label-position="left"
|
||||
>
|
||||
<ElFormItem label="头像" prop="avatar">
|
||||
<el-upload
|
||||
class="qrcode-upload"
|
||||
:action="actionImageUplode"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUploadavatar"
|
||||
:disabled="!isEdit"
|
||||
name="image"
|
||||
>
|
||||
<div class="qrcode-upload-box">
|
||||
<img
|
||||
class="avatar"
|
||||
:src="
|
||||
imageUrlavatar != '' ? imageUrlavatar : '/src/assets/img/user/avatar.webp'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</el-upload>
|
||||
</ElFormItem>
|
||||
<ElRow>
|
||||
<ElFormItem label="昵称" prop="name">
|
||||
<el-input v-model="form.name" :disabled="!isEdit" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="性别" prop="sex" class="right-input">
|
||||
<ElSelect v-model="form.sex" placeholder="未知" :disabled="!isEdit">
|
||||
<ElOption
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
|
||||
<ElRow>
|
||||
<ElFormItem label="联系方式" prop="mobile">
|
||||
<ElInput v-model="form.mobile" disabled />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="邮箱" prop="email" class="right-input">
|
||||
<ElInput v-model="form.email" :disabled="!isEdit" />
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
<div class="el-form-item-right">
|
||||
<ElButton type="primary" style="width: 90px" v-ripple @click="edit">
|
||||
<i v-if="!isEdit" class="iconfont-sys"> </i>
|
||||
{{ isEdit ? '保存' : '编辑' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<div class="info box-style" style="margin-top: 20px">
|
||||
<h1 class="title">更改密码</h1>
|
||||
|
||||
<ElForm :model="pwdForm" class="form" label-width="86px" label-position="left">
|
||||
<ElFormItem label="当前密码" prop="password">
|
||||
<ElInput
|
||||
v-model="pwdForm.password"
|
||||
type="password"
|
||||
:disabled="!isEditPwd"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="新密码" prop="password_new">
|
||||
<ElInput
|
||||
v-model="pwdForm.password_new"
|
||||
type="password"
|
||||
:disabled="!isEditPwd"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="确认新密码" prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model="pwdForm.confirmPassword"
|
||||
type="password"
|
||||
:disabled="!isEditPwd"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<div class="el-form-item-right">
|
||||
<ElButton type="primary" style="width: 90px" v-ripple @click="editPwd">
|
||||
<i v-if="!isEditPwd" class="iconfont-sys"> </i>
|
||||
{{ isEditPwd ? '保存' : '编辑' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElForm, FormInstance } from 'element-plus'
|
||||
// import imageUrl from '@/assets/img/crm/upimg.png'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
let actionImageUplode = `${import.meta.env.VITE_API_URL}/upload/image?scene=user`
|
||||
defineOptions({ name: 'AdminUserCenter' })
|
||||
// 定义客户详情类型
|
||||
interface CustomerInfo {
|
||||
[key: string]: any
|
||||
}
|
||||
const imageUrlavatar = ref('')
|
||||
const sign_img_url = ref('')
|
||||
const form = ref<CustomerInfo>({})
|
||||
const userInfo = ref<CustomerInfo>({})
|
||||
const getuserInfo = async () => {
|
||||
const data = await UserService.getUserInfo()
|
||||
userInfo.value = data.user
|
||||
form.value = data.user
|
||||
imageUrlavatar.value = data.user.avatar_pull
|
||||
sign_img_url.value = data.user.sign_img_url
|
||||
}
|
||||
const isEdit = ref(false)
|
||||
const showReSignQR = ref(false)
|
||||
const isEditPwd = ref(false)
|
||||
const pwdForm = reactive({
|
||||
password: '',
|
||||
password_new: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const ruleFormRef = ref<FormInstance>()
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: '',
|
||||
label: '未知'
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '男'
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '女'
|
||||
}
|
||||
]
|
||||
onMounted(async () => {
|
||||
// await getarea()
|
||||
await getuserInfo()
|
||||
})
|
||||
const edit = async () => {
|
||||
if (isEdit.value) {
|
||||
const payload: any = { ...(form.value as any) }
|
||||
delete payload.sign_img_url
|
||||
const res = await UserService.editUserInfo(payload)
|
||||
ElMessage.success(res?.message || '操作成功')
|
||||
getuserInfo()
|
||||
}
|
||||
isEdit.value = !isEdit.value
|
||||
if (isEdit.value === false) {
|
||||
showReSignQR.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editPwd = async () => {
|
||||
if (isEditPwd.value) {
|
||||
// 判断新密码和确认密码是否一致
|
||||
if (pwdForm.password_new !== pwdForm.confirmPassword) {
|
||||
ElMessage.error('新密码和确认密码不一致')
|
||||
return
|
||||
}
|
||||
const res = await UserService.savePassword(pwdForm)
|
||||
ElMessage.success(res?.message || '操作成功')
|
||||
}
|
||||
isEditPwd.value = !isEditPwd.value
|
||||
}
|
||||
//头像上传
|
||||
function handleUploadavatar(response: any) {
|
||||
// 假设返回图片地址字段为 response.url 或 response.data.url
|
||||
imageUrlavatar.value = response.data.url
|
||||
// 如需同步到 form.picture
|
||||
form.value.avatar = response.data.href
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.user {
|
||||
.icon {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
overflow: hidden;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.qrcode-radio-group .el-form-item__content) {
|
||||
height: 40px;
|
||||
}
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.qrcode-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.page-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
$box-radius: calc(var(--custom-radius) + 4px);
|
||||
|
||||
.box-style {
|
||||
border: 1px solid var(--art-border-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
|
||||
.left-wrap {
|
||||
width: 350px;
|
||||
margin-right: 25px;
|
||||
|
||||
.user-wrap {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
padding: 35px 30px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
// background: var(--art-main-bg-color);
|
||||
background: linear-gradient(180deg, #e9f0ff 0%, #ffffff 67.9%);
|
||||
|
||||
border-radius: $box-radius;
|
||||
.avatar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
// margin-top: 120px;
|
||||
object-fit: cover;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-top: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.des {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.outer-info {
|
||||
// width: 260px;
|
||||
margin: 0 auto;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.info-label {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
color: #222;
|
||||
text-align: right;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.info-value {
|
||||
color: #333;
|
||||
margin-left: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.lables {
|
||||
margin-top: 40px;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 15px;
|
||||
|
||||
> div {
|
||||
padding: 3px 6px;
|
||||
margin: 0 10px 10px 0;
|
||||
font-size: 12px;
|
||||
background: var(--art-main-bg-color);
|
||||
border: 1px solid var(--art-border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery {
|
||||
margin-top: 25px;
|
||||
border-radius: 10px;
|
||||
|
||||
.item {
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: $box-radius;
|
||||
|
||||
.info {
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: $box-radius;
|
||||
|
||||
.title {
|
||||
padding: 15px 25px;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--art-text-gray-800);
|
||||
// border-bottom: 1px solid var(--art-border-color);
|
||||
}
|
||||
|
||||
.form {
|
||||
box-sizing: border-box;
|
||||
padding: 30px 25px;
|
||||
|
||||
> .el-row {
|
||||
.el-form-item {
|
||||
width: calc(50% - 10px);
|
||||
}
|
||||
|
||||
.el-input,
|
||||
.el-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.right-input {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.el-form-item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
.el-button {
|
||||
width: 110px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad-vertical) {
|
||||
.page-content {
|
||||
.content {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
|
||||
.left-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.signature-img {
|
||||
width: 160px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,782 @@
|
||||
<template>
|
||||
<div class="customer-page">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="customer-toolbar boxshadow">
|
||||
<div class="customer-search-group">
|
||||
<el-form-item label="关键字:" label-width="70px">
|
||||
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态:" label-width="50px">
|
||||
<el-select v-model="selectValue" style="min-width: 120px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="正常" value="1" />
|
||||
<el-option label="离职" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxshadow">
|
||||
<el-button type="primary" @click="onEdit('add', '')">
|
||||
<i class="iconfont-sys"> </i> 新增用户
|
||||
</el-button>
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
class="customer-table"
|
||||
>
|
||||
<template #default>
|
||||
<!-- <el-table-column prop="id" label="用户ID" align="center" /> -->
|
||||
<el-table-column prop="sn" label="SN" align="center" />
|
||||
<el-table-column prop="follow" label="头像" align="center">
|
||||
<template #default="scope">
|
||||
<img
|
||||
:src="scope.row.avatar_pull"
|
||||
alt="头像"
|
||||
class="avatar"
|
||||
@click="handleAvatarClick(scope.row.avatar_pull)"
|
||||
style="cursor: pointer"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="用户名称" align="center" />
|
||||
<el-table-column prop="mobile" label="联系电话" align="center" />
|
||||
<el-table-column prop="email" label="邮箱" align="center" />
|
||||
<el-table-column prop="follow" label="状态" align="center">
|
||||
<template #default="scope">
|
||||
<span :class="statusClass(scope.row.status)">
|
||||
{{ scope.row.status_txt }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column fixed="right" label="操作" width="200px" align="center">
|
||||
<template #default="scope">
|
||||
<el-link
|
||||
type="success"
|
||||
underline="never"
|
||||
style="margin-right: 8px"
|
||||
@click="showPermissionDialog(scope.row)"
|
||||
v-if="hasAuth('user@direct_perm')"
|
||||
>
|
||||
<i class="iconfont-sys"></i>直接授权
|
||||
</el-link>
|
||||
<el-link
|
||||
type="warning"
|
||||
underline="never"
|
||||
style="margin-right: 8px"
|
||||
@click="showroleDialog(scope.row)"
|
||||
v-if="hasAuth('user@role_system')"
|
||||
>
|
||||
<i class="iconfont-sys"></i>角色&系统
|
||||
</el-link>
|
||||
|
||||
<el-link
|
||||
type="success"
|
||||
underline="never"
|
||||
style="margin-right: 8px"
|
||||
@click="onEdit('edit', scope.row)"
|
||||
v-if="hasAuth('user@edit')"
|
||||
>
|
||||
<i class="iconfont-sys"> </i>编辑
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog
|
||||
:title="dialogType === 'add' ? '新增用户' : '编辑用户'"
|
||||
v-model="AddDialogVisible"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="addForm" ref="formRef" :rules="rules" label-width="100px" class="add-form">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="用户名称" prop="name">
|
||||
<el-input v-model="addForm.name" placeholder="请输入用户名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="联系电话" prop="mobile">
|
||||
<el-input v-model="addForm.mobile" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="SN编码" prop="sn">
|
||||
<el-input v-model="addForm.sn" placeholder="请输入SN编码" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="Email" prop="email">
|
||||
<el-input v-model="addForm.email" placeholder="请输入Email" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="用户昵称" prop="nickname">
|
||||
<el-input v-model="addForm.nickname" placeholder="请输入用户昵称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="头像">
|
||||
<el-upload
|
||||
class="qrcode-upload"
|
||||
:action="actionImageUplode"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:multiple="false"
|
||||
:limit="1"
|
||||
:on-success="handleAvatarUploadSuccess"
|
||||
name="image"
|
||||
>
|
||||
<div class="qrcode-upload-box">
|
||||
<img class="avatar" :src="avatar_pull !== '' ? avatar_pull : imgs" />
|
||||
</div>
|
||||
</el-upload>
|
||||
<el-input v-model="addForm.avatar" type="hidden" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="addForm.status">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="平台管理员" prop="is_admin">
|
||||
<el-switch v-model="addForm.is_admin" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="form-submit-row">
|
||||
<el-button type="primary" size="large" style="width: 50%" @click="handleAddSubmit"
|
||||
>提交</el-button
|
||||
>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
<!-- 图片预览组件 -->
|
||||
<ElImageViewer
|
||||
v-if="showImageViewer"
|
||||
:url-list="[currentImageUrl]"
|
||||
:hide-on-click-modal="true"
|
||||
@close="showImageViewer = false"
|
||||
/>
|
||||
<ElDialog v-model="permissionDialog" title="菜单权限" align-center>
|
||||
<el-tabs v-model="activeName" class="demo-tabs">
|
||||
<el-tab-pane
|
||||
v-for="sys in systemsList"
|
||||
:key="sys.id"
|
||||
:label="sys.name"
|
||||
:name="String(sys.id)"
|
||||
>
|
||||
<ElScrollbar height="70vh">
|
||||
<ElTree
|
||||
:ref="(el) => setTreeRef(sys.id, el)"
|
||||
:data="processedMenuBySystem[sys.id] || []"
|
||||
show-checkbox
|
||||
node-key="id"
|
||||
:default-expand-all="isExpandAll"
|
||||
:props="defaultProps"
|
||||
@check="() => handleTreeCheck(sys.id)"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div style="display: flex; align-items: center">
|
||||
<span v-if="data.isAuth">
|
||||
{{ data.label }}
|
||||
</span>
|
||||
<span v-else>{{ defaultProps.label(data) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</ElScrollbar>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton type="primary" @click="savePermission">保存</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
<el-dialog
|
||||
title="角色&系统设置"
|
||||
v-model="roleDialogVisible"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="roleForm" ref="addFormRef" label-width="80px" class="add-form">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="角色" prop="status">
|
||||
<ElSelect
|
||||
v-model="roleForm.role_ids"
|
||||
multiple
|
||||
class="account-select"
|
||||
placeholder="请选择角色"
|
||||
>
|
||||
<ElOption
|
||||
v-for="account in roleOptions"
|
||||
:key="account.id"
|
||||
:label="account.name"
|
||||
:value="account.id"
|
||||
>
|
||||
<span>{{ account.name }}</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="系统" prop="status">
|
||||
<ElSelect
|
||||
v-model="roleForm.system_ids"
|
||||
multiple
|
||||
class="account-select"
|
||||
placeholder="请选择系统"
|
||||
>
|
||||
<ElOption
|
||||
v-for="account in systemsList"
|
||||
:key="account.id"
|
||||
:label="account.name"
|
||||
:value="account.id"
|
||||
>
|
||||
<span>{{ account.name }}</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="form-submit-row">
|
||||
<el-button type="primary" size="large" style="width: 70%" @click="handleRoleSubmit"
|
||||
>提交</el-button
|
||||
>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
|
||||
import { ElImageViewer, ElMessage } from 'element-plus'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { systemService } from '@/api/systemApi'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import imgs from '@/assets/img/user/upimg.png'
|
||||
defineOptions({ name: 'UserList' })
|
||||
const { hasAuth } = useAuth()
|
||||
const dialogType = ref('add')
|
||||
const activeName = ref('1')
|
||||
const selectValue = ref('1')
|
||||
const keyword = ref<string>('')
|
||||
const loading = ref(false)
|
||||
const AddDialogVisible = ref(false)
|
||||
const permissionDialog = ref(false)
|
||||
const roleDialogVisible = ref(false)
|
||||
// 权限树数据(来自接口 getPermissionMenuList)
|
||||
const permissionMenuList = ref<any[]>([])
|
||||
const permissionMenuListwork = ref<any[]>([])
|
||||
const permissionMenuListcrm = ref<any[]>([])
|
||||
// 精简后的多系统映射
|
||||
const permissionMenuBySystem = reactive<Record<number, any[]>>({})
|
||||
const processedMenuBySystem = reactive<Record<number, any[]>>({})
|
||||
const treeRefs = reactive<Record<number, any>>({})
|
||||
const setTreeRef = (systemId: number, el: any) => {
|
||||
if (el) treeRefs[systemId] = el
|
||||
}
|
||||
const treeRef = ref()
|
||||
const isExpandAll = ref(true)
|
||||
const isSelectAll = ref(false)
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
const avatar_pull = ref('')
|
||||
const addForm = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
sn: '',
|
||||
avatar: '',
|
||||
status: 1 as 0 | 1,
|
||||
is_admin: 0 as 0 | 1
|
||||
})
|
||||
const roleForm = reactive({
|
||||
user_id: '',
|
||||
role_ids: '',
|
||||
system_ids: ''
|
||||
})
|
||||
const formRef = ref<FormInstance>()
|
||||
// 表单验证规则
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
mobile: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
|
||||
sn: [{ required: true, message: '请输入sn', trigger: 'blur' }]
|
||||
})
|
||||
const userOptions = ref<any>()
|
||||
//系统
|
||||
const systemsList = ref<any>()
|
||||
const getsystemsList = async () => {
|
||||
const response = await UserService.getsystemsList()
|
||||
systemsList.value = response || []
|
||||
}
|
||||
//角色下拉框
|
||||
const roleOptions = ref<any>()
|
||||
const getroleOptions = async () => {
|
||||
const response = await systemService.getRoleSelectList()
|
||||
roleOptions.value = response || []
|
||||
}
|
||||
const getuserList = async () => {
|
||||
// 这里调用获取用户列表的接口
|
||||
const res = await manageService.presetusers()
|
||||
userOptions.value = res || []
|
||||
// return res || []
|
||||
}
|
||||
// 表格数据
|
||||
const tableData = ref<any[]>([])
|
||||
onMounted(() => {
|
||||
getuserList()
|
||||
gettableList()
|
||||
})
|
||||
// tabs 切换联动
|
||||
watch([selectValue, keyword], () => {
|
||||
pagination.currentPage = 1
|
||||
gettableList()
|
||||
})
|
||||
const gettableList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
const params: any = {
|
||||
current: currentPage,
|
||||
size: pageSize
|
||||
}
|
||||
if (selectValue.value) {
|
||||
params.status = selectValue.value
|
||||
}
|
||||
if (keyword.value) {
|
||||
params.keyword = keyword.value
|
||||
}
|
||||
const { records, current, size, total } = await manageService.getUserList(params)
|
||||
tableData.value = records.map((item: any) => ({
|
||||
...item
|
||||
}))
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// 处理表格分页变化
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
gettableList()
|
||||
}
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
gettableList()
|
||||
}
|
||||
function statusClass(status: any) {
|
||||
if (status === 1) return 'status-following'
|
||||
if (status === 0) return 'status-lost'
|
||||
return ''
|
||||
}
|
||||
// 显示角色&系统对话框
|
||||
const showroleDialog = (row: any) => {
|
||||
getsystemsList()
|
||||
getroleOptions()
|
||||
roleDialogVisible.value = true
|
||||
roleForm.user_id = row.id
|
||||
roleForm.role_ids = row.role_ids || []
|
||||
roleForm.system_ids = row.system_ids || []
|
||||
}
|
||||
// 提交角色&系统
|
||||
const handleRoleSubmit = async () => {
|
||||
const res = await manageService.userSetRole(roleForm)
|
||||
ElMessage.success(res?.message || '角色&系统设置成功!')
|
||||
roleDialogVisible.value = false
|
||||
gettableList()
|
||||
}
|
||||
// 显示权限对话框
|
||||
const userId = ref('')
|
||||
const showPermissionDialog = async (row: any) => {
|
||||
permissionDialog.value = true
|
||||
userId.value = row.id
|
||||
try {
|
||||
await getsystemsList()
|
||||
// 并行请求所选用户的各系统权限
|
||||
const sysIds = (systemsList.value || []).map((s: any) => s.id)
|
||||
const results = await Promise.all(
|
||||
sysIds.map((id: number) =>
|
||||
systemService.getPermissionMenuList({ user_id: row.id, system_id: id })
|
||||
)
|
||||
)
|
||||
sysIds.forEach((id: number, idx: number) => {
|
||||
const raw = results[idx] as any
|
||||
const list = raw?.data ?? raw?.records ?? raw
|
||||
permissionMenuBySystem[id] = Array.isArray(list) ? list : []
|
||||
processedMenuBySystem[id] = buildProcessedMenu(permissionMenuBySystem[id])
|
||||
})
|
||||
// 兼容旧变量,避免其它逻辑引用报错
|
||||
permissionMenuList.value = permissionMenuBySystem[1] || []
|
||||
permissionMenuListwork.value = permissionMenuBySystem[2] || []
|
||||
permissionMenuListcrm.value = permissionMenuBySystem[3] || []
|
||||
// 设置默认选中
|
||||
nextTick(() => {
|
||||
sysIds.forEach((id: number) => {
|
||||
const keys = buildDefaultCheckedKeys(permissionMenuBySystem[id] || [])
|
||||
treeRefs[id]?.setCheckedKeys?.(keys)
|
||||
handleTreeCheck(id)
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
;(systemsList.value || []).forEach((s: any) => {
|
||||
permissionMenuBySystem[s.id] = []
|
||||
processedMenuBySystem[s.id] = []
|
||||
})
|
||||
permissionMenuList.value = []
|
||||
permissionMenuListwork.value = []
|
||||
permissionMenuListcrm.value = []
|
||||
}
|
||||
}
|
||||
// 构建默认选中的 key 列表(与 node-key="id" 一致)
|
||||
const buildDefaultCheckedKeys = (source: any[]): Array<string | number> => {
|
||||
const keys: Array<string | number> = []
|
||||
const walk = (node: any) => {
|
||||
const hasChildren = Array.isArray(node?.children) && node.children.length > 0
|
||||
const hasAuths = Array.isArray(node?.meta?.authList) && node.meta.authList.length > 0
|
||||
// 仅当没有子节点且没有权限子项时,才勾选父节点,避免级联误选
|
||||
if (node?.meta?.selected && !hasChildren && !hasAuths) {
|
||||
keys.push(node.id)
|
||||
}
|
||||
if (Array.isArray(node?.meta?.authList)) {
|
||||
node.meta.authList.forEach((auth: any) => {
|
||||
if (auth?.selected) {
|
||||
keys.push(`${node.id}__auth__${auth.authMark}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (hasChildren) node.children.forEach(walk)
|
||||
}
|
||||
source.forEach(walk)
|
||||
return keys
|
||||
}
|
||||
const defaultProps = {
|
||||
children: 'children',
|
||||
label: (data: any) => formatMenuTitle(data.meta?.title) || ''
|
||||
}
|
||||
// 通用:处理节点,展开权限为子节点
|
||||
const processNode = (node: any) => {
|
||||
const processed: any = { ...node, id: node.id }
|
||||
if (node.meta && node.meta.authList && node.meta.authList.length) {
|
||||
const authNodes = node.meta.authList.map((auth: any) => ({
|
||||
id: `${node.id}__auth__${auth.authMark}`,
|
||||
name: `${node.name}_${auth.authMark}`,
|
||||
label: auth.title,
|
||||
auth_mark: auth.authMark,
|
||||
auth_id: auth.id,
|
||||
isAuth: true,
|
||||
checked: auth.selected || false
|
||||
}))
|
||||
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
|
||||
}
|
||||
if (processed.children) processed.children = processed.children.map(processNode)
|
||||
return processed
|
||||
}
|
||||
const buildProcessedMenu = (source: any[]) => (source || []).map(processNode)
|
||||
const getAllNodeKeys = (nodes: any[]): Array<string | number> => {
|
||||
const keys: Array<string | number> = []
|
||||
const traverse = (nodeList: any[]) => {
|
||||
nodeList.forEach((node) => {
|
||||
if (node.id !== undefined && node.id !== null) {
|
||||
keys.push(node.id)
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
traverse(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
traverse(nodes)
|
||||
return keys
|
||||
}
|
||||
const handleTreeCheck = (systemId?: number) => {
|
||||
const sysId = systemId ?? Number(activeName.value)
|
||||
const tree = treeRefs[sysId] || treeRef.value
|
||||
if (!tree) return
|
||||
const checkedKeys = tree.getCheckedKeys?.() || []
|
||||
const allKeys = getAllNodeKeys(processedMenuBySystem[sysId] || [])
|
||||
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
|
||||
}
|
||||
const getSelectedIdsBySystem = (systemId: number) => {
|
||||
const tree = treeRefs[systemId]
|
||||
if (!tree) return [] as (string | number)[]
|
||||
const checkedNodes = tree.getCheckedNodes?.(false) || []
|
||||
const halfCheckedNodes = tree.getHalfCheckedNodes?.() || []
|
||||
const allNodes = [...checkedNodes, ...halfCheckedNodes]
|
||||
const menuIds = Array.from(
|
||||
new Set(allNodes.filter((n: any) => !n.isAuth && n.id != null).map((n: any) => n.id))
|
||||
)
|
||||
const authIds = Array.from(
|
||||
new Set(
|
||||
checkedNodes.filter((n: any) => n.isAuth && n.auth_id != null).map((n: any) => n.auth_id)
|
||||
)
|
||||
)
|
||||
return Array.from(new Set([...(menuIds as any[]), ...(authIds as any[])]))
|
||||
}
|
||||
const savePermission = async () => {
|
||||
const sysIds = (systemsList.value || []).map((s: any) => s.id)
|
||||
const idall = Array.from(new Set(sysIds.flatMap((id: number) => getSelectedIdsBySystem(id))))
|
||||
const res = await manageService.userMenuAuth({
|
||||
user_id: userId.value,
|
||||
resources: idall
|
||||
})
|
||||
ElMessage.success(res?.message || '权限保存成功')
|
||||
permissionDialog.value = false
|
||||
}
|
||||
const onEdit = (type: 'add' | 'edit', row: any) => {
|
||||
dialogType.value = type
|
||||
if (type === 'add') {
|
||||
addForm.id = ''
|
||||
addForm.name = ''
|
||||
addForm.nickname = ''
|
||||
addForm.email = ''
|
||||
addForm.mobile = ''
|
||||
addForm.sn = ''
|
||||
addForm.avatar = ''
|
||||
avatar_pull.value = ''
|
||||
addForm.status = 1
|
||||
addForm.is_admin = 0
|
||||
}
|
||||
if (type === 'edit') {
|
||||
addForm.id = row.id
|
||||
addForm.name = row.name || ''
|
||||
addForm.nickname = row.nickname || ''
|
||||
addForm.email = row.email || ''
|
||||
addForm.mobile = row.mobile || ''
|
||||
addForm.sn = row.sn || ''
|
||||
addForm.avatar = row.avatar || ''
|
||||
avatar_pull.value = row.avatar_pull || row.avatar || ''
|
||||
addForm.status = typeof row.status === 'number' ? row.status : row.status ? 1 : 0
|
||||
addForm.is_admin = typeof row.is_admin === 'number' ? row.is_admin : row.is_admin ? 1 : 0
|
||||
}
|
||||
AddDialogVisible.value = true
|
||||
}
|
||||
const handleAddSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
if (dialogType.value === 'add') {
|
||||
const res = await manageService.userAdd({
|
||||
name: addForm.name,
|
||||
nickname: addForm.nickname,
|
||||
email: addForm.email,
|
||||
mobile: addForm.mobile,
|
||||
sn: addForm.sn,
|
||||
avatar: addForm.avatar,
|
||||
status: addForm.status,
|
||||
is_admin: addForm.is_admin
|
||||
})
|
||||
ElMessage.success(res?.message || '新增成功!')
|
||||
AddDialogVisible.value = false
|
||||
gettableList()
|
||||
}
|
||||
if (dialogType.value === 'edit') {
|
||||
const res = await manageService.userEdit(buildSubmitPayload())
|
||||
ElMessage.success(res?.message || '编辑成功!')
|
||||
AddDialogVisible.value = false
|
||||
gettableList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 图片预览相关
|
||||
const showImageViewer = ref(false)
|
||||
const currentImageUrl = ref('')
|
||||
const handleAvatarClick = (avatarUrl: string) => {
|
||||
if (avatarUrl) {
|
||||
currentImageUrl.value = avatarUrl
|
||||
showImageViewer.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 上传相关
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
const actionImageUplode = `${import.meta.env.VITE_API_URL}/upload/image?scene=user`
|
||||
const handleAvatarUploadSuccess = (response: any) => {
|
||||
addForm.avatar = response?.href || response?.data?.href || ''
|
||||
avatar_pull.value = response?.url || response?.data?.url || ''
|
||||
}
|
||||
const buildSubmitPayload = () => ({
|
||||
id: addForm.id,
|
||||
name: addForm.name,
|
||||
nickname: addForm.nickname,
|
||||
email: addForm.email,
|
||||
mobile: addForm.mobile,
|
||||
sn: addForm.sn,
|
||||
avatar: addForm.avatar,
|
||||
status: addForm.status,
|
||||
is_admin: addForm.is_admin
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-badge) {
|
||||
--el-badge-size: 16px;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
transition: opacity 0.2s;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
.itemtag {
|
||||
margin-top: -16px;
|
||||
margin-left: -13px;
|
||||
}
|
||||
:deep(.el-table__header th) {
|
||||
background: #f7f9fa !important;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
:deep(.el-table__empty-block) {
|
||||
height: 450px !important;
|
||||
}
|
||||
:deep(.el-table__body tr.custom-table-row td) {
|
||||
border-bottom: 1px solid #f7f9fa !important;
|
||||
border-right: 1px solid #f7f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr td:last-child) {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr:last-child td) {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__header-wrapper th) {
|
||||
border-bottom: 1px solid #f7f9fa !important;
|
||||
border-right: 1px solid #f7f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__header-wrapper th:last-child) {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border: 1px solid #f7f9fa !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.customer-page {
|
||||
background: var(--art-main-bg-color);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.customer-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 20px;
|
||||
background: var(--art-main-bg-color);
|
||||
}
|
||||
|
||||
.customer-search-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.customer-tags {
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
width: 120px;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.customer-tabs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-following {
|
||||
color: #00a43f;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-dealed {
|
||||
color: #3471ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-lost {
|
||||
color: #ff0000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.customer-table {
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: 8px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-more-btn {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.form-submit-row {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="feishu-callback">
|
||||
<div v-if="loading">正在登录,请稍候...</div>
|
||||
<div v-else-if="error">{{ error }}</div>
|
||||
<div v-else>登录成功,正在跳转...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
onMounted(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const state = urlParams.get('state')
|
||||
if (!code) {
|
||||
error.value = '未获取到飞书授权 code'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 调用后端接口换 token,接口路径请根据实际情况调整
|
||||
const { data } = await UserService.login({
|
||||
feishu_code: code
|
||||
})
|
||||
// 存储token和用户信息
|
||||
userStore.setToken(data.access_token, data.refresh_token, data.expires_in)
|
||||
const userInfo = await UserService.getUserInfo()
|
||||
userStore.setUserInfo(userInfo)
|
||||
userStore.setLoginStatus(true)
|
||||
userStore.sethome_page(data.home_url)
|
||||
localStorage.setItem('home_page', data.home_url)
|
||||
router.push(data.home_url)
|
||||
// router.replace('/')
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.message || '登录失败,请重试'
|
||||
router.replace('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feishu-callback {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="login register">
|
||||
<LoginLeftView></LoginLeftView>
|
||||
<div class="right-wrap">
|
||||
<div class="header">
|
||||
<ArtLogo class="icon" />
|
||||
<h1>{{ systemName }}</h1>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
|
||||
<div class="input-wrap">
|
||||
<span class="input-label" v-if="showInputLabel">账号</span>
|
||||
<ElInput :placeholder="$t('forgetPassword.placeholder')" v-model.trim="username" />
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton
|
||||
class="login-btn"
|
||||
type="primary"
|
||||
@click="register"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
>
|
||||
{{ $t('forgetPassword.submitBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton class="back-btn" plain @click="toLogin">
|
||||
{{ $t('forgetPassword.backBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' })
|
||||
|
||||
const router = useRouter()
|
||||
const showInputLabel = ref(false)
|
||||
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
const username = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const register = async () => {}
|
||||
|
||||
const toLogin = () => {
|
||||
router.push(RoutesAlias.Login)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../login/index';
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.login {
|
||||
background: url('@imgs/login/pcbg1.png') no-repeat center center;
|
||||
background-size: 100% 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
.el-input__inner {
|
||||
&:focus {
|
||||
border: 1px solid #4e83fd;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input--medium .el-input__inner {
|
||||
height: var(--el-component-custom-height);
|
||||
line-height: var(--el-component-custom-height);
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
|
||||
.top-right-wrap {
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
margin-left: 15px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 440px;
|
||||
height: 610px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
border-radius: 5px;
|
||||
|
||||
.form {
|
||||
box-sizing: border-box;
|
||||
// height: 100%;
|
||||
widows: 100%;
|
||||
padding: 40px 30px;
|
||||
background: rgb(0 10 34 / 50%);
|
||||
border-radius: 18px;
|
||||
|
||||
.title {
|
||||
margin-left: -2px;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
color: var(--art-text-gray-900) !important;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-500) !important;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
margin-top: 25px;
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
padding-bottom: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
}
|
||||
|
||||
.account-select :deep(.el-select__wrapper),
|
||||
.el-input,
|
||||
.login-btn {
|
||||
height: 50px !important;
|
||||
}
|
||||
|
||||
.drag-verify {
|
||||
position: relative;
|
||||
// padding-bottom: 20px;
|
||||
margin-top: 25px;
|
||||
|
||||
.drag-verify-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.error {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #f56c6c;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.show-error-text {
|
||||
transform: translateY(40px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forget-password {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-500);
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-800);
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad-pro) {
|
||||
.login {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
.right-wrap {
|
||||
margin: auto;
|
||||
|
||||
.login-wrap {
|
||||
position: relative;
|
||||
width: 440px;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
.form {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.login {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
.right-wrap {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
padding: 0 30px;
|
||||
margin: auto;
|
||||
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
|
||||
.form {
|
||||
margin-top: 12vh;
|
||||
|
||||
.input-wrap {
|
||||
.input-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrap,
|
||||
.drag-verify {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<LoginLeftView></LoginLeftView>
|
||||
|
||||
<div class="right-wrap">
|
||||
<div class="top-right-wrap"></div>
|
||||
<div class="header">
|
||||
<ArtLogo class="icon" />
|
||||
<h1>{{ systemName }}</h1>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
<div class="form">
|
||||
<div class="login-type-tabs">
|
||||
<span class="active">欢迎登录</span>
|
||||
</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
@keyup.enter="handleSubmit"
|
||||
style="margin-top: 25px"
|
||||
>
|
||||
<ElFormItem prop="username">
|
||||
<ElInput :placeholder="$t('login.placeholder[0]')" v-model.trim="formData.username">
|
||||
<template #prefix>
|
||||
<span class="input-label">账号</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
:placeholder="$t('login.placeholder[1]')"
|
||||
v-model.trim="formData.password"
|
||||
type="password"
|
||||
radius="8px"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="input-label">密码</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<div class="drag-verify">
|
||||
<div class="drag-verify-content" :class="{ error: !isPassing && isClickPass }">
|
||||
<ArtDragVerify
|
||||
ref="dragVerify"
|
||||
v-model:value="isPassing"
|
||||
:width="width < 500 ? 260 : 380"
|
||||
:text="$t('login.sliderText')"
|
||||
textColor="var(--art-gray-800)"
|
||||
:successText="$t('login.sliderSuccessText')"
|
||||
:progressBarBg="getCssVar('--el-color-primary')"
|
||||
background="var(--art-gray-200)"
|
||||
handlerBg="var(--art-main-bg-color)"
|
||||
/>
|
||||
</div>
|
||||
<p class="error-text" :class="{ 'show-error-text': !isPassing && isClickPass }">{{
|
||||
$t('login.placeholder[2]')
|
||||
}}</p>
|
||||
</div>
|
||||
<div class="login-bottom-row">
|
||||
<p>
|
||||
<!-- {{ $t('login.noAccount') }}
|
||||
<RouterLink :to="RoutesAlias.Register" style="color: #5d87ff">{{
|
||||
$t('login.register')
|
||||
}}</RouterLink> -->
|
||||
</p>
|
||||
<ElCheckbox v-model="formData.rememberPassword">{{
|
||||
$t('login.rememberPwd')
|
||||
}}</ElCheckbox>
|
||||
</div>
|
||||
<div style="margin-top: 30px">
|
||||
<ElButton
|
||||
class="login-btn"
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
>
|
||||
{{ $t('login.btnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { ElNotification, ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { HttpError } from '@/utils/http/error'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { commonService } from '@/api/commonApi'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
const { t } = useI18n()
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const dragVerify = ref()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const isPassing = ref(false)
|
||||
const isClickPass = ref(false)
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
username: '',
|
||||
password: '',
|
||||
rememberPassword: true
|
||||
})
|
||||
const rules = computed<FormRules>(() => ({
|
||||
username: [{ required: true, message: t('login.placeholder[0]'), trigger: 'blur' }],
|
||||
password: [{ required: true, message: t('login.placeholder[1]'), trigger: 'blur' }]
|
||||
}))
|
||||
|
||||
const loading = ref(false)
|
||||
const { width } = useWindowSize()
|
||||
//获取背景图
|
||||
const getbg = async () => {
|
||||
const res = await commonService.getSetting({ code: 'pc_loginbg' })
|
||||
if (res.value_pull) {
|
||||
const loginElement = document.getElementsByClassName('login')[0] as HTMLElement
|
||||
const bannerStr = res.value_pull.trim()
|
||||
if (bannerStr && loginElement) {
|
||||
// 按逗号分隔,过滤空字符串
|
||||
loginElement.style.backgroundImage = `url(${
|
||||
bannerStr
|
||||
.split(',')
|
||||
.map((url: string) => url.trim())
|
||||
.filter((url: any) => url)[0]
|
||||
})`
|
||||
}
|
||||
// document.body.style.backgroundImage = `url(${res.value_pull})`
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
// 表单验证
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 拖拽验证
|
||||
if (!isPassing.value) {
|
||||
isClickPass.value = true
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 登录请求
|
||||
const { username, password } = formData
|
||||
|
||||
const { data } = await UserService.login({
|
||||
account: username,
|
||||
password
|
||||
})
|
||||
|
||||
// 验证token
|
||||
if (!data.access_token) {
|
||||
throw new Error('Login failed - no token received')
|
||||
}
|
||||
|
||||
// 存储token和用户信息
|
||||
userStore.setToken(data.access_token, data.refresh_token, data.expires_in)
|
||||
const userInfo = await UserService.getUserInfo()
|
||||
userStore.setUserInfo(userInfo)
|
||||
userStore.setLoginStatus(true)
|
||||
userStore.sethome_page(data.home_url)
|
||||
localStorage.setItem('home_page', data.home_url)
|
||||
// 登录成功处理
|
||||
showLoginSuccessNotice()
|
||||
router.push(data.home_url)
|
||||
} catch (error) {
|
||||
// 处理 HttpError
|
||||
if (error instanceof HttpError) {
|
||||
// console.log(error.code)
|
||||
} else {
|
||||
// 处理非 HttpError
|
||||
ElMessage.error('登录失败,请稍后重试')
|
||||
console.error('[Login] Unexpected error:', error)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
resetDragVerify()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置拖拽验证
|
||||
const resetDragVerify = () => {
|
||||
dragVerify.value.reset()
|
||||
}
|
||||
|
||||
// 登录成功提示
|
||||
const showLoginSuccessNotice = () => {
|
||||
setTimeout(() => {
|
||||
ElNotification({
|
||||
title: t('login.success.title'),
|
||||
type: 'success',
|
||||
duration: 2500,
|
||||
zIndex: 10000,
|
||||
message: `${t('login.success.message')}, ${systemName}!`
|
||||
})
|
||||
}, 150)
|
||||
}
|
||||
onMounted(() => {
|
||||
getbg()
|
||||
// 组件挂载时的初始化逻辑
|
||||
})
|
||||
|
||||
import { UserService } from '@/api/usersApi'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './index';
|
||||
.login-type-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
span {
|
||||
padding: 8px 24px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
&.active {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
// border-bottom: 2px solid #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.input-label {
|
||||
color: #333;
|
||||
}
|
||||
.login-bottom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
// margin-top: 16px;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 28px;
|
||||
margin-top: 60px;
|
||||
border-radius: 30px;
|
||||
border: none;
|
||||
background: #009688;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
}
|
||||
.login-btn:hover {
|
||||
background-color: #009688;
|
||||
border-color: #009688;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
.register {
|
||||
.right-wrap {
|
||||
.login-wrap {
|
||||
.form {
|
||||
.el-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.privacy-policy {
|
||||
margin-top: 15px;
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="login register">
|
||||
<LoginLeftView></LoginLeftView>
|
||||
<div class="right-wrap">
|
||||
<div class="header">
|
||||
<ArtLogo class="icon" />
|
||||
<h1>{{ systemName }}</h1>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('register.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('register.subTitle') }}</p>
|
||||
<ElForm ref="formRef" :model="formData" :rules="rules" label-position="top">
|
||||
<ElFormItem prop="username">
|
||||
<ElInput
|
||||
v-model.trim="formData.username"
|
||||
:placeholder="$t('register.placeholder[0]')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model.trim="formData.password"
|
||||
:placeholder="$t('register.placeholder[1]')"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model.trim="formData.confirmPassword"
|
||||
:placeholder="$t('register.placeholder[2]')"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
@keyup.enter="register"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- <ElFormItem prop="agreement">
|
||||
<ElCheckbox v-model="formData.agreement">
|
||||
{{ $t('register.agreeText') }}
|
||||
<router-link
|
||||
style="color: var(--main-color); text-decoration: none"
|
||||
to="/privacy-policy"
|
||||
>{{ $t('register.privacyPolicy') }}</router-link
|
||||
>
|
||||
</ElCheckbox>
|
||||
</ElFormItem> -->
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton
|
||||
class="register-btn"
|
||||
type="primary"
|
||||
@click="register"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
>
|
||||
{{ $t('register.submitBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
{{ $t('register.hasAccount') }}
|
||||
<router-link :to="RoutesAlias.Login">{{ $t('register.toLogin') }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
|
||||
defineOptions({ name: 'Register' })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
const loading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
const validatePass = (rule: any, value: string, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error(t('register.placeholder[1]')))
|
||||
} else {
|
||||
if (formData.confirmPassword !== '') {
|
||||
formRef.value?.validateField('confirmPassword')
|
||||
}
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const validatePass2 = (rule: any, value: string, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error(t('register.rule[0]')))
|
||||
} else if (value !== formData.password) {
|
||||
callback(new Error(t('register.rule[1]')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
username: [
|
||||
{ required: true, message: t('register.placeholder[0]'), trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: t('register.rule[2]'), trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, validator: validatePass, trigger: 'blur' },
|
||||
{ min: 6, message: t('register.rule[3]'), trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [{ required: true, validator: validatePass2, trigger: 'blur' }],
|
||||
agreement: [
|
||||
{
|
||||
validator: (rule: any, value: boolean, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error(t('register.rule[4]')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const register = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
// 注册请求
|
||||
const { username, password,confirmPassword } = formData
|
||||
|
||||
const { code } = await UserService.register({
|
||||
account: username,
|
||||
password,
|
||||
confirm_password:confirmPassword
|
||||
})
|
||||
// 验证token
|
||||
loading.value = false
|
||||
if (code!= 200) {
|
||||
throw new Error('Login failed - no token received')
|
||||
}else{
|
||||
ElMessage.success('注册成功')
|
||||
toLogin()
|
||||
}
|
||||
|
||||
// 模拟注册请求
|
||||
// setTimeout(() => {
|
||||
|
||||
// }, 1000)
|
||||
} catch (error) {
|
||||
console.log('验证失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toLogin = () => {
|
||||
setTimeout(() => {
|
||||
router.push(RoutesAlias.Login)
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../login/index' as login;
|
||||
@use './index' as register;
|
||||
</style>
|
||||
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="signature-page" :class="{ landscape }">
|
||||
<div class="header">电子签名</div>
|
||||
<div class="canvas-wrap">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="canvas"
|
||||
:style="{ pointerEvents: locked ? 'none' : 'auto' }"
|
||||
@touchstart.prevent="onStart"
|
||||
@touchmove.prevent="onMove"
|
||||
@touchend.prevent="onEnd"
|
||||
@mousedown.prevent="onMouseDown"
|
||||
@mousemove.prevent="onMouseMove"
|
||||
@mouseup.prevent="onMouseUp"
|
||||
></canvas>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button :disabled="locked" @click="clearCanvas">清空</el-button>
|
||||
<!-- <el-button :disabled="locked" @click="toggleLandscape">横屏</el-button> -->
|
||||
<el-button type="primary" :disabled="locked" :loading="submitting" @click="saveSignature">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="tip">
|
||||
{{ locked ? '签名已保存,页面已锁定' : '请在画布上手写签名,然后点击保存' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
// import { CrmService } from '@/api/crmApi'
|
||||
import { commonService } from '@/api/commonApi'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const route = useRoute()
|
||||
const userId = route.query.userId as string | undefined
|
||||
const userStore = useUserStore()
|
||||
// 优先使用URL传入的token,否则取当前应用内的token
|
||||
const urlToken =
|
||||
(route.query.accessToken as string | undefined) || (route.query.token as string | undefined)
|
||||
// 其他头部信息从URL透传(便于直达页调用接口)
|
||||
const urlLang = route.query.lang as string | undefined
|
||||
const urlCorp =
|
||||
(route.query.companyabbr as string | undefined) ||
|
||||
(route.query.corp as string | undefined) ||
|
||||
userStore.companyabbr
|
||||
const urlLoginType =
|
||||
(route.query.loginType as string | undefined) || (route.query.type as string | undefined)
|
||||
const urlSys =
|
||||
(route.query.systemsabbr as string | undefined) ||
|
||||
(route.query.sys as string | undefined) ||
|
||||
userStore.systemsabbr ||
|
||||
'fdesk'
|
||||
const effectiveToken = urlToken || userStore.accessToken
|
||||
|
||||
// 从URL参数获取公司信息并存储到userStore(如果路由守卫没有处理)
|
||||
const companyid =
|
||||
(route.query.companyid as string | undefined) || (route.query.corpid as string | undefined)
|
||||
const companyabbr =
|
||||
(route.query.companyabbr as string | undefined) || (route.query.corp as string | undefined)
|
||||
if (companyid && companyabbr && (!userStore.companyid || !userStore.companyabbr)) {
|
||||
userStore.setCompany(companyid, companyabbr)
|
||||
}
|
||||
|
||||
// 从URL参数获取系统信息并存储到userStore(如果路由守卫没有处理)
|
||||
const systemsabbr =
|
||||
(route.query.systemsabbr as string | undefined) || (route.query.sys as string | undefined)
|
||||
const systemsid =
|
||||
(route.query.systemsid as string | undefined) || (route.query.sysid as string | undefined)
|
||||
if (systemsid && systemsabbr && (!userStore.systemsid || !userStore.systemsabbr)) {
|
||||
userStore.setSystems(systemsid, systemsabbr)
|
||||
}
|
||||
|
||||
// 从URL参数获取登录类型并存储到userStore(如果路由守卫没有处理)
|
||||
if (urlLoginType && !userStore.loginType) {
|
||||
userStore.setLoginType(urlLoginType as 'feishu' | 'upwd')
|
||||
}
|
||||
|
||||
// 从URL参数获取token并存储到userStore(如果路由守卫没有处理)
|
||||
if (urlToken && !userStore.accessToken) {
|
||||
userStore.setToken(urlToken)
|
||||
}
|
||||
// 调试信息:打印初始化时的userStore状态
|
||||
// console.log('Signature page initialized:', {
|
||||
// urlParams: {
|
||||
// userId,
|
||||
// urlToken: urlToken ? 'exists' : 'missing',
|
||||
// urlLang,
|
||||
// urlCorp,
|
||||
// urlLoginType,
|
||||
// urlSys
|
||||
// },
|
||||
// userStore: {
|
||||
// companyabbr: userStore.companyabbr,
|
||||
// companyid: userStore.companyid,
|
||||
// systemsabbr: userStore.systemsabbr,
|
||||
// systemsid: userStore.systemsid,
|
||||
// language: userStore.language,
|
||||
// loginType: userStore.loginType,
|
||||
// accessToken: userStore.accessToken ? 'exists' : 'missing',
|
||||
// isLogin: userStore.isLogin
|
||||
// }
|
||||
// })
|
||||
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
// 基于URL参数动态构造上传头,优先使用URL透传值
|
||||
const dynamicHeaders = computed(() => {
|
||||
const headers: Record<string, string> = { ...(uploadHeaders.value || {}) }
|
||||
|
||||
// 语言设置
|
||||
if (urlLang) headers['Fdesk-Language'] = urlLang
|
||||
|
||||
// 公司标识 - 优先使用URL参数,其次使用userStore,最后使用默认值
|
||||
const corpAbbr = urlCorp || userStore.companyabbr || 'default'
|
||||
headers['Fdesk-Corp-Abbr'] = corpAbbr
|
||||
|
||||
// 登录类型
|
||||
if (urlLoginType) headers['Fdesk-Login-Type'] = urlLoginType
|
||||
|
||||
// 系统标识
|
||||
headers['Fdesk-System-Code'] = urlSys
|
||||
|
||||
// 认证令牌
|
||||
if (effectiveToken) headers['Fdesk-Auth-Token'] = 'Bearer ' + effectiveToken
|
||||
|
||||
// 调试信息:打印请求头
|
||||
// console.log('Signature page headers:', headers)
|
||||
// console.log('URL params:', {
|
||||
// urlLang,
|
||||
// urlCorp,
|
||||
// urlLoginType,
|
||||
// urlSys,
|
||||
// effectiveToken
|
||||
// })
|
||||
// console.log('Final corp abbr:', corpAbbr)
|
||||
|
||||
return headers
|
||||
})
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||
const drawing = ref(false)
|
||||
const submitting = ref(false)
|
||||
const landscape = ref(false)
|
||||
const locked = ref(false)
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
let width = Math.min(window.innerWidth - 32, 800)
|
||||
let height = Math.round(width * 0.5)
|
||||
if (landscape.value && window.innerHeight > 0) {
|
||||
// 横屏时尽量占满高度
|
||||
const h = Math.min(window.innerHeight - 120, 500)
|
||||
height = h
|
||||
width = Math.round(h * 2)
|
||||
}
|
||||
canvas.style.width = width + 'px'
|
||||
canvas.style.height = height + 'px'
|
||||
canvas.width = Math.floor(width * dpr)
|
||||
canvas.height = Math.floor(height * dpr)
|
||||
const c = canvas.getContext('2d')!
|
||||
c.scale(dpr, dpr)
|
||||
c.lineWidth = 3
|
||||
c.lineCap = 'round'
|
||||
c.lineJoin = 'round'
|
||||
c.strokeStyle = '#111'
|
||||
ctx.value = c
|
||||
// 背景白色
|
||||
c.fillStyle = '#fff'
|
||||
c.fillRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
})
|
||||
|
||||
const getPoint = (e: TouchEvent | MouseEvent) => {
|
||||
const canvas = canvasRef.value!
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
let clientX = 0
|
||||
let clientY = 0
|
||||
if (e instanceof TouchEvent) {
|
||||
const t = e.touches[0] || e.changedTouches[0]
|
||||
clientX = t.clientX
|
||||
clientY = t.clientY
|
||||
} else {
|
||||
clientX = (e as MouseEvent).clientX
|
||||
clientY = (e as MouseEvent).clientY
|
||||
}
|
||||
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||
}
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (locked.value) return
|
||||
drawing.value = true
|
||||
const p = getPoint(e)
|
||||
ctx.value!.beginPath()
|
||||
ctx.value!.moveTo(p.x, p.y)
|
||||
}
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (locked.value) return
|
||||
if (!drawing.value) return
|
||||
const p = getPoint(e)
|
||||
ctx.value!.lineTo(p.x, p.y)
|
||||
ctx.value!.stroke()
|
||||
}
|
||||
const onEnd = () => {
|
||||
if (locked.value) return
|
||||
drawing.value = false
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => onStart(e as any)
|
||||
const onMouseMove = (e: MouseEvent) => onMove(e as any)
|
||||
const onMouseUp = () => onEnd()
|
||||
|
||||
const clearCanvas = () => {
|
||||
if (locked.value) return
|
||||
resizeCanvas()
|
||||
}
|
||||
|
||||
const dataURLToFile = (dataURL: string, filename: string) => {
|
||||
const arr = dataURL.split(',')
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png'
|
||||
const bstr = atob(arr[1])
|
||||
let n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
while (n--) u8arr[n] = bstr.charCodeAt(n)
|
||||
return new File([u8arr], filename, { type: mime })
|
||||
}
|
||||
|
||||
const uploadUrl = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=user_signature`
|
||||
|
||||
const requestFullscreen = async (el: HTMLElement) => {
|
||||
const anyEl = el as any
|
||||
if (anyEl.requestFullscreen) return anyEl.requestFullscreen()
|
||||
if (anyEl.webkitRequestFullscreen) return anyEl.webkitRequestFullscreen()
|
||||
if (anyEl.msRequestFullscreen) return anyEl.msRequestFullscreen()
|
||||
}
|
||||
|
||||
const lockOrientationLandscape = async () => {
|
||||
const anyScr = screen as any
|
||||
if (anyScr.orientation && anyScr.orientation.lock) {
|
||||
try {
|
||||
await anyScr.orientation.lock('landscape')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLimitedWebview = () => /MicroMessenger|Lark|Feishu|DingTalk/i.test(navigator.userAgent)
|
||||
|
||||
const toggleLandscape = async () => {
|
||||
landscape.value = !landscape.value
|
||||
if (landscape.value) {
|
||||
// 在微信/飞书等内置浏览器中,强制横屏普遍受限,采用 CSS 旋转兜底
|
||||
if (!isLimitedWebview()) {
|
||||
await requestFullscreen(document.documentElement)
|
||||
await lockOrientationLandscape()
|
||||
}
|
||||
}
|
||||
setTimeout(resizeCanvas, 200)
|
||||
}
|
||||
|
||||
const saveSignature = async () => {
|
||||
if (!userId) {
|
||||
ElMessage.error('缺少用户信息')
|
||||
return
|
||||
}
|
||||
try {
|
||||
submitting.value = true
|
||||
const canvas = canvasRef.value!
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
const file = dataURLToFile(dataUrl, `signature_${userId}_${Date.now()}.png`)
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
const headers: Record<string, string> = { ...(dynamicHeaders.value || {}) }
|
||||
// 兼容后端可能验 Authorization 的情况
|
||||
if (effectiveToken) headers['Authorization'] = `Bearer ${effectiveToken}`
|
||||
|
||||
// 调试信息:打印上传请求详情
|
||||
// console.log('Upload URL:', uploadUrl)
|
||||
// console.log('Upload headers:', headers)
|
||||
// console.log('UserStore state:', {
|
||||
// systemsabbr: userStore.systemsabbr,
|
||||
// systemsid: userStore.systemsid,
|
||||
// companyabbr: userStore.companyabbr,
|
||||
// companyid: userStore.companyid,
|
||||
// language: userStore.language,
|
||||
// loginType: userStore.loginType
|
||||
// })
|
||||
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form
|
||||
})
|
||||
const json = await res.json()
|
||||
// console.log('Upload response:', json)
|
||||
if (json.code !== 200) throw new Error(json.message || '上传失败')
|
||||
|
||||
// 将签名保存到个人信息(假设 crmuseredit 接受 signature 和 signature_pic_full 字段)
|
||||
const res1 = await commonService.signatureEdit({
|
||||
sign_img: json.data.file_name
|
||||
})
|
||||
|
||||
ElMessage.success(res1?.message || '签名已保存')
|
||||
locked.value = true
|
||||
// 完成后可提示返回或自动关闭
|
||||
// setTimeout(() => {
|
||||
// if (window.history.length > 1) window.history.back()
|
||||
// }, 800)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signature-page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.canvas-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.canvas {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
touch-action: none;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.tip {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="analysis-dashboard">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xl="14" :lg="15" :xs="24">
|
||||
<TodaySales />
|
||||
</el-col>
|
||||
<el-col :xl="10" :lg="9" :xs="24">
|
||||
<VisitorInsights />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-20">
|
||||
<el-col :xl="10" :lg="10" :xs="24">
|
||||
<TotalRevenue />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<CustomerSatisfaction />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<TargetVsReality />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-20">
|
||||
<el-col :xl="10" :lg="10" :xs="24">
|
||||
<TopProducts />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<SalesMappingByCountry />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<VolumeServiceLevel />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TodaySales from './widget/TodaySales.vue'
|
||||
import VisitorInsights from './widget/VisitorInsights.vue'
|
||||
import TotalRevenue from './widget/TotalRevenue.vue'
|
||||
import CustomerSatisfaction from './widget/CustomerSatisfaction.vue'
|
||||
import TargetVsReality from './widget/TargetVsReality.vue'
|
||||
import TopProducts from './widget/TopProducts.vue'
|
||||
import SalesMappingByCountry from './widget/SalesMappingByCountry.vue'
|
||||
import VolumeServiceLevel from './widget/VolumeServiceLevel.vue'
|
||||
|
||||
defineOptions({ name: 'Analysis' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
.analysis-dashboard {
|
||||
padding-bottom: 20px;
|
||||
|
||||
:deep(.custom-card) {
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) + 4px) !important;
|
||||
}
|
||||
|
||||
// 卡片头部
|
||||
:deep(.custom-card-header) {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--art-text-gray-900);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 21px;
|
||||
font-size: 13px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border: 1px solid #e8ebf1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.analysis-dashboard {
|
||||
:deep(.custom-card) {
|
||||
box-shadow: 0 4px 20px rgb(0 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.analysis-dashboard {
|
||||
.mt-20 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:deep(.custom-card) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card customer-satisfaction">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.customerSatisfaction.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" style="height: 300px; margin-top: 10px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
const options: () => echarts.EChartsOption = () => ({
|
||||
grid: {
|
||||
top: 30,
|
||||
right: 20,
|
||||
bottom: 50,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.customerSatisfaction.legend.lastMonth'),
|
||||
t('analysis.customerSatisfaction.legend.thisMonth')
|
||||
],
|
||||
bottom: 0,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['Week 0', 'Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5', 'Week 6'],
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false } // 隐藏 x 轴标签
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: {
|
||||
show: false // 将 show 设置为 false 以去除水平线条
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.customerSatisfaction.legend.lastMonth'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [1800, 2800, 1800, 2300, 2600, 2500, 3000],
|
||||
areaStyle: {
|
||||
opacity: 0.8,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,157,255,0.33)' },
|
||||
{ offset: 1, color: 'rgba(255,255,255,0)' }
|
||||
])
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#0086E1'
|
||||
},
|
||||
symbol: 'none',
|
||||
itemStyle: {
|
||||
color: '#0095FF'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t('analysis.customerSatisfaction.legend.thisMonth'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [4000, 3500, 4300, 3700, 4500, 3500, 4000],
|
||||
areaStyle: {
|
||||
opacity: 0.8,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(147,241,180,0.33)' },
|
||||
{ offset: 1, color: 'rgba(255,255,255,0)' }
|
||||
])
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#14DEB9'
|
||||
},
|
||||
symbol: 'none',
|
||||
itemStyle: {
|
||||
color: '#14DEB9'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 400px;
|
||||
|
||||
&-body {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.custom-card {
|
||||
height: 350px;
|
||||
|
||||
&-body {
|
||||
> div {
|
||||
height: 260px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card sales-mapping-country">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.salesMappingCountry.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" class="sales-mapping-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, initChart, isDark } = useChart()
|
||||
|
||||
const chartData = [
|
||||
{ value: 1048, name: 'Beijing', itemStyle: { color: 'rgba(99, 102, 241, 0.9)' } },
|
||||
{ value: 735, name: 'Shanghai', itemStyle: { color: 'rgba(134, 239, 172, 0.9)' } },
|
||||
{ value: 580, name: 'Guangzhou', itemStyle: { color: 'rgba(253, 224, 71, 0.9)' } },
|
||||
{ value: 484, name: 'Shenzhen', itemStyle: { color: 'rgba(248, 113, 113, 0.9)' } },
|
||||
{ value: 300, name: 'Chengdu', itemStyle: { color: 'rgba(125, 211, 252, 0.9)' } }
|
||||
]
|
||||
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Sales Mapping',
|
||||
type: 'pie',
|
||||
radius: ['40%', '60%'],
|
||||
avoidLabelOverlap: false,
|
||||
padAngle: 5,
|
||||
itemStyle: {
|
||||
borderRadius: 10
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 40,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: chartData
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sales-mapping-country {
|
||||
height: 330px;
|
||||
|
||||
.sales-mapping-chart {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card target-vs-reality">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.targetVsReality.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" style="height: 160px"></div>
|
||||
</div>
|
||||
<div class="custom-card-footer">
|
||||
<div class="total-item">
|
||||
<div class="label">
|
||||
<i class="iconfont-sys"></i>
|
||||
<div class="label-text">
|
||||
<span>{{ t('analysis.targetVsReality.realitySales.label') }}</span>
|
||||
<span>{{ t('analysis.targetVsReality.realitySales.sublabel') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value text-color-green">8,823</div>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<div class="label">
|
||||
<i class="iconfont-sys"></i>
|
||||
<div class="label-text">
|
||||
<span>{{ t('analysis.targetVsReality.targetSales.label') }}</span>
|
||||
<span>{{ t('analysis.targetVsReality.targetSales.sublabel') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value text-color-orange">12,122</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June', 'July'],
|
||||
axisLabel: {
|
||||
color: '#7B91B0'
|
||||
},
|
||||
axisLine: {
|
||||
show: false // 隐藏 x 轴线
|
||||
},
|
||||
axisTick: {
|
||||
show: false // 隐藏刻度线
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
show: false // 隐藏 y 轴文字
|
||||
},
|
||||
splitLine: {
|
||||
show: false // 隐藏 y 轴分割线
|
||||
},
|
||||
axisLine: {
|
||||
show: false // 隐藏 y 轴线
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Reality Sales',
|
||||
type: 'bar',
|
||||
data: [8000, 7000, 6000, 8500, 9000, 10000, 9500],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: '#2B8DFA'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Target Sales',
|
||||
type: 'bar',
|
||||
data: [10000, 9000, 11000, 10000, 12000, 12500, 11500],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 4, 4],
|
||||
color: '#95E0FB'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 400px;
|
||||
|
||||
&-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
margin-top: 15px;
|
||||
|
||||
.total-item {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
|
||||
&:first-of-type .label .iconfont-sys {
|
||||
color: #2b8dfa !important;
|
||||
background-color: #e6f7ff !important;
|
||||
}
|
||||
|
||||
&:last-of-type .label .iconfont-sys {
|
||||
color: #1cb8fc !important;
|
||||
background-color: #e6f7ff !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 60%;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
|
||||
.iconfont-sys {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
background-color: #f2f2f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
span {
|
||||
&:first-of-type {
|
||||
font-size: 16px;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #737791;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
|
||||
&.text-color-green {
|
||||
color: #2b8dfa !important;
|
||||
}
|
||||
|
||||
&.text-color-orange {
|
||||
color: #1cb8fc !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.custom-card {
|
||||
height: 350px;
|
||||
|
||||
&-body {
|
||||
padding-top: 10px;
|
||||
|
||||
> div {
|
||||
height: 140px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.custom-card {
|
||||
&-footer {
|
||||
.total-item {
|
||||
&:first-of-type .label .iconfont-sys {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
|
||||
&:last-of-type .label .iconfont-sys {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card today-sales">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.todaySales.title') }}</span>
|
||||
<span class="subtitle">{{ t('analysis.todaySales.subtitle') }}</span>
|
||||
<div class="export-btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>{{ t('analysis.todaySales.export') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sales-summary">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" :xs="24" v-for="(item, index) in salesData" :key="index">
|
||||
<div :class="['sales-card art-custom-card']">
|
||||
<i class="iconfont-sys" :class="item.class" v-html="item.iconfont"></i>
|
||||
<h2>
|
||||
<CountTo
|
||||
class="number box-title"
|
||||
:endVal="item.value"
|
||||
:duration="1000"
|
||||
separator=""
|
||||
></CountTo>
|
||||
</h2>
|
||||
<p>{{ item.label }}</p>
|
||||
<small>{{ item.change }} {{ t('analysis.todaySales.fromYesterday') }}</small>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { CountTo } from 'vue3-count-to'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const salesData = ref([
|
||||
{
|
||||
label: t('analysis.todaySales.cards.totalSales.label'),
|
||||
value: 999,
|
||||
change: t('analysis.todaySales.cards.totalSales.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-primary'
|
||||
},
|
||||
{
|
||||
label: t('analysis.todaySales.cards.totalOrder.label'),
|
||||
value: 300,
|
||||
change: t('analysis.todaySales.cards.totalOrder.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-warning'
|
||||
},
|
||||
{
|
||||
label: t('analysis.todaySales.cards.productSold.label'),
|
||||
value: 56,
|
||||
change: t('analysis.todaySales.cards.productSold.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-error'
|
||||
},
|
||||
{
|
||||
label: t('analysis.todaySales.cards.newCustomers.label'),
|
||||
value: 68,
|
||||
change: t('analysis.todaySales.cards.newCustomers.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-success'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.today-sales {
|
||||
height: 330px;
|
||||
|
||||
.export-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 66px;
|
||||
padding: 6px 0;
|
||||
color: var(--art-gray-600);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.iconfont-sys {
|
||||
margin-right: 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.sales-summary {
|
||||
padding: 20px;
|
||||
|
||||
.sales-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 220px;
|
||||
padding: 0 20px;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 4px) !important;
|
||||
|
||||
.iconfont-sys {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 20px;
|
||||
line-height: 48px;
|
||||
color: #fff;
|
||||
color: var(--el-color-primary);
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
color: var(--art-text-gray-900) !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
color: var(--art-text-gray-700) !important;
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--art-text-gray-500) !important;
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗黑模式降低颜色强度
|
||||
.dark {
|
||||
.today-sales {
|
||||
.sales-summary {
|
||||
.sales-card {
|
||||
.iconfont-sys {
|
||||
&.red,
|
||||
&.yellow,
|
||||
&.green,
|
||||
&.purple {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.today-sales {
|
||||
height: 280px;
|
||||
|
||||
.sales-summary {
|
||||
.sales-card {
|
||||
height: 170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.today-sales {
|
||||
height: auto;
|
||||
|
||||
.sales-summary {
|
||||
padding-bottom: 0;
|
||||
|
||||
.sales-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card top-products">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.topProducts.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<art-table
|
||||
:data="products"
|
||||
style="width: 100%"
|
||||
:pagination="false"
|
||||
size="large"
|
||||
:border="false"
|
||||
:stripe="false"
|
||||
:show-header-background="false"
|
||||
>
|
||||
<el-table-column prop="name" :label="t('analysis.topProducts.columns.name')" width="200" />
|
||||
<el-table-column prop="popularity" :label="t('analysis.topProducts.columns.popularity')">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="scope.row.popularity"
|
||||
:color="getColor(scope.row.popularity)"
|
||||
:stroke-width="5"
|
||||
:show-text="false"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sales" :label="t('analysis.topProducts.columns.sales')" width="80">
|
||||
<template #default="scope">
|
||||
<span
|
||||
:style="{
|
||||
color: getColor(scope.row.popularity),
|
||||
backgroundColor: `rgba(${hexToRgb(getColor(scope.row.popularity))}, 0.08)`,
|
||||
border: '1px solid',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}"
|
||||
>{{ scope.row.sales }}</span
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</art-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { hexToRgb } from '@/utils/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
// 使用 computed 来创建响应式的产品数据
|
||||
const products = computed(() => [
|
||||
{
|
||||
name: t('analysis.topProducts.products.homeDecor.name'),
|
||||
popularity: 10,
|
||||
sales: t('analysis.topProducts.products.homeDecor.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.disneyBag.name'),
|
||||
popularity: 29,
|
||||
sales: t('analysis.topProducts.products.disneyBag.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.bathroom.name'),
|
||||
popularity: 65,
|
||||
sales: t('analysis.topProducts.products.bathroom.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.smartwatch.name'),
|
||||
popularity: 32,
|
||||
sales: t('analysis.topProducts.products.smartwatch.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.fitness.name'),
|
||||
popularity: 78,
|
||||
sales: t('analysis.topProducts.products.fitness.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.earbuds.name'),
|
||||
popularity: 41,
|
||||
sales: t('analysis.topProducts.products.earbuds.sales')
|
||||
}
|
||||
])
|
||||
|
||||
const getColor = (percentage: number) => {
|
||||
if (percentage < 25) return '#00E096'
|
||||
if (percentage < 50) return '#0095FF'
|
||||
if (percentage < 75) return '#884CFF'
|
||||
return '#FE8F0E'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 330px;
|
||||
overflow-y: scroll;
|
||||
|
||||
// 隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.custom-card {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card total-revenue">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.totalRevenue.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" style="height: 300px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { EChartsOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
// 创建图表选项
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 3,
|
||||
bottom: 40,
|
||||
left: 3,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.totalRevenue.legend.onlineSales'),
|
||||
t('analysis.totalRevenue.legend.offlineSales')
|
||||
],
|
||||
bottom: 0,
|
||||
icon: 'circle',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 15,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: isDark.value ? '#808290' : '#7B91B0'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
|
||||
width: 0.8
|
||||
}
|
||||
},
|
||||
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.totalRevenue.legend.onlineSales'),
|
||||
type: 'bar',
|
||||
data: [12, 13, 5, 15, 10, 15, 18],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
color: '#0095FF',
|
||||
borderRadius: [4, 4, 4, 4]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t('analysis.totalRevenue.legend.offlineSales'),
|
||||
type: 'bar',
|
||||
data: [10, 11, 20, 5, 11, 13, 10],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
color: '#95E0FB',
|
||||
borderRadius: [4, 4, 4, 4]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 400px;
|
||||
|
||||
&-body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.custom-card {
|
||||
height: 350px;
|
||||
|
||||
&-body {
|
||||
> div {
|
||||
height: 260px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card visitor-insights">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.visitorInsights.title') }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div ref="chartRef" style="height: 250px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: width.value < 600 ? 80 : 40,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.visitorInsights.legend.loyalCustomers'),
|
||||
t('analysis.visitorInsights.legend.newCustomers')
|
||||
],
|
||||
bottom: 0,
|
||||
left: 'center',
|
||||
itemWidth: 14,
|
||||
itemHeight: 14,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
},
|
||||
icon: 'roundRect',
|
||||
itemStyle: {
|
||||
borderRadius: 4
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
|
||||
width: 0.8
|
||||
}
|
||||
},
|
||||
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.visitorInsights.legend.loyalCustomers'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
data: [260, 200, 150, 130, 180, 270, 340, 380, 300, 220, 170, 130],
|
||||
lineStyle: {
|
||||
color: '#2B8DFA',
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#2B8DFA'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t('analysis.visitorInsights.legend.newCustomers'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
data: [280, 350, 300, 250, 230, 210, 240, 280, 320, 350, 300, 200],
|
||||
lineStyle: {
|
||||
color: '#49BEFF',
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#49BEFF'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.visitor-insights {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.visitor-insights {
|
||||
height: 280px;
|
||||
|
||||
.card-body {
|
||||
> div {
|
||||
height: 210px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-phone) {
|
||||
.visitor-insights {
|
||||
height: 315px;
|
||||
|
||||
.card-body {
|
||||
> div {
|
||||
height: 240px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card volume-service-level">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.volumeServiceLevel.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
// 模拟数据
|
||||
const chartData = [
|
||||
{ volume: 800, services: 400 },
|
||||
{ volume: 1000, services: 600 },
|
||||
{ volume: 750, services: 300 },
|
||||
{ volume: 600, services: 250 },
|
||||
{ volume: 450, services: 200 },
|
||||
{ volume: 500, services: 300 }
|
||||
]
|
||||
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.volumeServiceLevel.legend.volume'),
|
||||
t('analysis.volumeServiceLevel.legend.services')
|
||||
],
|
||||
bottom: 20,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
icon: 'circle',
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '20',
|
||||
right: '20',
|
||||
bottom: '60',
|
||||
top: '30',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.map((_, index) => `${index + 1}`),
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
|
||||
width: 0.8
|
||||
}
|
||||
},
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.volumeServiceLevel.legend.volume'),
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: chartData.map((item) => item.volume),
|
||||
itemStyle: {
|
||||
color: '#0095FF',
|
||||
borderRadius: [0, 0, 4, 4]
|
||||
},
|
||||
barWidth: '15'
|
||||
},
|
||||
{
|
||||
name: t('analysis.volumeServiceLevel.legend.services'),
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: chartData.map((item) => item.services),
|
||||
itemStyle: {
|
||||
color: '#95E0FB',
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
barWidth: '50%'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.volume-service-level {
|
||||
height: 330px;
|
||||
|
||||
.custom-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.console {
|
||||
--card-spacing: 20px;
|
||||
|
||||
// 卡片头部
|
||||
:deep(.card-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20px 25px 5px 0;
|
||||
|
||||
.title {
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900) !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 3px;
|
||||
font-size: 13px;
|
||||
color: var(--art-gray-600) !important;
|
||||
|
||||
span {
|
||||
margin-left: 10px;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置卡片背景色、圆角、间隙
|
||||
:deep(.card-list .card),
|
||||
.card {
|
||||
margin-bottom: var(--card-spacing);
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) + 4px) !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $device-phone) {
|
||||
--card-spacing: 15px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="card about-project art-custom-card">
|
||||
<div>
|
||||
<h2 class="box-title">关于项目</h2>
|
||||
<p>{{ systemName }} 是一款专注于用户体验和视觉设计的后台管理系统模版</p>
|
||||
<p>使用了 Vue3、TypeScript、Vite、Element Plus 等前沿技术</p>
|
||||
|
||||
<div class="button-wrap">
|
||||
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.DOCS)">
|
||||
<span>项目官网</span>
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.INTRODUCE)">
|
||||
<span>文档</span>
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.GITHUB_HOME)">
|
||||
<span>Github</span>
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="btn art-custom-card" @click="goPage(WEB_LINKS.BLOG)">
|
||||
<span>博客</span>
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img class="right-img" src="@imgs/draw/draw1.png" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
|
||||
const goPage = (url: string) => {
|
||||
window.open(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.about-project {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 300px;
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900) !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.button-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 600px;
|
||||
margin-top: 35px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 240px;
|
||||
height: 50px;
|
||||
padding: 0 15px;
|
||||
margin: 0 15px 15px 0;
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-800);
|
||||
cursor: pointer;
|
||||
background: var(--art-bg-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 5px 10px rgb(0 0 0 / 5%);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - iPad Pro及以下
|
||||
@media screen and (max-width: $device-ipad-pro) {
|
||||
.about-project {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.button-wrap {
|
||||
width: 470px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.right-img {
|
||||
width: 300px;
|
||||
height: 230px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - iPad垂直及以下
|
||||
@media screen and (max-width: $device-ipad-vertical) {
|
||||
.button-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.right-img {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 手机端
|
||||
@media screen and (max-width: $device-phone) {
|
||||
.about-project {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="card art-custom-card">
|
||||
<div class="chart" ref="chartRef"></div>
|
||||
<div class="text">
|
||||
<h3 class="box-title">用户概述</h3>
|
||||
<p class="subtitle">比上周 <span class="text-success">+23%</span></p>
|
||||
<p class="subtitle">我们为您创建了多个选项,可将它们组合在一起并定制为像素完美的页面</p>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div v-for="(item, index) in list" :key="index">
|
||||
<p>{{ item.num }}</p>
|
||||
<p class="subtitle">{{ item.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
|
||||
const {
|
||||
chartRef,
|
||||
isDark,
|
||||
initChart,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
const list = [
|
||||
{ name: '总用户量', num: '32k' },
|
||||
{ name: '总访问量', num: '128k' },
|
||||
{ name: '日访问量', num: '1.2k' },
|
||||
{ name: '周同比', num: '+5%' }
|
||||
]
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
return {
|
||||
grid: {
|
||||
top: 15,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: [1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(true),
|
||||
axisLabel: getAxisLabelStyle(true)
|
||||
},
|
||||
yAxis: {
|
||||
axisLabel: getAxisLabelStyle(true),
|
||||
axisLine: getAxisLineStyle(!isDark.value),
|
||||
splitLine: getSplitLineStyle(true)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [160, 100, 150, 80, 190, 100, 175, 120, 160],
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary')
|
||||
}
|
||||
])
|
||||
},
|
||||
barWidth: '50%',
|
||||
animationDelay: (idx) => idx * 50 + 300,
|
||||
animationDuration: (idx) => 1500 - idx * 50,
|
||||
animationEasing: 'quarticOut' // 推荐动画: quarticOut exponentialOut quinticOut backOut
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
padding: 16px;
|
||||
|
||||
.chart {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
padding: 20px 0 20px 20px;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 4px) !important;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 3px;
|
||||
|
||||
h3 {
|
||||
margin-top: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 5px;
|
||||
font-size: 14px;
|
||||
|
||||
&:last-of-type {
|
||||
height: 42px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-left: 3px;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
|
||||
p {
|
||||
font-weight: 400;
|
||||
|
||||
&:first-of-type {
|
||||
font-size: 24px;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.card {
|
||||
.chart {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $device-phone) {
|
||||
.dark {
|
||||
.card {
|
||||
.chart {
|
||||
padding: 15px 0 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<el-row :gutter="20" :style="{ marginTop: showWorkTab ? '0' : '10px' }" class="card-list">
|
||||
<el-col v-for="(item, index) in dataList" :key="index" :sm="12" :md="6" :lg="6">
|
||||
<div class="card art-custom-card">
|
||||
<span class="des subtitle">{{ item.des }}</span>
|
||||
<CountTo
|
||||
class="number box-title"
|
||||
:endVal="item.num"
|
||||
:duration="1000"
|
||||
separator=""
|
||||
></CountTo>
|
||||
<div class="change-box">
|
||||
<span class="change-text">较上周</span>
|
||||
<span
|
||||
class="change"
|
||||
:class="[item.change.indexOf('+') === -1 ? 'text-danger' : 'text-success']"
|
||||
>
|
||||
{{ item.change }}
|
||||
</span>
|
||||
</div>
|
||||
<i class="iconfont-sys" v-html="item.icon"></i>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { CountTo } from 'vue3-count-to'
|
||||
|
||||
const { showWorkTab } = storeToRefs(useSettingStore())
|
||||
|
||||
const dataList = reactive([
|
||||
{
|
||||
des: '总访问次数',
|
||||
icon: '',
|
||||
startVal: 0,
|
||||
duration: 1000,
|
||||
num: 9120,
|
||||
change: '+20%'
|
||||
},
|
||||
{
|
||||
des: '在线访客数',
|
||||
icon: '',
|
||||
startVal: 0,
|
||||
duration: 1000,
|
||||
num: 182,
|
||||
change: '+10%'
|
||||
},
|
||||
{
|
||||
des: '点击量',
|
||||
icon: '',
|
||||
startVal: 0,
|
||||
duration: 1000,
|
||||
num: 9520,
|
||||
change: '-12%'
|
||||
},
|
||||
{
|
||||
des: '新用户',
|
||||
icon: '',
|
||||
startVal: 0,
|
||||
duration: 1000,
|
||||
num: 156,
|
||||
change: '+30%'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-list {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
background-color: transparent !important;
|
||||
|
||||
.art-custom-card {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
padding: 0 18px;
|
||||
list-style: none;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
$icon-size: 52px;
|
||||
|
||||
.iconfont-sys {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
bottom: 0;
|
||||
width: $icon-size;
|
||||
height: $icon-size;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
font-size: 22px;
|
||||
line-height: $icon-size;
|
||||
color: var(--el-color-primary) !important;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.des {
|
||||
display: block;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.number {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.change-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.change-text {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--art-text-gray-600);
|
||||
}
|
||||
|
||||
.change {
|
||||
display: block;
|
||||
margin-left: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
|
||||
&.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
&.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.card-list {
|
||||
.art-custom-card {
|
||||
.iconfont-sys {
|
||||
background-color: #232323 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="card art-custom-card">
|
||||
<div class="card-header">
|
||||
<div class="title">
|
||||
<h4 class="box-title">动态</h4>
|
||||
<p class="subtitle">新增<span class="text-success">+6</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<div v-for="(item, index) in list" :key="index">
|
||||
<span class="user">{{ item.username }}</span>
|
||||
<span class="type">{{ item.type }}</span>
|
||||
<span class="target">{{ item.target }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue-demi'
|
||||
|
||||
const list = reactive([
|
||||
{
|
||||
username: '中小鱼',
|
||||
type: '关注了',
|
||||
target: '誶誶淰'
|
||||
},
|
||||
{
|
||||
username: '何小荷',
|
||||
type: '发表文章',
|
||||
target: 'Vue3 + Typescript + Vite 项目实战笔记'
|
||||
},
|
||||
{
|
||||
username: '誶誶淰',
|
||||
type: '提出问题',
|
||||
target: '主题可以配置吗'
|
||||
},
|
||||
{
|
||||
username: '发呆草',
|
||||
type: '兑换了物品',
|
||||
target: '《奇特的一生》'
|
||||
},
|
||||
{
|
||||
username: '甜筒',
|
||||
type: '关闭了问题',
|
||||
target: '发呆草'
|
||||
},
|
||||
{
|
||||
username: '冷月呆呆',
|
||||
type: '兑换了物品',
|
||||
target: '《高效人士的七个习惯》'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 510px;
|
||||
padding: 0 25px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
height: calc(100% - 100px);
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
> div {
|
||||
height: 70px;
|
||||
overflow: hidden;
|
||||
line-height: 70px;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-weight: 500;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
.type {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.target {
|
||||
color: var(--main-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="card art-custom-card">
|
||||
<div class="card-header">
|
||||
<div class="title">
|
||||
<h4 class="box-title">新用户</h4>
|
||||
<p class="subtitle">这个月增长<span class="text-success">+20%</span></p>
|
||||
</div>
|
||||
<el-radio-group v-model="radio2">
|
||||
<el-radio-button value="本月" label="本月"></el-radio-button>
|
||||
<el-radio-button value="上月" label="上月"></el-radio-button>
|
||||
<el-radio-button value="今年" label="今年"></el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<art-table
|
||||
class="table"
|
||||
:data="tableData"
|
||||
:pagination="false"
|
||||
size="large"
|
||||
:border="false"
|
||||
:stripe="false"
|
||||
:show-header-background="false"
|
||||
>
|
||||
<template #default>
|
||||
<el-table-column label="头像" prop="avatar" width="150px">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; align-items: center">
|
||||
<img class="avatar" :src="scope.row.avatar" />
|
||||
<span class="user-name">{{ scope.row.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="地区" prop="province" />
|
||||
<el-table-column label="性别" prop="avatar">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; align-items: center">
|
||||
<span style="margin-left: 10px">{{ scope.row.sex === 1 ? '男' : '女' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="240">
|
||||
<template #default="scope">
|
||||
<el-progress :percentage="scope.row.pro" :color="scope.row.color" :stroke-width="4" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</art-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, reactive } from 'vue-demi'
|
||||
import avatar1 from '@/assets/img/avatar/avatar1.webp'
|
||||
import avatar2 from '@/assets/img/avatar/avatar2.webp'
|
||||
import avatar3 from '@/assets/img/avatar/avatar3.webp'
|
||||
import avatar4 from '@/assets/img/avatar/avatar4.webp'
|
||||
import avatar5 from '@/assets/img/avatar/avatar5.webp'
|
||||
import avatar6 from '@/assets/img/avatar/avatar6.webp'
|
||||
|
||||
const radio2 = ref('本月')
|
||||
|
||||
const tableData = reactive([
|
||||
{
|
||||
username: '中小鱼',
|
||||
province: '北京',
|
||||
sex: 0,
|
||||
age: 22,
|
||||
percentage: 60,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-primary)) !important',
|
||||
avatar: avatar1
|
||||
},
|
||||
{
|
||||
username: '何小荷',
|
||||
province: '深圳',
|
||||
sex: 1,
|
||||
age: 21,
|
||||
percentage: 20,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-secondary)) !important',
|
||||
avatar: avatar2
|
||||
},
|
||||
{
|
||||
username: '誶誶淰',
|
||||
province: '上海',
|
||||
sex: 1,
|
||||
age: 23,
|
||||
percentage: 60,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-warning)) !important',
|
||||
avatar: avatar3
|
||||
},
|
||||
{
|
||||
username: '发呆草',
|
||||
province: '长沙',
|
||||
sex: 0,
|
||||
age: 28,
|
||||
percentage: 50,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-info)) !important',
|
||||
avatar: avatar4
|
||||
},
|
||||
{
|
||||
username: '甜筒',
|
||||
province: '浙江',
|
||||
sex: 1,
|
||||
age: 26,
|
||||
percentage: 70,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-error)) !important',
|
||||
avatar: avatar5
|
||||
},
|
||||
{
|
||||
username: '冷月呆呆',
|
||||
province: '湖北',
|
||||
sex: 1,
|
||||
age: 25,
|
||||
percentage: 90,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-success)) !important',
|
||||
avatar: avatar6
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
addAnimation()
|
||||
})
|
||||
|
||||
const addAnimation = () => {
|
||||
setTimeout(() => {
|
||||
for (let i = 0; i < tableData.length; i++) {
|
||||
let item = tableData[i]
|
||||
tableData[i].pro = item.percentage
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.card {
|
||||
// 进度动画
|
||||
.el-progress-bar__inner {
|
||||
transition: all 1s !important;
|
||||
}
|
||||
|
||||
.el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||
color: var(--el-color-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
width: 100%;
|
||||
height: 510px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-header {
|
||||
padding-left: 25px !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr:last-child td) {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="card art-custom-card">
|
||||
<div class="card-header">
|
||||
<div class="title">
|
||||
<h4 class="box-title">访问量</h4>
|
||||
<p class="subtitle">今年增长<span class="text-success">+15%</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart" ref="chartRef"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import { hexToRgba, getCssVar } from '@/utils/ui'
|
||||
import { EChartsOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
|
||||
const {
|
||||
chartRef,
|
||||
isDark,
|
||||
initChart,
|
||||
updateChart,
|
||||
getAxisLabelStyle,
|
||||
getAxisLineStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
// 定义真实数据
|
||||
const realData = [50, 25, 40, 20, 70, 35, 65, 30, 35, 20, 40, 44]
|
||||
|
||||
// 初始化动画函数
|
||||
const initChartWithAnimation = () => {
|
||||
// 首先初始化图表,数据为0
|
||||
initChart(options(true))
|
||||
updateChart(options(false))
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChartWithAnimation()
|
||||
})
|
||||
|
||||
const options: (isInitial?: boolean) => EChartsOption = (isInitial) => {
|
||||
const isInit = isInitial || false
|
||||
return {
|
||||
// 添加动画配置
|
||||
animation: true,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0,
|
||||
grid: {
|
||||
left: '2.2%',
|
||||
right: '3%',
|
||||
bottom: '0%',
|
||||
top: '5px',
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: [
|
||||
'1月',
|
||||
'2月',
|
||||
'3月',
|
||||
'4月',
|
||||
'5月',
|
||||
'6月',
|
||||
'7月',
|
||||
'8月',
|
||||
'9月',
|
||||
'10月',
|
||||
'11月',
|
||||
'12月'
|
||||
],
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLabel: getAxisLabelStyle(true),
|
||||
axisLine: getAxisLineStyle(true)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: realData.reduce((prev, curr) => Math.max(prev, curr), 0),
|
||||
axisLabel: getAxisLabelStyle(true),
|
||||
axisLine: getAxisLineStyle(!isDark.value),
|
||||
splitLine: getSplitLineStyle(true)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '访客',
|
||||
color: getCssVar('--main-color'),
|
||||
type: 'line',
|
||||
stack: '总量',
|
||||
data: isInit ? new Array(12).fill(0) : realData,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: 2.2
|
||||
},
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRgba(getCssVar('--el-color-primary'), 0.15).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
|
||||
}
|
||||
])
|
||||
},
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 1500
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
padding: 20px 0 30px;
|
||||
|
||||
.card-header {
|
||||
padding: 0 18px !important;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: calc(100% - 80px);
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="card art-custom-card">
|
||||
<div class="card-header">
|
||||
<div class="title">
|
||||
<h4 class="box-title">代办事项</h4>
|
||||
<p class="subtitle">待处理<span class="text-danger">3</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<div v-for="(item, index) in list" :key="index">
|
||||
<p class="title">{{ item.username }}</p>
|
||||
<p class="date subtitle">{{ item.date }}</p>
|
||||
<el-checkbox v-model="item.complate" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue-demi'
|
||||
|
||||
const list = reactive([
|
||||
{
|
||||
username: '查看今天工作内容',
|
||||
date: '上午 09:30',
|
||||
complate: true
|
||||
},
|
||||
{
|
||||
username: '回复邮件',
|
||||
date: '上午 10:30',
|
||||
complate: true
|
||||
},
|
||||
{
|
||||
username: '工作汇报整理',
|
||||
date: '上午 11:00',
|
||||
complate: true
|
||||
},
|
||||
{
|
||||
username: '产品需求会议',
|
||||
date: '下午 02:00',
|
||||
complate: false
|
||||
},
|
||||
{
|
||||
username: '整理会议内容',
|
||||
date: '下午 03:30',
|
||||
complate: false
|
||||
},
|
||||
{
|
||||
username: '明天工作计划',
|
||||
date: '下午 06:30',
|
||||
complate: false
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 510px;
|
||||
padding: 0 25px;
|
||||
|
||||
.list {
|
||||
height: calc(100% - 90px);
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
> div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 70px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.el-checkbox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="ecommerce">
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="24" :lg="16">
|
||||
<Banner />
|
||||
</el-col>
|
||||
<el-col :sm="12" :md="12" :lg="4">
|
||||
<TotalOrderVolume />
|
||||
</el-col>
|
||||
<el-col :sm="12" :md="12" :lg="4">
|
||||
<TotalProducts />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="12" :md="12" :lg="8">
|
||||
<SalesTrend />
|
||||
</el-col>
|
||||
<el-col :sm="12" :md="12" :lg="8">
|
||||
<SalesClassification />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="24" :lg="8">
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12" :lg="12">
|
||||
<ProductSales />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12" :lg="12">
|
||||
<SalesGrowth />
|
||||
</el-col>
|
||||
<el-col :span="24" class="no-margin-bottom">
|
||||
<CartConversionRate />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12" :lg="8">
|
||||
<HotCommodity />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12" :lg="8">
|
||||
<AnnualSales />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="24" :lg="8">
|
||||
<TransactionList />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :md="24" :lg="8">
|
||||
<RecentTransaction />
|
||||
</el-col>
|
||||
<el-col :md="24" :lg="16" class="no-margin-bottom">
|
||||
<HotProductsList />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Banner from './widget/Banner.vue'
|
||||
import TotalOrderVolume from './widget/TotalOrderVolume.vue'
|
||||
import TotalProducts from './widget/TotalProducts.vue'
|
||||
import SalesTrend from './widget/SalesTrend.vue'
|
||||
import SalesClassification from './widget/SalesClassification.vue'
|
||||
import TransactionList from './widget/TransactionList.vue'
|
||||
import HotCommodity from './widget/HotCommodity.vue'
|
||||
import RecentTransaction from './widget/RecentTransaction.vue'
|
||||
import AnnualSales from './widget/AnnualSales.vue'
|
||||
import ProductSales from './widget/ProductSales.vue'
|
||||
import SalesGrowth from './widget/SalesGrowth.vue'
|
||||
import CartConversionRate from './widget/CartConversionRate.vue'
|
||||
import HotProductsList from './widget/HotProductsList.vue'
|
||||
|
||||
defineOptions({ name: 'Ecommerce' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
.ecommerce {
|
||||
:deep(.card) {
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: var(--custom-radius);
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 15px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900);
|
||||
|
||||
i {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.icon-text-widget) {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-right: 10px;
|
||||
line-height: 42px;
|
||||
color: var(--main-color);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
p {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-margin-bottom {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.el-col {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="card art-custom-card yearly-card" style="height: 28.2rem">
|
||||
<div class="card-header">
|
||||
<p class="title">年度销售额</p>
|
||||
<p class="subtitle">按季度统计</p>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 50, 90, 60, 70, 50]"
|
||||
barWidth="26px"
|
||||
height="16rem"
|
||||
/>
|
||||
<div class="icon-text-widget" style="margin-top: 50px">
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥200,858</p>
|
||||
<span>线上销售</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥102,927</p>
|
||||
<span>线下销售</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<ArtBasicBanner
|
||||
class="banner"
|
||||
height="13.3rem"
|
||||
:title="`欢迎回来 ${userInfo.userName}`"
|
||||
backgroundColor="var(--el-color-primary-light-9)"
|
||||
titleColor="var(--art-gray-900)"
|
||||
:decoration="false"
|
||||
:meteorConfig="{
|
||||
enabled: true,
|
||||
count: 10
|
||||
}"
|
||||
:buttonConfig="{
|
||||
show: false,
|
||||
text: ''
|
||||
}"
|
||||
:imageConfig="{
|
||||
src: bannerCover,
|
||||
width: '18rem',
|
||||
bottom: '-7.5rem'
|
||||
}"
|
||||
@click="handleBannerClick"
|
||||
>
|
||||
<div class="banner-slot">
|
||||
<div class="item">
|
||||
<p class="title">¥2,340<i class="iconfont-sys text-success"></i></p>
|
||||
<p class="subtitle">今日销售额</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<p class="title">35%<i class="iconfont-sys text-success"></i></p>
|
||||
<p class="subtitle">较昨日</p>
|
||||
</div>
|
||||
</div>
|
||||
</ArtBasicBanner>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import bannerCover from '@imgs/login/lf_icon2.webp'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
const userStore = useUserStore()
|
||||
|
||||
const userInfo = computed(() => userStore.getUserInfo)
|
||||
|
||||
const handleBannerClick = () => {}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.banner {
|
||||
justify-content: center;
|
||||
|
||||
.banner-slot {
|
||||
display: flex;
|
||||
margin-top: 24px;
|
||||
|
||||
.item {
|
||||
margin-right: 30px;
|
||||
|
||||
&:first-of-type {
|
||||
padding-right: 30px;
|
||||
border-right: 1px solid var(--art-gray-300);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 30px;
|
||||
color: var(--art-gray-900) !important;
|
||||
|
||||
i {
|
||||
position: relative;
|
||||
top: -10px;
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-700) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<ArtLineChartCard
|
||||
class="margin-bottom-0"
|
||||
:value="2545"
|
||||
label="购物车转化率"
|
||||
:percentage="1.2"
|
||||
:height="13.5"
|
||||
:chartData="[120, 132, 101, 134, 90, 230, 210]"
|
||||
:showAreaColor="true"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="card art-custom-card weekly-card" style="height: 28.2rem">
|
||||
<div class="card-header">
|
||||
<p class="title">热销商品</p>
|
||||
<p class="subtitle">本周销售排行</p>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:showAreaColor="true"
|
||||
:data="[8, 40, 82, 35, 90, 52, 35]"
|
||||
height="9rem"
|
||||
/>
|
||||
<div class="content">
|
||||
<div class="item" v-for="item in weeklyList" :key="item.title">
|
||||
<div class="icon" :class="item.color">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<p class="title">{{ item.title }}</p>
|
||||
<span class="subtitle">{{ item.subtitle }}</span>
|
||||
</div>
|
||||
<div class="value" :class="item.color">
|
||||
<span>+{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const weeklyList = [
|
||||
{
|
||||
icon: '',
|
||||
title: '智能手表Pro',
|
||||
subtitle: '电子产品',
|
||||
value: '1,286件',
|
||||
color: 'bg-primary'
|
||||
},
|
||||
{
|
||||
icon: '',
|
||||
title: '时尚连衣裙',
|
||||
subtitle: '女装服饰',
|
||||
value: '892件',
|
||||
color: 'bg-success'
|
||||
},
|
||||
{
|
||||
icon: '',
|
||||
title: '厨房小家电',
|
||||
subtitle: '家居用品',
|
||||
value: '756件',
|
||||
color: 'bg-error'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.weekly-card {
|
||||
.content {
|
||||
margin-top: 40px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
line-height: 42px;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 10px;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
padding: 6px 12px;
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 27.8rem">
|
||||
<div class="card-header">
|
||||
<p class="title">热销产品</p>
|
||||
<p class="subtitle">本月销售情况</p>
|
||||
</div>
|
||||
<div class="table">
|
||||
<el-scrollbar style="height: 21.55rem">
|
||||
<art-table
|
||||
:data="tableData"
|
||||
:pagination="false"
|
||||
style="margin-top: 0 !important"
|
||||
size="large"
|
||||
:border="false"
|
||||
:stripe="false"
|
||||
:show-header-background="false"
|
||||
>
|
||||
<template #default>
|
||||
<el-table-column label="产品" prop="product" width="220px">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; align-items: center">
|
||||
<img class="product-image" :src="scope.row.image" />
|
||||
<div class="product-info">
|
||||
<div class="product-name">{{ scope.row.name }}</div>
|
||||
<div class="product-category">{{ scope.row.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" prop="price">
|
||||
<template #default="scope">
|
||||
<span class="price">¥{{ scope.row.price.toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" prop="stock">
|
||||
<template #default="scope">
|
||||
<div class="stock-badge" :class="getStockClass(scope.row.stock)">
|
||||
{{ getStockStatus(scope.row.stock) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销量" prop="sales" />
|
||||
<el-table-column label="销售趋势" width="240">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="scope.row.pro"
|
||||
:color="scope.row.color"
|
||||
:stroke-width="4"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</art-table>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
|
||||
// 导入产品图片
|
||||
import product1 from '@/assets/img/3d/icon1.webp'
|
||||
import product2 from '@/assets/img/3d/icon2.webp'
|
||||
import product3 from '@/assets/img/3d/icon3.webp'
|
||||
import product4 from '@/assets/img/3d/icon4.webp'
|
||||
import product5 from '@/assets/img/3d/icon5.webp'
|
||||
import product6 from '@/assets/img/3d/icon6.webp'
|
||||
|
||||
const tableData = reactive([
|
||||
{
|
||||
name: '智能手表 Pro',
|
||||
category: '电子设备',
|
||||
price: 1299,
|
||||
stock: 156,
|
||||
sales: 423,
|
||||
percentage: 75,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-primary)) !important',
|
||||
image: product1
|
||||
},
|
||||
{
|
||||
name: '无线蓝牙耳机',
|
||||
category: '音频设备',
|
||||
price: 499,
|
||||
stock: 89,
|
||||
sales: 652,
|
||||
percentage: 85,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-success)) !important',
|
||||
image: product2
|
||||
},
|
||||
{
|
||||
name: '机械键盘',
|
||||
category: '电脑配件',
|
||||
price: 399,
|
||||
stock: 12,
|
||||
sales: 238,
|
||||
percentage: 45,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-warning)) !important',
|
||||
image: product3
|
||||
},
|
||||
{
|
||||
name: '超薄笔记本电脑',
|
||||
category: '电子设备',
|
||||
price: 5999,
|
||||
stock: 0,
|
||||
sales: 126,
|
||||
percentage: 30,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-error)) !important',
|
||||
image: product4
|
||||
},
|
||||
{
|
||||
name: '智能音箱',
|
||||
category: '智能家居',
|
||||
price: 799,
|
||||
stock: 45,
|
||||
sales: 321,
|
||||
percentage: 60,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-info)) !important',
|
||||
image: product5
|
||||
},
|
||||
{
|
||||
name: '游戏手柄',
|
||||
category: '游戏配件',
|
||||
price: 299,
|
||||
stock: 78,
|
||||
sales: 489,
|
||||
percentage: 70,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-secondary)) !important',
|
||||
image: product6
|
||||
}
|
||||
])
|
||||
|
||||
// 根据库存获取状态文本
|
||||
const getStockStatus = (stock: number) => {
|
||||
if (stock === 0) return '缺货'
|
||||
if (stock < 20) return '低库存'
|
||||
if (stock < 50) return '适中'
|
||||
return '充足'
|
||||
}
|
||||
|
||||
// 根据库存获取状态类名
|
||||
const getStockClass = (stock: number) => {
|
||||
if (stock === 0) return 'out-of-stock'
|
||||
if (stock < 20) return 'low-stock'
|
||||
if (stock < 50) return 'medium-stock'
|
||||
return 'in-stock'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addAnimation()
|
||||
})
|
||||
|
||||
const addAnimation = () => {
|
||||
setTimeout(() => {
|
||||
for (let i = 0; i < tableData.length; i++) {
|
||||
let item = tableData[i]
|
||||
tableData[i].pro = item.percentage
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table {
|
||||
width: 100%;
|
||||
|
||||
.card-header {
|
||||
padding-left: 25px !important;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-category {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stock-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.in-stock {
|
||||
color: rgb(var(--art-success));
|
||||
background-color: rgba(var(--art-success-rgb), 0.1);
|
||||
}
|
||||
|
||||
.medium-stock {
|
||||
color: rgb(var(--art-info));
|
||||
background-color: rgba(var(--art-info-rgb), 0.1);
|
||||
}
|
||||
|
||||
.low-stock {
|
||||
color: rgb(var(--art-warning));
|
||||
background-color: rgba(var(--art-warning-rgb), 0.1);
|
||||
}
|
||||
|
||||
.out-of-stock {
|
||||
color: rgb(var(--art-error));
|
||||
background-color: rgba(var(--art-error-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 11rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px"
|
||||
>14.5k<i class="iconfont-sys text-success"></i></p
|
||||
>
|
||||
<p class="subtitle">销售量</p>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 50, 90, 60, 70, 50]"
|
||||
barWidth="16px"
|
||||
height="4rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<ArtTimelineListCard :list="timelineData" title="最近交易" subtitle="今日订单动态" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const timelineData = [
|
||||
{
|
||||
time: '上午 09:30',
|
||||
status: 'rgb(73, 190, 255)',
|
||||
content: '收到订单 #38291 支付 ¥385.90'
|
||||
},
|
||||
{
|
||||
time: '上午 10:00',
|
||||
status: 'rgb(54, 158, 255)',
|
||||
content: '新商品上架',
|
||||
code: 'SKU-3467'
|
||||
},
|
||||
{
|
||||
time: '上午 12:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '向供应商支付了 ¥6495.00'
|
||||
},
|
||||
{
|
||||
time: '下午 14:30',
|
||||
status: 'rgb(255, 193, 7)',
|
||||
content: '促销活动开始',
|
||||
code: 'PROMO-2023'
|
||||
},
|
||||
{
|
||||
time: '下午 15:45',
|
||||
status: 'rgb(255, 105, 105)',
|
||||
content: '订单取消提醒',
|
||||
code: 'ORD-9876'
|
||||
},
|
||||
{
|
||||
time: '下午 17:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '完成日销售报表'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="card art-custom-card sales-card" style="height: 26rem">
|
||||
<div class="card-header">
|
||||
<p class="title">销售分类</p>
|
||||
<p class="subtitle">按产品类别</p>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '电子产品' },
|
||||
{ value: 55, name: '服装鞋包' },
|
||||
{ value: 36, name: '家居用品' }
|
||||
]"
|
||||
:color="['#4C87F3', '#EDF2FF', '#8BD8FC']"
|
||||
:radius="['70%', '80%']"
|
||||
height="16.5rem"
|
||||
:showLabel="false"
|
||||
:borderRadius="0"
|
||||
centerText="¥300,458"
|
||||
/>
|
||||
<div class="icon-text-widget">
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥500,458</p>
|
||||
<span>总收入</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥130,580</p>
|
||||
<span>净利润</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 11rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px"
|
||||
>12%<i class="iconfont-sys text-success"></i></p
|
||||
>
|
||||
<p class="subtitle">增长</p>
|
||||
</div>
|
||||
|
||||
<ArtLineChart
|
||||
:showAreaColor="true"
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 85, 65, 95, 75, 130, 180]"
|
||||
barWidth="16px"
|
||||
height="4rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 26rem">
|
||||
<div class="card-header">
|
||||
<p class="title">销售趋势</p>
|
||||
<p class="subtitle">月度销售对比</p>
|
||||
</div>
|
||||
<ArtDualBarCompareChart
|
||||
:topData="[50, 80, 120, 90, 60]"
|
||||
:bottomData="[30, 60, 90, 70, 40]"
|
||||
:xAxisData="['一月', '二月', '三月', '四月', '五月']"
|
||||
height="18rem"
|
||||
:barWidth="16"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 13.3rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px">205,216</p>
|
||||
<p class="subtitle">总订单量</p>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '已完成' },
|
||||
{ value: 25, name: '处理中' },
|
||||
{ value: 45, name: '待发货' }
|
||||
]"
|
||||
:color="['#4C87F3', '#93F1B4', '#8BD8FC']"
|
||||
:radius="['56%', '76%']"
|
||||
height="7rem"
|
||||
:showLabel="false"
|
||||
:borderRadius="0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 13.3rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px">55,231</p>
|
||||
<p class="subtitle">商品总数</p>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 40, 90, 60, 70]"
|
||||
height="7rem"
|
||||
barWidth="18px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<ArtDataListCard
|
||||
:maxCount="4"
|
||||
:list="dataList"
|
||||
title="最近活动"
|
||||
subtitle="订单处理状态"
|
||||
:showMoreButton="true"
|
||||
@more="handleMore"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const dataList = [
|
||||
{
|
||||
title: '新订单 #38291',
|
||||
status: '待处理',
|
||||
time: '5分钟',
|
||||
class: 'bg-primary',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '退款申请 #12845',
|
||||
status: '处理中',
|
||||
time: '10分钟',
|
||||
class: 'bg-secondary',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '客户投诉处理',
|
||||
status: '待处理',
|
||||
time: '15分钟',
|
||||
class: 'bg-warning',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '库存不足提醒',
|
||||
status: '紧急',
|
||||
time: '20分钟',
|
||||
class: 'bg-danger',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '订单 #29384 已发货',
|
||||
status: '已完成',
|
||||
time: '20分钟',
|
||||
class: 'bg-success',
|
||||
icon: ''
|
||||
}
|
||||
]
|
||||
|
||||
const handleMore = () => {}
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ArtException
|
||||
:data="{
|
||||
title: '403',
|
||||
desc: $t('exceptionPage.403'),
|
||||
btnText: $t('exceptionPage.gohome'),
|
||||
imgUrl
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import imgUrl from '@imgs/state/403.png'
|
||||
defineOptions({ name: 'Exception403' })
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ArtException
|
||||
:data="{
|
||||
title: '404',
|
||||
desc: $t('exceptionPage.404'),
|
||||
btnText: $t('exceptionPage.gohome'),
|
||||
imgUrl
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import imgUrl from '@imgs/state/404.png'
|
||||
defineOptions({ name: 'Exception404' })
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ArtException
|
||||
:data="{
|
||||
title: '500',
|
||||
desc: $t('exceptionPage.500'),
|
||||
btnText: $t('exceptionPage.gohome'),
|
||||
imgUrl
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import imgUrl from '@imgs/state/500.png'
|
||||
defineOptions({ name: 'Exception500' })
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<ArtLayouts>
|
||||
<!-- 顶栏、水平/混合菜单 -->
|
||||
<ArtHeaderBar />
|
||||
<!-- 左侧/双列菜单 -->
|
||||
<ArtSidebarMenu />
|
||||
<!-- 页面内容 -->
|
||||
<ArtPageContent />
|
||||
<!-- 设置面板 -->
|
||||
<ArtSettingsPanel />
|
||||
<!-- 全局搜索 -->
|
||||
<ArtGlobalSearch />
|
||||
<!-- 屏幕锁定 -->
|
||||
<ArtScreenLock />
|
||||
<!-- 聊天窗口 -->
|
||||
<ArtChatWindow />
|
||||
<!-- 礼花效果 -->
|
||||
<ArtFireworksEffect />
|
||||
<!-- 水印效果 -->
|
||||
<ArtWatermark />
|
||||
</ArtLayouts>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.layouts {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--art-bg-color);
|
||||
transition: padding 0.3s ease-in-out;
|
||||
|
||||
.layout-content {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 40px);
|
||||
margin: auto;
|
||||
|
||||
// 子页面默认style
|
||||
:deep(.page-content) {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad) {
|
||||
.layouts {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding-left: 0 !important;
|
||||
overflow-y: scroll;
|
||||
|
||||
.layout-content {
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.layouts {
|
||||
.layout-content {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="iframe-container" v-loading="isLoading">
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:src="iframeUrl"
|
||||
frameborder="0"
|
||||
class="iframe-content"
|
||||
@load="handleIframeLoad"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIframeRoutes } from '@/router/utils/menuToRouter'
|
||||
|
||||
const route = useRoute()
|
||||
const isLoading = ref(true)
|
||||
const iframeUrl = ref('')
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
const iframeRoute = getIframeRoutes().find((item: any) => item.path === route.path)
|
||||
|
||||
if (iframeRoute?.meta) {
|
||||
iframeUrl.value = iframeRoute.meta.link || ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.iframe-container {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.iframe-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 120px);
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,662 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="menu-page" id="table-full-screen">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="formFilters"
|
||||
:items="formItems"
|
||||
:showExpand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
></ArtSearchBar>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader :showZebra="false" v-model:columns="columnChecks" @refresh="handleRefresh">
|
||||
<template #left>
|
||||
<!-- 按钮权限:后端控制模式,使用自定义指令 -->
|
||||
<ElButton v-auth="'add'" @click="showModel('menu', null, true)" v-ripple>
|
||||
添加菜单
|
||||
</ElButton>
|
||||
<ElButton @click="toggleExpand" v-ripple>
|
||||
{{ isExpanded ? '收起' : '展开' }}
|
||||
</ElButton>
|
||||
<!-- 按钮权限:前端控制模式,使用 hasAuth 方法 -->
|
||||
<!-- <ElButton v-if="hasAuth('B_CODE1')" @click="showModel('menu', null, true)" v-ripple>
|
||||
添加菜单
|
||||
</ElButton> -->
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
rowKey="path"
|
||||
ref="tableRef"
|
||||
:loading="loading"
|
||||
:data="filteredTableData"
|
||||
:marginTop="10"
|
||||
:stripe="false"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ElDialog :title="dialogTitle" v-model="dialogVisible" width="700px" align-center>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="85px">
|
||||
<ElFormItem label="菜单类型">
|
||||
<ElRadioGroup v-model="labelPosition" :disabled="disableMenuType">
|
||||
<ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
|
||||
<ElRadioButton value="button" label="button">权限</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<template v-if="labelPosition === 'menu'">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="菜单名称" prop="name">
|
||||
<ElInput v-model="form.name" placeholder="菜单名称"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="路由地址" prop="path">
|
||||
<ElInput v-model="form.path" placeholder="路由地址"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="权限标识" prop="label">
|
||||
<ElInput v-model="form.label" placeholder="权限标识"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="图标" prop="icon">
|
||||
<ArtIconSelector v-model="form.icon" :iconType="iconType" width="100%" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="菜单排序" prop="sort" style="width: 100%">
|
||||
<ElInputNumber
|
||||
v-model="form.sort"
|
||||
style="width: 100%"
|
||||
@change="handleChange"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="外部链接" prop="link">
|
||||
<ElInput
|
||||
v-model="form.link"
|
||||
placeholder="外部链接/内嵌地址(https://www.baidu.com)"
|
||||
></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="是否启用" prop="isEnable">
|
||||
<ElSwitch v-model="form.isEnable"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="页面缓存" prop="keepAlive">
|
||||
<ElSwitch v-model="form.keepAlive"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="是否显示" prop="isHidden">
|
||||
<ElSwitch v-model="form.isHidden"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="5">
|
||||
<ElFormItem label="是否内嵌" prop="isMenu">
|
||||
<ElSwitch v-model="form.isIframe"></ElSwitch>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</template>
|
||||
|
||||
<template v-if="labelPosition === 'button'">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="权限名称" prop="authName">
|
||||
<ElInput v-model="form.authName" placeholder="权限名称"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="权限标识" prop="authLabel">
|
||||
<ElInput v-model="form.authLabel" placeholder="权限标识"></ElInput>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="权限排序" prop="authSort" style="width: 100%">
|
||||
<ElInputNumber
|
||||
v-model="form.authSort"
|
||||
style="width: 100%"
|
||||
@change="handleChange"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取 消</ElButton>
|
||||
<ElButton type="primary" @click="submitForm()">确 定</ElButton>
|
||||
</span>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { IconTypeEnum } from '@/enums/appEnum'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { ElPopover, ElButton } from 'element-plus'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { SearchFormItem } from '@/types'
|
||||
|
||||
defineOptions({ name: 'Menus' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const { menuList } = storeToRefs(useMenuStore())
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 定义表单搜索初始值
|
||||
const initialSearchState = {
|
||||
name: '',
|
||||
route: ''
|
||||
}
|
||||
|
||||
// 响应式表单数据
|
||||
const formFilters = reactive({ ...initialSearchState })
|
||||
|
||||
// 增加实际应用的搜索条件状态
|
||||
const appliedFilters = reactive({ ...initialSearchState })
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
Object.assign(formFilters, { ...initialSearchState })
|
||||
Object.assign(appliedFilters, { ...initialSearchState })
|
||||
getTableData()
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
// 将当前输入的筛选条件应用到实际搜索
|
||||
Object.assign(appliedFilters, { ...formFilters })
|
||||
getTableData()
|
||||
}
|
||||
|
||||
// 表单配置项
|
||||
const formItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '菜单名称',
|
||||
prop: 'name',
|
||||
type: 'input',
|
||||
config: {
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '路由地址',
|
||||
prop: 'route',
|
||||
type: 'input',
|
||||
config: {
|
||||
clearable: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 构建菜单类型标签
|
||||
const buildMenuTypeTag = (row: AppRouteRecord) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
return 'info'
|
||||
} else if (row.meta?.link && row.meta?.isIframe) {
|
||||
return 'success'
|
||||
} else if (row.path) {
|
||||
return 'primary'
|
||||
} else if (row.meta?.link) {
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单类型文本
|
||||
const buildMenuTypeText = (row: AppRouteRecord) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
return '目录'
|
||||
} else if (row.meta?.link && row.meta?.isIframe) {
|
||||
return '内嵌'
|
||||
} else if (row.path) {
|
||||
return '菜单'
|
||||
} else if (row.meta?.link) {
|
||||
return '外链'
|
||||
}
|
||||
}
|
||||
|
||||
// 动态列配置
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'meta.title',
|
||||
label: '菜单名称',
|
||||
minWidth: 120,
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return formatMenuTitle(row.meta?.title)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'type',
|
||||
label: '菜单类型',
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return h(ElTag, { type: buildMenuTypeTag(row) }, () => buildMenuTypeText(row))
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'path',
|
||||
label: '路由',
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return row.meta?.link || row.path || ''
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'meta.authList',
|
||||
label: '可操作权限',
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
row.meta.authList?.map((item: { title: string; auth_mark: string }, index: number) => {
|
||||
return h(
|
||||
ElPopover,
|
||||
{
|
||||
placement: 'top-start',
|
||||
title: '操作',
|
||||
width: 200,
|
||||
trigger: 'click',
|
||||
key: index
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h('div', { style: 'margin: 0; text-align: right' }, [
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => showModel('button', item)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'danger',
|
||||
onClick: () => deleteAuth()
|
||||
},
|
||||
{ default: () => '删除' }
|
||||
)
|
||||
]),
|
||||
reference: () => h(ElButton, { class: 'small-btn' }, { default: () => item.title })
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'date',
|
||||
label: '编辑时间',
|
||||
formatter: () => '2022-3-12 12:00:00'
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '隐藏菜单',
|
||||
formatter: (row) => {
|
||||
return h(ElTag, { type: row.meta.isHide ? 'danger' : 'info' }, () =>
|
||||
row.meta.isHide ? '是' : '否'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'operation',
|
||||
label: '操作',
|
||||
width: 180,
|
||||
formatter: (row: AppRouteRecord) => {
|
||||
return h('div', [
|
||||
// 这里写两组权限标识判断是为了方便演示,在实际开发中可以删除其中一组
|
||||
// 前端模式权限标识
|
||||
hasAuth('B_CODE1') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'add',
|
||||
onClick: () => showModel('menu')
|
||||
}),
|
||||
hasAuth('B_CODE2') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'edit',
|
||||
onClick: () => showDialog('edit', row)
|
||||
}),
|
||||
hasAuth('B_CODE3') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'delete',
|
||||
onClick: () => deleteMenu()
|
||||
}),
|
||||
// 后端模式权限标识
|
||||
hasAuth('add') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'add',
|
||||
onClick: () => showModel('menu')
|
||||
}),
|
||||
hasAuth('edit') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'edit',
|
||||
onClick: () => showDialog('edit', row)
|
||||
}),
|
||||
hasAuth('delete') &&
|
||||
h(ArtButtonTable, {
|
||||
type: 'delete',
|
||||
onClick: () => deleteMenu()
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const form = reactive({
|
||||
// 菜单
|
||||
name: '',
|
||||
path: '',
|
||||
label: '',
|
||||
icon: '',
|
||||
isEnable: true,
|
||||
sort: 1,
|
||||
isMenu: true,
|
||||
keepAlive: true,
|
||||
isHidden: true,
|
||||
link: '',
|
||||
isIframe: false,
|
||||
// 权限 (修改这部分)
|
||||
authName: '',
|
||||
authLabel: '',
|
||||
authIcon: '',
|
||||
authSort: 1
|
||||
})
|
||||
const iconType = ref(IconTypeEnum.UNICODE)
|
||||
|
||||
const labelPosition = ref('menu')
|
||||
const rules = reactive<FormRules>({
|
||||
name: [
|
||||
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
|
||||
label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
|
||||
// 修改这部分
|
||||
authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
|
||||
authLabel: [{ required: true, message: '请输入权限权限标识', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const tableData = ref<AppRouteRecord[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
|
||||
const getTableData = () => {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
tableData.value = menuList.value
|
||||
loading.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 过滤后的表格数据
|
||||
const filteredTableData = computed(() => {
|
||||
// 递归搜索函数
|
||||
const searchMenu = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
return items.filter((item) => {
|
||||
// 获取搜索关键词,转换为小写并去除首尾空格
|
||||
const searchName = appliedFilters.name?.toLowerCase().trim() || ''
|
||||
const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
|
||||
|
||||
// 获取菜单标题和路径,确保它们存在
|
||||
const menuTitle = formatMenuTitle(item.meta?.title || '').toLowerCase()
|
||||
const menuPath = (item.path || '').toLowerCase()
|
||||
|
||||
// 使用 includes 进行模糊匹配
|
||||
const nameMatch = !searchName || menuTitle.includes(searchName)
|
||||
const routeMatch = !searchRoute || menuPath.includes(searchRoute)
|
||||
|
||||
// 如果有子菜单,递归搜索
|
||||
if (item.children && item.children.length > 0) {
|
||||
const matchedChildren = searchMenu(item.children)
|
||||
// 如果子菜单有匹配项,保留当前菜单
|
||||
if (matchedChildren.length > 0) {
|
||||
item.children = matchedChildren
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return nameMatch && routeMatch
|
||||
})
|
||||
}
|
||||
|
||||
return searchMenu(tableData.value)
|
||||
})
|
||||
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const dialogTitle = computed(() => {
|
||||
const type = labelPosition.value === 'menu' ? '菜单' : '权限'
|
||||
return isEdit.value ? `编辑${type}` : `新建${type}`
|
||||
})
|
||||
|
||||
const showDialog = (type: string, row: AppRouteRecord) => {
|
||||
showModel('menu', row, true)
|
||||
}
|
||||
|
||||
const handleChange = () => {}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
// const menuStore = useMenuStore()
|
||||
// const params =
|
||||
// labelPosition.value === 'menu'
|
||||
// ? {
|
||||
// title: form.name,
|
||||
// path: form.path,
|
||||
// name: form.label,
|
||||
// icon: form.icon,
|
||||
// sort: form.sort,
|
||||
// isEnable: form.isEnable,
|
||||
// isMenu: form.isMenu,
|
||||
// keepAlive: form.keepAlive,
|
||||
// isHidden: form.isHidden,
|
||||
// link: form.link
|
||||
// }
|
||||
// : {
|
||||
// title: form.authName,
|
||||
// name: form.authLabel,
|
||||
// icon: form.authIcon,
|
||||
// sort: form.authSort
|
||||
// }
|
||||
|
||||
if (isEdit.value) {
|
||||
// await menuStore.updateMenu(params)
|
||||
} else {
|
||||
// await menuStore.addMenu(params)
|
||||
}
|
||||
|
||||
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
|
||||
dialogVisible.value = false
|
||||
} catch {
|
||||
ElMessage.error(`${isEdit.value ? '编辑' : '新增'}失败`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const showModel = (type: string, row?: any, lock: boolean = false) => {
|
||||
dialogVisible.value = true
|
||||
labelPosition.value = type
|
||||
isEdit.value = false
|
||||
lockMenuType.value = lock
|
||||
resetForm()
|
||||
|
||||
if (row) {
|
||||
isEdit.value = true
|
||||
nextTick(() => {
|
||||
// 回显数据
|
||||
if (type === 'menu') {
|
||||
// 菜单数据回显
|
||||
form.name = formatMenuTitle(row.meta.title)
|
||||
form.path = row.path
|
||||
form.label = row.name
|
||||
form.icon = row.meta.icon
|
||||
form.sort = row.meta.sort || 1
|
||||
form.isMenu = row.meta.isMenu
|
||||
form.keepAlive = row.meta.keepAlive
|
||||
form.isHidden = row.meta.isHidden || true
|
||||
form.isEnable = row.meta.isEnable || true
|
||||
form.link = row.meta.link
|
||||
form.isIframe = row.meta.isIframe || false
|
||||
} else {
|
||||
// 权限按钮数据回显
|
||||
form.authName = row.title
|
||||
form.authLabel = row.auth_mark
|
||||
form.authIcon = row.icon || ''
|
||||
form.authSort = row.sort || 1
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value?.resetFields()
|
||||
Object.assign(form, {
|
||||
// 菜单
|
||||
name: '',
|
||||
path: '',
|
||||
label: '',
|
||||
icon: '',
|
||||
sort: 1,
|
||||
isMenu: true,
|
||||
keepAlive: true,
|
||||
isHidden: true,
|
||||
link: '',
|
||||
isIframe: false,
|
||||
// 权限
|
||||
authName: '',
|
||||
authLabel: '',
|
||||
authIcon: '',
|
||||
authSort: 1
|
||||
})
|
||||
}
|
||||
|
||||
const deleteMenu = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAuth = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修改计算属性,增加锁定控制参数
|
||||
const disableMenuType = computed(() => {
|
||||
// 编辑权限时锁定为权限类型
|
||||
if (isEdit.value && labelPosition.value === 'button') return true
|
||||
// 编辑菜单时锁定为菜单类型
|
||||
if (isEdit.value && labelPosition.value === 'menu') return true
|
||||
// 顶部添加菜单按钮时锁定为菜单类型
|
||||
if (!isEdit.value && labelPosition.value === 'menu' && lockMenuType.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 添加一个控制变量
|
||||
const lockMenuType = ref(false)
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const tableRef = ref()
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
nextTick(() => {
|
||||
if (tableRef.value) {
|
||||
tableRef.value[isExpanded.value ? 'expandAll' : 'collapseAll']()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-page {
|
||||
.svg-icon {
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
vertical-align: -8px;
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
:deep(.small-btn) {
|
||||
height: 30px !important;
|
||||
padding: 0 10px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<ElRow>
|
||||
<ElCol :xs="24" :sm="12" :lg="6">
|
||||
<ElInput placeholder="部门名称"></ElInput>
|
||||
</ElCol>
|
||||
<div style="width: 12px"></div>
|
||||
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
|
||||
<ElButton v-ripple>搜索</ElButton>
|
||||
<ElButton @click="showDialog('add')" v-ripple>新增角色</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ArtTable ref="tableRef" row-key="id" :loading="loading" :data="roleList" :currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize" :total="pagination.total" @size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange">
|
||||
<template #default>
|
||||
<ElTableColumn label="角色名称" prop="name" />
|
||||
<ElTableColumn label="角色编码" prop="id" />
|
||||
<ElTableColumn label="描述" prop="desc" />
|
||||
<ElTableColumn label="启用" prop="enable">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status ? 'primary' : 'info'">
|
||||
{{ scope.row.status ? '启用' : '禁用' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="创建时间" prop="created_at">
|
||||
</ElTableColumn>
|
||||
<ElTableColumn fixed="right" label="操作" width="200px">
|
||||
<template #default="scope">
|
||||
<ElRow>
|
||||
<el-button link @click="showPermissionDialog()"> 菜单权限 </el-button>
|
||||
<el-button link @click="showDialog('edit', scope.row)"> 编辑 </el-button>
|
||||
<el-button link @click="deleteRole()"> 删除 </el-button>
|
||||
<!-- <ArtButtonMore
|
||||
:list="[
|
||||
{ key: 'permission', label: '菜单权限' },
|
||||
{ key: 'edit', label: '编辑角色' },
|
||||
{ key: 'delete', label: '删除角色' }
|
||||
]"
|
||||
@click="buttonMoreClick($event, scope.row)"
|
||||
/> -->
|
||||
</ElRow>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
|
||||
width="30%"
|
||||
align-center
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<ElFormItem label="角色名称" prop="roleName">
|
||||
<ElInput v-model="form.roleName" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="角色编码" prop="roleCode">
|
||||
<ElInput v-model="form.roleCode" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="描述" prop="roleStatus">
|
||||
<ElInput v-model="form.des" type="textarea" :rows="3" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用">
|
||||
<ElSwitch v-model="form.enable" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog
|
||||
v-model="permissionDialog"
|
||||
title="菜单权限"
|
||||
width="520px"
|
||||
align-center
|
||||
class="el-dialog-border"
|
||||
>
|
||||
<ElScrollbar height="70vh">
|
||||
<ElTree
|
||||
ref="treeRef"
|
||||
:data="processedMenuList"
|
||||
show-checkbox
|
||||
node-key="name"
|
||||
:default-expand-all="isExpandAll"
|
||||
:default-checked-keys="[1, 2, 3]"
|
||||
:props="defaultProps"
|
||||
@check="handleTreeCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div style="display: flex; align-items: center">
|
||||
<span v-if="data.isAuth">
|
||||
{{ data.label }}
|
||||
</span>
|
||||
<span v-else>{{ defaultProps.label(data) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</ElScrollbar>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="toggleExpandAll">{{ isExpandAll ? '全部收起' : '全部展开' }}</ElButton>
|
||||
<ElButton @click="toggleSelectAll" style="margin-left: 8px">{{
|
||||
isSelectAll ? '取消全选' : '全部选择'
|
||||
}}</ElButton>
|
||||
<ElButton type="primary" @click="savePermission">保存</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
// import { ButtonMoreItem } from '@/components/core/forms/ArtButtonMore.vue'
|
||||
import { Role, ROLE_LIST_DATA } from '@/mock/temp/formData'
|
||||
const loading = ref(false)
|
||||
defineOptions({ name: 'Role' })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const permissionDialog = ref(false)
|
||||
const { menuList } = storeToRefs(useMenuStore())
|
||||
const treeRef = ref()
|
||||
const isExpandAll = ref(true)
|
||||
const isSelectAll = ref(false)
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
// 处理菜单数据,将 authList 转换为子节点
|
||||
const processedMenuList = computed(() => {
|
||||
const processNode = (node: any) => {
|
||||
const processed = { ...node }
|
||||
|
||||
// 如果有 authList,将其转换为子节点
|
||||
if (node.meta && node.meta.authList && node.meta.authList.length) {
|
||||
const authNodes = node.meta.authList.map((auth: any) => ({
|
||||
id: `${node.id}_${auth.auth_mark}`,
|
||||
name: `${node.name}_${auth.auth_mark}`,
|
||||
label: auth.title,
|
||||
auth_mark: auth.auth_mark,
|
||||
isAuth: true,
|
||||
checked: auth.checked || false
|
||||
}))
|
||||
|
||||
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
if (processed.children) {
|
||||
processed.children = processed.children.map(processNode)
|
||||
}
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
return menuList.value.map(processNode)
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [
|
||||
{ required: true, message: '请输入角色名称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
des: [{ required: true, message: '请输入角色描述', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const form = reactive<Role>({
|
||||
roleName: '',
|
||||
roleCode: '',
|
||||
des: '',
|
||||
date: '',
|
||||
enable: true
|
||||
})
|
||||
|
||||
const roleList = ref<Role[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
|
||||
// const getTableData = () => {
|
||||
// roleList.value = ROLE_LIST_DATA
|
||||
// }
|
||||
// 获取列表数据
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
|
||||
const { records, current, size, total } = await UserService.getRoleList({
|
||||
current: currentPage,
|
||||
size: pageSize
|
||||
})
|
||||
roleList.value = records.map((item: any, index: number) => ({
|
||||
...item
|
||||
}))
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// 处理表格分页变化
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
getTableData()
|
||||
}
|
||||
const dialogType = ref('add')
|
||||
|
||||
const showDialog = (type: string, row?: any) => {
|
||||
dialogVisible.value = true
|
||||
dialogType.value = type
|
||||
|
||||
if (type === 'edit' && row) {
|
||||
form.roleName = row.roleName
|
||||
form.roleCode = row.roleCode
|
||||
form.des = row.des
|
||||
form.date = row.date
|
||||
form.enable = row.enable
|
||||
} else {
|
||||
form.roleName = ''
|
||||
form.roleCode = ''
|
||||
form.des = ''
|
||||
form.date = ''
|
||||
form.enable = true
|
||||
}
|
||||
}
|
||||
|
||||
const showPermissionDialog = () => {
|
||||
permissionDialog.value = true
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
children: 'children',
|
||||
label: (data: any) => formatMenuTitle(data.meta?.title) || ''
|
||||
}
|
||||
|
||||
const deleteRole = () => {
|
||||
ElMessageBox.confirm('确定删除该角色吗?', '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}).then(() => {
|
||||
ElMessage.success('删除成功')
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
|
||||
await formEl.validate((valid) => {
|
||||
if (valid) {
|
||||
const message = dialogType.value === 'add' ? '新增成功' : '修改成功'
|
||||
ElMessage.success(message)
|
||||
dialogVisible.value = false
|
||||
formEl.resetFields()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const savePermission = () => {
|
||||
ElMessage.success('权限保存成功')
|
||||
permissionDialog.value = false
|
||||
}
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
const tree = treeRef.value
|
||||
if (!tree) return
|
||||
|
||||
// 使用store.nodesMap直接控制所有节点的展开状态
|
||||
const nodes = tree.store.nodesMap
|
||||
for (const node in nodes) {
|
||||
nodes[node].expanded = !isExpandAll.value
|
||||
}
|
||||
|
||||
isExpandAll.value = !isExpandAll.value
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const tree = treeRef.value
|
||||
if (!tree) return
|
||||
|
||||
if (!isSelectAll.value) {
|
||||
// 全选:获取所有节点的key并设置为选中
|
||||
const allKeys = getAllNodeKeys(processedMenuList.value)
|
||||
tree.setCheckedKeys(allKeys)
|
||||
} else {
|
||||
// 取消全选:清空所有选中
|
||||
tree.setCheckedKeys([])
|
||||
}
|
||||
|
||||
isSelectAll.value = !isSelectAll.value
|
||||
}
|
||||
|
||||
const getAllNodeKeys = (nodes: any[]): string[] => {
|
||||
const keys: string[] = []
|
||||
const traverse = (nodeList: any[]) => {
|
||||
nodeList.forEach((node) => {
|
||||
if (node.name) {
|
||||
keys.push(node.name)
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
traverse(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
traverse(nodes)
|
||||
return keys
|
||||
}
|
||||
|
||||
const handleTreeCheck = () => {
|
||||
const tree = treeRef.value
|
||||
if (!tree) return
|
||||
|
||||
// 使用树组件的getCheckedKeys方法获取选中的节点
|
||||
const checkedKeys = tree.getCheckedKeys()
|
||||
const allKeys = getAllNodeKeys(processedMenuList.value)
|
||||
|
||||
// 判断是否全选:选中的节点数量等于总节点数量
|
||||
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date)
|
||||
.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
.replace(/\//g, '-')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-content {
|
||||
.svg-icon {
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
vertical-align: -8px;
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user