feat(manager): 优化定时任务管理、敏感词场景配置与商品选择器多规格匹配

This commit is contained in:
pikachu1995@126.com
2026-10-29 17:06:21 +08:00
parent 8739811b04
commit 2563f122fc
4 changed files with 667 additions and 75 deletions

View File

@@ -1,11 +1,16 @@
import { managerApiUrl, getRequest, postRequest } from '@/libs/axios';
import { managerApiUrl, getRequest, postRequest, putRequestWithNoForm } from '@/libs/axios';
// 获取定时任务列表
export const getTimedTaskList = () => {
return getRequest(`${managerApiUrl}/manager/setting/timed-task`);
};
// 手动触发定时任务
export const triggerTimedTask = (type) => {
return postRequest(`${managerApiUrl}/manager/setting/timed-task/trigger/${type}`);
// 修改定时任务(Cron表达式、启用/暂停状态)
export const updateTimedTask = (params) => {
return putRequestWithNoForm(`${managerApiUrl}/manager/setting/timed-task`, params);
};
// 手动触发定时任务
export const triggerTimedTask = (taskId) => {
return postRequest(`${managerApiUrl}/manager/setting/timed-task/trigger/${taskId}`);
};

View File

@@ -189,7 +189,7 @@ function syncTableSelection() {
return;
}
goodsData.value.forEach((item) => {
const selected = selectedList.value.some((e) => e.id && e.id === item.id);
const selected = selectedList.value.some((e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId));
table.toggleRowSelection(item, selected);
});
}

View File

