feat: 为多个管理页面添加统一的操作反馈提示,重构设置页面UI

1. 为用户、角色、权限管理页添加增删改成功/失败的全局提示,统一弹窗样式与时长
2. 重构系统设置管理页面的UI,替换element组件为原生html实现,优化布局样式
This commit is contained in:
jayhgq 2026-05-27 16:26:36 +08:00
parent 82cf0192e8
commit 2c78e254e1
6 changed files with 373 additions and 180 deletions

View File

@ -1,18 +1,44 @@
<template>
<fs-page>
<fs-crud ref="crudRef" v-bind="crudBinding" />
<fs-crud ref="crudRef" v-bind="crudBinding" @add-success="onAddSuccess" @edit-success="onEditSuccess" @del-success="onDelSuccess" @add-error="onError" @edit-error="onError" @del-error="onError" />
</fs-page>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import { ElMessage } from "element-plus";
import { createPermissionCrudOptions } from "./permission-crud";
const { crudBinding, crudRef, crudExpose } = useFs({
createCrudOptions: createPermissionCrudOptions,
});
const onAddSuccess = () => {
ElMessage.success({ message: "添加成功", duration: 5000 });
};
const onEditSuccess = () => {
ElMessage.success({ message: "修改成功", duration: 5000 });
};
const onDelSuccess = () => {
ElMessage.success({ message: "删除成功", duration: 5000 });
};
const onError = (error: any) => {
let message = "操作失败";
if (error.response?.data) {
const data = error.response.data;
if (data.message) {
message = data.message;
} else if (data.detail) {
message = typeof data.detail === "string" ? data.detail : "操作失败";
}
}
ElMessage.error({ message, duration: 5000 });
};
onMounted(() => {
crudExpose.doRefresh();
});

View File

