GuDingZiChan/frontend/src/views/inventory/InventoryCreate.vue

651 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>