6d46506726
安全 - /api/ziniao/** 五个匿名接口加管理员鉴权(此前可匿名换取任意员工店铺登录令牌) - 删除 Flask 遗留后门:默认密码建超管 + 每次启动写生产 users 表(服务端与客户端各一份) - 进度/详情接口归属过滤:新增 TaskProgressOwnershipSupport,11 模块 progress/light 与 /tasks/batch 接入,DTO 补 userId,前端 13 个查询封装补传(未传时后端不过滤,兼容旧端) - 代理提取链接(含账密)不再明文入日志(新增 common/util/SecretMasking) - 全局异常兜底不再回传原始异常信息;内部令牌比较改常量时间 - 登录加失败计数与锁定(10 次锁 15 分钟);品牌源文件下载加 SSRF 防护 - AdminApiGuardFilter 覆盖前缀从 2 扩到 15(开关默认 false,行为不变,为收紧做准备) - 生产关闭 springdoc/knife4j(/doc.html 匿名可读全部接口定义) 正确性 - 40901/40902 拆分:锁竞争不再被伪装成 success=true(此前客户端停止重试、分片静默丢失) - 假成功收敛:集采明细批量写失败改为抛出、去重 worker 异常标失败、4 个 worker 改判 success 字段、publish 空 ASIN 行参与批次 flush、巡店删除全失败带 error 上报 - 客户端心跳 discard 移入 finally(7 模块,失败路径不再留僵尸 RUNNING 任务) - 状态机条件更新:跟价停止循环、集采 activate/fail、imagevideo 归档回填、店铺匹配提交 性能 - 前端入口包 JS 1.05MB→204KB、CSS 355KB→10.7KB(Element Plus 改按需 + el-config-provider) - 载荷引用计数按指针里的 taskId 收敛(原 JSON 列 IN 全表扫且逐行调用) - 店铺明细多值批量 INSERT;快照 upsert 预载缓存;结果文件列改单条 UPDATE - 新增迁移 V120(补 3 个缺失索引)/V121(删 4 个被覆盖的冗余索引)/V122(URL 前缀索引) 稳定性 - 新增 common/util/ThreadPools 有界线程池替换 5 处无界队列(防堆积 OOM) - Redis 锁释放改 Lua 原子校验(原裸 delete 会误删他人已过期的锁) - imagevideo 加死节点接管;锁续期失败重试;调度池 4→16;openStream 全部加超时 - 事务内远程对象删除移到提交后;启动恢复锁按实例命名 客户端 - 不再 taskkill /f /im chrome.exe(改为按调试端口精准回收,不杀用户自己的浏览器) - 密码检测不再无条件杀紫鸟进程;品牌检测加全局互斥(代理池不再互相覆盖) - base_dir 统一到 exe 目录(原被 os.getcwd() 覆盖,日志/缓存会分裂两个目录) - 缓存加定时清理;图片下载加超时;mkstemp 句柄托管 测试 - 同步更新受影响的契约测试(构造器签名/条件更新/方法改名/新增接口方法等) - 修复 FaultInjectionTest 等 3 处 mock 未 stub 流式 read 导致的读循环 OOM - mvn test 2795 个测试全绿
257 lines
6.3 KiB
Vue
257 lines
6.3 KiB
Vue
<template>
|
||
<div class="history-task-layer">
|
||
<button type="button" class="history-btn" @click="open = true">
|
||
历史任务<template v-if="count > 0">({{ count }})</template>
|
||
</button>
|
||
|
||
<!-- destroy-on-close:抽屉关闭即销毁内部 DOM。默认按 v-show 保留,历史条目多时
|
||
(每卡 8+ 节点)关闭后仍留在文档里参与后续 diff;条目数上限属产品语义,另行决策。 -->
|
||
<el-drawer v-model="open" :title="historyTitle" size="640px" class="task-history-drawer" destroy-on-close>
|
||
<div class="history-body">
|
||
<!-- 批量删除工具条(传入 onBatchDelete 时显示) -->
|
||
<div v-if="onBatchDelete" class="history-toolbar">
|
||
<label class="select-all">
|
||
<input v-model="allSelected" type="checkbox" title="全选/取消全选" />
|
||
<span>全选</span>
|
||
</label>
|
||
<span class="selected-count" v-if="selectedKeys.size > 0">已选 {{ selectedKeys.size }} 项</span>
|
||
<button type="button" class="btn-delete" :disabled="!selectedKeys.size || batchDeleting"
|
||
@click="confirmBatchDelete">
|
||
{{ batchDeleting ? '删除中...' : `批量删除${selectedKeys.size ? `(${selectedKeys.size})` : ''}` }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="!items.length" class="empty-tasks">{{ emptyText }}</div>
|
||
<ul v-else class="history-list">
|
||
<li v-for="item in items" :key="item.key" class="history-item-row" :class="{ 'is-selected': isSelected(item.key) }">
|
||
<label v-if="onBatchDelete" class="row-check" @click.stop>
|
||
<input v-model="selectedSet" type="checkbox" :value="item.key" :disabled="batchDeleting" />
|
||
</label>
|
||
<div class="history-item-card">
|
||
<TaskItemCard :item="item">
|
||
<template #extra="scope">
|
||
<slot name="history-item-extra" v-bind="scope" />
|
||
</template>
|
||
<template #actions="scope">
|
||
<slot name="history-item-actions" v-bind="scope" />
|
||
</template>
|
||
</TaskItemCard>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { ElMessageBox } from 'element-plus'
|
||
|
||
import TaskItemCard from './TaskItemCard.vue'
|
||
import type { TaskItemView } from './types'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
/** 历史任务数量(按钮角标) */
|
||
count: number
|
||
/** 弹层标题 */
|
||
historyTitle?: string
|
||
/** 历史任务列表 */
|
||
items: TaskItemView[]
|
||
/** 空列表提示 */
|
||
emptyText?: string
|
||
/** 批量删除回调(传入后历史列表支持勾选批量删除;不传则不显示复选框) */
|
||
onBatchDelete?: (items: TaskItemView[]) => Promise<void> | void
|
||
}>(), {
|
||
historyTitle: '历史任务',
|
||
emptyText: '暂无历史任务',
|
||
})
|
||
|
||
const open = ref(false)
|
||
const batchDeleting = ref(false)
|
||
/** 勾选集合:以 TaskItemView.key 为维度 */
|
||
const selectedSet = ref<Set<string>>(new Set())
|
||
|
||
const selectedKeys = computed(() => selectedSet.value)
|
||
const allSelected = computed({
|
||
get: () => Boolean(props.items.length) && selectedSet.value.size === props.items.length,
|
||
set: (checked: boolean) => {
|
||
const next = new Set<string>()
|
||
if (checked) {
|
||
props.items.forEach((item) => next.add(item.key))
|
||
}
|
||
selectedSet.value = next
|
||
},
|
||
})
|
||
|
||
watch(open, (value) => {
|
||
if (!value) {
|
||
selectedSet.value = new Set()
|
||
}
|
||
})
|
||
|
||
function isSelected(key: string) {
|
||
return selectedSet.value.has(key)
|
||
}
|
||
|
||
async function confirmBatchDelete() {
|
||
if (!props.onBatchDelete || !selectedSet.value.size || batchDeleting.value) return
|
||
const target = props.items.filter((item) => selectedSet.value.has(item.key))
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定批量删除选中的 ${target.length} 条历史记录吗?删除后不可恢复。`,
|
||
'批量删除历史记录',
|
||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
batchDeleting.value = true
|
||
try {
|
||
await props.onBatchDelete(target)
|
||
selectedSet.value = new Set()
|
||
} finally {
|
||
batchDeleting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.history-task-layer {
|
||
display: inline-flex;
|
||
}
|
||
|
||
.history-btn {
|
||
padding: 5px 12px;
|
||
border: 1px solid #3e4a62;
|
||
border-radius: 6px;
|
||
background: #242424;
|
||
color: #c8d2e2;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.history-btn:hover {
|
||
background: #2b3447;
|
||
color: #f5f8fc;
|
||
}
|
||
|
||
.history-body {
|
||
padding: 4px 2px 12px;
|
||
}
|
||
|
||
.history-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 0 4px 12px;
|
||
border-bottom: 1px solid #2e3a52;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.select-all {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #c8d2e2;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.selected-count {
|
||
color: #a0acbe;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.btn-delete {
|
||
margin-left: auto;
|
||
padding: 6px 14px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
background: rgba(231, 76, 60, .18);
|
||
color: #ff8f8f;
|
||
font-size: 12px;
|
||
transition: background .2s;
|
||
}
|
||
|
||
.btn-delete:hover:not(:disabled) {
|
||
background: rgba(231, 76, 60, .32);
|
||
}
|
||
|
||
.btn-delete:disabled {
|
||
opacity: .5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.empty-tasks {
|
||
color: #5e6878;
|
||
font-size: 13px;
|
||
padding: 18px;
|
||
text-align: center;
|
||
}
|
||
|
||
.history-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.history-item-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.row-check {
|
||
flex: 0 0 auto;
|
||
padding-top: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.row-check input {
|
||
width: 15px;
|
||
height: 15px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.history-item-card {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.history-item-row.is-selected .history-item-card {
|
||
border-radius: 8px;
|
||
outline: 2px solid rgba(52, 152, 219, .35);
|
||
outline-offset: -1px;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* el-drawer 深色主题(非 scoped:Element Plus 组件挂在 body 下) */
|
||
.task-history-drawer .el-drawer__header {
|
||
margin-bottom: 0;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #2e3a52;
|
||
color: #f5f8fc;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.task-history-drawer .el-drawer__close-btn {
|
||
color: #a0acbe;
|
||
}
|
||
|
||
.task-history-drawer .el-drawer__body {
|
||
background: #151a25;
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.task-history-drawer .el-drawer {
|
||
background: #151a25;
|
||
}
|
||
</style>
|