932 lines
36 KiB
Vue
932 lines
36 KiB
Vue
<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>
|