@@ -1,48 +1,105 @@
<template>
<div class="search">
<el-card>
<div class="operation">
<el-button type="primary" @click="add">添加</el-button>
<el-button @click="delAll">批量删除</el-button>
</div>
<el-tabs v-model="activeTab" @tab-change="clickTab">
<el-tab-pane label="词库管理" name="words">
<div class="operation">
<el-button type="primary" @click="add">添加</el-button>
<el-button @click="delAll">批量删除</el-button>
</div>
<el-table
ref="table"
v-loading="loading"
border
:data="data"
style="width: 100%"
row-key="id"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="60" align="center" />
<el-table-column prop="sensitiveWord" label="敏感词" min-width="120" />
<el-table-column prop="createTime" label="创建时间" width="200" />
<el-table-column prop="updateTime" label="更新时间" width="200" />
<el-table-column prop="createBy" label="操作人" min-width="150" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="detail(row)">修改</a>
<span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a>
</template>
</template>
</el-table-column>
</el-table>
<el-table
ref="table"
v-loading="loading"
border
:data="data"
style="width: 100%"
row-key="id"
@selection-change="changeSelect"
>
<el-table-column type="selection" width="60" align="center" />
<el-table-column prop="sensitiveWord" label="敏感词" min-width="120" />
<el-table-column prop="createTime" label="创建时间" width="200" />
<el-table-column prop="updateTime" label="更新时间" width="200" />
<el-table-column prop="createBy" label="操作人" min-width="150" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row">
<a class="link-text" @click="detail(row)">修改</a>
<span class="op-split">|</span>
<a class="link-text" @click="remove(row)">删除</a>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</el-tab-pane>
<el-tab-pane label="敏感词设置" name="switch">
<div v-loading="settingLoading" class="switch-setting">
<ul class="side-menu">
<li
:class="{ active: sideKey === 'func' }"
@click="sideKey = 'func'"
>
功能开关
</li>
<li
:class="{ active: sideKey === 'scene' }"
@click="sideKey = 'scene'"
>
应用场景配置
</li>
</ul>
<div class="setting-content">
<template v-if="sideKey === 'func'">
<div class="setting-row">
<span class="row-label">敏感词过滤:</span>
<el-switch v-model="settingForm.enabled" />
</div>
<div class="row-desc">关闭后,所有场景的敏感词过滤均不生效</div>
</template>
<template v-else>
<div
v-for="group in sensitiveGroups"
:key="group.key"
class="scene-row"
>
<div class="setting-row">
<span class="row-label">{{ group.name }}:</span>
<el-switch
:model-value="!settingForm.disabledGroups.includes(group.key)"
@update:model-value="toggleGroup(group.key, $event)"
/>
</div>
<div class="row-desc">涉及:{{ group.desc }}</div>
</div>
</template>
</div>
</div>
<div class="save-bar">
<el-button
type="primary"
:loading="settingSaving"
@click="saveSetting"
>
保存
</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog
@@ -72,11 +129,16 @@ import {
delSensitive,
insertSensitiveWords,
updateSensitiveWords,
getSetting,
setSetting,
} from "@/api/index";
import { Message, Modal } from "@/utils/message";
defineOptions({ name: "sensitive-words" });
const SETTING_KEY = "SENSITIVE_WORDS_SETTING";
const activeTab = ref("words");
const loading = ref(true);
const modalType = ref(0);
const modalVisible = ref(false);
@@ -111,9 +173,90 @@ let id = null;
const table = ref(null);
const formRef = ref(null);
// 开关设置
const sideKey = ref("func");
const settingLoading = ref(false);
const settingSaving = ref(false);
const settingLoaded = ref(false);
const settingForm = reactive({
enabled: true,
disabledGroups: [],
});
const sensitiveGroups = [
{ key: "MEMBER", name: "会员信息", desc: "用户昵称、收货地址信息" },
{ key: "EVALUATION", name: "产品评价", desc: "用户发布评论信息" },
{ key: "STORE", name: "商家", desc: "商家入驻信息" },
{ key: "GOODS", name: "产品信息", desc: "商品发布、商品修改、商品分类信息" },
{ key: "PROMOTION", name: "促销活动", desc: "活动名称、优惠券名称信息" },
{ key: "DECORATION", name: "装修模块", desc: "导航名称、装修楼层中的标题和子标题" },
{ key: "REASON", name: "原因管理", desc: "退款/退货/换货原因信息" },
{ key: "ARTICLE", name: "资讯管理", desc: "分类名称、文章标题、文章详情" },
];
function init() {
getDataList();
}
function clickTab(name) {
if (name === "switch" && !settingLoaded.value) {
loadSetting();
}
}
function loadSetting() {
settingLoading.value = true;
getSetting(SETTING_KEY)
.then((res) => {
let parsed = {};
const raw = res ? res.result : null;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch (e) {
parsed = {};
}
} else if (raw && typeof raw === "object") {
parsed = raw;
}
settingForm.enabled =
parsed.enabled !== undefined ? Boolean(parsed.enabled) : true;
settingForm.disabledGroups = Array.isArray(parsed.disabledGroups)
? parsed.disabledGroups
: [];
})
.catch(() => {
// 设置记录不存在时按默认值处理
settingForm.enabled = true;
settingForm.disabledGroups = [];
})
.finally(() => {
settingLoading.value = false;
settingLoaded.value = true;
});
}
function toggleGroup(key, enabled) {
const index = settingForm.disabledGroups.indexOf(key);
if (enabled && index > -1) {
settingForm.disabledGroups.splice(index, 1);
} else if (!enabled && index === -1) {
settingForm.disabledGroups.push(key);
}
}
function saveSetting() {
settingSaving.value = true;
setSetting(SETTING_KEY, {
enabled: settingForm.enabled,
disabledGroups: settingForm.disabledGroups,
})
.then((res) => {
if (res.success) {
Message.success("保存成功");
} else {
Message.error(res.message || "保存失败");
}
})
.finally(() => {
settingSaving.value = false;
});
}
function changePage(v) {
searchForm.pageNumber = v;
getDataList();
@@ -236,3 +379,81 @@ onMounted(() => {
init();
});
</script>
<style lang="scss" scoped>
.switch-setting {
display: flex;
min-height: 300px;
border: 1px solid #e8eaec;
border-radius: 4px;
}
.side-menu {
width: 140px;
flex-shrink: 0;
margin: 0;
padding: 8px 0;
list-style: none;
border-right: 1px solid #e8eaec;
li {
padding: 10px 16px;
color: #2d8cf0;
font-size: 13px;
cursor: pointer;
border-left: 2px solid transparent;
&.active {
color: #2d8cf0;
font-weight: 600;
background-color: #f0f8ff;
border-left-color: #2d8cf0;
}
&:hover {
background-color: #f5f7fa;
}
}
}
.setting-content {
flex: 1;
padding: 16px 24px;
}
.setting-row {
display: flex;
align-items: center;
}
.row-label {
display: inline-block;
min-width: 80px;
margin-right: 12px;
color: #515a6e;
font-size: 13px;
}
.row-desc {
margin: 8px 0 0 92px;
color: #999;
font-size: 12px;
}
.scene-row {
padding: 12px 0;
border-bottom: 1px solid #e8eaec;
&:last-child {
border-bottom: none;
}
.row-desc {
margin-top: 4px;
}
}
.save-bar {
margin-top: 16px;
}
</style>

View File

@@ -1,54 +1,281 @@
<template>
<div>
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane label="每日任务" name="每日任务" />
<el-tab-pane label="每小时任务" name="每小时" />
<el-tab-pane label="每分钟任务" name="每分钟" />
<div class="timed-task-page">
<el-card shadow="never">
<!-- 顶部统计与操作栏 -->
<div class="header-bar">
<div class="stats-info">
<span class="stat-item">全部任务:<b>{{ taskList.length }}</b></span>
<el-divider direction="vertical" />
<span class="stat-item text-success">运行中:<b>{{ runningCount }}</b></span>
<el-divider direction="vertical" />
<span class="stat-item text-muted">已暂停:<b>{{ pausedCount }}</b></span>
</div>
<div class="filter-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索任务名称 / 标识"
clearable
style="width: 240px; margin-right: 12px"
:prefix-icon="Search"
/>
<el-button :icon="Refresh" @click="loadData" :loading="loading">刷新</el-button>
</div>
</div>
<!-- 分组 Tabs -->
<el-tabs v-model="activeGroup" style="margin-top: 12px">
<el-tab-pane label="全部" name="ALL" />
<el-tab-pane
v-for="g in groupList"
:key="g"
:label="g"
:name="g"
/>
</el-tabs>
<el-table v-loading="loading" :data="currentTasks" border style="width: 100%; margin-top: 12px">
<el-table-column prop="name" label="任务名称" width="220" />
<el-table-column prop="cron" label="Cron表达式" width="140" />
<el-table-column label="操作" width="120" align="center">
<!-- 任务列表表格 -->
<el-table
v-loading="loading"
:data="filteredTasks"
border
stripe
style="width: 100%; margin-top: 8px"
>
<el-table-column label="任务名称 / 标识" min-width="220">
<template #default="{ row }">
<div class="task-title-cell">
<span class="task-name">{{ row.name }}</span>
<span class="task-id">{{ row.id }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="group" label="分组" width="110" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.group || '其他' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="任务说明" min-width="240" show-overflow-tooltip />
<el-table-column label="Cron 表达式" width="180">
<template #default="{ row }">
<div class="cron-cell">
<code class="cron-tag">{{ row.cron }}</code>
<el-button
link
type="primary"
size="small"
:icon="Edit"
title="修改Cron"
@click="openEditDialog(row)"
/>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-model="row.status"
:loading="row.statusLoading"
:active-value="true"
:inactive-value="false"
@change="(val) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column prop="lastExecTime" label="上次执行时间" width="170" align="center">
<template #default="{ row }">
<span>{{ row.lastExecTime || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="上次执行结果" width="150" align="center">
<template #default="{ row }">
<template v-if="row.lastExecStatus === 1">
<el-tag type="success" size="small">
成功
<span v-if="row.lastExecCost != null">({{ row.lastExecCost }}ms)</span>
</el-tag>
</template>
<template v-else-if="row.lastExecStatus === 0">
<el-tooltip
class="box-item"
effect="dark"
:content="row.lastExecError || '未知错误'"
placement="top"
>
<el-tag type="danger" size="small" style="cursor: pointer">
失败 ℹ
</el-tag>
</el-tooltip>
</template>
<template v-else>
<el-tag type="info" size="small">未执行</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="openEditDialog(row)"
>
配置
</el-button>
<el-button
type="warning"
link
size="small"
:loading="row.triggering"
@click="handleTrigger(row)"
>
手动触发
立即执行
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 修改 Cron 表达式弹窗 -->
<el-dialog
v-model="editDialogVisible"
:title="`配置任务: ${currentRow?.name || ''}`"
width="540px"
destroy-on-close
>
<el-form label-width="110px" style="margin-top: 10px">
<el-form-item label="任务标识">
<el-input :model-value="currentRow?.id" disabled />
</el-form-item>
<el-form-item label="任务名称">
<el-input :model-value="currentRow?.name" disabled />
</el-form-item>
<el-form-item label="任务说明">
<div class="desc-text">{{ currentRow?.description || '-' }}</div>
</el-form-item>
<el-form-item label="Cron 表达式" required>
<el-input
v-model="editForm.cron"
placeholder="例如: 0 0/5 * * * ?"
clearable
/>
</el-form-item>
<el-form-item label="快捷预设">
<div class="cron-presets">
<el-button
v-for="p in cronPresets"
:key="p.cron"
size="small"
text
bg
@click="editForm.cron = p.cron"
>
{{ p.label }}
</el-button>
</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="editForm.status" />
<span style="margin-left: 10px; color: #909399; font-size: 13px">
{{ editForm.status ? '保存后自动开启调度' : '保存后暂停该任务调度' }}
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getTimedTaskList, triggerTimedTask } from "@/api/timedTask.js";
import { ref, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search, Refresh, Edit } from '@element-plus/icons-vue';
import { getTimedTaskList, updateTimedTask, triggerTimedTask } from '@/api/timedTask.js';
defineOptions({ name: "timed-task-manage" });
defineOptions({ name: 'timed-task-manage' });
const loading = ref(false);
const activeTab = ref("每日任务");
const taskList = ref([]);
const activeGroup = ref('ALL');
const searchKeyword = ref('');
const currentTasks = computed(() => {
return taskList.value.filter((t) => t.group === activeTab.value);
// 对话框表单
const editDialogVisible = ref(false);
const currentRow = ref(null);
const saving = ref(false);
const editForm = ref({
id: '',
cron: '',
status: true,
});
function init() {
// 快捷 Cron 模板
const cronPresets = [
{ label: '每1分钟', cron: '0 * * * * ?' },
{ label: '每5分钟', cron: '0 0/5 * * * ?' },
{ label: '每10分钟', cron: '0 0/10 * * * ?' },
{ label: '每30分钟', cron: '0 0/30 * * * ?' },
{ label: '每小时整点', cron: '0 0 * * * ?' },
{ label: '每日0点', cron: '0 0 0 * * ?' },
{ label: '每日凌晨2点', cron: '0 0 2 * * ?' },
{ label: '每日凌晨3点', cron: '0 0 3 * * ?' },
{ label: '每日凌晨6点', cron: '0 0 6 * * ?' },
];
const runningCount = computed(() => {
return taskList.value.filter((t) => t.status).length;
});
const pausedCount = computed(() => {
return taskList.value.filter((t) => !t.status).length;
});
const groupList = computed(() => {
const set = new Set();
taskList.value.forEach((t) => {
if (t.group) set.add(t.group);
});
return Array.from(set);
});
const filteredTasks = computed(() => {
let list = taskList.value;
if (activeGroup.value !== 'ALL') {
list = list.filter((t) => t.group === activeGroup.value);
}
if (searchKeyword.value.trim()) {
const kw = searchKeyword.value.trim().toLowerCase();
list = list.filter(
(t) =>
(t.name && t.name.toLowerCase().includes(kw)) ||
(t.id && t.id.toLowerCase().includes(kw)) ||
(t.description && t.description.toLowerCase().includes(kw))
);
}
return list;
});
function loadData() {
loading.value = true;
getTimedTaskList()
.then((res) => {
loading.value = false;
if (res.success) {
taskList.value = res.result.map((t) => ({ ...t, triggering: false }));
if (res.success && res.result) {
taskList.value = res.result.map((t) => ({
...t,
statusLoading: false,
triggering: false,
}));
}
})
.catch(() => {
@@ -56,11 +283,70 @@ function init() {
});
}
function handleStatusChange(row, newStatus) {
row.statusLoading = true;
updateTimedTask({
id: row.id,
status: newStatus,
})
.then((res) => {
row.statusLoading = false;
if (res.success) {
ElMessage.success(`任务 "${row.name}" 已${newStatus ? '开启' : '暂停'}`);
} else {
row.status = !newStatus;
ElMessage.error(res.message || '修改状态失败');
}
})
.catch(() => {
row.statusLoading = false;
row.status = !newStatus;
ElMessage.error('网络异常,修改状态失败');
});
}
function openEditDialog(row) {
currentRow.value = row;
editForm.value = {
id: row.id,
cron: row.cron,
status: row.status,
};
editDialogVisible.value = true;
}
function handleSave() {
if (!editForm.value.cron || !editForm.value.cron.trim()) {
ElMessage.warning('Cron 表达式不能为空');
return;
}
saving.value = true;
updateTimedTask({
id: editForm.value.id,
cron: editForm.value.cron.trim(),
status: editForm.value.status,
})
.then((res) => {
saving.value = false;
if (res.success) {
ElMessage.success('定时任务配置保存成功');
editDialogVisible.value = false;
loadData();
} else {
ElMessage.error(res.message || '保存失败');
}
})
.catch(() => {
saving.value = false;
ElMessage.error('保存失败');
});
}
function handleTrigger(row) {
ElMessageBox.confirm(`确认要手动触发 "${row.name}" 吗?`, "提示", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
ElMessageBox.confirm(`确认要手动立即执行一次 "${row.name}" 任务吗?`, '提示', {
confirmButtonText: '立即执行',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
row.triggering = true;
@@ -68,20 +354,100 @@ function handleTrigger(row) {
.then((res) => {
row.triggering = false;
if (res.success) {
ElMessage.success(`"${row.name}" 已触发执行`);
ElMessage.success(`"${row.name}" 执行成功`);
loadData();
} else {
ElMessage.error(res.message || "触发失败");
ElMessage.error(res.message || '执行失败');
}
})
.catch(() => {
.catch((err) => {
row.triggering = false;
ElMessage.error("触发失败");
ElMessage.error(err?.response?.data?.message || '执行失败');
});
})
.catch(() => {});
}
onMounted(() => {
init();
loadData();
});
</script>
<style scoped>
.timed-task-page {
padding: 10px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.stats-info {
display: flex;
align-items: center;
font-size: 14px;
}
.stat-item {
color: #606266;
}
.stat-item b {
margin-left: 4px;
font-size: 16px;
}
.text-success b {
color: #67c23a;
}
.text-muted b {
color: #909399;
}
.task-title-cell {
display: flex;
flex-direction: column;
}
.task-name {
font-weight: 600;
color: #303133;
}
.task-id {
font-size: 12px;
color: #909399;
font-family: Consolas, Monaco, monospace;
}
.cron-cell {
display: flex;
align-items: center;
gap: 6px;
}
.cron-tag {
background-color: #f4f4f5;
color: #409eff;
padding: 2px 6px;
border-radius: 4px;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
}
.desc-text {
color: #606266;
font-size: 13px;
}
.cron-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>