- diff长行折行可读/任务描述与产出宽表格横滚/确认弹窗长路径折行 - 滚动条色抽token浅色主题可见/阶段徽章与优先级色对齐语义token - git状态色与错误色替换散落硬编码 - alert残留换toast走i18n/分离窗口缺参数补错误态/dev路由生产守卫
278 lines
9.5 KiB
Vue
278 lines
9.5 KiB
Vue
<template>
|
|
<div class="task-batch-bar" v-if="count > 0">
|
|
<div class="tbb-info">
|
|
<span class="tbb-count">{{ $t('tasks.batch.selected', { n: count }) }}</span>
|
|
<button class="btn btn-ghost btn-sm" @click="$emit('clear')">{{ $t('tasks.batch.clear') }}</button>
|
|
</div>
|
|
|
|
<div class="tbb-actions">
|
|
<!-- 推进到 ▾(仅渲染全部可达的交集目标) -->
|
|
<div class="tbb-menu" ref="advanceMenuRef">
|
|
<button class="btn btn-ghost btn-sm" :disabled="busy || advanceTargets.length === 0" @click.stop="toggleMenu('advance')">
|
|
{{ $t('tasks.batch.advanceTo') }} ▾
|
|
</button>
|
|
<div v-if="activeMenu === 'advance'" class="tbb-dropdown" @click.stop>
|
|
<div v-if="advanceTargets.length === 0" class="tbb-dropdown-empty">{{ $t('tasks.batch.noCommonTarget') }}</div>
|
|
<button v-for="s in advanceTargets" :key="s.key" class="tbb-dropdown-item" @click="pick('advance', s.key)">
|
|
<span>{{ s.icon }}</span>{{ s.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-ghost btn-sm" :disabled="busy || !cancelAvailable" @click="$emit('cancel')">{{ $t('tasks.batch.cancel') }}</button>
|
|
<button class="btn btn-ghost btn-sm" :disabled="busy || !deferAvailable" @click="$emit('defer')">{{ $t('tasks.batch.defer') }}</button>
|
|
<button class="btn btn-ghost btn-sm" :disabled="busy || !resumeAvailable" @click="$emit('resume')">{{ $t('tasks.batch.resume') }}</button>
|
|
|
|
<!-- 改优先级 ▾ -->
|
|
<div class="tbb-menu" ref="priorityMenuRef">
|
|
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('priority')">
|
|
{{ $t('tasks.batch.priority') }} ▾
|
|
</button>
|
|
<div v-if="activeMenu === 'priority'" class="tbb-dropdown" @click.stop>
|
|
<button v-for="p in priorityOptions" :key="p.value" class="tbb-dropdown-item" @click="pick('priority', String(p.value))">
|
|
<span :class="p.cls">●</span>{{ p.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 指派 ▾(内嵌输入 + 建议) -->
|
|
<div class="tbb-menu" ref="assigneeMenuRef">
|
|
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('assignee')">
|
|
{{ $t('tasks.batch.assignee') }} ▾
|
|
</button>
|
|
<div v-if="activeMenu === 'assignee'" class="tbb-dropdown tbb-dropdown--assignee" @click.stop>
|
|
<input
|
|
v-model="assigneeInput"
|
|
class="tbb-assignee-input"
|
|
:placeholder="$t('tasks.batch.assigneePlaceholder')"
|
|
list="tbb-assignee-suggest"
|
|
@keydown.enter="confirmAssignee"
|
|
/>
|
|
<datalist id="tbb-assignee-suggest">
|
|
<option v-for="a in assigneeSuggestions" :key="a" :value="a" />
|
|
</datalist>
|
|
<button class="btn btn-primary btn-sm tbb-assignee-ok" :disabled="!assigneeInput.trim()" @click="confirmAssignee">
|
|
{{ $t('common.confirm') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<span v-if="busy" class="tbb-spinner" aria-hidden="true"></span>
|
|
</div>
|
|
|
|
<button class="btn btn-sm tbb-danger" :disabled="busy" @click="confirmDelete">{{ $t('tasks.batch.delete') }}</button>
|
|
</div>
|
|
|
|
<!-- toast 反馈(批量动作结果) -->
|
|
<Transition name="toast">
|
|
<div v-if="toast.visible" class="tbb-toast" :class="'tbb-toast--' + toast.type">{{ toast.msg }}</div>
|
|
</Transition>
|
|
|
|
<!-- 删除二次确认 -->
|
|
<ConfirmDialog
|
|
:visible="confirmState.visible"
|
|
:msg="confirmState.msg"
|
|
:danger-label="$t('common.delete')"
|
|
@result="answerConfirm"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
|
import { useConfirm } from '@/composables/useConfirm'
|
|
import type { ToastState } from '@/composables/useToast'
|
|
|
|
export interface AdvanceTargetOption {
|
|
key: string
|
|
icon: string
|
|
label: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
count: number
|
|
busy: boolean
|
|
/** 交集合法推进目标(空 = 无可统一推进) */
|
|
advanceTargets: AdvanceTargetOption[]
|
|
cancelAvailable: boolean
|
|
deferAvailable: boolean
|
|
resumeAvailable: boolean
|
|
/** 删除确认文案用的级联子任务数 */
|
|
cascadeEstimate: number
|
|
assigneeSuggestions?: string[]
|
|
/** 批量动作的 toast 状态(来自 useTaskBatchActions),本组件统一渲染 */
|
|
toast: ToastState
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
advance: [target: string]
|
|
cancel: []
|
|
defer: []
|
|
resume: []
|
|
priority: [value: string]
|
|
assignee: [value: string]
|
|
delete: []
|
|
clear: []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|
|
|
const priorityOptions = computed(() => [
|
|
{ value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' },
|
|
{ value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' },
|
|
{ value: 2, label: t('tasks.modal.priorityMedium'), cls: 'priority-medium' },
|
|
{ value: 3, label: t('tasks.modal.priorityLow'), cls: 'priority-low' },
|
|
])
|
|
|
|
// ---- 下拉展开态(点击外部关闭) ----
|
|
const activeMenu = ref<'advance' | 'priority' | 'assignee' | ''>('')
|
|
const assigneeInput = ref('')
|
|
const advanceMenuRef = ref<HTMLElement | null>(null)
|
|
const priorityMenuRef = ref<HTMLElement | null>(null)
|
|
const assigneeMenuRef = ref<HTMLElement | null>(null)
|
|
|
|
function toggleMenu(menu: 'advance' | 'priority' | 'assignee') {
|
|
activeMenu.value = activeMenu.value === menu ? '' : menu
|
|
}
|
|
|
|
function onDocClick(e: MouseEvent) {
|
|
const inside =
|
|
advanceMenuRef.value?.contains(e.target as Node) ||
|
|
priorityMenuRef.value?.contains(e.target as Node) ||
|
|
assigneeMenuRef.value?.contains(e.target as Node)
|
|
if (!inside) activeMenu.value = ''
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('click', onDocClick))
|
|
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
|
|
|
function pick(kind: 'advance' | 'priority', value: string) {
|
|
activeMenu.value = ''
|
|
if (kind === 'advance') emit('advance', value)
|
|
else emit('priority', value)
|
|
}
|
|
|
|
function confirmAssignee() {
|
|
const v = assigneeInput.value.trim()
|
|
if (!v) return
|
|
activeMenu.value = ''
|
|
assigneeInput.value = ''
|
|
emit('assignee', v)
|
|
}
|
|
|
|
function confirmDelete() {
|
|
const msg =
|
|
props.cascadeEstimate > 0
|
|
? t('tasks.batch.confirmDelete', { n: props.count, c: props.cascadeEstimate })
|
|
: t('tasks.batch.confirmDeletePlain', { n: props.count })
|
|
confirmDialog(msg, t('common.delete')).then(ok => {
|
|
if (ok) emit('delete')
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 批量操作栏:底部固定,居中对齐,选中任务时浮现 */
|
|
.task-batch-bar {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 100;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 16px;
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-lg);
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
max-width: 92vw;
|
|
}
|
|
.tbb-info { display: flex; align-items: center; gap: 8px; }
|
|
.tbb-count { font-size: 12px; color: var(--df-accent); white-space: nowrap; }
|
|
.tbb-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.tbb-spinner {
|
|
width: 14px;
|
|
height: 14px;
|
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
|
border-top-color: var(--df-accent);
|
|
border-radius: 50%;
|
|
animation: tbb-spin 0.9s linear infinite;
|
|
}
|
|
@keyframes tbb-spin { to { transform: rotate(360deg); } }
|
|
.tbb-danger {
|
|
background: transparent;
|
|
color: var(--df-danger);
|
|
border: 0.5px solid rgba(240, 101, 101, 0.4);
|
|
}
|
|
.tbb-danger:hover { background: rgba(240, 101, 101, 0.12); }
|
|
.tbb-danger:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
/* 下拉菜单(向上弹出) */
|
|
.tbb-menu { position: relative; }
|
|
.tbb-dropdown {
|
|
position: absolute;
|
|
bottom: calc(100% + 4px);
|
|
left: 0;
|
|
min-width: 160px;
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-sm);
|
|
box-shadow: 0 -6px 16px rgba(0, 0, 0, 0.25);
|
|
z-index: 110;
|
|
padding: 6px 0;
|
|
}
|
|
.tbb-dropdown--assignee { min-width: 220px; padding: 10px; }
|
|
.tbb-dropdown-empty { font-size: 12px; color: var(--df-text-dim); padding: 4px 12px; }
|
|
.tbb-dropdown-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 5px 12px;
|
|
background: none;
|
|
border: none;
|
|
font-size: 13px;
|
|
color: var(--df-text);
|
|
cursor: pointer;
|
|
}
|
|
.tbb-dropdown-item:hover { background: var(--df-accent-bg); }
|
|
.tbb-assignee-input {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 6px 10px;
|
|
font-size: 13px;
|
|
color: var(--df-text);
|
|
background: var(--df-bg);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-sm);
|
|
outline: none;
|
|
}
|
|
.tbb-assignee-input:focus { border-color: var(--df-accent); }
|
|
.tbb-assignee-ok { margin-top: 8px; width: 100%; }
|
|
|
|
/* toast(独立于操作栏定位,避免被栏遮挡) */
|
|
.tbb-toast {
|
|
position: fixed;
|
|
top: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 200;
|
|
padding: 8px 16px;
|
|
border-radius: var(--df-radius-sm);
|
|
font-size: 13px;
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border);
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
max-width: 80vw;
|
|
}
|
|
.tbb-toast--success { border-color: color-mix(in srgb, var(--df-success) 50%, transparent); color: var(--df-success); }
|
|
.tbb-toast--warning { border-color: color-mix(in srgb, var(--df-warning) 50%, transparent); color: var(--df-warning); }
|
|
.tbb-toast--error { border-color: rgba(240, 101, 101, 0.5); color: var(--df-danger); }
|
|
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s; }
|
|
.toast-enter-from, .toast-leave-to { opacity: 0; }
|
|
</style>
|