Files
zkxw/mobile-h5/pages/oa/drafts/index.vue
2026-08-13 16:50:36 +08:00

832 lines
20 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="page-head" :style="{ paddingTop: statusBarPx + 'px' }">
<view class="nav__inner">
<view class="nav__back" @click="goBack">
<view class="nav__back-ico" aria-hidden="true" />
</view>
<text class="nav__title">草稿箱</text>
</view>
<view class="toolbar">
<view class="toolbar-row">
<view class="search-box" :class="{ 'is-focus': searchFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="keyword"
confirm-type="search"
placeholder="搜索流程名称"
placeholder-class="search-ph"
@input="onSearchInput"
@confirm="onSearchConfirm"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<view v-if="keyword" class="search-clear" @click="clearKeyword">
<text class="search-clear__x">×</text>
</view>
</view>
<view
class="manage-ico-btn"
:class="{ 'is-done': manageMode }"
@click="toggleManage"
>
<view
v-if="!manageMode"
class="manage-ico manage-ico--more"
aria-hidden="true"
>
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
</view>
<view
v-else
class="manage-ico manage-ico--check"
aria-hidden="true"
/>
</view>
</view>
</view>
</view>
<view v-if="manageMode && selectedIds.length" class="batch-bar">
<text class="batch-bar__hint">已选 {{ selectedIds.length }} 项</text>
<view
class="batch-bar__btn oa-btn-loading"
:class="{ 'is-loading': batchDeleting }"
@click="batchDelete"
>
<text class="oa-btn-loading__label">批量删除</text>
<view
v-if="batchDeleting"
class="oa-btn-loading__spin"
aria-hidden="true"
/>
</view>
</view>
<view v-if="loading && !list.length" class="state state-loading">
<view class="pulse-dots" aria-hidden="true">
<view class="pulse-dot" />
<view class="pulse-dot" />
<view class="pulse-dot" />
</view>
<text class="state-loading__text">加载草稿…</text>
</view>
<view v-else-if="!list.length" class="oa-empty-wrap">
<VanEmpty
description="暂无草稿"
tip="保存过的审批草稿会出现在这里"
/>
</view>
<view v-else class="draft-list">
<view
v-for="(row, idx) in list"
:key="row.id"
class="draft-row"
:class="{ 'is-manage': manageMode, 'is-selected': isSelected(row.id) }"
@click="onRowClick(row)"
>
<view v-if="manageMode" class="draft-check" @click.stop="toggleSelect(row.id)">
<view class="draft-check__box" :class="{ on: isSelected(row.id) }">
<text v-if="isSelected(row.id)" class="draft-check__mark">✓</text>
</view>
</view>
<view class="dd-card">
<view class="dd-card__corner" aria-hidden="true">
<text class="dd-card__corner-num">{{ cornerIndex(idx) }}</text>
</view>
<view class="dd-card__head">
<text class="dd-card__title">{{ cardTitle(row) }}</text>
<text class="dd-card__time">{{ formatRelativeTime(row.updateTime) }}</text>
</view>
<view v-if="metaLines(row).length" class="dd-card__meta">
<text
v-for="(line, idx) in metaLines(row)"
:key="idx"
class="dd-card__meta-line"
>
{{ line }}
</text>
</view>
<view class="dd-card__foot">
<view class="dd-card__user">
<text class="dd-card__user-text">待提交草稿</text>
</view>
<text class="dd-card__status">草稿</text>
</view>
</view>
</view>
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
</view>
</view>
</template>
<script>
import {
fetchBatchDeleteOaInstanceDrafts,
fetchOaInstanceDraftCount,
fetchOaInstanceDraftList
} from '../../../utils/oa-api.js'
import { forceRelogin, getToken } from '../../../utils/auth.js'
import { stashOaStartTitle } from '../../../utils/oa-h5.js'
import { formatCornerIndex } from '../../../utils/oa-corner-index.js'
import VanEmpty from '../../../components/van-empty/van-empty.vue'
const PAGE_SIZE = 10
function pad2(n) {
return n < 10 ? '0' + n : String(n)
}
function parseDateTime(str) {
if (!str) return null
const s = String(str).trim().replace(/-/g, '/')
const d = new Date(s)
return Number.isNaN(d.getTime()) ? null : d
}
export default {
components: { VanEmpty },
data() {
return {
statusBarPx: 0,
keyword: '',
searchFocused: false,
list: [],
page: 1,
total: 0,
loading: false,
loadingMore: false,
hasMore: true,
manageMode: false,
selectedIds: [],
batchDeleting: false,
_searchTimer: null
}
},
onLoad() {
try {
const sys = uni.getSystemInfoSync() || {}
this.statusBarPx = Number(sys.statusBarHeight) || 0
} catch (_) {
this.statusBarPx = 0
}
},
onShow() {
if (!getToken()) {
forceRelogin('请先登录')
return
}
this.loadList(true)
this.refreshDraftCount()
},
onPullDownRefresh() {
this.loadList(true)
.catch(() => {})
.finally(() => {
uni.stopPullDownRefresh()
})
},
onReachBottom() {
if (!this.hasMore || this.loading || this.loadingMore) return
this.loadList(false)
},
onUnload() {
if (this._searchTimer) {
clearTimeout(this._searchTimer)
this._searchTimer = null
}
},
methods: {
goBack() {
uni.navigateBack({
fail: () => {
uni.reLaunch({ url: '/pages/index/index' })
}
})
},
cornerIndex(idx) {
return formatCornerIndex(Number(idx) + 1)
},
cardTitle(row) {
return (row && (row.appName || row.title)) || '-'
},
metaLines(row) {
const lines = []
const fields = (row && row.previewFields) || []
for (let i = 0; i < fields.length && i < 3; i++) {
const f = fields[i] || {}
const label = String(f.label || '').trim() || '字段'
const value =
f.value != null && String(f.value).trim() !== ''
? String(f.value)
: '-'
lines.push(label + ':' + value)
}
if (row && row.updateTime) {
lines.push('最近保存:' + row.updateTime)
}
return lines
},
formatRelativeTime(str) {
const d = parseDateTime(str)
if (!d) return str || '-'
const now = new Date()
const startToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
)
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const dayDiff = Math.round(
(startToday.getTime() - startThat.getTime()) / 86400000
)
if (dayDiff === 0) {
return pad2(d.getHours()) + ':' + pad2(d.getMinutes())
}
if (dayDiff === 1) return '昨天'
if (dayDiff === 2) return '前天'
if (dayDiff > 2 && dayDiff < 7) return dayDiff + '天前'
return (
d.getFullYear() +
'.' +
pad2(d.getMonth() + 1) +
'.' +
pad2(d.getDate())
)
},
isSelected(id) {
return this.selectedIds.indexOf(id) >= 0
},
onSearchInput(e) {
const v =
e && e.detail && e.detail.value != null
? String(e.detail.value)
: e && e.target
? String(e.target.value || '')
: ''
this.keyword = v
if (this._searchTimer) clearTimeout(this._searchTimer)
this._searchTimer = setTimeout(() => {
this.loadList(true)
}, 400)
},
onSearchConfirm() {
if (this._searchTimer) {
clearTimeout(this._searchTimer)
this._searchTimer = null
}
this.loadList(true)
},
clearKeyword() {
this.keyword = ''
if (this._searchTimer) {
clearTimeout(this._searchTimer)
this._searchTimer = null
}
this.loadList(true)
},
toggleManage() {
this.manageMode = !this.manageMode
if (!this.manageMode) this.selectedIds = []
},
toggleSelect(id) {
if (!id) return
const i = this.selectedIds.indexOf(id)
if (i >= 0) {
this.selectedIds = this.selectedIds.filter((x) => x !== id)
} else {
this.selectedIds = this.selectedIds.concat([id])
}
},
onRowClick(row) {
if (this.manageMode) {
this.toggleSelect(row && row.id)
return
}
this.openEdit(row)
},
openEdit(row) {
if (!row || !row.id) return
stashOaStartTitle(row.appName || row.title)
uni.navigateTo({
url:
'/pages/oa/webview/index?draftId=' +
encodeURIComponent(row.id) +
'&appId=' +
encodeURIComponent(row.appId || '')
})
},
confirm(title, content, confirmText) {
return new Promise((resolve) => {
uni.showModal({
title: title || '提示',
content: content || '',
confirmText: confirmText || '确定',
cancelText: '取消',
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
})
})
},
async loadList(reset) {
if (!getToken()) {
forceRelogin('请先登录')
return
}
const isReset = !!reset
if (isReset) {
this.page = 1
this.hasMore = true
this.loading = true
this.selectedIds = []
} else {
this.loadingMore = true
}
try {
const res = await fetchOaInstanceDraftList({
keyword: (this.keyword || '').trim() || undefined,
page: this.page,
pageSize: PAGE_SIZE
})
const rows = (res && res.list) || []
const total = (res && res.total) != null ? Number(res.total) : 0
this.total = total
if (isReset) this.list = rows
else this.list = this.list.concat(rows)
const loaded = this.list.length
this.hasMore = loaded < total && rows.length > 0
if (rows.length > 0) this.page += 1
} catch (e) {
if (isReset) this.list = []
uni.showToast({
title: (e && e.message) || '加载失败',
icon: 'none'
})
} finally {
this.loading = false
this.loadingMore = false
}
},
async refreshDraftCount() {
try {
await fetchOaInstanceDraftCount()
} catch (_) {
/* 角标预热 */
}
},
async batchDelete() {
const ids = (this.selectedIds || []).filter(Boolean)
if (!ids.length || this.batchDeleting) return
const ok = await this.confirm(
'批量删除',
'确认删除选中的 ' + ids.length + ' 条草稿吗?',
'删除'
)
if (!ok) return
this.batchDeleting = true
try {
await fetchBatchDeleteOaInstanceDrafts(ids)
uni.showToast({ title: '已删除', icon: 'success' })
this.selectedIds = []
this.manageMode = false
await this.loadList(true)
this.refreshDraftCount()
} catch (e) {
uni.showToast({
title: (e && e.message) || '批量删除失败',
icon: 'none'
})
} finally {
this.batchDeleting = false
}
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding-bottom: 48rpx;
background: #f5f6f8;
box-sizing: border-box;
}
.page-head {
position: sticky;
top: 0;
z-index: 20;
background: #ecfdf5;
border-bottom: 1rpx solid #e8eeeb;
}
.nav__inner {
height: 88rpx;
display: flex;
align-items: center;
padding: 0 16rpx 0 8rpx;
position: relative;
box-sizing: border-box;
background: #ecfdf5;
}
.nav__back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.nav__back-ico {
width: 20rpx;
height: 20rpx;
border-left: 4rpx solid #0f291e;
border-bottom: 4rpx solid #0f291e;
transform: rotate(45deg);
margin-left: 8rpx;
box-sizing: border-box;
}
.nav__title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 32rpx;
font-weight: 650;
color: #0f291e;
}
.toolbar {
background: #fff;
padding: 16rpx 24rpx;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.search-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16rpx;
height: 72rpx;
padding: 0 24rpx;
border-radius: 20rpx;
border: 1rpx solid #e8eaed;
background: #fafafa;
box-sizing: border-box;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.manage-ico-btn {
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
border: 1rpx solid #c6f0dc;
background: #ecfdf5;
color: #166534;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.manage-ico-btn.is-done {
border-color: #e2e8f0;
background: #f8fafc;
color: #64748b;
}
.manage-ico-btn:active {
opacity: 0.72;
}
.manage-ico--more {
width: 34rpx;
height: 10rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.manage-ico__dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.manage-ico--check {
width: 20rpx;
height: 12rpx;
border-left: 4rpx solid currentColor;
border-bottom: 4rpx solid currentColor;
transform: rotate(-45deg);
margin-top: -4rpx;
box-sizing: border-box;
}
.search-box.is-focus {
border-color: #cbd5e1;
background: #fff;
box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
}
.search-ico {
position: relative;
width: 26rpx;
height: 26rpx;
border: 3rpx solid #94a3b8;
border-radius: 50%;
box-sizing: border-box;
flex-shrink: 0;
}
.search-ico::after {
content: '';
position: absolute;
width: 12rpx;
height: 3rpx;
right: -8rpx;
bottom: -5rpx;
background: #94a3b8;
border-radius: 3rpx;
transform: rotate(45deg);
}
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #1f2937;
min-width: 0;
}
.search-ph {
color: #b0b7c3;
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: #e5e7eb;
flex-shrink: 0;
}
.search-clear__x {
font-size: 28rpx;
line-height: 1;
color: #6b7280;
margin-top: -2rpx;
}
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin: 16rpx 24rpx 0;
padding: 20rpx 24rpx;
border-radius: 16rpx;
background: #fff;
border: 1rpx solid #d8f3e5;
box-sizing: border-box;
}
.batch-bar__hint {
font-size: 26rpx;
color: #5f7a6d;
}
.batch-bar__btn {
min-height: 64rpx;
padding: 0 28rpx;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14rpx;
font-size: 26rpx;
font-weight: 600;
color: #ef4444;
background: rgba(239, 68, 68, 0.06);
border: 1rpx solid rgba(239, 68, 68, 0.35);
box-sizing: border-box;
}
.batch-bar__btn:active {
opacity: 0.78;
}
.draft-list {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
box-sizing: border-box;
}
.draft-row {
display: flex;
align-items: center;
gap: 16rpx;
width: 100%;
box-sizing: border-box;
}
.draft-check {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.draft-check__box {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #cbd5e1;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.draft-check__box.on {
border-color: #22c55e;
background: #22c55e;
}
.draft-check__mark {
font-size: 22rpx;
line-height: 1;
color: #fff;
font-weight: 700;
}
.dd-card {
flex: 1;
min-width: 0;
width: 100%;
background: #fff;
border-radius: 20rpx;
padding: 28rpx 28rpx 24rpx 84rpx;
box-sizing: border-box;
box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.04);
}
.draft-row.is-selected .dd-card {
box-shadow: 0 8rpx 28rpx rgba(22, 163, 74, 0.12);
outline: 2rpx solid rgba(34, 197, 94, 0.4);
}
.dd-card__head {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.dd-card__title {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 700;
line-height: 1.4;
color: #1f2329;
}
.dd-card__time {
flex-shrink: 0;
font-size: 24rpx;
line-height: 1.4;
color: #8f959e;
padding-top: 4rpx;
}
.dd-card__meta {
margin-top: 16rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.dd-card__meta-line {
font-size: 26rpx;
line-height: 1.45;
color: #8f959e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dd-card__foot {
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.dd-card__user {
display: flex;
align-items: center;
min-width: 0;
}
.dd-card__user-text {
font-size: 24rpx;
color: #646a73;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dd-card__status {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 600;
color: #d97706;
}
.state {
padding: 80rpx 24rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
.state-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28rpx;
min-height: 50vh;
padding: 96rpx 32rpx;
box-sizing: border-box;
}
.pulse-dots {
display: flex;
align-items: center;
gap: 16rpx;
}
.pulse-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: var(--brand-green, #22c55e);
animation: oa-pulse-bounce 1.2s ease-in-out infinite;
}
.pulse-dot:nth-child(2) {
animation-delay: 0.15s;
opacity: 0.75;
}
.pulse-dot:nth-child(3) {
animation-delay: 0.3s;
opacity: 0.55;
}
.state-loading__text {
font-size: 26rpx;
font-weight: 500;
color: var(--text-muted, #5f7a6d);
line-height: 1.4;
}
@keyframes oa-pulse-bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-12rpx);
}
}
</style>