feat(oa): add mobile draft box list with edit submit delete

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-12 16:07:10 +08:00
1 parent 387e82426d
commit 6b0fb7b128
2 files changed
+627 -7

No files matched your search

+3 -1
View File
@@ -69,7 +69,9 @@
"style": {
"navigationBarTitleText": "草稿箱",
"navigationBarBackgroundColor": "#ecfdf5",
"navigationBarTextStyle": "black"
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
+624 -6
View File
@@ -1,12 +1,630 @@
<template>
<view class="page-shell" style="padding:24rpx">
<text>{{ title }}</text>
<view class="page">
<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-btn"
:class="{ 'is-active': manageMode }"
@click="toggleManage"
>
{{ manageMode ? '完成' : '管理' }}
</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" @click="batchDelete">批量删除</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-task-list__empty">暂无草稿</view>
<view v-else class="oa-task-list">
<view
v-for="row in list"
:key="row.id"
class="draft-row"
:class="{ 'is-manage': manageMode, 'is-selected': isSelected(row.id) }"
>
<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="draft-card-wrap">
<OaTaskCard
:title="cardTitle(row)"
status-text="草稿"
status-tone="warn"
:meta-lines="metaLines(row)"
:actions="manageMode ? [] : cardActions"
@open="onCardOpen(row)"
@action="onCardAction($event, row)"
/>
</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>
export default {
data() {
return { title: '草稿箱' }
import OaTaskCard from '../../../components/OaTaskCard.vue'
import {
fetchBatchDeleteOaInstanceDrafts,
fetchDeleteOaInstanceDraft,
fetchOaInstanceDraftCount,
fetchOaInstanceDraftList,
fetchSubmitOaInstanceDraft
} from '../../../utils/oa-api.js'
import { forceRelogin, getToken } from '../../../utils/auth.js'
import { stashOaStartTitle } from '../../../utils/oa-h5.js'
const PAGE_SIZE = 20
export default {
components: { OaTaskCard },
data() {
return {
keyword: '',
searchFocused: false,
list: [],
page: 1,
total: 0,
loading: false,
loadingMore: false,
hasMore: true,
manageMode: false,
selectedIds: [],
submittingId: '',
_searchTimer: null,
cardActions: [
{ key: 'edit', label: '编辑', primary: true },
{ key: 'submit', label: '提交审批' },
{ key: 'delete', label: '删除', danger: true }
]
}
},
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: {
cardTitle(row) {
return (row && (row.appName || row.title)) || '-'
},
metaLines(row) {
const ver =
row && row.versionNo != null && row.versionNo !== ''
? `v${row.versionNo}`
: '-'
return [
{ label: '最近保存', value: (row && row.updateTime) || '-' },
{ label: '版本', value: ver }
]
},
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])
}
},
onCardOpen(row) {
if (this.manageMode) {
this.toggleSelect(row && row.id)
return
}
this.openEdit(row)
},
onCardAction(act, row) {
if (!act || !row) return
if (act.key === 'edit') this.openEdit(row)
else if (act.key === 'submit') this.submitRow(row)
else if (act.key === 'delete') this.deleteRow(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 (_) {
/* 角标 UI 在 Task 11;此处仅预热 count API */
}
},
async submitRow(row) {
if (!row || !row.id || this.submittingId) return
const ok = await this.confirm(
'提交审批',
'确认提交该草稿并发起审批吗?',
'提交'
)
if (!ok) return
this.submittingId = row.id
try {
const data = await fetchSubmitOaInstanceDraft(row.id)
uni.showToast({ title: '已提交', icon: 'success' })
await this.loadList(true)
this.refreshDraftCount()
if (data && data.id) {
uni.navigateTo({
url:
'/pages/oa/webview/index?instanceId=' +
encodeURIComponent(data.id) +
'&mode=view'
})
}
} catch (e) {
uni.showToast({
title: (e && e.message) || '提交失败,请完善后重试',
icon: 'none'
})
this.openEdit(row)
} finally {
this.submittingId = ''
}
},
async deleteRow(row) {
if (!row || !row.id) return
const ok = await this.confirm(
'删除草稿',
'确认删除该草稿吗?删除后不可恢复。',
'删除'
)
if (!ok) return
try {
await fetchDeleteOaInstanceDraft(row.id)
uni.showToast({ title: '已删除', icon: 'success' })
await this.loadList(true)
this.refreshDraftCount()
} catch (e) {
uni.showToast({
title: (e && e.message) || '删除失败',
icon: 'none'
})
}
},
async batchDelete() {
const ids = (this.selectedIds || []).filter(Boolean)
if (!ids.length) return
const ok = await this.confirm(
'批量删除',
'确认删除选中的 ' + ids.length + ' 条草稿吗?',
'删除'
)
if (!ok) return
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'
})
}
}
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding-bottom: 48rpx;
background: #f5f6f8;
box-sizing: border-box;
}
.toolbar {
position: sticky;
top: 0;
z-index: 10;
background: #fff;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 24rpx 0;
box-sizing: border-box;
}
.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;
}
.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;
}
.manage-btn {
flex-shrink: 0;
min-width: 96rpx;
height: 72rpx;
padding: 0 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
color: #166534;
box-sizing: border-box;
}
.manage-btn.is-active {
color: #64748b;
}
.manage-btn:active {
opacity: 0.72;
}
.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-row {
display: flex;
align-items: stretch;
gap: 0;
}
.draft-row.is-manage .draft-card-wrap {
flex: 1;
min-width: 0;
}
.draft-row.is-selected .draft-card-wrap :deep(.oa-task-card) {
border-color: rgba(34, 197, 94, 0.45);
box-shadow: 0 8rpx 32rpx rgba(22, 163, 74, 0.12);
}
.draft-check {
display: flex;
align-items: flex-start;
padding: 40rpx 0 0 4rpx;
margin-right: 12rpx;
flex-shrink: 0;
}
.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;
}
.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>