@ -1,5 +1,6 @@
import type { CreateCrudOptions } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import { http } from "@/modules/admin/api/http";
import { toSearchParams } from "@/modules/admin/utils/list-query";
@ -58,6 +59,27 @@ export const createPermissionCrudOptions: CreateCrudOptions<PermissionRow> = ()
if (!row) return;
await http.delete(`/permissions/${row.id}`);
},
onSuccess: ({ action }: { action: string }) => {
if (action === "add") {
ElMessage.success({ message: "添加成功", duration: 5000 });
} else if (action === "edit") {
ElMessage.success({ message: "修改成功", duration: 5000 });
} else if (action === "del") {
ElMessage.success({ message: "删除成功", duration: 5000 });
}
},
onError: (error: any) => {
let message = "操作失败";
if (error.response?.data) {
const data = error.response.data;
if (data.message) {
message = data.message;
} else if (data.detail) {
message = typeof data.detail === "string" ? data.detail : "操作失败";
}
}
ElMessage.error({ message, duration: 5000 });
},
},
rowHandle: {
buttons: {

View File

@ -1,5 +1,6 @@
import type { CreateCrudOptions } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import { http } from "@/modules/admin/api/http";
import { toSearchParams } from "@/modules/admin/utils/list-query";
@ -54,6 +55,27 @@ export function buildRoleCrudOptions(h: RoleCrudHandlers): CreateCrudOptions<Rol
if (!row) return;
await http.delete(`/roles/${row.id}`);
},
onSuccess: ({ action }: { action: string }) => {
if (action === "add") {
ElMessage.success({ message: "添加成功", duration: 5000 });
} else if (action === "edit") {
ElMessage.success({ message: "修改成功", duration: 5000 });
} else if (action === "del") {
ElMessage.success({ message: "删除成功", duration: 5000 });
}
},
onError: (error: any) => {
let message = "操作失败";
if (error.response?.data) {
const data = error.response.data;
if (data.message) {
message = data.message;
} else if (data.detail) {
message = typeof data.detail === "string" ? data.detail : "操作失败";
}
}
ElMessage.error({ message, duration: 5000 });
},
},
rowHandle: {
width: 300,

View File

@ -1,146 +1,92 @@
<template>
<fs-page>
<el-card>
<template #header>
<div class="flex items-center justify-between">
<span>系统设置管理</span>
<el-button type="primary" @click="openAddModal">添加设置</el-button>
</div>
</template>
<el-table :data="settings" border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="key" label="设置键名" />
<el-table-column prop="value" label="设置值" :show-overflow-tooltip="true" />
<el-table-column prop="type" label="类型" width="120">
<template #default="scope">
{{ getTypeLabel(scope.row.type) }}
</template>
</el-table-column>
<el-table-column prop="description" label="描述" />
<el-table-column prop="createtime" label="创建时间" width="180" />
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="small" @click="openEditModal(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="setting-container">
<div class="header">
<h2>系统设置管理</h2>
<button class="add-btn" @click="openAddModal">添加设置</button>
</div>
<table class="settings-table">
<thead>
<tr>
<th>ID</th>
<th>设置键名</th>
<th>设置值</th>
<th>描述</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in settings" :key="item.id">
<td>{{ item.id }}</td>
<td>{{ item.key }}</td>
<td class="value-cell">{{ item.value }}</td>
<td>{{ item.description || '-' }}</td>
<td>{{ item.createtime }}</td>
<td class="actions">
<button class="edit-btn" @click="openEditModal(item)">编辑</button>
<button class="delete-btn" @click="handleDelete(item)">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 添加/编辑弹窗 -->
<el-dialog :title="isEdit ? '编辑设置' : '添加设置'" :visible.sync="showModal" width="600px">
<el-form :model="form" label-width="120px">
<el-form-item label="设置键名" prop="key">
<el-input v-model="form.key" placeholder="请输入设置键名" />
</el-form-item>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<h3>{{ isEdit ? '编辑设置' : '添加设置' }}</h3>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="请选择类型">
<el-option label="字符串" value="string" />
<el-option label="图片" value="image" />
<el-option label="下拉列表" value="select" />
<el-option label="布尔值" value="boolean" />
</el-select>
</el-form-item>
<!-- 字符串类型 -->
<el-form-item v-if="form.type === 'string'" label="设置值" prop="value">
<el-input v-model="form.value" type="textarea" :rows="3" placeholder="请输入值" />
</el-form-item>
<!-- 图片类型 -->
<el-form-item v-if="form.type === 'image'" label="设置值" prop="value">
<el-upload
action="#"
accept="image/*"
list-type="picture-card"
:show-file-list="false"
:before-upload="handleImageUpload"
:on-remove="handleImageRemove"
>
<div v-if="!form.value">
<el-icon><UploadIcon /></el-icon>
<div>上传图片</div>
</div>
<img v-else :src="form.value" style="width: 100%; height: 100%" />
</el-upload>
</el-form-item>
<!-- 下拉列表类型 -->
<template v-if="form.type === 'select'">
<el-form-item label="选项配置" prop="options">
<el-input v-model="form.options" type="textarea" :rows="2" placeholder="多个选项用 | 分隔选项1|选项2|选项3" />
</el-form-item>
<el-form-item label="设置值" prop="value">
<el-select v-model="form.value" placeholder="请选择">
<el-option v-for="opt in optionsList" :key="opt" :label="opt" :value="opt" />
</el-select>
</el-form-item>
</template>
<!-- 布尔值类型 -->
<el-form-item v-if="form.type === 'boolean'" label="设置值" prop="value">
<el-switch v-model="form.booleanValue" active-text="开启" inactive-text="关闭" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="请输入描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showModal = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</fs-page>
<div class="form-group">
<label>设置键名 *</label>
<input v-model="formData.key" type="text" placeholder="请输入设置键名" />
</div>
<div class="form-group">
<label>设置值</label>
<textarea v-model="formData.value" rows="3" placeholder="请输入设置值"></textarea>
</div>
<div class="form-group">
<label>描述</label>
<textarea v-model="formData.description" rows="2" placeholder="请输入描述"></textarea>
</div>
<div class="modal-footer">
<button class="cancel-btn" @click="closeModal">取消</button>
<button class="submit-btn" @click="handleSubmit">{{ isEdit ? '保存修改' : '确认添加' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { http } from "@/modules/admin/api/http";
import type { SettingRow } from "./setting-crud";
const settings = ref<SettingRow[]>([]);
interface SettingItem {
id: number;
key: string;
value: string;
description: string;
createtime: string;
}
const settings = ref<SettingItem[]>([]);
const showModal = ref(false);
const isEdit = ref(false);
const form = ref({
const formData = ref({
id: 0,
key: "",
value: "",
type: "string",
options: "",
description: "",
booleanValue: false,
});
const optionsList = computed(() => {
if (!form.value.options) return [];
return form.value.options.split("|").filter(Boolean).map((o) => o.trim());
});
watch(() => form.value.type, (newType) => {
if (newType === "boolean") {
form.value.booleanValue = form.value.value === "true";
}
});
const getTypeLabel = (type: string) => {
const types: Record<string, string> = {
string: "字符串",
image: "图片",
select: "下拉列表",
boolean: "布尔值",
};
return types[type] || type;
};
const loadData = async () => {
try {
const { data } = await http.get<SettingRow[]>("/settings/");
const { data } = await http.get<SettingItem[]>("/settings/");
settings.value = data;
} catch (error) {
ElMessage.error("加载失败");
@ -149,83 +95,59 @@ const loadData = async () => {
const openAddModal = () => {
isEdit.value = false;
form.value = {
formData.value = {
id: 0,
key: "",
value: "",
type: "string",
options: "",
description: "",
booleanValue: false,
};
showModal.value = true;
};
const openEditModal = (row: SettingRow) => {
const openEditModal = (item: SettingItem) => {
isEdit.value = true;
form.value = {
id: row.id,
key: row.key,
value: row.value,
type: row.type,
options: row.options || "",
description: row.description || "",
booleanValue: row.value === "true",
formData.value = {
id: item.id,
key: item.key,
value: item.value,
description: item.description,
};
showModal.value = true;
};
const handleImageUpload = async (file: any) => {
const formData = new FormData();
formData.append("file", file.raw);
try {
const response = await http.post("/settings/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
form.value.value = response.data.url;
} catch (error) {
ElMessage.error("上传失败");
}
return false;
const closeModal = () => {
showModal.value = false;
};
const handleImageRemove = () => {
form.value.value = "";
const handleDelete = async (item: SettingItem) => {
try {
await ElMessageBox.confirm("确定要删除吗?");
await http.delete(`/settings/${item.id}`);
ElMessage.success("删除成功");
await loadData();
} catch (error) {
ElMessage.error("删除失败");
}
};
const handleSubmit = async () => {
if (!form.value.key) {
ElMessage.error("请输入设置键名");
if (!formData.value.key) {
ElMessage.warning("请输入设置键名");
return;
}
if (form.value.type === "boolean") {
form.value.value = form.value.booleanValue ? "true" : "false";
}
try {
if (isEdit.value) {
await http.put(`/settings/${form.value.id}`, form.value);
await http.put(`/settings/${formData.value.id}`, formData.value);
ElMessage.success("修改成功");
} else {
await http.post("/settings/", form.value);
await http.post("/settings/", formData.value);
ElMessage.success("添加成功");
}
showModal.value = false;
closeModal();
await loadData();
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || "操作失败");
}
};
const handleDelete = async (row: SettingRow) => {
try {
await ElMessageBox.confirm("确定要删除吗?");
await http.delete(`/settings/${row.id}`);
ElMessage.success("删除成功");
await loadData();
} catch {
//
} catch (error) {
ElMessage.error("操作失败");
}
};
@ -233,13 +155,168 @@ loadData();
</script>
<style scoped>
.flex {
.setting-container {
padding: 20px;
}
.header {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h2 {
margin: 0;
font-size: 18px;
}
.add-btn {
background: #409eff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.add-btn:hover {
background: #66b1ff;
}
.settings-table {
width: 100%;
border-collapse: collapse;
}
.settings-table th,
.settings-table td {
border: 1px solid #ebeef5;
padding: 12px;
text-align: left;
}
.settings-table th {
background: #f5f7fa;
font-weight: 600;
}
.value-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: 8px;
}
.edit-btn {
background: #67c23a;
color: white;
border: none;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
}
.edit-btn:hover {
background: #85ce61;
}
.delete-btn {
background: #f56c6c;
color: white;
border: none;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #f78989;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 8px;
width: 480px;
}
.modal-content h3 {
margin: 0 0 20px 0;
font-size: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.cancel-btn {
background: #f5f7fa;
color: #606266;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.cancel-btn:hover {
background: #e4e7ed;
}
.submit-btn {
background: #409eff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.submit-btn:hover {
background: #66b1ff;
}
</style>

View File

@ -1,18 +1,55 @@
<template>
<fs-page>
<fs-crud ref="crudRef" v-bind="crudBinding" />
<fs-crud ref="crudRef" v-bind="crudBinding" @add-success="onAddSuccess" @edit-success="onEditSuccess" @del-success="onDelSuccess" @add-error="onError" @edit-error="onError" @del-error="onError" />
</fs-page>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import { ElMessage } from "element-plus";
import { createUserCrudOptions } from "./user-crud";
const { crudBinding, crudRef, crudExpose } = useFs({
createCrudOptions: createUserCrudOptions,
});
const onAddSuccess = () => {
ElMessage.success({ message: "添加成功", duration: 5000 });
};
const onEditSuccess = () => {
ElMessage.success({ message: "修改成功", duration: 5000 });
};
const onDelSuccess = () => {
ElMessage.success({ message: "删除成功", duration: 5000 });
};
const onError = (error: any) => {
let message = "操作失败";
if (error.response?.data) {
const data = error.response.data;
if (data.errors && Array.isArray(data.errors)) {
message = data.errors.map((e: any) => e.message).join("");
} else if (data.message) {
message = data.message;
} else if (data.detail) {
const detail = data.detail;
if (typeof detail === "string") {
if (detail.includes("at least 6 characters")) {
message = "密码长度至少为6个字符";
} else {
message = detail;
}
}
}
} else if (error.message) {
message = error.message;
}
ElMessage.error({ message, duration: 5000 });
};
onMounted(() => {
crudExpose.doRefresh();
});

View File

@ -71,7 +71,7 @@ export const createUserCrudOptions: CreateCrudOptions<UserRow> = () => {
} else if (error.message) {
message = error.message;
}
ElMessage.error(message);
ElMessage.error({ message, duration: 5000 });
throw error;
},
addRequest: async ({ form }) => {
@ -87,6 +87,15 @@ export const createUserCrudOptions: CreateCrudOptions<UserRow> = () => {
if (!row) return;
await http.delete(`/users/${row.id}`);
},
onSuccess: ({ action }: { action: string }) => {
if (action === "add") {
ElMessage.success({ message: "添加成功", duration: 5000 });
} else if (action === "edit") {
ElMessage.success({ message: "修改成功", duration: 5000 });
} else if (action === "del") {
ElMessage.success({ message: "删除成功", duration: 5000 });
}
},
},
rowHandle: {
buttons: {