手机端协会入会增加审核、双端待办优化,协会入会申请增加双端待办,并给会长、秘书发送企业微信消息,这两个角色同时拥有审核权限

This commit is contained in:
2026-10-21 16:36:50 +08:00
34 changed files with 3437 additions and 1045 deletions
@@ -203,7 +203,7 @@ layout("/mobile/platform.html"){
></van-empty>
</div>
<van-popup v-model="infoShow" :close-on-click-overlay="false" position="right" :style="{width: '100%', height: '100%' }">
<van-popup :value="infoShow" :close-on-popstate="false" :close-on-click-overlay="false" position="right" :style="{width: '100%', height: '100%' }">
<van-nav-bar
title="详细信息"
left-arrow
@@ -243,7 +243,9 @@ layout("/mobile/platform.html"){
<div class="mb10">审核签字</div>
</template>
<template #input>
<mobile-sign :path-mode="true" ref="signature" v-model="formData.auditSign" @hook:mounted="bindSignatureHistory"></mobile-sign>
<mobile-sign :path-mode="true" ref="signature" v-model="formData.auditSign"
:popup-visible="signatureShow" :disabled="formLoading"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
@@ -280,42 +282,35 @@ layout("/mobile/platform.html"){
radioType: 3
},
infoShow: false,
signatureShow: false,
formData: {},
}
},
components: {
'club-user-apply-info': httpVueLoader('/components/mobile/club/mobileClubUserApplyInfo.vue?v=1.0.4'),
'mobile-sign': httpVueLoader('/components/sign/mobileSign.vue?v=1.0.1'),
'mobile-sign': httpVueLoader('/components/sign/mobileSign.vue?v=1.0.2'),
},
methods: {
// 使用项目统一历史栈;先关闭签字板,再关闭申请详情。
syncPopup(key, visible) {
if (key === 'info') this.$set(this, 'infoShow', visible)
if (key === 'signature' && this.$refs.signature) {
this.$set(this.$refs.signature, 'showSignaturePanel', visible)
}
// 使用远端统一历史会话;签字层和详情层由同一回调驱动,避免重复压栈。
syncPopup(layers) {
this.$set(this, 'infoShow', layers.includes('info'))
this.$set(this, 'signatureShow', layers.includes('signature'))
},
closePopup() {
return mobilePopupHistory.close()
return this.$options.popupSession.close()
},
openSignature() {
this.$options.popupSession.open('signature')
},
leavePage() {
if (mobilePopupHistory.stack.length) return this.closePopup()
if (this.infoShow || this.signatureShow) return this.closePopup()
this.disposePopup()
pjaxReplace('/mobile/index')
},
disposePopup() {
if (this.stopSignatureWatch) this.stopSignatureWatch()
mobilePopupHistory.unregister(this.syncPopup)
if (this.$options.popupSession) this.$options.popupSession.dispose()
$(document).off('pjax:beforeSend.clubAudit')
},
// 仅在本页适配现有签字组件,不修改其他业务共用的签字实现。
bindSignatureHistory() {
if (this.stopSignatureWatch) this.stopSignatureWatch()
const signature = this.$refs.signature
this.stopSignatureWatch = signature.$watch('showSignaturePanel', visible => {
if (visible) mobilePopupHistory.open('signature')
else if (mobilePopupHistory.stack.includes('signature')) this.closePopup()
})
$(document).off('pjax:beforeReplace.clubAudit')
},
openAudit(row) {
this.$set(this, 'formData', {
@@ -327,12 +322,12 @@ layout("/mobile/platform.html"){
})
this.$set(this, 'applyId', row.id)
this.$set(this, 'handle', true)
mobilePopupHistory.open('info')
this.$options.popupSession.open('info')
},
openView(row) {
this.$set(this, 'applyId', row.id)
this.$set(this, 'handle', false)
mobilePopupHistory.open('info')
this.$options.popupSession.open('info')
},
// id 为申请记录,flag 为是否通过;响应 code 为 0 时清理弹框历史并刷新列表。
doReview(flag) {
@@ -348,7 +343,7 @@ layout("/mobile/platform.html"){
this.$set(this, 'formLoading', true)
const params = Object.assign({}, this.formData, {flag})
$.post('/platform/sys/club/sh/check', params).then(resp => {
if (resp.code === 0) mobilePopupHistory.clear().then(() => this.doSearch())
if (resp.code === 0) this.$options.popupSession.clear().then(() => this.doSearch())
vant.Toast(resp.msg)
}).fail(() => vant.Toast('审核提交失败,请重试'))
.always(() => { this.$set(this, 'formLoading', false) })
@@ -377,12 +372,12 @@ layout("/mobile/platform.html"){
},
},
created() {
mobilePopupHistory.register(this.syncPopup)
this.$options.popupSession = window.mobilePopupHistory.register('clubAudit', this.syncPopup)
// PJAX 离开前清理本页历史,再执行原跳转,防止返回到已关闭弹框。
$(document).on('pjax:beforeSend.clubAudit', (event, xhr, options) => {
if (mobilePopupHistory.stack.length) {
if (this.infoShow || this.signatureShow) {
event.preventDefault()
mobilePopupHistory.clear().then(() => {
this.$options.popupSession.clear().then(() => {
this.disposePopup()
pjaxReplace(options.url)
})
@@ -390,6 +385,8 @@ layout("/mobile/platform.html"){
}
this.disposePopup()
})
// PJAX 不会自动销毁旧页面实例,替换容器前释放本页会话及事件。
$(document).one('pjax:beforeReplace.clubAudit', () => this.$destroy())
this.getMyMangeClub().then(data => {
this.$set(this, 'clubOptions', [{text: '全部社团'}].concat(
data.map(item => ({text: item.name, value: item.id}))))
@@ -407,7 +404,12 @@ layout("/mobile/platform.html"){
this.onLoad()
},
beforeDestroy() {
mobilePopupHistory.clear().then(() => this.disposePopup())
// 空栈同步注销,防止下一页注册时旧会话尚未释放。
if (this.infoShow || this.signatureShow) {
this.$options.popupSession.clear().then(() => this.disposePopup())
} else {
this.disposePopup()
}
}
})
</script>
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/activitybx/review/index.html'){}#-->
<script>
// 节点配置由当前页面固定提供,业务权限及状态仍由服务端校验。
const activityReviewConfig = {
stage: 'accounting', title: '校工会负责人审核', state: 1020,
confirmMessage: '通过后将转交校工会主席审核。',
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/activitybx/review/info.js'){}#-->
<!--#include('/mobile/jf/activitybx/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,135 @@
<!--#
layout("/mobile/platform.html"){
#-->
<style>
.association-page { min-height: 100vh; background: #f6f7f9; color: #323233; font-size: 13px; }
.association-page .van-nav-bar__title { font-size: 15px; font-weight: 400; }
.association-filters .van-dropdown-menu__bar { box-shadow: none; border-top: 1px solid #f8f8f8; border-bottom: 1px solid #f8f8f8; }
.association-filters .van-dropdown-menu__item { flex: 1; min-width: 0; }
.association-filters .van-dropdown-menu__title { max-width: calc(100% - 40px); padding: 0 8px; font-size: 14px; }
.association-filters .van-dropdown-item__content { max-height: 70vh; border-radius: 0 0 15px 15px; }
.association-list { padding: 2px 0 12px; }
.association-card { margin: 12px 13px 0; padding: 11px 12px 10px; background: white; border-radius: 10px; box-shadow: 0 5px 12px rgba(30,50,70,.04); }
.association-card h3 { position: relative; margin: 0 0 7px; color: #086bc3; font-size: 15px; line-height: 21px; font-weight: 600; overflow-wrap: anywhere; }
.association-card h3:before { content: ''; position: absolute; left: -10px; top: 3px; height: 15px; border-left: 2px solid #1989fa; }
.association-card p { margin: 0; line-height: 26px; color: #85878d; overflow-wrap: anywhere; }
.association-card label { color: #242424; }
.association-card .association-pair { display: flex; gap: 10px; justify-content: space-between; }
.association-pair span:last-child { flex-shrink: 0; }
.association-card-footer { display: flex; align-items: center; gap: 8px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #f5f5f5; }
.association-state { flex: 1; min-width: 0; font-size: 12px; color: #b7790c; overflow-wrap: anywhere; }
.association-card-footer .van-button { height: 25px; padding: 0 8px; font-size: 13px; border-radius: 6px; flex-shrink: 0; }
.association-popup { display: flex; flex-direction: column; overflow: hidden; background: #f6f7f9; height: 100%; width: 100%; }
.association-popup > .van-nav-bar { flex-shrink: 0; }
.association-detail-body { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 12px; }
.association-section { margin: 12px; padding: 10px 0; border-radius: 10px; background: white; overflow: hidden; }
.association-section h3 { margin: 4px 14px 8px; font-size: 15px; color: #1768b0; }
.association-section .van-cell { font-size: 13px; padding: 8px 14px; }
.association-section .van-cell__title { flex: 0 0 88px; }
.association-section .van-cell__value { white-space: pre-wrap; overflow-wrap: anywhere; }
.association-section .association-file .van-cell__title { flex: 1; overflow-wrap: anywhere; }
.association-section .association-file .van-cell__value { flex: 0 0 40px; }
.association-content-image { display: block; max-width: calc(100% - 28px); margin: 10px 14px; }
.association-description { padding: 0 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.association-muted { margin: 10px 14px; color: #969799; }
.association-total .van-cell__value { color: #086bc3; font-weight: 600; }
.association-sign-image { display: block; max-width: calc(100% - 28px); width: 240px; height: 100px; object-fit: contain; margin: 8px 14px; }
.association-actions { flex-shrink: 0; display: flex; gap: 12px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: white; border-top: 1px solid #eee; z-index: 2; }
.association-actions .van-button { flex: 1; border-radius: 6px; height: 40px; }
.association-section .association-sign-field { display: block; }
.association-sign-field .van-field__label { width: auto; margin: 0 0 10px; }
.association-sign-field .main-panel .van-image { width: 100%; height: 112px; min-height: 112px; overflow: hidden; border-radius: 6px; }
.association-sign-field .main-panel .open-button { width: 100%; border-radius: 6px; }
.association-preview { display: block; width: 100%; height: calc(100% - 46px); border: none; background: white; }
.association-preview-image { width: 100%; height: calc(100% - 46px); object-fit: contain; }
.association-loading { padding: 30px; text-align: center; }
</style>
<div id="app" class="association-page" v-cloak>
<van-sticky>
<van-nav-bar title="协会活动审核" left-arrow @click-left="back"></van-nav-bar>
<van-dropdown-menu class="association-filters" active-color="#1867b0"
:close-on-click-outside="false" :close-on-click-overlay="false">
<van-dropdown-item ref="yearFilter" :options="yearOptions" :value="pageForm.year" :disabled="filterClosing"
@change="selectFilter('year', $event)"
@open="setFilterOpen('year', true)" @close="setFilterOpen('year', false)"></van-dropdown-item>
<van-dropdown-item ref="clubIdFilter" :options="clubs" :value="pageForm.clubId" :disabled="filterClosing"
@change="selectFilter('clubId', $event)"
@open="setFilterOpen('clubId', true)" @close="setFilterOpen('clubId', false)">
<van-loading v-if="clubsLoading" class="association-loading">加载协会中</van-loading>
</van-dropdown-item>
<van-dropdown-item ref="isAuditFilter" :options="auditOptions" :value="pageForm.isAudit" :disabled="filterClosing"
@change="selectFilter('isAudit', $event)"
@open="setFilterOpen('isAudit', true)" @close="setFilterOpen('isAudit', false)"></van-dropdown-item>
</van-dropdown-menu>
</van-sticky>
<van-pull-refresh :value="refreshing" @input="setRefreshing" @refresh="doSearch">
<van-list :value="tableLoading" :finished="finished" :error="listError" error-text="加载失败,点击重试"
:finished-text="tableData.length ? '没有更多了' : ''" :immediate-check="false"
@input="setTableLoading" @update:error="setListError" @load="pageData" class="association-list">
<article v-for="item in tableData" :key="item.id" class="association-card">
<h3>{{ item.activity_name }}</h3>
<p><label>申请人:</label>{{ item.user_name }}<span v-if="item.bxr_loginname">({{ item.bxr_loginname }})</span></p>
<p><label>所属协会:</label>{{ item.clubName || item.fundsUnitName || '—' }}</p>
<p class="association-pair"><span><label>预算:</label>¥{{ money(item.funds_money) }}</span><span><label>申请:</label>{{ shortDate(item.apply_time) }}</span></p>
<div class="association-card-footer">
<span class="association-state" :style="{color: item.state_color || '#b7790c'}">{{ item.state_name || '待协会负责人审核' }}</span>
<van-button type="info" size="small" :disabled="formLoading" @click="openDetail(item.id, false)">查看</van-button>
<van-button v-if="Number(item.state_id) === 1000" type="primary" size="small" :disabled="formLoading" @click="openDetail(item.id, true)">审核</van-button>
</div>
</article>
<van-empty v-if="!tableLoading && !listError && !tableData.length" description="暂无申请"></van-empty>
</van-list>
</van-pull-refresh>
<van-popup :value="popups.detail" position="right" class="association-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="auditMode ? '协会负责人审核' : '申请详情'" left-arrow @click-left="closePopup"></van-nav-bar>
<div class="association-detail-body">
<van-loading v-if="detailLoading" class="association-loading">加载申请中</van-loading>
<van-empty v-else-if="!detail.id" description="详情加载失败">
<van-button size="small" type="info" @click="loadDetail(detailId)">重新加载</van-button>
</van-empty>
<association-info v-else :data="detail" @preview="previewFile">
<section v-if="auditMode && detail.canReview" class="association-section">
<h3>审核信息</h3>
<van-field label="审核人" :value="reviewerName" readonly></van-field>
<van-field label="审核时间" :value="reviewDate" readonly></van-field>
<van-field label="审核意见" :value="formData.idea" @input="setIdea" type="textarea" rows="3"
maxlength="500" show-word-limit required placeholder="请填写审核意见" :disabled="formLoading"></van-field>
<van-field label="审核签字" required class="association-sign-field">
<template #input>
<mobile-sign :value="formData.sign" @input="setSignature" :disabled="formLoading"
:save-to-profile="false" :popup-visible="popups.sign" panel-title="审核签字"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
</section>
</association-info>
</div>
<div v-if="detail.id && !detailLoading" class="association-actions">
<template v-if="auditMode && detail.canReview">
<van-button type="danger" plain :disabled="formLoading" @click="confirmReview(false)">拒绝</van-button>
<van-button type="primary" :loading="formLoading" :disabled="formLoading" @click="confirmReview(true)">通过</van-button>
</template>
<van-button v-else-if="detail.canReview" type="primary" @click="startAudit">去审核</van-button>
<van-button v-else @click="closePopup">关闭</van-button>
</div>
</van-popup>
<van-dialog :value="popups.confirm" :title="reviewFlag ? '确认通过' : '确认拒绝'"
:message="reviewFlag ? '通过后将转交校工会负责人审核。' : '拒绝后将通知申请人,请核对审核意见。'"
show-cancel-button :allow-html="false" :close-on-click-overlay="false" :close-on-popstate="false"
:before-close="beforeReviewClose"></van-dialog>
<van-popup :value="popups.preview" position="right" class="association-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar title="附件查看" left-arrow @click-left="closePopup"></van-nav-bar>
<img v-if="popups.preview && previewIsImage" :src="previewUrl" class="association-preview-image" alt="申请附件">
<iframe v-else-if="popups.preview" :src="previewUrl" title="申请附件" class="association-preview"></iframe>
</van-popup>
</div>
<script>
<!--#include('info.js'){}#-->
<!--#include('page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,104 @@
/** 协会活动申报详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
const associationInfo = {
template: /*language=HTML*/ `
<div class="association-info">
<section class="association-section">
<h3>{{ data.activity_name }}</h3>
<van-cell title="当前状态" :value="data.state_name"></van-cell>
<van-cell title="申请人" :value="data.user_name + '(' + (data.loginname || '—') + ')'"></van-cell>
<van-cell title="联系方式" :value="data.mobile || '—'"></van-cell>
<van-cell title="申请时间" :value="data.apply_time || '—'"></van-cell>
<van-cell title="承办协会" :value="data.clubName || data.fundsUnitName || '—'"></van-cell>
<van-cell title="项目编号" :value="data.projectCode || '—'"></van-cell>
<van-cell title="经费类型" :value="data.projectJfName || '—'"></van-cell>
<van-cell title="计划时间" :value="(data.startPlannedDate || '—') + ' 至 ' + (data.endPlannedDate || '—')"></van-cell>
<van-cell title="活动地点" :value="data.address || '—'"></van-cell>
<van-cell title="活动人数" :value="data.activity_number || '—'"></van-cell>
</section>
<section class="association-section">
<h3>活动方案及简介</h3>
<div class="association-description">{{ activityText || '暂无' }}</div>
<img v-for="(url, index) in activityImages" :key="index" :src="url" class="association-content-image"
alt="活动方案配图" @click="$emit('preview', {imageUrl: url})">
</section>
<section class="association-section">
<h3>预算明细</h3>
<van-cell v-for="(item, index) in budgets" :key="index" :title="item.name || '费用项目'"
:value="'¥' + money(item.budgetPrice)"></van-cell>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="association-total"></van-cell>
</section>
<section class="association-section">
<h3>申请附件</h3>
<van-cell v-for="(file, index) in files" :key="file.id || index" :title="file.filename || file.name || '附件'" class="association-file"
value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!files.length" class="association-muted">暂无附件</p>
<h3>申请人签字</h3>
<img v-if="data.us_s" class="association-sign-image" :src="signUrl(data.us_s)" alt="申请人签字">
<p v-else class="association-muted">暂无签字</p>
</section>
<section v-for="audit in audits" :key="audit.key" class="association-section">
<h3>{{ audit.title }}</h3>
<van-cell title="审核人" :value="audit.name || '—'"></van-cell>
<van-cell title="审核时间" :value="audit.time || '—'"></van-cell>
<van-cell title="审核意见" :value="audit.idea || '暂无'"></van-cell>
<img v-if="audit.sign" class="association-sign-image" :src="signUrl(audit.sign)" :alt="audit.title + '签字'">
</section>
<slot></slot>
</div>
`,
props: {
data: {type: Object, default: () => ({})}
},
computed: {
budgets() {
return this.asList(this.data.budgets)
},
files() {
return this.asList(this.data.files)
},
activityText() {
// 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。
const container = document.createElement('div')
container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n')
container.querySelectorAll('script,style').forEach((node) => node.remove())
return container.textContent.trim()
},
activityImages() {
// 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。
const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html')
return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '')
.filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url))
},
audits() {
const data = this.data
return [
{key: 'association', title: '协会负责人审核', id: data.association_principal_id,
name: data.ap_username, time: data.association_principal_time, idea: data.association_principal_idea, sign: data.ap_s},
{key: 'accounting', title: '校工会负责人审核', id: data.accounting_name,
name: data.a_username, time: data.accounting_time, idea: data.accounting_idea, sign: data.a_s},
{key: 'union', title: '校工会主席审核', id: data.union_chairman_name,
name: data.ucc_username, time: data.union_chairman_time, idea: data.union_chairman_idea, sign: data.ucc_s}
].filter((audit) => audit.id)
}
},
methods: {
/** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */
asList(value) {
if (Array.isArray(value)) return value
if (!value) return []
try {
const list = JSON.parse(value)
return Array.isArray(list) ? list : []
} catch (error) {
return []
}
},
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
/** 签字兼容 PC 保存的 Base64 图片和历史文件路径。 */
signUrl(value) {
return value && value.startsWith('data:image/') ? value : '/signature/getSignData?path=' + encodeURIComponent(value || '')
}
}
}
@@ -0,0 +1,272 @@
// 独立手机审核页面;不复用旧 declare 页面及其有缺陷的滚动分页。
const vue = new Vue({
el: '#app',
components: {'association-info': associationInfo},
data() {
return {
baseUrl: '/platform/jf/activity_bx/association',
pageForm: {year: new Date().getFullYear(), clubId: '', isAudit: 3, pageNumber: 1, pageSize: 10},
tableData: [], tableLoading: false, refreshing: false, finished: false, listError: false,
listSerial: 0, listBusy: false, detailSerial: 0,
clubs: [{text: '全部协会', value: ''}], clubsLoading: false,
auditOptions: [{text: '未审核', value: 3}, {text: '已审核', value: 2}, {text: '全部', value: 1}],
filterOpen: {year: false, clubId: false, isAudit: false}, filterClosing: false,
popups: {filter: false, detail: false, sign: false, confirm: false, preview: false},
detail: {}, detailId: '', detailLoading: false, auditMode: false,
formData: {id: '', idea: '', sign: ''}, formLoading: false, reviewFlag: true,
reviewerName: '', reviewDate: '', previewUrl: '', previewIsImage: false
}
},
computed: {
yearOptions() {
return Array.from({length: 11}, (_, index) => {
const year = new Date().getFullYear() - index
return {text: year + '年', value: year}
})
}
},
methods: {
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
shortDate(value) {
return value ? String(value).slice(5, 10) : '—'
},
setRefreshing(value) { this.refreshing = value },
setTableLoading(value) { this.tableLoading = value },
setListError(value) { this.listError = value },
setIdea(value) { this.$set(this.formData, 'idea', value) },
/** 筛选切换或提交后,从第一页替换数据;旧请求晚到时不能混入当前筛选结果。 */
doSearch() {
this.listSerial++
this.listBusy = false
this.$set(this.pageForm, 'pageNumber', 1)
this.tableData = []
this.finished = false
this.listError = false
this.pageData()
},
/** 列表返回 data.list 和 totalCount;失败保留页码供重试,成功后才推进下一页。 */
pageData() {
if (this.listBusy || this.finished) return
const serial = this.listSerial
this.listBusy = true
this.tableLoading = true
this.listError = false
$.post(this.baseUrl + '/pageData', Object.assign({}, this.pageForm))
.then((res) => {
if (serial !== this.listSerial || this._isDestroyed) return
if (res.code !== 0 || !res.data) {
this.listError = true
this.$toast(res.msg || '列表加载失败')
return
}
const list = res.data.list || []
this.tableData = this.tableData.concat(list)
this.finished = !list.length || this.tableData.length >= Number(res.data.totalCount)
this.$set(this.pageForm, 'pageNumber', this.pageForm.pageNumber + 1)
})
.fail(() => {
if (serial === this.listSerial && !this._isDestroyed) this.listError = true
})
.always(() => {
if (serial !== this.listSerial || this._isDestroyed) return
this.listBusy = false
this.tableLoading = false
this.refreshing = false
})
},
loadClubs() {
this.clubsLoading = true
$.post(this.baseUrl + '/clubs')
.then((res) => {
if (res.code === 0) {
this.clubs = [{text: '全部协会', value: ''}].concat((res.data || []).map((club) => ({text: club.name, value: club.id})))
} else {
this.$toast(res.msg || '协会加载失败')
}
})
.fail(() => this.$toast('协会加载失败,请重新打开筛选'))
.always(() => { this.clubsLoading = false })
},
/** key 为年度/协会/状态字段,open 为菜单展开状态;三个菜单切换时共用一层历史。 */
setFilterOpen(key, open) {
this.$set(this.filterOpen, key, open)
if (open && key === 'clubId' && this.clubs.length === 1 && !this.clubsLoading) this.loadClubs()
// Vant 切换菜单会同时触发旧菜单 close 和新菜单 open,等本轮更新结束后统一判断。
this.$nextTick(() => {
if (this._isDestroyed || this.filterClosing) return
if (Object.values(this.filterOpen).some(Boolean)) {
if (!this.popups.filter) this.$options.popupSession.open('filter')
} else if (this.popups.filter) {
this.filterClosing = true
this.$options.popupSession.close().then(() => { this.filterClosing = false })
}
})
},
/** key 为筛选字段,value 为对应选项值;仅值改变时刷新,菜单收起事件负责回退历史。 */
selectFilter(key, value) {
this.$set(this.pageForm, key, value)
this.doSearch()
},
/** 每次打开申请清空上次的意见和签字,防止在不同申请之间串用审核数据。 */
openDetail(id, audit) {
if (this.formLoading) return
this.detailId = id
this.detail = {}
this.auditMode = audit
this.formData = {id, idea: '', sign: ''}
this.reviewDate = moment().format('YYYY-MM-DD')
this.$options.popupSession.open('detail')
this.loadDetail(id)
},
loadDetail(id) {
const serial = ++this.detailSerial
this.detailLoading = true
$.post(this.baseUrl + '/findOne', {id})
.then((res) => {
if (serial !== this.detailSerial || !this.popups.detail || this._isDestroyed) return
if (res.code === 0 && res.data) {
this.detail = res.data
if (!this.detail.canReview) this.auditMode = false
} else {
this.$toast(res.msg || '详情加载失败')
}
})
.fail(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.$toast('详情加载失败,请重试')
})
.always(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.detailLoading = false
})
},
startAudit() {
this.auditMode = true
this.$nextTick(() => {
const body = this.$el.querySelector('.association-detail-body')
if (body) body.scrollTop = body.scrollHeight
})
},
openSignature() {
if (!this.formLoading) this.$options.popupSession.open('sign')
},
/** signature 是 mobile-sign 回传的本次签字 Base64,随审核请求一起保存。 */
setSignature(signature) {
this.$set(this.formData, 'sign', signature)
},
confirmReview(flag) {
if (this.formLoading || !this.detail.canReview) return
if (!this.formData.idea.trim() || !this.formData.sign) {
this.$toast('请填写审核意见并签字')
return
}
this.reviewFlag = flag
this.$options.popupSession.open('confirm')
},
/** Vant 用 done 结束按钮 loading;显隐仍由历史栈驱动,避免组件自行关闭后留下历史层。 */
beforeReviewClose(action, done) {
if (this.formLoading) {
done(false)
return
}
if (action === 'confirm') {
this.submitReview().always(() => done(false))
} else {
this.$options.popupSession.close().then(() => done(false))
}
},
/** id/idea/sign/flag 与 PC 共用;审核时间和人员由服务器取值,不信任前端身份字段。 */
submitReview() {
if (this.formLoading) return
this.formLoading = true
return $.post(this.baseUrl + '/doReview', Object.assign({}, this.formData, {flag: this.reviewFlag}))
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) {
this.$toast.success('审核成功')
this.$options.popupSession.clear().then(() => {
this.formData = {id: '', idea: '', sign: ''}
this.doSearch()
})
} else {
this.$toast(res.msg || '审核失败')
// 关闭确认层后重读状态;若已被 PC 处理则切换为只读,保留未提交的意见便于核对。
if (this.popups.confirm) this.$options.popupSession.close().then(() => this.loadDetail(this.detailId))
}
})
.fail(() => this.$toast('提交失败,请检查网络后重试'))
.always(() => { this.formLoading = false })
},
/** 图片使用文件流,文档沿用项目预览接口;预览自身同样占一层历史。 */
previewFile(file) {
if (file.imageUrl) {
this.previewIsImage = true
this.previewUrl = file.imageUrl
this.$options.popupSession.open('preview')
return
}
if (!file.id) {
this.$toast('附件信息不完整')
return
}
this.previewIsImage = /\.(png|jpe?g|gif|webp|bmp)$/i.test(file.filename || file.filepath || '')
const streamUrl = '/file_server/fileStreamPreview?id=' + encodeURIComponent(file.id)
this.previewUrl = this.previewIsImage
? streamUrl
: '/assets/platform/plugins/pdfJs/web/viewer.html?file=' + encodeURIComponent(streamUrl)
this.$options.popupSession.open('preview')
},
closePopup() {
if (this.formLoading) {
this.$toast('正在提交,请稍候')
return
}
this.$options.popupSession.close()
},
back() {
if (Object.values(this.popups).some(Boolean)) {
this.closePopup()
return
}
this.$options.popupSession.clear().then(() => {
this.$destroy()
pjaxReplace('/mobile/index')
})
}
},
created() {
// 仅注册状态同步回调;popstate 由公共管理器统一监听。
this.$options.popupSession = window.mobilePopupHistory.register('associationAudit', (layers) => {
Object.keys(this.popups).forEach((name) => this.$set(this.popups, name, layers.includes(name)))
// 系统返回或页面返回先收起下拉菜单;由公共历史栈驱动,页面不另绑 popstate。
if (!layers.includes('filter')) {
Object.keys(this.filterOpen).forEach((key) => {
this.$set(this.filterOpen, key, false)
const dropdown = this.$refs[key + 'Filter']
if (dropdown) dropdown.toggle(false)
})
}
})
const user = JSON.parse(window.sessionStorage.getItem('user') || '{}')
this.reviewerName = user.username || ''
this.loadClubs()
this.doSearch()
const query = new URLSearchParams(window.location.search)
const id = query.get('id')
if (id) this.openDetail(id, query.get('mode') === 'audit')
// PJAX 不会自动销毁旧 Vue 实例,在替换容器前注销页面回调。
$(document).on('pjax:beforeReplace.associationAudit', () => this.$destroy())
},
beforeDestroy() {
this.listSerial++
this.detailSerial++
const session = this.$options.popupSession
if (Object.values(this.popups).some(Boolean)) {
session.clear().then(() => session.dispose())
} else {
// PJAX 替换后可能立即创建新页面;空栈必须同步注销,不能等 Promise 回调再释放注册。
session.dispose()
}
$(document).off('pjax:beforeReplace.associationAudit')
}
})
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/activitybx/review/index.html'){}#-->
<script>
// 节点配置由当前页面固定提供,业务权限及状态仍由服务端校验。
const activityReviewConfig = {
stage: 'division', title: '基层工会主席审核', state: 1010,
confirmMessage: '通过后将转交校工会负责人审核。',
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/activitybx/review/info.js'){}#-->
<!--#include('/mobile/jf/activitybx/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,223 @@
<style>
.review-page { min-height: 100vh; background: #f6f7f9; color: #323233; font-size: 13px; }
.review-page .van-nav-bar__title { font-size: 15px; font-weight: 400; }
.review-filters .van-dropdown-menu__bar { box-shadow: none; border-top: 1px solid #f8f8f8; border-bottom: 1px solid #f8f8f8; }
.review-filters .van-dropdown-menu__item { flex: 1; min-width: 0; }
.review-filters .van-dropdown-menu__title { max-width: calc(100% - 40px); padding: 0 8px; font-size: 14px; }
.review-filters .van-dropdown-item__content { max-height: 70vh; border-radius: 0 0 15px 15px; }
.review-list { padding: 2px 0 12px; }
.review-card { margin: 12px 13px 0; padding: 11px 12px 10px; background: white; border-radius: 10px; box-shadow: 0 5px 12px rgba(30,50,70,.04); }
.review-card h3 { position: relative; margin: 0 0 7px; color: #086bc3; font-size: 15px; line-height: 21px; font-weight: 600; overflow-wrap: anywhere; }
.review-card h3:before { content: ''; position: absolute; left: -10px; top: 3px; height: 15px; border-left: 2px solid #1989fa; }
.review-card p { margin: 0; line-height: 26px; color: #85878d; overflow-wrap: anywhere; }
.review-card label { color: #242424; }
.review-card .review-pair { display: flex; gap: 10px; justify-content: space-between; }
.review-pair span:last-child { flex-shrink: 0; }
.review-card-footer { display: flex; align-items: center; gap: 8px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #f5f5f5; }
.review-state { flex: 1; min-width: 0; font-size: 12px; color: #b7790c; overflow-wrap: anywhere; }
.review-card-footer .van-button { height: 25px; padding: 0 8px; font-size: 13px; border-radius: 6px; flex-shrink: 0; }
.review-popup { display: flex; flex-direction: column; overflow: hidden; background: #f6f7f9; height: 100%; width: 100%; }
.review-popup > .van-nav-bar { flex-shrink: 0; }
.review-detail-body { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 12px; }
.review-section { margin: 12px; padding: 10px 0; border-radius: 10px; background: white; overflow: hidden; }
.review-section h3 { margin: 4px 14px 8px; font-size: 15px; color: #1768b0; }
.review-section .van-cell { font-size: 13px; padding: 8px 14px; }
.review-section .van-cell__title { flex: 0 0 88px; }
.review-section .van-cell__value { white-space: pre-wrap; overflow-wrap: anywhere; }
.review-section .review-file .van-cell__title { flex: 1; overflow-wrap: anywhere; }
.review-section .review-file .van-cell__value { flex: 0 0 40px; }
.review-content-image { display: block; max-width: calc(100% - 28px); margin: 10px 14px; }
.review-description { padding: 0 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.review-muted { margin: 10px 14px; color: #969799; }
.review-total .van-cell__value { color: #086bc3; font-weight: 600; }
.review-sign-image { display: block; max-width: calc(100% - 28px); width: 240px; height: 100px; object-fit: contain; margin: 8px 14px; }
.review-actions { flex-shrink: 0; display: flex; gap: 12px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: white; border-top: 1px solid #eee; z-index: 2; }
.review-actions .van-button { flex: 1; border-radius: 6px; height: 40px; }
.review-section .review-sign-field { display: block; }
.review-sign-field .van-field__label { width: auto; margin: 0 0 10px; }
.review-sign-field .main-panel .van-image { width: 100%; height: 112px; min-height: 112px; overflow: hidden; border-radius: 6px; }
.review-sign-field .main-panel .open-button { width: 100%; border-radius: 6px; }
.review-preview { display: block; width: 100%; height: calc(100% - 46px); border: none; background: white; }
.review-preview-image { width: 100%; height: calc(100% - 46px); object-fit: contain; }
.review-loading { padding: 30px; text-align: center; }
.review-more-bar { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 7px 13px; background: white; color: #969799; font-size: 12px; }
.review-more-bar > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-more-popup, .review-choice-popup { display: flex; flex-direction: column; max-height: 80vh; background: #f7f8fa; }
.review-choice-popup { height: 60vh; }
.review-more-body { flex: 1; overflow-y: auto; min-height: 0; }
/* 筛选面板独立限定样式,避免影响详情、审核按钮和其他公共弹框。 */
.review-more-popup { background: #fff; border-radius: 18px 18px 0 0; overflow: hidden; }
.review-more-handle { flex-shrink: 0; width: 38px; height: 4px; margin: 8px auto 0; border-radius: 4px; background: #d5d8df; }
.review-more-header { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; padding: 4px 12px 8px 18px; }
.review-more-header h2 { margin: 0; font-size: 18px; line-height: 32px; font-weight: 600; color: #20242d; }
.review-more-close.van-button { width: 36px; height: 36px; padding: 0; border: 0; color: #92969f; background: transparent; font-size: 20px; }
.review-more-popup > .review-more-body { flex: 0 1 auto; padding: 0 18px 14px; }
.review-filter-group + .review-filter-group { margin-top: 16px; }
.review-filter-label { margin: 0 0 8px; color: #252a34; font-size: 14px; line-height: 20px; font-weight: 600; }
.review-filter-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.review-filter-row .review-filter-label { margin: 0; }
.review-unit-select.van-cell { align-items: center; padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.review-unit-select .van-cell__title { min-width: 0; overflow-wrap: anywhere; }
.review-unit-select:after, .review-filter-input:after { display: none; }
.review-filter-input.van-cell { padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.review-filter-input .van-field__left-icon { color: #969ba4; margin-right: 8px; }
.review-segment { display: flex; flex-shrink: 0; gap: 2px; padding: 2px; border-radius: 8px; background: #f5f6f8; }
.review-filter-option.van-button { min-width: 0; height: 30px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; background: #f5f6f8; color: #333943; font-size: 12px; }
.review-segment .review-filter-option { min-width: 58px; height: 28px; }
.review-filter-option.is-selected { background: #eaf3ff; border-color: #1989fa; color: #1989fa; font-weight: 500; }
.review-segment .review-filter-option.is-selected { border-color: transparent; }
.review-sort-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.review-sort-options .review-filter-option { position: relative; width: 100%; height: 30px; padding: 0 4px; white-space: nowrap; }
.review-sort-options .is-selected { padding-right: 15px; }
.review-sort-check { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); font-size: 12px; }
.review-sort-direction { margin: 14px 0 0; }
.review-sort-direction .review-segment { background: transparent; gap: 6px; padding: 0; }
.review-more-footer { display: flex; flex-shrink: 0; gap: 10px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); border-top: 1px solid #f1f2f5; background: #fff; }
.review-more-footer .van-button { flex: 1; height: 40px; border-radius: 8px; font-size: 14px; }
.review-more-footer .van-button--info { flex: 2; }
.review-more-footer .van-button--default { color: #60656e; border-color: #e4e7ed; }
.review-choice-popup { background: #fff; }
.review-choice-popup .van-search { padding: 8px 16px; }
.review-choice-popup .van-search__content { border-radius: 8px; background: #f5f6f8; }
.review-choice-popup .van-cell { padding: 12px 18px; font-size: 13px; }
.review-choice-popup .review-choice-selected { color: #1989fa; background: #f0f6ff; }
.review-budget-item + .review-budget-item { border-top: 1px solid #f2f3f5; }
</style>
<div id="app" class="review-page" v-cloak>
<van-sticky>
<van-nav-bar :title="config.title" left-arrow @click-left="back"></van-nav-bar>
<van-dropdown-menu class="review-filters" active-color="#1867b0"
:close-on-click-outside="false" :close-on-click-overlay="false">
<van-dropdown-item ref="yearFilter" :options="yearOptions" :value="pageForm.year" :disabled="filterClosing"
@change="selectFilter('year', $event)"
@open="setFilterOpen('year', true)" @close="setFilterOpen('year', false)"></van-dropdown-item>
<van-dropdown-item ref="organizationFilter" :options="organizationOptions" :value="pageForm[organizationKey]" :disabled="filterClosing"
@change="selectFilter(organizationKey, $event)"
@open="setFilterOpen('organization', true)" @close="setFilterOpen('organization', false)">
<van-loading v-if="organizationsLoading" class="review-loading">加载中</van-loading>
</van-dropdown-item>
<van-dropdown-item ref="isAuditFilter" :options="auditOptions" :value="pageForm.isAudit" :disabled="filterClosing"
@change="selectFilter('isAudit', $event)"
@open="setFilterOpen('isAudit', true)" @close="setFilterOpen('isAudit', false)"></van-dropdown-item>
</van-dropdown-menu>
<div class="review-more-bar"><span>{{ searchSummary }}</span><van-button size="mini" plain type="info" @click="openMore">更多筛选</van-button></div>
</van-sticky>
<van-pull-refresh :value="refreshing" @input="setRefreshing" @refresh="doSearch">
<van-list :value="tableLoading" :finished="finished" :error="listError" error-text="加载失败,点击重试"
:finished-text="tableData.length ? '没有更多了' : ''" :immediate-check="false"
@input="setTableLoading" @update:error="setListError" @load="pageData" class="review-list">
<article v-for="item in tableData" :key="item.id" class="review-card">
<h3>{{ item.activity_name }}</h3>
<p><label>申请人:</label>{{ item.user_name }}<span v-if="item.bxr_loginname">({{ item.bxr_loginname }})</span></p>
<p><label>所属工会:</label>{{ item.bxr_unionname || item.fundsUnitName || '—' }}</p>
<p class="review-pair"><span><label>预算:</label>¥{{ money(item.funds_money) }}</span><span><label>申请:</label>{{ shortDate(item.apply_time) }}</span></p>
<div class="review-card-footer">
<span class="review-state" :style="{color: item.state_color || '#b7790c'}">{{ item.state_name || config.title }}</span>
<van-button type="info" size="small" :disabled="formLoading" @click="openDetail(item.id, false)">查看</van-button>
<van-button v-if="Number(item.state_id) === config.state" type="primary" size="small" :disabled="formLoading" @click="openDetail(item.id, true)">审核</van-button>
</div>
</article>
<van-empty v-if="!tableLoading && !listError && !tableData.length" description="暂无申请"></van-empty>
</van-list>
</van-pull-refresh>
<van-popup :value="popups.detail" position="right" class="review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="auditMode ? config.title : '申请详情'" left-arrow @click-left="closePopup"></van-nav-bar>
<div class="review-detail-body">
<van-loading v-if="detailLoading" class="review-loading">加载申请中</van-loading>
<van-empty v-else-if="!detail.id" description="详情加载失败">
<van-button size="small" type="info" @click="loadDetail(detailId)">重新加载</van-button>
</van-empty>
<review-info v-else :data="detail" @preview="previewFile">
<section v-if="auditMode && detail.canReview" class="review-section">
<h3>审核信息</h3>
<van-field label="审核人" :value="reviewerName" readonly></van-field>
<van-field label="审核时间" :value="reviewDate" readonly></van-field>
<van-field label="审核意见" :value="formData.idea" @input="setIdea" type="textarea" rows="3"
maxlength="500" show-word-limit required placeholder="请填写审核意见" :disabled="formLoading"></van-field>
<van-field label="审核签字" required class="review-sign-field">
<template #input>
<mobile-sign :value="formData.sign" @input="setSignature" :disabled="formLoading"
:save-to-profile="false" :popup-visible="popups.sign" panel-title="审核签字"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
</section>
</review-info>
</div>
<div v-if="detail.id && !detailLoading" class="review-actions">
<template v-if="auditMode && detail.canReview">
<van-button type="danger" plain :disabled="formLoading" @click="confirmReview(false)">拒绝</van-button>
<van-button type="primary" :loading="formLoading" :disabled="formLoading" @click="confirmReview(true)">通过</van-button>
</template>
<van-button v-else-if="detail.canReview" type="primary" @click="startAudit">去审核</van-button>
<van-button v-else @click="closePopup">关闭</van-button>
</div>
</van-popup>
<van-dialog :value="popups.confirm" :title="reviewFlag ? '确认通过' : '确认拒绝'"
:message="reviewFlag ? config.confirmMessage : '拒绝后将通知申请人,请核对审核意见。'"
show-cancel-button :allow-html="false" :close-on-click-overlay="false" :close-on-popstate="false"
:before-close="beforeReviewClose"></van-dialog>
<van-popup :value="popups.preview" position="right" class="review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar title="附件查看" left-arrow @click-left="closePopup"></van-nav-bar>
<img v-if="popups.preview && previewIsImage" :src="previewUrl" class="review-preview-image" alt="申请附件">
<iframe v-else-if="popups.preview" :src="previewUrl" title="申请附件" class="review-preview"></iframe>
</van-popup>
<van-popup :value="popups.more" position="bottom" round class="review-more-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<div class="review-more-handle" aria-hidden="true"></div>
<header class="review-more-header">
<h2>更多筛选</h2>
<van-button icon="cross" class="review-more-close" aria-label="关闭更多筛选" @click="closePopup"></van-button>
</header>
<div class="review-more-body">
<section v-if="config.canChooseUnion" class="review-filter-group">
<h3 class="review-filter-label">所属单位</h3>
<van-cell :title="optionText('unitId', moreForm.unitId)" class="review-unit-select" is-link @click="openChoice('unitId')"></van-cell>
</section>
<section class="review-filter-group">
<div class="review-filter-row">
<h3 class="review-filter-label">查询人员</h3>
<div class="review-segment" role="group" aria-label="查询类型">
<van-button v-for="option in choiceOptions.searchName" :key="option.value" class="review-filter-option"
:class="{'is-selected': moreForm.searchName === option.value}" :aria-pressed="moreForm.searchName === option.value"
@click="setMoreField('searchName', option.value)">{{ option.text }}</van-button>
</div>
</div>
<van-field class="review-filter-input" left-icon="search" :value="moreForm.searchKeyword"
@input="setMoreField('searchKeyword', $event)" maxlength="100" clearable
:placeholder="moreSearchPlaceholder" :aria-label="moreSearchPlaceholder"></van-field>
</section>
<section class="review-filter-group">
<h3 class="review-filter-label">排序方式</h3>
<div class="review-sort-options" role="group" aria-label="排序字段">
<van-button v-for="option in choiceOptions.pageOrderName" :key="option.value" class="review-filter-option"
:class="{'is-selected': moreForm.pageOrderName === option.value}" :aria-pressed="moreForm.pageOrderName === option.value"
@click="setMoreField('pageOrderName', option.value)">
{{ option.text }}<van-icon v-if="moreForm.pageOrderName === option.value" name="success" class="review-sort-check"></van-icon>
</van-button>
</div>
<div v-if="moreForm.pageOrderName" class="review-filter-row review-sort-direction">
<h3 class="review-filter-label">排列顺序</h3>
<div class="review-segment" role="group" aria-label="排列顺序">
<van-button v-for="option in choiceOptions.pageOrderBy" :key="option.value" class="review-filter-option"
:class="{'is-selected': moreForm.pageOrderBy === option.value}" :aria-pressed="moreForm.pageOrderBy === option.value"
@click="setMoreField('pageOrderBy', option.value)">{{ option.text }}</van-button>
</div>
</div>
</section>
</div>
<div class="review-more-footer"><van-button @click="resetMore">重置</van-button><van-button type="info" @click="applyMore">确定筛选</van-button></div>
</van-popup>
<van-popup :value="popups.choice" position="bottom" round class="review-choice-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="choiceTitle" left-text="返回" @click-left="closePopup"></van-nav-bar>
<van-search v-if="choiceKey === 'unitId'" :value="choiceQuery" @input="setChoiceQuery" placeholder="搜索单位"></van-search>
<div class="review-more-body">
<van-cell v-for="option in filteredChoices" :key="option.value" :title="option.text" :class="{'review-choice-selected': moreForm[choiceKey] === option.value}" clickable @click="selectChoice(option.value)">
<van-icon v-if="moreForm[choiceKey] === option.value" name="success" color="#1989fa"></van-icon>
</van-cell>
<van-empty v-if="!filteredChoices.length" description="暂无选项"></van-empty>
</div>
</van-popup>
</div>
@@ -0,0 +1,146 @@
/** 活动申报审核详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
const activityReviewInfo = {
template: /*language=HTML*/ `
<div class="review-info">
<section class="review-section">
<h3>{{ data.activity_name }}</h3>
<van-cell title="当前状态" :value="data.state_name"></van-cell>
<van-cell title="申请人" :value="data.user_name + '(' + (data.loginname || '—') + ')'"></van-cell>
<van-cell title="所属工会" :value="data.bxr_unionname || '—'"></van-cell>
<van-cell title="所属单位" :value="data.bxr_unitname || '—'"></van-cell>
<van-cell title="活动主体" :value="activityType"></van-cell>
<van-cell title="申请类型" :value="Number(data.apply_type) === 1 ? '经费申报' : '报销申请'"></van-cell>
<van-cell title="联系方式" :value="data.mobile || '—'"></van-cell>
<van-cell title="申请时间" :value="data.apply_time || '—'"></van-cell>
<van-cell title="承办单位/协会" :value="data.clubName || data.fundsUnitName || '—'"></van-cell>
<van-cell title="项目编号" :value="data.projectCode || '—'"></van-cell>
<van-cell title="经费类型" :value="data.projectJfName || '—'"></van-cell>
<van-cell title="计划时间" :value="(data.startPlannedDate || '—') + ' 至 ' + (data.endPlannedDate || '—')"></van-cell>
<van-cell title="活动时间" :value="data.startTime ? data.startTime + ' 至 ' + (data.endTime || '—') : '暂无'"></van-cell>
<van-cell title="实际费用" :value="'¥' + money(data.activity_money)"></van-cell>
<van-cell title="活动地点" :value="data.address || '—'"></van-cell>
<van-cell title="活动人数" :value="data.activity_number || '—'"></van-cell>
</section>
<section class="review-section">
<h3>活动方案及简介</h3>
<div class="review-description">{{ activityText || '暂无' }}</div>
<img v-for="(url, index) in activityImages" :key="index" :src="url" class="review-content-image"
alt="活动方案配图" @click="$emit('preview', {imageUrl: url})">
</section>
<section class="review-section">
<h3>预算明细</h3>
<div v-for="(item, index) in budgets" :key="index" class="review-budget-item">
<van-cell :title="item.name || '费用项目'" :value="'¥' + money(item.budgetPrice)"></van-cell>
<template v-if="data.reimbursement_state_id">
<van-cell title="实际费用" :value="'¥' + money(item.actualPrice)"></van-cell>
<van-cell title="收款人" :value="item.username || '—'"></van-cell>
<van-cell title="支行名称" :value="item.bankName || '—'"></van-cell>
<van-cell title="报销卡号" :value="item.bankCardNum || '—'"></van-cell>
<van-cell title="备注" :value="item.remark || '—'"></van-cell>
</template>
</div>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="review-total"></van-cell>
</section>
<section class="review-section">
<h3>申请附件</h3>
<van-cell v-for="(file, index) in files" :key="file.id || index" :title="file.filename || file.name || '附件'" class="review-file"
value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!files.length" class="review-muted">暂无附件</p>
<h3>申请人签字</h3>
<img v-if="data.us_s" class="review-sign-image" :src="signUrl(data.us_s)" alt="申请人签字">
<p v-else class="review-muted">暂无签字</p>
</section>
<section v-for="group in reimbursementFiles" :key="group.key" class="review-section">
<h3>{{ group.title }}</h3>
<van-cell v-for="(file, index) in group.files" :key="file.id || index" :title="file.filename || file.name || '附件'"
class="review-file" value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!group.files.length" class="review-muted">暂无附件</p>
</section>
<section v-for="audit in audits" :key="audit.key" class="review-section">
<h3>{{ audit.title }}</h3>
<van-cell title="审核人" :value="audit.name || '—'"></van-cell>
<van-cell title="审核时间" :value="audit.time || '—'"></van-cell>
<van-cell title="审核意见" :value="audit.idea || '暂无'"></van-cell>
<img v-if="audit.sign" class="review-sign-image" :src="signUrl(audit.sign)" :alt="audit.title + '签字'">
</section>
<slot></slot>
</div>
`,
props: {
data: {type: Object, default: () => ({})}
},
computed: {
activityType() {
return {40001: '校工会活动', 40002: '基层工会活动', 40003: '协会活动', 40004: '女职工活动'}[this.data.activity_type] || '—'
},
reimbursementFiles() {
if (!this.data.reimbursement_state_id) return []
return [{key: 'billFiles', title: '发票'}, {key: 'photoFiles', title: '活动报道照片/总结/链接'}, {key: 'otherFiles', title: '奖品/纪念品/服装/道具等活动人员名单'}]
.map((group) => Object.assign({}, group, {files: this.asList(this.data[group.key])}))
},
budgets() {
return this.asList(this.data.budgets)
},
files() {
return this.asList(this.data.files)
},
activityText() {
// 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。
const container = document.createElement('div')
container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n')
container.querySelectorAll('script,style').forEach((node) => node.remove())
return container.textContent.trim()
},
activityImages() {
// 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。
const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html')
return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '')
.filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url))
},
audits() {
const data = this.data
const records = [
{key: 'division', title: '基层工会主席审核', id: data.division_chairman_name, name: data.dc_username, time: data.division_chairman_time, idea: data.division_chairman_idea, sign: data.dc_s},
{key: 'association', title: '协会负责人审核', id: data.association_principal_id,
name: data.ap_username, time: data.association_principal_time, idea: data.association_principal_idea, sign: data.ap_s},
{key: 'accounting', title: '校工会负责人审核', id: data.accounting_name,
name: data.a_username, time: data.accounting_time, idea: data.accounting_idea, sign: data.a_s},
{key: 'confirm', title: '校工会副主席审核', id: data.confirm_trade_name, name: data.ct_username, time: data.confirm_trade_time, idea: data.confirm_trade_idea, sign: data.ct_s},
{key: 'cashier', title: '校工会出纳审核', id: data.union_cashier_name, name: data.uc_username, time: data.union_cashier_time, idea: data.union_cashier_idea, sign: data.uc_s},
{key: 'union', title: '校工会主席审核', id: data.union_chairman_name,
name: data.ucc_username, time: data.union_chairman_time, idea: data.union_chairman_idea, sign: data.ucc_s}
].filter((audit) => audit.id)
// PC 详情同时展示已有报销审批记录;只读呈现,不把报销流程混入当前申请审核。
const reimbursementAudits = [
{key: 'reimbursement_club_audit', title: '协会负责人审核(报销)'},
{key: 'reimbursement_union_audit', title: '基层工会主席审核(报销)'},
{key: 'reimbursement_school_director', title: '校工会负责人审核(报销)'},
{key: 'reimbursement_school_chairman', title: '校工会主席审核(报销)'}
].filter((item) => data[item.key]).map((item) => {
const audit = data[item.key]
return {key: item.key, title: item.title, name: audit.username, time: audit.auditTime, idea: audit.auditOpinion, sign: audit.auditSign}
})
return records.concat(reimbursementAudits)
}
},
methods: {
/** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */
asList(value) {
if (Array.isArray(value)) return value
if (!value) return []
try {
const list = JSON.parse(value)
return Array.isArray(list) ? list : []
} catch (error) {
return []
}
},
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
/** 签字兼容 PC 保存的 Base64 图片和历史文件路径。 */
signUrl(value) {
return value && value.startsWith('data:image/') ? value : '/signature/getSignData?path=' + encodeURIComponent(value || '')
}
}
}
@@ -0,0 +1,353 @@
// 三个节点共用协会页的手机交互,节点与提示文案由独立入口配置。
const vue = new Vue({
el: '#app',
components: {'review-info': activityReviewInfo},
data() {
return {
config: activityReviewConfig,
baseUrl: '/platform/jf/activity_bx/' + activityReviewConfig.stage,
pageForm: {year: new Date().getFullYear(), unionId: '', unitId: '', isAudit: 3, pageNumber: 1, pageSize: 10, searchName: 'username', searchKeyword: '', pageOrderName: '', pageOrderBy: 'desc'},
tableData: [], tableLoading: false, refreshing: false, finished: false, listError: false,
listSerial: 0, listBusy: false, detailSerial: 0,
unions: [{text: '全部工会', value: ''}], units: [{text: '全部单位', value: ''}], organizationsLoading: false, unitsSerial: 0,
moreForm: {}, choiceKey: '', choiceQuery: '',
auditOptions: [{text: '未审核', value: 3}, {text: '已审核', value: 2}, {text: '全部', value: 1}],
filterOpen: {year: false, organization: false, isAudit: false}, filterClosing: false,
popups: {filter: false, detail: false, sign: false, confirm: false, preview: false, more: false, choice: false},
detail: {}, detailId: '', detailLoading: false, auditMode: false,
formData: {id: '', idea: '', sign: ''}, formLoading: false, reviewFlag: true,
reviewerName: '', reviewDate: '', previewUrl: '', previewIsImage: false
}
},
computed: {
organizationKey() { return this.config.canChooseUnion ? 'unionId' : 'unitId' },
organizationOptions() { return this.config.canChooseUnion ? this.unions : this.units },
/** 查询类型直接切换草稿字段,输入提示同步变化;确定筛选前不触发列表请求。 */
moreSearchPlaceholder() { return this.moreForm.searchName === 'loginname' ? '请输入工号' : '请输入姓名' },
searchSummary() {
const parts = []
if (this.pageForm.searchKeyword) parts.push(this.pageForm.searchKeyword)
if (this.config.canChooseUnion && this.pageForm.unitId) parts.push(this.optionText('unitId', this.pageForm.unitId))
if (this.pageForm.pageOrderName) parts.push(this.optionText('pageOrderName', this.pageForm.pageOrderName))
return parts.join(' · ') || '按姓名、工号、单位筛选'
},
choiceOptions() {
return {
unitId: this.units,
searchName: [{text: '姓名', value: 'username'}, {text: '工号', value: 'loginname'}],
pageOrderName: [{text: '默认排序', value: ''}, {text: '所属工会', value: 'bxr_unionname'}, {text: '活动主体类型', value: 'activity_type'}, {text: '经费类型', value: 'absName'}, {text: '申请类型', value: 'apply_type'}, {text: '申请时间', value: 'apply_time'}, {text: '申请状态', value: 'state_id'}],
pageOrderBy: [{text: '升序', value: 'asc'}, {text: '降序', value: 'desc'}]
}
},
choiceTitle() { return {unitId: '所属单位', searchName: '查询类型', pageOrderName: '排序字段', pageOrderBy: '排序方式'}[this.choiceKey] || '请选择' },
filteredChoices() {
return (this.choiceOptions[this.choiceKey] || []).filter((item) => item.text.includes(this.choiceQuery.trim()))
},
yearOptions() {
return [{text: '全部年份', value: ''}].concat(Array.from({length: 11}, (_, index) => {
const year = new Date().getFullYear() - index
return {text: year + '年', value: year}
}))
}
},
methods: {
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
shortDate(value) {
return value ? String(value).slice(5, 10) : '—'
},
setRefreshing(value) { this.refreshing = value },
setTableLoading(value) { this.tableLoading = value },
setListError(value) { this.listError = value },
setIdea(value) { this.$set(this.formData, 'idea', value) },
/** 筛选切换或提交后,从第一页替换数据;旧请求晚到时不能混入当前筛选结果。 */
doSearch() {
this.listSerial++
this.listBusy = false
this.$set(this.pageForm, 'pageNumber', 1)
this.tableData = []
this.finished = false
this.listError = false
this.pageData()
},
/** 列表返回 data.list 和 totalCount;失败保留页码供重试,成功后才推进下一页。 */
pageData() {
if (this.listBusy || this.finished) return
const serial = this.listSerial
this.listBusy = true
this.tableLoading = true
this.listError = false
$.post(this.baseUrl + '/pageData', Object.assign({}, this.pageForm))
.then((res) => {
if (serial !== this.listSerial || this._isDestroyed) return
if (res.code !== 0 || !res.data) {
this.listError = true
this.$toast(res.msg || '列表加载失败')
return
}
const list = res.data.list || []
this.tableData = this.tableData.concat(list)
this.finished = !list.length || this.tableData.length >= Number(res.data.totalCount)
this.$set(this.pageForm, 'pageNumber', this.pageForm.pageNumber + 1)
})
.fail(() => {
if (serial === this.listSerial && !this._isDestroyed) this.listError = true
})
.always(() => {
if (serial !== this.listSerial || this._isDestroyed) return
this.listBusy = false
this.tableLoading = false
this.refreshing = false
})
},
/** 工会和单位沿用 PC 的公共选项接口;接口返回 data 数组,组织权限仍由服务端控制。 */
loadOrganizations() {
this.organizationsLoading = true
$.post('/platform/vi/common/unions', {})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) this.unions = [{text: '全部工会', value: ''}].concat((res.data || []).map((item) => ({text: item.unionname, value: item.id})))
else this.$toast(res.msg || '工会加载失败')
})
.fail(() => this.$toast('工会加载失败,请重试'))
.always(() => { this.organizationsLoading = false })
this.loadUnits()
},
/** 更换工会后清空旧单位;序号避免前一次请求晚返回而覆盖新工会的选项。 */
loadUnits() {
const serial = ++this.unitsSerial
return $.post('/platform/vi/common/units', {unionId: this.config.canChooseUnion ? this.pageForm.unionId : this.config.currentUnionId})
.then((res) => {
if (serial !== this.unitsSerial || this._isDestroyed) return
if (res.code === 0) this.units = [{text: '全部单位', value: ''}].concat((res.data || []).map((item) => ({text: item.name, value: item.id})))
else this.$toast(res.msg || '单位加载失败')
})
.fail(() => { if (serial === this.unitsSerial && !this._isDestroyed) this.$toast('单位加载失败,请重试') })
},
openMore() {
this.moreForm = Object.assign({}, this.pageForm)
this.loadUnits()
this.$options.popupSession.open('more')
},
setMoreField(key, value) { this.$set(this.moreForm, key, value) },
setChoiceQuery(value) { this.choiceQuery = value },
/** 选择项弹框压在更多筛选上层,返回时只退出当前选择,不丢弃其他筛选值。 */
openChoice(key) {
this.choiceKey = key
this.choiceQuery = ''
this.$options.popupSession.open('choice')
},
selectChoice(value) {
this.$set(this.moreForm, this.choiceKey, value)
this.$options.popupSession.close()
},
optionText(key, value) {
const option = (this.choiceOptions[key] || []).find((item) => item.value === value)
return option ? option.text : '请选择'
},
resetMore() {
this.moreForm = Object.assign({}, this.moreForm, {unitId: this.config.canChooseUnion ? '' : this.pageForm.unitId, searchName: 'username', searchKeyword: '', pageOrderName: '', pageOrderBy: 'desc'})
},
applyMore() {
const values = this.moreForm
this.$options.popupSession.close().then(() => {
if (this._isDestroyed) return
const keys = ['searchName', 'searchKeyword', 'pageOrderName', 'pageOrderBy']
if (this.config.canChooseUnion) keys.push('unitId')
keys.forEach((key) => this.$set(this.pageForm, key, values[key]))
this.doSearch()
})
},
/** key 为年度/组织/状态字段,open 为菜单展开状态;三个菜单切换时共用一层历史。 */
setFilterOpen(key, open) {
this.$set(this.filterOpen, key, open)
if (open && key === 'organization') {
if (this.organizationOptions.length === 1 && !this.organizationsLoading) this.loadOrganizations()
}
// Vant 切换菜单会同时触发旧菜单 close 和新菜单 open,等本轮更新结束后统一判断。
this.$nextTick(() => {
if (this._isDestroyed || this.filterClosing) return
if (Object.values(this.filterOpen).some(Boolean)) {
if (!this.popups.filter) this.$options.popupSession.open('filter')
} else if (this.popups.filter) {
this.filterClosing = true
this.$options.popupSession.close().then(() => { this.filterClosing = false })
}
})
},
/** key 为筛选字段,value 为对应选项值;仅值改变时刷新,菜单收起事件负责回退历史。 */
selectFilter(key, value) {
this.$set(this.pageForm, key, value)
if (key === 'unionId') {
this.$set(this.pageForm, 'unitId', '')
this.units = [{text: '全部单位', value: ''}]
this.loadUnits()
}
this.doSearch()
},
/** 每次打开申请清空上次的意见和签字,防止在不同申请之间串用审核数据。 */
openDetail(id, audit) {
if (this.formLoading) return
this.detailId = id
this.detail = {}
this.auditMode = audit
this.formData = {id, idea: '', sign: ''}
this.reviewDate = moment().format('YYYY-MM-DD')
this.$options.popupSession.open('detail')
this.loadDetail(id)
},
loadDetail(id) {
const serial = ++this.detailSerial
this.detailLoading = true
$.post(this.baseUrl + '/findOne', {id})
.then((res) => {
if (serial !== this.detailSerial || !this.popups.detail || this._isDestroyed) return
if (res.code === 0 && res.data) {
this.detail = res.data
if (!this.detail.canReview) this.auditMode = false
} else {
this.$toast(res.msg || '详情加载失败')
}
})
.fail(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.$toast('详情加载失败,请重试')
})
.always(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.detailLoading = false
})
},
startAudit() {
this.auditMode = true
this.$nextTick(() => {
const body = this.$el.querySelector('.review-detail-body')
if (body) body.scrollTop = body.scrollHeight
})
},
openSignature() {
if (!this.formLoading) this.$options.popupSession.open('sign')
},
/** signature 是 mobile-sign 回传的本次签字 Base64,随审核请求一起保存。 */
setSignature(signature) {
this.$set(this.formData, 'sign', signature)
},
confirmReview(flag) {
if (this.formLoading || !this.detail.canReview) return
if (!this.formData.idea.trim() || !this.formData.sign) {
this.$toast('请填写审核意见并签字')
return
}
this.reviewFlag = flag
this.$options.popupSession.open('confirm')
},
/** Vant 用 done 结束按钮 loading;显隐仍由历史栈驱动,避免组件自行关闭后留下历史层。 */
beforeReviewClose(action, done) {
if (this.formLoading) {
done(false)
return
}
if (action === 'confirm') {
this.submitReview().always(() => done(false))
} else {
this.$options.popupSession.close().then(() => done(false))
}
},
/** id/idea/sign/flag 与 PC 共用;审核时间和人员由服务器取值,不信任前端身份字段。 */
submitReview() {
if (this.formLoading) return
this.formLoading = true
const form = Object.assign({}, this.formData, {flag: this.reviewFlag})
const params = this.config.stage === 'division' ? form : {data: JSON.stringify(form)}
return $.post(this.baseUrl + '/doReview', params)
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) {
this.$toast.success('审核成功')
this.$options.popupSession.clear().then(() => {
if (this._isDestroyed) return
this.formData = {id: '', idea: '', sign: ''}
this.doSearch()
})
} else {
this.$toast(res.msg || '审核失败')
// 关闭确认层后重读状态;若已被 PC 处理则切换为只读,保留未提交的意见便于核对。
if (this.popups.confirm) this.$options.popupSession.close().then(() => this.loadDetail(this.detailId))
}
})
.fail(() => this.$toast('提交失败,请检查网络后重试'))
.always(() => { this.formLoading = false })
},
/** 图片使用文件流,文档沿用项目预览接口;预览自身同样占一层历史。 */
previewFile(file) {
if (file.imageUrl) {
this.previewIsImage = true
this.previewUrl = file.imageUrl
this.$options.popupSession.open('preview')
return
}
if (!file.id) {
this.$toast('附件信息不完整')
return
}
this.previewIsImage = /\.(png|jpe?g|gif|webp|bmp)$/i.test(file.filename || file.filepath || '')
const streamUrl = '/file_server/fileStreamPreview?id=' + encodeURIComponent(file.id)
this.previewUrl = this.previewIsImage
? streamUrl
: '/assets/platform/plugins/pdfJs/web/viewer.html?file=' + encodeURIComponent(streamUrl)
this.$options.popupSession.open('preview')
},
closePopup() {
if (this.formLoading) {
this.$toast('正在提交,请稍候')
return
}
this.$options.popupSession.close()
},
back() {
if (Object.values(this.popups).some(Boolean)) {
this.closePopup()
return
}
this.$options.popupSession.clear().then(() => {
this.$destroy()
pjaxReplace('/mobile/index')
})
}
},
created() {
// 仅注册状态同步回调;popstate 由公共管理器统一监听。
this.$options.popupSession = window.mobilePopupHistory.register('activityBxReview-' + this.config.stage, (layers) => {
Object.keys(this.popups).forEach((name) => this.$set(this.popups, name, layers.includes(name)))
// 系统返回或页面返回先收起下拉菜单;由公共历史栈驱动,页面不另绑 popstate。
if (!layers.includes('filter')) {
Object.keys(this.filterOpen).forEach((key) => {
this.$set(this.filterOpen, key, false)
const dropdown = this.$refs[key + 'Filter']
if (dropdown) dropdown.toggle(false)
})
}
})
const user = JSON.parse(window.sessionStorage.getItem('user') || '{}')
this.reviewerName = user.username || ''
this.loadOrganizations()
this.doSearch()
const query = new URLSearchParams(window.location.search)
const id = query.get('id')
if (id) this.openDetail(id, query.get('mode') === 'audit')
// PJAX 不会自动销毁旧 Vue 实例,在替换容器前注销页面回调。
$(document).on('pjax:beforeReplace.activityBxReview', () => this.$destroy())
},
beforeDestroy() {
this.listSerial++
this.detailSerial++
this.unitsSerial++
const session = this.$options.popupSession
if (Object.values(this.popups).some(Boolean)) {
session.clear().then(() => session.dispose())
} else {
// PJAX 替换后可能立即创建新页面;空栈必须同步注销,不能等 Promise 回调再释放注册。
session.dispose()
}
$(document).off('pjax:beforeReplace.activityBxReview')
}
})
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/activitybx/review/index.html'){}#-->
<script>
// 节点配置由当前页面固定提供,业务权限及状态仍由服务端校验。
const activityReviewConfig = {
stage: 'union', title: '校工会主席审核', state: 1040,
confirmMessage: '确认通过后,将按原流程生成活动并通知申请人。',
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/activitybx/review/info.js'){}#-->
<!--#include('/mobile/jf/activitybx/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'clubChairman', title: '协会负责人审核(报销)', state: 1000,
confirmMessage: '通过后将转交校工会负责人审核。', showClub: true, showUnit: false,
canChooseUnion: false,
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,233 @@
<style>
.reimbursement-review-page { min-height: 100vh; background: #f6f7f9; color: #323233; font-size: 13px; }
.reimbursement-review-page .van-nav-bar__title { font-size: 15px; font-weight: 400; }
.reimbursement-review-filters .van-dropdown-menu__bar { box-shadow: none; border-top: 1px solid #f8f8f8; border-bottom: 1px solid #f8f8f8; }
.reimbursement-review-filters .van-dropdown-menu__item { flex: 1; min-width: 0; }
.reimbursement-review-filters .van-dropdown-menu__title { max-width: calc(100% - 40px); padding: 0 8px; font-size: 14px; }
.reimbursement-review-filters .van-dropdown-item__content { max-height: 70vh; border-radius: 0 0 15px 15px; }
.reimbursement-review-list { padding: 2px 0 12px; }
.reimbursement-review-card { margin: 12px 13px 0; padding: 11px 12px 10px; background: white; border-radius: 10px; box-shadow: 0 5px 12px rgba(30,50,70,.04); }
.reimbursement-review-card h3 { position: relative; margin: 0 0 7px; color: #086bc3; font-size: 15px; line-height: 21px; font-weight: 600; overflow-wrap: anywhere; }
.reimbursement-review-card h3:before { content: ''; position: absolute; left: -10px; top: 3px; height: 15px; border-left: 2px solid #1989fa; }
.reimbursement-review-card p { margin: 0; line-height: 26px; color: #85878d; overflow-wrap: anywhere; }
.reimbursement-review-card label { color: #242424; }
.reimbursement-review-card .reimbursement-review-pair { display: flex; gap: 10px; justify-content: space-between; }
.reimbursement-review-pair span:last-child { flex-shrink: 0; }
.reimbursement-review-card-footer { display: flex; align-items: center; gap: 8px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #f5f5f5; }
.reimbursement-review-state { flex: 1; min-width: 0; font-size: 12px; color: #b7790c; overflow-wrap: anywhere; }
.reimbursement-review-card-footer .van-button { height: 25px; padding: 0 8px; font-size: 13px; border-radius: 6px; flex-shrink: 0; }
.reimbursement-review-popup { display: flex; flex-direction: column; overflow: hidden; background: #f6f7f9; height: 100%; width: 100%; }
.reimbursement-review-popup > .van-nav-bar { flex-shrink: 0; }
.reimbursement-review-detail-body { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 12px; }
.reimbursement-review-section { margin: 12px; padding: 10px 0; border-radius: 10px; background: white; overflow: hidden; }
.reimbursement-review-section h3 { margin: 4px 14px 8px; font-size: 15px; color: #1768b0; }
.reimbursement-review-section .van-cell { font-size: 13px; padding: 8px 14px; }
.reimbursement-review-section .van-cell__title { flex: 0 0 88px; }
.reimbursement-review-section .van-cell__value { white-space: pre-wrap; overflow-wrap: anywhere; }
.reimbursement-review-section .reimbursement-review-file .van-cell__title { flex: 1; overflow-wrap: anywhere; }
.reimbursement-review-section .reimbursement-review-file .van-cell__value { flex: 0 0 40px; }
.reimbursement-review-content-image { display: block; max-width: calc(100% - 28px); margin: 10px 14px; }
.reimbursement-review-description { padding: 0 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.reimbursement-review-muted { margin: 10px 14px; color: #969799; }
.reimbursement-review-total .van-cell__value { color: #086bc3; font-weight: 600; }
.reimbursement-review-sign-image { display: block; max-width: calc(100% - 28px); width: 240px; height: 100px; object-fit: contain; margin: 8px 14px; }
.reimbursement-review-actions { flex-shrink: 0; display: flex; gap: 12px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: white; border-top: 1px solid #eee; z-index: 2; }
.reimbursement-review-actions .van-button { flex: 1; border-radius: 6px; height: 40px; }
.reimbursement-review-section .reimbursement-review-sign-field { display: block; }
.reimbursement-review-sign-field .van-field__label { width: auto; margin: 0 0 10px; }
.reimbursement-review-sign-field .main-panel .van-image { width: 100%; height: 112px; min-height: 112px; overflow: hidden; border-radius: 6px; }
.reimbursement-review-sign-field .main-panel .open-button { width: 100%; border-radius: 6px; }
.reimbursement-review-preview { display: block; width: 100%; height: calc(100% - 46px); border: none; background: white; }
.reimbursement-review-preimbursement-review-image { width: 100%; height: calc(100% - 46px); object-fit: contain; }
.reimbursement-review-loading { padding: 30px; text-align: center; }
.reimbursement-review-more-bar { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 7px 13px; background: white; color: #969799; font-size: 12px; }
.reimbursement-review-more-bar > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reimbursement-review-more-popup, .reimbursement-review-choice-popup { display: flex; flex-direction: column; max-height: 80vh; background: #f7f8fa; }
.reimbursement-review-choice-popup { height: 60vh; }
.reimbursement-review-more-body { flex: 1; overflow-y: auto; min-height: 0; }
/* 筛选面板独立限定样式,避免影响详情、审核按钮和其他公共弹框。 */
.reimbursement-review-more-popup { background: #fff; border-radius: 18px 18px 0 0; overflow: hidden; }
.reimbursement-review-more-handle { flex-shrink: 0; width: 38px; height: 4px; margin: 8px auto 0; border-radius: 4px; background: #d5d8df; }
.reimbursement-review-more-header { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; padding: 4px 12px 8px 18px; }
.reimbursement-review-more-header h2 { margin: 0; font-size: 18px; line-height: 32px; font-weight: 600; color: #20242d; }
.reimbursement-review-more-close.van-button { width: 36px; height: 36px; padding: 0; border: 0; color: #92969f; background: transparent; font-size: 20px; }
.reimbursement-review-more-popup > .reimbursement-review-more-body { flex: 0 1 auto; padding: 0 18px 14px; }
.reimbursement-review-filter-group + .reimbursement-review-filter-group { margin-top: 16px; }
.reimbursement-review-filter-label { margin: 0 0 8px; color: #252a34; font-size: 14px; line-height: 20px; font-weight: 600; }
.reimbursement-review-filter-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.reimbursement-review-filter-row .reimbursement-review-filter-label { margin: 0; }
.reimbursement-review-unit-select.van-cell { align-items: center; padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.reimbursement-review-unit-select .van-cell__title { min-width: 0; overflow-wrap: anywhere; }
.reimbursement-review-unit-select:after, .reimbursement-review-filter-input:after { display: none; }
.reimbursement-review-filter-input.van-cell { padding: 8px 12px; border-radius: 8px; background: #f5f6f8; font-size: 13px; }
.reimbursement-review-filter-input .van-field__left-icon { color: #969ba4; margin-right: 8px; }
.reimbursement-review-segment { display: flex; flex-shrink: 0; gap: 2px; padding: 2px; border-radius: 8px; background: #f5f6f8; }
.reimbursement-review-filter-option.van-button { min-width: 0; height: 30px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; background: #f5f6f8; color: #333943; font-size: 12px; }
.reimbursement-review-segment .reimbursement-review-filter-option { min-width: 58px; height: 28px; }
.reimbursement-review-filter-option.is-selected { background: #eaf3ff; border-color: #1989fa; color: #1989fa; font-weight: 500; }
.reimbursement-review-segment .reimbursement-review-filter-option.is-selected { border-color: transparent; }
.reimbursement-review-sort-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.reimbursement-review-sort-options .reimbursement-review-filter-option { position: relative; width: 100%; height: 30px; padding: 0 4px; white-space: nowrap; }
.reimbursement-review-sort-options .is-selected { padding-right: 15px; }
.reimbursement-review-sort-check { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); font-size: 12px; }
.reimbursement-review-sort-direction { margin: 14px 0 0; }
.reimbursement-review-sort-direction .reimbursement-review-segment { background: transparent; gap: 6px; padding: 0; }
.reimbursement-review-more-footer { display: flex; flex-shrink: 0; gap: 10px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); border-top: 1px solid #f1f2f5; background: #fff; }
.reimbursement-review-more-footer .van-button { flex: 1; height: 40px; border-radius: 8px; font-size: 14px; }
.reimbursement-review-more-footer .van-button--info { flex: 2; }
.reimbursement-review-more-footer .van-button--default { color: #60656e; border-color: #e4e7ed; }
.reimbursement-review-choice-popup { background: #fff; }
.reimbursement-review-choice-popup .van-search { padding: 8px 16px; }
.reimbursement-review-choice-popup .van-search__content { border-radius: 8px; background: #f5f6f8; }
.reimbursement-review-choice-popup .van-cell { padding: 12px 18px; font-size: 13px; }
.reimbursement-review-choice-popup .reimbursement-review-choice-selected { color: #1989fa; background: #f0f6ff; }
.reimbursement-review-budget-item + .reimbursement-review-budget-item { border-top: 1px solid #f2f3f5; }
</style>
<div id="app" class="reimbursement-review-page" v-cloak>
<van-sticky>
<van-nav-bar :title="config.title" left-arrow @click-left="back"></van-nav-bar>
<van-dropdown-menu class="reimbursement-review-filters" active-color="#1867b0"
:close-on-click-outside="false" :close-on-click-overlay="false">
<van-dropdown-item ref="yearFilter" :options="yearOptions" :value="pageForm.year" :disabled="filterClosing"
@change="selectFilter('year', $event)"
@open="setFilterOpen('year', true)" @close="setFilterOpen('year', false)"></van-dropdown-item>
<van-dropdown-item ref="organizationFilter" :options="organizationOptions" :value="pageForm[organizationKey]" :disabled="filterClosing"
@change="selectFilter(organizationKey, $event)"
@open="setFilterOpen('organization', true)" @close="setFilterOpen('organization', false)">
<van-loading v-if="organizationLoading" class="reimbursement-review-loading">加载中</van-loading>
</van-dropdown-item>
<van-dropdown-item ref="isAuditFilter" :options="auditOptions" :value="pageForm.isAudit" :disabled="filterClosing"
@change="selectFilter('isAudit', $event)"
@open="setFilterOpen('isAudit', true)" @close="setFilterOpen('isAudit', false)"></van-dropdown-item>
</van-dropdown-menu>
<div class="reimbursement-review-more-bar"><span>{{ searchSummary }}</span><van-button size="mini" plain type="info" @click="openMore">更多筛选</van-button></div>
</van-sticky>
<van-pull-refresh :value="refreshing" @input="setRefreshing" @refresh="doSearch">
<van-list :value="tableLoading" :finished="finished" :error="listError" error-text="加载失败,点击重试"
:finished-text="tableData.length ? '没有更多了' : ''" :immediate-check="false"
@input="setTableLoading" @update:error="setListError" @load="pageData" class="reimbursement-review-list">
<article v-for="item in tableData" :key="item.id" class="reimbursement-review-card">
<h3>{{ item.activity_name }}</h3>
<p><label>申请人:</label>{{ item.user_name }}<span v-if="item.bxr_loginname">({{ item.bxr_loginname }})</span></p>
<p><label>{{ config.stage === 'clubChairman' ? '所属协会:' : '所属工会:' }}</label>{{ config.stage === 'clubChairman' ? (item.clubName || item.fundsUnitName || '—') : (item.unionname || item.fundsUnitName || '—') }}</p>
<p class="reimbursement-review-pair"><span><label>报销:</label>¥{{ money(item.activity_money) }}</span><span><label>申请:</label>{{ shortDate(item.reimbursement_apply_time) }}</span></p>
<div class="reimbursement-review-card-footer">
<span class="reimbursement-review-state" :style="{color: item.state_color || '#b7790c'}">{{ item.state_name || config.title }}</span>
<van-button type="info" size="small" :disabled="formLoading" @click="openDetail(item.id, false)">查看</van-button>
<van-button v-if="Number(item.reimbursement_state_id) === config.state" type="primary" size="small" :disabled="formLoading" @click="openDetail(item.id, true)">审核</van-button>
</div>
</article>
<van-empty v-if="!tableLoading && !listError && !tableData.length" description="暂无报销申请"></van-empty>
</van-list>
</van-pull-refresh>
<van-popup :value="popups.detail" position="right" class="reimbursement-review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="auditMode ? config.title : '报销详情'" left-arrow @click-left="closePopup"></van-nav-bar>
<div class="reimbursement-review-detail-body">
<van-loading v-if="detailLoading" class="reimbursement-review-loading">加载报销中</van-loading>
<van-empty v-else-if="!detail.id" description="详情加载失败">
<van-button size="small" type="info" @click="loadDetail(detailId)">重新加载</van-button>
</van-empty>
<reimbursement-info v-else :data="detail" @preview="previewFile">
<section v-if="auditMode && detail.canReview" class="reimbursement-review-section">
<h3>审核信息</h3>
<van-field label="审核人" :value="reviewerName" readonly></van-field>
<van-field label="审核时间" :value="reviewDate" readonly></van-field>
<van-field label="审核意见" :value="formData.auditOpinion" @input="setIdea" type="textarea" rows="3"
maxlength="500" show-word-limit required placeholder="请填写审核意见" :disabled="formLoading"></van-field>
<van-field label="审核签字" required class="reimbursement-review-sign-field">
<template #input>
<mobile-sign :value="formData.auditSign" @input="setSignature" :disabled="formLoading"
:save-to-profile="false" :popup-visible="popups.sign" panel-title="审核签字"
@open="openSignature" @close="closePopup"></mobile-sign>
</template>
</van-field>
</section>
</reimbursement-info>
</div>
<div v-if="detail.id && !detailLoading" class="reimbursement-review-actions">
<template v-if="auditMode && detail.canReview">
<van-button type="danger" plain :disabled="formLoading" @click="confirmReview(false)">拒绝</van-button>
<van-button type="primary" :loading="formLoading" :disabled="formLoading" @click="confirmReview(true)">通过</van-button>
</template>
<van-button v-else-if="detail.canReview" type="primary" @click="startAudit">去审核</van-button>
<van-button v-else @click="closePopup">关闭</van-button>
</div>
</van-popup>
<van-dialog :value="popups.confirm" :title="reviewFlag ? '确认通过' : '确认拒绝'"
:message="reviewFlag ? config.confirmMessage : '拒绝后将结束当前报销流程,请核对审核意见。'"
show-cancel-button :allow-html="false" :close-on-click-overlay="false" :close-on-popstate="false"
:before-close="beforeReviewClose"></van-dialog>
<van-popup :value="popups.preview" position="right" class="reimbursement-review-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar title="附件查看" left-arrow @click-left="closePopup"></van-nav-bar>
<img v-if="popups.preview && previewIsImage" :src="previewUrl" class="reimbursement-review-preimbursement-review-image" alt="报销附件">
<iframe v-else-if="popups.preview" :src="previewUrl" title="报销附件" class="reimbursement-review-preview"></iframe>
</van-popup>
<van-popup :value="popups.more" position="bottom" round class="reimbursement-review-more-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<div class="reimbursement-review-more-handle" aria-hidden="true"></div>
<header class="reimbursement-review-more-header">
<h2>更多筛选</h2>
<van-button icon="cross" class="reimbursement-review-more-close" aria-label="关闭更多筛选" @click="closePopup"></van-button>
</header>
<div class="reimbursement-review-more-body">
<section v-if="config.canChooseUnion" class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">所属单位</h3>
<van-cell :title="optionText('unitId', moreForm.unitId)" class="reimbursement-review-unit-select" is-link @click="openChoice('unitId')"></van-cell>
</section>
<section v-if="config.showClub && config.stage !== 'clubChairman'" class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">所属协会</h3>
<van-cell :title="optionText('clubId', moreForm.clubId)" class="reimbursement-review-unit-select" is-link @click="openChoice('clubId')"></van-cell>
</section>
<section class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">活动名称</h3>
<van-field class="reimbursement-review-filter-input" left-icon="search" :value="moreForm.activityName"
@input="setMoreField('activityName', $event)" maxlength="100" clearable placeholder="请输入活动名称"></van-field>
</section>
<section class="reimbursement-review-filter-group">
<div class="reimbursement-review-filter-row">
<h3 class="reimbursement-review-filter-label">查询人员</h3>
<div class="reimbursement-review-segment" role="group" aria-label="查询类型">
<van-button v-for="option in choiceOptions.searchName" :key="option.value" class="reimbursement-review-filter-option"
:class="{'is-selected': moreForm.searchName === option.value}" :aria-pressed="moreForm.searchName === option.value"
@click="setMoreField('searchName', option.value)">{{ option.text }}</van-button>
</div>
</div>
<van-field class="reimbursement-review-filter-input" left-icon="search" :value="moreForm.searchKeyword"
@input="setMoreField('searchKeyword', $event)" maxlength="100" clearable
:placeholder="moreSearchPlaceholder" :aria-label="moreSearchPlaceholder"></van-field>
</section>
<section class="reimbursement-review-filter-group">
<h3 class="reimbursement-review-filter-label">排序方式</h3>
<div class="reimbursement-review-sort-options" role="group" aria-label="排序字段">
<van-button v-for="option in choiceOptions.pageOrderName" :key="option.value" class="reimbursement-review-filter-option"
:class="{'is-selected': moreForm.pageOrderName === option.value}" :aria-pressed="moreForm.pageOrderName === option.value"
@click="setMoreField('pageOrderName', option.value)">
{{ option.text }}<van-icon v-if="moreForm.pageOrderName === option.value" name="success" class="reimbursement-review-sort-check"></van-icon>
</van-button>
</div>
<div v-if="moreForm.pageOrderName" class="reimbursement-review-filter-row reimbursement-review-sort-direction">
<h3 class="reimbursement-review-filter-label">排列顺序</h3>
<div class="reimbursement-review-segment" role="group" aria-label="排列顺序">
<van-button v-for="option in choiceOptions.pageOrderBy" :key="option.value" class="reimbursement-review-filter-option"
:class="{'is-selected': moreForm.pageOrderBy === option.value}" :aria-pressed="moreForm.pageOrderBy === option.value"
@click="setMoreField('pageOrderBy', option.value)">{{ option.text }}</van-button>
</div>
</div>
</section>
</div>
<div class="reimbursement-review-more-footer"><van-button @click="resetMore">重置</van-button><van-button type="info" @click="applyMore">确定筛选</van-button></div>
</van-popup>
<van-popup :value="popups.choice" position="bottom" round class="reimbursement-review-choice-popup" :close-on-popstate="false" :close-on-click-overlay="false">
<van-nav-bar :title="choiceTitle" left-text="返回" @click-left="closePopup"></van-nav-bar>
<van-search v-if="choiceKey === 'unitId' || choiceKey === 'clubId'" :value="choiceQuery" @input="setChoiceQuery" :placeholder="choiceKey === 'clubId' ? '搜索协会' : '搜索单位'"></van-search>
<div class="reimbursement-review-more-body">
<van-loading v-if="choiceKey === 'unitId' ? unitsLoading : clubsLoading" class="reimbursement-review-loading">加载选项中</van-loading>
<van-cell v-for="option in filteredChoices" :key="option.value" :title="option.text" :class="{'reimbursement-review-choice-selected': moreForm[choiceKey] === option.value}" clickable @click="selectChoice(option.value)">
<van-icon v-if="moreForm[choiceKey] === option.value" name="success" color="#1989fa"></van-icon>
</van-cell>
<van-empty v-if="!filteredChoices.length" description="暂无选项"></van-empty>
</div>
</van-popup>
</div>
@@ -0,0 +1,147 @@
/** 活动报销审核详情:仅展示服务端已校验权限的数据,审核表单通过末尾插槽传入。 */
const reimbursementInfo = {
template: /*language=HTML*/ `
<div class="reimbursement-review-info">
<section class="reimbursement-review-section">
<h3>{{ data.activity_name }}</h3>
<van-cell title="报销状态" :value="data.state_name"></van-cell>
<van-cell title="申请人" :value="data.user_name + '(' + (data.loginname || '—') + ')'"></van-cell>
<van-cell title="所属工会" :value="data.bxr_unionname || '—'"></van-cell>
<van-cell title="所属单位" :value="data.bxr_unitname || '—'"></van-cell>
<van-cell title="活动主体" :value="activityType"></van-cell>
<van-cell title="申请类型" :value="Number(data.apply_type) === 1 ? '经费申报' : '报销申请'"></van-cell>
<van-cell title="联系方式" :value="data.mobile || '—'"></van-cell>
<van-cell title="报销申请时间" :value="data.reimbursement_apply_time || '—'"></van-cell>
<van-cell title="承办单位/协会" :value="data.clubName || data.fundsUnitName || '—'"></van-cell>
<van-cell title="项目编号" :value="data.projectCode || '—'"></van-cell>
<van-cell title="经费类型" :value="data.projectJfName || '—'"></van-cell>
<van-cell title="计划时间" :value="(data.startPlannedDate || '—') + ' 至 ' + (data.endPlannedDate || '—')"></van-cell>
<van-cell title="活动时间" :value="data.startTime ? data.startTime + ' 至 ' + (data.endTime || '—') : '暂无'"></van-cell>
<van-cell title="实际费用" :value="'¥' + money(data.activity_money)"></van-cell>
<van-cell title="活动地点" :value="data.address || '—'"></van-cell>
<van-cell title="活动人数" :value="data.activity_number || '—'"></van-cell>
</section>
<section class="reimbursement-review-section">
<h3>活动方案及简介</h3>
<div class="reimbursement-review-description">{{ activityText || '暂无' }}</div>
<img v-for="(url, index) in activityImages" :key="index" :src="url" class="reimbursement-review-content-image"
alt="活动方案配图" @click="$emit('preview', {imageUrl: url})">
</section>
<section class="reimbursement-review-section">
<h3>费用及收款信息</h3>
<div v-for="(item, index) in budgets" :key="index" class="reimbursement-review-budget-item">
<van-cell :title="item.name || '费用项目'" :value="'¥' + money(item.budgetPrice)"></van-cell>
<template v-if="data.reimbursement_state_id">
<van-cell title="实际费用" :value="'¥' + money(item.actualPrice)"></van-cell>
<van-cell title="收款人" :value="item.username || '—'"></van-cell>
<van-cell title="支行名称" :value="item.bankName || '—'"></van-cell>
<van-cell title="报销卡号" :value="item.bankCardNum || '—'"></van-cell>
<van-cell title="备注" :value="item.remark || '—'"></van-cell>
</template>
</div>
<van-cell title="报销合计" :value="'¥' + money(data.activity_money)" class="reimbursement-review-total"></van-cell>
<van-cell title="预算合计" :value="'¥' + money(data.funds_money)" class="reimbursement-review-total"></van-cell>
</section>
<section class="reimbursement-review-section">
<h3>申请附件</h3>
<van-cell v-for="(file, index) in files" :key="file.id || index" :title="file.filename || file.name || '附件'" class="reimbursement-review-file"
value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!files.length" class="reimbursement-review-muted">暂无附件</p>
<h3>申请人签字</h3>
<img v-if="data.us_s" class="reimbursement-review-sign-image" :src="signUrl(data.us_s)" alt="申请人签字">
<p v-else class="reimbursement-review-muted">暂无签字</p>
</section>
<section v-for="group in reimbursementFiles" :key="group.key" class="reimbursement-review-section">
<h3>{{ group.title }}</h3>
<van-cell v-for="(file, index) in group.files" :key="file.id || index" :title="file.filename || file.name || '附件'"
class="reimbursement-review-file" value="查看" is-link @click="$emit('preview', file)"></van-cell>
<p v-if="!group.files.length" class="reimbursement-review-muted">暂无附件</p>
</section>
<section v-for="audit in audits" :key="audit.key" class="reimbursement-review-section">
<h3>{{ audit.title }}</h3>
<van-cell title="审核人" :value="audit.name || '—'"></van-cell>
<van-cell title="审核时间" :value="audit.time || '—'"></van-cell>
<van-cell title="审核意见" :value="audit.idea || '暂无'"></van-cell>
<img v-if="audit.sign" class="reimbursement-review-sign-image" :src="signUrl(audit.sign)" :alt="audit.title + '签字'">
</section>
<slot></slot>
</div>
`,
props: {
data: {type: Object, default: () => ({})}
},
computed: {
activityType() {
return {40001: '校工会活动', 40002: '基层工会活动', 40003: '协会活动', 40004: '女职工活动'}[this.data.activity_type] || '—'
},
reimbursementFiles() {
if (!this.data.reimbursement_state_id) return []
return [{key: 'billFiles', title: '发票'}, {key: 'photoFiles', title: '活动报道照片/总结/链接'}, {key: 'otherFiles', title: '奖品/纪念品/服装/道具等活动人员名单'}]
.map((group) => Object.assign({}, group, {files: this.asList(this.data[group.key])}))
},
budgets() {
return this.asList(this.data.budgets)
},
files() {
return this.asList(this.data.files)
},
activityText() {
// 申请内容来自富文本,按段落提取文本,避免在审核页面执行其中的脚本或事件属性。
const container = document.createElement('div')
container.innerHTML = (this.data.activity_content || '').replace(/<\/(p|div|li)>/gi, '\n')
container.querySelectorAll('script,style').forEach((node) => node.remove())
return container.textContent.trim()
},
activityImages() {
// 保留富文本中的方案配图,只提取图片地址,不执行原文中的 HTML 属性和事件。
const content = new DOMParser().parseFromString(this.data.activity_content || '', 'text/html')
return Array.from(content.querySelectorAll('img')).map((img) => img.getAttribute('src') || '')
.filter((url) => /^(https?:\/\/|\/(?!\/)|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url))
},
audits() {
const data = this.data
const records = [
{key: 'division', title: '基层工会主席审核(申报)', id: data.division_chairman_name, name: data.dc_username, time: data.division_chairman_time, idea: data.division_chairman_idea, sign: data.dc_s},
{key: 'association', title: '协会负责人审核(申报)', id: data.association_principal_id,
name: data.ap_username, time: data.association_principal_time, idea: data.association_principal_idea, sign: data.ap_s},
{key: 'accounting', title: '校工会负责人审核(申报)', id: data.accounting_name,
name: data.a_username, time: data.accounting_time, idea: data.accounting_idea, sign: data.a_s},
{key: 'confirm', title: '校工会副主席审核(申报)', id: data.confirm_trade_name, name: data.ct_username, time: data.confirm_trade_time, idea: data.confirm_trade_idea, sign: data.ct_s},
{key: 'cashier', title: '校工会出纳审核(申报)', id: data.union_cashier_name, name: data.uc_username, time: data.union_cashier_time, idea: data.union_cashier_idea, sign: data.uc_s},
{key: 'union', title: '校工会主席审核(申报)', id: data.union_chairman_name,
name: data.ucc_username, time: data.union_chairman_time, idea: data.union_chairman_idea, sign: data.ucc_s}
].filter((audit) => audit.id)
// 按 PC 详情展示报销审核记录,并与前期申报记录明确区分。
const reimbursementAudits = [
{key: 'reimbursement_club_audit', title: '协会负责人审核(报销)'},
{key: 'reimbursement_union_audit', title: '基层工会主席审核(报销)'},
{key: 'reimbursement_school_director', title: '校工会负责人审核(报销)'},
{key: 'reimbursement_school_chairman', title: '校工会主席审核(报销)'}
].filter((item) => data[item.key]).map((item) => {
const audit = data[item.key]
return {key: item.key, title: item.title, name: audit.username, time: audit.auditTime, idea: audit.auditOpinion, sign: audit.auditSign}
})
return records.concat(reimbursementAudits)
}
},
methods: {
/** 历史接口可能返回 JSON 字符串或数组;空值、异常数据均不阻断整页展示。 */
asList(value) {
if (Array.isArray(value)) return value
if (!value) return []
try {
const list = JSON.parse(value)
return Array.isArray(list) ? list : []
} catch (error) {
return []
}
},
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
/** 签字兼容 PC 保存的 Base64 图片和历史文件路径。 */
signUrl(value) {
return value && value.startsWith('data:image/') ? value : '/signature/getSignData?path=' + encodeURIComponent(value || '')
}
}
}
@@ -0,0 +1,381 @@
// 四个报销节点沿用活动申报手机页交互;入口固定节点,报销状态与审核字段独立。
const vue = new Vue({
el: '#app',
components: {'reimbursement-info': reimbursementInfo},
data() {
return {
config: reimbursementReviewConfig,
baseUrl: '/platform/jf/reimbursement/' + reimbursementReviewConfig.stage,
pageForm: {year: new Date().getFullYear(), unionId: '', unitId: '', clubId: '', activityName: '', isAudit: 2, pageNumber: 1, pageSize: 10, searchName: 'u.username', searchKeyword: '', pageOrderName: '', pageOrderBy: 'desc'},
tableData: [], tableLoading: false, refreshing: false, finished: false, listError: false,
listSerial: 0, listBusy: false, detailSerial: 0,
unions: [{text: '全部工会', value: ''}], units: [{text: '全部单位', value: ''}], organizationsLoading: false, unitsLoading: false, unitsSerial: 0,
clubs: [{text: '全部协会', value: ''}], clubsLoading: false,
moreForm: {}, choiceKey: '', choiceQuery: '',
auditOptions: [{text: '未审核', value: 2}, {text: '已审核', value: 1}, {text: '全部', value: 0}],
filterOpen: {year: false, organization: false, isAudit: false}, filterClosing: false,
popups: {filter: false, detail: false, sign: false, confirm: false, preview: false, more: false, choice: false},
detail: {}, detailId: '', detailLoading: false, auditMode: false,
formData: {id: '', auditOpinion: '', auditSign: ''}, formLoading: false, reviewFlag: true,
reviewerName: '', reviewDate: '', previewUrl: '', previewIsImage: false
}
},
computed: {
organizationKey() { return this.config.stage === 'clubChairman' ? 'clubId' : this.config.canChooseUnion ? 'unionId' : 'unitId' },
organizationOptions() { return this.config.stage === 'clubChairman' ? this.clubs : this.config.canChooseUnion ? this.unions : this.units },
organizationLoading() { return this.config.stage === 'clubChairman' ? this.clubsLoading : this.config.canChooseUnion ? this.organizationsLoading : this.unitsLoading },
/** 查询类型直接切换草稿字段,输入提示同步变化;确定筛选前不触发列表请求。 */
moreSearchPlaceholder() { return this.moreForm.searchName === 'u.loginname' ? '请输入工号' : '请输入姓名' },
searchSummary() {
const parts = []
if (this.pageForm.activityName) parts.push(this.pageForm.activityName)
if (this.config.stage !== 'clubChairman' && this.pageForm.clubId) parts.push(this.optionText('clubId', this.pageForm.clubId))
if (this.pageForm.searchKeyword) parts.push(this.pageForm.searchKeyword)
if (this.config.canChooseUnion && this.pageForm.unitId) parts.push(this.optionText('unitId', this.pageForm.unitId))
if (this.pageForm.pageOrderName) parts.push(this.optionText('pageOrderName', this.pageForm.pageOrderName))
return parts.join(' · ') || '按活动名称、姓名、工号筛选'
},
choiceOptions() {
return {
unitId: this.units,
clubId: this.clubs,
searchName: [{text: '姓名', value: 'u.username'}, {text: '工号', value: 'u.loginname'}],
pageOrderName: [{text: '默认排序', value: ''}, {text: '所属工会', value: 'unionname'}, {text: '活动主体类型', value: 'activity_type'}, {text: '经费类型', value: 'absName'}, {text: '申请时间', value: 'reimbursement_apply_time'}, {text: '申请状态', value: 'reimbursement_state_id'}],
pageOrderBy: [{text: '升序', value: 'asc'}, {text: '降序', value: 'desc'}]
}
},
choiceTitle() { return {clubId: '所属协会', unitId: '所属单位', searchName: '查询类型', pageOrderName: '排序字段', pageOrderBy: '排序方式'}[this.choiceKey] || '请选择' },
filteredChoices() {
return (this.choiceOptions[this.choiceKey] || []).filter((item) => item.text.includes(this.choiceQuery.trim()))
},
yearOptions() {
return [{text: '全部年份', value: ''}].concat(Array.from({length: 11}, (_, index) => {
const year = new Date().getFullYear() - index
return {text: year + '年', value: year}
}))
}
},
methods: {
money(value) {
return value == null || !Number.isFinite(Number(value)) ? '—' : Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})
},
shortDate(value) {
return value ? String(value).slice(5, 10) : '—'
},
setRefreshing(value) { this.$set(this, 'refreshing', value) },
setTableLoading(value) { this.$set(this, 'tableLoading', value) },
setListError(value) { this.$set(this, 'listError', value) },
setIdea(value) { this.$set(this.formData, 'auditOpinion', value) },
/** 筛选切换或提交后,从第一页替换数据;旧请求晚到时不能混入当前筛选结果。 */
doSearch() {
this.listSerial++
this.listBusy = false
this.$set(this.pageForm, 'pageNumber', 1)
this.tableData = []
this.finished = false
this.listError = false
this.pageData()
},
/** 列表返回 data.list 和 totalCount;失败保留页码供重试,成功后才推进下一页。 */
pageData() {
if (this.listBusy || this.finished) return
const serial = this.listSerial
this.listBusy = true
this.tableLoading = true
this.listError = false
$.post(this.baseUrl + '/pageData', Object.assign({}, this.pageForm))
.then((res) => {
if (serial !== this.listSerial || this._isDestroyed) return
if (res.code !== 0 || !res.data) {
this.listError = true
this.$toast(res.msg || '列表加载失败')
return
}
const list = res.data.list || []
this.tableData = this.tableData.concat(list)
this.finished = !list.length || this.tableData.length >= Number(res.data.totalCount)
this.$set(this.pageForm, 'pageNumber', this.pageForm.pageNumber + 1)
})
.fail(() => {
if (serial === this.listSerial && !this._isDestroyed) this.listError = true
})
.always(() => {
if (serial !== this.listSerial || this._isDestroyed) return
this.listBusy = false
this.tableLoading = false
this.refreshing = false
})
},
/** 返回 data 数组的组织接口沿用 PC;组织数据范围由服务端控制。 */
loadOrganizations() {
if (this.config.showClub) this.loadClubs()
if (!this.config.showUnit) return
if (this.config.canChooseUnion) {
this.organizationsLoading = true
$.post('/platform/vi/common/unions', {isPermission: false})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) this.unions = [{text: '全部工会', value: ''}].concat((res.data || []).map((item) => ({text: item.unionname, value: item.id})))
else this.$toast(res.msg || '工会加载失败')
})
.fail(() => { if (!this._isDestroyed) this.$toast('工会加载失败,请重试') })
.always(() => { this.organizationsLoading = false })
}
this.loadUnits()
},
/** 协会选项返回 stid/name,与 PC getClubsByRole 的筛选值一致。 */
loadClubs() {
if (this.clubsLoading) return
this.clubsLoading = true
$.post('/platform/vi/common/getClubsByRole', {})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) this.clubs = [{text: '全部协会', value: ''}].concat((res.data || []).map((item) => ({text: item.name, value: item.stid})))
else this.$toast(res.msg || '协会加载失败')
})
.fail(() => { if (!this._isDestroyed) this.$toast('协会加载失败,请重试') })
.always(() => { this.clubsLoading = false })
},
/** 更换工会清空单位;序号避免旧响应覆盖新选项。无单位筛选的协会页不请求该接口。 */
loadUnits() {
if (!this.config.showUnit) return
const serial = ++this.unitsSerial
this.unitsLoading = true
return $.post('/platform/vi/common/units', {unionId: this.config.canChooseUnion ? this.pageForm.unionId : this.config.currentUnionId})
.then((res) => {
if (serial !== this.unitsSerial || this._isDestroyed) return
if (res.code === 0) this.units = [{text: '全部单位', value: ''}].concat((res.data || []).map((item) => ({text: item.name, value: item.id})))
else this.$toast(res.msg || '单位加载失败')
})
.fail(() => { if (serial === this.unitsSerial && !this._isDestroyed) this.$toast('单位加载失败,请重试') })
.always(() => { if (serial === this.unitsSerial) this.unitsLoading = false })
},
openMore() {
this.moreForm = Object.assign({}, this.pageForm)
this.loadUnits()
if (this.config.showClub && this.clubs.length === 1) this.loadClubs()
this.$options.popupSession.open('more')
},
setMoreField(key, value) { this.$set(this.moreForm, key, value) },
setChoiceQuery(value) { this.$set(this, 'choiceQuery', value) },
/** 选择项弹框压在更多筛选上层,返回时只退出当前选择,不丢弃其他筛选值。 */
openChoice(key) {
this.choiceKey = key
this.choiceQuery = ''
this.$options.popupSession.open('choice')
},
selectChoice(value) {
this.$set(this.moreForm, this.choiceKey, value)
this.$options.popupSession.close()
},
optionText(key, value) {
const option = (this.choiceOptions[key] || []).find((item) => item.value === value)
return option ? option.text : '请选择'
},
resetMore() {
this.moreForm = Object.assign({}, this.moreForm, {unitId: this.config.canChooseUnion ? '' : this.pageForm.unitId, searchName: 'u.username', searchKeyword: '', activityName: '', clubId: this.config.stage === 'clubChairman' ? this.pageForm.clubId : '', pageOrderName: '', pageOrderBy: 'desc'})
},
applyMore() {
const values = this.moreForm
this.$options.popupSession.close().then(() => {
if (this._isDestroyed) return
const keys = ['searchName', 'searchKeyword', 'activityName', 'pageOrderName', 'pageOrderBy']
if (this.config.showClub && this.config.stage !== 'clubChairman') keys.push('clubId')
if (this.config.canChooseUnion) keys.push('unitId')
keys.forEach((key) => this.$set(this.pageForm, key, values[key]))
this.doSearch()
})
},
/** key 为年度/组织/状态字段,open 为菜单展开状态;三个菜单切换时共用一层历史。 */
setFilterOpen(key, open) {
this.$set(this.filterOpen, key, open)
if (open && key === 'organization') {
if (this.organizationOptions.length === 1 && !this.organizationLoading) this.loadOrganizations()
}
// Vant 切换菜单会同时触发旧菜单 close 和新菜单 open,等本轮更新结束后统一判断。
this.$nextTick(() => {
if (this._isDestroyed || this.filterClosing) return
if (Object.values(this.filterOpen).some(Boolean)) {
if (!this.popups.filter) this.$options.popupSession.open('filter')
} else if (this.popups.filter) {
this.filterClosing = true
this.$options.popupSession.close().then(() => { this.filterClosing = false })
}
})
},
/** key 为筛选字段,value 为对应选项值;仅值改变时刷新,菜单收起事件负责回退历史。 */
selectFilter(key, value) {
if (this.pageForm[key] === value) return
this.$set(this.pageForm, key, value)
if (key === 'unionId') {
this.$set(this.pageForm, 'unitId', '')
this.units = [{text: '全部单位', value: ''}]
this.loadUnits()
}
this.doSearch()
},
/** 每次打开申请清空上次的意见和签字,防止在不同申请之间串用审核数据。 */
openDetail(id, audit) {
if (this.formLoading) return
this.detailId = id
this.detail = {}
this.auditMode = audit
this.formData = {id, auditOpinion: '', auditSign: ''}
this.reviewDate = moment().format('YYYY-MM-DD')
this.$options.popupSession.open('detail')
this.loadDetail(id)
},
loadDetail(id) {
const serial = ++this.detailSerial
this.detailLoading = true
$.post(this.baseUrl + '/findOne', {id})
.then((res) => {
if (serial !== this.detailSerial || !this.popups.detail || this._isDestroyed) return
if (res.code === 0 && res.data) {
this.detail = res.data
if (!this.detail.canReview) this.auditMode = false
} else {
this.$toast(res.msg || '详情加载失败')
}
})
.fail(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.$toast('详情加载失败,请重试')
})
.always(() => {
if (serial === this.detailSerial && !this._isDestroyed) this.detailLoading = false
})
},
startAudit() {
this.auditMode = true
this.$nextTick(() => {
const body = this.$el.querySelector('.reimbursement-review-detail-body')
if (body) body.scrollTop = body.scrollHeight
})
},
openSignature() {
if (!this.formLoading) this.$options.popupSession.open('sign')
},
/** signature 是 mobile-sign 回传的本次签字 Base64,随审核请求一起保存。 */
setSignature(signature) {
this.$set(this.formData, 'auditSign', signature)
},
confirmReview(flag) {
if (this.formLoading || !this.detail.canReview) return
if (!this.formData.auditOpinion.trim() || this.formData.auditOpinion.trim().length > 500 || !this.formData.auditSign) {
this.$toast('请填写1至500字审核意见并签字')
return
}
this.reviewFlag = flag
this.$options.popupSession.open('confirm')
},
/** Vant 用 done 结束按钮 loading;显隐仍由历史栈驱动,避免组件自行关闭后留下历史层。 */
beforeReviewClose(action, done) {
if (this.formLoading) {
done(false)
return
}
if (action === 'confirm') {
this.submitReview().always(() => done(false))
} else {
this.$options.popupSession.close().then(() => done(false))
}
},
/** id 为报销主键,audit 为意见/签字/通过标识 JSON;返回 code=0 成功,身份与时间由服务端确定。 */
submitReview() {
if (this.formLoading) return
this.formLoading = true
const audit = {auditOpinion: this.formData.auditOpinion.trim(), auditSign: this.formData.auditSign, auditPass: this.reviewFlag}
return $.post(this.baseUrl + '/doAuditing', {id: this.formData.id, audit: JSON.stringify(audit)})
.then((res) => {
if (this._isDestroyed) return
if (res.code === 0) {
this.$toast.success('审核成功')
this.$options.popupSession.clear().then(() => {
if (this._isDestroyed) return
this.formData = {id: '', auditOpinion: '', auditSign: ''}
this.doSearch()
})
} else {
this.$toast(res.msg || '审核失败')
// 关闭确认层后重读状态;若已被 PC 处理则切换为只读,保留未提交的意见便于核对。
if (this.popups.confirm) this.$options.popupSession.close().then(() => this.loadDetail(this.detailId))
}
})
.fail(() => this.$toast('提交失败,请检查网络后重试'))
.always(() => { this.formLoading = false })
},
/** 图片使用文件流,文档沿用项目预览接口;预览自身同样占一层历史。 */
previewFile(file) {
if (file.imageUrl) {
this.previewIsImage = true
this.previewUrl = file.imageUrl
this.$options.popupSession.open('preview')
return
}
if (!file.id) {
this.$toast('附件信息不完整')
return
}
this.previewIsImage = /\.(png|jpe?g|gif|webp|bmp)$/i.test(file.filename || file.filepath || '')
const streamUrl = '/file_server/fileStreamPreview?id=' + encodeURIComponent(file.id)
this.previewUrl = this.previewIsImage
? streamUrl
: '/assets/platform/plugins/pdfJs/web/viewer.html?file=' + encodeURIComponent(streamUrl)
this.$options.popupSession.open('preview')
},
closePopup() {
if (this.formLoading) {
this.$toast('正在提交,请稍候')
return
}
this.$options.popupSession.close()
},
/** 有弹框时仅关闭顶层;列表返回沿用活动申报页的 PJAX 首页入口,避免浏览器历史指向其他页面。 */
back() {
if (Object.values(this.popups).some(Boolean)) {
this.closePopup()
return
}
this.$options.popupSession.clear().then(() => {
this.$destroy()
pjaxReplace('/mobile/index')
})
}
},
created() {
// 仅注册状态同步回调;popstate 由公共管理器统一监听。
this.$options.popupSession = window.mobilePopupHistory.register('reimbursementReview-' + this.config.stage, (layers) => {
Object.keys(this.popups).forEach((name) => this.$set(this.popups, name, layers.includes(name)))
// 系统返回或页面返回先收起下拉菜单;由公共历史栈驱动,页面不另绑 popstate。
if (!layers.includes('filter')) {
Object.keys(this.filterOpen).forEach((key) => {
this.$set(this.filterOpen, key, false)
const dropdown = this.$refs[key + 'Filter']
if (dropdown) dropdown.toggle(false)
})
}
})
const user = JSON.parse(window.sessionStorage.getItem('user') || '{}')
this.reviewerName = user.username || ''
this.loadOrganizations()
this.doSearch()
const query = new URLSearchParams(window.location.search)
const id = query.get('id')
if (id) this.openDetail(id, query.get('mode') === 'audit')
// PJAX 不会自动销毁旧 Vue 实例,在替换容器前注销页面回调。
$(document).on('pjax:beforeReplace.reimbursementReview', () => this.$destroy())
},
beforeDestroy() {
this.listSerial++
this.detailSerial++
this.unitsSerial++
const session = this.$options.popupSession
if (Object.values(this.popups).some(Boolean)) {
session.clear().then(() => session.dispose())
} else {
// PJAX 替换后可能立即创建新页面;空栈必须同步注销,不能等 Promise 回调再释放注册。
session.dispose()
}
$(document).off('pjax:beforeReplace.reimbursementReview')
}
})
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'schoolChairman', title: '校工会主席审核(报销)', state: 1040,
confirmMessage: '通过后将完成本次活动报销流程。', showClub: true, showUnit: true,
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'schoolDirector', title: '校工会负责人审核(报销)', state: 1020,
confirmMessage: '通过后将转交校工会主席审核。', showClub: true, showUnit: true,
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')||@shiro.hasRole('H03')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -0,0 +1,18 @@
<!--#
layout("/mobile/platform.html"){
#-->
<!--#include('/mobile/jf/reimbursement/review/index.html'){}#-->
<script>
// 本页固定审核节点;服务端按相同节点校验权限、归属及报销状态。
const reimbursementReviewConfig = {
stage: 'unionChairman', title: '基层工会主席审核(报销)', state: 1010,
confirmMessage: '通过后将转交校工会负责人审核。', showClub: false, showUnit: true,
canChooseUnion: ${@shiro.hasRole('sysadmin')||@shiro.hasRole('A06')},
currentUnionId: '${@io.v.nutz.base.utils.Vi.getUnionId()}'
}
<!--#include('/mobile/jf/reimbursement/review/info.js'){}#-->
<!--#include('/mobile/jf/reimbursement/review/page.js'){}#-->
</script>
<!--#
}
#-->
@@ -25,6 +25,8 @@
<script src="${base!}/assets/platform/plugins/jquery/jquery-1.11.1.min.js"></script>
<script src="${base!}/assets/platform/plugins/pjax/jquery.pjax.js"></script>
<!-- PJAX 会晚于片段内脚本加载外链脚本,弹框历史管理必须随公共布局提前加载。 -->
<script src="${base!}/assets/mobile/js/popupHistory.js"></script>
<script src="${base!}/assets/platform/plugins/jSignature/jSignature.min.js"></script>
<script src="${base!}/assets/platform/plugins/jSignature/flashcanvas.js"></script>
<script src="${base!}/assets/platform/js/pinyin.js"></script>
@@ -79,7 +81,9 @@
}
})()
function pjaxReplace(url){
window.pjaxReplace = (url) => {
// 普通表单的导航也先消费公共签字层,避免携带未回退的弹框历史进入下一页面。
if (window.mobilePopupHistory.closeTop()) return
$.pjax({
url: url,
container: "#container",
@@ -1,53 +1,49 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<title>签字</title>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" />
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vant/vant.css">
<script src="${base!}/assets/platform/plugins/vue/vue.js"></script>
<script src="${base!}/assets/platform/plugins/vant/vant.js"></script>
<script src="${base!}/assets/platform/plugins/vue-loader/httpVueLoader.js"></script>
<script src="${base!}/assets/platform/plugins/smooth-signature/index.umd.min.js"></script>
<script src="${base!}/assets/platform/plugins/jquery/jquery-1.11.1.min.js"></script>
<script src="${base!}/assets/mobile/js/popupHistory.js"></script>
<style>body { margin: 0; background: #f7f8fa; } [v-cloak] { display: none; }</style>
</head>
<body>
<div id="app">
<signature @save="save"></signature>
<div id="app" v-cloak>
<van-nav-bar title="签字"></van-nav-bar>
<van-notice-bar v-if="subDis" text="缺少签字参数,请重新扫码。"></van-notice-bar>
<mobile-sign :value="sign" :save-to-profile="false" :popup-visible="showSignaturePanel"
:disabled="subDis || formLoading" @input="save" @open="openSignature" @close="closeSignature"></mobile-sign>
</div>
</body>
<script>
function getQueryVariable(variable) {
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split("=");
if (pair[0] == variable) {
return pair[1];
}
}
return '';
}
new Vue({
el: "#app",
components: {
signature: httpVueLoader("/components/sign/index.vue?v=" + new Date().getTime())
'mobile-sign': httpVueLoader("/components/sign/mobileSign.vue?v=" + new Date().getTime())
},
data() {
return {
prefix: '',
id: '',
isNull: function (val) {
if (val === null || val === undefined || val === '') {
return true
}
return false
},
}
return {prefix: '', id: '', sign: '', subDis: false, formLoading: false, showSignaturePanel: false}
},
methods: {
openSignature() {
if (!this.subDis && !this.formLoading) this.$options.popupSession.open('sign')
},
closeSignature() {
if (!this.formLoading) this.$options.popupSession.close()
},
/**
* signature 为公共签字板回传的 PNG Base64;prefix/id 来自二维码,关联 PC 端轮询。
* 保留 is_value_base64=false 的文件保存方式;接口返回 {code, msg},code=0 表示成功。
*/
save(signature) {
if (this.formLoading || this.subDis) return
this.sign = signature
this.formLoading = true
$.post("/signature/doSub", {
prefix: this.prefix,
id: this.id,
@@ -56,44 +52,31 @@
})
.then((res) => {
if (res.code === 0) {
alert("保存成功")
this.showSignaturePanel = false
this.$toast.success("保存成功")
this.$options.popupSession.clear()
} else {
alert("保存失败")
this.$toast.fail(res.msg || "保存失败,请重试")
}
})
},
base64ToFile(base64Data, filename) {
// 将base64的数据部分提取出来
const parts = base64Data.split(";base64,")
const contentType = parts[0].split(":")[1]
const raw = window.atob(parts[1])
const rawLength = raw.length
const uInt8Array = new Uint8Array(rawLength)
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i)
}
// 使用Blob对象创建File对象
const blob = new Blob([uInt8Array], { type: contentType })
blob.lastModifiedDate = new Date()
blob.name = filename
return new File([blob], filename, { type: contentType })
.fail(() => this.$toast.fail("保存失败,请重试"))
.always(() => { this.formLoading = false })
}
},
created: function () {
if (this.isNull(getQueryVariable("id")) || this.isNull(getQueryVariable("prefix"))) {
this.$notify({
message: '缺少参数!',
type: 'danger'
});
this.subDis = true
}
this.id = getQueryVariable("id")
this.prefix = getQueryVariable("prefix")
created() {
// 扫码入口也是独立页面,使用公共历史栈统一处理返回、取消及保存成功后的清理。
this.$options.popupSession = window.mobilePopupHistory.register('qrSignature', (layers) => {
this.showSignaturePanel = layers.includes('sign')
})
const query = new URLSearchParams(window.location.search)
this.id = query.get('id') || ''
this.prefix = query.get('prefix') || ''
this.subDis = !this.id || !this.prefix
},
beforeDestroy() {
const session = this.$options.popupSession
session.clear().then(() => session.dispose())
}
})
</script>
</body>
</html>