| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677 |
- <script setup lang="ts">
- import { reactive, ref, computed, onMounted } from 'vue'
- import { ElMessage } from 'element-plus'
- import { Check, Download, View, Upload, Close, Plus, Delete, ZoomIn, Document } from '@element-plus/icons-vue'
- import { paymentApi, attachmentApi, advanceApi } from '../../api'
- import { ElMessageBox } from 'element-plus'
- import { statusLabel, statusTone } from '../../utils/format'
- import { useServerPaged } from '../../hooks/useServerPaged'
- import ListPageScaffold from '../../components/template/ListPageScaffold.vue'
- import BaseButton from '../../components/base/BaseButton.vue'
- import BaseDialog from '../../components/base/BaseDialog.vue'
- import BaseInput from '../../components/base/BaseInput.vue'
- import BaseSelect from '../../components/base/BaseSelect.vue'
- import BaseTag from '../../components/base/BaseTag.vue'
- import BaseOpButton from '../../components/base/BaseOpButton.vue'
- import ApprovalFlowView from '../../components/base/ApprovalFlowView.vue'
- import BaseClampText from '../../components/base/BaseClampText.vue'
- import BaseDetailTableSection from '../../components/base/BaseDetailTableSection.vue'
- type DialogKey = 'issue' | 'view'
- const showMoreFilters = ref(false)
- const loadingMap = reactive<Record<string, boolean>>({})
- const dialogs = reactive<Record<DialogKey, boolean>>({
- issue: false,
- view: false
- })
- const activeTab = ref('pending')
- const filterForm = reactive({
- keyword: '',
- applicant: '',
- })
- const { rows, total, currentPage, pageSize, rangeText, loading, refresh, search, onSizeChange } = useServerPaged(
- async (params) => {
- const issueStatus = activeTab.value === 'pending' ? 'pending' : 'issued'
- const res: any = await paymentApi.advanceIssues({ ...params, issueStatus })
- const data = res.data
- if (Array.isArray(data)) {
- return { rows: data, total: data.length }
- }
- return { rows: data?.rows ?? [], total: data?.total ?? 0 }
- }
- )
- const stats = computed(() => {
- const list = rows.value
- const unissued = list.filter((r: any) => r.issueStatus !== 'issued').length
- const issued = list.filter((r: any) => r.issueStatus === 'issued').length
- const issuedAmount = list.filter((r: any) => r.issueStatus === 'issued').reduce((s: number, r: any) => s + Number(r.amount || 0), 0)
- const unissuedAmount = list.filter((r: any) => r.issueStatus !== 'issued').reduce((s: number, r: any) => s + Number(r.amount || 0), 0)
- return [
- { name: '待发放', value: String(unissued) },
- { name: '已发放', value: String(issued) },
- { name: '本月发放金额(元)', value: issuedAmount.toLocaleString('zh-CN') },
- { name: '待发放金额(元)', value: unissuedAmount.toLocaleString('zh-CN') }
- ]
- })
- const currentRow = ref<Record<string, any>>({})
- const selectedRows = ref<any[]>([])
- const issueRemark = ref('')
- const viewData = ref<Record<string, any>>({})
- const actualAmount = ref<number | string>('')
- const uploadedFiles = ref<Array<{ id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string }>>([])
- const uploadLoading = ref(false)
- const previewVisible = ref(false)
- const previewUrl = ref('')
- const previewFileName = ref('')
- const viewAttachments = ref<Array<any>>([])
- const approvalNodes = ref<Array<any>>([])
- const approvalLoading = ref(false)
- const offsetRecords = ref<Array<any>>([])
- function issueStatusLabel(status: string) {
- return status === 'issued' ? '已发放' : '待发放'
- }
- function issueStatusTone(status: string) {
- return status === 'issued' ? 'done' : 'warning'
- }
- onMounted(() => {
- refresh()
- })
- function onSelectionChange(rows: any[]) {
- selectedRows.value = rows
- }
- function handleToolbarIssue() {
- if (selectedRows.value.length === 0) {
- ElMessage.warning('请先勾选一条待发放记录')
- return
- }
- openIssueDialog(selectedRows.value[0])
- }
- function toggleMoreFilters() {
- showMoreFilters.value = !showMoreFilters.value
- }
- function handleTabChange(tab: string | number) {
- activeTab.value = String(tab)
- Object.assign(filterForm, { keyword: '', applicant: '' })
- search()
- }
- function handleSearch() {
- search({ ...filterForm })
- }
- function handleReset() {
- Object.assign(filterForm, { keyword: '', applicant: '' })
- search()
- }
- async function handleExport() {
- loadingMap.export = true
- try {
- const issueStatus = activeTab.value === 'pending' ? 'pending' : 'issued'
- const res: any = await paymentApi.exportAdvanceIssues({
- issueStatus,
- keyword: filterForm.keyword,
- })
- const blob = new Blob([res], { type: 'text/csv;charset=utf-8' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- const statusLabel = activeTab.value === 'pending' ? '待发放' : '已发放'
- a.download = `备用金${statusLabel}列表.csv`
- a.click()
- URL.revokeObjectURL(url)
- ElMessage.success('导出成功')
- } catch (e: any) {
- ElMessage.error(e?.message || '导出失败')
- } finally {
- loadingMap.export = false
- }
- }
- function openIssueDialog(row: any) {
- currentRow.value = row
- issueRemark.value = ''
- actualAmount.value = row.amount || ''
- uploadedFiles.value = []
- dialogs.issue = true
- }
- async function handleUpload(options: any) {
- uploadLoading.value = true
- try {
- const file = options.file as File
- const res: any = await attachmentApi.upload(file, 'advance_issue', currentRow.value.id)
- const fileData: { id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string } = {
- id: res.data.id,
- fileName: res.data.fileName,
- fileType: res.data.fileType || '',
- fileSize: res.data.fileSize,
- previewUrl: '',
- }
- if (isImageFile(fileData.fileType)) {
- try {
- const previewRes: any = await attachmentApi.preview(fileData.id)
- fileData.previewUrl = previewRes.data.url
- } catch { /* preview not critical */ }
- }
- uploadedFiles.value.push(fileData)
- } catch (e: any) {
- ElMessage.error(e.message || '上传失败')
- } finally {
- uploadLoading.value = false
- }
- }
- async function handleRemoveAttachment(index: number) {
- const file = uploadedFiles.value[index]
- try {
- await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
- await attachmentApi.delete(file.id)
- uploadedFiles.value.splice(index, 1)
- ElMessage.success('已删除')
- } catch { /* cancelled or already errored */ }
- }
- async function handleDownload(file: { id: number; fileName: string }) {
- try {
- const res: any = await attachmentApi.download(file.id)
- const blob = new Blob([res], { type: 'application/octet-stream' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = file.fileName
- a.click()
- URL.revokeObjectURL(url)
- } catch (e: any) {
- ElMessage.error(e.message || '下载失败')
- }
- }
- async function handlePreview(file: { id: number; fileName: string; fileType: string }) {
- const isImage = file.fileType?.startsWith('image/')
- if (!isImage) {
- handleDownload(file)
- return
- }
- try {
- const res: any = await attachmentApi.preview(file.id)
- previewUrl.value = res.data.url
- previewFileName.value = file.fileName
- previewVisible.value = true
- } catch (e: any) {
- ElMessage.error(e.message || '预览失败')
- }
- }
- function formatFileSize(bytes: number): string {
- if (bytes < 1024) return bytes + ' B'
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
- }
- function isImageFile(fileType: string): boolean {
- return fileType?.startsWith('image/')
- }
- function fileExt(name: string): string {
- const ext = name.split('.').pop()
- return ext ? ext.toUpperCase() : 'FILE'
- }
- async function submitIssue() {
- loadingMap.issueSubmit = true
- try {
- await paymentApi.issueAdvance(currentRow.value.id, {
- issueRemark: issueRemark.value,
- actualAmount: actualAmount.value !== '' ? Number(actualAmount.value) : undefined,
- })
- ElMessage.success('发放成功')
- dialogs.issue = false
- await refresh()
- } catch (e: any) {
- ElMessage.error(e.message || '发放失败')
- } finally {
- loadingMap.issueSubmit = false
- }
- }
- async function openView(row: any) {
- try {
- viewData.value = row
- viewAttachments.value = []
- approvalNodes.value = []
- offsetRecords.value = []
- // Load attachments for issued records
- if (row.issueStatus === 'issued' && row.id) {
- const res: any = await attachmentApi.listByBiz('advance_issue', row.id)
- const attachments = (res.data || []).map((a: any) => ({ ...a, previewUrl: '' }))
- const imagePromises = attachments
- .filter((a: any) => isImageFile(a.fileType || ''))
- .map(async (a: any) => {
- try {
- const pRes: any = await attachmentApi.preview(a.id)
- a.previewUrl = pRes.data.url
- } catch { /* skip */ }
- })
- await Promise.all(imagePromises)
- viewAttachments.value = attachments
- }
- // Load approval flow
- if (row.id) {
- approvalLoading.value = true
- try {
- const [aRes, offsetRes]: any[] = await Promise.all([
- advanceApi.getApprovals(row.id),
- paymentApi.advanceOffsetRecords(row.id).catch(() => ({ data: [] })),
- ])
- approvalNodes.value = aRes.data || []
- offsetRecords.value = offsetRes.data || []
- } catch { /* skip */ }
- finally {
- approvalLoading.value = false
- }
- }
- dialogs.view = true
- } catch (e: any) {
- ElMessage.error(e.message || '加载详情失败')
- }
- }
- </script>
- <template>
- <div>
- <div class="page-head">
- <h1>备用金发放管理</h1>
- <p class="desc">审批通过后由财务人员操作标记发放并上传发放截图。</p>
- </div>
- <div class="stats">
- <div v-for="item in stats" :key="item.name" class="stat">
- <p class="k">{{ item.name }}</p>
- <p class="v">{{ item.value }}</p>
- </div>
- </div>
- <el-tabs v-model="activeTab" class="issue-tabs" @tab-change="handleTabChange">
- <el-tab-pane label="待发放" name="pending" />
- <el-tab-pane label="已发放" name="issued" />
- </el-tabs>
- <ListPageScaffold
- panel-title=""
- :show-more="showMoreFilters"
- :selected-count="selectedRows.length"
- :rows="rows"
- :range-text="rangeText"
- :total="total"
- :current-page="currentPage"
- :page-size="pageSize"
- :loading="loading"
- :table-max-height="'min(520px, calc(100vh - 360px))'"
- @toggle-more="toggleMoreFilters"
- @reset="handleReset"
- @search="handleSearch"
- @update:current-page="currentPage = $event"
- @update:page-size="pageSize = $event"
- @size-change="onSizeChange"
- @selection-change="onSelectionChange"
- >
- <template #filters>
- <div class="filter-grid">
- <div class="filter-field">
- <label>申请单号</label>
- <BaseInput v-model="filterForm.keyword" />
- </div>
- <div class="filter-field">
- <label>申请人</label>
- <BaseInput v-model="filterForm.applicant" />
- </div>
- </div>
- </template>
- <template #toolbar>
- <BaseButton v-if="activeTab === 'pending'" type="primary" plain :icon="Check" @click="handleToolbarIssue">标记已发放</BaseButton>
- <BaseButton :loading="loadingMap.export" :icon="Download" @click="handleExport">导出</BaseButton>
- </template>
- <template #table-columns>
- <el-table-column type="selection" width="48" />
- <el-table-column type="index" label="序号" width="70" />
- <el-table-column prop="applyCode" label="申请单号" min-width="140" />
- <el-table-column prop="applicantName" label="申请人" min-width="100" />
- <el-table-column prop="reason" label="申请原因" min-width="160" />
- <el-table-column prop="approveTime" label="审批通过时间" min-width="140" />
- <el-table-column v-if="activeTab === 'issued'" prop="issueTime" label="发放时间" min-width="140">
- <template #default="{ row }">
- {{ row.issueTime ?? '-' }}
- </template>
- </el-table-column>
- <el-table-column v-if="activeTab === 'issued'" prop="issueUserName" label="发放操作人" min-width="110">
- <template #default="{ row }">
- {{ row.issueUserName ?? '-' }}
- </template>
- </el-table-column>
- <el-table-column label="操作" :width="activeTab === 'pending' ? '260' : '140'" fixed="right" class-name="col-actions">
- <template #default="{ row }">
- <BaseOpButton v-if="activeTab === 'pending'" :icon="Check" @click="openIssueDialog(row)">
- 标记发放
- </BaseOpButton>
- <BaseOpButton primary :icon="View" @click="openView(row)">
- 查看详情
- </BaseOpButton>
- </template>
- </el-table-column>
- </template>
- </ListPageScaffold>
- <!-- 标记发放弹窗 -->
- <BaseDialog v-model="dialogs.issue" title="标记已发放" width="600px">
- <div class="form-section">
- <div class="form-grid-2">
- <div class="field">
- <label>发放金额(元)</label>
- <BaseInput :model-value="String(currentRow.amount || '')" disabled placeholder="发放金额" />
- </div>
- <div class="field">
- <label>实际发放金额(元)</label>
- <BaseInput v-model="actualAmount" placeholder="请输入实际发放金额" />
- </div>
- </div>
- </div>
- <div class="form-section">
- <h4>发放备注</h4>
- <div class="field field-no-margin">
- <label class="sr-only">发放备注</label>
- <BaseInput v-model="issueRemark" type="textarea" :rows="3" placeholder="请输入发放备注" />
- </div>
- </div>
- <div class="form-section form-section-last">
- <h4>发放附件</h4>
- <div class="attachment-upload-area">
- <el-upload
- :http-request="handleUpload"
- :show-file-list="false"
- multiple
- :disabled="uploadLoading"
- >
- <div class="upload-trigger">
- <el-icon class="upload-icon"><Plus /></el-icon>
- <p>点击上传附件</p>
- </div>
- </el-upload>
- <div v-if="uploadedFiles.length" class="attachment-grid">
- <div v-for="(file, idx) in uploadedFiles" :key="file.id" class="attachment-card">
- <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
- <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
- <div v-else class="card-file-icon">
- <el-icon :size="32"><Document /></el-icon>
- <span class="card-ext">{{ fileExt(file.fileName) }}</span>
- </div>
- </div>
- <div class="card-footer">
- <el-button size="small" text @click="handleRemoveAttachment(idx)"><el-icon><Delete /></el-icon> 删除</el-button>
- <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
- </div>
- </div>
- </div>
- </div>
- </div>
- <template #footer>
- <BaseButton :icon="Close" @click="dialogs.issue = false">取消</BaseButton>
- <BaseButton type="primary" :loading="loadingMap.issueSubmit" :icon="Check" @click="submitIssue">确认发放</BaseButton>
- </template>
- </BaseDialog>
- <!-- 图片预览弹窗 -->
- <el-dialog v-model="previewVisible" title="图片预览" width="700px" class="bx-dialog" align-center>
- <div class="img-preview-wrap">
- <img :src="previewUrl" :alt="previewFileName" class="img-preview" />
- </div>
- </el-dialog>
- <!-- 查看详情弹窗 -->
- <BaseDialog v-model="dialogs.view" title="备用金发放详情" width="920px" class="dialog-view-xl">
- <div class="dlg-view dlg-view-scroll">
- <div class="dlg-view-hero">
- <div class="dlg-view-hero-top">
- <h2 class="dlg-view-title">{{ viewData.applyCode || '-' }}</h2>
- <div class="dlg-view-tags">
- <BaseTag :tone="issueStatusTone(viewData.issueStatus)">{{ issueStatusLabel(viewData.issueStatus) }}</BaseTag>
- </div>
- </div>
- <p class="dlg-view-meta">
- 申请金额(元):<b class="money-em">¥{{ Number(viewData.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</b>
- <template v-if="viewData.actualAmount != null">
- 实际发放金额(元):<b class="money-em">¥{{ Number(viewData.actualAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</b>
- </template>
- </p>
- </div>
- <div class="dlg-view-section">
- <h4>申请信息</h4>
- <div class="dlg-kv dlg-kv--triple">
- <div class="k">申请人</div>
- <div class="v">{{ viewData.applicantName || '-' }}</div>
- <div class="k">申请原因</div>
- <div class="v"><BaseClampText :text="viewData.reason" /></div>
- <div class="k">审批通过时间</div>
- <div class="v">{{ viewData.approveTime || '-' }}</div>
- </div>
- </div>
- <div class="dlg-view-section">
- <h4>审批流程</h4>
- <div class="flow-scroll">
- <ApprovalFlowView :nodes="approvalNodes" :loading="approvalLoading" />
- </div>
- </div>
- <div v-if="viewData.issueStatus === 'issued'" class="dlg-view-section">
- <h4>发放信息</h4>
- <div class="dlg-kv dlg-kv--triple">
- <div class="k">发放时间</div>
- <div class="v">{{ viewData.issueTime || '-' }}</div>
- <div class="k">发放操作人</div>
- <div class="v">{{ viewData.issueUserName || '-' }}</div>
- <div class="k">发放备注</div>
- <div class="v kv-full"><BaseClampText :text="viewData.issueRemark" /></div>
- </div>
- </div>
- <BaseDetailTableSection title="冲抵流水" :data="offsetRecords" empty-text="暂无冲抵记录">
- <el-table :data="offsetRecords" size="small" border>
- <el-table-column prop="reimburseApplyCode" label="报销单号" min-width="130" />
- <el-table-column prop="reimburseApplicantName" label="报销申请人" min-width="100" />
- <el-table-column label="冲抵金额(元)" min-width="120" align="right">
- <template #default="{ row: offsetRow }">
- {{ Number(offsetRow.offsetAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
- </template>
- </el-table-column>
- <el-table-column prop="createTime" label="冲抵时间" min-width="160" />
- <el-table-column prop="operator" label="操作人" min-width="100" />
- <el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
- </el-table>
- </BaseDetailTableSection>
- <div v-if="viewAttachments.length" class="dlg-view-section">
- <h4>发放附件</h4>
- <div class="attachment-grid">
- <div v-for="file in viewAttachments" :key="file.id" class="attachment-card">
- <div class="card-preview" @click="isImageFile(file.fileType || '') && handlePreview(file)">
- <img v-if="isImageFile(file.fileType || '') && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
- <div v-else class="card-file-icon">
- <el-icon :size="32"><Document /></el-icon>
- <span class="card-ext">{{ fileExt(file.fileName) }}</span>
- </div>
- </div>
- <div class="card-footer">
- <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </BaseDialog>
- </div>
- </template>
- <style scoped>
- .dlg-view-scroll {
- max-height: 60vh;
- overflow-y: auto;
- }
- .attachment-upload-area {
- margin-top: 4px;
- }
- .upload-trigger {
- width: 100%;
- border: 1px dashed var(--border);
- border-radius: var(--radius-md);
- padding: 20px;
- text-align: center;
- cursor: pointer;
- transition: border-color 0.2s;
- color: var(--text-muted);
- }
- .upload-trigger:hover {
- border-color: var(--primary);
- color: var(--primary-dark);
- }
- .upload-trigger .upload-icon {
- font-size: 24px;
- margin-bottom: 6px;
- }
- .upload-trigger p {
- margin: 0;
- font-size: 13px;
- }
- .attachment-grid {
- margin-top: 10px;
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
- gap: 12px;
- }
- .attachment-card {
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- overflow: hidden;
- background: var(--surface);
- transition: box-shadow 0.2s;
- }
- .attachment-card:hover {
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
- }
- .card-preview {
- width: 100%;
- aspect-ratio: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- background: #f8fafc;
- cursor: default;
- }
- .card-preview img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- .card-preview:has(img) {
- cursor: pointer;
- }
- .card-file-icon {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 4px;
- color: var(--text-muted);
- }
- .card-ext {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-muted);
- }
- .card-footer {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 2px;
- padding: 4px 0;
- border-top: 1px solid var(--border);
- background: var(--surface);
- }
- .card-footer .el-button {
- font-size: 12px;
- padding: 4px 8px;
- height: auto;
- }
- /* Tab styles */
- :deep(.issue-tabs .el-tabs__header) {
- margin-bottom: 0;
- }
- :deep(.issue-tabs .el-tabs__nav-wrap::after) {
- display: none;
- }
- :deep(.issue-tabs .el-tabs__item) {
- font-size: 15px;
- font-weight: 600;
- color: var(--text-muted);
- padding: 0 20px;
- height: 44px;
- line-height: 44px;
- }
- :deep(.issue-tabs .el-tabs__item.is-active) {
- color: var(--primary-dark);
- }
- :deep(.issue-tabs .el-tabs__active-bar) {
- background-color: var(--primary);
- height: 3px;
- border-radius: 2px;
- }
- :deep(.issue-tabs .el-tabs__item:hover) {
- color: var(--text);
- }
- </style>
|