更新功能:数据字典和定时任务
This commit is contained in:
@@ -91,6 +91,7 @@ class User extends Controller
|
||||
'real_name' => 'nullable|string|max:50',
|
||||
'email' => 'nullable|email|unique:auth_users,email,' . $id,
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'avatar' => 'nullable|string|max:500',
|
||||
'department_id' => 'nullable|integer|exists:auth_departments,id',
|
||||
'role_ids' => 'nullable|array',
|
||||
'role_ids.*' => 'integer|exists:auth_roles,id',
|
||||
|
||||
@@ -20,7 +20,8 @@ class DictionaryService
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($params['status']) && $params['status'] !== '') {
|
||||
// 处理状态筛选:只接受布尔值或数字1/0
|
||||
if (array_key_exists('status', $params) && is_bool($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
@@ -37,20 +38,48 @@ class DictionaryService
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
return Dictionary::where('status', true)
|
||||
->orderBy('sort')
|
||||
->get()
|
||||
->toArray();
|
||||
$cacheKey = 'system:dictionaries:all';
|
||||
$dictionaries = Cache::get($cacheKey);
|
||||
|
||||
if ($dictionaries === null) {
|
||||
$dictionaries = Dictionary::where('status', true)
|
||||
->orderBy('sort')
|
||||
->get()
|
||||
->toArray();
|
||||
Cache::put($cacheKey, $dictionaries, 3600);
|
||||
}
|
||||
|
||||
return $dictionaries;
|
||||
}
|
||||
|
||||
public function getById(int $id): ?Dictionary
|
||||
{
|
||||
return Dictionary::with('items')->find($id);
|
||||
$cacheKey = 'system:dictionaries:' . $id;
|
||||
$dictionary = Cache::get($cacheKey);
|
||||
|
||||
if ($dictionary === null) {
|
||||
$dictionary = Dictionary::with('items')->find($id);
|
||||
if ($dictionary) {
|
||||
Cache::put($cacheKey, $dictionary->toArray(), 3600);
|
||||
}
|
||||
}
|
||||
|
||||
return $dictionary ? $dictionary : null;
|
||||
}
|
||||
|
||||
public function getByCode(string $code): ?Dictionary
|
||||
{
|
||||
return Dictionary::where('code', $code)->first();
|
||||
$cacheKey = 'system:dictionaries:code:' . $code;
|
||||
$dictionary = Cache::get($cacheKey);
|
||||
|
||||
if ($dictionary === null) {
|
||||
$dictionary = Dictionary::where('code', $code)->first();
|
||||
if ($dictionary) {
|
||||
Cache::put($cacheKey, $dictionary->toArray(), 3600);
|
||||
}
|
||||
}
|
||||
|
||||
return $dictionary;
|
||||
}
|
||||
|
||||
public function getItemsByCode(string $code): array
|
||||
@@ -125,11 +154,26 @@ class DictionaryService
|
||||
return true;
|
||||
}
|
||||
|
||||
private function clearCache(): void
|
||||
private function clearCache($dictionaryId = null): void
|
||||
{
|
||||
$codes = Dictionary::pluck('code')->toArray();
|
||||
foreach ($codes as $code) {
|
||||
Cache::forget('system:dictionary:' . $code);
|
||||
// 清理所有字典列表缓存
|
||||
Cache::forget('system:dictionaries:all');
|
||||
|
||||
if ($dictionaryId) {
|
||||
// 清理特定字典的缓存
|
||||
$dictionary = Dictionary::find($dictionaryId);
|
||||
if ($dictionary) {
|
||||
Cache::forget('system:dictionaries:' . $dictionaryId);
|
||||
Cache::forget('system:dictionaries:code:' . $dictionary->code);
|
||||
Cache::forget('system:dictionary:' . $dictionary->code);
|
||||
}
|
||||
} else {
|
||||
// 清理所有字典缓存
|
||||
$codes = Dictionary::pluck('code')->toArray();
|
||||
foreach ($codes as $code) {
|
||||
Cache::forget('system:dictionary:' . $code);
|
||||
Cache::forget('system:dictionaries:code:' . $code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +185,8 @@ class DictionaryService
|
||||
$query->where('dictionary_id', $params['dictionary_id']);
|
||||
}
|
||||
|
||||
if (isset($params['status']) && $params['status'] !== '') {
|
||||
// 处理状态筛选:只接受布尔值或数字1/0
|
||||
if (array_key_exists('status', $params) && is_bool($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
@@ -167,7 +212,7 @@ class DictionaryService
|
||||
])->validate();
|
||||
|
||||
$item = DictionaryItem::create($data);
|
||||
$this->clearCache();
|
||||
$this->clearCache($data['dictionary_id']);
|
||||
return $item;
|
||||
}
|
||||
|
||||
@@ -182,29 +227,46 @@ class DictionaryService
|
||||
])->validate();
|
||||
|
||||
$item->update($data);
|
||||
$this->clearCache();
|
||||
$this->clearCache($item->dictionary_id);
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function deleteItem(int $id): bool
|
||||
{
|
||||
$item = DictionaryItem::findOrFail($id);
|
||||
$dictionaryId = $item->dictionary_id;
|
||||
$item->delete();
|
||||
$this->clearCache();
|
||||
$this->clearCache($dictionaryId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function batchDeleteItems(array $ids): bool
|
||||
{
|
||||
$items = DictionaryItem::whereIn('id', $ids)->get();
|
||||
$dictionaryIds = $items->pluck('dictionary_id')->unique()->toArray();
|
||||
|
||||
DictionaryItem::whereIn('id', $ids)->delete();
|
||||
$this->clearCache();
|
||||
|
||||
// 清理相关字典的缓存
|
||||
foreach ($dictionaryIds as $dictionaryId) {
|
||||
$this->clearCache($dictionaryId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function batchUpdateItemsStatus(array $ids, bool $status): bool
|
||||
{
|
||||
$items = DictionaryItem::whereIn('id', $ids)->get();
|
||||
$dictionaryIds = $items->pluck('dictionary_id')->unique()->toArray();
|
||||
|
||||
DictionaryItem::whereIn('id', $ids)->update(['status' => $status]);
|
||||
$this->clearCache();
|
||||
|
||||
// 清理相关字典的缓存
|
||||
foreach ($dictionaryIds as $dictionaryId) {
|
||||
$this->clearCache($dictionaryId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ class LogService
|
||||
$query->where('action', $params['action']);
|
||||
}
|
||||
|
||||
if (!empty($params['method'])) {
|
||||
$query->where('method', $params['method']);
|
||||
}
|
||||
|
||||
if (!empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace App\Services\System;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Facades\Image;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
|
||||
class UploadService
|
||||
{
|
||||
@@ -17,6 +18,7 @@ class UploadService
|
||||
public function __construct()
|
||||
{
|
||||
$this->disk = Storage::disk('public');
|
||||
$this->imageManager = new ImageManager(new Driver());
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file, string $directory = 'uploads', array $options = []): array
|
||||
@@ -135,17 +137,14 @@ class UploadService
|
||||
$width = $options['width'] ?? null;
|
||||
$height = $options['height'] ?? null;
|
||||
|
||||
$image = Image::make($file);
|
||||
$image = $this->imageManager->read($file);
|
||||
|
||||
if ($width || $height) {
|
||||
$image->resize($width, $height, function ($constraint) {
|
||||
$constraint->aspectRatio();
|
||||
$constraint->upsize();
|
||||
});
|
||||
$image->scale($width, $height);
|
||||
}
|
||||
|
||||
$image->encode(null, $quality);
|
||||
$this->disk->put($filePath, (string) $image);
|
||||
$encoded = $image->toJpeg(quality: $quality);
|
||||
$this->disk->put($filePath, (string) $encoded);
|
||||
}
|
||||
|
||||
public function getFileUrl(string $path): string
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"hhxsv5/laravel-s": "^3.8",
|
||||
"intervention/image": "^3.11",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"nwidart/laravel-modules": "^12.0",
|
||||
|
||||
@@ -576,6 +576,13 @@ class SystemSeeder extends Seeder
|
||||
'sort' => 7,
|
||||
'status' => 1,
|
||||
],
|
||||
[
|
||||
'name' => '配置分组',
|
||||
'code' => 'config_group',
|
||||
'description' => '系统配置分组类型',
|
||||
'sort' => 8,
|
||||
'status' => 1,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($dictionaries as $dictionary) {
|
||||
@@ -645,6 +652,14 @@ class SystemSeeder extends Seeder
|
||||
['label' => '否', 'value' => 0, 'sort' => 2, 'status' => 1],
|
||||
];
|
||||
break;
|
||||
|
||||
case 'config_group':
|
||||
$items = [
|
||||
['label' => '网站设置', 'value' => 'site', 'sort' => 1, 'status' => 1],
|
||||
['label' => '上传设置', 'value' => 'upload', 'sort' => 2, 'status' => 1],
|
||||
['label' => '系统设置', 'value' => 'system', 'sort' => 3, 'status' => 1],
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<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-form-item label="配置名称" name="name" required>
|
||||
<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-form-item>
|
||||
|
||||
<!-- 配置分组 -->
|
||||
<a-form-item label="配置分组" name="group" required>
|
||||
<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-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="text">文本</a-select-option>
|
||||
<a-select-option value="file">文件</a-select-option>
|
||||
<a-select-option value="image">图片</a-select-option>
|
||||
<a-select-option value="select">选择框</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 配置值 -->
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<!-- 数字 -->
|
||||
<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-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-form-item>
|
||||
|
||||
<!-- 默认值 -->
|
||||
<a-form-item label="默认值" name="default_value">
|
||||
<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-form-item>
|
||||
|
||||
<!-- 状态 -->
|
||||
<a-form-item label="状态" name="status">
|
||||
<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-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-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 dictionaryCache from '@/utils/dictionaryCache'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref(null)
|
||||
const isSaving = ref(false)
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const title = computed(() => {
|
||||
return isEdit.value ? '编辑配置' : '新增配置'
|
||||
})
|
||||
|
||||
// 配置分组选项
|
||||
const groupOptions = ref([])
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
key: '',
|
||||
group: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
default_value: '',
|
||||
description: '',
|
||||
status: true,
|
||||
sort: 0,
|
||||
options: []
|
||||
})
|
||||
|
||||
// 选项文本(用于选择框类型)
|
||||
const optionsText = ref('')
|
||||
|
||||
// 计算属性:状态开关
|
||||
const statusChecked = computed({
|
||||
get: () => form.value.status === true,
|
||||
set: (val) => {
|
||||
form.value.status = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:值开关(布尔类型)
|
||||
const valueChecked = computed({
|
||||
get: () => form.value.value === '1' || form.value.value === true,
|
||||
set: (val) => {
|
||||
form.value.value = val ? '1' : '0'
|
||||
}
|
||||
})
|
||||
|
||||
// 加载配置分组
|
||||
const loadGroups = async () => {
|
||||
const groups = await dictionaryCache.getItemsByCode('config_group')
|
||||
groupOptions.value = groups.map(item => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}))
|
||||
}
|
||||
|
||||
// 验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ 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' }
|
||||
],
|
||||
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: '',
|
||||
status: true,
|
||||
sort: 0,
|
||||
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 || '',
|
||||
status: data.status !== undefined ? data.status : true,
|
||||
sort: data.sort !== undefined ? data.sort : 0,
|
||||
options: 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()
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
// 处理选项
|
||||
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'
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...form.value,
|
||||
value: submitValue
|
||||
}
|
||||
|
||||
let res = {}
|
||||
if (isEdit.value) {
|
||||
res = await systemApi.config.update.put(form.value.id, submitData)
|
||||
} else {
|
||||
res = await systemApi.config.add.post(submitData)
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
message.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
console.log('表单验证失败:', error)
|
||||
} else {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
resetForm()
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
loadGroups()
|
||||
if (props.record) {
|
||||
setData(props.record)
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="config-form-container">
|
||||
<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>
|
||||
|
||||
<!-- 数字类型 -->
|
||||
<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>
|
||||
|
||||
<!-- 文件类型 -->
|
||||
<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>
|
||||
</template>
|
||||
</a-input>
|
||||
<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">
|
||||
<div class="image-upload-wrapper">
|
||||
<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" />
|
||||
</div>
|
||||
<a-button type="link" @click="handleUpload(config)">选择图片</a-button>
|
||||
</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>
|
||||
|
||||
<!-- 选择框类型 -->
|
||||
<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>
|
||||
|
||||
<div class="form-actions">
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="saving" @click="handleSave">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存配置
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><RedoOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</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'
|
||||
|
||||
const props = defineProps({
|
||||
group: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
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 })
|
||||
if (res.code === 200) {
|
||||
configs.value = res.data.list || []
|
||||
|
||||
// 初始化表单数据
|
||||
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
|
||||
} else {
|
||||
formData[config.key] = config.value || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
message.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
// 准备提交数据
|
||||
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)
|
||||
}
|
||||
|
||||
updates.push({
|
||||
key: config.key,
|
||||
value: value
|
||||
})
|
||||
})
|
||||
|
||||
const res = await systemApi.config.batchUpdate.post({ configs: updates })
|
||||
if (res.code === 200) {
|
||||
message.success('保存成功')
|
||||
emit('refresh')
|
||||
} else {
|
||||
message.error(res.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const handleReset = () => {
|
||||
loadConfigs()
|
||||
message.info('已重置')
|
||||
}
|
||||
|
||||
// 上传处理(占位)
|
||||
const handleUpload = (config) => {
|
||||
message.info(`${config.name}上传功能待实现`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.config-form-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.image-upload-wrapper {
|
||||
.image-preview {
|
||||
margin-top: 8px;
|
||||
img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<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-form-item label="字典名称" name="name" required>
|
||||
<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-form-item>
|
||||
|
||||
<!-- 排序 -->
|
||||
<a-form-item label="排序" name="sort">
|
||||
<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">
|
||||
<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-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-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
dictionaryList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Emits =====
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
// ===== 状态 =====
|
||||
const formRef = ref(null)
|
||||
const isSaving = ref(false)
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const title = computed(() => {
|
||||
return isEdit.value ? '编辑字典类型' : '新增字典类型'
|
||||
})
|
||||
|
||||
// ===== 表单数据 =====
|
||||
const form = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: true,
|
||||
sort: 0
|
||||
})
|
||||
|
||||
// ===== 计算属性:状态开关 =====
|
||||
const statusChecked = computed({
|
||||
get: () => form.value.status === true,
|
||||
set: (val) => {
|
||||
form.value.status = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 验证规则 =====
|
||||
// 编码唯一性验证
|
||||
const validateCodeUnique = async (rule, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
|
||||
// 检查编码是否已存在(编辑时排除自己)
|
||||
const exists = props.dictionaryList.some(
|
||||
item => item.code === value && item.id !== props.record?.id
|
||||
)
|
||||
|
||||
if (exists) {
|
||||
return Promise.reject('该编码已存在,请使用其他编码')
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入字典名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '字典名称长度在 2 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: '请输入字典编码', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
|
||||
message: '编码格式不正确,只能包含字母、数字、下划线,且必须以字母开头',
|
||||
trigger: 'blur'
|
||||
},
|
||||
{ validator: validateCodeUnique, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// ===== 方法:重置表单 =====
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
id: '',
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: true,
|
||||
sort: 0
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// ===== 方法:设置数据(编辑时) =====
|
||||
const setData = (data) => {
|
||||
if (data) {
|
||||
form.value = {
|
||||
id: data.id || '',
|
||||
name: data.name || '',
|
||||
code: data.code || '',
|
||||
description: data.description || '',
|
||||
status: data.status !== undefined ? data.status : true,
|
||||
sort: data.sort !== undefined ? data.sort : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:提交表单 =====
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// 验证表单
|
||||
await formRef.value.validate()
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const submitData = {
|
||||
name: form.value.name,
|
||||
code: form.value.code,
|
||||
description: form.value.description,
|
||||
status: form.value.status,
|
||||
sort: form.value.sort
|
||||
}
|
||||
|
||||
let res = {}
|
||||
if (isEdit.value) {
|
||||
// 编辑
|
||||
res = await systemApi.dictionaries.edit.put(form.value.id, submitData)
|
||||
} else {
|
||||
// 新增
|
||||
res = await systemApi.dictionaries.add.post(submitData)
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
message.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
// 表单验证失败
|
||||
console.log('表单验证失败:', error)
|
||||
} else {
|
||||
// API 调用失败
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:取消 =====
|
||||
const handleCancel = () => {
|
||||
resetForm()
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// ===== 监听 visible 变化 =====
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
// 打开弹窗时,如果有 record 则设置数据
|
||||
if (props.record) {
|
||||
setData(props.record)
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<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-form-item label="标签名称" name="label" required>
|
||||
<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 />
|
||||
<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" />
|
||||
</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">
|
||||
设置为默认项后,同一字典内的其他默认项将自动取消
|
||||
</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-form-item>
|
||||
|
||||
<!-- 状态 -->
|
||||
<a-form-item label="状态" name="status">
|
||||
<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-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-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
dictionaryId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
itemList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Emits =====
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
// ===== 状态 =====
|
||||
const formRef = ref(null)
|
||||
const isSaving = ref(false)
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const title = computed(() => {
|
||||
return isEdit.value ? '编辑字典项' : '新增字典项'
|
||||
})
|
||||
|
||||
// ===== 表单数据 =====
|
||||
const form = ref({
|
||||
id: '',
|
||||
label: '',
|
||||
value: '',
|
||||
color: '',
|
||||
description: '',
|
||||
is_default: false,
|
||||
status: true,
|
||||
sort: 0,
|
||||
dictionary_id: null
|
||||
})
|
||||
|
||||
// ===== 计算属性:状态开关 =====
|
||||
const statusChecked = computed({
|
||||
get: () => form.value.status === true,
|
||||
set: (val) => {
|
||||
form.value.status = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 计算属性:默认项开关 =====
|
||||
const isDefaultChecked = computed({
|
||||
get: () => form.value.is_default === true,
|
||||
set: (val) => {
|
||||
form.value.is_default = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 验证规则 =====
|
||||
// 数据值唯一性验证
|
||||
const validateValueUnique = async (rule, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
|
||||
// 检查数据值是否已存在(同一字典内,编辑时排除自己)
|
||||
const exists = props.itemList.some(
|
||||
item => item.value === value && item.id !== props.record?.id
|
||||
)
|
||||
|
||||
if (exists) {
|
||||
return Promise.reject('该数据值已存在,请使用其他值')
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const rules = {
|
||||
label: [
|
||||
{ required: true, message: '请输入标签名称', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '标签名称长度在 1 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
value: [
|
||||
{ required: true, message: '请输入数据值', trigger: 'blur' },
|
||||
{ validator: validateValueUnique, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// ===== 方法:重置表单 =====
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
id: '',
|
||||
label: '',
|
||||
value: '',
|
||||
color: '',
|
||||
description: '',
|
||||
is_default: false,
|
||||
status: true,
|
||||
sort: 0,
|
||||
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 || '',
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:提交表单 =====
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// 验证表单
|
||||
await formRef.value.validate()
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const submitData = {
|
||||
label: form.value.label,
|
||||
value: form.value.value,
|
||||
color: form.value.color,
|
||||
description: form.value.description,
|
||||
is_default: form.value.is_default,
|
||||
status: form.value.status,
|
||||
sort: form.value.sort,
|
||||
dictionary_id: props.dictionaryId
|
||||
}
|
||||
|
||||
let res = {}
|
||||
if (isEdit.value) {
|
||||
// 编辑
|
||||
res = await systemApi.dictionaryItems.edit.put(form.value.id, submitData)
|
||||
} else {
|
||||
// 新增
|
||||
res = await systemApi.dictionaryItems.add.post(submitData)
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
message.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
// 表单验证失败
|
||||
console.log('表单验证失败:', error)
|
||||
} else {
|
||||
// API 调用失败
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:取消 =====
|
||||
const handleCancel = () => {
|
||||
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
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
600
resources/admin/src/pages/system/dictionaries/index.vue
Normal file
600
resources/admin/src/pages/system/dictionaries/index.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<template>
|
||||
<div class="pages-sidebar-layout dictionary-page">
|
||||
<!-- 左侧:字典类型列表 -->
|
||||
<div class="left-box">
|
||||
<div class="header">
|
||||
<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">
|
||||
<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 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>
|
||||
<span class="item-count">{{ item.items_count || 0 }} 项</span>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<a-dropdown>
|
||||
<a-button type="text" size="small">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleEditDictionary(item)">
|
||||
<EditOutlined />编辑
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleDeleteDictionary(item)" danger>
|
||||
<DeleteOutlined />删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<a-empty v-else-if="!dictionaryLoading" description="暂无字典类型" :image-size="80">
|
||||
<a-button type="primary" @click="handleAddDictionary">
|
||||
创建第一个字典
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:字典项表格 -->
|
||||
<div class="right-box">
|
||||
<!-- 工具栏 -->
|
||||
<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-select>
|
||||
<a-button type="primary" @click="handleItemSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleItemReset">
|
||||
<template #icon><RedoOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="right-panel">
|
||||
<a-dropdown :disabled="selectedRows.length === 0">
|
||||
<a-button :disabled="selectedRows.length === 0">
|
||||
批量操作
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleBatchStatus">
|
||||
<CheckCircleOutlined />批量启用/禁用
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handleBatchDelete" danger>
|
||||
<DeleteOutlined />批量删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-button type="primary" :disabled="!selectedDictionaryId" @click="handleAddItem">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格内容 -->
|
||||
<div class="table-content">
|
||||
<!-- 空状态:未选择字典 -->
|
||||
<div v-if="!selectedDictionaryId" class="empty-state">
|
||||
<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">
|
||||
<template #color="{ record }">
|
||||
<span v-if="record.color" class="color-cell" :style="{ backgroundColor: record.color }"></span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #is_default="{ record }">
|
||||
<a-tag v-if="record.is_default" color="orange">
|
||||
<StarOutlined />默认
|
||||
</a-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="record.status ? 'success' : 'error'">
|
||||
{{ record.status ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #action="{ record }">
|
||||
<a-space>
|
||||
<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>
|
||||
</a-space>
|
||||
</template>
|
||||
</scTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字典类型弹窗 -->
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
RedoOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
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 dictionaryCache from '@/utils/dictionaryCache'
|
||||
|
||||
// ===== 字典列表相关 =====
|
||||
const dictionaryList = ref([])
|
||||
const filteredDictionaries = ref([])
|
||||
const selectedDictionary = ref(null)
|
||||
const selectedDictionaryId = ref(null)
|
||||
const dictionaryKeyword = ref('')
|
||||
const dictionaryLoading = ref(false)
|
||||
|
||||
// ===== 字典项相关(使用 useTable Hook)=====
|
||||
const {
|
||||
tableRef,
|
||||
searchForm,
|
||||
tableData,
|
||||
loading,
|
||||
pagination,
|
||||
selectedRows,
|
||||
rowSelection,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePaginationChange,
|
||||
handleSelectChange,
|
||||
handleSelectAll,
|
||||
refreshTable
|
||||
} = useTable({
|
||||
api: systemApi.dictionaryItems.list.get,
|
||||
searchForm: {
|
||||
dictionary_id: null,
|
||||
label: '',
|
||||
value: '',
|
||||
status: undefined
|
||||
},
|
||||
columns: [],
|
||||
needPagination: true,
|
||||
needSelection: true,
|
||||
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' }
|
||||
]
|
||||
|
||||
const rowKey = 'id'
|
||||
|
||||
// ===== 弹窗状态 =====
|
||||
const dialog = reactive({
|
||||
dictionary: false,
|
||||
item: false
|
||||
})
|
||||
|
||||
// ===== 当前操作的数据 =====
|
||||
const currentDictionary = ref(null)
|
||||
const currentItem = ref(null)
|
||||
|
||||
// ===== 方法:加载字典列表 =====
|
||||
const loadDictionaryList = async () => {
|
||||
try {
|
||||
dictionaryLoading.value = true
|
||||
const res = await systemApi.dictionaries.all.get()
|
||||
if (res.code === 200) {
|
||||
dictionaryList.value = res.data || []
|
||||
filteredDictionaries.value = res.data || []
|
||||
} else {
|
||||
message.error(res.message || '加载字典列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载字典列表失败:', error)
|
||||
message.error('加载字典列表失败')
|
||||
} finally {
|
||||
dictionaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:搜索过滤字典 =====
|
||||
const handleDictionarySearch = (e) => {
|
||||
const keyword = e.target?.value || ''
|
||||
dictionaryKeyword.value = keyword
|
||||
|
||||
if (!keyword) {
|
||||
filteredDictionaries.value = dictionaryList.value
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤字典列表(支持搜索名称和编码)
|
||||
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
|
||||
|
||||
// 重置右侧搜索条件
|
||||
searchForm.label = ''
|
||||
searchForm.value = ''
|
||||
searchForm.status = undefined
|
||||
|
||||
// 更新 dictionary_id
|
||||
searchForm.dictionary_id = dictionary.id
|
||||
|
||||
// 加载字典项列表
|
||||
handleItemSearch()
|
||||
}
|
||||
|
||||
// ===== 方法:字典项搜索 =====
|
||||
const handleItemSearch = () => {
|
||||
if (!selectedDictionaryId.value) {
|
||||
message.warning('请先选择字典类型')
|
||||
return
|
||||
}
|
||||
searchForm.dictionary_id = selectedDictionaryId.value
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// ===== 方法:字典项重置 =====
|
||||
const handleItemReset = () => {
|
||||
searchForm.label = ''
|
||||
searchForm.value = ''
|
||||
searchForm.status = undefined
|
||||
searchForm.dictionary_id = selectedDictionaryId.value
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// ===== 方法:新增字典 =====
|
||||
const handleAddDictionary = () => {
|
||||
currentDictionary.value = null
|
||||
dialog.dictionary = true
|
||||
}
|
||||
|
||||
// ===== 方法:编辑字典 =====
|
||||
const handleEditDictionary = (dictionary) => {
|
||||
currentDictionary.value = { ...dictionary }
|
||||
dialog.dictionary = true
|
||||
}
|
||||
|
||||
// ===== 方法:删除字典 =====
|
||||
const handleDeleteDictionary = (dictionary) => {
|
||||
const itemCount = dictionary.items_count || 0
|
||||
|
||||
Modal.confirm({
|
||||
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)
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功')
|
||||
|
||||
// 如果删除的是当前选中的字典,清空右侧
|
||||
if (selectedDictionaryId.value === dictionary.id) {
|
||||
selectedDictionary.value = null
|
||||
selectedDictionaryId.value = null
|
||||
tableData.value = []
|
||||
}
|
||||
|
||||
// 刷新字典列表
|
||||
await loadDictionaryList()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除字典失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 方法:新增字典项 =====
|
||||
const handleAddItem = () => {
|
||||
if (!selectedDictionaryId.value) {
|
||||
message.warning('请先选择字典类型')
|
||||
return
|
||||
}
|
||||
|
||||
currentItem.value = null
|
||||
dialog.item = true
|
||||
}
|
||||
|
||||
// ===== 方法:编辑字典项 =====
|
||||
const handleEditItem = (record) => {
|
||||
currentItem.value = { ...record }
|
||||
dialog.item = true
|
||||
}
|
||||
|
||||
// ===== 方法:删除字典项 =====
|
||||
const handleDeleteItem = async (record) => {
|
||||
try {
|
||||
const res = await systemApi.dictionaryItems.delete.delete(record.id)
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功')
|
||||
refreshTable()
|
||||
// 刷新字典列表以更新项数量
|
||||
loadDictionaryList()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除字典项失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:批量删除字典项 =====
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
message.warning('请选择要删除的字典项')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定删除选中的 ${selectedRows.value.length} 个字典项吗?`,
|
||||
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 })
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功')
|
||||
selectedRows.value = []
|
||||
refreshTable()
|
||||
loadDictionaryList()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 方法:批量启用/禁用字典项 =====
|
||||
const handleBatchStatus = () => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
message.warning('请选择要操作的字典项')
|
||||
return
|
||||
}
|
||||
|
||||
const newStatus = selectedRows.value[0].status ? false : true
|
||||
const statusText = newStatus === 1 ? '启用' : '禁用'
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认${statusText}`,
|
||||
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个字典项吗?`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const ids = selectedRows.value.map(item => item.id)
|
||||
const res = await systemApi.dictionaryItems.batchStatus.post({
|
||||
ids,
|
||||
status: newStatus
|
||||
})
|
||||
if (res.code === 200) {
|
||||
message.success(`${statusText}成功`)
|
||||
selectedRows.value = []
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量操作失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 方法:字典操作成功回调 =====
|
||||
const handleDictionarySuccess = () => {
|
||||
dialog.dictionary = false
|
||||
// 清理字典缓存
|
||||
dictionaryCache.clearDictionary()
|
||||
loadDictionaryList()
|
||||
}
|
||||
|
||||
// ===== 方法:字典项操作成功回调 =====
|
||||
const handleItemSuccess = () => {
|
||||
dialog.item = false
|
||||
// 清理字典缓存
|
||||
dictionaryCache.clearDictionary(selectedDictionaryId.value)
|
||||
refreshTable()
|
||||
loadDictionaryList()
|
||||
}
|
||||
|
||||
// ===== 生命周期 =====
|
||||
onMounted(() => {
|
||||
// 加载字典列表
|
||||
loadDictionaryList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dictionary-page {
|
||||
// 左侧字典列表样式
|
||||
.left-box {
|
||||
.body {
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
|
||||
.dictionary-list {
|
||||
.dictionary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: #fafafa;
|
||||
border-color: #d9d9d9;
|
||||
|
||||
.item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-right: 8px;
|
||||
|
||||
.item-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 表格自定义列样式
|
||||
:deep(.sc-table) {
|
||||
.color-cell {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #d9d9d9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<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="任务类型">
|
||||
<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>
|
||||
</a-descriptions-item>
|
||||
<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="状态" :span="2">
|
||||
<a-tag :color="task.is_active ? 'success' : 'error'">
|
||||
{{ task.is_active ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="上次运行时间" :span="2">
|
||||
{{ 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) : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="运行次数">
|
||||
<a-tag color="success">成功: {{ task.run_count || 0 }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="失败次数">
|
||||
<a-tag :color="task.failed_count > 0 ? 'error' : 'default'">
|
||||
失败: {{ task.failed_count || 0 }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="后台运行" :span="2">
|
||||
{{ task.run_in_background ? '是' : '否' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="2">
|
||||
{{ task.description || '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 最后输出 -->
|
||||
<div v-if="task.last_output" class="output-section">
|
||||
<a-divider>最后输出</a-divider>
|
||||
<pre class="output-content">{{ task.last_output }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="dialog-footer">
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">关闭</a-button>
|
||||
<a-button type="primary" @click="handleRun">
|
||||
<template #icon><PlayCircleOutlined /></template>
|
||||
立即执行
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</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'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'refresh'])
|
||||
|
||||
const task = computed(() => props.record)
|
||||
|
||||
// 获取任务类型文本
|
||||
const getTypeText = (type) => {
|
||||
const typeMap = {
|
||||
command: '命令',
|
||||
job: '任务',
|
||||
closure: '闭包'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
// 获取任务类型颜色
|
||||
const getTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
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')
|
||||
}
|
||||
|
||||
// 执行任务
|
||||
const handleRun = async () => {
|
||||
if (!props.record) return
|
||||
|
||||
try {
|
||||
const res = await systemApi.tasks.run.post(props.record.id)
|
||||
if (res.code === 200) {
|
||||
message.success('任务执行成功')
|
||||
emit('refresh')
|
||||
handleCancel()
|
||||
} else {
|
||||
message.error(res.message || '任务执行失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('任务执行失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.command-code {
|
||||
padding: 4px 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.cron-code {
|
||||
padding: 2px 6px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.output-section {
|
||||
margin-top: 16px;
|
||||
|
||||
.output-content {
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
409
resources/admin/src/pages/system/tasks/components/TaskDialog.vue
Normal file
409
resources/admin/src/pages/system/tasks/components/TaskDialog.vue
Normal file
@@ -0,0 +1,409 @@
|
||||
<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-form-item label="任务名称" name="name" required>
|
||||
<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-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-form-item>
|
||||
|
||||
<!-- 命令/类名 -->
|
||||
<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 />
|
||||
<div class="form-tip">
|
||||
<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 />
|
||||
<div class="form-tip">
|
||||
分 时 日 月 周,如:0 0 * * * (每天0点执行)
|
||||
<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-option value="UTC">UTC</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-form-item>
|
||||
|
||||
<!-- 高级选项 -->
|
||||
<a-divider>高级选项</a-divider>
|
||||
|
||||
<a-form-item label="后台运行" name="run_in_background">
|
||||
<a-switch v-model:checked="runInBackgroundChecked" />
|
||||
<div class="form-tip">命令类型任务是否在后台运行</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="防止重叠" name="without_overlapping">
|
||||
<a-switch v-model:checked="withoutOverlappingChecked" />
|
||||
<div class="form-tip">防止任务重叠执行</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="仅单例" name="only_one">
|
||||
<a-switch v-model:checked="onlyOneChecked" />
|
||||
<div class="form-tip">同一时间只运行一个实例</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 启用状态 -->
|
||||
<a-form-item label="启用状态" name="is_active">
|
||||
<a-switch v-model:checked="isActiveChecked" checked-children="启用" un-checked-children="禁用" />
|
||||
</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-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-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref(null)
|
||||
const isSaving = ref(false)
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const title = computed(() => {
|
||||
return isEdit.value ? '编辑定时任务' : '新增定时任务'
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
command: '',
|
||||
type: 'command',
|
||||
expression: '* * * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
description: '',
|
||||
is_active: true,
|
||||
run_in_background: false,
|
||||
without_overlapping: false,
|
||||
only_one: false,
|
||||
sort: 0
|
||||
})
|
||||
|
||||
// 计算属性:开关
|
||||
const isActiveChecked = computed({
|
||||
get: () => form.value.is_active === true,
|
||||
set: (val) => {
|
||||
form.value.is_active = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
const runInBackgroundChecked = computed({
|
||||
get: () => form.value.run_in_background === true,
|
||||
set: (val) => {
|
||||
form.value.run_in_background = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
const withoutOverlappingChecked = computed({
|
||||
get: () => form.value.without_overlapping === true,
|
||||
set: (val) => {
|
||||
form.value.without_overlapping = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
const onlyOneChecked = computed({
|
||||
get: () => form.value.only_one === true,
|
||||
set: (val) => {
|
||||
form.value.only_one = val ? true : false
|
||||
}
|
||||
})
|
||||
|
||||
// Cron 表达式验证函数
|
||||
const validateCronExpression = (rule, value) => {
|
||||
if (!value || !value.trim()) {
|
||||
return Promise.reject('请输入Cron表达式')
|
||||
}
|
||||
|
||||
const parts = value.trim().split(/\s+/)
|
||||
if (parts.length !== 5) {
|
||||
return Promise.reject('Cron表达式应由5部分组成:分 时 日 月 周')
|
||||
}
|
||||
|
||||
const [minute, hour, day, month, weekday] = parts
|
||||
|
||||
// 验证分钟 (0-59)
|
||||
if (!validateCronPart(minute, 0, 59)) {
|
||||
return Promise.reject('分钟部分格式不正确 (0-59)')
|
||||
}
|
||||
|
||||
// 验证小时 (0-23)
|
||||
if (!validateCronPart(hour, 0, 23)) {
|
||||
return Promise.reject('小时部分格式不正确 (0-23)')
|
||||
}
|
||||
|
||||
// 验证日 (1-31)
|
||||
if (!validateCronPart(day, 1, 31)) {
|
||||
return Promise.reject('日部分格式不正确 (1-31)')
|
||||
}
|
||||
|
||||
// 验证月 (1-12)
|
||||
if (!validateCronPart(month, 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.resolve()
|
||||
}
|
||||
|
||||
// 验证 Cron 单个部分
|
||||
const validateCronPart = (part, min, max, allowDayNames = false) => {
|
||||
// 支持 * (所有值)
|
||||
if (part === '*') return true
|
||||
|
||||
// 支持逗号分隔的列表
|
||||
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
|
||||
}
|
||||
|
||||
// 支持范围,如 1-5
|
||||
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']
|
||||
if (dayNames.includes(item) || dayNamesLower.includes(item)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为有效数字
|
||||
if (!/^\d+$/.test(item)) return false
|
||||
const num = parseInt(item, 10)
|
||||
if (num < min || num > max) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 验证 Cron 范围
|
||||
const validateCronRange = (range, min, max, allowDayNames = false) => {
|
||||
// 处理 * 范围
|
||||
if (range === '*') return true
|
||||
|
||||
const parts = range.split('-')
|
||||
if (parts.length !== 2) return false
|
||||
|
||||
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)
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
return startIdx <= endIdx
|
||||
}
|
||||
}
|
||||
|
||||
// 数字范围验证
|
||||
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
|
||||
}
|
||||
|
||||
// 验证规则
|
||||
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' }
|
||||
],
|
||||
expression: [
|
||||
{ required: true, message: '请输入Cron表达式', trigger: 'blur' },
|
||||
{ validator: validateCronExpression, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 下拉筛选
|
||||
const filterOption = (input, option) => {
|
||||
return option.value.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
id: '',
|
||||
name: '',
|
||||
command: '',
|
||||
type: 'command',
|
||||
expression: '* * * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
description: '',
|
||||
is_active: true,
|
||||
run_in_background: false,
|
||||
without_overlapping: false,
|
||||
only_one: false,
|
||||
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 || '',
|
||||
is_active: data.is_active !== undefined ? data.is_active : true,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const submitData = { ...form.value }
|
||||
|
||||
let res = {}
|
||||
if (isEdit.value) {
|
||||
res = await systemApi.tasks.edit.put(form.value.id, submitData)
|
||||
} else {
|
||||
res = await systemApi.tasks.add.post(submitData)
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
message.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
console.log('表单验证失败:', error)
|
||||
} else {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
resetForm()
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
if (props.record) {
|
||||
setData(props.record)
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
376
resources/admin/src/pages/system/tasks/index.vue
Normal file
376
resources/admin/src/pages/system/tasks/index.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="pages-base-layout task-page">
|
||||
<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-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-option :value="true">启用</a-select-option>
|
||||
<a-select-option :value="false">禁用</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><RedoOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<a-dropdown :disabled="selectedRows.length === 0">
|
||||
<a-button :disabled="selectedRows.length === 0">
|
||||
批量操作
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleBatchStatus(true)">
|
||||
<CheckCircleOutlined />批量启用
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleBatchStatus(false)">
|
||||
<StopOutlined />批量禁用
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handleBatchDelete" danger>
|
||||
<DeleteOutlined />批量删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增
|
||||
</a-button>
|
||||
</div>
|
||||
</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">
|
||||
<template #type="{ record }">
|
||||
<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)" />
|
||||
</template>
|
||||
|
||||
<template #expression="{ record }">
|
||||
<code class="cron-code">{{ record.expression }}</code>
|
||||
</template>
|
||||
|
||||
<template #last_run_at="{ record }">
|
||||
{{ record.last_run_at ? formatDate(record.last_run_at) : '-' }}
|
||||
</template>
|
||||
|
||||
<template #next_run_at="{ record }">
|
||||
<span v-if="record.next_run_at" class="next-run">
|
||||
<ClockCircleOutlined />
|
||||
{{ formatDate(record.next_run_at) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #statistics="{ record }">
|
||||
<a-space>
|
||||
<a-tooltip title="成功次数">
|
||||
<a-tag color="success">
|
||||
<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>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #action="{ record }">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
<EyeOutlined />查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
<EditOutlined />编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确定立即执行该任务吗?" @confirm="handleRun(record)">
|
||||
<a-button type="link" size="small">
|
||||
<PlayCircleOutlined />执行
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm title="确定删除该任务吗?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined />删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</scTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
RedoOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
DownOutlined,
|
||||
CheckCircleOutlined,
|
||||
StopOutlined,
|
||||
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'
|
||||
|
||||
// ===== useTable Hook =====
|
||||
const {
|
||||
tableRef,
|
||||
searchForm,
|
||||
tableData,
|
||||
loading,
|
||||
pagination,
|
||||
selectedRows,
|
||||
rowSelection,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePaginationChange,
|
||||
refreshTable
|
||||
} = useTable({
|
||||
api: systemApi.tasks.list.get,
|
||||
searchForm: {
|
||||
keyword: '',
|
||||
type: undefined,
|
||||
is_active: undefined
|
||||
},
|
||||
columns: [],
|
||||
needPagination: true,
|
||||
needSelection: true
|
||||
})
|
||||
|
||||
// ===== 表格列配置 =====
|
||||
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' }
|
||||
]
|
||||
|
||||
// ===== 弹窗状态 =====
|
||||
const dialog = reactive({
|
||||
save: false,
|
||||
detail: false
|
||||
})
|
||||
|
||||
const currentRecord = ref(null)
|
||||
|
||||
// ===== 权限控制 =====
|
||||
const canEdit = ref(true)
|
||||
|
||||
// ===== 方法:获取任务类型文本 =====
|
||||
const getTypeText = (type) => {
|
||||
const typeMap = {
|
||||
command: '命令',
|
||||
job: '任务',
|
||||
closure: '闭包'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
// ===== 方法:获取任务类型颜色 =====
|
||||
const getTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
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')
|
||||
}
|
||||
|
||||
// ===== 方法:切换状态 =====
|
||||
const handleToggleStatus = async (record) => {
|
||||
try {
|
||||
const res = await systemApi.tasks.batchStatus.post({
|
||||
ids: [record.id],
|
||||
status: record.is_active
|
||||
})
|
||||
if (res.code === 200) {
|
||||
message.success(record.is_active ? '已启用' : '已禁用')
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:新增 =====
|
||||
const handleAdd = () => {
|
||||
currentRecord.value = null
|
||||
dialog.save = true
|
||||
}
|
||||
|
||||
// ===== 方法:编辑 =====
|
||||
const handleEdit = (record) => {
|
||||
currentRecord.value = { ...record }
|
||||
dialog.save = true
|
||||
}
|
||||
|
||||
// ===== 方法:查看 =====
|
||||
const handleView = (record) => {
|
||||
currentRecord.value = { ...record }
|
||||
dialog.detail = true
|
||||
}
|
||||
|
||||
// ===== 方法:删除 =====
|
||||
const handleDelete = async (record) => {
|
||||
try {
|
||||
const res = await systemApi.tasks.delete.delete(record.id)
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功')
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:执行任务 =====
|
||||
const handleRun = async (record) => {
|
||||
try {
|
||||
const res = await systemApi.tasks.run.post(record.id)
|
||||
if (res.code === 200) {
|
||||
message.success('任务执行成功')
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '任务执行失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('任务执行失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 方法:批量删除 =====
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
message.warning('请选择要删除的任务')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定删除选中的 ${selectedRows.value.length} 个任务吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
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()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 方法:批量更新状态 =====
|
||||
const handleBatchStatus = (status) => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
message.warning('请选择要操作的任务')
|
||||
return
|
||||
}
|
||||
|
||||
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 })
|
||||
if (res.code === 200) {
|
||||
message.success(`${statusText}成功`)
|
||||
selectedRows.value = []
|
||||
refreshTable()
|
||||
} else {
|
||||
message.error(res.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 方法:保存成功回调 =====
|
||||
const handleSaveSuccess = () => {
|
||||
dialog.save = false
|
||||
refreshTable()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.task-page {
|
||||
.cron-code {
|
||||
padding: 2px 6px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.next-run {
|
||||
color: #1890ff;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
resources/admin/src/utils/dictionaryCache.js
Normal file
147
resources/admin/src/utils/dictionaryCache.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 数据字典缓存工具
|
||||
* 用于缓存和管理数据字典数据
|
||||
*/
|
||||
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
// 缓存存储
|
||||
const cacheStorage = new Map()
|
||||
|
||||
// 缓存过期时间(毫秒)默认1小时
|
||||
const CACHE_EXPIRE_TIME = 3600 * 1000
|
||||
|
||||
/**
|
||||
* 字典缓存管理类
|
||||
*/
|
||||
class DictionaryCacheManager {
|
||||
/**
|
||||
* 获取所有字典(带缓存)
|
||||
*/
|
||||
async getAll() {
|
||||
const cacheKey = 'all'
|
||||
const cached = this.get(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const res = await systemApi.dictionaries.all.get()
|
||||
if (res.code === 200) {
|
||||
const data = res.data || []
|
||||
this.set(cacheKey, data)
|
||||
return data
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据编码获取字典项(带缓存)
|
||||
*/
|
||||
async getItemsByCode(code) {
|
||||
const cacheKey = `items:${code}`
|
||||
const cached = this.get(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const res = await systemApi.public.dictionaries.code.get({ code })
|
||||
if (res.code === 200) {
|
||||
const data = res.data || []
|
||||
this.set(cacheKey, data)
|
||||
return data
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据编码获取字典(带缓存)
|
||||
*/
|
||||
async getByCode(code) {
|
||||
const cacheKey = `code:${code}`
|
||||
const cached = this.get(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const all = await this.getAll()
|
||||
const dictionary = all.find((item) => item.code === code)
|
||||
|
||||
if (dictionary) {
|
||||
this.set(cacheKey, dictionary)
|
||||
}
|
||||
|
||||
return dictionary
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据编码获取字典项的标签
|
||||
*/
|
||||
async getLabelByCode(code, value) {
|
||||
const items = await this.getItemsByCode(code)
|
||||
const item = items.find((item) => item.value === value)
|
||||
return item ? item.label : value
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
clear() {
|
||||
cacheStorage.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定缓存
|
||||
*/
|
||||
delete(key) {
|
||||
cacheStorage.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除字典相关缓存
|
||||
*/
|
||||
clearDictionary(dictionaryId) {
|
||||
// 清除特定字典的缓存(暂时清除所有,因为前端不知道字典ID对应的code)
|
||||
this.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
*/
|
||||
get(key) {
|
||||
const item = cacheStorage.get(key)
|
||||
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > item.expires) {
|
||||
cacheStorage.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return item.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
*/
|
||||
set(key, data) {
|
||||
const item = {
|
||||
data,
|
||||
expires: Date.now() + CACHE_EXPIRE_TIME
|
||||
}
|
||||
|
||||
cacheStorage.set(key, item)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const dictionaryCache = new DictionaryCacheManager()
|
||||
|
||||
export default dictionaryCache
|
||||
Reference in New Issue
Block a user