格式化代码,websocket功能完善

This commit is contained in:
2026-02-18 21:50:05 +08:00
parent 6543e2ccdd
commit b6c133952b
101 changed files with 15829 additions and 10739 deletions

View File

@@ -1,25 +1,56 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="700px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 配置名称 -->
<a-form-item label="配置名称" name="name" required>
<a-input v-model:value="form.name" placeholder="如:网站名称" allow-clear />
<a-input
v-model:value="form.name"
placeholder="如:网站名称"
allow-clear
/>
</a-form-item>
<!-- 配置键名 -->
<a-form-item label="配置键名" name="key" required>
<a-input v-model:value="form.key" placeholder="如site_name" allow-clear :disabled="isEdit" />
<div class="form-tip">系统唯一标识只能包含字母数字下划线</div>
<a-input
v-model:value="form.key"
placeholder="如site_name"
allow-clear
:disabled="isEdit"
/>
<div class="form-tip">
系统唯一标识只能包含字母数字下划线
</div>
</a-form-item>
<!-- 配置分组 -->
<a-form-item label="配置分组" name="group" required>
<a-select v-model:value="form.group" placeholder="请选择配置分组" :options="groupOptions" />
<a-select
v-model:value="form.group"
placeholder="请选择配置分组"
:options="groupOptions"
/>
</a-form-item>
<!-- 配置类型 -->
<a-form-item label="配置类型" name="type" required>
<a-select v-model:value="form.type" placeholder="请选择配置类型">
<a-select
v-model:value="form.type"
placeholder="请选择配置类型"
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
@@ -34,41 +65,88 @@
<a-form-item label="配置值" name="value">
<!-- 字符串/文本 -->
<template v-if="['string', 'text'].includes(form.type)">
<a-input v-if="form.type === 'string'" v-model:value="form.value" placeholder="请输入配置值" allow-clear />
<a-textarea v-else v-model:value="form.value" placeholder="请输入配置值" :rows="4" />
<a-input
v-if="form.type === 'string'"
v-model:value="form.value"
placeholder="请输入配置值"
allow-clear
/>
<a-textarea
v-else
v-model:value="form.value"
placeholder="请输入配置值"
:rows="4"
/>
</template>
<!-- 数字 -->
<a-input-number v-else-if="form.type === 'number'" v-model:value="form.value" :min="0" style="width: 100%" />
<a-input-number
v-else-if="form.type === 'number'"
v-model:value="form.value"
:min="0"
style="width: 100%"
/>
<!-- 布尔值 -->
<a-switch v-else-if="form.type === 'boolean'" v-model:checked="valueChecked" checked-children="启用" un-checked-children="禁用" />
<a-switch
v-else-if="form.type === 'boolean'"
v-model:checked="valueChecked"
checked-children="启用"
un-checked-children="禁用"
/>
<!-- 文件/图片 -->
<a-input v-else-if="['file', 'image'].includes(form.type)" v-model:value="form.value" placeholder="请输入文件地址" />
<a-input
v-else-if="['file', 'image'].includes(form.type)"
v-model:value="form.value"
placeholder="请输入文件地址"
/>
<!-- 选择框 -->
<a-input v-else v-model:value="form.optionsText" placeholder="选项值用逗号分隔选项1,选项2,选项3" />
<a-input
v-else
v-model:value="form.optionsText"
placeholder="选项值用逗号分隔选项1,选项2,选项3"
/>
</a-form-item>
<!-- 默认值 -->
<a-form-item label="默认值" name="default_value">
<a-input v-model:value="form.default_value" placeholder="默认值(可选)" allow-clear />
<a-input
v-model:value="form.default_value"
placeholder="默认值(可选)"
allow-clear
/>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<a-switch v-model:checked="statusChecked" checked-children="启用" un-checked-children="禁用" />
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入配置描述" :rows="3" maxlength="200" show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入配置描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
@@ -76,215 +154,228 @@
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import systemApi from '@/api/system'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
import { useDictionaryStore } from "@/stores/modules/dictionary";
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑配置' : '新增配置'
})
return isEdit.value ? "编辑配置" : "新增配置";
});
// 配置分组选项
const groupOptions = ref([])
const groupOptions = ref([]);
// 表单数据
const form = ref({
id: '',
name: '',
key: '',
group: '',
type: 'string',
value: '',
default_value: '',
description: '',
id: "",
name: "",
key: "",
group: "",
type: "string",
value: "",
default_value: "",
description: "",
status: true,
sort: 0,
options: []
})
options: [],
});
// 选项文本(用于选择框类型)
const optionsText = ref('')
const optionsText = ref("");
// 计算属性:状态开关
const statusChecked = computed({
get: () => form.value.status === true,
set: (val) => {
form.value.status = val ? true : false
}
})
form.value.status = val ? true : false;
},
});
// 计算属性:值开关(布尔类型)
const valueChecked = computed({
get: () => form.value.value === '1' || form.value.value === true,
get: () => form.value.value === "1" || form.value.value === true,
set: (val) => {
form.value.value = val ? '1' : '0'
}
})
form.value.value = val ? "1" : "0";
},
});
// 初始化字典 store
const dictionaryStore = useDictionaryStore()
const dictionaryStore = useDictionaryStore();
// 加载配置分组
const loadGroups = async () => {
const groups = await dictionaryStore.getDictionary('config_group')
groupOptions.value = groups.map(item => ({
const groups = await dictionaryStore.getDictionary("config_group");
groupOptions.value = groups.map((item) => ({
label: item.label,
value: item.value
}))
}
value: item.value,
}));
};
// 验证规则
const rules = {
name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入配置名称", trigger: "blur" },
{ min: 2, max: 50, message: "长度在 2 到 50 个字符", trigger: "blur" },
],
key: [
{ required: true, message: '请输入配置键名', trigger: 'blur' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: '格式不正确', trigger: 'blur' }
{ required: true, message: "请输入配置键名", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "格式不正确",
trigger: "blur",
},
],
group: [
{ required: true, message: '请选择配置分组', trigger: 'change' }
],
type: [
{ required: true, message: '请选择配置类型', trigger: 'change' }
]
}
group: [{ required: true, message: "请选择配置分组", trigger: "change" }],
type: [{ required: true, message: "请选择配置类型", trigger: "change" }],
};
// 重置表单
const resetForm = () => {
form.value = {
id: '',
name: '',
key: '',
group: '',
type: 'string',
value: '',
default_value: '',
description: '',
id: "",
name: "",
key: "",
group: "",
type: "string",
value: "",
default_value: "",
description: "",
status: true,
sort: 0,
options: []
}
optionsText.value = ''
formRef.value?.clearValidate()
}
options: [],
};
optionsText.value = "";
formRef.value?.clearValidate();
};
// 设置数据
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
name: data.name || '',
key: data.key || '',
group: data.group || '',
type: data.type || 'string',
value: data.value || '',
default_value: data.default_value || '',
description: data.description || '',
id: data.id || "",
name: data.name || "",
key: data.key || "",
group: data.group || "",
type: data.type || "string",
value: data.value || "",
default_value: data.default_value || "",
description: data.description || "",
status: data.status !== undefined ? data.status : true,
sort: data.sort !== undefined ? data.sort : 0,
options: data.options || []
}
options: data.options || [],
};
// 如果是选择框类型,设置选项文本
if (data.type === 'select' && data.options) {
optionsText.value = Array.isArray(data.options) ? data.options.join(',') : data.options
if (data.type === "select" && data.options) {
optionsText.value = Array.isArray(data.options)
? data.options.join(",")
: data.options;
}
}
}
};
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
// 处理选项
if (form.value.type === 'select') {
form.value.options = optionsText.value ? optionsText.value.split(',').map(s => s.trim()) : []
if (form.value.type === "select") {
form.value.options = optionsText.value
? optionsText.value.split(",").map((s) => s.trim())
: [];
}
// 处理值
let submitValue = form.value.value
if (form.value.type === 'boolean') {
submitValue = valueChecked.value ? '1' : '0'
let submitValue = form.value.value;
if (form.value.type === "boolean") {
submitValue = valueChecked.value ? "1" : "0";
}
const submitData = {
...form.value,
value: submitValue
}
value: submitValue,
};
let res = {}
let res = {};
if (isEdit.value) {
res = await systemApi.config.update.put(form.value.id, submitData)
res = await systemApi.config.update.put(form.value.id, submitData);
} else {
res = await systemApi.config.add.post(submitData)
res = await systemApi.config.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// 取消
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// 监听 visible 变化
watch(() => props.visible, (newVal) => {
if (newVal) {
loadGroups()
if (props.record) {
setData(props.record)
} else {
resetForm()
watch(
() => props.visible,
(newVal) => {
if (newVal) {
loadGroups();
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">

View File

@@ -1,59 +1,141 @@
<template>
<div class="config-form-container">
<a-form ref="formRef" :model="formData" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<!-- 根据配置类型动态渲染表单项 -->
<template v-for="config in configs" :key="config.id">
<!-- 字符串类型 -->
<a-form-item v-if="config.type === 'string'" :label="config.name" :name="config.key">
<a-input v-model:value="formData[config.key]" :placeholder="`请输入${config.name}`" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-if="config.type === 'string'"
:label="config.name"
:name="config.key"
>
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}`"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 数字类型 -->
<a-form-item v-else-if="config.type === 'number'" :label="config.name" :name="config.key">
<a-input-number v-model:value="formData[config.key]" :min="config.options?.min || 0"
:max="config.options?.max || 100000" style="width: 100%" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'number'"
:label="config.name"
:name="config.key"
>
<a-input-number
v-model:value="formData[config.key]"
:min="config.options?.min || 0"
:max="config.options?.max || 100000"
style="width: 100%"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 布尔类型 -->
<a-form-item v-else-if="config.type === 'boolean'" :label="config.name" :name="config.key">
<a-switch v-model:checked="formData[config.key]" checked-children="启用" un-checked-children="禁用" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'boolean'"
:label="config.name"
:name="config.key"
>
<a-switch
v-model:checked="formData[config.key]"
checked-children="启用"
un-checked-children="禁用"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 文件类型 -->
<a-form-item v-else-if="config.type === 'file'" :label="config.name" :name="config.key">
<a-input v-model:value="formData[config.key]" :placeholder="`请输入${config.name}地址`">
<a-form-item
v-else-if="config.type === 'file'"
:label="config.name"
:name="config.key"
>
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}地址`"
>
<template #suffix>
<a-button type="link" size="small" @click="handleUpload(config)">上传</a-button>
<a-button
type="link"
size="small"
@click="handleUpload(config)"
>上传</a-button
>
</template>
</a-input>
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 图片类型 -->
<a-form-item v-else-if="config.type === 'image'" :label="config.name" :name="config.key">
<a-form-item
v-else-if="config.type === 'image'"
:label="config.name"
:name="config.key"
>
<div class="image-upload-wrapper">
<a-input v-model:value="formData[config.key]" :placeholder="`请输入${config.name}地址`" />
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}地址`"
/>
<div v-if="formData[config.key]" class="image-preview">
<img :src="formData[config.key]" :alt="config.name" />
<img
:src="formData[config.key]"
:alt="config.name"
/>
</div>
<a-button type="link" @click="handleUpload(config)">选择图片</a-button>
<a-button type="link" @click="handleUpload(config)"
>选择图片</a-button
>
</div>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
</a-form-item>
<!-- 文本类型 -->
<a-form-item v-else-if="config.type === 'text'" :label="config.name" :name="config.key">
<a-textarea v-model:value="formData[config.key]" :rows="4" :placeholder="`请输入${config.name}`" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'text'"
:label="config.name"
:name="config.key"
>
<a-textarea
v-model:value="formData[config.key]"
:rows="4"
:placeholder="`请输入${config.name}`"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 选择框类型 -->
<a-form-item v-else-if="config.type === 'select'" :label="config.name" :name="config.key">
<a-select v-model:value="formData[config.key]" :placeholder="`请选择${config.name}`" :options="config.options || []" />
<div v-if="config.description" class="form-tip">{{ config.description }}</div>
<a-form-item
v-else-if="config.type === 'select'"
:label="config.name"
:name="config.key"
>
<a-select
v-model:value="formData[config.key]"
:placeholder="`请选择${config.name}`"
:options="config.options || []"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
</template>
</a-form>
@@ -74,102 +156,105 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SaveOutlined, RedoOutlined } from '@ant-design/icons-vue'
import systemApi from '@/api/system'
import { ref, reactive, onMounted } from "vue";
import { message } from "ant-design-vue";
import { SaveOutlined, RedoOutlined } from "@ant-design/icons-vue";
import systemApi from "@/api/system";
const props = defineProps({
group: {
type: String,
required: true
}
})
required: true,
},
});
const emit = defineEmits(['refresh'])
const emit = defineEmits(["refresh"]);
const formRef = ref(null)
const saving = ref(false)
const configs = ref([])
const formData = reactive({})
const formRef = ref(null);
const saving = ref(false);
const configs = ref([]);
const formData = reactive({});
// 加载配置
const loadConfigs = async () => {
try {
const res = await systemApi.config.list.get({ group: props.group })
const res = await systemApi.config.list.get({ group: props.group });
if (res.code === 200) {
configs.value = res.data.list || []
configs.value = res.data.list || [];
// 初始化表单数据
configs.value.forEach(config => {
configs.value.forEach((config) => {
// 根据类型转换值
if (config.type === 'boolean') {
formData[config.key] = config.value === '1' || config.value === true
} else if (config.type === 'number') {
formData[config.key] = Number(config.value) || 0
if (config.type === "boolean") {
formData[config.key] =
config.value === "1" || config.value === true;
} else if (config.type === "number") {
formData[config.key] = Number(config.value) || 0;
} else {
formData[config.key] = config.value || ''
formData[config.key] = config.value || "";
}
})
});
}
} catch (error) {
console.error('加载配置失败:', error)
message.error('加载配置失败')
console.error("加载配置失败:", error);
message.error("加载配置失败");
}
}
};
// 保存配置
const handleSave = async () => {
try {
saving.value = true
saving.value = true;
// 准备提交数据
const updates = []
configs.value.forEach(config => {
let value = formData[config.key]
const updates = [];
configs.value.forEach((config) => {
let value = formData[config.key];
// 根据类型转换值
if (config.type === 'boolean') {
value = value ? '1' : '0'
} else if (config.type === 'number') {
value = String(value)
if (config.type === "boolean") {
value = value ? "1" : "0";
} else if (config.type === "number") {
value = String(value);
}
updates.push({
key: config.key,
value: value
})
})
value: value,
});
});
const res = await systemApi.config.batchUpdate.post({ configs: updates })
const res = await systemApi.config.batchUpdate.post({
configs: updates,
});
if (res.code === 200) {
message.success('保存成功')
emit('refresh')
message.success("保存成功");
emit("refresh");
} else {
message.error(res.message || '保存失败')
message.error(res.message || "保存失败");
}
} catch (error) {
console.error('保存配置失败:', error)
message.error('保存失败')
console.error("保存配置失败:", error);
message.error("保存失败");
} finally {
saving.value = false
saving.value = false;
}
}
};
// 重置配置
const handleReset = () => {
loadConfigs()
message.info('已重置')
}
loadConfigs();
message.info("已重置");
};
// 上传处理(占位)
const handleUpload = (config) => {
message.info(`${config.name}上传功能待实现`)
}
message.info(`${config.name}上传功能待实现`);
};
onMounted(() => {
loadConfigs()
})
loadConfigs();
});
</script>
<style scoped lang="scss">

View File

@@ -7,9 +7,19 @@
@ok="handleOk"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formData" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="配置分组" name="group">
<a-select v-model:value="formData.group" placeholder="请选择配置分组" allow-show-search>
<a-select
v-model:value="formData.group"
placeholder="请选择配置分组"
allow-show-search
>
<a-select-option value="system">系统设置</a-select-option>
<a-select-option value="site">站点配置</a-select-option>
<a-select-option value="upload">上传配置</a-select-option>
@@ -18,24 +28,45 @@
<a-select-option value="other">其他</a-select-option>
<template #notFoundContent>
<div style="text-align: center">
<a-input v-model:value="customGroup" placeholder="输入新分组" style="margin-bottom: 8px" />
<a-button type="primary" size="small" @click="handleAddCustomGroup">添加</a-button>
<a-input
v-model:value="customGroup"
placeholder="输入新分组"
style="margin-bottom: 8px"
/>
<a-button
type="primary"
size="small"
@click="handleAddCustomGroup"
>添加</a-button
>
</div>
</template>
</a-select>
</a-form-item>
<a-form-item label="配置键" name="key">
<a-input v-model:value="formData.key" placeholder="请输入配置键site_name" :disabled="isEdit && formData.is_system" />
<div style="color: #999; font-size: 12px; margin-top: 4px">唯一标识建议使用英文下划线命名</div>
<a-input
v-model:value="formData.key"
placeholder="请输入配置键site_name"
:disabled="isEdit && formData.is_system"
/>
<div style="color: #999; font-size: 12px; margin-top: 4px">
唯一标识建议使用英文下划线命名
</div>
</a-form-item>
<a-form-item label="配置名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入配置名称" />
<a-input
v-model:value="formData.name"
placeholder="请输入配置名称"
/>
</a-form-item>
<a-form-item label="数据类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择数据类型">
<a-select
v-model:value="formData.type"
placeholder="请选择数据类型"
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="text">文本</a-select-option>
<a-select-option value="number">数字</a-select-option>
@@ -99,7 +130,11 @@
placeholder="请选择配置值"
allow-clear
>
<a-select-option v-for="opt in parsedOptions" :key="opt.value" :value="opt.value">
<a-select-option
v-for="opt in parsedOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
@@ -108,7 +143,11 @@
v-else-if="formData.type === 'checkbox'"
v-model:value="formData.value"
>
<a-checkbox v-for="opt in parsedOptions" :key="opt.value" :value="opt.value">
<a-checkbox
v-for="opt in parsedOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
@@ -132,7 +171,12 @@
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" placeholder="请输入排序" :min="0" style="width: 100%" />
<a-input-number
v-model:value="formData.sort"
placeholder="请输入排序"
:min="0"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
@@ -143,113 +187,117 @@
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea v-model:value="formData.description" placeholder="请输入配置描述" :rows="2" />
<a-textarea
v-model:value="formData.description"
placeholder="请输入配置描述"
:rows="2"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import systemApi from '@/api/system'
import { ref, reactive, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
const props = defineProps({
visible: Boolean,
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null)
const loading = ref(false)
const customGroup = ref('')
const optionsText = ref('')
const formRef = ref(null);
const loading = ref(false);
const customGroup = ref("");
const optionsText = ref("");
// 是否编辑模式
const isEdit = computed(() => !!props.record?.id)
const isEdit = computed(() => !!props.record?.id);
// 表单数据
const formData = reactive({
group: 'system',
key: '',
name: '',
type: 'string',
value: '',
group: "system",
key: "",
name: "",
type: "string",
value: "",
options: null,
sort: 0,
status: 1,
description: '',
is_system: false
})
description: "",
is_system: false,
});
// 表单验证规则
const rules = {
group: [{ required: true, message: '请选择配置分组', trigger: 'change' }],
key: [{ required: true, message: '请输入配置键', trigger: 'blur' }],
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择数据类型', trigger: 'change' }],
value: [{ required: true, message: '请输入配置值', trigger: 'change' }]
}
group: [{ required: true, message: "请选择配置分组", trigger: "change" }],
key: [{ required: true, message: "请输入配置键", trigger: "blur" }],
name: [{ required: true, message: "请输入配置名称", trigger: "blur" }],
type: [{ required: true, message: "请选择数据类型", trigger: "change" }],
value: [{ required: true, message: "请输入配置值", trigger: "change" }],
};
// 解析选项
const parsedOptions = computed(() => {
if (!optionsText.value) return []
if (!optionsText.value) return [];
try {
return optionsText.value
.split('\n')
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const [label, value] = line.split(':').map((s) => s.trim())
return { label, value: value || label }
})
const [label, value] = line.split(":").map((s) => s.trim());
return { label, value: value || label };
});
} catch {
return []
return [];
}
})
});
// 根据类型解析值
const parseValueByType = (value, type) => {
if (!value) return value
if (!value) return value;
switch (type) {
case 'number':
return Number(value)
case 'boolean':
return value === 'true' || value === true || value === 1
case 'checkbox':
if (typeof value === 'string') {
case "number":
return Number(value);
case "boolean":
return value === "true" || value === true || value === 1;
case "checkbox":
if (typeof value === "string") {
try {
return JSON.parse(value)
return JSON.parse(value);
} catch {
return value.split(',')
return value.split(",");
}
}
return value
return value;
default:
return value
return value;
}
}
};
// 重置表单
const resetForm = () => {
Object.assign(formData, {
group: 'system',
key: '',
name: '',
type: 'string',
value: '',
group: "system",
key: "",
name: "",
type: "string",
value: "",
options: null,
sort: 0,
status: 1,
description: '',
is_system: false
})
optionsText.value = ''
formRef.value?.clearValidate()
}
description: "",
is_system: false,
});
optionsText.value = "";
formRef.value?.clearValidate();
};
// 监听record变化初始化表单
watch(
@@ -258,102 +306,108 @@ watch(
if (newRecord) {
// 编辑模式
Object.assign(formData, {
group: newRecord.group || 'system',
key: newRecord.key || '',
name: newRecord.name || '',
type: newRecord.type || 'string',
group: newRecord.group || "system",
key: newRecord.key || "",
name: newRecord.name || "",
type: newRecord.type || "string",
value: parseValueByType(newRecord.value, newRecord.type),
options: newRecord.options,
sort: newRecord.sort || 0,
status: newRecord.status ?? 1,
description: newRecord.description || '',
is_system: newRecord.is_system || false
})
description: newRecord.description || "",
is_system: newRecord.is_system || false,
});
// 解析选项
if (newRecord.options && typeof newRecord.options === 'object') {
if (newRecord.options && typeof newRecord.options === "object") {
optionsText.value = newRecord.options
.map((opt) => {
if (typeof opt === 'object') {
return `${opt.label}:${opt.value}`
if (typeof opt === "object") {
return `${opt.label}:${opt.value}`;
}
return opt
return opt;
})
.join('\n')
.join("\n");
}
} else {
// 新增模式,重置表单
resetForm()
resetForm();
}
},
{ immediate: true }
)
{ immediate: true },
);
// 添加自定义分组
const handleAddCustomGroup = () => {
if (!customGroup.value) {
message.warning('请输入分组名称')
return
message.warning("请输入分组名称");
return;
}
formData.group = customGroup.value
customGroup.value = ''
message.success('分组已添加')
}
formData.group = customGroup.value;
customGroup.value = "";
message.success("分组已添加");
};
// 处理确定按钮
const handleOk = async () => {
try {
await formRef.value.validate()
loading.value = true
await formRef.value.validate();
loading.value = true;
const submitData = { ...formData }
const submitData = { ...formData };
// 处理选项
if (['select', 'radio', 'checkbox'].includes(formData.type) && optionsText.value) {
submitData.options = parsedOptions.value
if (
["select", "radio", "checkbox"].includes(formData.type) &&
optionsText.value
) {
submitData.options = parsedOptions.value;
}
// 处理值类型转换
if (formData.type === 'number') {
submitData.value = Number(submitData.value)
} else if (formData.type === 'boolean') {
submitData.value = submitData.value ? '1' : '0'
} else if (formData.type === 'checkbox' && Array.isArray(submitData.value)) {
submitData.value = submitData.value.join(',')
} else if (formData.type === 'json') {
if (formData.type === "number") {
submitData.value = Number(submitData.value);
} else if (formData.type === "boolean") {
submitData.value = submitData.value ? "1" : "0";
} else if (
formData.type === "checkbox" &&
Array.isArray(submitData.value)
) {
submitData.value = submitData.value.join(",");
} else if (formData.type === "json") {
// 验证JSON格式
try {
JSON.parse(submitData.value)
JSON.parse(submitData.value);
} catch (e) {
throw new Error('JSON格式不正确')
throw new Error("JSON格式不正确");
}
}
if (isEdit.value) {
await systemApi.configs.edit.put(formData.id, submitData)
message.success('更新成功')
await systemApi.configs.edit.put(formData.id, submitData);
message.success("更新成功");
} else {
await systemApi.configs.add.post(submitData)
message.success('创建成功')
await systemApi.configs.add.post(submitData);
message.success("创建成功");
}
emit('success')
emit('update:visible', false)
resetForm()
emit("success");
emit("update:visible", false);
resetForm();
} catch (error) {
if (error.message) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
} finally {
loading.value = false
loading.value = false;
}
}
};
// 处理取消按钮
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
</script>
<style scoped lang="scss">

View File

@@ -70,27 +70,57 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">{{ getTypeText(record.type) }}</a-tag>
<a-tag :color="getTypeColor(record.type)">{{
getTypeText(record.type)
}}</a-tag>
</template>
<template v-if="column.key === 'value'">
<span v-if="['string', 'number', 'boolean'].includes(record.type)" class="value-text">
<span
v-if="
['string', 'number', 'boolean'].includes(
record.type,
)
"
class="value-text"
>
{{ formatValue(record.value, record.type) }}
</span>
<span v-else-if="record.type === 'file'" class="file-value">
<span
v-else-if="record.type === 'file'"
class="file-value"
>
<file-outlined />
{{ record.value }}
</span>
<a v-else-if="record.type === 'image'" :href="record.value" target="_blank" class="image-value">
<img :src="record.value" alt="预览" class="config-image" />
<a
v-else-if="record.type === 'image'"
:href="record.value"
target="_blank"
class="image-value"
>
<img
:src="record.value"
alt="预览"
class="config-image"
/>
</a>
<span v-else class="json-value">{{ record.value }}</span>
<span v-else class="json-value">{{
record.value
}}</span>
</template>
<template v-if="column.key === 'status'">
<a-badge :status="record.status ? 'success' : 'default'" :text="record.status ? '启用' : '禁用'" />
<a-badge
:status="record.status ? 'success' : 'default'"
:text="record.status ? '启用' : '禁用'"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<edit-outlined />
编辑
</a-button>
@@ -111,13 +141,17 @@
</div>
<!-- 新增/编辑弹窗 -->
<SaveDialog v-model:visible="showSaveDialog" :record="currentRecord" @success="handleSaveSuccess" />
<SaveDialog
v-model:visible="showSaveDialog"
:record="currentRecord"
@success="handleSaveSuccess"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive, onMounted, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -127,256 +161,272 @@ import {
CheckOutlined,
StopOutlined,
EditOutlined,
FileOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import SaveDialog from './components/SaveDialog.vue'
FileOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import SaveDialog from "./components/SaveDialog.vue";
// 表格引用
const tableRef = ref(null)
const tableRef = ref(null);
// 搜索表单
const searchForm = reactive({
keyword: '',
group: undefined
})
keyword: "",
group: undefined,
});
// 分组选项
const groupOptions = ref([])
const groupOptions = ref([]);
// 当前记录
const currentRecord = ref(null)
const currentRecord = ref(null);
// 显示新增/编辑弹窗
const showSaveDialog = ref(false)
const showSaveDialog = ref(false);
// 使用 useTable Hook
const { tableData, loading, pagination, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } =
useTable({
api: systemApi.configs.list.get,
searchForm,
needPagination: true
})
const {
tableData,
loading,
pagination,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
api: systemApi.configs.list.get,
searchForm,
needPagination: true,
});
// 表格列配置
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: '配置分组',
dataIndex: 'group',
key: 'group',
width: 120
},
{
title: '配置键',
dataIndex: 'key',
key: 'key',
width: 200,
ellipsis: true
},
{
title: '配置名称',
dataIndex: 'name',
key: 'name',
width: 180
},
{
title: '配置值',
dataIndex: 'value',
key: 'value',
ellipsis: true,
width: 250
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
align: 'center'
},
{
title: '排序',
dataIndex: 'sort',
key: 'sort',
title: "ID",
dataIndex: "id",
key: "id",
width: 80,
align: 'center'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
title: "配置分组",
dataIndex: "group",
key: "group",
width: 120,
},
{
title: "配置键",
dataIndex: "key",
key: "key",
width: 200,
ellipsis: true,
},
{
title: "配置名称",
dataIndex: "name",
key: "name",
width: 180,
},
{
title: "配置值",
dataIndex: "value",
key: "value",
ellipsis: true,
width: 250,
},
{
title: "类型",
dataIndex: "type",
key: "type",
width: 100,
align: 'center'
align: "center",
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true
title: "排序",
dataIndex: "sort",
key: "sort",
width: 80,
align: "center",
},
{
title: '操作',
key: 'action',
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "操作",
key: "action",
width: 150,
fixed: 'right'
}
]
fixed: "right",
},
];
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
string: 'blue',
text: 'cyan',
number: 'green',
boolean: 'orange',
select: 'purple',
radio: 'purple',
checkbox: 'purple',
file: 'pink',
json: 'geekblue'
}
return colors[type] || 'default'
}
string: "blue",
text: "cyan",
number: "green",
boolean: "orange",
select: "purple",
radio: "purple",
checkbox: "purple",
file: "pink",
json: "geekblue",
};
return colors[type] || "default";
};
// 获取类型文本
const getTypeText = (type) => {
const texts = {
string: '字符串',
text: '文本',
number: '数字',
boolean: '布尔值',
select: '下拉框',
radio: '单选框',
checkbox: '多选框',
file: '文件',
json: 'JSON'
}
return texts[type] || type
}
string: "字符串",
text: "文本",
number: "数字",
boolean: "布尔值",
select: "下拉框",
radio: "单选框",
checkbox: "多选框",
file: "文件",
json: "JSON",
};
return texts[type] || type;
};
// 格式化值
const formatValue = (value, type) => {
if (type === 'boolean') {
return value === 'true' || value === true ? '是' : '否'
if (type === "boolean") {
return value === "true" || value === true ? "" : "";
}
if (type === 'number') {
return Number(value)
if (type === "number") {
return Number(value);
}
return value
}
return value;
};
// 获取分组列表
const loadGroups = async () => {
try {
const res = await systemApi.configs.groups.get()
groupOptions.value = res.data.map((item) => ({ label: item, value: item }))
const res = await systemApi.configs.groups.get();
groupOptions.value = res.data.map((item) => ({
label: item,
value: item,
}));
} catch (error) {
console.error('获取分组列表失败:', error)
console.error("获取分组列表失败:", error);
}
}
};
// 新增
const handleAdd = () => {
currentRecord.value = null
showSaveDialog.value = true
}
currentRecord.value = null;
showSaveDialog.value = true;
};
// 编辑
const handleEdit = (record) => {
currentRecord.value = { ...record }
showSaveDialog.value = true
}
currentRecord.value = { ...record };
showSaveDialog.value = true;
};
// 删除
const handleDelete = (record) => {
if (record.is_system) {
message.warning('系统配置不能删除')
return
message.warning("系统配置不能删除");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除配置"${record.name}"吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.delete.delete(record.id)
message.success('删除成功')
refreshTable()
await systemApi.configs.delete.delete(record.id);
message.success("删除成功");
refreshTable();
} catch (error) {
message.error(error.message || '删除失败')
message.error(error.message || "删除失败");
}
}
})
}
},
});
};
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的配置')
return
message.warning("请先选择要删除的配置");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.batchDelete.post({ ids: selectedRowKeys })
message.success('批量删除成功')
rowSelection.selectedRowKeys = []
refreshTable()
await systemApi.configs.batchDelete.post({
ids: selectedRowKeys,
});
message.success("批量删除成功");
rowSelection.selectedRowKeys = [];
refreshTable();
} catch (error) {
message.error(error.message || '批量删除失败')
message.error(error.message || "批量删除失败");
}
}
})
}
},
});
};
// 批量更新状态
const handleBatchStatus = (status) => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要操作的配置')
return
message.warning("请先选择要操作的配置");
return;
}
Modal.confirm({
title: status === 1 ? '确认启用' : '确认禁用',
content: `确定要${status === 1 ? '启用' : '禁用'}选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: '确定',
cancelText: '取消',
title: status === 1 ? "确认启用" : "确认禁用",
content: `确定要${status === 1 ? "启用" : "禁用"}选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.batchStatus.post({ ids: selectedRowKeys, status })
message.success(`${status === 1 ? '启用' : '禁用'}成功`)
rowSelection.selectedRowKeys = []
refreshTable()
await systemApi.configs.batchStatus.post({
ids: selectedRowKeys,
status,
});
message.success(`${status === 1 ? "启用" : "禁用"}成功`);
rowSelection.selectedRowKeys = [];
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 保存成功
const handleSaveSuccess = () => {
showSaveDialog.value = false
refreshTable()
}
showSaveDialog.value = false;
refreshTable();
};
// 初始化
onMounted(() => {
loadGroups()
})
loadGroups();
});
</script>
<style scoped lang="scss">

View File

@@ -1,43 +1,91 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="600px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 字典名称 -->
<a-form-item label="字典名称" name="name" required>
<a-input v-model:value="form.name" placeholder="如:用户状态" allow-clear maxlength="50" show-count />
<a-input
v-model:value="form.name"
placeholder="如:用户状态"
allow-clear
maxlength="50"
show-count
/>
</a-form-item>
<!-- 字典编码 -->
<a-form-item label="字典编码" name="code" required>
<a-input v-model:value="form.code" placeholder="如user_status" allow-clear :disabled="isEdit" />
<div class="form-tip">系统唯一标识只能包含字母数字下划线且必须以字母开头</div>
<a-input
v-model:value="form.code"
placeholder="如user_status"
allow-clear
:disabled="isEdit"
/>
<div class="form-tip">
系统唯一标识只能包含字母数字下划线且必须以字母开头
</div>
</a-form-item>
<!-- 值类型 -->
<a-form-item label="值类型" name="value_type" required>
<a-select v-model:value="form.value_type" placeholder="请选择值类型" allow-clear>
<a-select
v-model:value="form.value_type"
placeholder="请选择值类型"
allow-clear
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
<a-select-option value="json">JSON</a-select-option>
</a-select>
<div class="form-tip">指定字典项值的类型系统会根据类型自动格式化返回数据</div>
<div class="form-tip">
指定字典项值的类型系统会根据类型自动格式化返回数据
</div>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
<div class="form-tip">数值越小越靠前</div>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="dictionary_status" placeholder="请选择状态" allow-clear />
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="dictionary_status"
placeholder="请选择状态"
allow-clear
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入字典描述" :rows="3" maxlength="200"
show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入字典描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
@@ -45,129 +93,140 @@
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import systemApi from '@/api/system'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import systemApi from "@/api/system";
// ===== Props =====
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
default: null,
},
dictionaryList: {
type: Array,
default: () => []
}
})
default: () => [],
},
});
// ===== Emits =====
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
// ===== 状态 =====
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑字典类型' : '新增字典类型'
})
return isEdit.value ? "编辑字典类型" : "新增字典类型";
});
// ===== 表单数据 =====
const form = ref({
id: '',
name: '',
code: '',
value_type: 'string',
description: '',
id: "",
name: "",
code: "",
value_type: "string",
description: "",
status: null,
sort: 0
})
sort: 0,
});
// ===== 验证规则 =====
// 编码唯一性验证
const validateCodeUnique = async (rule, value) => {
if (!value) return Promise.resolve()
if (!value) return Promise.resolve();
// 检查编码是否已存在(编辑时排除自己)
const exists = props.dictionaryList.some(
item => item.code === value && item.id !== props.record?.id
)
(item) => item.code === value && item.id !== props.record?.id,
);
if (exists) {
return Promise.reject('该编码已存在,请使用其他编码')
return Promise.reject("该编码已存在,请使用其他编码");
}
return Promise.resolve()
}
return Promise.resolve();
};
const rules = {
name: [
{ required: true, message: '请输入字典名称', trigger: 'blur' },
{ min: 2, max: 50, message: '字典名称长度在 2 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入字典名称", trigger: "blur" },
{
min: 2,
max: 50,
message: "字典名称长度在 2 到 50 个字符",
trigger: "blur",
},
],
code: [
{ required: true, message: '请输入字典编码', trigger: 'blur' },
{ required: true, message: "请输入字典编码", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: '编码格式不正确,只能包含字母、数字、下划线,且必须以字母开头',
trigger: 'blur'
message:
"编码格式不正确,只能包含字母、数字、下划线,且必须以字母开头",
trigger: "blur",
},
{ validator: validateCodeUnique, trigger: 'blur' }
{ validator: validateCodeUnique, trigger: "blur" },
],
value_type: [
{ required: true, message: '请选择值类型', trigger: 'change' }
]
}
{ required: true, message: "请选择值类型", trigger: "change" },
],
};
// ===== 方法:重置表单 =====
const resetForm = () => {
form.value = {
id: '',
name: '',
code: '',
value_type: 'string',
description: '',
id: "",
name: "",
code: "",
value_type: "string",
description: "",
status: null,
sort: 0
}
formRef.value?.clearValidate()
}
sort: 0,
};
formRef.value?.clearValidate();
};
// ===== 方法:设置数据(编辑时) =====
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
name: data.name || '',
code: data.code || '',
value_type: data.value_type || 'string',
description: data.description || '',
id: data.id || "",
name: data.name || "",
code: data.code || "",
value_type: data.value_type || "string",
description: data.description || "",
status: data.status !== undefined ? data.status : null,
sort: data.sort !== undefined ? data.sort : 0
}
sort: data.sort !== undefined ? data.sort : 0,
};
}
}
};
// ===== 方法:提交表单 =====
const handleSubmit = async () => {
try {
// 验证表单
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
const submitData = {
name: form.value.name,
@@ -175,56 +234,63 @@ const handleSubmit = async () => {
value_type: form.value.value_type,
description: form.value.description,
status: form.value.status,
sort: form.value.sort
}
sort: form.value.sort,
};
let res = {}
let res = {};
if (isEdit.value) {
// 编辑
res = await systemApi.dictionaries.edit.put(form.value.id, submitData)
res = await systemApi.dictionaries.edit.put(
form.value.id,
submitData,
);
} else {
// 新增
res = await systemApi.dictionaries.add.post(submitData)
res = await systemApi.dictionaries.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
// 表单验证失败
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
// API 调用失败
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// ===== 方法:取消 =====
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// ===== 监听 visible 变化 =====
watch(() => props.visible, (newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record)
} else {
resetForm()
watch(
() => props.visible,
(newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">

View File

@@ -1,50 +1,103 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="600px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 标签名称 -->
<a-form-item label="标签名称" name="label" required>
<a-input v-model:value="form.label" placeholder="如:正常" allow-clear maxlength="50" show-count />
<a-input
v-model:value="form.label"
placeholder="如:正常"
allow-clear
maxlength="50"
show-count
/>
<div class="form-tip">用于前端显示的文本</div>
</a-form-item>
<!-- 数据值 -->
<a-form-item label="数据值" name="value" required>
<a-input v-model:value="form.value" placeholder="如1" allow-clear />
<a-input
v-model:value="form.value"
placeholder="如1"
allow-clear
/>
<div class="form-tip">实际使用的值同一字典内必须唯一</div>
</a-form-item>
<!-- 颜色标记 -->
<a-form-item label="颜色标记" name="color">
<div style="display: flex; align-items: center">
<input v-model="form.color" type="color" style="width: 60px; height: 32px; cursor: pointer" />
<a-input v-model:value="form.color" placeholder="#1890ff" allow-clear
style="flex: 1; margin-left: 10px" />
<input
v-model="form.color"
type="color"
style="width: 60px; height: 32px; cursor: pointer"
/>
<a-input
v-model:value="form.color"
placeholder="#1890ff"
allow-clear
style="flex: 1; margin-left: 10px"
/>
</div>
<div class="form-tip">用于前端展示的颜色标记</div>
</a-form-item>
<!-- 是否默认 -->
<a-form-item label="默认项" name="is_default">
<a-switch v-model:checked="isDefaultChecked" checked-children="" un-checked-children="" />
<div class="form-tip" v-if="form.is_default" style="color: #faad14">
<a-switch
v-model:checked="isDefaultChecked"
checked-children=""
un-checked-children=""
/>
<div
class="form-tip"
v-if="form.is_default"
style="color: #faad14"
>
设置为默认项后同一字典内的其他默认项将自动取消
</div>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<a-switch v-model:checked="statusChecked" checked-children="启用" un-checked-children="禁用" />
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入描述" :rows="3" maxlength="200"
show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
@@ -52,146 +105,156 @@
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import systemApi from '@/api/system'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
// ===== Props =====
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
default: null,
},
dictionaryId: {
type: Number,
default: null
default: null,
},
itemList: {
type: Array,
default: () => []
}
})
default: () => [],
},
});
// ===== Emits =====
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
// ===== 状态 =====
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑字典项' : '新增字典项'
})
return isEdit.value ? "编辑字典项" : "新增字典项";
});
// ===== 表单数据 =====
const form = ref({
id: '',
label: '',
value: '',
color: '',
description: '',
id: "",
label: "",
value: "",
color: "",
description: "",
is_default: false,
status: true,
sort: 0,
dictionary_id: null
})
dictionary_id: null,
});
// ===== 计算属性:状态开关 =====
const statusChecked = computed({
get: () => form.value.status === true,
set: (val) => {
form.value.status = val ? true : false
}
})
form.value.status = val ? true : false;
},
});
// ===== 计算属性:默认项开关 =====
const isDefaultChecked = computed({
get: () => form.value.is_default === true,
set: (val) => {
form.value.is_default = val ? true : false
}
})
form.value.is_default = val ? true : false;
},
});
// ===== 验证规则 =====
// 数据值唯一性验证
const validateValueUnique = async (rule, value) => {
if (!value) return Promise.resolve()
if (!value) return Promise.resolve();
// 检查数据值是否已存在(同一字典内,编辑时排除自己)
const exists = props.itemList.some(
item => item.value === value && item.id !== props.record?.id
)
(item) => item.value === value && item.id !== props.record?.id,
);
if (exists) {
return Promise.reject('该数据值已存在,请使用其他值')
return Promise.reject("该数据值已存在,请使用其他值");
}
return Promise.resolve()
}
return Promise.resolve();
};
const rules = {
label: [
{ required: true, message: '请输入标签名称', trigger: 'blur' },
{ min: 1, max: 50, message: '标签名称长度在 1 到 50 个字符', trigger: 'blur' }
{ required: true, message: "请输入标签名称", trigger: "blur" },
{
min: 1,
max: 50,
message: "标签名称长度在 1 到 50 个字符",
trigger: "blur",
},
],
value: [
{ required: true, message: '请输入数据值', trigger: 'blur' },
{ validator: validateValueUnique, trigger: 'blur' }
]
}
{ required: true, message: "请输入数据值", trigger: "blur" },
{ validator: validateValueUnique, trigger: "blur" },
],
};
// ===== 方法:重置表单 =====
const resetForm = () => {
form.value = {
id: '',
label: '',
value: '',
color: '',
description: '',
id: "",
label: "",
value: "",
color: "",
description: "",
is_default: false,
status: true,
sort: 0,
dictionary_id: null
}
formRef.value?.clearValidate()
}
dictionary_id: null,
};
formRef.value?.clearValidate();
};
// ===== 方法:设置数据(编辑时) =====
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
label: data.label || '',
value: data.value || '',
color: data.color || '',
description: data.description || '',
id: data.id || "",
label: data.label || "",
value: data.value || "",
color: data.color || "",
description: data.description || "",
is_default: data.is_default || false,
status: data.status !== undefined ? data.status : true,
sort: data.sort !== undefined ? data.sort : 0,
dictionary_id: data.dictionary_id || props.dictionaryId
}
dictionary_id: data.dictionary_id || props.dictionaryId,
};
}
}
};
// ===== 方法:提交表单 =====
const handleSubmit = async () => {
try {
// 验证表单
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
const submitData = {
label: form.value.label,
@@ -201,58 +264,65 @@ const handleSubmit = async () => {
is_default: form.value.is_default,
status: form.value.status,
sort: form.value.sort,
dictionary_id: props.dictionaryId
}
dictionary_id: props.dictionaryId,
};
let res = {}
let res = {};
if (isEdit.value) {
// 编辑
res = await systemApi.dictionaryItems.edit.put(form.value.id, submitData)
res = await systemApi.dictionaryItems.edit.put(
form.value.id,
submitData,
);
} else {
// 新增
res = await systemApi.dictionaryItems.add.post(submitData)
res = await systemApi.dictionaryItems.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
// 表单验证失败
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
// API 调用失败
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// ===== 方法:取消 =====
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// ===== 监听 visible 变化 =====
watch(() => props.visible, (newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record)
} else {
resetForm()
// 新增时设置 dictionary_id
form.value.dictionary_id = props.dictionaryId
watch(
() => props.visible,
(newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record);
} else {
resetForm();
// 新增时设置 dictionary_id
form.value.dictionary_id = props.dictionaryId;
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">

View File

@@ -3,31 +3,55 @@
<!-- 左侧字典类型列表 -->
<div class="left-box">
<div class="header">
<a-input v-model:value="dictionaryKeyword" placeholder="搜索字典..." allow-clear @change="handleDictionarySearch">
<a-input
v-model:value="dictionaryKeyword"
placeholder="搜索字典..."
allow-clear
@change="handleDictionarySearch"
>
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
</template>
</a-input>
<a-button type="primary" size="small" style="margin-top: 12px; width: 100%" @click="handleAddDictionary">
<a-button
type="primary"
size="small"
style="margin-top: 12px; width: 100%"
@click="handleAddDictionary"
>
<PlusOutlined /> 新增字典
</a-button>
</div>
<div class="body">
<!-- 字典列表 -->
<div v-if="filteredDictionaries.length > 0" class="dictionary-list">
<div v-for="item in filteredDictionaries" :key="item.id"
:class="['dictionary-item', { 'active': selectedDictionaryId === item.id }]"
@click="handleSelectDictionary(item)">
<div
v-if="filteredDictionaries.length > 0"
class="dictionary-list"
>
<div
v-for="item in filteredDictionaries"
:key="item.id"
:class="[
'dictionary-item',
{ active: selectedDictionaryId === item.id },
]"
@click="handleSelectDictionary(item)"
>
<div class="item-main">
<div class="item-name">{{ item.name }}</div>
<div class="item-code">{{ item.code }}</div>
</div>
<div class="item-meta">
<a-tag :color="item.status ? 'success' : 'default'" size="small">
{{ item.status ? '启用' : '禁用' }}
<a-tag
:color="item.status ? 'success' : 'default'"
size="small"
>
{{ item.status ? "启用" : "禁用" }}
</a-tag>
<span class="item-count">{{ item.items_count || 0 }} </span>
<span class="item-count"
>{{ item.items_count || 0 }} </span
>
</div>
<div class="item-actions" @click.stop>
<a-dropdown>
@@ -36,10 +60,17 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEditDictionary(item)">
<a-menu-item
@click="handleEditDictionary(item)"
>
<EditOutlined />编辑
</a-menu-item>
<a-menu-item @click="handleDeleteDictionary(item)" danger>
<a-menu-item
@click="
handleDeleteDictionary(item)
"
danger
>
<DeleteOutlined />删除
</a-menu-item>
</a-menu>
@@ -50,7 +81,11 @@
</div>
<!-- 空状态 -->
<a-empty v-else-if="!dictionaryLoading" description="暂无字典类型" :image-size="80">
<a-empty
v-else-if="!dictionaryLoading"
description="暂无字典类型"
:image-size="80"
>
<a-button type="primary" @click="handleAddDictionary">
创建第一个字典
</a-button>
@@ -64,11 +99,30 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.label" placeholder="标签名称" allow-clear style="width: 140px" />
<a-input v-model:value="searchForm.value" placeholder="数据值" allow-clear style="width: 140px" />
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 100px">
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
<a-input
v-model:value="searchForm.label"
placeholder="标签名称"
allow-clear
style="width: 140px"
/>
<a-input
v-model:value="searchForm.value"
placeholder="数据值"
allow-clear
style="width: 140px"
/>
<a-select
v-model:value="searchForm.status"
placeholder="状态"
allow-clear
style="width: 100px"
>
<a-select-option :value="true"
>启用</a-select-option
>
<a-select-option :value="false"
>禁用</a-select-option
>
</a-select>
<a-button type="primary" @click="handleItemSearch">
<template #icon><SearchOutlined /></template>
@@ -99,7 +153,11 @@
</a-menu>
</template>
</a-dropdown>
<a-button type="primary" :disabled="!selectedDictionaryId" @click="handleAddItem">
<a-button
type="primary"
:disabled="!selectedDictionaryId"
@click="handleAddItem"
>
<template #icon><PlusOutlined /></template>
新增
</a-button>
@@ -110,15 +168,33 @@
<div class="table-content">
<!-- 空状态未选择字典 -->
<div v-if="!selectedDictionaryId" class="empty-state">
<a-empty description="请选择左侧字典类型后操作" :image-size="120" />
<a-empty
description="请选择左侧字典类型后操作"
:image-size="120"
/>
</div>
<!-- 字典项表格 -->
<scTable v-else ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
:pagination="pagination" :row-key="rowKey" :row-selection="rowSelection" @refresh="refreshTable"
@paginationChange="handlePaginationChange" @select="handleSelectChange" @selectAll="handleSelectAll">
<scTable
v-else
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
:row-selection="rowSelection"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<template #color="{ record }">
<span v-if="record.color" class="color-cell" :style="{ backgroundColor: record.color }"></span>
<span
v-if="record.color"
class="color-cell"
:style="{ backgroundColor: record.color }"
></span>
<span v-else>-</span>
</template>
@@ -131,17 +207,26 @@
<template #status="{ record }">
<a-tag :color="record.status ? 'success' : 'error'">
{{ record.status ? '启用' : '禁用' }}
{{ record.status ? "启用" : "禁用" }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleEditItem(record)">
<a-button
type="link"
size="small"
@click="handleEditItem(record)"
>
编辑
</a-button>
<a-popconfirm title="确定删除该字典项吗?" @confirm="handleDeleteItem(record)">
<a-button type="link" size="small" danger>删除</a-button>
<a-popconfirm
title="确定删除该字典项吗?"
@confirm="handleDeleteItem(record)"
>
<a-button type="link" size="small" danger
>删除</a-button
>
</a-popconfirm>
</a-space>
</template>
@@ -151,17 +236,28 @@
</div>
<!-- 字典类型弹窗 -->
<DictionaryDialog v-if="dialog.dictionary" v-model:visible="dialog.dictionary" :record="currentDictionary"
:dictionary-list="dictionaryList" @success="handleDictionarySuccess" />
<DictionaryDialog
v-if="dialog.dictionary"
v-model:visible="dialog.dictionary"
:record="currentDictionary"
:dictionary-list="dictionaryList"
@success="handleDictionarySuccess"
/>
<!-- 字典项弹窗 -->
<ItemDialog v-if="dialog.item" v-model:visible="dialog.item" :record="currentItem"
:dictionary-id="selectedDictionaryId" :item-list="tableData" @success="handleItemSuccess" />
<ItemDialog
v-if="dialog.item"
v-model:visible="dialog.item"
:record="currentItem"
:dictionary-id="selectedDictionaryId"
:item-list="tableData"
@success="handleItemSuccess"
/>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive, onMounted, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -171,22 +267,22 @@ import {
MoreOutlined,
CheckCircleOutlined,
StarOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons-vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import scTable from '@/components/scTable/index.vue'
import DictionaryDialog from './components/DictionaryDialog.vue'
import ItemDialog from './components/ItemDialog.vue'
import { useDictionaryStore } from '@/stores/modules/dictionary'
ExclamationCircleOutlined,
} from "@ant-design/icons-vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import scTable from "@/components/scTable/index.vue";
import DictionaryDialog from "./components/DictionaryDialog.vue";
import ItemDialog from "./components/ItemDialog.vue";
import { useDictionaryStore } from "@/stores/modules/dictionary";
// ===== 字典列表相关 =====
const dictionaryList = ref([])
const filteredDictionaries = ref([])
const selectedDictionary = ref(null)
const selectedDictionaryId = ref(null)
const dictionaryKeyword = ref('')
const dictionaryLoading = ref(false)
const dictionaryList = ref([]);
const filteredDictionaries = ref([]);
const selectedDictionary = ref(null);
const selectedDictionaryId = ref(null);
const dictionaryKeyword = ref("");
const dictionaryLoading = ref(false);
// ===== 字典项相关(使用 useTable Hook=====
const {
@@ -202,308 +298,367 @@ const {
handlePaginationChange,
handleSelectChange,
handleSelectAll,
refreshTable
refreshTable,
} = useTable({
api: systemApi.dictionaryItems.list.get,
searchForm: {
dictionary_id: null,
label: '',
value: '',
status: undefined
label: "",
value: "",
status: undefined,
},
columns: [],
needPagination: true,
needSelection: true,
immediateLoad: false // 不自动加载,等待选择字典
})
immediateLoad: false, // 不自动加载,等待选择字典
});
// 表格列配置
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{ title: '标签名称', dataIndex: 'label', key: 'label', width: 150, ellipsis: true },
{ title: '数据值', dataIndex: 'value', key: 'value', width: 120, ellipsis: true },
{ title: '颜色', dataIndex: 'color', key: 'color', width: 100, align: 'center', slot: 'color' },
{ title: '默认项', dataIndex: 'is_default', key: 'is_default', width: 100, align: 'center', slot: 'is_default' },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80, align: 'center' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '操作', dataIndex: 'action', key: 'action', width: 150, align: 'center', fixed: 'right', slot: 'action' }
]
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{
title: "标签名称",
dataIndex: "label",
key: "label",
width: 150,
ellipsis: true,
},
{
title: "数据值",
dataIndex: "value",
key: "value",
width: 120,
ellipsis: true,
},
{
title: "颜色",
dataIndex: "color",
key: "color",
width: 100,
align: "center",
slot: "color",
},
{
title: "默认项",
dataIndex: "is_default",
key: "is_default",
width: 100,
align: "center",
slot: "is_default",
},
{
title: "排序",
dataIndex: "sort",
key: "sort",
width: 80,
align: "center",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
slot: "status",
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 150,
align: "center",
fixed: "right",
slot: "action",
},
];
const rowKey = 'id'
const rowKey = "id";
// ===== 弹窗状态 =====
const dialog = reactive({
dictionary: false,
item: false
})
item: false,
});
// ===== 当前操作的数据 =====
const currentDictionary = ref(null)
const currentItem = ref(null)
const currentDictionary = ref(null);
const currentItem = ref(null);
// ===== 方法:加载字典列表 =====
const loadDictionaryList = async () => {
try {
dictionaryLoading.value = true
const res = await systemApi.dictionaries.all.get()
dictionaryLoading.value = true;
const res = await systemApi.dictionaries.all.get();
if (res.code === 200) {
dictionaryList.value = res.data || []
filteredDictionaries.value = res.data || []
dictionaryList.value = res.data || [];
filteredDictionaries.value = res.data || [];
// 建立字典ID到Code的映射
dictionaryStore.buildIdToCodeMap(res.data || [])
dictionaryStore.buildIdToCodeMap(res.data || []);
} else {
message.error(res.message || '加载字典列表失败')
message.error(res.message || "加载字典列表失败");
}
} catch (error) {
console.error('加载字典列表失败:', error)
message.error('加载字典列表失败')
console.error("加载字典列表失败:", error);
message.error("加载字典列表失败");
} finally {
dictionaryLoading.value = false
dictionaryLoading.value = false;
}
}
};
// ===== 方法:搜索过滤字典 =====
const handleDictionarySearch = (e) => {
const keyword = e.target?.value || ''
dictionaryKeyword.value = keyword
const keyword = e.target?.value || "";
dictionaryKeyword.value = keyword;
if (!keyword) {
filteredDictionaries.value = dictionaryList.value
return
filteredDictionaries.value = dictionaryList.value;
return;
}
// 过滤字典列表(支持搜索名称和编码)
filteredDictionaries.value = dictionaryList.value.filter(dict => {
return dict.name.toLowerCase().includes(keyword.toLowerCase()) ||
filteredDictionaries.value = dictionaryList.value.filter((dict) => {
return (
dict.name.toLowerCase().includes(keyword.toLowerCase()) ||
dict.code.toLowerCase().includes(keyword.toLowerCase())
})
}
);
});
};
// ===== 方法:选择字典 =====
const handleSelectDictionary = (dictionary) => {
selectedDictionary.value = dictionary
selectedDictionaryId.value = dictionary.id
selectedDictionary.value = dictionary;
selectedDictionaryId.value = dictionary.id;
// 重置右侧搜索条件
searchForm.label = ''
searchForm.value = ''
searchForm.status = undefined
searchForm.label = "";
searchForm.value = "";
searchForm.status = undefined;
// 更新 dictionary_id
searchForm.dictionary_id = dictionary.id
searchForm.dictionary_id = dictionary.id;
// 加载字典项列表
handleItemSearch()
}
handleItemSearch();
};
// ===== 方法:字典项搜索 =====
const handleItemSearch = () => {
if (!selectedDictionaryId.value) {
message.warning('请先选择字典类型')
return
message.warning("请先选择字典类型");
return;
}
searchForm.dictionary_id = selectedDictionaryId.value
handleSearch()
}
searchForm.dictionary_id = selectedDictionaryId.value;
handleSearch();
};
// ===== 方法:字典项重置 =====
const handleItemReset = () => {
searchForm.label = ''
searchForm.value = ''
searchForm.status = undefined
searchForm.dictionary_id = selectedDictionaryId.value
handleSearch()
}
searchForm.label = "";
searchForm.value = "";
searchForm.status = undefined;
searchForm.dictionary_id = selectedDictionaryId.value;
handleSearch();
};
// ===== 方法:新增字典 =====
const handleAddDictionary = () => {
currentDictionary.value = null
dialog.dictionary = true
}
currentDictionary.value = null;
dialog.dictionary = true;
};
// ===== 方法:编辑字典 =====
const handleEditDictionary = (dictionary) => {
currentDictionary.value = { ...dictionary }
dialog.dictionary = true
}
currentDictionary.value = { ...dictionary };
dialog.dictionary = true;
};
// ===== 方法:删除字典 =====
const handleDeleteDictionary = (dictionary) => {
const itemCount = dictionary.items_count || 0
const itemCount = dictionary.items_count || 0;
Modal.confirm({
title: '确认删除',
content: itemCount > 0
? `确定删除字典类型"${dictionary.name}"吗?删除后该字典下的 ${itemCount} 个字典项也会被删除!`
: `确定删除字典类型"${dictionary.name}"吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
title: "确认删除",
content:
itemCount > 0
? `确定删除字典类型"${dictionary.name}"吗?删除后该字典下的 ${itemCount} 个字典项也会被删除!`
: `确定删除字典类型"${dictionary.name}"吗?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const res = await systemApi.dictionaries.delete.delete(dictionary.id)
const res = await systemApi.dictionaries.delete.delete(
dictionary.id,
);
if (res.code === 200) {
message.success('删除成功')
message.success("删除成功");
// 如果删除的是当前选中的字典,清空右侧
if (selectedDictionaryId.value === dictionary.id) {
selectedDictionary.value = null
selectedDictionaryId.value = null
tableData.value = []
selectedDictionary.value = null;
selectedDictionaryId.value = null;
tableData.value = [];
}
// 刷新字典列表
await loadDictionaryList()
await loadDictionaryList();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除字典失败:', error)
message.error('删除失败')
console.error("删除字典失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// ===== 方法:新增字典项 =====
const handleAddItem = () => {
if (!selectedDictionaryId.value) {
message.warning('请先选择字典类型')
return
message.warning("请先选择字典类型");
return;
}
currentItem.value = null
dialog.item = true
}
currentItem.value = null;
dialog.item = true;
};
// ===== 方法:编辑字典项 =====
const handleEditItem = (record) => {
currentItem.value = { ...record }
dialog.item = true
}
currentItem.value = { ...record };
dialog.item = true;
};
// ===== 方法:删除字典项 =====
const handleDeleteItem = async (record) => {
try {
const res = await systemApi.dictionaryItems.delete.delete(record.id)
const res = await systemApi.dictionaryItems.delete.delete(record.id);
if (res.code === 200) {
message.success('删除成功')
message.success("删除成功");
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
refreshTable()
dictionaryStore.clearDictionary(selectedDictionaryId.value);
refreshTable();
// 刷新字典列表以更新项数量
loadDictionaryList()
loadDictionaryList();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('删除字典项失败:', error)
message.error('删除失败')
console.error("删除字典项失败:", error);
message.error("删除失败");
}
}
};
// ===== 方法:批量删除字典项 =====
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的字典项')
return
message.warning("请选择要删除的字典项");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
okText: "删除",
okType: "danger",
cancelText: "取消",
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await systemApi.dictionaryItems.batchDelete.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.dictionaryItems.batchDelete.post({
ids,
});
if (res.code === 200) {
message.success('删除成功')
message.success("删除成功");
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
selectedRows.value = []
refreshTable()
loadDictionaryList()
dictionaryStore.clearDictionary(selectedDictionaryId.value);
selectedRows.value = [];
refreshTable();
loadDictionaryList();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
console.error('批量删除失败:', error)
message.error('删除失败')
console.error("批量删除失败:", error);
message.error("删除失败");
}
}
})
}
},
});
};
// ===== 方法:批量启用/禁用字典项 =====
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的字典项')
return
message.warning("请选择要操作的字典项");
return;
}
const newStatus = selectedRows.value[0].status ? false : true
const statusText = newStatus === 1 ? '启用' : '禁用'
const newStatus = selectedRows.value[0].status ? false : true;
const statusText = newStatus === 1 ? "启用" : "禁用";
Modal.confirm({
title: `确认${statusText}`,
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.dictionaryItems.batchStatus.post({
ids,
status: newStatus
})
status: newStatus,
});
if (res.code === 200) {
message.success(`${statusText}成功`)
message.success(`${statusText}成功`);
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
selectedRows.value = []
refreshTable()
dictionaryStore.clearDictionary(selectedDictionaryId.value);
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
console.error('批量操作失败:', error)
message.error('操作失败')
console.error("批量操作失败:", error);
message.error("操作失败");
}
}
})
}
},
});
};
// 初始化字典 store
const dictionaryStore = useDictionaryStore()
const dictionaryStore = useDictionaryStore();
// ===== 方法:字典操作成功回调 =====
const handleDictionarySuccess = () => {
dialog.dictionary = false
dialog.dictionary = false;
// 清理字典缓存
dictionaryStore.clearCache()
loadDictionaryList()
}
dictionaryStore.clearCache();
loadDictionaryList();
};
// ===== 方法:字典项操作成功回调 =====
const handleItemSuccess = () => {
dialog.item = false
dialog.item = false;
// 清理字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
refreshTable()
loadDictionaryList()
}
dictionaryStore.clearDictionary(selectedDictionaryId.value);
refreshTable();
loadDictionaryList();
};
// ===== 生命周期 =====
onMounted(() => {
// 加载字典列表
loadDictionaryList()
})
loadDictionaryList();
});
</script>
<style scoped lang="scss">
@@ -557,7 +712,7 @@ onMounted(() => {
.item-code {
font-size: 12px;
color: #8c8c8c;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -3,7 +3,12 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="通知标题/内容" allow-clear style="width: 180px" />
<a-input
v-model:value="searchForm.keyword"
placeholder="通知标题/内容"
allow-clear
style="width: 180px"
/>
<a-select
v-model:value="searchForm.is_read"
placeholder="阅读状态"
@@ -39,7 +44,11 @@
</div>
<div class="right-panel">
<a-space>
<a-badge :count="unreadCount" :offset="[-5, 5]" :number-style="{ backgroundColor: '#f5222d' }">
<a-badge
:count="unreadCount"
:offset="[-5, 5]"
:number-style="{ backgroundColor: '#f5222d' }"
>
<a-button @click="handleMarkAllRead">
<template #icon><check-outlined /></template>
全部已读
@@ -85,29 +94,48 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="notification-title" :class="{ unread: !record.is_read }" @click="handleViewDetail(record)">
<WarningOutlined v-if="!record.is_read" class="unread-icon" />
<div
class="notification-title"
:class="{ unread: !record.is_read }"
@click="handleViewDetail(record)"
>
<WarningOutlined
v-if="!record.is_read"
class="unread-icon"
/>
<span>{{ record.title }}</span>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">
<component :is="getTypeIcon(record.type)" class="type-icon" />
<component
:is="getTypeIcon(record.type)"
class="type-icon"
/>
{{ getTypeText(record.type) }}
</a-tag>
</template>
<template v-if="column.key === 'category'">
<a-tag color="blue">{{ getCategoryText(record.category) }}</a-tag>
<a-tag color="blue">{{
getCategoryText(record.category)
}}</a-tag>
</template>
<template v-if="column.key === 'is_read'">
<a-badge :status="record.is_read ? 'default' : 'processing'" :text="record.is_read ? '已读' : '未读'" />
<a-badge
:status="record.is_read ? 'default' : 'processing'"
:text="record.is_read ? '已读' : '未读'"
/>
</template>
<template v-if="column.key === 'created_at'">
<span>{{ formatTime(record.created_at) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record)">
<a-button
type="link"
size="small"
@click="handleViewDetail(record)"
>
<eye-outlined />
查看
</a-button>
@@ -120,7 +148,12 @@
<check-outlined />
标为已读
</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">
<a-button
type="link"
size="small"
danger
@click="handleDelete(record)"
>
<delete-outlined />
删除
</a-button>
@@ -131,7 +164,12 @@
</div>
<!-- 通知详情弹窗 -->
<a-drawer v-model:open="showDetailDrawer" title="通知详情" placement="right" width="600">
<a-drawer
v-model:open="showDetailDrawer"
title="通知详情"
placement="right"
width="600"
>
<template v-if="currentNotification">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="标题">
@@ -139,38 +177,68 @@
</a-descriptions-item>
<a-descriptions-item label="类型">
<a-tag :color="getTypeColor(currentNotification.type)">
<component :is="getTypeIcon(currentNotification.type)" class="type-icon" />
<component
:is="getTypeIcon(currentNotification.type)"
class="type-icon"
/>
{{ getTypeText(currentNotification.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="分类">
<a-tag color="blue">{{ getCategoryText(currentNotification.category) }}</a-tag>
<a-tag color="blue">{{
getCategoryText(currentNotification.category)
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="currentNotification.is_read ? 'default' : 'processing'"
:text="currentNotification.is_read ? '已读' : '未读'"
:status="
currentNotification.is_read
? 'default'
: 'processing'
"
:text="
currentNotification.is_read ? '已读' : '未读'
"
/>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(currentNotification.created_at) }}
</a-descriptions-item>
<a-descriptions-item v-if="currentNotification.read_at" label="阅读时间">
<a-descriptions-item
v-if="currentNotification.read_at"
label="阅读时间"
>
{{ formatTime(currentNotification.read_at) }}
</a-descriptions-item>
</a-descriptions>
<div class="notification-content">
<div class="content-label">通知内容:</div>
<div class="content-text">{{ currentNotification.content }}</div>
<div class="content-text">
{{ currentNotification.content }}
</div>
</div>
<div v-if="currentNotification.data && Object.keys(currentNotification.data).length > 0" class="notification-data">
<div
v-if="
currentNotification.data &&
Object.keys(currentNotification.data).length > 0
"
class="notification-data"
>
<div class="data-label">附加数据:</div>
<pre class="data-text">{{ JSON.stringify(currentNotification.data, null, 2) }}</pre>
<pre class="data-text">{{
JSON.stringify(currentNotification.data, null, 2)
}}</pre>
</div>
<div v-if="currentNotification.action_type && currentNotification.action_type !== 'none'" class="notification-action">
<div
v-if="
currentNotification.action_type &&
currentNotification.action_type !== 'none'
"
class="notification-action"
>
<a-button type="primary" @click="handleAction">
<template #icon><arrow-right-outlined /></template>
{{ getActionText(currentNotification.action_type) }}
@@ -182,8 +250,8 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive, onMounted, onUnmounted, computed, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -200,120 +268,127 @@ import {
MessageOutlined,
ClockCircleOutlined,
BulbOutlined,
ArrowRightOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import { useWebSocket } from '@/composables/useWebSocket'
ArrowRightOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
// 表格引用
const tableRef = ref(null)
const tableRef = ref(null);
// WebSocket
const ws = useWebSocket()
let unreadCountInterval = null
// 注意:WebSocket 已在 App.vue 中统一初始化,这里不需要重复连接
// 通知会通过 notification store 自动更新
let unreadCountInterval = null;
// 搜索表单
const searchForm = reactive({
keyword: '',
keyword: "",
is_read: undefined,
type: undefined,
category: undefined
})
category: undefined,
});
// 未读数量
const unreadCount = ref(0)
const unreadCount = ref(0);
// 当前通知
const currentNotification = ref(null)
const currentNotification = ref(null);
// 显示详情抽屉
const showDetailDrawer = ref(false)
const showDetailDrawer = ref(false);
// 通知类型选项
const typeOptions = [
{ label: '信息', value: 'info' },
{ label: '成功', value: 'success' },
{ label: '警告', value: 'warning' },
{ label: '错误', value: 'error' },
{ label: '任务', value: 'task' },
{ label: '系统', value: 'system' }
]
{ label: "信息", value: "info" },
{ label: "成功", value: "success" },
{ label: "警告", value: "warning" },
{ label: "错误", value: "error" },
{ label: "任务", value: "task" },
{ label: "系统", value: "system" },
];
// 通知分类选项
const categoryOptions = [
{ label: '系统通知', value: 'system' },
{ label: '任务通知', value: 'task' },
{ label: '消息通知', value: 'message' },
{ label: '提醒通知', value: 'reminder' },
{ label: '公告通知', value: 'announcement' }
]
{ label: "系统通知", value: "system" },
{ label: "任务通知", value: "task" },
{ label: "消息通知", value: "message" },
{ label: "提醒通知", value: "reminder" },
{ label: "公告通知", value: "announcement" },
];
// 使用 useTable Hook
const { tableData, loading, pagination, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } =
useTable({
api: systemApi.notifications.list.get,
searchForm,
needPagination: true
})
const {
tableData,
loading,
pagination,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
api: systemApi.notifications.list.get,
searchForm,
needPagination: true,
});
// 表格列配置
const columns = [
{
title: '通知标题',
dataIndex: 'title',
key: 'title',
title: "通知标题",
dataIndex: "title",
key: "title",
width: 300,
ellipsis: true
ellipsis: true,
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
title: "类型",
dataIndex: "type",
key: "type",
width: 120,
align: 'center'
align: "center",
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
title: "分类",
dataIndex: "category",
key: "category",
width: 120,
align: 'center'
align: "center",
},
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
title: "状态",
dataIndex: "is_read",
key: "is_read",
width: 100,
align: 'center'
align: "center",
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180
},
{
title: '操作',
key: 'action',
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 180,
fixed: 'right'
}
]
},
{
title: "操作",
key: "action",
width: 180,
fixed: "right",
},
];
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan'
}
return colors[type] || 'default'
}
info: "blue",
success: "green",
warning: "orange",
error: "red",
task: "purple",
system: "cyan",
};
return colors[type] || "default";
};
// 获取类型图标
const getTypeIcon = (type) => {
@@ -323,251 +398,255 @@ const getTypeIcon = (type) => {
warning: ExclamationCircleOutlined,
error: CloseCircleOutlined,
task: BellOutlined,
system: MessageOutlined
}
return icons[type] || InfoCircleOutlined
}
system: MessageOutlined,
};
return icons[type] || InfoCircleOutlined;
};
// 获取类型文本
const getTypeText = (type) => {
const texts = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统'
}
return texts[type] || type
}
info: "信息",
success: "成功",
warning: "警告",
error: "错误",
task: "任务",
system: "系统",
};
return texts[type] || type;
};
// 获取分类文本
const getCategoryText = (category) => {
const texts = {
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知'
}
return texts[category] || category
}
system: "系统通知",
task: "任务通知",
message: "消息通知",
reminder: "提醒通知",
announcement: "公告通知",
};
return texts[category] || category;
};
// 获取操作文本
const getActionText = (actionType) => {
const texts = {
link: '查看详情',
modal: '打开弹窗',
none: ''
}
return texts[actionType] || '查看'
}
link: "查看详情",
modal: "打开弹窗",
none: "",
};
return texts[actionType] || "查看";
};
// 格式化时间
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
if (!time) return "-";
return new Date(time).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
// 获取未读数量
const loadUnreadCount = async () => {
try {
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count
const res = await systemApi.notifications.unreadCount.get();
unreadCount.value = res.data.count;
} catch (error) {
console.error('获取未读数量失败:', error)
console.error("获取未读数量失败:", error);
}
}
};
// 查看详情
const handleViewDetail = async (record) => {
currentNotification.value = { ...record }
showDetailDrawer.value = true
currentNotification.value = { ...record };
showDetailDrawer.value = true;
// 自动标记为已读
if (!record.is_read) {
await handleMarkRead(record)
await handleMarkRead(record);
}
}
};
// 标记已读
const handleMarkRead = async (record) => {
try {
await systemApi.notifications.markAsRead.post(record.id)
message.success('已标记为已读')
await systemApi.notifications.markAsRead.post(record.id);
message.success("已标记为已读");
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
refreshTable()
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
};
// 批量标记已读
const handleBatchMarkRead = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要操作的通知')
return
message.warning("请先选择要操作的通知");
return;
}
Modal.confirm({
title: '确认标记为已读',
title: "确认标记为已读",
content: `确定要将选中的 ${selectedRowKeys.length} 条通知标记为已读吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({ ids: selectedRowKeys })
message.success('批量标记成功')
rowSelection.selectedRowKeys = []
unreadCount.value = Math.max(0, unreadCount.value - res.data.count)
refreshTable()
const res = await systemApi.notifications.batchMarkAsRead.post({
ids: selectedRowKeys,
});
message.success("批量标记成功");
rowSelection.selectedRowKeys = [];
unreadCount.value = Math.max(
0,
unreadCount.value - res.data.count,
);
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 标记全部已读
const handleMarkAllRead = () => {
Modal.confirm({
title: '确认全部已读',
content: '确定要将所有未读通知标记为已读吗',
okText: '确定',
cancelText: '取消',
title: "确认全部已读",
content: "确定要将所有未读通知标记为已读吗",
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const res = await systemApi.notifications.markAllAsRead.post()
message.success('已标记全部为已读')
unreadCount.value = 0
refreshTable()
const res = await systemApi.notifications.markAllAsRead.post();
message.success("已标记全部为已读");
unreadCount.value = 0;
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 删除
const handleDelete = (record) => {
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除通知"${record.title}"吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.notifications.delete.delete(record.id)
message.success('删除成功')
await systemApi.notifications.delete.delete(record.id);
message.success("删除成功");
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
refreshTable()
refreshTable();
} catch (error) {
message.error(error.message || '删除失败')
message.error(error.message || "删除失败");
}
}
})
}
},
});
};
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的通知')
return
message.warning("请先选择要删除的通知");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定要删除选中的 ${selectedRowKeys.length} 条通知吗?`,
okText: '确定',
cancelText: '取消',
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const res = await systemApi.notifications.batchDelete.post({ ids: selectedRowKeys })
message.success('批量删除成功')
rowSelection.selectedRowKeys = []
loadUnreadCount()
refreshTable()
const res = await systemApi.notifications.batchDelete.post({
ids: selectedRowKeys,
});
message.success("批量删除成功");
rowSelection.selectedRowKeys = [];
loadUnreadCount();
refreshTable();
} catch (error) {
message.error(error.message || '批量删除失败')
message.error(error.message || "批量删除失败");
}
}
})
}
},
});
};
// 清空已读
const handleClearRead = () => {
Modal.confirm({
title: '确认清空',
content: '确定要清空所有已读通知吗此操作不可恢复。',
okText: '确定',
cancelText: '取消',
title: "确认清空",
content: "确定要清空所有已读通知吗此操作不可恢复",
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.notifications.clearRead.post()
message.success('已清空已读通知')
refreshTable()
await systemApi.notifications.clearRead.post();
message.success("已清空已读通知");
refreshTable();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || "操作失败");
}
}
})
}
},
});
};
// 处理操作
const handleAction = () => {
const notification = currentNotification.value
if (!notification) return
const notification = currentNotification.value;
if (!notification) return;
if (notification.action_type === 'link' && notification.action_data?.url) {
window.open(notification.action_data.url, '_blank')
} else if (notification.action_type === 'modal') {
if (notification.action_type === "link" && notification.action_data?.url) {
window.open(notification.action_data.url, "_blank");
} else if (notification.action_type === "modal") {
// 打开弹窗的逻辑
message.info('打开弹窗功能')
message.info("打开弹窗功能");
}
}
};
// WebSocket 消息处理
const handleWebSocketMessage = (msg) => {
if (msg.type === 'notification') {
if (msg.type === "notification") {
// 收到新通知
const notification = msg.data
message.info(`新通知: ${notification.title}`)
unreadCount.value++
refreshTable()
const notification = msg.data;
message.info(`新通知: ${notification.title}`);
unreadCount.value++;
refreshTable();
}
}
};
// 初始化
onMounted(() => {
loadUnreadCount()
// 连接 WebSocket
ws.connect()
ws.onMessage(handleWebSocketMessage)
loadUnreadCount();
// 定时刷新未读数量每30秒
// 注意:通知也会通过 WebSocket 实时推送,这是作为备用刷新机制
unreadCountInterval = setInterval(() => {
loadUnreadCount()
}, 30000)
})
loadUnreadCount();
}, 30000);
});
// 清理
onUnmounted(() => {
if (unreadCountInterval) {
clearInterval(unreadCountInterval)
clearInterval(unreadCountInterval);
}
})
});
</script>
<style scoped lang="scss">

View File

@@ -1,9 +1,19 @@
<template>
<a-modal title="任务详情" :open="visible" :footer="null" @cancel="handleCancel" width="800px">
<a-modal
title="任务详情"
:open="visible"
:footer="null"
@cancel="handleCancel"
width="800px"
>
<a-descriptions bordered :column="2" v-if="task">
<a-descriptions-item label="任务名称">{{ task.name }}</a-descriptions-item>
<a-descriptions-item label="任务名称">{{
task.name
}}</a-descriptions-item>
<a-descriptions-item label="任务类型">
<a-tag :color="getTypeColor(task.type)">{{ getTypeText(task.type) }}</a-tag>
<a-tag :color="getTypeColor(task.type)">{{
getTypeText(task.type)
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="命令/类" :span="2">
<code class="command-code">{{ task.command }}</code>
@@ -11,17 +21,19 @@
<a-descriptions-item label="Cron表达式">
<code class="cron-code">{{ task.expression }}</code>
</a-descriptions-item>
<a-descriptions-item label="时区">{{ task.timezone }}</a-descriptions-item>
<a-descriptions-item label="时区">{{
task.timezone
}}</a-descriptions-item>
<a-descriptions-item label="状态" :span="2">
<a-tag :color="task.is_active ? 'success' : 'error'">
{{ task.is_active ? '启用' : '禁用' }}
{{ task.is_active ? "启用" : "禁用" }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="上次运行时间" :span="2">
{{ task.last_run_at ? formatDate(task.last_run_at) : '未运行' }}
{{ task.last_run_at ? formatDate(task.last_run_at) : "未运行" }}
</a-descriptions-item>
<a-descriptions-item label="下次运行时间" :span="2">
{{ task.next_run_at ? formatDate(task.next_run_at) : '-' }}
{{ task.next_run_at ? formatDate(task.next_run_at) : "-" }}
</a-descriptions-item>
<a-descriptions-item label="运行次数">
<a-tag color="success">成功: {{ task.run_count || 0 }}</a-tag>
@@ -32,10 +44,10 @@
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="后台运行" :span="2">
{{ task.run_in_background ? '是' : '否' }}
{{ task.run_in_background ? "是" : "否" }}
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ task.description || '-' }}
{{ task.description || "-" }}
</a-descriptions-item>
</a-descriptions>
@@ -59,75 +71,75 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { PlayCircleOutlined } from '@ant-design/icons-vue'
import systemApi from '@/api/system'
import { ref, computed } from "vue";
import { message } from "ant-design-vue";
import { PlayCircleOutlined } from "@ant-design/icons-vue";
import systemApi from "@/api/system";
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'refresh'])
const emit = defineEmits(["update:visible", "refresh"]);
const task = computed(() => props.record)
const task = computed(() => props.record);
// 获取任务类型文本
const getTypeText = (type) => {
const typeMap = {
command: '命令',
job: '任务',
closure: '闭包'
}
return typeMap[type] || type
}
command: "命令",
job: "任务",
closure: "闭包",
};
return typeMap[type] || type;
};
// 获取任务类型颜色
const getTypeColor = (type) => {
const colorMap = {
command: 'blue',
job: 'green',
closure: 'orange'
}
return colorMap[type] || 'default'
}
command: "blue",
job: "green",
closure: "orange",
};
return colorMap[type] || "default";
};
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN");
};
// 执行任务
const handleRun = async () => {
if (!props.record) return
if (!props.record) return;
try {
const res = await systemApi.tasks.run.post(props.record.id)
const res = await systemApi.tasks.run.post(props.record.id);
if (res.code === 200) {
message.success('任务执行成功')
emit('refresh')
handleCancel()
message.success("任务执行成功");
emit("refresh");
handleCancel();
} else {
message.error(res.message || '任务执行失败')
message.error(res.message || "任务执行失败");
}
} catch (error) {
message.error('任务执行失败')
message.error("任务执行失败");
}
}
};
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
emit("update:visible", false);
};
</script>
<style scoped lang="scss">
@@ -135,7 +147,7 @@ const handleCancel = () => {
padding: 4px 8px;
background: #f5f5f5;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
word-break: break-all;
}
@@ -144,7 +156,7 @@ const handleCancel = () => {
padding: 2px 6px;
background: #f5f5f5;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
}
@@ -157,7 +169,7 @@ const handleCancel = () => {
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;

View File

@@ -1,14 +1,34 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="700px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 任务名称 -->
<a-form-item label="任务名称" name="name" required>
<a-input v-model:value="form.name" placeholder="如:清理日志" allow-clear />
<a-input
v-model:value="form.name"
placeholder="如:清理日志"
allow-clear
/>
</a-form-item>
<!-- 任务类型 -->
<a-form-item label="任务类型" name="type" required>
<a-select v-model:value="form.type" placeholder="请选择任务类型">
<a-select
v-model:value="form.type"
placeholder="请选择任务类型"
>
<a-select-option value="command">命令</a-select-option>
<a-select-option value="job">任务</a-select-option>
<a-select-option value="closure">闭包</a-select-option>
@@ -17,38 +37,74 @@
<!-- 命令/类名 -->
<a-form-item label="命令/类" name="command" required>
<a-textarea v-if="form.type === 'command'" v-model:value="form.command" placeholder="如php artisan schedule:run"
:rows="3" />
<a-input v-else v-model:value="form.command" placeholder="如App\Jobs\SendEmailJob" allow-clear />
<a-textarea
v-if="form.type === 'command'"
v-model:value="form.command"
placeholder="如php artisan schedule:run"
:rows="3"
/>
<a-input
v-else
v-model:value="form.command"
placeholder="如App\Jobs\SendEmailJob"
allow-clear
/>
<div class="form-tip">
<span v-if="form.type === 'command'">Shell命令或Artisan命令</span>
<span v-else-if="form.type === 'job'">任务类的完整命名空间</span>
<span v-if="form.type === 'command'"
>Shell命令或Artisan命令</span
>
<span v-else-if="form.type === 'job'"
>任务类的完整命名空间</span
>
<span v-else>闭包函数的代码</span>
</div>
</a-form-item>
<!-- Cron表达式 -->
<a-form-item label="Cron表达式" name="expression" required>
<a-input v-model:value="form.expression" placeholder="* * * * *" allow-clear />
<a-input
v-model:value="form.expression"
placeholder="* * * * *"
allow-clear
/>
<div class="form-tip">
0 0 * * * (每天0点执行)
<a-link href="https://crontab.guru/" target="_blank" style="color: #1890ff">在线生成工具</a-link>
<a-link
href="https://crontab.guru/"
target="_blank"
style="color: #1890ff"
>在线生成工具</a-link
>
</div>
</a-form-item>
<!-- 时区 -->
<a-form-item label="时区" name="timezone">
<a-select v-model:value="form.timezone" placeholder="请选择时区" show-search :filter-option="filterOption">
<a-select-option value="Asia/Shanghai">Asia/Shanghai (中国)</a-select-option>
<a-select
v-model:value="form.timezone"
placeholder="请选择时区"
show-search
:filter-option="filterOption"
>
<a-select-option value="Asia/Shanghai"
>Asia/Shanghai (中国)</a-select-option
>
<a-select-option value="UTC">UTC</a-select-option>
<a-select-option value="America/New_York">America/New_York</a-select-option>
<a-select-option value="America/New_York"
>America/New_York</a-select-option
>
</a-select>
</a-form-item>
<!-- 描述 -->
<a-form-item label="任务描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入任务描述" :rows="3" maxlength="200"
show-count />
<a-textarea
v-model:value="form.description"
placeholder="请输入任务描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
<!-- 高级选项 -->
@@ -71,291 +127,326 @@
<!-- 启用状态 -->
<a-form-item label="启用状态" name="is_active">
<sc-select v-model:value="form.is_active" source-type="dictionary" dictionary-code="yes_no" placeholder="请选择状态" allow-clear />
<sc-select
v-model:value="form.is_active"
source-type="dictionary"
dictionary-code="yes_no"
placeholder="请选择状态"
allow-clear
/>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
</a-form>
<!-- 底部按钮 -->
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import systemApi from '@/api/system'
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import systemApi from "@/api/system";
const props = defineProps({
visible: {
type: Boolean,
default: false
default: false,
},
record: {
type: Object,
default: null
}
})
default: null,
},
});
const emit = defineEmits(['update:visible', 'success'])
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? '编辑定时任务' : '新增定时任务'
})
return isEdit.value ? "编辑定时任务" : "新增定时任务";
});
// 表单数据
const form = ref({
id: '',
name: '',
command: '',
type: 'command',
expression: '* * * * *',
timezone: 'Asia/Shanghai',
description: '',
id: "",
name: "",
command: "",
type: "command",
expression: "* * * * *",
timezone: "Asia/Shanghai",
description: "",
is_active: null,
run_in_background: false,
without_overlapping: false,
only_one: false,
sort: 0
})
sort: 0,
});
// Cron 表达式验证函数
const validateCronExpression = (rule, value) => {
if (!value || !value.trim()) {
return Promise.reject('请输入Cron表达式')
return Promise.reject("请输入Cron表达式");
}
const parts = value.trim().split(/\s+/)
const parts = value.trim().split(/\s+/);
if (parts.length !== 5) {
return Promise.reject('Cron表达式应由5部分组成分 时 日 月 周')
return Promise.reject("Cron表达式应由5部分组成分 时 日 月 周");
}
const [minute, hour, day, month, weekday] = parts
const [minute, hour, day, month, weekday] = parts;
// 验证分钟 (0-59)
if (!validateCronPart(minute, 0, 59)) {
return Promise.reject('分钟部分格式不正确 (0-59)')
return Promise.reject("分钟部分格式不正确 (0-59)");
}
// 验证小时 (0-23)
if (!validateCronPart(hour, 0, 23)) {
return Promise.reject('小时部分格式不正确 (0-23)')
return Promise.reject("小时部分格式不正确 (0-23)");
}
// 验证日 (1-31)
if (!validateCronPart(day, 1, 31)) {
return Promise.reject('日部分格式不正确 (1-31)')
return Promise.reject("日部分格式不正确 (1-31)");
}
// 验证月 (1-12)
if (!validateCronPart(month, 1, 12)) {
return Promise.reject('月部分格式不正确 (1-12)')
return Promise.reject("月部分格式不正确 (1-12)");
}
// 验证周 (0-6 或 SUN-SAT)
if (!validateCronPart(weekday, 0, 6, true)) {
return Promise.reject('周部分格式不正确 (0-6 或 SUN-SAT)')
return Promise.reject("周部分格式不正确 (0-6 或 SUN-SAT)");
}
return Promise.resolve()
}
return Promise.resolve();
};
// 验证 Cron 单个部分
const validateCronPart = (part, min, max, allowDayNames = false) => {
// 支持 * (所有值)
if (part === '*') return true
if (part === "*") return true;
// 支持逗号分隔的列表
const listItems = part.split(',')
const listItems = part.split(",");
for (const item of listItems) {
// 支持步长 (step),如 */5 或 0-10/2
if (item.includes('/')) {
const [range, step] = item.split('/')
if (!range || !step) return false
if (!validateCronRange(range, min, max, allowDayNames)) return false
if (!/^\d+$/.test(step)) return false
continue
if (item.includes("/")) {
const [range, step] = item.split("/");
if (!range || !step) return false;
if (!validateCronRange(range, min, max, allowDayNames))
return false;
if (!/^\d+$/.test(step)) return false;
continue;
}
// 支持范围,如 1-5
if (item.includes('-')) {
if (!validateCronRange(item, min, max, allowDayNames)) return false
continue
if (item.includes("-")) {
if (!validateCronRange(item, min, max, allowDayNames)) return false;
continue;
}
// 支持星期名称
if (allowDayNames) {
const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
const dayNamesLower = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const dayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const dayNamesLower = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
];
if (dayNames.includes(item) || dayNamesLower.includes(item)) {
continue
continue;
}
}
// 检查是否为有效数字
if (!/^\d+$/.test(item)) return false
const num = parseInt(item, 10)
if (num < min || num > max) return false
if (!/^\d+$/.test(item)) return false;
const num = parseInt(item, 10);
if (num < min || num > max) return false;
}
return true
}
return true;
};
// 验证 Cron 范围
const validateCronRange = (range, min, max, allowDayNames = false) => {
// 处理 * 范围
if (range === '*') return true
if (range === "*") return true;
const parts = range.split('-')
if (parts.length !== 2) return false
const parts = range.split("-");
if (parts.length !== 2) return false;
const [start, end] = parts
const [start, end] = parts;
// 支持星期名称范围
if (allowDayNames) {
const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
const startIdx = dayNames.indexOf(start)
const endIdx = dayNames.indexOf(end)
const dayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const startIdx = dayNames.indexOf(start);
const endIdx = dayNames.indexOf(end);
if (startIdx !== -1 && endIdx !== -1) {
return startIdx <= endIdx
return startIdx <= endIdx;
}
}
// 数字范围验证
if (!/^\d+$/.test(start) || !/^\d+$/.test(end)) return false
const startNum = parseInt(start, 10)
const endNum = parseInt(end, 10)
if (!/^\d+$/.test(start) || !/^\d+$/.test(end)) return false;
const startNum = parseInt(start, 10);
const endNum = parseInt(end, 10);
return startNum >= min && startNum <= max && endNum >= min && endNum <= max && startNum <= endNum
}
return (
startNum >= min &&
startNum <= max &&
endNum >= min &&
endNum <= max &&
startNum <= endNum
);
};
// 验证规则
const rules = {
name: [
{ required: true, message: '请输入任务名称', trigger: 'blur' },
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择任务类型', trigger: 'change' }
],
command: [
{ required: true, message: '请输入命令或类名', trigger: 'blur' }
{ required: true, message: "请输入任务名称", trigger: "blur" },
{
min: 2,
max: 100,
message: "长度在 2 到 100 个字符",
trigger: "blur",
},
],
type: [{ required: true, message: "请选择任务类型", trigger: "change" }],
command: [{ required: true, message: "请输入命令或类名", trigger: "blur" }],
expression: [
{ required: true, message: '请输入Cron表达式', trigger: 'blur' },
{ validator: validateCronExpression, trigger: 'blur' }
]
}
{ required: true, message: "请输入Cron表达式", trigger: "blur" },
{ validator: validateCronExpression, trigger: "blur" },
],
};
// 下拉筛选
const filterOption = (input, option) => {
return option.value.toLowerCase().includes(input.toLowerCase())
}
return option.value.toLowerCase().includes(input.toLowerCase());
};
// 重置表单
const resetForm = () => {
form.value = {
id: '',
name: '',
command: '',
type: 'command',
expression: '* * * * *',
timezone: 'Asia/Shanghai',
description: '',
id: "",
name: "",
command: "",
type: "command",
expression: "* * * * *",
timezone: "Asia/Shanghai",
description: "",
is_active: null,
run_in_background: false,
without_overlapping: false,
only_one: false,
sort: 0
}
formRef.value?.clearValidate()
}
sort: 0,
};
formRef.value?.clearValidate();
};
// 设置数据
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
name: data.name || '',
command: data.command || '',
type: data.type || 'command',
expression: data.expression || '* * * * *',
timezone: data.timezone || 'Asia/Shanghai',
description: data.description || '',
id: data.id || "",
name: data.name || "",
command: data.command || "",
type: data.type || "command",
expression: data.expression || "* * * * *",
timezone: data.timezone || "Asia/Shanghai",
description: data.description || "",
is_active: data.is_active !== undefined ? data.is_active : null,
run_in_background: data.run_in_background || false,
without_overlapping: data.without_overlapping || false,
only_one: data.only_one || false,
sort: data.sort !== undefined ? data.sort : 0
}
sort: data.sort !== undefined ? data.sort : 0,
};
}
}
};
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
await formRef.value.validate();
isSaving.value = true
isSaving.value = true;
const submitData = { ...form.value }
const submitData = { ...form.value };
let res = {}
let res = {};
if (isEdit.value) {
res = await systemApi.tasks.edit.put(form.value.id, submitData)
res = await systemApi.tasks.edit.put(form.value.id, submitData);
} else {
res = await systemApi.tasks.add.post(submitData)
res = await systemApi.tasks.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
console.log('表单验证失败:', error)
console.log("表单验证失败:", error);
} else {
console.error('提交失败:', error)
message.error('操作失败')
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false
isSaving.value = false;
}
}
};
// 取消
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
resetForm();
emit("update:visible", false);
};
// 监听 visible 变化
watch(() => props.visible, (newVal) => {
if (newVal) {
if (props.record) {
setData(props.record)
} else {
resetForm()
watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
}
}, { immediate: true })
},
{ immediate: true },
);
</script>
<style scoped lang="scss">

View File

@@ -3,13 +3,28 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="任务名称/命令" allow-clear style="width: 180px" />
<a-select v-model:value="searchForm.type" placeholder="任务类型" allow-clear style="width: 120px">
<a-input
v-model:value="searchForm.keyword"
placeholder="任务名称/命令"
allow-clear
style="width: 180px"
/>
<a-select
v-model:value="searchForm.type"
placeholder="任务类型"
allow-clear
style="width: 120px"
>
<a-select-option value="command">命令</a-select-option>
<a-select-option value="job">任务</a-select-option>
<a-select-option value="closure">闭包</a-select-option>
</a-select>
<a-select v-model:value="searchForm.is_active" placeholder="状态" allow-clear style="width: 100px">
<a-select
v-model:value="searchForm.is_active"
placeholder="状态"
allow-clear
style="width: 100px"
>
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
</a-select>
@@ -52,15 +67,29 @@
</div>
<div class="table-content">
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
:pagination="pagination" :row-selection="rowSelection" :row-key="rowKey" @refresh="refreshTable"
@paginationChange="handlePaginationChange">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:row-key="rowKey"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<template #type="{ record }">
<a-tag :color="getTypeColor(record.type)">{{ getTypeText(record.type) }}</a-tag>
<a-tag :color="getTypeColor(record.type)">{{
getTypeText(record.type)
}}</a-tag>
</template>
<template #is_active="{ record }">
<a-switch :checked="record.is_active" :disabled="!canEdit" @change="handleToggleStatus(record)" />
<a-switch
:checked="record.is_active"
:disabled="!canEdit"
@change="handleToggleStatus(record)"
/>
</template>
<template #expression="{ record }">
@@ -68,7 +97,11 @@
</template>
<template #last_run_at="{ record }">
{{ record.last_run_at ? formatDate(record.last_run_at) : '-' }}
{{
record.last_run_at
? formatDate(record.last_run_at)
: "-"
}}
</template>
<template #next_run_at="{ record }">
@@ -83,12 +116,20 @@
<a-space>
<a-tooltip title="成功次数">
<a-tag color="success">
<CheckCircleOutlined /> {{ record.run_count || 0 }}
<CheckCircleOutlined />
{{ record.run_count || 0 }}
</a-tag>
</a-tooltip>
<a-tooltip title="失败次数">
<a-tag :color="record.failed_count > 0 ? 'error' : 'default'">
<CloseCircleOutlined /> {{ record.failed_count || 0 }}
<a-tag
:color="
record.failed_count > 0
? 'error'
: 'default'
"
>
<CloseCircleOutlined />
{{ record.failed_count || 0 }}
</a-tag>
</a-tooltip>
</a-space>
@@ -96,18 +137,32 @@
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<a-button
type="link"
size="small"
@click="handleView(record)"
>
<EyeOutlined />查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<EditOutlined />编辑
</a-button>
<a-popconfirm title="确定立即执行该任务吗?" @confirm="handleRun(record)">
<a-popconfirm
title="确定立即执行该任务吗?"
@confirm="handleRun(record)"
>
<a-button type="link" size="small">
<PlayCircleOutlined />执行
</a-button>
</a-popconfirm>
<a-popconfirm title="确定删除该任务吗?" @confirm="handleDelete(record)">
<a-popconfirm
title="确定删除该任务吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
@@ -119,15 +174,24 @@
</div>
<!-- 新增/编辑弹窗 -->
<TaskDialog v-if="dialog.save" v-model:visible="dialog.save" :record="currentRecord" @success="handleSaveSuccess" />
<TaskDialog
v-if="dialog.save"
v-model:visible="dialog.save"
:record="currentRecord"
@success="handleSaveSuccess"
/>
<!-- 查看详情弹窗 -->
<TaskDetailDialog v-if="dialog.detail" v-model:visible="dialog.detail" :record="currentRecord" />
<TaskDetailDialog
v-if="dialog.detail"
v-model:visible="dialog.detail"
:record="currentRecord"
/>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, reactive } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
@@ -140,13 +204,13 @@ import {
EyeOutlined,
PlayCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons-vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import scTable from '@/components/scTable/index.vue'
import TaskDialog from './components/TaskDialog.vue'
import TaskDetailDialog from './components/TaskDetailDialog.vue'
CloseCircleOutlined,
} from "@ant-design/icons-vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import scTable from "@/components/scTable/index.vue";
import TaskDialog from "./components/TaskDialog.vue";
import TaskDetailDialog from "./components/TaskDetailDialog.vue";
// ===== useTable Hook =====
const {
@@ -160,202 +224,261 @@ const {
handleSearch,
handleReset,
handlePaginationChange,
refreshTable
refreshTable,
} = useTable({
api: systemApi.tasks.list.get,
searchForm: {
keyword: '',
keyword: "",
type: undefined,
is_active: undefined
is_active: undefined,
},
columns: [],
needPagination: true,
needSelection: true
})
needSelection: true,
});
// ===== 表格列配置 =====
const rowKey = 'id'
const rowKey = "id";
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{ title: '任务名称', dataIndex: 'name', key: 'name', width: 150, ellipsis: true },
{ title: '任务类型', dataIndex: 'type', key: 'type', width: 100, align: 'center', slot: 'type' },
{ title: '命令/类', dataIndex: 'command', key: 'command', ellipsis: true },
{ title: 'Cron表达式', dataIndex: 'expression', key: 'expression', width: 120, align: 'center', slot: 'expression' },
{ title: '状态', dataIndex: 'is_active', key: 'is_active', width: 80, align: 'center', slot: 'is_active' },
{ title: '上次运行', dataIndex: 'last_run_at', key: 'last_run_at', width: 160, align: 'center', slot: 'last_run_at' },
{ title: '下次运行', dataIndex: 'next_run_at', key: 'next_run_at', width: 160, align: 'center', slot: 'next_run_at' },
{ title: '统计', dataIndex: 'statistics', key: 'statistics', width: 120, align: 'center', slot: 'statistics' },
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, align: 'center', fixed: 'right', slot: 'action' }
]
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{
title: "任务名称",
dataIndex: "name",
key: "name",
width: 150,
ellipsis: true,
},
{
title: "任务类型",
dataIndex: "type",
key: "type",
width: 100,
align: "center",
slot: "type",
},
{ title: "命令/类", dataIndex: "command", key: "command", ellipsis: true },
{
title: "Cron表达式",
dataIndex: "expression",
key: "expression",
width: 120,
align: "center",
slot: "expression",
},
{
title: "状态",
dataIndex: "is_active",
key: "is_active",
width: 80,
align: "center",
slot: "is_active",
},
{
title: "上次运行",
dataIndex: "last_run_at",
key: "last_run_at",
width: 160,
align: "center",
slot: "last_run_at",
},
{
title: "下次运行",
dataIndex: "next_run_at",
key: "next_run_at",
width: 160,
align: "center",
slot: "next_run_at",
},
{
title: "统计",
dataIndex: "statistics",
key: "statistics",
width: 120,
align: "center",
slot: "statistics",
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 200,
align: "center",
fixed: "right",
slot: "action",
},
];
// ===== 弹窗状态 =====
const dialog = reactive({
save: false,
detail: false
})
detail: false,
});
const currentRecord = ref(null)
const currentRecord = ref(null);
// ===== 权限控制 =====
const canEdit = ref(true)
const canEdit = ref(true);
// ===== 方法:获取任务类型文本 =====
const getTypeText = (type) => {
const typeMap = {
command: '命令',
job: '任务',
closure: '闭包'
}
return typeMap[type] || type
}
command: "命令",
job: "任务",
closure: "闭包",
};
return typeMap[type] || type;
};
// ===== 方法:获取任务类型颜色 =====
const getTypeColor = (type) => {
const colorMap = {
command: 'blue',
job: 'green',
closure: 'orange'
}
return colorMap[type] || 'default'
}
command: "blue",
job: "green",
closure: "orange",
};
return colorMap[type] || "default";
};
// ===== 方法:格式化日期 =====
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN");
};
// ===== 方法:切换状态 =====
const handleToggleStatus = async (record) => {
try {
const res = await systemApi.tasks.batchStatus.post({
ids: [record.id],
status: record.is_active
})
status: record.is_active,
});
if (res.code === 200) {
message.success(record.is_active ? '已启用' : '已禁用')
refreshTable()
message.success(record.is_active ? "已启用" : "已禁用");
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
message.error('操作失败')
message.error("操作失败");
}
}
};
// ===== 方法:新增 =====
const handleAdd = () => {
currentRecord.value = null
dialog.save = true
}
currentRecord.value = null;
dialog.save = true;
};
// ===== 方法:编辑 =====
const handleEdit = (record) => {
currentRecord.value = { ...record }
dialog.save = true
}
currentRecord.value = { ...record };
dialog.save = true;
};
// ===== 方法:查看 =====
const handleView = (record) => {
currentRecord.value = { ...record }
dialog.detail = true
}
currentRecord.value = { ...record };
dialog.detail = true;
};
// ===== 方法:删除 =====
const handleDelete = async (record) => {
try {
const res = await systemApi.tasks.delete.delete(record.id)
const res = await systemApi.tasks.delete.delete(record.id);
if (res.code === 200) {
message.success('删除成功')
refreshTable()
message.success("删除成功");
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
message.error('删除失败')
message.error("删除失败");
}
}
};
// ===== 方法:执行任务 =====
const handleRun = async (record) => {
try {
const res = await systemApi.tasks.run.post(record.id)
const res = await systemApi.tasks.run.post(record.id);
if (res.code === 200) {
message.success('任务执行成功')
refreshTable()
message.success("任务执行成功");
refreshTable();
} else {
message.error(res.message || '任务执行失败')
message.error(res.message || "任务执行失败");
}
} catch (error) {
message.error('任务执行失败')
message.error("任务执行失败");
}
}
};
// ===== 方法:批量删除 =====
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的任务')
return
message.warning("请选择要删除的任务");
return;
}
Modal.confirm({
title: '确认删除',
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个任务吗?`,
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await systemApi.tasks.batchDelete.post({ ids })
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.tasks.batchDelete.post({ ids });
if (res.code === 200) {
message.success('删除成功')
selectedRows.value = []
refreshTable()
message.success("删除成功");
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '删除失败')
message.error(res.message || "删除失败");
}
} catch (error) {
message.error('删除失败')
message.error("删除失败");
}
}
})
}
},
});
};
// ===== 方法:批量更新状态 =====
const handleBatchStatus = (status) => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的任务')
return
message.warning("请选择要操作的任务");
return;
}
const statusText = status ? '启用' : '禁用'
const statusText = status ? "启用" : "禁用";
Modal.confirm({
title: `确认${statusText}`,
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个任务吗?`,
onOk: async () => {
try {
const ids = selectedRows.value.map(item => item.id)
const res = await systemApi.tasks.batchStatus.post({ ids, status })
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.tasks.batchStatus.post({
ids,
status,
});
if (res.code === 200) {
message.success(`${statusText}成功`)
selectedRows.value = []
refreshTable()
message.success(`${statusText}成功`);
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || '操作失败')
message.error(res.message || "操作失败");
}
} catch (error) {
message.error('操作失败')
message.error("操作失败");
}
}
})
}
},
});
};
// ===== 方法:保存成功回调 =====
const handleSaveSuccess = () => {
dialog.save = false
refreshTable()
}
dialog.save = false;
refreshTable();
};
</script>
<style scoped lang="scss">
@@ -364,7 +487,7 @@ const handleSaveSuccess = () => {
padding: 2px 6px;
background: #f5f5f5;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
}