651 lines
26 KiB
Vue
651 lines
26 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<section class="page-header">
|
||
<div>
|
||
<p class="page-kicker">Scope Builder</p>
|
||
<h2 class="page-title">新建盘点任务</h2>
|
||
<div class="mt-3 flex flex-wrap gap-2">
|
||
<router-link to="/inventory" class="btn-secondary text-sm">返回</router-link>
|
||
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
|
||
{{ previewLoading ? '预览中...' : '预览' }}
|
||
</button>
|
||
<button type="button" class="btn-primary text-sm" :disabled="!canCreate" @click="handleSubmit">
|
||
{{ creating ? '创建中...' : '创建' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-meta-grid">
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">当前范围</p>
|
||
<p class="page-meta__value text-base">{{ currentScopeLabel }}</p>
|
||
<p class="page-meta__subvalue">同维度多选并集,跨维度自动取交集</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">预估参与人</p>
|
||
<p class="page-meta__value">{{ formatNumber(preview?.participants_count || 0) }}</p>
|
||
<p class="page-meta__subvalue">有明确责任人的资产会自动挂到对应盘点人</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">预估资产数</p>
|
||
<p class="page-meta__value">{{ formatNumber(preview?.items_count || 0) }}</p>
|
||
<p class="page-meta__subvalue">其中无人归属 {{ formatNumber(preview?.unassigned_count || 0) }} 件</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="list-header">
|
||
<h3 class="section-title">任务信息</h3>
|
||
<span v-if="previewDirty" class="badge badge-warning">范围已变更,请重新预览</span>
|
||
</div>
|
||
|
||
<div class="field-grid">
|
||
<div class="field-stack">
|
||
<label class="field-label">任务标题 *</label>
|
||
<input
|
||
v-model.trim="form.title"
|
||
type="text"
|
||
class="input"
|
||
placeholder="例如:2026 年 3 月在用资产盘点"
|
||
/>
|
||
</div>
|
||
<div class="field-stack lg:col-span-2">
|
||
<label class="field-label">任务说明</label>
|
||
<textarea
|
||
v-model.trim="form.description"
|
||
class="input"
|
||
rows="3"
|
||
placeholder="可选,给盘点负责人留一句简短说明"
|
||
></textarea>
|
||
</div>
|
||
<div class="field-stack">
|
||
<label class="field-label">截止时间</label>
|
||
<input v-model="form.deadline_at" type="datetime-local" class="input" />
|
||
<p class="text-xs leading-5 text-text-tertiary">通知中会明确显示,便于员工及时完成。</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card space-y-6">
|
||
<div class="list-header">
|
||
<div>
|
||
<h3 class="section-title">盘点范围</h3>
|
||
<p class="mt-1 text-sm text-text-secondary">留空表示该维度不限;同一维度可以多选。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="panel space-y-4">
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<p class="text-sm font-semibold text-text-primary">指定盘点人员(可选)</p>
|
||
<p class="mt-1 text-xs leading-5 text-text-secondary">
|
||
留空时覆盖当前资产范围内的全部责任人;指定后只纳入这些人员当前持有的资产。
|
||
</p>
|
||
</div>
|
||
<span class="badge badge-neutral">已选 {{ form.person_ids.length }} 人</span>
|
||
</div>
|
||
|
||
<div v-if="selectedPeople.length" class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="person in selectedPeople"
|
||
:key="person.id"
|
||
type="button"
|
||
class="badge badge-neutral"
|
||
:aria-label="`移除 ${person.nickname}`"
|
||
@click="togglePerson(person)"
|
||
>
|
||
{{ person.nickname }} · {{ person.real_name }} ×
|
||
</button>
|
||
</div>
|
||
|
||
<div class="field-stack">
|
||
<label class="field-label">搜索花名、姓名或工号</label>
|
||
<input v-model.trim="personKeyword" type="text" class="input" placeholder="输入关键词选择人员" />
|
||
</div>
|
||
|
||
<div v-if="personLoading" class="empty-state">正在搜索人员...</div>
|
||
<div v-else class="grid max-h-64 gap-2 overflow-auto sm:grid-cols-2 xl:grid-cols-3">
|
||
<button
|
||
v-for="person in personOptions"
|
||
:key="person.id"
|
||
type="button"
|
||
class="touch-option text-left"
|
||
:class="{ 'is-active': form.person_ids.includes(person.id) }"
|
||
@click="togglePerson(person)"
|
||
>
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="min-w-0">
|
||
<p class="truncate text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
||
<p class="mt-1 truncate text-xs text-text-secondary">
|
||
{{ person.main_department?.name || person.department || '未归属部门' }}
|
||
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
||
</p>
|
||
</div>
|
||
<span class="badge" :class="person.dingtalk_ready ? 'badge-success' : 'badge-warning'">
|
||
{{ person.dingtalk_ready ? '钉钉可达' : '无法通知' }}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
<p v-if="personOptions.length === 0" class="py-6 text-center text-sm text-text-tertiary sm:col-span-2 xl:col-span-3">
|
||
没有匹配的在职人员。
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="grid gap-6 xl:grid-cols-3">
|
||
<section class="space-y-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<p class="text-sm font-semibold text-text-primary">业务部门</p>
|
||
<p class="mt-1 text-xs text-text-tertiary">{{ selectedDepartmentLabel }},自动包含下级部门</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="btn-secondary text-xs"
|
||
:disabled="form.department_ids.length === 0"
|
||
@click="clearSelection('department_ids')"
|
||
>
|
||
清空
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="selectedDepartments.length > 0" class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="department in selectedDepartments"
|
||
:key="department.id"
|
||
class="badge badge-neutral"
|
||
>
|
||
{{ department.name }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="panel max-h-72 space-y-2 overflow-auto">
|
||
<label
|
||
v-for="department in departments"
|
||
:key="department.id"
|
||
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
|
||
>
|
||
<input
|
||
:checked="form.department_ids.includes(department.id)"
|
||
type="checkbox"
|
||
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
|
||
@change="toggleSelection('department_ids', department.id)"
|
||
/>
|
||
<span class="min-w-0 flex-1">
|
||
<span class="block text-sm text-text-primary">{{ department.name }}</span>
|
||
<span v-if="department.parent_name" class="mt-1 block text-xs text-text-tertiary">
|
||
上级:{{ department.parent_name }}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<p v-if="departments.length === 0" class="py-6 text-center text-sm text-text-tertiary">
|
||
暂无可选业务部门
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="space-y-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<p class="text-sm font-semibold text-text-primary">资产类型</p>
|
||
<p class="mt-1 text-xs text-text-tertiary">{{ selectedAssetTypeLabel }}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="btn-secondary text-xs"
|
||
:disabled="form.asset_type_ids.length === 0"
|
||
@click="clearSelection('asset_type_ids')"
|
||
>
|
||
清空
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="selectedAssetTypes.length > 0" class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="assetType in selectedAssetTypes"
|
||
:key="assetType.id"
|
||
class="badge badge-neutral"
|
||
>
|
||
{{ assetType.name }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="panel max-h-72 space-y-2 overflow-auto">
|
||
<label
|
||
v-for="assetType in assetTypes"
|
||
:key="assetType.id"
|
||
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
|
||
>
|
||
<input
|
||
:checked="form.asset_type_ids.includes(assetType.id)"
|
||
type="checkbox"
|
||
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
|
||
@change="toggleSelection('asset_type_ids', assetType.id)"
|
||
/>
|
||
<span class="min-w-0 flex-1">
|
||
<span class="block text-sm text-text-primary">{{ assetType.name }}</span>
|
||
<span v-if="assetType.short_name" class="mt-1 block text-xs text-text-tertiary">
|
||
编码缩写:{{ assetType.short_name }}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<p v-if="assetTypes.length === 0" class="py-6 text-center text-sm text-text-tertiary">
|
||
暂无可选资产类型
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="space-y-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<p class="text-sm font-semibold text-text-primary">地区</p>
|
||
<p class="mt-1 text-xs text-text-tertiary">{{ selectedStorageLocationLabel }}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="btn-secondary text-xs"
|
||
:disabled="form.storage_location_ids.length === 0"
|
||
@click="clearSelection('storage_location_ids')"
|
||
>
|
||
清空
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="selectedStorageLocations.length > 0" class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="location in selectedStorageLocations"
|
||
:key="location.id"
|
||
class="badge badge-neutral"
|
||
>
|
||
{{ location.name }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="panel max-h-72 space-y-2 overflow-auto">
|
||
<label
|
||
v-for="location in storageLocations"
|
||
:key="location.id"
|
||
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
|
||
>
|
||
<input
|
||
:checked="form.storage_location_ids.includes(location.id)"
|
||
type="checkbox"
|
||
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
|
||
@change="toggleSelection('storage_location_ids', location.id)"
|
||
/>
|
||
<span class="min-w-0 flex-1">
|
||
<span class="block text-sm text-text-primary">{{ location.name }}</span>
|
||
<span v-if="location.code" class="mt-1 block text-xs text-text-tertiary">
|
||
编码:{{ location.code }}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<p v-if="storageLocations.length === 0" class="py-6 text-center text-sm text-text-tertiary">
|
||
暂无可选地区
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="list-header">
|
||
<h3 class="section-title">范围预览</h3>
|
||
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
|
||
{{ previewLoading ? '刷新中...' : '刷新' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="previewLoading" class="empty-state">正在根据当前范围生成预览...</div>
|
||
|
||
<div v-else-if="preview" class="space-y-6">
|
||
<div class="grid gap-4 sm:grid-cols-3">
|
||
<div class="panel">
|
||
<p class="mobile-card__label">参与人</p>
|
||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.participants_count) }}</p>
|
||
<p class="mt-1 text-xs text-text-secondary">系统会自动汇总到明确责任人名下</p>
|
||
</div>
|
||
<div class="panel">
|
||
<p class="mobile-card__label">待盘点资产</p>
|
||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.items_count) }}</p>
|
||
<p class="mt-1 text-xs text-text-secondary">按当前借用快照生成</p>
|
||
</div>
|
||
<div class="panel">
|
||
<p class="mobile-card__label">无人归属资产</p>
|
||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
|
||
<p class="mt-1 text-xs text-text-secondary">这部分会单列给管理人员处理,管理人员也可直接代扫</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-6 lg:grid-cols-2">
|
||
<section class="space-y-3">
|
||
<h4 class="section-title">按部门拆分</h4>
|
||
<div v-if="preview.department_breakdown.length === 0" class="empty-state">当前范围没有匹配到部门分布。</div>
|
||
<div v-else class="space-y-3">
|
||
<div
|
||
v-for="department in preview.department_breakdown.slice(0, 8)"
|
||
:key="department.department_name"
|
||
class="panel"
|
||
>
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p class="text-sm font-medium text-text-primary">{{ department.department_name }}</p>
|
||
<p class="mt-1 text-xs text-text-secondary">
|
||
已分配 {{ formatNumber(department.assigned) }} 件 / 待分配 {{ formatNumber(department.unassigned) }} 件
|
||
</p>
|
||
</div>
|
||
<span class="badge badge-neutral">{{ formatNumber(department.total) }} 件</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="space-y-3">
|
||
<h4 class="section-title">按资产类型拆分</h4>
|
||
<div v-if="preview.asset_type_breakdown.length === 0" class="empty-state">当前范围没有匹配到资产类型分布。</div>
|
||
<div v-else class="space-y-3">
|
||
<div
|
||
v-for="assetType in preview.asset_type_breakdown.slice(0, 8)"
|
||
:key="assetType.asset_type_name"
|
||
class="panel flex items-center justify-between gap-3"
|
||
>
|
||
<div>
|
||
<p class="text-sm font-medium text-text-primary">{{ assetType.asset_type_name }}</p>
|
||
<p class="mt-1 text-xs text-text-secondary">已纳入本次盘点范围</p>
|
||
</div>
|
||
<span class="badge badge-neutral">{{ formatNumber(assetType.total) }} 件</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="grid gap-6 lg:grid-cols-2">
|
||
<section class="space-y-3">
|
||
<h4 class="section-title">参与人样例</h4>
|
||
<div v-if="preview.participants_sample.length === 0" class="empty-state">当前范围没有生成参与人。</div>
|
||
<div v-else class="space-y-3">
|
||
<div
|
||
v-for="participant in preview.participants_sample"
|
||
:key="participant.person_id"
|
||
class="panel"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<p class="text-sm font-medium text-text-primary">
|
||
{{ participant.person_nickname }} · {{ participant.person_name }}
|
||
</p>
|
||
<span
|
||
v-if="isInventoryPublicAssetPerson(participant)"
|
||
class="badge badge-neutral !border-status-warning/30 !bg-status-warning/10 !text-status-warning"
|
||
>
|
||
{{ getInventoryPublicAssetLabel() }}
|
||
</span>
|
||
</div>
|
||
<p class="mt-1 text-xs text-text-secondary">
|
||
{{ participant.department_name || '未归属部门' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="space-y-3">
|
||
<h4 class="section-title">资产样例</h4>
|
||
<div v-if="preview.items_sample.length === 0" class="empty-state">当前范围没有资产。</div>
|
||
<div v-else class="space-y-3">
|
||
<div
|
||
v-for="item in preview.items_sample"
|
||
:key="item.equipment_id"
|
||
class="panel"
|
||
>
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p class="text-sm font-medium text-text-primary">{{ item.equipment_name }}</p>
|
||
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
|
||
<p class="mt-2 text-xs text-text-secondary">
|
||
{{ item.snapshot_person_nickname || '未纳入' }}
|
||
<span v-if="isInventoryPublicAssetPerson(item)" class="ml-2 text-status-warning">
|
||
· {{ getInventoryPublicAssetLabel() }}
|
||
</span>
|
||
<span v-if="item.snapshot_department_name"> · {{ item.snapshot_department_name }}</span>
|
||
</p>
|
||
</div>
|
||
<span class="badge badge-neutral">已纳入</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="empty-state">还没有生成预览,点“预览”查看本次覆盖范围。</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { getAssetTypeOptions } from '@/api/assetType'
|
||
import { getDepartmentOptions } from '@/api/department'
|
||
import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
|
||
import { getStorageLocationOptions } from '@/api/storageLocation'
|
||
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||
import type { AssetType, Department, InventoryTaskPreview, StorageLocation } from '@/types'
|
||
import { formatNumber } from '@/utils/format'
|
||
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
|
||
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||
|
||
const router = useRouter()
|
||
|
||
const departments = ref<Department[]>([])
|
||
const assetTypes = ref<AssetType[]>([])
|
||
const storageLocations = ref<StorageLocation[]>([])
|
||
const personOptions = ref<PersonOption[]>([])
|
||
const selectedPeople = ref<PersonOption[]>([])
|
||
const personKeyword = ref('')
|
||
const personLoading = ref(false)
|
||
const preview = ref<InventoryTaskPreview | null>(null)
|
||
const previewLoading = ref(false)
|
||
const creating = ref(false)
|
||
const previewDirty = ref(true)
|
||
|
||
const toLocalDateTimeInput = (date: Date) => {
|
||
const pad = (value: number) => String(value).padStart(2, '0')
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||
}
|
||
|
||
const defaultDeadline = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||
defaultDeadline.setSeconds(0, 0)
|
||
|
||
const form = reactive({
|
||
title: '',
|
||
description: '',
|
||
deadline_at: toLocalDateTimeInput(defaultDeadline),
|
||
department_ids: [] as number[],
|
||
asset_type_ids: [] as number[],
|
||
storage_location_ids: [] as number[],
|
||
person_ids: [] as number[]
|
||
})
|
||
|
||
const selectedDepartments = computed(() =>
|
||
departments.value.filter((department) => form.department_ids.includes(department.id))
|
||
)
|
||
const selectedAssetTypes = computed(() =>
|
||
assetTypes.value.filter((assetType) => form.asset_type_ids.includes(assetType.id))
|
||
)
|
||
const selectedStorageLocations = computed(() =>
|
||
storageLocations.value.filter((location) => form.storage_location_ids.includes(location.id))
|
||
)
|
||
|
||
const currentScopeLabel = computed(() => {
|
||
const scope = buildInventoryScopeLabel({
|
||
scope_departments: selectedDepartments.value,
|
||
scope_asset_types: selectedAssetTypes.value,
|
||
scope_storage_locations: selectedStorageLocations.value
|
||
})
|
||
return form.person_ids.length > 0 ? `${scope} · 指定 ${form.person_ids.length} 人` : scope
|
||
})
|
||
const selectedDepartmentLabel = computed(() =>
|
||
form.department_ids.length > 0 ? `已选 ${form.department_ids.length} 个业务部门` : '留空表示全部业务部门'
|
||
)
|
||
const selectedAssetTypeLabel = computed(() =>
|
||
form.asset_type_ids.length > 0 ? `已选 ${form.asset_type_ids.length} 个资产类型` : '留空表示全部资产类型'
|
||
)
|
||
const selectedStorageLocationLabel = computed(() =>
|
||
form.storage_location_ids.length > 0 ? `已选 ${form.storage_location_ids.length} 个地区` : '留空表示全部地区'
|
||
)
|
||
const canCreate = computed(() => form.title.length > 0 && !creating.value)
|
||
|
||
const buildPreviewPayload = () => ({
|
||
department_ids: [...form.department_ids],
|
||
asset_type_ids: [...form.asset_type_ids],
|
||
storage_location_ids: [...form.storage_location_ids],
|
||
person_ids: [...form.person_ids]
|
||
})
|
||
|
||
const toggleSelection = (
|
||
key: 'department_ids' | 'asset_type_ids' | 'storage_location_ids',
|
||
id: number
|
||
) => {
|
||
const currentValues = form[key]
|
||
const nextValues = currentValues.includes(id)
|
||
? currentValues.filter((currentId) => currentId !== id)
|
||
: [...currentValues, id]
|
||
|
||
form[key] = nextValues.sort((left, right) => left - right) as never
|
||
}
|
||
|
||
const clearSelection = (key: 'department_ids' | 'asset_type_ids' | 'storage_location_ids') => {
|
||
form[key] = [] as never
|
||
}
|
||
|
||
const togglePerson = (person: PersonOption) => {
|
||
if (form.person_ids.includes(person.id)) {
|
||
form.person_ids = form.person_ids.filter((id) => id !== person.id)
|
||
selectedPeople.value = selectedPeople.value.filter((item) => item.id !== person.id)
|
||
return
|
||
}
|
||
|
||
form.person_ids = [...form.person_ids, person.id].sort((left, right) => left - right)
|
||
selectedPeople.value = [...selectedPeople.value, person]
|
||
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
|
||
}
|
||
|
||
let personSearchTimer: number | null = null
|
||
|
||
const searchPeople = async () => {
|
||
personLoading.value = true
|
||
try {
|
||
const response = await getPersonOptions({
|
||
page: 1,
|
||
pageSize: 30,
|
||
keyword: personKeyword.value,
|
||
status: 'active'
|
||
})
|
||
personOptions.value = response.data?.list || []
|
||
} catch (error: any) {
|
||
personOptions.value = []
|
||
alert(error?.message || '搜索盘点人员失败')
|
||
} finally {
|
||
personLoading.value = false
|
||
}
|
||
}
|
||
|
||
const loadOptions = async () => {
|
||
try {
|
||
const [departmentResponse, assetTypeResponse, storageLocationResponse] = await Promise.all([
|
||
getDepartmentOptions(),
|
||
getAssetTypeOptions(),
|
||
getStorageLocationOptions()
|
||
])
|
||
|
||
departments.value = (departmentResponse.data || [])
|
||
.filter((department) => department.status === 'active')
|
||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||
assetTypes.value = (assetTypeResponse.data || [])
|
||
.filter((assetType) => assetType.status === 'active')
|
||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||
storageLocations.value = (storageLocationResponse.data || [])
|
||
.filter((location) => location.status === 'active')
|
||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||
} catch (error: any) {
|
||
alert(error?.message || '加载盘点范围选项失败')
|
||
}
|
||
}
|
||
|
||
const handlePreview = async () => {
|
||
previewLoading.value = true
|
||
try {
|
||
const response = await previewInventoryTask(buildPreviewPayload())
|
||
preview.value = response.data
|
||
previewDirty.value = false
|
||
} catch (error: any) {
|
||
alert(error?.message || '预览盘点范围失败')
|
||
} finally {
|
||
previewLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
if (!form.title.trim()) {
|
||
alert('请先填写任务标题')
|
||
return
|
||
}
|
||
|
||
if (previewDirty.value || !preview.value) {
|
||
await handlePreview()
|
||
}
|
||
|
||
if (!preview.value) {
|
||
return
|
||
}
|
||
|
||
if (preview.value.items_count === 0) {
|
||
const shouldContinue = window.confirm('当前范围没有匹配到资产,仍然要创建空盘点任务吗?')
|
||
if (!shouldContinue) {
|
||
return
|
||
}
|
||
}
|
||
|
||
creating.value = true
|
||
try {
|
||
const response = await createInventoryTask({
|
||
title: form.title.trim(),
|
||
description: form.description.trim() || undefined,
|
||
deadline_at: form.deadline_at ? new Date(form.deadline_at).toISOString() : undefined,
|
||
...buildPreviewPayload()
|
||
})
|
||
alert('盘点任务已创建')
|
||
router.push(`/inventory/${response.data.id}`)
|
||
} catch (error: any) {
|
||
alert(error?.message || '创建盘点任务失败')
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => [form.department_ids.join(','), form.asset_type_ids.join(','), form.storage_location_ids.join(','), form.person_ids.join(',')],
|
||
() => {
|
||
previewDirty.value = true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => personKeyword.value,
|
||
() => {
|
||
if (personSearchTimer) window.clearTimeout(personSearchTimer)
|
||
personSearchTimer = window.setTimeout(searchPeople, 250)
|
||
}
|
||
)
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadOptions(), searchPeople()])
|
||
handlePreview()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (personSearchTimer) window.clearTimeout(personSearchTimer)
|
||
})
|
||
</script>
|