fix: 修复多项后端缺陷并加固安全与限流

- 修正 AI 会话表初始化及异步数据库 URI 处理
- 新增全局 API 与 WS 聊天限流,健康检查豁免
- 修复忘记密码接口防用户枚举与参数校验
- 修复 SSE 推送数据重复编码问题
- 修复 Cron 表达式 Quartz 问号兼容
- 修复存储模块权限标识前缀
- 新增过期日志清理与任务错误中文提示
This commit is contained in:
zhangtao
2026-09-07 00:08:00 +08:00
parent 30dfc7097b
commit a8d29d4ab4
59 changed files with 989 additions and 891 deletions
@@ -141,8 +141,6 @@ export default UserAPI;
export interface ForgetPasswordForm {
username: string;
new_password: string;
confirmPassword: string;
}
export interface RegisterForm {
@@ -6,7 +6,9 @@
<el-link type="primary" :href="modelValue" target="_blank" :underline="false">
{{ fileName }}
</el-link>
<el-icon class="file-item-action" title="移除" @click="handleRemove"><CircleCloseFilled /></el-icon>
<el-icon class="file-item-action" title="移除" @click="handleRemove"
><CircleCloseFilled
/></el-icon>
</div>
<el-button v-else type="primary" plain :loading="uploading" @click="fileInputRef?.click()">
<el-icon class="mr-1"><UploadFilled /></el-icon>
@@ -115,20 +117,20 @@ const doUpload = async (file: File) => {
.fa-file-upload .file-item {
display: flex;
align-items: center;
gap: 6px;
align-items: center;
min-width: 0;
}
.fa-file-upload .file-item-icon {
color: var(--el-color-primary);
flex-shrink: 0;
color: var(--el-color-primary);
}
.fa-file-upload .file-item-action {
cursor: pointer;
color: var(--el-text-color-secondary);
flex-shrink: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
}
.fa-file-upload .file-item-action:hover {
@@ -138,7 +140,7 @@ const doUpload = async (file: File) => {
.fa-file-upload .file-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
color: var(--el-text-color-secondary);
}
</style>
@@ -22,11 +22,12 @@
>
<ElFormItem
:prop="item.key"
:label="typeof item.label === 'string' ? item.label : undefined"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
<!-- 字符串 label 走 ElFormItem 默认渲染使 label-suffix 生效;组件 label 保留插槽 -->
<template v-if="item.label && typeof item.label !== 'string'" #label>
<component :is="item.label" />
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
@@ -119,11 +120,12 @@
>
<ElFormItem
:prop="item.key"
:label="typeof item.label === 'string' ? item.label : undefined"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
<!-- 字符串 label 走 ElFormItem 默认渲染使 label-suffix 生效;组件 label 保留插槽 -->
<template v-if="item.label && typeof item.label !== 'string'" #label>
<component :is="item.label" />
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
+3 -35
View File
@@ -5,8 +5,8 @@
* `fetchDataQuiet` 吞掉 reject,避免页面重复 try/catch。
*
* 去重:
* - 同实例:`inFlightDedupePromise` + `inFlightDedupeKey`
* - 跨实例(同参):模块级 `globalListNetworkInflight`(布局短时多挂载时合并为一条 HTTP)
* - 仅限单个 useTable 实例内部:`inFlightDedupePromise` + `inFlightDedupeKey`(同参进行中的请求复用 Promise)。
* - 不做跨实例去重:key 仅由参数生成,不同接口的同参请求会碰撞串台(PR #516)。
*
* KeepAlive:`onDeactivated` 会取消请求并置 `tableViewActive`,失活期间 `fetchData` 直接返回当前快照不发 HTTP。
*/
@@ -44,9 +44,6 @@ import {
} from "@utils";
import type { ColumnOption } from "@/types/component";
/** 跨组件实例:同一 dedupeKey 仅一条进行中的网络请求 */
const globalListNetworkInflight = new Map<string, Promise<TableResponse<unknown>>>();
// --- 类型推导(由 apiFn / 响应类型反推记录类型) ---
type InferApiParams<T> = T extends (params: infer P) => any ? P : never;
type UnwrapAxiosResponse<T> = T extends AxiosResponse<infer D> ? D : T;
@@ -389,26 +386,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
return inFlightDedupePromise;
}
const sharedGlobal = globalListNetworkInflight.get(dedupeKey);
if (sharedGlobal) {
loadingState.value = "loading";
error.value = null;
try {
const standardResponse = (await sharedGlobal) as TableResponse<TRecord>;
commitNetworkSuccess(standardResponse, requestParams, useCache);
return standardResponse;
} catch (err) {
if (err instanceof Error && err.message === "请求已取消") {
loadingState.value = "idle";
return { records: [], total: 0, current: 1, size: 10 };
}
loadingState.value = "error";
data.value = [];
const tableError = handleError(err, "获取表格数据失败");
throw tableError;
}
}
if (!tableViewActive) {
return {
records: [...data.value],
@@ -477,13 +454,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
}
})();
globalListNetworkInflight.set(dedupeKey, networkPromise as Promise<TableResponse<unknown>>);
networkPromise
.finally(() => {
globalListNetworkInflight.delete(dedupeKey);
})
.catch(() => {}); // 忽略取消/reject,仅用于清理全局 Map
inFlightDedupePromise = networkPromise;
try {
@@ -826,10 +796,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
removeColumn: columnConfig.removeColumn,
/** 切换列显示状态 */
toggleColumn: columnConfig.toggleColumn,
/** 更新列配置 */
/** 更新列配置(支持单个或批量,批量传数组) */
updateColumn: columnConfig.updateColumn,
/** 批量更新列配置 */
batchUpdateColumns: columnConfig.batchUpdateColumns,
/** 重新排序列 */
reorderColumns: columnConfig.reorderColumns,
/** 获取指定列配置 */
@@ -183,29 +183,29 @@ export const MANUAL_MODULES_AFTER_SYSTEM: ManualModuleSection[] = [
{
anchor: "page-workflow",
title: "流程管理",
path: "module_storage/workflow/index.vue",
path: "module_task/storage/workflow/index.vue",
notes: [
"检索:FaSearchBar(可展开)。",
"工具栏:新增、批量删除、刷新、列配置。",
"流程定义:源节点 + 目标节点列表(parallel 多目标 / chain 链式),目标路径随行配置。",
"权限:`module_storage:workflow:flow:create|delete|update` + 行级操作。",
"权限:`module_task:storage:flow:create|delete|update` + 行级操作。",
],
},
{
anchor: "page-workflow-node",
title: "节点管理",
path: "module_storage/node/index.vue",
path: "module_task/storage/node/index.vue",
notes: [
"检索:FaSearchBar(可展开)。",
"节点即传输地址(存储源):FTP/FTPS/SFTP/S3/OBS/OSS/COS/本地目录。",
"行操作:打开(进入存储管理)、测试连接、详情、编辑、删除。",
"权限:`module_storage:workflow:node:create|delete|update` + 行级操作。",
"权限:`module_task:storage:node:create|delete|update` + 行级操作。",
],
},
{
anchor: "page-workflow-storage",
title: "文件浏览",
path: "module_storage/browse/index.vue",
path: "module_task/storage/browse/index.vue",
notes: [
"多节点标签浏览各节点内部文件(类似 IDE 标签栏)。",
"文件操作:上传、新建目录、下载、复制/移动、重命名、分享、删除。",
@@ -215,7 +215,7 @@ export const MANUAL_MODULES_AFTER_SYSTEM: ManualModuleSection[] = [
{
anchor: "page-workflow-transfer",
title: "传输任务",
path: "module_storage/transfer/index.vue",
path: "module_task/storage/transfer/index.vue",
notes: [
"创建传输任务:可选「从流程自动填充」源节点与多目标,或手动指定。",
"实时进度:WebSocket 推送任务/步骤状态与速度。",
+22 -3
View File
@@ -408,7 +408,7 @@ const getInfo = async () => {
loading.value = true;
const response = await CacheAPI.getCacheInfo();
cache.value = response.data.data || { info: {}, command_stats: [], db_size: 0 };
initCharts();
void initCharts();
} catch (error: unknown) {
if (import.meta.env.DEV) console.error("获取缓存监控数据失败:", error);
ElMessage.error("获取缓存监控数据失败");
@@ -417,8 +417,27 @@ const getInfo = async () => {
}
};
const initCharts = () => {
/** 等待容器完成布局(路由过渡/首次渲染时 clientWidth/Height 可能为 0),避免 ECharts "Can't get DOM width or height" 警告 */
const waitForSize = (el: HTMLElement): Promise<void> =>
new Promise((resolve) => {
const check = () => {
// 组件卸载后 template ref 置 null,此时 ref 不再指向 el,直接结束避免悬挂循环
if (
(commandstats.value !== el && usedmemory.value !== el) ||
(el.clientWidth > 0 && el.clientHeight > 0)
) {
resolve();
return;
}
requestAnimationFrame(check);
};
check();
});
const initCharts = async () => {
if (!commandstats.value || !usedmemory.value) return;
await Promise.all([waitForSize(commandstats.value), waitForSize(usedmemory.value)]);
if (!commandstats.value || !usedmemory.value) return; // 等待期间组件被卸载
commandstatsInstance = echarts.init(commandstats.value, "macarons");
usedmemoryInstance = echarts.init(usedmemory.value, "macarons");
@@ -480,7 +499,7 @@ onMounted(() => {
watch(activeTab, (tab) => {
if (tab === "0") {
nextTick(() => initCharts());
nextTick(() => void initCharts());
}
});
@@ -22,42 +22,6 @@
</template>
</ElInput>
</ElFormItem>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="new_password">
<ElInput
v-model.trim="forgetForm.new_password"
class="custom-height"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="confirmPassword">
<ElInput
v-model.trim="forgetForm.confirmPassword"
class="custom-height"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.message.password.confirm')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<div class="mt-6">
<ElButton
class="h-11 w-full min-w-0 rounded-lg! text-base font-medium"
@@ -80,7 +44,7 @@
</template>
<script setup lang="ts">
import { Lock, User } from "@element-plus/icons-vue";
import { User } from "@element-plus/icons-vue";
import type { ForgetPasswordForm } from "@/api/module_system/user";
import type { FormRules } from "element-plus";
@@ -101,19 +65,9 @@ interface Emits {
toLogin: [];
}
const emit = defineEmits<Emits>();
defineEmits<Emits>();
const formRef = ref();
const isCapsLock = ref(false);
function checkCapsLock(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
defineExpose({
validate: () => formRef.value?.validate?.(),
@@ -381,8 +381,6 @@ const registerForm = reactive<RegisterForm>({
const forgetForm = reactive<ForgetPasswordForm>({
username: "",
new_password: "",
confirmPassword: "",
});
const validateRegisterPassword = (_rule: unknown, value: string, callback: (e?: Error) => void) => {
@@ -422,29 +420,8 @@ const registerRules = computed<FormRules<RegisterForm>>(() => ({
],
}));
const validateForgetConfirm = (_rule: unknown, value: string, callback: (e?: Error) => void) => {
if (!value) {
callback(new Error(t("login.message.password.required")));
return;
}
if (value !== forgetForm.new_password) {
callback(new Error(t("login.message.password.inconformity")));
return;
}
callback();
};
const forgetRules = computed<FormRules<ForgetPasswordForm>>(() => ({
username: [{ required: true, message: t("login.message.username.required"), trigger: "blur" }],
new_password: [
{ required: true, message: t("login.message.password.required"), trigger: "blur" },
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
],
confirmPassword: [
{ required: true, message: t("login.message.password.required"), trigger: "blur" },
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
{ validator: validateForgetConfirm, trigger: "blur" },
],
}));
const loginForm = reactive<LoginFormData>({
@@ -671,11 +648,7 @@ async function submitForget() {
await forgetPanelRef.value.validate?.();
forgetLoading.value = true;
await UserAPI.forgetPassword(forgetForm);
loginForm.username = forgetForm.username;
loginForm.password = forgetForm.new_password;
forgetForm.username = "";
forgetForm.new_password = "";
forgetForm.confirmPassword = "";
setAuthPanel("login");
} catch (error) {
console.error("[Login] forget password:", error);
@@ -276,9 +276,10 @@ const jobStoreOptions: OptionItem[] = [
{ label: "内存(Memory)", value: "memory" },
];
/** 执行器选项(对应 ap_scheduler executors 配置,不再依赖字典) */
/** 执行器选项(对应 ap_scheduler executors 配置:default=AsyncIOExecutor / threadpool / processpool) */
const jobExecutorOptions: OptionItem[] = [
{ label: "线程池", value: "default" },
{ label: "异步协程(事件循环)", value: "default" },
{ label: "线程池", value: "threadpool" },
{ label: "进程池", value: "processpool" },
];
@@ -833,7 +834,7 @@ const initialFormData: Partial<NodeForm> = {
id: undefined,
name: "",
code: undefined,
jobstore: "sqlalchemy",
jobstore: "default",
executor: "default",
func: defaultCodeBlock,
args: undefined,
@@ -931,7 +932,9 @@ async function handleSubmit() {
? formData.value.start_date || undefined
: undefined,
end_date:
trig === "cron" || trig === "interval" ? formData.value.end_date || undefined : undefined,
trig === "cron" || trig === "interval"
? formData.value.end_date || undefined
: undefined,
};
if (id) {
await NodeAPI.updateNode(id, submitData);
@@ -27,8 +27,8 @@
<template #left>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['module_storage:workflow:node:create']"
:perm-delete="['module_storage:workflow:node:delete']"
:perm-create="['module_task:storage:node:create']"
:perm-delete="['module_task:storage:node:delete']"
:delete-loading="batchDeleting"
:create-loading="createLoading"
@add="handleAdd"
@@ -347,14 +347,14 @@ function buildSourceRowActions(
artType: "view",
icon: "ri:link",
iconColor: "var(--el-color-primary)",
perm: "module_storage:workflow:node:query",
perm: "module_task:storage:node:query",
run: () => ctx.onTest(row.id!),
},
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_storage:workflow:node:query",
perm: "module_task:storage:node:query",
run: () => ctx.onDetail(row.id!),
},
{
@@ -362,7 +362,7 @@ function buildSourceRowActions(
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_storage:workflow:node:update",
perm: "module_task:storage:node:update",
run: () => ctx.onEdit(row.id!),
},
{
@@ -370,7 +370,7 @@ function buildSourceRowActions(
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_storage:workflow:node:delete",
perm: "module_task:storage:node:delete",
run: () => ctx.onDelete(row.id!, row.name ?? ""),
},
];
@@ -458,7 +458,7 @@ async function handleTest(id: number) {
// 打开浏览:跳转到文件管理并携带存储源信息
function handleOpen(row: SourceTable) {
router.push({
path: "/task/storage/workflow/browse",
path: "/task/storage/browse",
query: { source_id: row.id, name: row.name, protocol: row.protocol },
});
}
@@ -34,121 +34,113 @@
:class="sseConnected ? 'bg-(--el-color-success)' : 'bg-(--el-color-info)'"
/>{{ sseConnected ? "实时连接" : "连接中…" }}
</span>
<el-button type="primary" @click="openCreate">新建传输任务</el-button>
<ElButton
v-hasPerm="['module_task:storage:transfer:create']"
type="primary"
@click="openCreate"
>新建传输任务</ElButton
>
</div>
</template>
</FaTableHeader>
<div class="min-h-0 flex-1">
<FaTable
:data="tasks"
:columns="columns"
:pagination="{ current: page, size: pageSize, total }"
stripe
border
height="100%"
@pagination:size-change="onPageSizeChange"
@pagination:current-change="onPageChange"
>
<template #task_type="{ row }">
<el-tag :type="row.task_type === 'chain' ? 'warning' : 'primary'" size="small">
{{ row.task_type === "chain" ? "链式" : "多目标" }}
</el-tag>
</template>
<template #source="{ row }">
<div class="min-w-0">
<div>
<el-tag v-if="row.source_type === 'local'" size="small" type="success">本地</el-tag>
<el-tag v-else size="small" type="info">{{
protoText(sourceById(row.source_id))
}}</el-tag>
<span class="ml-1 text-[#606266]">{{
row.source_type === "local"
? row.source_name || "本地文件"
: sourceName(row.source_id)
}}</span>
</div>
<div v-if="row.source_path" class="mt-0.5 truncate text-xs text-[#909399]">
{{ row.source_path }}
</div>
<FaTable
:data="tasks"
:columns="columns"
:pagination="{ current: page, size: pageSize, total }"
@pagination:size-change="onPageSizeChange"
@pagination:current-change="onPageChange"
>
<template #task_type="{ row }">
<ElTag :type="row.task_type === 'chain' ? 'warning' : 'primary'" size="small">
{{ row.task_type === "chain" ? "链式" : "多目标" }}
</ElTag>
</template>
<template #source="{ row }">
<div class="min-w-0">
<div>
<ElTag v-if="row.source_type === 'local'" size="small" type="success">本地</ElTag>
<ElTag v-else size="small" type="info">{{
protoText(sourceById(row.source_id))
}}</ElTag>
<span class="ml-1 text-(--el-text-color-regular)">{{
row.source_type === "local"
? row.source_name || "本地文件"
: sourceName(row.source_id)
}}</span>
</div>
</template>
<template #target="{ row }">
<span class="text-[#606266]" :title="targetFullText(row as TransferTaskItem)">{{
targetShortText(row as TransferTaskItem)
}}</span>
</template>
<template #status="{ row }">
<el-tag :type="statusTypeMap[row.status as TransferStatus] || 'info'">{{
statusText[row.status as TransferStatus] || row.status
}}</el-tag>
</template>
<template #progress="{ row }">
<div class="flex flex-col gap-0.5">
<el-progress
:percentage="row.progress || 0"
:status="row.status === 'failed' ? 'exception' : undefined"
/>
<div
v-if="row.source_path"
class="mt-0.5 truncate text-xs text-(--el-text-color-secondary)"
>
{{ row.source_path }}
</div>
</div>
</template>
<template #target="{ row }">
<span
class="text-(--el-text-color-regular)"
:title="targetFullText(row as TransferTaskItem)"
>{{ targetShortText(row as TransferTaskItem) }}</span
>
</template>
<template #status="{ row }">
<ElTag :type="statusTypeMap[row.status as TransferStatus] || 'info'">{{
statusText[row.status as TransferStatus] || row.status
}}</ElTag>
</template>
<template #progress="{ row }">
<div class="flex flex-col gap-0.5">
<ElProgress
:percentage="row.progress || 0"
:status="row.status === 'failed' ? 'exception' : undefined"
/>
<div
v-if="row.status === 'running' || row.speed > 0"
class="flex items-center gap-1.5 text-xs"
>
<span
class="shrink-0 rounded-[3px] bg-(--el-color-success) px-1.25 py-0.5 text-[11px] leading-none text-white"
>{{ formatSpeed(row.speed) }}</span
>
<span class="whitespace-nowrap text-(--el-text-color-secondary)"
>{{ formatSize(row.transferred_size) }} / {{ formatSize(row.total_size) }}</span
>
</div>
</div>
</template>
<template #info="{ row }">
<div v-if="row.error_msg" class="flex min-w-0 items-center gap-1.5">
<ElPopover placement="top-start" width="360" trigger="hover">
<div
v-if="row.status === 'running' || row.speed > 0"
class="flex items-center gap-1.5 text-xs"
class="max-h-75 overflow-auto break-all whitespace-pre-line text-(--el-color-danger) leading-[1.6]"
>
<span
class="shrink-0 rounded-[3px] bg-(--el-color-success) px-1.25 py-0.5 text-[11px] leading-none text-white"
>{{ formatSpeed(row.speed) }}</span
>
<span class="whitespace-nowrap text-(--el-text-color-secondary)"
>{{ formatSize(row.transferred_size) }} / {{ formatSize(row.total_size) }}</span
>
{{ row.error_msg }}
</div>
</div>
</template>
<template #info="{ row }">
<div v-if="row.error_msg" class="flex min-w-0 items-center gap-1.5">
<el-popover placement="top-start" width="360" trigger="hover">
<div
class="max-h-75 overflow-auto break-all whitespace-pre-line text-[#800000] leading-[1.6]"
<template #reference>
<span
class="inline-block max-w-full truncate align-bottom text-(--el-color-danger)"
>{{ row.error_msg }}</span
>
{{ row.error_msg }}
</div>
<template #reference>
<span class="inline-block max-w-full truncate align-bottom text-[#800000]">{{
row.error_msg
}}</span>
</template>
</el-popover>
<el-button
v-if="row.status === 'failed'"
size="small"
type="primary"
link
@click="copyMessage(row.error_msg)"
>复制</el-button
>
</div>
<span v-else class="ml-1 text-[#606266]">{{
stepSummaryText(row as TransferTaskItem)
}}</span>
</template>
<template #actions="{ row }">
<el-button size="small" link type="primary" @click="openDetail(row as TransferTaskItem)"
>详情</el-button
>
<el-button
v-if="row.status === 'pending' || row.status === 'running'"
</template>
</ElPopover>
<ElButton
v-if="row.status === 'failed'"
size="small"
type="primary"
link
type="warning"
@click="cancelTask(row as TransferTaskItem)"
@click="copyMessage(row.error_msg)"
>复制</ElButton
>
取消
</el-button>
<el-button size="small" link type="danger" @click="removeTask(row as TransferTaskItem)"
>删除</el-button
>
</template>
</FaTable>
</div>
</div>
<span v-else class="ml-1 text-(--el-text-color-regular)">{{
stepSummaryText(row as TransferTaskItem)
}}</span>
</template>
<template #actions="{ row }">
<span v-html="formatTransferOperationCell(row as TransferTaskItem)" />
</template>
</FaTable>
</ElCard>
<!-- 新建传输任务 -->
@@ -174,7 +166,7 @@
>
<!-- 传输流程:具名插槽接管(需 @change 触发流程填充) -->
<template #flow_id>
<el-select
<ElSelect
v-model="selectedFlowId"
placeholder="从已配置流程自动填充(可选,仍可手动调整)"
clearable
@@ -182,18 +174,18 @@
style="width: 100%"
@change="applyFlow"
>
<el-option
<ElOption
v-for="f in enabledFlows"
:key="f.id"
:value="f.id!"
:label="flowOptionLabel(f)"
/>
</el-select>
</ElSelect>
</template>
<!-- 本地文件:componentMap 无 upload 类型,具名插槽接管 -->
<template #local_file>
<el-upload
<ElUpload
drag
:auto-upload="false"
:limit="1"
@@ -203,35 +195,35 @@
style="width: 100%"
>
<div class="el-upload__text">将文件拖到此处,或 <em>点击选择文件</em></div>
</el-upload>
</ElUpload>
</template>
<!-- 目标列表:动态增删行,具名插槽接管 -->
<template #targets>
<div style="width: 100%">
<div v-for="(t, idx) in form.targets" :key="idx" class="mb-2 flex items-center gap-2">
<el-tag
<ElTag
v-if="form.task_type === 'chain'"
size="small"
:type="idx === 0 ? 'primary' : 'warning'"
class="shrink-0"
>
#{{ idx + 1 }}
</el-tag>
<el-select
</ElTag>
<ElSelect
v-model="t.target_id"
placeholder="目标存储源"
filterable
style="width: 250px"
>
<el-option
<ElOption
v-for="s in enabledSources"
:key="s.id"
:label="`${s.name}(${s.protocol}${s.host ? ' · ' + s.host : ''})`"
:value="s.id!"
/>
</el-select>
<el-input
</ElSelect>
<ElInput
v-model="t.target_path"
:placeholder="
selectedFlowId && !t.target_path
@@ -240,15 +232,13 @@
"
style="flex: 1"
/>
<el-button link type="danger" @click="removeTarget(idx)">删除</el-button>
<ElButton link type="danger" @click="removeTarget(idx)">删除</ElButton>
</div>
<div class="flex items-center gap-3">
<el-button type="primary" plain size="small" @click="addTarget"
>+ 添加目标</el-button
>
<ElButton type="primary" plain size="small" @click="addTarget">+ 添加目标</ElButton>
<span
v-if="form.task_type === 'chain' && form.targets.length > 1"
class="text-[#909399]"
class="text-(--el-text-color-secondary)"
>
将按顺序执行:{{ flowPreview }}
</span>
@@ -277,12 +267,12 @@
max-height="70vh"
>
<template #name="{ row }">
<el-tooltip :content="String(row?.name ?? '')" placement="top" :show-after="100">
<ElTooltip :content="String(row?.name ?? '')" placement="top" :show-after="100">
<div class="max-w-45 truncate">{{ row?.name }}</div>
</el-tooltip>
</ElTooltip>
</template>
<template #source="{ row }">
<el-tooltip
<ElTooltip
:content="row ? sourceShortText(row as unknown as TransferTaskItem) : ''"
placement="top"
:show-after="100"
@@ -290,7 +280,7 @@
<div class="max-w-55 truncate">
{{ row ? sourceShortText(row as unknown as TransferTaskItem) : "" }}
</div>
</el-tooltip>
</ElTooltip>
</template>
<template #total_size="{ row }">{{ formatSize(row?.total_size as number) }}</template>
<template #speed="{ row }">{{ formatSpeed(row?.speed as number) }}</template>
@@ -314,12 +304,10 @@
class="rounded-md border border-(--el-border-color-lighter) bg-(--el-fill-color-lighter) px-3 py-2.5"
>
<div class="flex items-center gap-2">
<el-tag :type="idx === 0 ? 'primary' : 'warning'" size="small"
>#{{ idx + 1 }}</el-tag
>
<el-tag :type="statusTypeMap[step.status] || 'info'" size="small">{{
<ElTag :type="idx === 0 ? 'primary' : 'warning'" size="small">#{{ idx + 1 }}</ElTag>
<ElTag :type="statusTypeMap[step.status] || 'info'" size="small">{{
statusText[step.status] || step.status
}}</el-tag>
}}</ElTag>
<span class="ml-auto text-xs text-(--el-text-color-secondary)"
>{{ step.progress }}% · {{ formatSpeed(step.speed) }}</span
>
@@ -353,7 +341,7 @@
</div>
</template>
</div>
<el-empty v-else description="暂无步骤" :image-size="60" />
<ElEmpty v-else description="暂无步骤" :image-size="60" />
</template>
</FaDialog>
</div>
@@ -370,6 +358,7 @@ import TransferAPI, {
} from "@/api/module_storage/transfer";
import NodeAPI, { type SourceTable } from "@/api/module_storage/node";
import FlowAPI, { type FlowTable } from "@/api/module_storage/workflow";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import FaDescriptions, {
type DescriptionsItem,
} from "@/components/display/fa-descriptions/index.vue";
@@ -486,7 +475,13 @@ const columnChecks = ref<ColumnOption[]>([
{ prop: "status", label: "状态", width: 92, useSlot: true },
{ prop: "progress", label: "进度", minWidth: 240, useSlot: true },
{ prop: "info", label: "信息", minWidth: 180, useSlot: true },
{ prop: "actions", label: "操作", width: 150, fixed: "right", useSlot: true },
{
prop: "actions",
label: "操作",
width: 150,
fixed: "right",
formatter: formatTransferOperationCell,
},
]);
/** 渲染列:仅保留列设置中可见的列 */
const columns = computed(() => columnChecks.value.filter((c) => c.visible !== false));
@@ -811,10 +806,46 @@ async function submit() {
}
// ── 取消 / 删除 ───────────────────────────────────────────────────────
/** 操作列(与 demo 案例统一:renderTableOperationCell + 权限码) */
function buildTransferRowActions(row: TransferTaskItem): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_task:storage:transfer:query",
run: () => void openDetail(row),
},
];
if (row.status === "pending" || row.status === "running") {
all.push({
key: "cancel",
label: "取消",
artType: "more",
icon: "ri:stop-circle-line",
iconColor: "var(--el-color-warning)",
perm: "module_task:storage:transfer:update",
run: () => void cancelTask(row),
});
}
all.push({
key: "delete",
label: "删除",
artType: "delete",
perm: "module_task:storage:transfer:delete",
run: () => void removeTask(row),
});
return all;
}
function formatTransferOperationCell(row: TransferTaskItem) {
return renderTableOperationCell(buildTransferRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
});
}
async function cancelTask(row: TransferTaskItem) {
if (!row.id) return;
try {
await ElMessageBox.confirm(`确认取消任务「${row.name}」?`, "提示", { type: "warning" });
await confirmDelete(`确认取消任务「${row.name}」?取消后任务将停止传输。`);
} catch {
return;
}
@@ -824,7 +855,7 @@ async function cancelTask(row: TransferTaskItem) {
async function removeTask(row: TransferTaskItem) {
if (!row.id) return;
try {
await ElMessageBox.confirm(`确认删除任务「${row.name}」?`, "提示", { type: "warning" });
await confirmDelete(`确认删除任务「${row.name}」吗?此操作不可恢复!`);
} catch {
return;
}
@@ -27,8 +27,8 @@
<template #left>
<FaTableHeaderLeft
:remove-ids="selectedIds"
:perm-create="['module_storage:workflow:flow:create']"
:perm-delete="['module_storage:workflow:flow:delete']"
:perm-create="['module_task:storage:flow:create']"
:perm-delete="['module_task:storage:flow:delete']"
:delete-loading="batchDeleting"
:create-loading="createLoading"
@add="handleAdd"
@@ -358,7 +358,7 @@ function buildFlowRowActions(
key: "detail",
label: "查看",
artType: "view",
perm: "module_storage:workflow:flow:query",
perm: "module_task:storage:flow:query",
run: () => ctx.onDetail(row.id!),
},
{
@@ -366,7 +366,7 @@ function buildFlowRowActions(
label: "设计",
artType: "edit",
icon: "ri:pen-nib-line",
perm: "module_storage:workflow:flow:update",
perm: "module_task:storage:flow:update",
run: () => ctx.onEdit(row.id!),
},
{
@@ -375,7 +375,7 @@ function buildFlowRowActions(
artType: "view",
icon: "ri:play-line",
iconColor: "var(--el-color-success)",
perm: "module_storage:workflow:transfer:create",
perm: "module_task:storage:transfer:create",
run: () => ctx.onExecute(row),
},
{
@@ -383,7 +383,7 @@ function buildFlowRowActions(
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_storage:workflow:flow:delete",
perm: "module_task:storage:flow:delete",
run: () => ctx.onDelete(row.id!, row.name ?? ""),
},
];