458 lines
17 KiB
Vue
458 lines
17 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<!-- 页面头部 -->
|
||
<section class="page-header">
|
||
<div>
|
||
<p class="page-kicker">Disposal</p>
|
||
<h2 class="page-title">报废流程</h2>
|
||
</div>
|
||
<div class="page-meta-grid">
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">待审批</p>
|
||
<p class="page-meta__value">{{ pageSummary.pending }}</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">已审批</p>
|
||
<p class="page-meta__value">{{ pageSummary.approved }}</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">已完成</p>
|
||
<p class="page-meta__value">{{ pageSummary.completed }}</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">已驳回</p>
|
||
<p class="page-meta__value">{{ pageSummary.rejected }}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card !p-3">
|
||
<div class="overflow-x-auto">
|
||
<div class="segmented-toggle min-w-max" style="padding: 2px;">
|
||
<button
|
||
v-for="scope in availableScopes"
|
||
:key="scope.value"
|
||
type="button"
|
||
class="segmented-toggle__item"
|
||
style="min-height: 32px; padding: 0 12px; font-size: 12px;"
|
||
:class="{ 'is-active': activeScope === scope.value }"
|
||
@click="changeScope(scope.value)"
|
||
>
|
||
{{ scope.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 筛选区域 -->
|
||
<section class="card">
|
||
<div class="filter-bar">
|
||
<div class="filter-bar__inputs">
|
||
<div class="field-stack">
|
||
<label class="field-label">报废状态</label>
|
||
<select v-model="filters.status" class="select" @change="handleQuery">
|
||
<option value="">全部状态</option>
|
||
<option value="pending">待审批</option>
|
||
<option value="approved">已审批</option>
|
||
<option value="rejected">已驳回</option>
|
||
<option value="completed">已完成</option>
|
||
</select>
|
||
</div>
|
||
<div class="field-stack">
|
||
<label class="field-label">搜索记录</label>
|
||
<input
|
||
v-model.trim="filters.keyword"
|
||
type="text"
|
||
class="input"
|
||
placeholder="输入资产编码、名称、报废原因或处置方式"
|
||
@input="handleSearch"
|
||
@keyup.enter="handleQuery"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="filter-bar__actions">
|
||
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="resetFilters">重置</button>
|
||
<button class="btn-primary text-xs sm:text-sm" :disabled="loading" @click="handleQuery">
|
||
{{ loading ? '查询中...' : '查询' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 数据列表 -->
|
||
<section class="card">
|
||
<div class="list-header">
|
||
<h3 class="section-title">报废记录</h3>
|
||
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="loadList">刷新</button>
|
||
</div>
|
||
|
||
<!-- 移动端卡片 -->
|
||
<div class="mobile-card-list md:hidden">
|
||
<article v-for="item in list" :key="`mobile-${item.id}`" class="mobile-card">
|
||
<div class="mobile-card__row">
|
||
<div>
|
||
<p class="text-sm font-semibold text-text-primary">{{ item.equipment?.equipment_name || '-' }}</p>
|
||
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
|
||
</div>
|
||
<span class="badge" :class="getStatusBadgeClass(item.status)">
|
||
{{ getStatusText(item.status) }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="mobile-card__meta">
|
||
<div>
|
||
<p class="mobile-card__label">申请人</p>
|
||
<p class="mobile-card__value">{{ item.applier?.name || '-' }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="mobile-card__label">申请时间</p>
|
||
<p class="mobile-card__value">{{ formatDate(item.applied_at) }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="mobile-card__label">处置方式</p>
|
||
<p class="mobile-card__value">{{ item.disposal_method || '未填写' }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="mobile-card__label">残值估价</p>
|
||
<p class="mobile-card__value">{{ formatCurrency(item.estimated_value) }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 rounded-lg border border-border bg-bg-elevated px-4 py-3">
|
||
<p class="mobile-card__label">报废原因</p>
|
||
<p class="mobile-card__value">{{ item.disposal_reason || '未填写' }}</p>
|
||
</div>
|
||
|
||
<div v-if="item.notes" class="mt-3 rounded-lg border border-border bg-bg-elevated px-4 py-3">
|
||
<p class="mobile-card__label">备注</p>
|
||
<p class="mobile-card__value">{{ item.notes }}</p>
|
||
</div>
|
||
|
||
<div class="mt-4 flex flex-wrap gap-2">
|
||
<button class="btn-secondary text-xs sm:text-sm" @click="openDetail(item)">查看详情</button>
|
||
<button
|
||
v-if="item.status === 'pending' && canApprove"
|
||
class="btn-primary text-xs sm:text-sm"
|
||
@click="handleApprove(item, true)"
|
||
>
|
||
通过
|
||
</button>
|
||
<button
|
||
v-if="item.status === 'pending' && canApprove"
|
||
class="btn-secondary text-xs sm:text-sm !text-status-error"
|
||
@click="handleApprove(item, false)"
|
||
>
|
||
驳回
|
||
</button>
|
||
<button
|
||
v-if="item.status === 'approved' && canComplete"
|
||
class="btn-secondary text-xs sm:text-sm"
|
||
@click="handleComplete(item)"
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
</article>
|
||
|
||
<p v-if="!loading && list.length === 0" class="text-center text-sm text-text-tertiary py-8">
|
||
当前筛选条件下暂无报废记录。
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 桌面端表格 -->
|
||
<div class="hidden md:block">
|
||
<div class="table-container">
|
||
<table class="data-table min-w-[1080px]">
|
||
<thead>
|
||
<tr>
|
||
<th>资产编码</th>
|
||
<th>资产名称</th>
|
||
<th>报废原因</th>
|
||
<th>处置方式</th>
|
||
<th class="text-right">残值估价</th>
|
||
<th>状态</th>
|
||
<th>申请人</th>
|
||
<th>申请时间</th>
|
||
<th class="text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in list" :key="item.id">
|
||
<td class="font-mono text-sm text-text-secondary">{{ item.equipment_code }}</td>
|
||
<td>
|
||
<p class="font-medium text-text-primary">{{ item.equipment?.equipment_name || '-' }}</p>
|
||
<p class="text-xs text-text-tertiary">{{ item.equipment?.equipment_type || '-' }}</p>
|
||
</td>
|
||
<td class="max-w-[260px]">
|
||
<p class="truncate text-sm text-text-secondary">{{ item.disposal_reason }}</p>
|
||
</td>
|
||
<td class="text-sm text-text-secondary">{{ item.disposal_method || '-' }}</td>
|
||
<td class="text-right text-sm text-text-primary">{{ formatCurrency(item.estimated_value) }}</td>
|
||
<td>
|
||
<span class="badge" :class="getStatusBadgeClass(item.status)">
|
||
{{ getStatusText(item.status) }}
|
||
</span>
|
||
</td>
|
||
<td class="text-sm text-text-secondary">{{ item.applier?.name || '-' }}</td>
|
||
<td class="text-sm text-text-secondary">{{ formatDate(item.applied_at) }}</td>
|
||
<td class="text-right">
|
||
<div class="table-action-group justify-end">
|
||
<button class="table-action" @click="openDetail(item)">详情</button>
|
||
<button
|
||
v-if="item.status === 'pending' && canApprove"
|
||
class="table-action table-action--primary"
|
||
@click="handleApprove(item, true)"
|
||
>
|
||
通过
|
||
</button>
|
||
<button
|
||
v-if="item.status === 'pending' && canApprove"
|
||
class="table-action table-action--danger"
|
||
@click="handleApprove(item, false)"
|
||
>
|
||
驳回
|
||
</button>
|
||
<button
|
||
v-if="item.status === 'approved' && canComplete"
|
||
class="table-action table-action--warning"
|
||
@click="handleComplete(item)"
|
||
>
|
||
完成
|
||
</button>
|
||
<span v-if="item.status === 'completed'" class="text-sm text-text-tertiary">已完成</span>
|
||
<span v-if="item.status === 'rejected'" class="text-sm text-text-tertiary">已驳回</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!loading && list.length === 0">
|
||
<td colspan="9" class="py-8 text-center text-text-tertiary">当前筛选条件下暂无报废记录。</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div class="pagination-bar">
|
||
<p class="text-sm text-text-secondary">第 {{ page }} / {{ totalPages }} 页</p>
|
||
<div class="flex items-center gap-2">
|
||
<button class="btn-secondary text-sm" :disabled="page <= 1 || loading" @click="handlePrevPage">上一页</button>
|
||
<button class="btn-secondary text-sm" :disabled="page >= totalPages || loading" @click="handleNextPage">下一页</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 完成处置弹窗 -->
|
||
<div v-if="showCompleteModal" class="sheet-backdrop">
|
||
<div class="sheet-panel max-w-2xl">
|
||
<h3 class="text-xl font-semibold text-text-primary mb-4">完成处置</h3>
|
||
<p class="text-sm text-text-secondary mb-4">
|
||
{{ currentItem?.equipment?.equipment_name || currentItem?.equipment_code || '当前资产' }} 已通过审批,请补充最终处置结果。
|
||
</p>
|
||
|
||
<div class="field-grid">
|
||
<div class="field-stack">
|
||
<label class="field-label">实际处置价值</label>
|
||
<input v-model.number="completeForm.actual_value" type="number" min="0" step="0.01" class="input" />
|
||
</div>
|
||
<div class="panel">
|
||
<p class="text-xs text-text-tertiary">残值估价</p>
|
||
<p class="mt-1 text-base font-medium text-text-primary">{{ formatCurrency(currentItem?.estimated_value || 0) }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 field-stack">
|
||
<label class="field-label">完成说明 *</label>
|
||
<textarea
|
||
v-model.trim="completeForm.completion_notes"
|
||
class="input"
|
||
rows="4"
|
||
placeholder="例如:已转交回收商,回收凭证已登记"
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-3 mt-6">
|
||
<button class="btn-secondary" @click="showCompleteModal = false">取消</button>
|
||
<button class="btn-primary" :disabled="submittingComplete || !completeForm.completion_notes" @click="submitComplete">
|
||
{{ submittingComplete ? '提交中...' : '确认完成' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { getDisposalList, approveDisposal, completeDisposal, type DisposalRecord } from '@/api/disposal'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { PERMISSIONS } from '@/auth/permissions'
|
||
|
||
type DisposalScope = 'pending' | 'mine' | 'processed' | 'all'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
const canApprove = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPROVE))
|
||
const canComplete = computed(() => authStore.can(PERMISSIONS.DISPOSALS_COMPLETE))
|
||
const canApply = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
|
||
const availableScopes = computed<Array<{ value: DisposalScope; label: string }>>(() => {
|
||
const scopes: Array<{ value: DisposalScope; label: string }> = []
|
||
if (canApprove.value) scopes.push({ value: 'pending', label: '待我审批' })
|
||
if (canApply.value) scopes.push({ value: 'mine', label: '我发起的' })
|
||
if (canApprove.value || canComplete.value) scopes.push({ value: 'processed', label: '已处理' })
|
||
if (authStore.can(PERMISSIONS.SYSTEM_SETTINGS)) scopes.push({ value: 'all', label: '全部' })
|
||
return scopes
|
||
})
|
||
|
||
const list = ref<DisposalRecord[]>([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = ref(10)
|
||
const loading = ref(false)
|
||
const filters = ref({ status: '', keyword: '' })
|
||
const showCompleteModal = ref(false)
|
||
const currentItem = ref<DisposalRecord | null>(null)
|
||
const completeForm = ref({ actual_value: 0, completion_notes: '' })
|
||
const submittingComplete = ref(false)
|
||
const activeScope = ref<DisposalScope>('pending')
|
||
|
||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||
const pageSummary = computed(() => ({
|
||
pending: list.value.filter((item) => item.status === 'pending').length,
|
||
approved: list.value.filter((item) => item.status === 'approved').length,
|
||
completed: list.value.filter((item) => item.status === 'completed').length,
|
||
rejected: list.value.filter((item) => item.status === 'rejected').length
|
||
}))
|
||
|
||
const loadList = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await getDisposalList({ page: page.value, pageSize: pageSize.value, scope: activeScope.value, ...filters.value })
|
||
list.value = res.data?.list || []
|
||
total.value = res.data?.total || 0
|
||
} catch (e) {
|
||
console.error(e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const handleSearch = () => {
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(() => {
|
||
page.value = 1
|
||
loadList()
|
||
}, 300)
|
||
}
|
||
|
||
const handleQuery = () => {
|
||
page.value = 1
|
||
loadList()
|
||
}
|
||
|
||
const resetFilters = () => {
|
||
filters.value.status = ''
|
||
filters.value.keyword = ''
|
||
handleQuery()
|
||
}
|
||
|
||
const changeScope = (scope: DisposalScope) => {
|
||
activeScope.value = scope
|
||
page.value = 1
|
||
router.replace({ query: { ...route.query, scope } })
|
||
loadList()
|
||
}
|
||
|
||
const openDetail = (item: DisposalRecord) => router.push(`/workflows/disposals/${item.id}`)
|
||
|
||
const handleApprove = async (item: DisposalRecord, approved: boolean) => {
|
||
const msg = approved ? '确定通过此报废申请吗?' : '确定驳回此报废申请吗?'
|
||
if (!confirm(msg)) return
|
||
try {
|
||
const rejectReason = approved ? undefined : window.prompt('请填写驳回原因。', '')?.trim() || undefined
|
||
if (!approved && !rejectReason) {
|
||
alert('驳回原因不能为空')
|
||
return
|
||
}
|
||
await approveDisposal(item.id, { approved, reject_reason: rejectReason })
|
||
await loadList()
|
||
} catch (error: any) {
|
||
alert(error?.message || '申请状态已变化,请刷新后重试')
|
||
await loadList()
|
||
}
|
||
}
|
||
|
||
const handleComplete = (item: DisposalRecord) => {
|
||
currentItem.value = item
|
||
completeForm.value = { actual_value: Number(item.estimated_value || 0), completion_notes: item.completion_notes || '' }
|
||
showCompleteModal.value = true
|
||
}
|
||
|
||
const submitComplete = async () => {
|
||
if (!currentItem.value || !completeForm.value.completion_notes.trim()) return
|
||
try {
|
||
submittingComplete.value = true
|
||
await completeDisposal(currentItem.value.id, completeForm.value)
|
||
showCompleteModal.value = false
|
||
await loadList()
|
||
} catch (error: any) {
|
||
alert(error?.message || '申请状态已变化,请刷新后重试')
|
||
await loadList()
|
||
} finally {
|
||
submittingComplete.value = false
|
||
}
|
||
}
|
||
|
||
const handlePrevPage = async () => {
|
||
if (page.value <= 1) return
|
||
page.value -= 1
|
||
await loadList()
|
||
}
|
||
|
||
const handleNextPage = async () => {
|
||
if (page.value >= totalPages.value) return
|
||
page.value += 1
|
||
await loadList()
|
||
}
|
||
|
||
const getStatusBadgeClass = (status: string) => {
|
||
const map: Record<string, string> = {
|
||
pending: 'badge-warning',
|
||
approved: 'badge-neutral',
|
||
rejected: 'badge-error',
|
||
completed: 'badge-success'
|
||
}
|
||
return map[status] || ''
|
||
}
|
||
|
||
const getStatusText = (status: string) => {
|
||
const map: Record<string, string> = {
|
||
pending: '待审批',
|
||
approved: '已审批',
|
||
rejected: '已驳回',
|
||
completed: '已完成'
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
const formatDate = (date: string) => {
|
||
if (!date) return '-'
|
||
return new Date(date).toLocaleDateString('zh-CN')
|
||
}
|
||
|
||
const formatCurrency = (value: number) => `¥${Number(value || 0).toFixed(2)}`
|
||
|
||
onMounted(() => {
|
||
const requestedScope = route.query.scope as DisposalScope | undefined
|
||
const allowedScopes = availableScopes.value.map((scope) => scope.value)
|
||
activeScope.value = requestedScope && allowedScopes.includes(requestedScope)
|
||
? requestedScope
|
||
: allowedScopes[0] || 'mine'
|
||
loadList()
|
||
})
|
||
</script>
|