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

932 lines
36 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 v-if="loading && !task" class="card">
<div class="empty-state">正在加载盘点任务详情...</div>
</section>
<template v-else-if="task">
<section class="page-header">
<div>
<p class="page-kicker">Inventory Detail</p>
<h2 class="page-title">{{ task.title }}</h2>
<p class="mt-2 text-sm text-text-secondary">{{ task.description || '未填写任务说明' }}</p>
<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="loading" @click="loadTask">刷新</button>
<button type="button" class="btn-secondary text-sm" @click="copyPublicLink">复制链接</button>
<button type="button" class="btn-secondary text-sm" @click="openPublicLink">手机页</button>
<button
v-if="task.status === 'active'"
type="button"
class="btn-secondary text-sm !text-status-error"
:disabled="closing"
@click="handleCloseTask"
>
{{ closing ? '结束中...' : '结束任务' }}
</button>
<button
type="button"
class="btn-secondary text-sm text-status-error"
:disabled="deleting"
@click="handleDeleteTask"
>
{{ deleting ? '删除中...' : '删除任务' }}
</button>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">任务状态</p>
<p class="page-meta__value">{{ getTaskStatusText(task.status) }}</p>
<p class="page-meta__subvalue">
{{ task.closed_at ? `关闭于 ${formatDateTime(task.closed_at)}` : `创建于 ${formatDateTime(task.created_at)}` }}
</p>
</div>
<div class="page-meta">
<p class="page-meta__label">总体进度</p>
<p class="page-meta__value">{{ taskProgressText }}</p>
<p class="page-meta__subvalue">异常 {{ formatNumber(task.exception_count) }} 件</p>
</div>
<div class="page-meta">
<p class="page-meta__label">参与人</p>
<p class="page-meta__value">{{ formatNumber(task.participants_count) }}</p>
<p class="page-meta__subvalue">待确认 {{ formatNumber(pendingParticipantCount) }} 名</p>
</div>
<div class="page-meta">
<p class="page-meta__label">无人归属资产</p>
<p class="page-meta__value">{{ formatNumber(unassignedItems.length) }}</p>
<p class="page-meta__subvalue">这部分会单列处理,其他责任人资产管理员也可直接代扫</p>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">任务概览</h3>
<span class="badge" :class="task.status === 'closed' ? 'badge-neutral' : 'badge-success'">
{{ getTaskStatusText(task.status) }}
</span>
</div>
<div class="field-grid">
<div class="field-stack">
<p class="field-label">盘点范围</p>
<p class="text-sm text-text-primary">{{ scopeLabel }}</p>
</div>
<div class="field-stack">
<p class="field-label">创建人</p>
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
</div>
<div class="field-stack">
<p class="field-label">手机盘点地址</p>
<div class="flex flex-col gap-2 sm:flex-row">
<input :value="publicLink" type="text" class="input font-mono text-xs" readonly />
<button type="button" class="btn-secondary text-sm sm:w-auto" @click="copyPublicLink">复制</button>
</div>
</div>
<div class="field-stack">
<p class="field-label">范围快照时间</p>
<p class="text-sm text-text-primary">
{{ task.scope_snapshot?.generated_at ? formatDateTime(task.scope_snapshot.generated_at) : '未记录' }}
</p>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<section class="space-y-3">
<h4 class="section-title">部门进度</h4>
<div v-if="task.department_breakdown.length === 0" class="empty-state">暂无部门进度数据。</div>
<div v-else class="space-y-3">
<div
v-for="department in task.department_breakdown"
: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.checked) }} / 异常 {{ formatNumber(department.exception) }} / 待盘点 {{ formatNumber(department.pending) }}
</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="task.asset_type_breakdown.length === 0" class="empty-state">暂无资产类型进度数据。</div>
<div v-else class="space-y-3">
<div
v-for="assetType in task.asset_type_breakdown"
:key="assetType.asset_type_name"
class="panel"
>
<div class="flex items-start 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">
已确认 {{ formatNumber(assetType.checked) }} / 异常 {{ formatNumber(assetType.exception) }} / 待盘点 {{ formatNumber(assetType.pending) }}
</p>
</div>
<span class="badge badge-neutral">{{ formatNumber(assetType.total) }} 件</span>
</div>
</div>
</div>
</section>
</div>
</section>
<section v-if="task.status === 'active'" class="card">
<div class="list-header">
<div>
<h3 class="section-title">管理员代扫</h3>
<p class="mt-1 text-sm text-text-secondary">管理员可直接替任意责任人完成扫码盘点,也能顺手处理无人归属资产。</p>
</div>
<span class="badge badge-warning">全任务通用</span>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto_auto] lg:items-end">
<div class="field-stack">
<label class="field-label">资产编码</label>
<input
v-model.trim="adminScanCode"
type="text"
class="input"
placeholder="直接扫码或输入资产编码"
:disabled="adminScanning"
@keyup.enter="handleAdminManualScan"
/>
</div>
<button
type="button"
class="btn-secondary text-sm"
:disabled="adminScanning"
@click="showAdminScanner = true"
>
手机扫码
</button>
<button
type="button"
class="btn-primary text-sm"
:disabled="adminScanning || !adminScanCode"
@click="handleAdminManualScan"
>
{{ adminScanning ? '处理中...' : '确认代扫' }}
</button>
</div>
<div v-if="adminLastHandledItem" class="mt-4 rounded-2xl border border-status-success/30 bg-status-success/10 px-4 py-4">
<p class="text-sm font-medium text-status-success">最近代扫成功</p>
<p class="mt-2 text-sm text-text-primary">
{{ adminLastHandledItem.equipment_name }}({{ adminLastHandledItem.equipment_code }})
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ adminLastHandledItem.snapshot_person_nickname || '无人归属资产' }}
<span v-if="adminLastHandledItem.snapshot_person_name"> · {{ adminLastHandledItem.snapshot_person_name }}</span>
<span v-if="adminLastHandledItem.snapshot_department_name"> · {{ adminLastHandledItem.snapshot_department_name }}</span>
</p>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">参与人进度</h3>
<span class="badge badge-neutral">共 {{ formatNumber(participantRows.length) }} 名</span>
</div>
<div class="mobile-card-list md:hidden">
<article
v-for="participant in participantRows"
:key="participant.id"
class="mobile-card"
>
<div class="mobile-card__row">
<div>
<div class="flex items-center gap-2">
<p class="text-sm font-semibold 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-tertiary">{{ participant.department_name || '未归属部门' }}</p>
</div>
<span class="badge" :class="getParticipantStatusClass(participant.status)">
{{ getParticipantStatusText(participant.status) }}
</span>
</div>
<div class="mobile-card__meta mt-4">
<div>
<p class="mobile-card__label">资产数</p>
<p class="mobile-card__value">{{ formatNumber(participant.total) }}</p>
</div>
<div>
<p class="mobile-card__label">已确认 / 异常</p>
<p class="mobile-card__value">{{ formatNumber(participant.checked) }} / {{ formatNumber(participant.exception) }}</p>
</div>
<div>
<p class="mobile-card__label">最近动作</p>
<p class="mobile-card__value">{{ formatDateTime(participant.last_action_at || participant.confirmed_at || participant.created_at) }}</p>
</div>
<div>
<p class="mobile-card__label">待盘点</p>
<p class="mobile-card__value">{{ formatNumber(participant.pending) }}</p>
</div>
</div>
</article>
<p v-if="participantRows.length === 0" class="py-8 text-center text-sm text-text-tertiary">
当前任务还没有参与人。
</p>
</div>
<div class="hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>参与人</th>
<th>部门</th>
<th>状态</th>
<th>负责资产</th>
<th>已确认 / 异常</th>
<th>最近动作</th>
</tr>
</thead>
<tbody>
<tr v-for="participant in participantRows" :key="participant.id">
<td>
<div class="flex items-center gap-2">
<p class="text-text-primary">{{ participant.person_nickname }}</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.person_name }}</p>
</td>
<td class="text-sm text-text-primary">{{ participant.department_name || '-' }}</td>
<td>
<span class="badge" :class="getParticipantStatusClass(participant.status)">
{{ getParticipantStatusText(participant.status) }}
</span>
</td>
<td class="text-sm text-text-primary">{{ formatNumber(participant.total) }} 件</td>
<td class="text-sm text-text-secondary">
{{ formatNumber(participant.checked) }} / {{ formatNumber(participant.exception) }}
<span class="block text-xs text-text-tertiary">待盘点 {{ formatNumber(participant.pending) }}</span>
</td>
<td class="text-sm text-text-secondary">
{{ formatDateTime(participant.last_action_at || participant.confirmed_at || participant.created_at) }}
</td>
</tr>
<tr v-if="participantRows.length === 0">
<td colspan="6" class="py-8 text-center text-text-secondary">当前任务还没有参与人。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">盘点资产明细</h3>
<span class="badge badge-neutral">共 {{ formatNumber(filteredItems.length) }} 件</span>
</div>
<div class="filter-bar">
<div class="filter-bar__inputs">
<select v-model="itemFilter" class="select">
<option value="all">全部状态</option>
<option value="pending">待盘点</option>
<option value="checked">已确认</option>
<option value="exception">异常</option>
</select>
<select v-model="participantFilter" class="select">
<option value="all">全部参与人</option>
<option v-for="participant in participantRows" :key="participant.id" :value="String(participant.id)">
{{ participant.person_nickname }} · {{ participant.person_name }}
</option>
</select>
<input
v-model.trim="itemKeyword"
type="text"
class="input"
placeholder="搜索资产编码、名称、使用人"
/>
</div>
</div>
<div class="mt-4 mobile-card-list md:hidden">
<article
v-for="item in filteredItems"
:key="item.id"
class="mobile-card"
>
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
</div>
<span class="badge" :class="getItemStatusClass(item.check_status)">
{{ getItemStatusText(item.check_status) }}
</span>
</div>
<div class="mobile-card__meta mt-4">
<div>
<p class="mobile-card__label">责任人</p>
<p class="mobile-card__value">
{{ item.snapshot_person_nickname || '待分配' }}
<span v-if="isInventoryPublicAssetPerson(item)" class="ml-2 text-status-warning">
· {{ getInventoryPublicAssetLabel() }}
</span>
<span v-if="item.snapshot_person_name" class="block text-xs text-text-secondary">
{{ item.snapshot_person_name }}
</span>
</p>
</div>
<div>
<p class="mobile-card__label">部门</p>
<p class="mobile-card__value">{{ item.snapshot_department_name || '未归属' }}</p>
</div>
<div>
<p class="mobile-card__label">资产类型</p>
<p class="mobile-card__value">{{ item.asset_type_name || item.equipment_type }}</p>
</div>
<div>
<p class="mobile-card__label">盘点时间</p>
<p class="mobile-card__value">{{ item.checked_at ? formatDateTime(item.checked_at) : '-' }}</p>
</div>
</div>
<div v-if="item.exception_type || item.exception_note" class="mt-4 rounded-xl border border-status-warning/30 bg-status-warning/10 px-3 py-3 text-sm text-text-secondary">
<p class="font-medium text-status-warning">{{ getExceptionText(item.exception_type) }}</p>
<p v-if="item.exception_note" class="mt-1 text-text-secondary">{{ item.exception_note }}</p>
</div>
</article>
<p v-if="filteredItems.length === 0" class="py-8 text-center text-sm text-text-tertiary">
当前筛选条件下没有资产。
</p>
</div>
<div class="mt-4 hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>资产</th>
<th>责任人</th>
<th>部门</th>
<th>状态</th>
<th>异常说明</th>
<th>盘点时间</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item.id">
<td>
<p class="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-1 text-xs text-text-secondary">{{ item.asset_type_name || item.equipment_type }}</p>
</td>
<td>
<p class="text-text-primary">
{{ item.snapshot_person_nickname || '待分配' }}
<span v-if="isInventoryPublicAssetPerson(item)" class="ml-2 text-xs text-status-warning">
{{ getInventoryPublicAssetLabel() }}
</span>
</p>
<p class="mt-1 text-xs text-text-secondary">{{ item.snapshot_person_name || '-' }}</p>
</td>
<td class="text-sm text-text-primary">{{ item.snapshot_department_name || '-' }}</td>
<td>
<span class="badge" :class="getItemStatusClass(item.check_status)">
{{ getItemStatusText(item.check_status) }}
</span>
</td>
<td class="text-sm text-text-secondary">
{{ getExceptionText(item.exception_type) }}
<span v-if="item.exception_note" class="mt-1 block text-xs text-text-tertiary">{{ item.exception_note }}</span>
</td>
<td class="text-sm text-text-secondary">
{{ item.checked_at ? formatDateTime(item.checked_at) : '-' }}
</td>
</tr>
<tr v-if="filteredItems.length === 0">
<td colspan="6" class="py-8 text-center text-text-secondary">当前筛选条件下没有资产。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section v-if="unassignedItems.length > 0" class="card">
<div class="list-header">
<h3 class="section-title">无人归属待分配资产</h3>
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待分配</span>
</div>
<div class="mb-4 max-w-xl field-stack">
<label class="field-label">搜索责任人</label>
<input
v-model.trim="personOptionKeyword"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门"
@input="schedulePersonOptionSearch"
/>
<p class="field-hint">
{{ personOptionLoading ? '正在搜索...' : personOptionError || `已加载 ${personOptions.length} / ${personOptionTotal} 人` }}
</p>
</div>
<div class="space-y-3">
<div
v-for="item in unassignedItems"
:key="item.id"
class="panel"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="min-w-0 flex-1">
<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_department_name || '未归属部门' }} · {{ item.asset_type_name || item.equipment_type }}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-[minmax(0,280px)_auto] sm:items-end">
<select
v-model="assignPersonIdMap[item.id]"
class="select"
:disabled="task.status === 'closed'"
>
<option value="">请选择责任人</option>
<option v-for="person in personOptions" :key="person.id" :value="String(person.id)">
{{ person.nickname }} · {{ person.real_name }}{{ person.department ? `(${person.department})` : '' }}
</option>
</select>
<button
type="button"
class="btn-primary text-sm"
:disabled="task.status === 'closed' || assigningItemId === item.id || !assignPersonIdMap[item.id]"
@click="assignItem(item.id)"
>
{{ assigningItemId === item.id ? '分配中...' : '确认分配' }}
</button>
</div>
</div>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">最近动作</h3>
<span class="badge badge-neutral">最近 {{ formatNumber(task.recent_actions.length) }} 条</span>
</div>
<div v-if="task.recent_actions.length === 0" class="empty-state">暂时还没有动作记录。</div>
<div v-else class="space-y-3">
<div
v-for="action in task.recent_actions"
:key="action.id"
class="panel"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-sm font-medium text-text-primary">{{ resolveActionSummary(action) }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ resolveActionDetail(action) }}</p>
</div>
<p class="text-xs text-text-tertiary">{{ formatDateTime(action.created_at) }}</p>
</div>
</div>
</div>
</section>
</template>
<section v-else class="card">
<div class="empty-state">没有找到这个盘点任务。</div>
</section>
<CameraScannerModal
v-model="showAdminScanner"
title="管理员代扫"
description="扫码后将直接替责任人完成本次盘点。"
manual-placeholder="请输入资产编码"
@detected="handleAdminScannerDetected"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getPersonOptions, type PersonOption } from '@/api/person'
import {
assignInventoryTaskUnassignedItem,
closeInventoryTask,
deleteInventoryTask,
getInventoryTaskDetail,
getInventoryTaskUnassignedItems,
submitAdminInventoryScan
} from '@/api/inventory'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
import type {
InventoryTaskAction,
InventoryTaskDetail,
InventoryTaskItem,
InventoryTaskParticipant
} from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
interface ParticipantProgressRow extends InventoryTaskParticipant {
total: number
pending: number
checked: number
exception: number
}
const route = useRoute()
const router = useRouter()
const task = ref<InventoryTaskDetail | null>(null)
const unassignedItems = ref<InventoryTaskItem[]>([])
const personOptions = ref<PersonOption[]>([])
const personOptionKeyword = ref('')
const personOptionTotal = ref(0)
const personOptionLoading = ref(false)
const personOptionError = ref('')
const loading = ref(false)
const closing = ref(false)
const deleting = ref(false)
const assigningItemId = ref<number | null>(null)
const adminScanning = ref(false)
const itemFilter = ref<ItemFilter>('all')
const participantFilter = ref('all')
const itemKeyword = ref('')
const adminScanCode = ref('')
let personOptionSearchTimer: number | null = null
let latestPersonOptionRequestId = 0
const showAdminScanner = ref(false)
const adminLastHandledItem = ref<InventoryTaskItem | null>(null)
const assignPersonIdMap = reactive<Record<number, string>>({})
const taskId = computed(() => Number(route.params.id))
const publicLink = computed(() =>
task.value ? `${window.location.origin}/inventory/task/${task.value.public_token}` : ''
)
const scopeLabel = computed(() => (task.value ? buildInventoryScopeLabel(task.value) : '-'))
const taskProgressText = computed(() => {
if (!task.value || task.value.items_count === 0) return '暂无资产'
return `${formatPercent((task.value.completed_count / task.value.items_count) * 100)}`
})
const pendingParticipantCount = computed(
() => participantRows.value.filter((participant) => participant.status === 'pending').length
)
const participantRows = computed<ParticipantProgressRow[]>(() => {
const statsMap = new Map<number, { total: number; pending: number; checked: number; exception: number }>()
for (const item of task.value?.items || []) {
if (!item.participant_id) continue
const current = statsMap.get(item.participant_id) || {
total: 0,
pending: 0,
checked: 0,
exception: 0
}
current.total += 1
if (item.check_status === 'checked') {
current.checked += 1
} else if (item.check_status === 'exception') {
current.exception += 1
} else {
current.pending += 1
}
statsMap.set(item.participant_id, current)
}
return (task.value?.participants || []).map((participant) => ({
...participant,
...(statsMap.get(participant.id) || {
total: 0,
pending: 0,
checked: 0,
exception: 0
})
}))
})
const filteredItems = computed(() => {
const keyword = itemKeyword.value.trim().toLowerCase()
return (task.value?.items || []).filter((item) => {
const matchesFilter = itemFilter.value === 'all' || item.check_status === itemFilter.value
const matchesParticipant =
participantFilter.value === 'all' || String(item.participant_id || '') === participantFilter.value
const matchesKeyword =
!keyword ||
item.equipment_code.toLowerCase().includes(keyword) ||
item.equipment_name.toLowerCase().includes(keyword) ||
(item.snapshot_person_name || '').toLowerCase().includes(keyword) ||
(item.snapshot_person_nickname || '').toLowerCase().includes(keyword)
return matchesFilter && matchesParticipant && matchesKeyword
})
})
const getTaskStatusText = (status: string) => (status === 'closed' ? '已关闭' : '进行中')
const getParticipantStatusText = (status: string) => {
const mapping: Record<string, string> = {
pending: '待确认',
in_progress: '盘点中',
completed: '已完成'
}
return mapping[status] || status
}
const getParticipantStatusClass = (status: string) => {
const mapping: Record<string, string> = {
pending: 'badge-warning',
in_progress: 'badge-neutral',
completed: 'badge-success'
}
return mapping[status] || 'badge-neutral'
}
const getItemStatusText = (status: string) => {
const mapping: Record<string, string> = {
pending: '待盘点',
checked: '已确认',
exception: '异常'
}
return mapping[status] || status
}
const getItemStatusClass = (status: string) => {
const mapping: Record<string, string> = {
pending: 'badge-warning',
checked: 'badge-success',
exception: 'badge-error'
}
return mapping[status] || 'badge-neutral'
}
const getExceptionText = (exceptionType?: string | null) => {
const mapping: Record<string, string> = {
not_found: '资产未找到',
not_with_me: '资产不在本人处'
}
return exceptionType ? mapping[exceptionType] || exceptionType : '无'
}
const parseActionPayload = (payload?: string | null) => {
if (!payload) return null
try {
return JSON.parse(payload) as Record<string, any>
} catch {
return null
}
}
const resolveActionSummary = (action: InventoryTaskAction) => {
const mapping: Record<string, string> = {
task_created: '创建了盘点任务',
task_closed: '关闭了盘点任务',
participant_confirmed: '盘点人完成身份确认',
item_checked: '确认了一件资产',
item_checked_by_admin: '管理员代扫了一件资产',
item_exception_reported: '上报了一件异常资产',
item_reassigned: '重新分配了一件无人归属资产'
}
return mapping[action.action_type] || action.action_type
}
const resolveActionDetail = (action: InventoryTaskAction) => {
const payload = parseActionPayload(action.action_payload)
switch (action.action_type) {
case 'task_created':
return payload
? `参与人 ${formatNumber(payload.participants_count || 0)} 名,资产 ${formatNumber(payload.items_count || 0)} 件,待分配 ${formatNumber(payload.unassigned_count || 0)} 件`
: '任务已初始化完成。'
case 'item_checked':
return payload?.equipment_code ? `资产编码:${payload.equipment_code}` : '资产已被确认。'
case 'item_checked_by_admin':
return payload?.equipment_code
? `管理员代扫资产编码:${payload.equipment_code}${payload?.is_unassigned ? '(原为无人归属)' : ''}`
: '管理员已代为完成扫码盘点。'
case 'item_exception_reported':
return payload
? `${getExceptionText(payload.exception_type)}${payload.exception_note ? `,备注:${payload.exception_note}` : ''}`
: '已记录异常。'
case 'item_reassigned':
return payload?.target_person_name ? `分配给:${payload.target_person_name}` : '已完成重新分配。'
default:
return '详情已记录到盘点日志。'
}
}
const loadPersonOptions = async (keyword = '') => {
const requestId = ++latestPersonOptionRequestId
personOptionLoading.value = true
personOptionError.value = ''
try {
const response = await getPersonOptions({
page: 1,
pageSize: 50,
keyword: keyword || undefined,
status: 'active'
})
if (requestId !== latestPersonOptionRequestId) return
personOptions.value = (response.data?.list || [])
.slice()
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
personOptionTotal.value = response.data?.total || 0
} catch (error: any) {
console.error('获取人员列表失败', error)
if (requestId === latestPersonOptionRequestId) {
personOptions.value = []
personOptionTotal.value = 0
personOptionError.value = error?.message || '人员搜索失败'
}
} finally {
if (requestId === latestPersonOptionRequestId) {
personOptionLoading.value = false
}
}
}
const schedulePersonOptionSearch = () => {
if (personOptionSearchTimer) {
window.clearTimeout(personOptionSearchTimer)
}
personOptionSearchTimer = window.setTimeout(() => {
loadPersonOptions(personOptionKeyword.value.trim())
}, 300)
}
const loadTask = async () => {
if (!Number.isInteger(taskId.value) || taskId.value <= 0) {
router.push('/inventory')
return
}
loading.value = true
try {
const detailResponse = await getInventoryTaskDetail(taskId.value)
task.value = detailResponse.data
if (detailResponse.data.unassigned_count > 0) {
const unassignedResponse = await getInventoryTaskUnassignedItems(taskId.value)
unassignedItems.value = unassignedResponse.data
if (personOptions.value.length === 0) {
loadPersonOptions()
}
} else {
unassignedItems.value = []
}
} catch (error: any) {
alert(error?.message || '获取盘点任务详情失败')
router.push('/inventory')
} finally {
loading.value = false
}
}
const copyPublicLink = async () => {
if (!publicLink.value) return
try {
await navigator.clipboard.writeText(publicLink.value)
alert('手机盘点链接已复制')
} catch {
window.prompt('请复制这个手机盘点链接', publicLink.value)
}
}
const openPublicLink = () => {
if (!publicLink.value) return
window.open(publicLink.value, '_blank', 'noopener,noreferrer')
}
const handleCloseTask = async () => {
if (!task.value) return
const shouldClose = window.confirm('结束后手机端将不能继续提交盘点结果,确定结束吗?')
if (!shouldClose) return
closing.value = true
try {
await closeInventoryTask(task.value.id)
alert('盘点任务已结束')
loadTask()
} catch (error: any) {
alert(error?.message || '结束盘点任务失败')
} finally {
closing.value = false
}
}
const runAdminScan = async (equipmentCode: string) => {
const normalizedCode = equipmentCode.trim()
if (!normalizedCode || !task.value) return
adminScanning.value = true
try {
const response = await submitAdminInventoryScan(task.value.id, normalizedCode)
adminLastHandledItem.value = response.data
adminScanCode.value = ''
showAdminScanner.value = false
await loadTask()
} catch (error: any) {
alert(error?.message || '管理员扫码盘点失败')
} finally {
adminScanning.value = false
}
}
const handleAdminManualScan = async () => {
await runAdminScan(adminScanCode.value)
}
const handleAdminScannerDetected = async (value: string) => {
adminScanCode.value = value.trim()
await runAdminScan(adminScanCode.value)
}
const handleDeleteTask = async () => {
if (!task.value) return
const shouldDelete = window.confirm('删除后该任务、参与人、资产明细和动作记录都会一起移除,确定删除吗?')
if (!shouldDelete) return
deleting.value = true
try {
await deleteInventoryTask(task.value.id)
alert('盘点任务已删除')
router.push('/inventory')
} catch (error: any) {
alert(error?.message || '删除盘点任务失败')
} finally {
deleting.value = false
}
}
const assignItem = async (itemId: number) => {
if (!task.value) return
const personId = Number(assignPersonIdMap[itemId])
if (!Number.isInteger(personId) || personId <= 0) {
alert('请先选择责任人')
return
}
assigningItemId.value = itemId
try {
await assignInventoryTaskUnassignedItem(task.value.id, itemId, personId)
alert('资产责任人已更新')
loadTask()
} catch (error: any) {
alert(error?.message || '分配责任人失败')
} finally {
assigningItemId.value = null
}
}
watch(
() => route.params.id,
() => {
loadTask()
}
)
onMounted(() => {
loadTask()
})
onUnmounted(() => {
if (personOptionSearchTimer) {
window.clearTimeout(personOptionSearchTimer)
}
})
</script>