<script setup lang="ts">
|
import { computed, ref, watch } from 'vue'
|
import NameAvatar from '@/components/NameAvatar.vue'
|
import { fetchApprovalDetail, submitGeneralApproval, submitHandlerApproval } from '@/api/appointment'
|
import { fetchPassAccessAreaTree, searchLocalUnits, searchVisitedUsersByExactName } from '@/api/lookup'
|
import type { ApprovalFlowNodeItem, MiniApprovalDetailItem } from '@/types/appointment'
|
import type { MiniLocalUnitItem, MiniPassAccessAreaTreeItem } from '@/types/lookup'
|
import { normalizeVisitReasonText } from '@/utils/visitReason'
|
|
type ReviewNodeType = '经办人审批' | '其他属地单位领导审批' | '其他'
|
type ReviewOverallStatus = 'underReview' | 'passReview' | 'refueReview' | 'cancelReview'
|
|
type CcUser = {
|
id: number
|
name: string
|
department: string
|
}
|
|
type ReviewVisitor = {
|
id: number
|
name: string
|
gender: 'male' | 'female'
|
phone: string
|
idCard: string
|
company: string
|
plate: string
|
}
|
|
type TimelineUser = {
|
name: string
|
avatar?: string
|
cancelled?: boolean
|
label?: string
|
labelType?: 'gray' | 'green' | 'red' | 'blue'
|
time?: string
|
comment?: string
|
}
|
|
type TimelineGroup = {
|
department?: string
|
users: TimelineUser[]
|
}
|
|
type TimelineItem = {
|
title: string
|
time?: string
|
users?: TimelineUser[]
|
groups?: TimelineGroup[]
|
signType?: 'or' | 'and'
|
status: 'completed' | 'rejected' | 'current' | 'pending'
|
}
|
|
definePage({
|
name: 'reviewDetail',
|
layout: 'default',
|
style: {
|
navigationStyle: 'custom',
|
},
|
})
|
|
const router = useRouter()
|
const sysInfo = uni.getSystemInfoSync()
|
const statusBarHeight = sysInfo.statusBarHeight || 0
|
const navBarHeight = 44
|
|
const detail = ref<MiniApprovalDetailItem | null>(null)
|
const canApprove = ref(false)
|
const currentReviewNode = ref<ReviewNodeType>('经办人审批')
|
const overallStatus = ref<ReviewOverallStatus>('underReview')
|
const reviewStatusText = ref('待审批')
|
const visitedUserDisplay = ref('王明(管理部)')
|
const accessAreaDisplay = ref('-')
|
const otherLocalUnitDisplay = ref('不涉及')
|
const visitTypeDisplay = ref('访客+车辆预约')
|
const visitReasonDisplay = ref('业务访问')
|
const visitReasonOtherDisplay = ref('-')
|
const visitTimeDisplay = ref('2023-10-13 00:00 至 2023-10-13 23:59')
|
|
const approveForm = ref({
|
opinion: '',
|
region: '',
|
involveOther: 'yes',
|
otherUnit: '',
|
ccName: '',
|
ccList: [
|
{ id: 1, name: '张三', department: '技术部' },
|
{ id: 2, name: '李四', department: '产品部' },
|
{ id: 3, name: '赵五', department: '设计部' },
|
] as CcUser[],
|
})
|
|
const showCcDropdown = ref(false)
|
const filteredCcUsers = ref<CcUser[]>([])
|
|
const showRegionPicker = ref(false)
|
const showUnitPicker = ref(false)
|
|
const areaTree = ref<MiniPassAccessAreaTreeItem[]>([])
|
const selectedAreaIds = ref<number[]>([])
|
const selectedAreaParentId = ref<number | null>(null)
|
const selectedAreaChildId = ref<number | null>(null)
|
const activeAreaParentId = ref<number | null>(null)
|
|
const unitInput = ref('')
|
const unitList = ref<string[]>([])
|
const showUnitDropdown = ref(false)
|
const filteredUnits = ref<string[]>([])
|
const filteredUnitItems = ref<MiniLocalUnitItem[]>([])
|
const selectedLocalUnits = ref<MiniLocalUnitItem[]>([])
|
const unitPopupOffsetY = ref(0)
|
const unitPickerBaseHeight = uni.upx2px(940)
|
const unitPickerMinHeight = uni.upx2px(620)
|
const unitPickerPanelStyle = computed(() => ({
|
height: `${Math.max(unitPickerMinHeight, unitPickerBaseHeight - unitPopupOffsetY.value)}px`,
|
transform: `translateY(-${unitPopupOffsetY.value}px)`,
|
}))
|
const handleUnitKeyboardHeightChange = (res: any) => {
|
const height = Number(res?.height || 0)
|
const maxOffset = Math.max(0, unitPickerBaseHeight - unitPickerMinHeight)
|
unitPopupOffsetY.value = height > 0 ? Math.min(height, maxOffset) : 0
|
}
|
|
const appointmentIcons = {
|
male: '/static/appointment/man.png',
|
female: '/static/appointment/woman.png',
|
company: '/static/appointment/company.png',
|
plate: '/static/appointment/plate.png',
|
idCard: '/static/appointment/idCard.png',
|
}
|
|
const visitors = ref<ReviewVisitor[]>([
|
{
|
id: 1,
|
name: '张三',
|
gender: 'male',
|
phone: '1475836901',
|
idCard: '5120**********258',
|
company: '成都xxxxxx有限公司',
|
plate: '苏U123456',
|
},
|
{
|
id: 2,
|
name: '张三',
|
gender: 'female',
|
phone: '1475836901',
|
idCard: '5120**********258',
|
company: '成都xxxxxx有限公司',
|
plate: '苏U123456',
|
},
|
])
|
|
const timeline = ref<TimelineItem[]>([
|
{
|
title: '发起预约',
|
time: '26-03-05 9:00',
|
users: [
|
{ name: '李天泽', avatar: '', label: '发起申请', labelType: 'gray' },
|
],
|
status: 'completed',
|
},
|
{
|
title: '经办人审批',
|
time: '26-03-05 9:00',
|
users: [
|
{ name: '李怡悦', avatar: '', label: '审批通过', labelType: 'green' },
|
],
|
status: 'completed',
|
},
|
{
|
title: '部门负责人审批',
|
signType: 'or',
|
groups: [
|
{
|
users: [
|
{ name: '王乐康', avatar: '' },
|
{ name: '杨静云', avatar: '', time: '26-03-05 9:00', label: '审批通过', labelType: 'green' },
|
],
|
},
|
],
|
status: 'completed',
|
},
|
{
|
title: '其他属地单位领导审批',
|
signType: 'and',
|
groups: [
|
{
|
department: 'xx部',
|
users: [
|
{ name: '王曼曼', avatar: '' },
|
{ name: '杨静', avatar: '', label: '审批通过', labelType: 'green', time: '26-03-05 9:00' },
|
],
|
},
|
{
|
department: 'xx部',
|
users: [
|
{ name: '赵雨', avatar: '', label: '审批通过', labelType: 'green', time: '26-03-05 9:00' },
|
],
|
},
|
],
|
status: 'completed',
|
},
|
{
|
title: '访客主管部门',
|
time: '26-03-05 9:00',
|
users: [
|
{ name: '顾伦', avatar: '', label: '审批通过', labelType: 'green' },
|
],
|
status: 'completed',
|
},
|
{
|
title: '访客主管部门领导审批',
|
time: '26-03-05 9:00',
|
users: [
|
{ name: '胡允', avatar: '', label: '审批驳回', labelType: 'red' },
|
],
|
status: 'rejected',
|
},
|
])
|
|
const showRejectPopup = ref(false)
|
const rejectReason = ref('')
|
const rejectInputFocused = ref(false)
|
const loading = ref(false)
|
|
const query = ref<{
|
taskId?: number
|
taskUuid?: string
|
orderId?: number
|
orderUuid?: string
|
}>({})
|
const showOtherLocalUnitInVisitInfo = computed(() => (detail.value?.localUnits?.length || 0) > 0)
|
|
function handleBack() {
|
router.back()
|
}
|
|
function resolveReviewNode(detailValue: MiniApprovalDetailItem): ReviewNodeType {
|
if (detailValue.pageMode === 'HANDLER_PENDING' || detailValue.nodeCode === 'LEVEL1_HANDLER') {
|
return '经办人审批'
|
}
|
if (detailValue.nodeCode === 'LEVEL3_LOCAL_DEPT_LEADER') {
|
return '其他属地单位领导审批'
|
}
|
return '其他'
|
}
|
|
function resolveOverallStatus(statusText?: string | null): ReviewOverallStatus {
|
if (!statusText) {
|
return 'underReview'
|
}
|
if (statusText.includes('驳回') || statusText.includes('拒')) {
|
return 'refueReview'
|
}
|
if (statusText.includes('通过') || statusText.includes('同意')) {
|
return 'passReview'
|
}
|
if (statusText.includes('取消') || statusText.includes('撤销')) {
|
return 'cancelReview'
|
}
|
return 'underReview'
|
}
|
|
function formatTimelineTime(value?: string | null) {
|
if (!value) {
|
return undefined
|
}
|
const normalized = value.replace('T', ' ').trim()
|
const [datePart = '', timePart = ''] = normalized.split(' ')
|
if (!datePart) {
|
return normalized
|
}
|
const shortDate = datePart.length >= 10 ? datePart.slice(2) : datePart
|
const shortTime = timePart.slice(0, 5)
|
return shortTime ? `${shortDate} ${shortTime}` : shortDate
|
}
|
|
function mapVisitors(rows: MiniApprovalDetailItem['visitors']) {
|
if (!rows || rows.length === 0) {
|
visitors.value = []
|
return
|
}
|
visitors.value = rows.map(visitor => ({
|
id: visitor.visitorId,
|
name: visitor.name,
|
gender: visitor.gender === 'FEMALE' ? 'female' : 'male',
|
phone: visitor.mobileMasked || '-',
|
idCard: visitor.idCardMasked || '-',
|
company: visitor.companyName || '-',
|
plate: visitor.plateNo || '',
|
}))
|
}
|
|
function resolveTimelineStatus(nodeStatus?: string | null): TimelineItem['status'] {
|
switch (nodeStatus) {
|
case 'APPROVED':
|
return 'completed'
|
case 'REJECTED':
|
return 'rejected'
|
case 'PENDING':
|
return 'current'
|
default:
|
return 'pending'
|
}
|
}
|
|
function resolveTimelineLabel(taskStatus?: string | null) {
|
switch (taskStatus) {
|
case 'APPROVED':
|
return { label: '审批通过', labelType: 'green' as const }
|
case 'REJECTED':
|
return { label: '审批驳回', labelType: 'red' as const }
|
case 'CANCELLED':
|
return { label: '已取消', labelType: 'gray' as const }
|
case 'SKIPPED':
|
return { label: '已跳过', labelType: 'gray' as const }
|
default:
|
return null
|
}
|
}
|
|
function hasCompletedApprovalAction(taskStatus?: string | null) {
|
return taskStatus === 'APPROVED' || taskStatus === 'REJECTED'
|
}
|
|
function mapTimelineUser(user: ApprovalFlowNodeItem['groups'][number]['users'][number]): TimelineUser {
|
const labelInfo = resolveTimelineLabel(user.taskStatus)
|
return {
|
name: user.userName || '审批人',
|
avatar: user.avatarUrl || '',
|
cancelled: user.taskStatus === 'CANCELLED',
|
label: labelInfo?.label,
|
labelType: labelInfo?.labelType,
|
time: hasCompletedApprovalAction(user.taskStatus) ? formatTimelineTime(user.actionTime) : '',
|
comment: user.comment || '',
|
}
|
}
|
|
function mapTimeline(rows: ApprovalFlowNodeItem[]) {
|
const sourceRows = rows || []
|
const hasLaterHandlerNode = sourceRows.slice(2).some(node => node.nodeCode === 'LEVEL1_HANDLER')
|
const visibleRows = currentReviewNode.value === '经办人审批' && !hasLaterHandlerNode
|
? sourceRows.slice(0, 2)
|
: sourceRows
|
|
const approvalRows = visibleRows.map((node) => {
|
const groups = node.groups || []
|
const groupCount = groups.length
|
const maxUserCount = groups.reduce((max, group) => Math.max(max, group.users?.length || 0), 0)
|
const signType = groupCount > 1 ? 'and' : (maxUserCount > 1 ? 'or' : undefined)
|
const status = resolveTimelineStatus(node.nodeStatus)
|
const firstActionTime = ['completed', 'rejected'].includes(status)
|
? groups.flatMap(group => group.users || []).filter(user => hasCompletedApprovalAction(user.taskStatus)).map(user => formatTimelineTime(user.actionTime)).find(Boolean)
|
: ''
|
|
if (signType) {
|
return {
|
title: node.nodeName,
|
signType,
|
groups: groups.map(group => ({
|
department: signType === 'and' ? (group.groupName || undefined) : undefined,
|
users: (group.users || []).map(mapTimelineUser),
|
})),
|
status,
|
}
|
}
|
|
return {
|
title: node.nodeName,
|
time: firstActionTime,
|
users: (groups[0]?.users || []).map(mapTimelineUser),
|
status,
|
}
|
})
|
|
const launchUser = (detail.value?.submitterName || '').trim() || '发起人'
|
const launchTime = formatTimelineTime(detail.value?.submitTime || undefined)
|
const launchNode: TimelineItem = {
|
title: '发起预约',
|
time: launchTime,
|
users: [
|
{
|
name: launchUser,
|
avatar: '',
|
label: '发起申请',
|
labelType: 'gray',
|
},
|
],
|
status: 'completed',
|
}
|
timeline.value = [launchNode, ...approvalRows]
|
}
|
|
function buildRegionColumns(parentIndex: number, childIndex: number) {
|
// 树形展开模式不需要此方法
|
}
|
|
function isAreaSelected(areaId: number) {
|
return selectedAreaIds.value.includes(areaId)
|
}
|
|
const activeAreaParent = computed(() => {
|
if (areaTree.value.length === 0) {
|
return null
|
}
|
return areaTree.value.find(item => item.id === activeAreaParentId.value) || areaTree.value[0]
|
})
|
|
const activeAreaChildren = computed(() => activeAreaParent.value?.children || [])
|
|
function getAreaSelectableIds(parent: MiniPassAccessAreaTreeItem) {
|
const childIds = (parent.children || []).map(child => child.id)
|
return childIds.length > 0 ? childIds : [parent.id]
|
}
|
|
function isParentAreaSelected(parent: MiniPassAccessAreaTreeItem) {
|
const ids = getAreaSelectableIds(parent)
|
return ids.length > 0 && ids.every(id => selectedAreaIds.value.includes(id))
|
}
|
|
function isParentAreaPartialSelected(parent: MiniPassAccessAreaTreeItem) {
|
const ids = getAreaSelectableIds(parent)
|
return ids.some(id => selectedAreaIds.value.includes(id)) && !ids.every(id => selectedAreaIds.value.includes(id))
|
}
|
|
function selectActiveParent(parentId: number) {
|
activeAreaParentId.value = parentId
|
}
|
|
function toggleParentAreaSelection(parent: MiniPassAccessAreaTreeItem) {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
activeAreaParentId.value = parent.id
|
const ids = getAreaSelectableIds(parent)
|
if (isParentAreaSelected(parent)) {
|
const removeIds = new Set(ids)
|
selectedAreaIds.value = selectedAreaIds.value.filter(id => !removeIds.has(id))
|
}
|
else {
|
selectedAreaIds.value = Array.from(new Set([...selectedAreaIds.value, ...ids]))
|
}
|
syncSelectedAreaDisplay()
|
}
|
|
function toggleChildAreaSelection(child: MiniPassAccessAreaTreeItem) {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
if (selectedAreaIds.value.includes(child.id)) {
|
selectedAreaIds.value = selectedAreaIds.value.filter(id => id !== child.id)
|
}
|
else {
|
selectedAreaIds.value = [...selectedAreaIds.value, child.id]
|
}
|
syncSelectedAreaDisplay()
|
}
|
|
function resolveAreaDisplayById(targetId: number) {
|
for (const parent of areaTree.value) {
|
if (parent.id === targetId) {
|
return parent.areaName
|
}
|
for (const child of parent.children || []) {
|
if (child.id === targetId) {
|
return `${parent.areaName}-${child.areaName}`
|
}
|
}
|
}
|
return ''
|
}
|
|
function buildOtherLocalUnitDisplay(response: MiniApprovalDetailItem) {
|
const localUnitNames = (response.localUnits || [])
|
.map(item => item.unitName?.trim() || '')
|
.filter(Boolean)
|
if (localUnitNames.length > 0) {
|
return localUnitNames.join(',')
|
}
|
return response.involveOtherLocalUnit ? '-' : '不涉及'
|
}
|
|
function syncSelectedAreaDisplay() {
|
if (selectedAreaIds.value.length === 0) {
|
approveForm.value.region = ''
|
accessAreaDisplay.value = '-'
|
return
|
}
|
const labels = selectedAreaIds.value
|
.map(id => resolveAreaDisplayById(id))
|
.filter(Boolean)
|
const text = labels.join(',')
|
approveForm.value.region = text
|
accessAreaDisplay.value = text || '-'
|
}
|
|
function isHandlerReadOnly() {
|
return currentReviewNode.value === '经办人审批' && !canApprove.value
|
}
|
|
function removeSelectedArea(id: number) {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
selectedAreaIds.value = selectedAreaIds.value.filter(item => item !== id)
|
syncSelectedAreaDisplay()
|
}
|
|
function syncRegionFromSelectedIds() {
|
if (areaTree.value.length === 0) {
|
return
|
}
|
// 自动展开已选中区域的父节点
|
areaTree.value.forEach((parent) => {
|
const hasSelectedChild = (parent.children || []).some(child => selectedAreaIds.value.includes(child.id))
|
if (hasSelectedChild || selectedAreaIds.value.includes(parent.id)) {
|
if (activeAreaParentId.value == null) {
|
activeAreaParentId.value = parent.id
|
}
|
}
|
})
|
if (activeAreaParentId.value == null && areaTree.value.length > 0) {
|
activeAreaParentId.value = areaTree.value[0].id
|
}
|
syncSelectedAreaDisplay()
|
}
|
|
async function loadAreaTree() {
|
if (currentReviewNode.value !== '经办人审批') {
|
return
|
}
|
try {
|
areaTree.value = await fetchPassAccessAreaTree()
|
if (areaTree.value.length > 0) {
|
if (activeAreaParentId.value == null || !areaTree.value.some(parent => parent.id === activeAreaParentId.value)) {
|
activeAreaParentId.value = areaTree.value[0].id
|
}
|
syncRegionFromSelectedIds()
|
}
|
}
|
catch (error) {
|
uni.showToast({ title: error instanceof Error ? error.message : '加载通行区域失败', icon: 'none' })
|
}
|
}
|
|
function confirmRegionPicker() {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
if (selectedAreaIds.value.length === 0) {
|
uni.showToast({ title: '请至少选择一个通行区域', icon: 'none' })
|
return
|
}
|
showRegionPicker.value = false
|
}
|
|
async function handleUnitInput(e: any) {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
const value = (e.detail.value || '').trim()
|
unitInput.value = value
|
if (!value) {
|
showUnitDropdown.value = false
|
filteredUnits.value = []
|
filteredUnitItems.value = []
|
return
|
}
|
try {
|
const results = await searchLocalUnits(value)
|
if (unitInput.value !== value) {
|
return
|
}
|
filteredUnitItems.value = results
|
filteredUnits.value = results.map(item => item.unitName)
|
showUnitDropdown.value = filteredUnits.value.length > 0
|
}
|
catch (error) {
|
showUnitDropdown.value = false
|
filteredUnits.value = []
|
filteredUnitItems.value = []
|
uni.showToast({ title: error instanceof Error ? error.message : '搜索属地单位失败', icon: 'none' })
|
}
|
}
|
|
function selectUnit(unit: string) {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
unitInput.value = unit
|
showUnitDropdown.value = false
|
}
|
|
function handleUnitFocus() {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
uni.onKeyboardHeightChange(handleUnitKeyboardHeightChange)
|
if (unitInput.value) {
|
showUnitDropdown.value = filteredUnits.value.length > 0
|
}
|
}
|
|
function handleUnitBlur() {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
unitPopupOffsetY.value = 0
|
uni.offKeyboardHeightChange(handleUnitKeyboardHeightChange)
|
setTimeout(() => {
|
showUnitDropdown.value = false
|
}, 200)
|
}
|
|
function syncUnitDisplay() {
|
unitList.value = selectedLocalUnits.value.map(item => item.unitName)
|
approveForm.value.otherUnit = unitList.value.join(',')
|
if (approveForm.value.involveOther === 'yes') {
|
otherLocalUnitDisplay.value = approveForm.value.otherUnit || '-'
|
} else {
|
otherLocalUnitDisplay.value = '不涉及'
|
}
|
}
|
|
function addUnit() {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
const value = unitInput.value.trim()
|
if (!value) {
|
uni.showToast({ title: '请输入单位名称', icon: 'none' })
|
return
|
}
|
const matched = filteredUnitItems.value.find(item => item.unitName === value)
|
if (!matched) {
|
uni.showToast({ title: '请选择搜索结果中的单位', icon: 'none' })
|
return
|
}
|
if (selectedLocalUnits.value.some(item => item.id === matched.id)) {
|
unitInput.value = ''
|
showUnitDropdown.value = false
|
return
|
}
|
selectedLocalUnits.value.push(matched)
|
syncUnitDisplay()
|
unitInput.value = ''
|
filteredUnits.value = []
|
filteredUnitItems.value = []
|
showUnitDropdown.value = false
|
}
|
|
function removeUnit(index: number) {
|
if (isHandlerReadOnly()) {
|
return
|
}
|
selectedLocalUnits.value.splice(index, 1)
|
syncUnitDisplay()
|
}
|
|
function confirmUnitSelection() {
|
syncUnitDisplay()
|
unitPopupOffsetY.value = 0
|
uni.offKeyboardHeightChange(handleUnitKeyboardHeightChange)
|
showUnitPicker.value = false
|
}
|
|
watch(
|
() => approveForm.value.involveOther,
|
(value) => {
|
if (value === 'yes') {
|
otherLocalUnitDisplay.value = approveForm.value.otherUnit || '-'
|
return
|
}
|
otherLocalUnitDisplay.value = '不涉及'
|
},
|
)
|
|
function cancelReject() {
|
showRejectPopup.value = false
|
rejectReason.value = ''
|
rejectInputFocused.value = false
|
}
|
|
function removeCcUser(index: number) {
|
approveForm.value.ccList.splice(index, 1)
|
}
|
|
async function handleCcInput(e: any) {
|
const value = (e.detail.value || '').trim()
|
approveForm.value.ccName = value
|
if (!value) {
|
showCcDropdown.value = false
|
filteredCcUsers.value = []
|
return
|
}
|
try {
|
const users = await searchVisitedUsersByExactName(value)
|
if (approveForm.value.ccName !== value) {
|
return
|
}
|
filteredCcUsers.value = users.map(user => ({
|
id: user.id,
|
name: user.realName,
|
department: user.deptName || '',
|
}))
|
showCcDropdown.value = filteredCcUsers.value.length > 0
|
}
|
catch (error) {
|
showCcDropdown.value = false
|
filteredCcUsers.value = []
|
uni.showToast({ title: error instanceof Error ? error.message : '搜索抄送人失败', icon: 'none' })
|
}
|
}
|
|
function selectCcUser(user: CcUser) {
|
if (!approveForm.value.ccList.some(item => item.id === user.id)) {
|
approveForm.value.ccList.push(user)
|
}
|
approveForm.value.ccName = ''
|
showCcDropdown.value = false
|
filteredCcUsers.value = []
|
}
|
|
function handleCcFocus() {
|
if (approveForm.value.ccName) {
|
showCcDropdown.value = filteredCcUsers.value.length > 0
|
}
|
}
|
|
function handleCcBlur() {
|
setTimeout(() => {
|
showCcDropdown.value = false
|
}, 200)
|
}
|
|
async function submitApproval(approve: boolean) {
|
if (!detail.value?.taskId || !detail.value.taskUuid) {
|
uni.showToast({ title: '审批参数缺失', icon: 'none' })
|
return
|
}
|
if (!canApprove.value) {
|
uni.showToast({ title: '当前不可审批', icon: 'none' })
|
return
|
}
|
const comment = approve ? approveForm.value.opinion.trim() : rejectReason.value.trim()
|
if (!approve && !comment) {
|
uni.showToast({ title: '请输入驳回原因', icon: 'none' })
|
return
|
}
|
if (!approve && comment.length > 30) {
|
uni.showToast({ title: '驳回原因最多30个字', icon: 'none' })
|
return
|
}
|
if (approve && currentReviewNode.value === '经办人审批' && selectedAreaIds.value.length === 0) {
|
uni.showToast({ title: '请选择通行区域', icon: 'none' })
|
return
|
}
|
if (
|
approve
|
&& currentReviewNode.value === '经办人审批'
|
&& approveForm.value.involveOther === 'yes'
|
&& selectedLocalUnits.value.length === 0
|
) {
|
uni.showToast({ title: '请选择属地单位', icon: 'none' })
|
return
|
}
|
|
try {
|
if (currentReviewNode.value === '经办人审批') {
|
await submitHandlerApproval({
|
taskId: detail.value.taskId,
|
taskUuid: detail.value.taskUuid,
|
approve,
|
comment,
|
passAccessAreaIds: approve ? selectedAreaIds.value : undefined,
|
involveOtherLocalUnit: approve ? approveForm.value.involveOther === 'yes' : false,
|
passLocalUnitIds: approve && approveForm.value.involveOther === 'yes'
|
? selectedLocalUnits.value.map(item => item.id)
|
: [],
|
})
|
}
|
else {
|
await submitGeneralApproval({
|
taskId: detail.value.taskId,
|
taskUuid: detail.value.taskUuid,
|
approve,
|
comment,
|
noticeIds: currentReviewNode.value === '其他属地单位领导审批'
|
? approveForm.value.ccList.map(item => item.id)
|
: undefined,
|
})
|
}
|
if (!approve) {
|
showRejectPopup.value = false
|
rejectReason.value = ''
|
}
|
uni.showToast({ title: approve ? '已通过' : '已驳回', icon: 'success' })
|
setTimeout(() => {
|
router.back()
|
}, 1000)
|
}
|
catch (error) {
|
uni.showToast({ title: error instanceof Error ? error.message : '审批提交失败', icon: 'none' })
|
}
|
}
|
|
function handlePass() {
|
void submitApproval(true)
|
}
|
|
function handleReject() {
|
if (!canApprove.value) {
|
uni.showToast({ title: '当前不可审批', icon: 'none' })
|
return
|
}
|
showRejectPopup.value = true
|
}
|
|
function confirmReject() {
|
const reason = rejectReason.value.trim()
|
if (!reason) {
|
uni.showToast({ title: '请输入驳回原因', icon: 'none' })
|
return
|
}
|
if (reason.length > 30) {
|
uni.showToast({ title: '驳回原因最多30个字', icon: 'none' })
|
return
|
}
|
void submitApproval(false)
|
}
|
|
async function loadDetail() {
|
if (!query.value.taskId && !query.value.orderId) {
|
return
|
}
|
loading.value = true
|
try {
|
const response = await fetchApprovalDetail(query.value)
|
detail.value = response
|
canApprove.value = Boolean(response.canApprove)
|
currentReviewNode.value = resolveReviewNode(response)
|
overallStatus.value = resolveOverallStatus(response.approvalStatusText)
|
reviewStatusText.value = response.approvalStatusText || '待审批'
|
visitedUserDisplay.value = response.visitedDeptName
|
? `${response.visitedUserName}(${response.visitedDeptName})`
|
: response.visitedUserName
|
accessAreaDisplay.value = response.accessAreaText || '-'
|
otherLocalUnitDisplay.value = buildOtherLocalUnitDisplay(response)
|
visitTypeDisplay.value = response.visitTypeText || '-'
|
visitReasonDisplay.value = normalizeVisitReasonText(response.visitReasonText) || '-'
|
visitReasonOtherDisplay.value = (response.visitReasonOther || '').trim() || '-'
|
visitTimeDisplay.value = response.visitTimeText || '-'
|
approveForm.value.opinion = ''
|
const passAccessAreaNames = response.passAccessAreaNames || []
|
approveForm.value.region = passAccessAreaNames.length > 0
|
? passAccessAreaNames[passAccessAreaNames.length - 1]
|
: ''
|
approveForm.value.involveOther = response.involveOtherLocalUnit ? 'yes' : 'no'
|
approveForm.value.ccName = ''
|
approveForm.value.ccList = (response.noticeUsers || []).map(user => ({
|
id: user.id,
|
name: user.realName,
|
department: user.deptName || '',
|
}))
|
selectedAreaIds.value = [...(response.passAccessAreaIds || [])]
|
selectedLocalUnits.value = [...(response.localUnits || [])]
|
syncUnitDisplay()
|
mapVisitors(response.visitors)
|
mapTimeline(response.flowTree)
|
await loadAreaTree()
|
}
|
catch (error) {
|
uni.showToast({ title: error instanceof Error ? error.message : '加载审批详情失败', icon: 'none' })
|
}
|
finally {
|
loading.value = false
|
}
|
}
|
|
onLoad((options) => {
|
query.value = {
|
taskId: options?.taskId ? Number(options.taskId) : undefined,
|
taskUuid: options?.taskUuid || undefined,
|
orderId: options?.orderId ? Number(options.orderId) : undefined,
|
orderUuid: options?.orderUuid || undefined,
|
}
|
void loadDetail()
|
})
|
</script>
|
|
<template>
|
<view class="min-h-screen w-full relative box-border flex flex-col pb-[160rpx]">
|
<!-- 整个页面的背景图 -->
|
<image src="/static/viewCode/bg@2x.png" class="fixed left-0 top-0 z-0 h-full w-full pointer-events-none" mode="scaleToFill" />
|
|
<!-- 固定的顶部区域 -->
|
<view class="fixed top-0 left-0 w-full z-50 flex flex-col overflow-hidden">
|
<!-- 截取顶部背景图作为 navBar 背景,防止滚动时内容透出 -->
|
<image src="/static/viewCode/bg@2x.png" class="absolute left-0 top-0 w-full z-0 pointer-events-none" mode="widthFix" />
|
|
<!-- 自定义导航栏 -->
|
<view class="relative w-full shrink-0" :style="{ height: (statusBarHeight + navBarHeight) + 'px' }">
|
<view class="flex items-center justify-between w-full absolute left-0 z-10"
|
:style="{ top: statusBarHeight + 'px', height: navBarHeight + 'px' }">
|
<view class="flex h-full w-[88rpx] items-center justify-center relative z-10" @click="handleBack">
|
<wd-icon name="arrow-left" size="40rpx" color="#ffffff" />
|
</view>
|
<view class="absolute left-0 w-full text-center text-[34rpx] font-medium text-white z-0">
|
预约
|
</view>
|
<view class="w-[88rpx] flex justify-center items-center relative z-10">
|
<view class="w-[64rpx] h-[32rpx] rounded-[8rpx] border-[1rpx] border-white/50 flex items-center justify-center bg-black/10">
|
<view class="flex gap-[4rpx] items-center">
|
<view class="w-[6rpx] h-[6rpx] rounded-full bg-white"></view>
|
<view class="w-[6rpx] h-[6rpx] rounded-full bg-white"></view>
|
<view class="w-[6rpx] h-[6rpx] rounded-full bg-white"></view>
|
</view>
|
<view class="w-[1rpx] h-[20rpx] bg-white/30 mx-[6rpx]"></view>
|
<view class="w-[16rpx] h-[16rpx] rounded-full border-[2rpx] border-solid border-white flex items-center justify-center box-border">
|
<view class="w-[6rpx] h-[6rpx] rounded-full bg-white"></view>
|
</view>
|
</view>
|
</view>
|
</view>
|
</view>
|
</view>
|
|
<!-- 占位元素,用于撑开固定的顶部高度 -->
|
<view :style="{ height: (statusBarHeight + navBarHeight) + 'px' }" class="shrink-0"></view>
|
|
<!-- 状态图标(右上角绝对定位) -->
|
<view class="absolute right-[24rpx] z-20 pointer-events-none" :style="{ top: (statusBarHeight + navBarHeight) - 5 + 'px' }">
|
<image v-if="overallStatus === 'underReview'" src="/static/review/underReview.png" class="w-[156rpx] h-[156rpx]" mode="aspectFit" />
|
<image v-else-if="overallStatus === 'passReview'" src="/static/review/passReview.png" class="w-[156rpx] h-[156rpx]" mode="aspectFit" />
|
<image v-else-if="overallStatus === 'refueReview'" src="/static/review/refueReview.png" class="w-[156rpx] h-[156rpx]" mode="aspectFit" />
|
<image v-else-if="overallStatus === 'cancelReview'" src="/static/review/cancelReview.png" class="w-[156rpx] h-[156rpx]" mode="aspectFit" />
|
</view>
|
|
<!-- 内容区 -->
|
<view class="relative z-10 px-[24rpx] flex-1 mt-[32rpx]">
|
|
<!-- 拜访信息 -->
|
<view class="flex items-end mb-[26rpx] px-[8rpx]">
|
<text class="text-[32rpx] text-[#000000] font-bold">拜访信息</text>
|
<text class="text-[24rpx] text-[#676767] ml-[12rpx] mb-[4rpx]">被访问的人</text>
|
</view>
|
<view class="bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)] w-full mb-[52rpx] px-[40rpx] py-[20rpx] box-border">
|
<view class="flex justify-between items-center h-[96rpx] border-b1">
|
<text class="text-[#000000] text-[28rpx]">被访人</text>
|
<text class="text-[#000000] text-[28rpx]">{{ visitedUserDisplay }}</text>
|
</view>
|
<view class="flex justify-between items-center h-[96rpx]" :class="showOtherLocalUnitInVisitInfo ? 'border-b1' : ''">
|
<text class="text-[#000000] text-[28rpx]">可访问区域</text>
|
<text class="text-[#000000] text-[28rpx]">{{ accessAreaDisplay }}</text>
|
</view>
|
<view v-if="showOtherLocalUnitInVisitInfo" class="flex justify-between items-center h-[96rpx]">
|
<text class="text-[#000000] text-[28rpx]">其他所属单位</text>
|
<text class="text-[#000000] text-[28rpx] text-right ml-[24rpx]">{{ otherLocalUnitDisplay }}</text>
|
</view>
|
</view>
|
|
<!-- 来访信息 -->
|
<view class="flex items-end mb-[24rpx] px-[8rpx]">
|
<text class="text-[32rpx] text-[#000000] font-bold">来访信息</text>
|
<text class="text-[24rpx] text-[#676767] ml-[12rpx] mb-[4rpx]">访客信息</text>
|
</view>
|
<view class="bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)] w-full mb-[32rpx] px-[40rpx] py-[20rpx] box-border">
|
<view class="flex justify-between items-center h-[96rpx] border-b1">
|
<text class="text-[#000000] text-[28rpx] shrink-0">来访类型</text>
|
<text class="text-[#000000] text-[28rpx] text-right truncate">{{ visitTypeDisplay }}</text>
|
</view>
|
<view class="flex justify-between items-center h-[96rpx] border-b1">
|
<text class="text-[#000000] text-[28rpx] shrink-0">来访事由</text>
|
<text class="text-[#000000] text-[28rpx] text-right truncate">{{ visitReasonDisplay }}</text>
|
</view>
|
<view class="flex justify-between items-start min-h-[96rpx] border-b1 py-[24rpx] box-border">
|
<text class="text-[#000000] text-[28rpx] shrink-0">事由说明</text>
|
<text class="text-[#000000] text-[28rpx] text-right ml-[24rpx] line-clamp-2 break-all">{{ visitReasonOtherDisplay }}</text>
|
</view>
|
<view class="flex justify-between items-center h-[96rpx] border-b1">
|
<text class="text-[#000000] text-[28rpx] shrink-0">来访时间</text>
|
<text class="text-[#000000] text-[24rpx] text-right truncate">{{ visitTimeDisplay }}</text>
|
</view>
|
<view class="flex justify-between items-center h-[96rpx]">
|
<text class="text-[#000000] text-[28rpx] shrink-0">来访人</text>
|
<text class="text-[#000000] text-[28rpx] text-right">合计{{ visitors.length }}人</text>
|
</view>
|
|
<!-- 访客列表 -->
|
<view class="pb-[24rpx]">
|
<view class="bg-[#F7F7F7] w-[608rpx] ml-[-16rpx] rounded-[8rpx] px-[24rpx] py-[20rpx]">
|
<view v-for="(visitor, index) in visitors" :key="index" class="mb-[12rpx] bg-white border-[2rpx] border-solid border-[#F5F5F5] rounded-[8rpx] p-[24rpx] shadow-[0_2rpx_8rpx_rgba(0,0,0,0.02)]">
|
<view class="flex items-center mb-[16rpx] border-b1 pb-[16rpx]">
|
<text class="text-[#000000] text-[28rpx] font-bold mr-[16rpx]">{{ visitor.name }}</text>
|
<image :src="visitor.gender === 'male' ? appointmentIcons.male : appointmentIcons.female" class="w-[32rpx] h-[32rpx] mr-[16rpx]" mode="aspectFit" />
|
<text class="text-[#B3B3B3] text-[24rpx]">{{ visitor.phone }}</text>
|
</view>
|
<view class="flex flex-col gap-[12rpx]">
|
<view class="flex items-center">
|
<image :src="appointmentIcons.idCard" class="w-[32rpx] h-[32rpx] mr-[12rpx] shrink-0" mode="aspectFit" />
|
<text class="text-[#B3B3B3] text-[24rpx]">{{ visitor.idCard }}</text>
|
</view>
|
<view class="flex items-center">
|
<image :src="appointmentIcons.company" class="w-[32rpx] h-[32rpx] mr-[12rpx] shrink-0" mode="aspectFit" />
|
<text class="text-[#B3B3B3] text-[24rpx]">{{ visitor.company }}</text>
|
</view>
|
<view v-if="visitor.plate" class="flex items-center">
|
<image :src="appointmentIcons.plate" class="w-[32rpx] h-[32rpx] mr-[12rpx] shrink-0" mode="aspectFit" />
|
<text class="text-[#B3B3B3] text-[24rpx]">{{ visitor.plate }}</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
</view>
|
</view>
|
|
<!-- 审批信息 -->
|
<view class="flex items-end mb-[48rpx] px-[8rpx]">
|
<text class="text-[32rpx] text-[#000000] font-bold">审批信息</text>
|
</view>
|
<view class="bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)] w-full mb-[32rpx] px-[32rpx] py-[8rpx] box-border">
|
<view class="flex justify-between items-center h-[96rpx] border-b1">
|
<text class="text-[#000000] text-[28rpx]">审批状态</text>
|
<text class="text-[#F7A820] text-[28rpx]">{{ reviewStatusText }}</text>
|
</view>
|
<view v-if="canApprove && currentReviewNode === '经办人审批'" class="flex justify-between items-start py-[28rpx] border-b1">
|
<text class="text-[#000000] text-[28rpx] shrink-0 w-[160rpx] leading-[40rpx]">接待说明</text>
|
<textarea v-model="approveForm.opinion" placeholder="请输入接待说明" placeholder-class="text-[#CCCCCC] text-[28rpx]" class="flex-1 h-[144rpx] text-right text-[28rpx] text-[#000000] leading-[40rpx]" />
|
</view>
|
<view v-else-if="canApprove" class="flex justify-between items-center h-[96rpx]" :class="currentReviewNode === '其他属地单位领导审批' ? 'border-b1' : ''">
|
<text class="text-[#000000] text-[28rpx] shrink-0 w-[160rpx]">审批意见</text>
|
<input v-model="approveForm.opinion" type="text" placeholder="请输入审批意见" placeholder-class="text-[#CCCCCC] text-[28rpx]" class="flex-1 text-right text-[28rpx] text-[#000000]" />
|
</view>
|
|
<!-- 经办人审批 专属字段 -->
|
<template v-if="currentReviewNode === '经办人审批'">
|
<view
|
class="flex justify-between items-center h-[96rpx]"
|
:class="[
|
selectedAreaIds.length > 0 ? '' : 'border-b1',
|
isHandlerReadOnly() ? 'opacity-60' : ''
|
]"
|
@click="!isHandlerReadOnly() && (showRegionPicker = true)"
|
>
|
<text class="text-[#000000] text-[28rpx]">通行区域<text class="text-[#FF4D4F]">*</text></text>
|
<view class="flex items-center">
|
<text class="text-[28rpx] mr-[8rpx]" :class="approveForm.region ? 'text-[#000000]' : 'text-[#CCCCCC]'">{{ approveForm.region || '请选择区域' }}</text>
|
<wd-icon name="arrow-right" size="32rpx" color="#000000" />
|
</view>
|
</view>
|
<view v-if="selectedAreaIds.length > 0" class="flex flex-wrap gap-[16rpx] pt-[16rpx] pb-[20rpx] border-b1">
|
<view v-for="areaId in selectedAreaIds" :key="areaId" class="flex items-center justify-center px-[24rpx] h-[56rpx] rounded-[28rpx] border-[2rpx] border-solid border-[#E5E5E5] box-border" :class="isHandlerReadOnly() ? 'opacity-70' : ''">
|
<text class="text-[#666666] text-[24rpx]">{{ resolveAreaDisplayById(areaId) }}</text>
|
<view v-if="!isHandlerReadOnly()" class="flex items-center justify-center w-[24rpx] h-[24rpx] ml-[8rpx] active:opacity-80" @click.stop="removeSelectedArea(areaId)">
|
<image src="/static/review/close.png" class="w-[24rpx] h-[24rpx]" mode="aspectFit" />
|
</view>
|
</view>
|
</view>
|
<view class="flex justify-between items-center h-[96rpx]" :class="[approveForm.involveOther === 'yes' ? 'border-b1' : '', isHandlerReadOnly() ? 'opacity-60' : '']">
|
<text class="text-[#000000] text-[28rpx]">是否涉及其他属地单位</text>
|
<view class="flex items-center gap-[40rpx]">
|
<view class="flex items-center" :class="isHandlerReadOnly() ? 'pointer-events-none' : ''" @click="!isHandlerReadOnly() && (approveForm.involveOther = 'yes')">
|
<view class="w-[32rpx] h-[32rpx] rounded-full border-[2rpx] border-solid flex items-center justify-center mr-[12rpx] box-border"
|
:class="approveForm.involveOther === 'yes' ? 'border-[#0068FF]' : 'border-[#CCCCCC]'">
|
<view v-if="approveForm.involveOther === 'yes'" class="w-[16rpx] h-[16rpx] rounded-full bg-[#0068FF]"></view>
|
</view>
|
<text class="text-[#333333] text-[28rpx]">是</text>
|
</view>
|
<view class="flex items-center" :class="isHandlerReadOnly() ? 'pointer-events-none' : ''" @click="!isHandlerReadOnly() && (approveForm.involveOther = 'no')">
|
<view class="w-[32rpx] h-[32rpx] rounded-full border-[2rpx] border-solid flex items-center justify-center mr-[12rpx] box-border"
|
:class="approveForm.involveOther === 'no' ? 'border-[#0068FF]' : 'border-[#CCCCCC]'">
|
<view v-if="approveForm.involveOther === 'no'" class="w-[16rpx] h-[16rpx] rounded-full bg-[#0068FF]"></view>
|
</view>
|
<text class="text-[#333333] text-[28rpx]">否</text>
|
</view>
|
</view>
|
</view>
|
<view
|
v-if="approveForm.involveOther === 'yes'"
|
class="flex justify-between items-center h-[96rpx]"
|
:class="isHandlerReadOnly() ? 'opacity-60' : ''"
|
@click="!isHandlerReadOnly() && (showUnitPicker = true)"
|
>
|
<text class="text-[#000000] text-[28rpx]">其他属地单位</text>
|
<view class="flex items-center">
|
<text class="text-[28rpx] mr-[8rpx]" :class="approveForm.otherUnit ? 'text-[#000000]' : 'text-[#CCCCCC]'">{{ approveForm.otherUnit || '请选择单位' }}</text>
|
<wd-icon name="arrow-right" size="32rpx" color="#000000" />
|
</view>
|
</view>
|
</template>
|
|
<!-- 其他属地单位领导审批 专属字段 -->
|
<template v-if="currentReviewNode === '其他属地单位领导审批' && canApprove">
|
<view class="flex flex-col pt-[8rpx] pb-[16rpx]">
|
<view class="flex items-center h-[96rpx] border-b1 relative">
|
<view class="flex items-center shrink-0">
|
<text class="text-[#000000] text-[28rpx]">抄送</text>
|
<text class="text-[#333333] text-[28rpx] ml-[8rpx]">(非必填)</text>
|
</view>
|
<text class="text-[#999999] text-[20rpx] ml-[12rpx] shrink-0">抄送后该人员将受到提醒</text>
|
<input v-model="approveForm.ccName" type="text" placeholder="请输入人名" placeholder-class="text-[#CCCCCC] text-[28rpx]" class="flex-1 text-right text-[28rpx] text-[#000000] min-w-0 ml-[12rpx]" @input="handleCcInput" @focus="handleCcFocus" @blur="handleCcBlur" />
|
|
<!-- 抄送人模糊搜索下拉框 -->
|
<view v-if="showCcDropdown" class="absolute top-[96rpx] right-0 w-[400rpx] bg-white rounded-[8rpx] shadow-[0_4rpx_12rpx_rgba(0,0,0,0.1)] z-50 max-h-[300rpx] overflow-y-auto">
|
<view v-for="(user, index) in filteredCcUsers" :key="index" class="flex justify-between items-center px-[24rpx] py-[20rpx] border-b border-solid border-[#F0F0F0] active:bg-[#F6F7F9]" @click.stop="selectCcUser(user)">
|
<text class="text-[#333333] text-[28rpx]">{{ user.name }}</text>
|
<text class="text-[#999999] text-[24rpx]">{{ user.department }}</text>
|
</view>
|
</view>
|
</view>
|
|
<view class="flex flex-wrap gap-[16rpx] mt-[16rpx]">
|
<view v-for="(cc, index) in approveForm.ccList" :key="index" class="flex items-center justify-center px-[24rpx] h-[56rpx] rounded-[28rpx] border-[2rpx] border-solid border-[#E5E5E5] box-border" :class="isHandlerReadOnly() ? 'opacity-70' : ''">
|
<text class="text-[#666666] text-[24rpx]">{{ cc.name }}</text>
|
<view v-if="!isHandlerReadOnly()" class="flex items-center justify-center w-[24rpx] h-[24rpx] ml-[8rpx] active:opacity-80" @click.stop="removeCcUser(index)">
|
<image src="/static/review/close.png" class="w-[24rpx] h-[24rpx]" mode="aspectFit" />
|
</view>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
</view>
|
|
<!-- 审批情况 -->
|
<view class="flex items-end mb-[26rpx] px-[8rpx]">
|
<text class="text-[32rpx] text-[#000000] font-bold">审批情况</text>
|
</view>
|
|
<view class="bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)] w-full mb-[32rpx] px-[40rpx] py-[40rpx] box-border relative">
|
<view class="flex flex-col relative z-10">
|
<view v-for="(item, index) in timeline" :key="index" class="flex w-full" :class="index !== timeline.length - 1 ? 'pb-[40rpx]' : ''">
|
|
<!-- 左侧:独立的固定宽度图标容器 -->
|
<view class="w-[48rpx] shrink-0 flex flex-col items-center justify-center mr-[24rpx] relative">
|
<!-- 绝对定位的背景连线 -->
|
<view v-if="index !== timeline.length - 1 && item.status !== 'rejected'" class="absolute top-[50%] bottom-[-40rpx] left-[22rpx] w-[4rpx] z-0" :class="item.status === 'completed' ? 'bg-[#0068FF]' : 'bg-[#E5E5E5]'"></view>
|
<view v-if="index !== 0 && timeline[index - 1].status !== 'rejected'" class="absolute top-[-40rpx] bottom-[50%] left-[22rpx] w-[4rpx] z-0" :class="timeline[index - 1].status === 'completed' ? 'bg-[#0068FF]' : 'bg-[#E5E5E5]'"></view>
|
|
<!-- 居中的节点图标 -->
|
<image v-if="item.status === 'completed'" src="/static/review/pass.png" class="w-[48rpx] h-[48rpx] relative z-10 shrink-0" mode="aspectFit" />
|
<image v-else-if="item.status === 'rejected'" src="/static/review/fail.png" class="w-[48rpx] h-[48rpx] relative z-10 shrink-0 bg-white rounded-full" mode="aspectFit" />
|
<image v-else src="/static/review/circle.png" class="w-[48rpx] h-[48rpx] relative z-10 shrink-0 bg-white rounded-full" mode="aspectFit" />
|
</view>
|
|
<!-- 右侧卡片内容 -->
|
<view class="flex-1 min-w-0 flex flex-col">
|
<view class="rounded-[8rpx] p-[32rpx] box-border relative overflow-hidden" :class="item.status === 'current' ? 'bg-[#FFFFFF] shadow-[0px_4px_8px_0px_rgba(6,47,108,0.1608)]' : 'bg-[#F6F7F9]'">
|
<!-- 悬浮的右侧中心蓝色条 -->
|
<view class="absolute right-0 top-1/2 -translate-y-1/2 w-[8rpx] h-[56rpx] bg-[#0068FF] rounded-l-[8rpx]"></view>
|
|
<view class="flex justify-between items-start" :class="(item.users && item.users.length > 0) || (item.groups && item.groups.length > 0) ? 'mb-[24rpx]' : ''">
|
<view class="flex items-center">
|
<text class="text-[28rpx] font-medium" :class="['completed', 'rejected'].includes(item.status) ? 'text-[#B3B3B3]' : 'text-[#333333]'">{{ item.title }}</text>
|
<!-- 或签/会签/成签标签 -->
|
<view v-if="item.signType === 'or'" class="ml-[6rpx] w-[78rpx] h-[32rpx] rounded-[16rpx] bg-[#F0F6FF] flex items-center justify-center box-border">
|
<text class="text-[#0068FF] text-[26rpx]">或签</text>
|
</view>
|
<view v-if="item.signType === 'and'" class="ml-[6rpx] w-[78rpx] h-[32rpx] rounded-[16rpx] bg-[#F0F6FF] flex items-center justify-center box-border">
|
<text class="text-[#0068FF] text-[26rpx]">会签</text>
|
</view>
|
<view v-if="!item.signType && item.title === '部门负责人审批'" class="ml-[12rpx] w-[64rpx] h-[32rpx] rounded-[16rpx] bg-[#F0F6FF] flex items-center justify-center box-border">
|
<text class="text-[#0068FF] text-[20rpx]">成签</text>
|
</view>
|
</view>
|
|
<view class="flex flex-col items-end">
|
<!-- 普通节点时间展示 -->
|
<text v-if="!item.signType && item.time && item.status !== 'current'" class="text-[24rpx] text-[#B3B3B3] leading-[44rpx]">{{ item.time }}</text>
|
|
<!-- 右上角的“审批中”标签(在current状态下) -->
|
<view v-if="item.status === 'current'" class="flex items-center justify-center w-[164rpx] h-[44rpx] rounded-[22rpx] bg-[#E6F0FF] box-border" :class="!item.signType && item.time ? 'mt-[16rpx]' : ''">
|
<image src="/static/review/review.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<text class="text-[22rpx] text-[#0068FF]">审批中</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 普通节点人员列表 -->
|
<view v-if="!item.signType && item.users && item.users.length > 0" class="flex flex-col gap-[20rpx]">
|
<view v-for="(user, uIndex) in item.users" :key="uIndex" class="flex flex-col min-w-0">
|
<view class="flex items-center justify-between min-w-0">
|
<view class="flex items-center min-w-0 flex-1">
|
<NameAvatar :name="user.name" :src="user.avatar" :size="40" :font-size="16" class="mr-[20rpx] shrink-0" :class="['completed', 'rejected'].includes(item.status) ? 'opacity-50' : ''" />
|
<text class="text-[30rpx] truncate min-w-0 flex-1 leading-[40rpx]" :class="['completed', 'rejected'].includes(item.status) ? 'text-[#B3B3B3]' : 'text-[#333333]'">{{ user.name }}</text>
|
</view>
|
<view v-if="user.label" class="flex items-center justify-center w-[164rpx] h-[44rpx] rounded-[22rpx] box-border shrink-0 ml-[16rpx]" :class="user.labelType === 'gray' ? 'bg-[#EAEAEA]' : (user.labelType === 'green' ? 'bg-[#E8F8E3]' : (user.labelType === 'red' ? 'bg-[#FFEBEB]' : 'bg-[#E6F0FF]'))">
|
<image v-if="user.labelType === 'gray'" src="/static/review/applay.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<image v-else-if="user.labelType === 'green'" src="/static/review/greenpass.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<image v-else-if="user.labelType === 'red'" src="/static/review/fail.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<image v-else src="/static/review/review.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<text class="text-[22rpx]" :class="user.labelType === 'gray' ? 'text-[#999999]' : (user.labelType === 'green' ? 'text-[#52C41A]' : (user.labelType === 'red' ? 'text-[#FF4D4F]' : 'text-[#0068FF]'))">{{ user.label }}</text>
|
</view>
|
</view>
|
<text v-if="user.comment && !user.cancelled" class="text-[22rpx] mt-[8rpx] leading-[32rpx] break-all" :class="['completed', 'rejected'].includes(item.status) ? 'text-[#B3B3B3]' : 'text-[#999999]'">审批意见:{{ user.comment }}</text>
|
</view>
|
</view>
|
|
<!-- 或签/会签 节点群组列表 -->
|
<view v-if="item.signType && item.groups && item.groups.length > 0" class="flex flex-col gap-[32rpx]">
|
<view v-for="(group, gIndex) in item.groups" :key="gIndex" class="flex flex-col">
|
<view v-if="group.department" class="w-[88rpx] h-[36rpx] rounded-full bg-[#EAEAEA] flex items-center justify-center mb-[16rpx]">
|
<text class="text-[#999999] text-[20rpx]">{{ group.department }}</text>
|
</view>
|
<view class="flex flex-col gap-[20rpx]">
|
<view v-for="(user, uIndex) in group.users" :key="uIndex" class="flex flex-col min-w-0">
|
<view class="flex justify-between items-center relative min-w-0">
|
<!-- 左侧:人员信息 -->
|
<view class="flex items-center min-w-0 flex-1">
|
<NameAvatar :name="user.name" :src="user.avatar" :size="40" :font-size="16" class="mr-[20rpx] shrink-0" :class="['completed', 'rejected'].includes(item.status) ? 'opacity-50' : ''" />
|
<text class="text-[30rpx] truncate min-w-0 flex-1 leading-[40rpx]" :class="['completed', 'rejected'].includes(item.status) ? 'text-[#B3B3B3]' : 'text-[#333333]'">{{ user.name }}</text>
|
</view>
|
<!-- 右侧:时间和状态 -->
|
<view class="flex flex-col items-end min-w-[180rpx] shrink-0 ml-[16rpx]">
|
<text v-if="user.time && item.status !== 'current'" class="text-[24rpx] text-[#B3B3B3] leading-none mb-[8rpx]">{{ user.time }}</text>
|
<view v-if="user.label" class="flex items-center justify-center w-[164rpx] h-[44rpx] rounded-[22rpx] box-border" :class="user.labelType === 'gray' ? 'bg-[#EAEAEA]' : (user.labelType === 'green' ? 'bg-[#E8F8E3]' : (user.labelType === 'red' ? 'bg-[#FFEBEB]' : 'bg-[#E6F0FF]'))">
|
<image v-if="user.labelType === 'gray'" src="/static/review/applay.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<image v-else-if="user.labelType === 'green'" src="/static/review/greenpass.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<image v-else-if="user.labelType === 'red'" src="/static/review/fail.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<image v-else src="/static/review/review.png" class="w-[24rpx] h-[24rpx] mr-[8rpx]" mode="aspectFit" />
|
<text class="text-[22rpx]" :class="user.labelType === 'gray' ? 'text-[#999999]' : (user.labelType === 'green' ? 'text-[#52C41A]' : (user.labelType === 'red' ? 'text-[#FF4D4F]' : 'text-[#0068FF]'))">{{ user.label }}</text>
|
</view>
|
</view>
|
</view>
|
<text v-if="user.comment && !user.cancelled" class="text-[22rpx] mt-[8rpx] leading-[32rpx] break-all" :class="['completed', 'rejected'].includes(item.status) ? 'text-[#B3B3B3]' : 'text-[#999999]'">审批意见:{{ user.comment }}</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
|
</view>
|
</view>
|
|
</view>
|
</view>
|
</view>
|
|
</view>
|
|
<!-- 底部操作按钮 -->
|
<view v-if="canApprove" class="fixed bottom-0 left-0 w-[750rpx] h-[108rpx] bg-[#FFFFFF] rounded-t-[20rpx] box-content pb-[env(safe-area-inset-bottom)] z-[100] shadow-[inset_0_2rpx_0_0_#E1E1E1] flex justify-end items-center">
|
<view class="w-[240rpx] h-[76rpx] rounded-full relative bg-[#0068FF] shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] active:opacity-80 transition-opacity" @click="handlePass">
|
<view class="absolute inset-0 flex items-center justify-center">
|
<text class="text-white text-[30rpx] font-medium leading-none">通过</text>
|
</view>
|
</view>
|
<view class="w-[240rpx] h-[76rpx] ml-[32rpx] rounded-full mr-[40rpx] relative bg-white border-[2rpx] border-solid border-[#FF4D4F] box-border active:opacity-80 transition-opacity" @click="handleReject">
|
<view class="absolute inset-0 flex items-center justify-center">
|
<text class="text-[#FF4D4F] text-[30rpx] font-medium leading-none">驳回</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 通行区域选择器 -->
|
<wd-popup v-model="showRegionPicker" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0; z-index: 9999;">
|
<view class="w-full bg-white rounded-t-[24rpx] flex flex-col h-[900rpx]">
|
<!-- 头部:关闭图标和标题 -->
|
<view class="relative h-[108rpx] px-[32rpx] border-b border-solid border-[#F0F0F0] shrink-0 flex items-center justify-center">
|
<text class="text-[#333333] text-[32rpx] font-medium">选择访问区域</text>
|
<view class="absolute right-[32rpx] top-[30rpx] w-[48rpx] h-[48rpx] flex items-center justify-center rounded-full bg-[#F5F7FB]" @click="showRegionPicker = false">
|
<text class="text-[#9AA4B2] text-[32rpx] leading-none">×</text>
|
</view>
|
</view>
|
|
<!-- 左右两栏选择区域 -->
|
<view class="relative flex-1 mx-[24rpx] mt-[24rpx] rounded-[24rpx] border border-solid border-[#EDF1F7] overflow-hidden flex min-h-0">
|
<scroll-view scroll-y class="w-[350rpx] bg-white py-[12rpx] px-[12rpx] box-border">
|
<view
|
v-for="parent in areaTree"
|
:key="parent.id"
|
class="h-[78rpx] mb-[10rpx] rounded-[12rpx] px-[14rpx] flex items-center justify-between"
|
:class="activeAreaParent?.id === parent.id ? 'bg-[#EEF4FF]' : 'bg-white'"
|
@click="selectActiveParent(parent.id)"
|
>
|
<view class="flex items-center min-w-0 flex-1" @click.stop="toggleParentAreaSelection(parent)">
|
<view
|
class="w-[34rpx] h-[34rpx] rounded-[8rpx] border-[2rpx] border-solid flex items-center justify-center mr-[14rpx] shrink-0"
|
:class="isParentAreaSelected(parent) || isParentAreaPartialSelected(parent) ? 'bg-[#2F74FF] border-[#2F74FF]' : 'bg-white border-[#E2E8F0]'"
|
>
|
<view v-if="isParentAreaSelected(parent)" class="checkbox-icon"></view>
|
<view v-else-if="isParentAreaPartialSelected(parent)" class="w-[16rpx] h-[4rpx] bg-white rounded-full"></view>
|
</view>
|
<text class="text-[26rpx] font-medium truncate" :class="isParentAreaSelected(parent) || isParentAreaPartialSelected(parent) ? 'text-[#2F74FF]' : 'text-[#4A5568]'">{{ parent.areaName }}</text>
|
</view>
|
<text class="text-[30rpx]" :class="activeAreaParent?.id === parent.id ? 'text-[#2F74FF]' : 'text-[#B8C0CC]'">›</text>
|
</view>
|
</scroll-view>
|
|
<scroll-view scroll-y class="flex-1 bg-white py-[12rpx] px-[12rpx] box-border border-l border-solid border-[#F2F5FA]">
|
<view
|
v-for="child in activeAreaChildren"
|
:key="child.id"
|
class="h-[78rpx] mb-[12rpx] rounded-[12rpx] px-[14rpx] flex items-center bg-[#EEF4FF]"
|
@click="toggleChildAreaSelection(child)"
|
>
|
<view
|
class="w-[34rpx] h-[34rpx] rounded-[8rpx] border-[2rpx] border-solid flex items-center justify-center mr-[14rpx] shrink-0"
|
:class="isAreaSelected(child.id) ? 'bg-[#2F74FF] border-[#2F74FF]' : 'bg-white border-[#E2E8F0]'"
|
>
|
<view v-if="isAreaSelected(child.id)" class="checkbox-icon"></view>
|
</view>
|
<text class="text-[26rpx] font-medium truncate" :class="isAreaSelected(child.id) ? 'text-[#2F74FF]' : 'text-[#4A5568]'">{{ child.areaName }}</text>
|
</view>
|
|
<view v-if="activeAreaParent && activeAreaChildren.length === 0" class="h-[180rpx] flex items-center justify-center">
|
<text class="text-[#B3B3B3] text-[26rpx]">当前区域无子区域</text>
|
</view>
|
</scroll-view>
|
|
<view v-if="areaTree.length === 0" class="absolute inset-0 bg-white flex items-center justify-center">
|
<text class="text-[#B3B3B3] text-[28rpx]">暂无通行区域</text>
|
</view>
|
</view>
|
|
<!-- 已选区域统计 -->
|
<view class="px-[32rpx] py-[16rpx] border-t border-solid border-[#F0F0F0] shrink-0">
|
<view class="flex items-center justify-between">
|
<text class="text-[#333333] text-[28rpx]">已选择</text>
|
<text class="text-[#0068FF] text-[28rpx] font-medium">{{ selectedAreaIds.length }} 个区域</text>
|
</view>
|
</view>
|
|
<!-- 底部确认按钮 -->
|
<view class="px-[32rpx] pt-[12rpx] pb-[68rpx] bg-white shrink-0">
|
<view class="w-full h-[88rpx] rounded-[44rpx] bg-[#0068FF] text-white text-[32rpx] font-medium flex items-center justify-center shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] active:opacity-80 transition-opacity" @click="confirmRegionPicker">确认</view>
|
</view>
|
</view>
|
</wd-popup>
|
|
<!-- 属地单位选择器 -->
|
<wd-popup v-model="showUnitPicker" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0; z-index: 9999;">
|
<view class="w-full bg-white rounded-t-[24rpx] flex flex-col unit-picker-panel" :style="unitPickerPanelStyle">
|
<!-- 头部:关闭图标和标题 -->
|
<view class="flex items-center justify-between h-[108rpx] px-[32rpx] border-b border-solid border-[#F0F0F0] shrink-0">
|
<view class="w-[48rpx] h-[48rpx] flex items-center justify-center" @click="unitPopupOffsetY = 0; uni.offKeyboardHeightChange(handleUnitKeyboardHeightChange); showUnitPicker = false">
|
<image src="/static/review/close@2x.png" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
|
</view>
|
<text class="text-[#333333] text-[32rpx] font-medium flex-1 text-center pr-[48rpx]">选择属地单位</text>
|
</view>
|
|
<!-- 内容区域 -->
|
<view class="flex-1 flex flex-col px-[32rpx] overflow-hidden">
|
<!-- 输入行 -->
|
<view class="flex items-center justify-between h-[108rpx] border-b1 shrink-0 mb-[32rpx] relative">
|
<view class="flex items-center">
|
<text class="text-[#333333] text-[28rpx] font-medium">单位</text>
|
<text class="text-[#FF4D4F] text-[28rpx] ml-[4rpx]">*</text>
|
</view>
|
<input v-model="unitInput" type="text" placeholder="请输入单位名称" placeholder-class="text-[#CCCCCC] text-[28rpx]" class="flex-1 text-[28rpx] text-[#333333] ml-[32rpx]" :disabled="isHandlerReadOnly()" :adjust-position="true" :cursor-spacing="120" @input="handleUnitInput" @focus="handleUnitFocus" @blur="handleUnitBlur" />
|
<view class="flex items-center h-full pl-[24rpx] active:opacity-80" :class="isHandlerReadOnly() ? 'opacity-60' : ''" @click="addUnit">
|
<image src="/static/review/add.png" class="w-[28rpx] h-[28rpx] mr-[8rpx]" mode="aspectFit" />
|
<text class="text-[#0068FF] text-[28rpx]">添加</text>
|
</view>
|
|
<!-- 单位模糊搜索下拉框 -->
|
<view v-if="showUnitDropdown" class="absolute top-[108rpx] left-[80rpx] right-[120rpx] bg-white rounded-[8rpx] shadow-[0_4rpx_12rpx_rgba(0,0,0,0.1)] z-50 max-h-[300rpx] overflow-y-auto">
|
<view v-for="(unit, index) in filteredUnits" :key="index" class="flex items-center px-[24rpx] py-[20rpx] border-b border-solid border-[#F0F0F0] active:bg-[#F6F7F9]" @click.stop="selectUnit(unit)">
|
<text class="text-[#333333] text-[28rpx]">{{ unit }}</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 标签列表 -->
|
<scroll-view scroll-y class="flex-1 min-h-0">
|
<view class="flex flex-wrap gap-[20rpx] pb-[32rpx]">
|
<view v-for="(unit, index) in unitList" :key="index" class="flex items-center justify-center px-[28rpx] h-[64rpx] rounded-[32rpx] border border-solid border-[#E5E5E5] box-border">
|
<text class="text-[#CCCCCC] text-[28rpx]">{{ unit }}</text>
|
<view class="flex items-center justify-center w-[24rpx] h-[24rpx] ml-[12rpx] active:opacity-80" @click.stop="removeUnit(index)">
|
<!-- 叉号 -->
|
<image src="/static/review/close.png" class="w-[24rpx] h-[24rpx]" mode="aspectFit" />
|
</view>
|
</view>
|
</view>
|
</scroll-view>
|
</view>
|
|
<!-- 底部确认按钮 -->
|
<view class="px-[32rpx] pb-[68rpx] shrink-0">
|
<view class="w-full h-[88rpx] rounded-[44rpx] bg-[#0068FF] text-white text-[32rpx] font-medium flex items-center justify-center shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] active:opacity-80 transition-opacity" @click="confirmUnitSelection">确认</view>
|
</view>
|
</view>
|
</wd-popup>
|
|
<!-- 驳回弹窗 -->
|
<wd-popup v-model="showRejectPopup" custom-style="border-radius: 24rpx; background-color: transparent; overflow: hidden;">
|
<view class="reject-popup-container w-[590rpx] h-[468rpx] rounded-[24rpx] flex flex-col relative box-border overflow-hidden">
|
|
<!-- 标题 -->
|
<view class="text-center text-[#333333] text-[36rpx] font-medium mt-[40rpx] mb-[40rpx] relative z-10">提示</view>
|
|
<!-- 输入框区域 -->
|
<view class="px-[32rpx] flex-1 relative z-10 w-full box-border">
|
<view class="relative w-full h-[160rpx]">
|
<textarea v-model="rejectReason" placeholder="" class="w-full h-[160rpx] bg-[#F7F7F7] rounded-[8rpx] p-[24rpx] box-border text-[28rpx] text-[#333333]" :maxlength="30" @focus="rejectInputFocused = true" @blur="rejectInputFocused = false" />
|
<text v-if="!rejectReason && !rejectInputFocused" class="absolute left-[24rpx] top-[24rpx] text-[#3D3D3D] text-[28rpx] leading-[40rpx] pointer-events-none">请输入驳回原因</text>
|
</view>
|
</view>
|
|
<!-- 底部按钮 -->
|
<view class="flex items-center justify-between px-[32rpx] pb-[40rpx] mt-[32rpx] relative z-10">
|
<view class="w-[248rpx] h-[80rpx] rounded-[40rpx] bg-[#F6F7F9] flex items-center justify-center active:opacity-80" @click="cancelReject">
|
<text class="text-[#999999] text-[30rpx] font-medium">取消</text>
|
</view>
|
<view class="w-[248rpx] h-[80rpx] rounded-[40rpx] bg-[#0068FF] flex items-center justify-center shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] active:opacity-80" @click="confirmReject">
|
<text class="text-white text-[30rpx] font-medium">确认驳回</text>
|
</view>
|
</view>
|
</view>
|
</wd-popup>
|
|
</view>
|
</template>
|
|
<style scoped lang="scss">
|
.border-b1 {
|
border-bottom: 2rpx solid #F5F5F5;
|
}
|
|
.reject-popup-container {
|
background: linear-gradient(180deg, #D1E6FE 0%, #FFFFFF 33%, #FFFFFF 100%);
|
border: 4rpx solid transparent;
|
border-radius: 24rpx;
|
background-clip: padding-box;
|
}
|
|
.reject-popup-container::before {
|
content: '';
|
position: absolute;
|
top: -4rpx; right: -4rpx; bottom: -4rpx; left: -4rpx;
|
z-index: -1;
|
border-radius: 28rpx;
|
background: linear-gradient(180deg, #FFFFFF 0%, rgba(255, 255, 255, 0) 69%);
|
}
|
|
/* 复选框勾选图标 - 使用CSS绘制 */
|
.checkbox-icon {
|
width: 16rpx;
|
height: 10rpx;
|
border-left: 3rpx solid #FFFFFF;
|
border-bottom: 3rpx solid #FFFFFF;
|
transform: rotate(-45deg);
|
margin-top: -4rpx;
|
}
|
|
/* 箭头图标 - 使用CSS绘制 */
|
.arrow-icon {
|
width: 16rpx;
|
height: 16rpx;
|
border-top: 3rpx solid #666666;
|
border-right: 3rpx solid #666666;
|
transition: transform 0.3s ease;
|
}
|
|
.arrow-down {
|
transform: rotate(135deg);
|
margin-top: -6rpx;
|
}
|
|
.arrow-up {
|
transform: rotate(-45deg);
|
margin-bottom: -6rpx;
|
}
|
</style>
|
|
<style>
|
.custom-picker-view .wd-picker-view-column__item {
|
height: 96rpx !important;
|
line-height: 96rpx !important;
|
font-size: 32rpx !important;
|
color: #B3B3B3 !important;
|
background-color: transparent !important;
|
position: relative;
|
z-index: 10;
|
}
|
|
.custom-picker-view .wd-picker-view-column__item--active {
|
color: #0068FF !important;
|
font-weight: 500 !important;
|
background-color: transparent !important;
|
}
|
|
:deep(.region-picker-view .wx-picker-view-indicator) {
|
border-top: none !important;
|
border-bottom: none !important;
|
background-color: transparent !important;
|
border-radius: 8rpx !important;
|
z-index: 1 !important;
|
}
|
|
:deep(.region-picker-view .wx-picker-view-indicator::before),
|
:deep(.region-picker-view .wx-picker-view-indicator::after) {
|
display: none !important;
|
}
|
</style>
|