更新
This commit is contained in:
@@ -85,7 +85,7 @@ class Department extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'parent_id' => 'nullable|integer|exists:auth_departments,id',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'leader' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'sort' => 'nullable|integer|min:0',
|
||||
@@ -108,7 +108,7 @@ class Department extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'nullable|string|max:50',
|
||||
'parent_id' => 'nullable|integer|exists:auth_departments,id',
|
||||
'parent_id' => 'nullable|integer',
|
||||
'leader' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'sort' => 'nullable|integer|min:0',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\System\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LogRequest;
|
||||
use App\Services\System\LogService;
|
||||
use Illuminate\Http\Request;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Exports\GenericExport;
|
||||
|
||||
@@ -30,39 +31,9 @@ class Log extends Controller
|
||||
public function export(LogRequest $request)
|
||||
{
|
||||
$params = $request->validated();
|
||||
$pageSize = $params['page_size'] ?? 10000; // 导出时默认获取更多数据
|
||||
$filePath = $this->logService->export($params);
|
||||
|
||||
// 获取所有符合条件的日志(不分页)
|
||||
$query = $this->logService->getListQuery($params);
|
||||
$logs = $query->limit($pageSize)->get();
|
||||
|
||||
// 准备导出数据
|
||||
$headers = [
|
||||
'ID', '用户名', '模块', '操作', '请求方法', 'URL', 'IP地址',
|
||||
'状态码', '状态', '错误信息', '执行时间(ms)', '创建时间'
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($logs as $log) {
|
||||
$data[] = [
|
||||
$log->id,
|
||||
$log->username,
|
||||
$log->module,
|
||||
$log->action,
|
||||
$log->method,
|
||||
$log->url,
|
||||
$log->ip,
|
||||
$log->status_code,
|
||||
$log->status === 'success' ? '成功' : '失败',
|
||||
$log->error_message ?? '-',
|
||||
$log->execution_time,
|
||||
$log->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
$filename = '系统操作日志_' . date('YmdHis') . '.xlsx';
|
||||
|
||||
return Excel::download(new GenericExport($headers, $data), $filename);
|
||||
return response()->download($filePath)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
@@ -93,7 +64,7 @@ class Log extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function batchDelete(Request $request)
|
||||
public function batchDelete(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$this->logService->batchDelete($request->input('ids', []));
|
||||
return response()->json([
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
class LogRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
* Determine if user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
@@ -19,7 +19,7 @@ class LogRequest extends FormRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
* Get validation rules that apply to request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
@@ -34,6 +34,7 @@ class LogRequest extends FormRequest
|
||||
'username' => 'nullable|string|max:50',
|
||||
'module' => 'nullable|string|max:50',
|
||||
'action' => 'nullable|string|max:100',
|
||||
'method' => 'nullable|in:GET,POST,PUT,DELETE,PATCH',
|
||||
'status' => 'nullable|in:success,error',
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
@@ -77,6 +78,7 @@ class LogRequest extends FormRequest
|
||||
'username.max' => '用户名最多50个字符',
|
||||
'module.max' => '模块名最多50个字符',
|
||||
'action.max' => '操作名最多100个字符',
|
||||
'method.in' => '请求方式必须是 GET、POST、PUT、DELETE 或 PATCH',
|
||||
'status.in' => '状态值必须是 success 或 error',
|
||||
'start_date.date' => '开始日期格式不正确',
|
||||
'end_date.date' => '结束日期格式不正确',
|
||||
@@ -121,7 +123,7 @@ class LogRequest extends FormRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
* Prepare for validation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -48,7 +48,7 @@ class LogService
|
||||
*/
|
||||
protected function buildQuery(array $params)
|
||||
{
|
||||
$query = Log::query()->with('user:id,name,username');
|
||||
$query = Log::query()->with('user:id,username');
|
||||
|
||||
if (!empty($params['user_id'])) {
|
||||
$query->where('user_id', $params['user_id']);
|
||||
@@ -108,6 +108,14 @@ class LogService
|
||||
{
|
||||
$query = Log::query();
|
||||
|
||||
if (!empty($params['method'])) {
|
||||
$query->where('method', $params['method']);
|
||||
}
|
||||
|
||||
if (!empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
if (!empty($params['start_date']) && !empty($params['end_date'])) {
|
||||
$query->whereBetween('created_at', [$params['start_date'], $params['end_date']]);
|
||||
}
|
||||
@@ -116,10 +124,74 @@ class LogService
|
||||
$successCount = (clone $query)->where('status', 'success')->count();
|
||||
$errorCount = (clone $query)->where('status', 'error')->count();
|
||||
|
||||
// 计算平均响应时间
|
||||
$avgTime = (clone $query)->avg('execution_time');
|
||||
$avgTime = $avgTime ? round($avgTime, 2) : 0;
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'success' => $successCount,
|
||||
'error' => $errorCount,
|
||||
'avg_time' => $avgTime,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志
|
||||
*
|
||||
* @param array $params
|
||||
* @return string
|
||||
*/
|
||||
public function export(array $params): string
|
||||
{
|
||||
$query = $this->buildQuery($params);
|
||||
$query->orderBy('created_at', 'desc');
|
||||
|
||||
$logs = $query->limit(10000)->get();
|
||||
|
||||
// 创建导出数据
|
||||
$data = [];
|
||||
foreach ($logs as $log) {
|
||||
$data[] = [
|
||||
'ID' => $log->id,
|
||||
'用户名' => $log->username,
|
||||
'模块' => $log->module,
|
||||
'操作' => $log->action,
|
||||
'请求方式' => $log->method,
|
||||
'URL' => $log->url,
|
||||
'IP地址' => $log->ip,
|
||||
'状态码' => $log->status_code,
|
||||
'状态' => $log->status === 'success' ? '成功' : '失败',
|
||||
'执行时间' => $log->execution_time . 'ms',
|
||||
'创建时间' => $log->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
// 生成CSV文件
|
||||
$filename = '系统日志_' . date('YmdHis') . '.csv';
|
||||
$filepath = storage_path('app/exports/' . $filename);
|
||||
|
||||
// 确保目录存在
|
||||
if (!file_exists(dirname($filepath))) {
|
||||
mkdir(dirname($filepath), 0755, true);
|
||||
}
|
||||
|
||||
$file = fopen($filepath, 'w');
|
||||
// 添加BOM以支持Excel中文显示
|
||||
fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// 写入表头
|
||||
if (!empty($data)) {
|
||||
fputcsv($file, array_keys($data[0]));
|
||||
|
||||
// 写入数据
|
||||
foreach ($data as $row) {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
|
||||
return $filepath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,17 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
// 文件上传
|
||||
upload: {
|
||||
post: async function (file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return await request.post('upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// 用户管理
|
||||
users: {
|
||||
list: {
|
||||
|
||||
@@ -52,37 +52,45 @@ export default {
|
||||
|
||||
// 操作日志管理
|
||||
logs: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('logs', { params })
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('logs', { params })
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`logs/${id}`)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`logs/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('logs/batch-delete', params)
|
||||
},
|
||||
},
|
||||
clear: {
|
||||
post: async function (params) {
|
||||
return await request.post('logs/clear', params)
|
||||
},
|
||||
},
|
||||
export: {
|
||||
get: async function (params) {
|
||||
return await request.get('logs/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
})
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
get: async function (params) {
|
||||
return await request.get('logs/statistics', { params })
|
||||
},
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`logs/${id}`)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`logs/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('logs/batch-delete', params)
|
||||
},
|
||||
},
|
||||
clear: {
|
||||
post: async function (params) {
|
||||
return await request.post('logs/clear', params)
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
get: async function (params) {
|
||||
return await request.get('logs/statistics', { params })
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 数据字典管理
|
||||
dictionaries: {
|
||||
|
||||
125
resources/admin/src/pages/auth/roles/components/CopyDialog.vue
Normal file
125
resources/admin/src/pages/auth/roles/components/CopyDialog.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<a-modal :title="title" :open="visible" :width="500" :destroy-on-close="true" @cancel="handleCancel">
|
||||
<a-form :model="form" :rules="rules" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
||||
<a-form-item label="角色名称" name="name">
|
||||
<a-input v-model:value="form.name" placeholder="请输入新角色名称" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色编码" name="code">
|
||||
<a-input v-model:value="form.code" placeholder="请输入新角色编码" allow-clear />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取 消</a-button>
|
||||
<a-button type="primary" :loading="loading" @click="handleOk">确 定</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import authApi from '@/api/auth'
|
||||
|
||||
const emit = defineEmits(['success', 'closed'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const sourceId = ref(null)
|
||||
const sourceName = ref('')
|
||||
const sourceCode = ref('')
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
name: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
// 标题
|
||||
const title = computed(() => sourceId.value ? '复制角色' : '批量复制')
|
||||
|
||||
// 表单引用
|
||||
const dialogForm = ref()
|
||||
|
||||
// 验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [
|
||||
{ required: true, message: '请输入角色编码', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '角色编码只能包含字母、数字和下划线', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 打开对话框
|
||||
const open = (data = null) => {
|
||||
if (data && data.id) {
|
||||
// 单个复制
|
||||
sourceId.value = data.id
|
||||
sourceName.value = data.name
|
||||
sourceCode.value = data.code
|
||||
form.name = `${data.name}_副本`
|
||||
form.code = `${data.code}_copy`
|
||||
} else {
|
||||
// 批量复制(暂不支持自定义名称,直接在后端处理)
|
||||
sourceId.value = null
|
||||
sourceName.value = ''
|
||||
sourceCode.value = ''
|
||||
form.name = ''
|
||||
form.code = ''
|
||||
}
|
||||
visible.value = true
|
||||
return {
|
||||
open,
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const close = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
emit('closed')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 处理确定
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
if (sourceId.value) {
|
||||
// 单个复制
|
||||
await dialogForm.value.validate()
|
||||
loading.value = true
|
||||
const res = await authApi.roles.copy.post(sourceId.value, {
|
||||
name: form.name,
|
||||
code: form.code
|
||||
})
|
||||
loading.value = false
|
||||
|
||||
if (res.code === 200) {
|
||||
emit('success')
|
||||
visible.value = false
|
||||
message.success('复制成功')
|
||||
} else {
|
||||
message.error(res.message || '复制失败')
|
||||
}
|
||||
} else {
|
||||
// 批量复制(通过外部传入的 ids)
|
||||
emit('success')
|
||||
visible.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<a-modal title="角色权限设置" :open="visible" :width="600" :destroy-on-close="true" :footer="null" @cancel="handleCancel">
|
||||
<a-modal title="角色权限设置" :open="visible" :width="600" :destroy-on-close="true" @cancel="handleCancel">
|
||||
<div class="permission-content">
|
||||
<div class="permission-tree">
|
||||
<a-tree ref="menuTreeRef" v-model:checkedKeys="checkedPermissionIds" :tree-data="permissionTree"
|
||||
@@ -12,8 +12,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<a-button @click="handleCancel">取 消</a-button>
|
||||
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取 消</a-button>
|
||||
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :footer="null"
|
||||
@cancel="handleCancel">
|
||||
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" @cancel="handleCancel">
|
||||
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }"
|
||||
:wrapper-col="{ span: 18 }">
|
||||
<a-form-item label="角色名称" name="name">
|
||||
@@ -20,8 +19,10 @@
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<template #footer>
|
||||
<a-button @click="handleCancel">取 消</a-button>
|
||||
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取 消</a-button>
|
||||
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
@@ -72,13 +72,28 @@
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handlePermission(record)">权限</a-button>
|
||||
<a-button type="link" size="small" @click="handleCopy(record)">复制</a-button>
|
||||
<a-popconfirm title="确定删除该角色吗?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
<a-dropdown>
|
||||
<a-button type="link" size="small">
|
||||
更多
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleView(record)">
|
||||
<SearchOutlined />查看
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handlePermission(record)">
|
||||
<ImportOutlined />权限
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handleDelete(record)" danger>
|
||||
<DeleteOutlined />删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</scTable>
|
||||
@@ -100,6 +115,9 @@
|
||||
<sc-export v-model:open="dialog.export" title="导出角色" :api="handleExportApi"
|
||||
:default-filename="`角色列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有角色数据"
|
||||
@success="handleExportSuccess" />
|
||||
|
||||
<!-- 复制角色弹窗 -->
|
||||
<copy-dialog v-if="dialog.copy" ref="copyDialogRef" @success="handleCopySuccess" @closed="dialog.copy = false" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -122,6 +140,7 @@ import scImport from '@/components/scImport/index.vue'
|
||||
import scExport from '@/components/scExport/index.vue'
|
||||
import saveDialog from './components/SaveDialog.vue'
|
||||
import permissionDialog from './components/PermissionDialog.vue'
|
||||
import copyDialog from './components/CopyDialog.vue'
|
||||
import authApi from '@/api/auth'
|
||||
import { useTable } from '@/hooks/useTable'
|
||||
|
||||
@@ -160,12 +179,14 @@ const dialog = reactive({
|
||||
save: false,
|
||||
permission: false,
|
||||
import: false,
|
||||
export: false
|
||||
export: false,
|
||||
copy: false
|
||||
})
|
||||
|
||||
// 弹窗引用
|
||||
const saveDialogRef = ref(null)
|
||||
const permissionDialogRef = ref(null)
|
||||
const copyDialogRef = ref(null)
|
||||
|
||||
// 行key
|
||||
const rowKey = 'id'
|
||||
@@ -178,7 +199,7 @@ const columns = [
|
||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 260, align: 'center', slot: 'action', fixed: 'right' }
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 180, align: 'center', slot: 'action', fixed: 'right' }
|
||||
]
|
||||
|
||||
|
||||
@@ -207,19 +228,28 @@ const handleEdit = (record) => {
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const handleDelete = async (record) => {
|
||||
try {
|
||||
const res = await authApi.roles.delete.delete(record.id)
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功')
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定删除该角色吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await authApi.roles.delete.delete(record.id)
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功')
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
@@ -288,8 +318,10 @@ const handleBatchStatus = () => {
|
||||
|
||||
// 复制角色
|
||||
const handleCopy = (record) => {
|
||||
// TODO: 实现复制角色弹窗
|
||||
message.info('复制角色功能开发中...')
|
||||
dialog.copy = true
|
||||
setTimeout(() => {
|
||||
copyDialogRef.value?.open(record)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 批量复制角色
|
||||
@@ -298,8 +330,34 @@ const handleBatchCopy = () => {
|
||||
message.warning('请选择要复制的角色')
|
||||
return
|
||||
}
|
||||
// TODO: 实现批量复制角色弹窗
|
||||
message.info('批量复制角色功能开发中...')
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认批量复制',
|
||||
content: `确定复制选中的 ${selectedRows.value.length} 个角色吗?`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const ids = selectedRows.value.map(item => item.id)
|
||||
const res = await authApi.roles.batchCopy.post({ ids })
|
||||
if (res.code === 200) {
|
||||
message.success('批量复制成功')
|
||||
selectedRows.value = []
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '批量复制失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量复制角色失败:', error)
|
||||
message.error('批量复制失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 复制成功回调
|
||||
const handleCopySuccess = () => {
|
||||
refreshTable()
|
||||
}
|
||||
|
||||
// 权限设置
|
||||
|
||||
804
resources/admin/src/pages/home/components/QuickActions.vue
Normal file
804
resources/admin/src/pages/home/components/QuickActions.vue
Normal file
@@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="quick-actions-component">
|
||||
<a-card :bordered="false" class="quick-actions-card">
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<ThunderboltOutlined class="title-icon" />
|
||||
<span>快速操作</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" size="small" @click="showCustomizeModal = true">
|
||||
<SettingOutlined />
|
||||
自定义
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="actions-container">
|
||||
<transition-group name="list" tag="div" class="actions-grid">
|
||||
<div
|
||||
v-for="action in displayActions"
|
||||
:key="action.path"
|
||||
class="action-item"
|
||||
:class="{ 'no-actions': displayActions.length === 0 }"
|
||||
@click="handleActionClick(action)"
|
||||
>
|
||||
<div class="action-icon-wrapper" :style="{ background: action.gradient || getGradient(action.color) }">
|
||||
<component :is="action.icon || 'AppstoreOutlined'" class="action-icon" />
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<div class="action-title">{{ action.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<div v-if="displayActions.length === 0" class="empty-state">
|
||||
<a-empty description="暂无快速操作" />
|
||||
<a-button type="link" @click="showCustomizeModal = true">
|
||||
<PlusOutlined />
|
||||
添加操作
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 自定义快速操作弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showCustomizeModal"
|
||||
title="自定义快速操作"
|
||||
:width="700"
|
||||
:footer="null"
|
||||
class="customize-modal"
|
||||
>
|
||||
<div class="customize-content">
|
||||
<!-- 已添加的操作 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">已添加的操作</h3>
|
||||
<a-tag :color="tempCustomActions.length >= 8 ? 'error' : 'blue'">
|
||||
{{ tempCustomActions.length }}/8
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="selected-actions">
|
||||
<div class="drag-container">
|
||||
<div
|
||||
v-for="(action, index) in tempCustomActions"
|
||||
:key="action.path"
|
||||
class="action-tag"
|
||||
:style="{ borderColor: action.color }"
|
||||
>
|
||||
<div class="tag-icon" :style="{ background: getGradient(action.color) }">
|
||||
<component :is="action.icon || 'AppstoreOutlined'" />
|
||||
</div>
|
||||
<span class="tag-title">{{ action.title }}</span>
|
||||
<CloseOutlined class="tag-close" @click="handleRemoveAction(action)" />
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-if="tempCustomActions.length === 0" description="从下方列表选择添加" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可选操作 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">可选操作</h3>
|
||||
<a-tag color="cyan">{{ availableMenus.length }}个可用</a-tag>
|
||||
</div>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索菜单名称或路径..."
|
||||
allow-clear
|
||||
size="large"
|
||||
class="search-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input-search>
|
||||
<div class="available-actions">
|
||||
<div
|
||||
v-for="menu in filteredMenus"
|
||||
:key="menu.path"
|
||||
class="menu-item"
|
||||
:class="{ disabled: isAdded(menu) }"
|
||||
@click="handleAddAction(menu)"
|
||||
>
|
||||
<div class="menu-icon" :style="{ background: getGradient(menu.meta?.iconColor) }">
|
||||
<component :is="menu.meta?.icon || 'AppstoreOutlined'" />
|
||||
</div>
|
||||
<div class="menu-info">
|
||||
<div class="menu-title">{{ menu.meta?.title || menu.title || menu.name }}</div>
|
||||
<div class="menu-path">{{ menu.path }}</div>
|
||||
</div>
|
||||
<div class="menu-action">
|
||||
<a-button v-if="!isAdded(menu)" type="primary" size="small" ghost>
|
||||
<PlusOutlined />
|
||||
添加
|
||||
</a-button>
|
||||
<a-tag v-else color="success">
|
||||
<CheckOutlined />
|
||||
已添加
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-if="filteredMenus.length === 0" description="未找到匹配的操作" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="modal-footer">
|
||||
<a-space>
|
||||
<a-button @click="handleCancelCustomize">取消</a-button>
|
||||
<a-button type="primary" @click="handleSaveCustomActions" :loading="saving">
|
||||
<SaveOutlined />
|
||||
保存配置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import {
|
||||
SettingOutlined,
|
||||
PlusOutlined,
|
||||
CheckOutlined,
|
||||
AppstoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
CloseOutlined,
|
||||
SearchOutlined,
|
||||
SaveOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'QuickActions',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const showCustomizeModal = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const saving = ref(false)
|
||||
const customActions = ref([])
|
||||
const tempCustomActions = ref([])
|
||||
|
||||
// 默认快速操作
|
||||
const defaultActions = ref([])
|
||||
|
||||
// 渐变色映射
|
||||
const gradientColors = {
|
||||
'#1890ff': 'linear-gradient(135deg, #1890ff 0%, #36cfc9 100%)',
|
||||
'#52c41a': 'linear-gradient(135deg, #52c41a 0%, #95de64 100%)',
|
||||
'#faad14': 'linear-gradient(135deg, #faad14 0%, #ffc53d 100%)',
|
||||
'#f5222d': 'linear-gradient(135deg, #f5222d 0%, #ff4d4f 100%)',
|
||||
'#722ed1': 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)',
|
||||
'#eb2f96': 'linear-gradient(135deg, #eb2f96 0%, #f759ab 100%)',
|
||||
'#13c2c2': 'linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%)',
|
||||
'#fa8c16': 'linear-gradient(135deg, #fa8c16 0%, #ffa940 100%)',
|
||||
}
|
||||
|
||||
// 获取渐变色
|
||||
const getGradient = (color) => {
|
||||
if (!color) return gradientColors['#1890ff']
|
||||
return gradientColors[color] || gradientColors['#1890ff']
|
||||
}
|
||||
|
||||
// 可用的菜单列表(扁平化所有菜单项)
|
||||
const availableMenus = computed(() => {
|
||||
const menus = userStore.menu || []
|
||||
const flattenMenus = []
|
||||
|
||||
const traverse = (menuList) => {
|
||||
menuList.forEach(menu => {
|
||||
// 兼容不同的菜单数据结构
|
||||
const menuType = menu.type || (menu.path ? 'menu' : '')
|
||||
if (menuType === 'menu' && menu.path && !menu.meta?.hidden) {
|
||||
flattenMenus.push({
|
||||
...menu,
|
||||
title: menu.meta?.title || menu.title || menu.name,
|
||||
icon: menu.meta?.icon || 'AppstoreOutlined'
|
||||
})
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
traverse(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
traverse(menus)
|
||||
return flattenMenus
|
||||
})
|
||||
|
||||
// 显示的快速操作
|
||||
const displayActions = computed(() => {
|
||||
if (customActions.value.length > 0) {
|
||||
return customActions.value
|
||||
}
|
||||
return defaultActions.value
|
||||
})
|
||||
|
||||
// 过滤后的菜单
|
||||
const filteredMenus = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return availableMenus.value.slice(0, 50)
|
||||
}
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
return availableMenus.value.filter(menu => {
|
||||
const title = (menu.meta?.title || menu.title || menu.name || '').toLowerCase()
|
||||
const path = (menu.path || '').toLowerCase()
|
||||
return title.includes(keyword) || path.includes(keyword)
|
||||
})
|
||||
})
|
||||
|
||||
// 检查是否已添加
|
||||
const isAdded = (menu) => {
|
||||
return tempCustomActions.value.some(action => action.path === menu.path)
|
||||
}
|
||||
|
||||
// 加载自定义操作
|
||||
const loadCustomActions = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('quick-actions')
|
||||
if (saved) {
|
||||
customActions.value = JSON.parse(saved)
|
||||
} else {
|
||||
setDefaultActions()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载自定义操作失败:', error)
|
||||
setDefaultActions()
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认快速操作
|
||||
const setDefaultActions = () => {
|
||||
const menus = availableMenus.value
|
||||
const commonPaths = ['/system/users', '/system/roles', '/system/permissions', '/system/config']
|
||||
defaultActions.value = menus
|
||||
.filter(menu => commonPaths.includes(menu.path))
|
||||
.map(menu => ({
|
||||
...menu,
|
||||
color: getRandomColor()
|
||||
}))
|
||||
.slice(0, 8)
|
||||
}
|
||||
|
||||
// 获取随机颜色
|
||||
const getRandomColor = () => {
|
||||
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#eb2f96', '#13c2c2', '#fa8c16']
|
||||
return colors[Math.floor(Math.random() * colors.length)]
|
||||
}
|
||||
|
||||
// 添加操作
|
||||
const handleAddAction = (menu) => {
|
||||
if (tempCustomActions.value.length >= 8) {
|
||||
message.warning('最多添加8个快速操作')
|
||||
return
|
||||
}
|
||||
if (isAdded(menu)) {
|
||||
message.info('该操作已添加')
|
||||
return
|
||||
}
|
||||
tempCustomActions.value.push({
|
||||
path: menu.path,
|
||||
title: menu.title,
|
||||
icon: menu.icon,
|
||||
color: getRandomColor()
|
||||
})
|
||||
message.success(`已添加: ${menu.title}`)
|
||||
}
|
||||
|
||||
// 移除操作
|
||||
const handleRemoveAction = (action) => {
|
||||
const index = tempCustomActions.value.findIndex(item => item.path === action.path)
|
||||
if (index > -1) {
|
||||
tempCustomActions.value.splice(index, 1)
|
||||
message.success(`已移除: ${action.title}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存自定义操作
|
||||
const handleSaveCustomActions = () => {
|
||||
if (tempCustomActions.value.length === 0) {
|
||||
message.warning('请至少添加一个快速操作')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
customActions.value = [...tempCustomActions.value]
|
||||
localStorage.setItem('quick-actions', JSON.stringify(customActions.value))
|
||||
setTimeout(() => {
|
||||
saving.value = false
|
||||
showCustomizeModal.value = false
|
||||
message.success('保存成功,快速操作已更新')
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
saving.value = false
|
||||
message.error('保存失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 取消自定义
|
||||
const handleCancelCustomize = () => {
|
||||
showCustomizeModal.value = false
|
||||
}
|
||||
|
||||
// 点击操作项
|
||||
const handleActionClick = (action) => {
|
||||
if (action.path) {
|
||||
router.push(action.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听弹窗打开
|
||||
watch(showCustomizeModal, (newVal) => {
|
||||
if (newVal) {
|
||||
tempCustomActions.value = [...customActions.value]
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听菜单变化
|
||||
watch(() => userStore.menu, (newMenu) => {
|
||||
if (newMenu && newMenu.length > 0 && customActions.value.length === 0) {
|
||||
setDefaultActions()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
loading.value = true
|
||||
loadCustomActions()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.quick-actions-component {
|
||||
.quick-actions-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
.title-icon {
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
min-height: 200px;
|
||||
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-4px);
|
||||
|
||||
.action-icon-wrapper {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-icon-wrapper {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.action-icon {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.action-content {
|
||||
text-align: center;
|
||||
|
||||
.action-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 40px;
|
||||
|
||||
:deep(.ant-empty) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义弹窗样式
|
||||
:deep(.customize-modal) {
|
||||
.ant-modal-content {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 24px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.customize-content {
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.selected-actions {
|
||||
min-height: 80px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.drag-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
cursor: move;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.tag-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
|
||||
&:hover {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-empty) {
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.ant-input) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.available-actions {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.menu-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-path {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-empty) {
|
||||
margin: 40px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.quick-actions-component {
|
||||
.actions-container {
|
||||
.actions-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
padding: 16px 12px;
|
||||
|
||||
.action-icon-wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
|
||||
.action-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.customize-modal) {
|
||||
.ant-modal {
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
max-height: 60vh;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.customize-content {
|
||||
.selected-actions {
|
||||
.action-tag {
|
||||
padding: 6px 12px;
|
||||
|
||||
.tag-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.available-actions {
|
||||
.menu-item {
|
||||
padding: 12px;
|
||||
|
||||
.menu-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 12px;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-info {
|
||||
.menu-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-path {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
resources/admin/src/pages/home/components/Welcome.vue
Normal file
157
resources/admin/src/pages/home/components/Welcome.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="welcome-component">
|
||||
<a-card>
|
||||
<div class="welcome-content">
|
||||
<div class="greeting">
|
||||
<h2 class="greeting-text">{{ greetingText }}</h2>
|
||||
<p class="welcome-subtitle">{{ userInfo?.username || '管理员' }},欢迎回来!</p>
|
||||
</div>
|
||||
<div class="welcome-info">
|
||||
<a-space direction="vertical" :size="8">
|
||||
<div class="info-item">
|
||||
<CalendarOutlined class="info-icon" />
|
||||
<span>{{ currentDate }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<ClockCircleOutlined class="info-icon" />
|
||||
<span>{{ currentTime }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<EnvironmentOutlined class="info-icon" />
|
||||
<span>{{ weatherText }}</span>
|
||||
</div>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'Welcome',
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
const currentDate = ref('')
|
||||
const currentTime = ref('')
|
||||
const weatherText = ref('天气晴朗,适合工作')
|
||||
let timer = null
|
||||
|
||||
// 根据时间段返回问候语
|
||||
const greetingText = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour >= 5 && hour < 12) {
|
||||
return '早上好'
|
||||
} else if (hour >= 12 && hour < 14) {
|
||||
return '中午好'
|
||||
} else if (hour >= 14 && hour < 18) {
|
||||
return '下午好'
|
||||
} else if (hour >= 18 && hour < 23) {
|
||||
return '晚上好'
|
||||
} else {
|
||||
return '夜深了'
|
||||
}
|
||||
})
|
||||
|
||||
// 更新日期和时间
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentDate.value = now.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
})
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.welcome-component {
|
||||
:deep(.ant-card) {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
|
||||
.greeting {
|
||||
flex: 1;
|
||||
|
||||
.greeting-text {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-info {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
|
||||
.info-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.greeting-text {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,444 +1,50 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-cards">
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card :loading="loading" hoverable>
|
||||
<a-statistic
|
||||
title="用户总数"
|
||||
:value="stats.userCount"
|
||||
:prefix="UserOutlined"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #suffix>
|
||||
<a-tag color="blue">活跃</a-tag>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card :loading="loading" hoverable>
|
||||
<a-statistic
|
||||
title="角色数量"
|
||||
:value="stats.roleCount"
|
||||
:prefix="TeamOutlined"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
>
|
||||
<template #suffix>
|
||||
<a-tag color="green">配置</a-tag>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card :loading="loading" hoverable>
|
||||
<a-statistic
|
||||
title="权限节点"
|
||||
:value="stats.permissionCount"
|
||||
:prefix="KeyOutlined"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
>
|
||||
<template #suffix>
|
||||
<a-tag color="purple">权限</a-tag>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card :loading="loading" hoverable>
|
||||
<a-statistic
|
||||
title="在线用户"
|
||||
:value="stats.onlineUserCount"
|
||||
:prefix="ClockCircleOutlined"
|
||||
:value-style="{ color: '#fa8c16' }"
|
||||
>
|
||||
<template #suffix>
|
||||
<a-tag color="orange">在线</a-tag>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<!-- 欢迎组件 -->
|
||||
<div class="welcome-section">
|
||||
<Welcome />
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<a-row :gutter="16" class="chart-row">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="用户分布" :loading="loading">
|
||||
<div class="chart-container">
|
||||
<div class="pie-chart">
|
||||
<div class="chart-item" v-for="(item, index) in userDistribution" :key="index">
|
||||
<div class="chart-bar" :style="{ width: item.percent + '%' }"></div>
|
||||
<div class="chart-label">{{ item.label }}: {{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="系统状态" :loading="loading">
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="系统版本">{{ systemInfo.version }}</a-descriptions-item>
|
||||
<a-descriptions-item label="PHP 版本">{{ systemInfo.phpVersion }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Laravel 版本">{{ systemInfo.laravelVersion }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务器时间">{{ systemInfo.serverTime }}</a-descriptions-item>
|
||||
<a-descriptions-item label="运行环境">{{ systemInfo.environment }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 快速操作和最近操作 -->
|
||||
<a-row :gutter="16" class="action-row">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="快速操作">
|
||||
<a-row :gutter="[8, 8]">
|
||||
<a-col :xs="12" :sm="8">
|
||||
<a-button type="primary" block @click="goToUser">
|
||||
<UserOutlined />
|
||||
用户管理
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8">
|
||||
<a-button type="primary" block @click="goToRole">
|
||||
<TeamOutlined />
|
||||
角色管理
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8">
|
||||
<a-button type="primary" block @click="goToPermission">
|
||||
<KeyOutlined />
|
||||
权限管理
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8">
|
||||
<a-button type="primary" block @click="goToDepartment">
|
||||
<ApartmentOutlined />
|
||||
部门管理
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8">
|
||||
<a-button type="primary" block @click="goToOnlineUsers">
|
||||
<WifiOutlined />
|
||||
在线用户
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8">
|
||||
<a-button type="primary" block @click="goToConfig">
|
||||
<SettingOutlined />
|
||||
系统配置
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="最近日志" :loading="loading">
|
||||
<a-list :data-source="recentLogs" size="small">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<a-tag :color="getLogColor(item.level)">{{ item.level }}</a-tag>
|
||||
{{ item.message }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ item.created_at }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<!-- 快速操作组件 -->
|
||||
<div class="quick-actions-section">
|
||||
<QuickActions />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import authApi from '@/api/auth'
|
||||
import { message } from 'ant-design-vue'
|
||||
import Welcome from './components/Welcome.vue'
|
||||
import QuickActions from './components/QuickActions.vue'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'HomePage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref({
|
||||
userCount: 0,
|
||||
roleCount: 0,
|
||||
permissionCount: 0,
|
||||
onlineUserCount: 0
|
||||
})
|
||||
|
||||
const userDistribution = ref([
|
||||
{ label: '已激活', value: 0, percent: 0 },
|
||||
{ label: '未激活', value: 0, percent: 0 },
|
||||
{ label: '已禁用', value: 0, percent: 0 }
|
||||
])
|
||||
|
||||
const systemInfo = ref({
|
||||
version: '1.0.0',
|
||||
phpVersion: '8.1.0',
|
||||
laravelVersion: '10.0.0',
|
||||
serverTime: '',
|
||||
environment: 'production'
|
||||
})
|
||||
|
||||
const recentLogs = ref([])
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 获取用户列表统计
|
||||
const userRes = await authApi.users.list.get({ page_size: 1 })
|
||||
if (userRes.code === 200) {
|
||||
stats.value.userCount = userRes.data.total || 0
|
||||
}
|
||||
|
||||
// 获取角色列表统计
|
||||
const roleRes = await authApi.roles.list.get({ page_size: 1 })
|
||||
if (roleRes.code === 200) {
|
||||
stats.value.roleCount = roleRes.data.total || 0
|
||||
}
|
||||
|
||||
// 获取权限列表统计
|
||||
const permRes = await authApi.permissions.list.get({ page_size: 1 })
|
||||
if (permRes.code === 200) {
|
||||
stats.value.permissionCount = permRes.data.total || 0
|
||||
}
|
||||
|
||||
// 获取在线用户统计
|
||||
const onlineRes = await authApi.onlineUsers.count.get()
|
||||
if (onlineRes.code === 200) {
|
||||
stats.value.onlineUserCount = onlineRes.data.count || 0
|
||||
}
|
||||
|
||||
// 获取用户分布数据(这里使用模拟数据,实际项目中应从后端获取)
|
||||
const activeCount = Math.floor(stats.value.userCount * 0.7)
|
||||
const inactiveCount = Math.floor(stats.value.userCount * 0.2)
|
||||
const disabledCount = stats.value.userCount - activeCount - inactiveCount
|
||||
|
||||
userDistribution.value = [
|
||||
{ label: '已激活', value: activeCount, percent: stats.value.userCount > 0 ? Math.round((activeCount / stats.value.userCount) * 100) : 0 },
|
||||
{ label: '未激活', value: inactiveCount, percent: stats.value.userCount > 0 ? Math.round((inactiveCount / stats.value.userCount) * 100) : 0 },
|
||||
{ label: '已禁用', value: disabledCount, percent: stats.value.userCount > 0 ? Math.round((disabledCount / stats.value.userCount) * 100) : 0 }
|
||||
]
|
||||
|
||||
// 更新系统信息
|
||||
systemInfo.value.serverTime = new Date().toLocaleString('zh-CN')
|
||||
systemInfo.value.environment = import.meta.env.VITE_APP_ENV || 'production'
|
||||
|
||||
// 模拟最近日志数据(实际项目中应从后端获取)
|
||||
recentLogs.value = [
|
||||
{ level: 'info', message: '用户登录成功', created_at: formatTime(new Date()) },
|
||||
{ level: 'info', message: '系统配置更新', created_at: formatTime(new Date(Date.now() - 300000)) },
|
||||
{ level: 'warning', message: '检测到异常访问', created_at: formatTime(new Date(Date.now() - 600000)) },
|
||||
{ level: 'error', message: '数据库连接失败', created_at: formatTime(new Date(Date.now() - 900000)) },
|
||||
{ level: 'info', message: '定时任务执行完成', created_at: formatTime(new Date(Date.now() - 1200000)) }
|
||||
]
|
||||
} catch (error) {
|
||||
message.error('获取统计数据失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date) => {
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
|
||||
if (minutes < 1) {
|
||||
return '刚刚'
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}分钟前`
|
||||
} else if (minutes < 1440) {
|
||||
return `${Math.floor(minutes / 60)}小时前`
|
||||
} else {
|
||||
return `${Math.floor(minutes / 1440)}天前`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取日志颜色
|
||||
const getLogColor = (level) => {
|
||||
const colors = {
|
||||
info: 'blue',
|
||||
warning: 'orange',
|
||||
error: 'red',
|
||||
success: 'green'
|
||||
}
|
||||
return colors[level] || 'default'
|
||||
}
|
||||
|
||||
// 路由跳转方法
|
||||
const goToUser = () => {
|
||||
router.push('/system/users')
|
||||
}
|
||||
|
||||
const goToRole = () => {
|
||||
router.push('/system/roles')
|
||||
}
|
||||
|
||||
const goToPermission = () => {
|
||||
router.push('/system/permissions')
|
||||
}
|
||||
|
||||
const goToDepartment = () => {
|
||||
router.push('/system/departments')
|
||||
}
|
||||
|
||||
const goToOnlineUsers = () => {
|
||||
router.push('/system/online-users')
|
||||
}
|
||||
|
||||
const goToConfig = () => {
|
||||
router.push('/system/config')
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
|
||||
// 每分钟更新一次服务器时间
|
||||
setInterval(() => {
|
||||
systemInfo.value.serverTime = new Date().toLocaleString('zh-CN')
|
||||
}, 60000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-container {
|
||||
padding: 20px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 64px);
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 16px;
|
||||
.welcome-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quick-actions-section {
|
||||
:deep(.ant-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.ant-statistic-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix {
|
||||
margin-right: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-row,
|
||||
.action-row {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.ant-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
height: 100%;
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
padding: 20px 0;
|
||||
|
||||
.pie-chart {
|
||||
.chart-item {
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
height: 8px;
|
||||
background: linear-gradient(90deg, #1890ff 0%, #36cfc9 100%);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-btn) {
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.anticon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-list-item) {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 100px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 响应式调整
|
||||
@media (max-width: 768px) {
|
||||
padding: 10px;
|
||||
|
||||
.stats-cards {
|
||||
.welcome-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chart-row,
|
||||
.action-row {
|
||||
margin-bottom: 10px;
|
||||
|
||||
.ant-col {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-btn) {
|
||||
height: 60px;
|
||||
|
||||
.anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,41 +25,48 @@ const loading = ref(false)
|
||||
// 表单初始值
|
||||
const initialValues = computed(() => ({
|
||||
username: props.userInfo.username || '',
|
||||
nickname: props.userInfo.nickname || '',
|
||||
mobile: props.userInfo.mobile || '',
|
||||
real_name: props.userInfo.real_name || '',
|
||||
phone: props.userInfo.phone || '',
|
||||
email: props.userInfo.email || '',
|
||||
gender: props.userInfo.gender || 0,
|
||||
birthday: props.userInfo.birthday || null,
|
||||
bio: props.userInfo.bio || '',
|
||||
}))
|
||||
|
||||
// 表单项配置
|
||||
const formItems = [
|
||||
{ field: 'username', label: '用户名', type: 'input' },
|
||||
{
|
||||
field: 'nickname', label: '昵称', type: 'input', required: true,
|
||||
field: 'username',
|
||||
label: '用户名',
|
||||
type: 'input',
|
||||
rules: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' },
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'phone', label: '手机号', type: 'input',
|
||||
rules: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'email', label: '邮箱', type: 'input',
|
||||
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'gender', label: '性别', type: 'radio',
|
||||
options: [
|
||||
{ label: '男', value: 1 },
|
||||
{ label: '女', value: 2 },
|
||||
{ label: '保密', value: 0 },
|
||||
field: 'real_name',
|
||||
label: '真实姓名',
|
||||
type: 'input',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'phone',
|
||||
label: '手机号',
|
||||
type: 'input',
|
||||
rules: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
label: '邮箱',
|
||||
type: 'input',
|
||||
rules: [
|
||||
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{ field: 'remark', label: '个人简介', type: 'textarea', rows: 4, maxLength: 200, showCount: true, },
|
||||
]
|
||||
|
||||
// 表单提交
|
||||
@@ -67,27 +74,32 @@ const handleFinish = async (values) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 调用更新当前用户信息接口
|
||||
let res = await api.users.edit.post({
|
||||
// 获取当前用户ID
|
||||
const userId = userStore.userInfo?.id
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('用户信息不存在,请重新登录')
|
||||
}
|
||||
|
||||
// 调用更新用户信息接口
|
||||
const res = await api.users.edit.put(userId, {
|
||||
username: values.username,
|
||||
nickname: values.nickname,
|
||||
mobile: values.mobile,
|
||||
real_name: values.real_name,
|
||||
phone: values.phone,
|
||||
email: values.email,
|
||||
gender: values.gender,
|
||||
remark: values.remark,
|
||||
})
|
||||
|
||||
if (!res || res.code !== 1) {
|
||||
if (!res || res.code !== 200) {
|
||||
throw new Error(res.message || '保存失败,请重试')
|
||||
}
|
||||
|
||||
// 重新获取用户信息
|
||||
const response = await api.user.get()
|
||||
const response = await api.me.get()
|
||||
if (response && response.data) {
|
||||
userStore.setUserInfo(response.data)
|
||||
emit('update', response.data)
|
||||
}
|
||||
|
||||
// 通知父组件更新
|
||||
emit('update', values)
|
||||
message.success('保存成功')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败,请重试')
|
||||
|
||||
@@ -6,30 +6,36 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import scForm from '@/components/scForm/index.vue'
|
||||
import api from '@/api/auth'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单初始值
|
||||
const initialValues = {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
}
|
||||
const initialValues = ref({
|
||||
old_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
// 表单项配置
|
||||
const formItems = [
|
||||
{
|
||||
field: 'oldPassword',
|
||||
field: 'old_password',
|
||||
label: '原密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
rules: [
|
||||
{ required: true, message: '请输入原密码', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'newPassword',
|
||||
field: 'password',
|
||||
label: '新密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
@@ -39,15 +45,19 @@ const formItems = [
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'confirmPassword',
|
||||
field: 'password_confirmation',
|
||||
label: '确认密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
dependencies: ['password'],
|
||||
rules: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value !== initialValues.newPassword) {
|
||||
validator: (_, value) => {
|
||||
if (!value || !initialValues.value.password) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (value !== initialValues.value.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
@@ -59,22 +69,47 @@ const formItems = [
|
||||
]
|
||||
|
||||
// 表单提交
|
||||
const handleFinish = (values) => {
|
||||
loading.value = true
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
const handleFinish = async (values) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 调用修改密码接口
|
||||
const res = await api.changePassword.post({
|
||||
old_password: values.old_password,
|
||||
password: values.password,
|
||||
password_confirmation: values.password_confirmation,
|
||||
})
|
||||
|
||||
if (!res || res.code !== 200) {
|
||||
throw new Error(res.message || '密码修改失败,请重试')
|
||||
}
|
||||
|
||||
message.success('密码修改成功,请重新登录')
|
||||
|
||||
// 清除用户信息和token
|
||||
userStore.logout()
|
||||
|
||||
// 延迟跳转到登录页
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
|
||||
emit('success')
|
||||
handleReset()
|
||||
} catch (error) {
|
||||
message.error(error.message || '密码修改失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
initialValues.oldPassword = ''
|
||||
initialValues.newPassword = ''
|
||||
initialValues.confirmPassword = ''
|
||||
initialValues.value = {
|
||||
old_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ const initUserInfo = async () => {
|
||||
userInfo.value = storeUserInfo
|
||||
} else {
|
||||
// 如果 store 中没有用户信息,则从接口获取
|
||||
const response = await api.user.get()
|
||||
const response = await api.me.get()
|
||||
if (response && response.data) {
|
||||
userStore.setUserInfo(response.data)
|
||||
userInfo.value = response.data
|
||||
@@ -140,21 +140,52 @@ const handleAvatarChange = ({ fileList }) => {
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
const handleAvatarUpload = () => {
|
||||
const handleAvatarUpload = async () => {
|
||||
if (avatarFileList.value.length === 0) {
|
||||
message.warning('请先选择头像')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// 模拟上传
|
||||
setTimeout(() => {
|
||||
const file = avatarFileList.value[0]
|
||||
userInfo.value.avatar = URL.createObjectURL(file.originFileObj)
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const file = avatarFileList.value[0].originFileObj
|
||||
|
||||
// 调用上传接口
|
||||
const uploadRes = await api.upload.post(file)
|
||||
if (!uploadRes || uploadRes.code !== 200) {
|
||||
throw new Error(uploadRes.message || '头像上传失败')
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
const userId = userStore.userInfo?.id
|
||||
if (!userId) {
|
||||
throw new Error('用户信息不存在,请重新登录')
|
||||
}
|
||||
|
||||
// 更新用户头像
|
||||
const updateRes = await api.users.edit.put(userId, {
|
||||
avatar: uploadRes.data.url,
|
||||
})
|
||||
|
||||
if (!updateRes || updateRes.code !== 200) {
|
||||
throw new Error(updateRes.message || '头像更新失败')
|
||||
}
|
||||
|
||||
// 重新获取用户信息
|
||||
const response = await api.me.get()
|
||||
if (response && response.data) {
|
||||
userStore.setUserInfo(response.data)
|
||||
userInfo.value = response.data
|
||||
}
|
||||
|
||||
message.success('头像更新成功')
|
||||
showAvatarModal.value = false
|
||||
avatarFileList.value = []
|
||||
} catch (error) {
|
||||
message.error(error.message || '头像上传失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -101,12 +101,12 @@ Route::middleware(['auth.check:admin', 'log.request'])->group(function () {
|
||||
// 系统操作日志
|
||||
Route::prefix('logs')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\System\Admin\Log::class, 'index']);
|
||||
Route::get('/export', [\App\Http\Controllers\System\Admin\Log::class, 'export']);
|
||||
Route::get('/statistics', [\App\Http\Controllers\System\Admin\Log::class, 'getStatistics']);
|
||||
Route::get('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'show']);
|
||||
Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'destroy']);
|
||||
Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Log::class, 'batchDelete']);
|
||||
Route::post('/clear', [\App\Http\Controllers\System\Admin\Log::class, 'clearLogs']);
|
||||
Route::post('/export', [\App\Http\Controllers\System\Admin\Log::class, 'export']);
|
||||
});
|
||||
|
||||
// 数据字典管理
|
||||
|
||||
Reference in New Issue
Block a user