feat(miniprogram): migrate advance apply list/form/detail from bx-mobile.

Reuse /advances APIs with bx envelope and Fresh Mint list/form styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-11 23:20:54 +08:00
1 parent 507823dfce
commit 2ff754d572
9 files changed
+2264 -188

No files matched your search

+33 -186
View File
@@ -1,191 +1,38 @@
/**
* 报销模块 API(REST,协议 code===0 / msg / data.rows)
* 路径挂在 /api 下:/reimburses、/advances、/expense-items、/attachments
*/
import { request, parseResponseJson } from '@/utils/request'
import { bxRequest, bxUploadFile } from '@/utils/bxRequest'
function unwrap(res) {
const body = parseResponseJson(res)
if (!body || typeof body !== 'object') {
throw new Error('接口响应异常')
}
if (body.code != null && body.code !== 0) {
throw new Error(body.msg || body.message || '请求失败')
}
return body.data != null ? body.data : body
export const advanceApi = {
my: (params) => bxRequest({ url: '/advances/my', method: 'GET', params }),
getById: (id) => bxRequest({ url: `/advances/${id}`, method: 'GET' }),
getApprovals: (id) => bxRequest({ url: `/advances/${id}/approvals`, method: 'GET' }),
getApprovalHistory: (id) => bxRequest({ url: `/advances/${id}/approval-history`, method: 'GET' }),
create: (data) => bxRequest({ url: '/advances/', method: 'POST', data }),
update: (id, data) => bxRequest({ url: `/advances/${id}`, method: 'PUT', data }),
submit: (id) => bxRequest({ url: `/advances/${id}/submit`, method: 'POST' }),
delete: (id) => bxRequest({ url: `/advances/${id}`, method: 'DELETE' })
}
async function get(url, params = {}) {
const res = await request({ url, method: 'GET', data: params, params })
return unwrap(res)
/** 附件:Batch2 申请页图片上传所需最小集合 */
export const attachmentApi = {
upload: (filePath, bizType = '', bizId = 0) =>
bxUploadFile(filePath, { bizType, bizId }),
listByBiz: (bizType, bizId) =>
bxRequest({
url: '/attachments/list',
method: 'GET',
params: { biz_type: bizType, biz_id: bizId }
}),
preview: (id) => bxRequest({ url: `/attachments/preview/${id}`, method: 'GET' }),
delete: (id) => bxRequest({ url: `/attachments/${id}`, method: 'DELETE' }),
bindBiz: (ids, bizType, bizId) =>
bxRequest({
url: '/attachments/bind-biz',
method: 'PUT',
params: {
ids: (ids || []).join(','),
biz_type: bizType,
biz_id: bizId
}
})
}
async function post(url, data = {}) {
const res = await request({ url, method: 'POST', data })
return unwrap(res)
}
async function put(url, data = {}, params = {}) {
const res = await request({ url, method: 'PUT', data, params })
return unwrap(res)
}
function pageParams(query = {}) {
const { pageNum, page, pageSize, ...rest } = query
const params = {
page: pageNum ?? page ?? 1,
pageSize: pageSize ?? 20
}
Object.keys(rest).forEach((k) => {
if (rest[k] != null && rest[k] !== '') params[k] = rest[k]
})
return params
}
function normalizeList(data) {
if (Array.isArray(data)) return { rows: data, total: data.length }
const d = data || {}
return {
rows: d.rows || d.list || [],
total: d.total ?? 0
}
}
// ── 费用项目 ──
export async function getExpenseItemTree() {
const data = await get('/expense-items/tree')
return Array.isArray(data) ? data : []
}
/** 展平可选叶子费用项目(供 picker) */
export function flattenExpenseLeaves(tree, path = []) {
const out = []
;(tree || []).forEach((node) => {
const name = node.itemName || node.item_name || ''
const nextPath = path.concat(name)
const children = node.children || []
if (children.length) {
out.push(...flattenExpenseLeaves(children, nextPath))
} else {
out.push({
id: String(node.id),
name,
pathLabel: nextPath.join(' / ')
})
}
})
return out
}
// ── 报销 ──
export async function listMyReimburse(query = {}) {
return normalizeList(await get('/reimburses/my', pageParams(query)))
}
export async function listPendingReimburse(query = {}) {
return normalizeList(await get('/reimburses/pending-approval', pageParams(query)))
}
export async function listApprovedByMeReimburse(query = {}) {
return normalizeList(await get('/reimburses/approved-by-me', pageParams(query)))
}
export async function getReimburse(id) {
return get(`/reimburses/${String(id)}`)
}
export async function generateReimburseTitle(reimburseDate) {
const params = {}
if (reimburseDate) params.reimburseDate = reimburseDate
return get('/reimburses/generate-title', params)
}
export function createReimburse(data) {
return post('/reimburses/', data)
}
export function updateReimburse(id, data) {
return put(`/reimburses/${String(id)}`, data)
}
export function submitReimburse(id) {
return post(`/reimburses/${String(id)}/submit`)
}
export function approveReimburse(id, approvalOpinion = '') {
return post(`/reimburses/${String(id)}/approve`, { approvalOpinion })
}
export function rejectReimburse(id, approvalOpinion = '') {
return post(`/reimburses/${String(id)}/reject`, { approvalOpinion })
}
export async function getReimburseApprovalHistory(id) {
const data = await get(`/reimburses/${String(id)}/approval-history`)
return Array.isArray(data) ? data : []
}
// ── 借款(备用金) ──
export async function listMyAdvance(query = {}) {
return normalizeList(await get('/advances/my', pageParams(query)))
}
export async function listPendingAdvance(query = {}) {
return normalizeList(await get('/advances/pending-approval', pageParams(query)))
}
export async function listApprovedByMeAdvance(query = {}) {
return normalizeList(await get('/advances/approved-by-me', pageParams(query)))
}
export async function getAdvance(id) {
return get(`/advances/${String(id)}`)
}
export function createAdvance(data) {
return post('/advances/', data)
}
export function submitAdvance(id) {
return post(`/advances/${String(id)}/submit`)
}
export function approveAdvance(id, approvalOpinion = '') {
return post(`/advances/${String(id)}/approve`, { approvalOpinion })
}
export function rejectAdvance(id, approvalOpinion = '') {
return post(`/advances/${String(id)}/reject`, { approvalOpinion })
}
export async function getAdvanceApprovalHistory(id) {
const data = await get(`/advances/${String(id)}/approval-history`)
return Array.isArray(data) ? data : []
}
// ── 附件 ──
export async function listAttachments(bizType, bizId) {
const data = await get('/attachments/list', {
biz_type: bizType,
biz_id: String(bizId)
})
return Array.isArray(data) ? data : []
}
export async function bindAttachments(ids, bizType, bizId) {
if (!ids?.length) return 0
const q = [
`ids=${encodeURIComponent(ids.map(String).join(','))}`,
`biz_type=${encodeURIComponent(bizType)}`,
`biz_id=${encodeURIComponent(String(bizId))}`
].join('&')
return put(`/attachments/bind-biz?${q}`, {})
}
export async function previewAttachment(id) {
return get(`/attachments/preview/${String(id)}`)
}
export default { advanceApi, attachmentApi }
+2
View File
@@ -50,6 +50,8 @@ export const PAGE_PATHS = {
dbWeighingList: '/pages/db/weighing/list',
dbWeighingDetail: '/pages/db/weighing/detail',
bxAdvanceApply: '/pages/bx/advance/list',
bxAdvanceApplyForm: '/pages/bx/advance/apply',
bxAdvanceDetail: '/pages/bx/advance/detail',
bxReimburseApply: '/pages/bx/reimburse/list',
bxMine: '/pages/bx/mine/list',
bxAll: '/pages/bx/all/list',
+8
View File
@@ -84,6 +84,14 @@
"path": "pages/bx/advance/list",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "备用金申请" }
},
{
"path": "pages/bx/advance/apply",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "备用金申请" }
},
{
"path": "pages/bx/advance/detail",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "备用金详情" }
},
{
"path": "pages/bx/reimburse/list",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "报销申请" }
+326
View File
@@ -0,0 +1,326 @@
<template>
<view class="bx-page bx-page--form">
<view class="topbar">
<view class="topbar-left">
<view class="topbar-back" @click="goBack">
<text class="topbar-back-icon">‹</text>
</view>
</view>
<text class="topbar-title">备用金申请</text>
<view class="topbar-right" />
</view>
<view class="page-padded">
<view class="form-card">
<view class="form-header">
<text class="form-header-title">备用金信息</text>
<text class="form-badge">{{ isEdit ? '编辑' : '新建' }}</text>
</view>
<view class="field">
<text class="field-label">申请原因<text class="req">*</text></text>
<textarea
class="form-textarea"
:class="{ error: fieldErr.reason }"
:value="form.reason"
placeholder="请输入申请原因"
:maxlength="500"
@input="onReasonInput"
/>
</view>
<view class="field">
<text class="field-label">申请金额<text class="req">*</text></text>
<view class="form-input-prefix">
<input
class="form-input"
:class="{ error: fieldErr.amount }"
type="digit"
:value="form.amount"
placeholder="请输入金额"
@input="onAmountInput"
/>
</view>
</view>
<view class="field">
<text class="field-label">备注</text>
<textarea
class="form-textarea"
:value="form.remark"
placeholder="选填"
:maxlength="500"
@input="onRemarkInput"
/>
</view>
</view>
<view class="form-card">
<view class="form-header">
<text class="form-header-title">附件</text>
</view>
<button
class="btn btn-primary btn-sm"
:disabled="uploading"
@click="chooseAttachments"
>
{{ uploading ? '上传中…' : '上传图片' }}
</button>
<view v-if="files.length" class="att-grid">
<view v-for="(file, idx) in files" :key="file.id" class="att-img">
<image :src="file.previewUrl || file.localPath" mode="aspectFill" />
<view v-if="!file._pending" class="del" @click.stop="removeFile(idx)">×</view>
</view>
</view>
<view v-else class="detail-section-empty" style="margin-top: 8px">暂无附件(可选)</view>
</view>
</view>
<view class="bottom-action-bar">
<button class="btn btn-primary btn-block" :disabled="submitting" @click="onSubmit">
{{ submitting ? '保存中…' : '保存' }}
</button>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { PAGE_PATHS } from '@/config/api'
import { advanceApi, attachmentApi } from '@/api/bx'
const MAX_ATTACHMENTS = 10
const editId = ref('')
const savedId = ref('')
const submitting = ref(false)
const uploading = ref(false)
const files = ref([])
const form = reactive({
reason: '',
amount: '',
remark: ''
})
const fieldErr = reactive({ reason: false, amount: false })
const isEdit = computed(() => !!editId.value || !!savedId.value)
function onReasonInput(e) {
form.reason = e.detail.value
fieldErr.reason = false
}
function onAmountInput(e) {
form.amount = e.detail.value
fieldErr.amount = false
}
function onRemarkInput(e) {
form.remark = e.detail.value
}
function goBack() {
uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxAdvanceApply }) })
}
function validate() {
fieldErr.reason = false
fieldErr.amount = false
if (!form.reason?.trim()) {
fieldErr.reason = true
uni.showToast({ title: '请输入申请原因', icon: 'none' })
return false
}
const raw = String(form.amount ?? '').trim()
if (!raw) {
fieldErr.amount = true
uni.showToast({ title: '请输入申请金额', icon: 'none' })
return false
}
const amt = Number(raw)
if (Number.isNaN(amt) || amt <= 0) {
fieldErr.amount = true
uni.showToast({ title: '请输入大于 0 的申请金额', icon: 'none' })
return false
}
return true
}
async function chooseAttachments() {
const remain = MAX_ATTACHMENTS - files.value.length
if (remain <= 0) {
uni.showToast({ title: `最多上传 ${MAX_ATTACHMENTS} 个附件`, icon: 'none' })
return
}
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const paths = res.tempFilePaths || []
for (const path of paths) {
await uploadOne(path)
}
}
})
}
async function uploadOne(filePath) {
const pendingId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
files.value.push({
id: pendingId,
previewUrl: filePath,
localPath: filePath,
_pending: true,
_existing: false
})
uploading.value = true
try {
const res = await attachmentApi.upload(filePath, '', 0)
const data = res?.data || {}
const id = String(data.id || '')
let previewUrl = filePath
if (id) {
try {
const pRes = await attachmentApi.preview(id)
if (pRes?.data?.url) previewUrl = pRes.data.url
} catch (e) {
// keep local
}
}
const idx = files.value.findIndex((f) => f.id === pendingId)
if (idx >= 0) {
files.value[idx] = {
id: id || pendingId,
previewUrl,
localPath: filePath,
_pending: false,
_existing: false
}
}
} catch (e) {
files.value = files.value.filter((f) => f.id !== pendingId)
} finally {
uploading.value = files.value.some((f) => f._pending)
}
}
function removeFile(idx) {
const file = files.value[idx]
if (!file) return
if (file._existing && file.id && !String(file.id).startsWith('pending_')) {
attachmentApi.delete(file.id).catch(() => {})
}
files.value.splice(idx, 1)
}
async function bindNewAttachments(bizId) {
const newIds = files.value
.filter((f) => f.id && !f._existing && !f._pending && !String(f.id).startsWith('pending_'))
.map((f) => f.id)
if (!newIds.length) return
await attachmentApi.bindBiz(newIds, 'advance', bizId)
files.value.forEach((f) => {
if (newIds.includes(f.id)) f._existing = true
})
}
async function loadEditData(id) {
try {
const res = await advanceApi.getById(id)
const data = res?.data || {}
form.reason = data.reason || ''
form.amount = data.amount != null ? String(data.amount) : ''
form.remark = data.remark != null ? String(data.remark) : ''
try {
const attRes = await attachmentApi.listByBiz('advance', id)
const list = attRes?.data || []
const mapped = []
for (const a of list) {
let previewUrl = ''
if ((a.fileType || '').startsWith('image/')) {
try {
const pRes = await attachmentApi.preview(a.id)
previewUrl = pRes?.data?.url || ''
} catch (e) {
// skip
}
}
mapped.push({
id: String(a.id),
previewUrl,
_pending: false,
_existing: true
})
}
files.value = mapped
} catch (e) {
files.value = []
}
} catch (e) {
uni.showToast({ title: '加载数据失败', icon: 'none' })
}
}
async function onSubmit() {
if (!validate()) return
if (uploading.value) {
uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
return
}
submitting.value = true
const isEditingAction = !!editId.value || !!savedId.value
try {
const data = {
reason: form.reason.trim(),
amount: Number(form.amount),
remark: form.remark
}
let id
if (editId.value || savedId.value) {
id = savedId.value || editId.value
await advanceApi.update(id, data)
await bindNewAttachments(id)
} else {
const res = await advanceApi.create(data)
id = String(res?.data?.id ?? res?.id ?? '')
if (!id) throw new Error('保存成功但未返回申请单ID')
await bindNewAttachments(id)
savedId.value = id
editId.value = id
}
uni.showModal({
title: isEditingAction ? '保存成功' : '创建成功',
content: '备用金申请已保存为草稿',
confirmText: '提交审批',
confirmColor: '#00a874',
cancelText: '继续编辑',
success: async (modalRes) => {
if (!modalRes.confirm) return
try {
await advanceApi.submit(id)
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => goBack(), 500)
} catch (e) {
// toasted
}
}
})
} catch (e) {
// toasted by bxRequest when BizError
} finally {
submitting.value = false
}
}
onLoad((query) => {
const id = query?.id ? String(query.id) : ''
if (id) {
editId.value = id
loadEditData(id)
}
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
</style>
+346
View File
@@ -0,0 +1,346 @@
<template>
<view class="bx-page bx-page--form">
<view class="topbar">
<view class="topbar-left">
<view class="topbar-back" @click="goBack">
<text class="topbar-back-icon">‹</text>
</view>
</view>
<text class="topbar-title">备用金详情</text>
<view class="topbar-right" />
</view>
<template v-if="detail">
<view class="advance-ticket-shell">
<view class="advance-ticket-stack">
<view class="advance-ticket-top">
<text class="advance-ticket-code">{{ detail.applyCode || '—' }}</text>
<text
class="advance-ticket-status"
:class="ticketHeaderStatusToneClass(detail.status)"
>
{{ statusLabel(detail.status) }}
</text>
</view>
<view class="advance-ticket-tear">
<view class="advance-ticket-tear__line" />
</view>
<view class="advance-ticket-bottom">
<view class="advance-ticket-reason">{{ detail.reason || '—' }}</view>
<view class="advance-ticket-amount">{{ formatAmount(detail.amount) }}</view>
</view>
</view>
</view>
<view class="detail-tabs">
<view
class="detail-tab"
:class="{ active: detailTab === 0 }"
@click="detailTab = 0"
>
基础信息
</view>
<view
class="detail-tab"
:class="{ active: detailTab === 1 }"
@click="detailTab = 1"
>
审批信息
</view>
<view
class="detail-tab"
:class="{ active: detailTab === 2 }"
@click="detailTab = 2"
>
发放信息
</view>
</view>
<view v-if="detailTab === 0" class="page-padded advance-detail-stack">
<view class="detail-info-card">
<view class="adv-field">
<text class="adv-field__label">申请单号</text>
<text class="adv-field__value">{{ detail.applyCode || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">申请原因</text>
<text class="adv-field__value adv-field__value--multiline">{{ detail.reason || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">实际发放金额</text>
<text
class="adv-field__value"
:class="{ 'adv-field__value--accent': detail.actualAmount != null }"
>
{{
detail.actualAmount != null
? '¥ ' + formatAmount(detail.actualAmount)
: '—'
}}
</text>
</view>
<view class="adv-field">
<text class="adv-field__label">申请人</text>
<text class="adv-field__value">{{ detail.applicantName || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">当前节点</text>
<text class="adv-field__value">{{ detail.currentNode || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">申请时间</text>
<text class="adv-field__value">{{ formatApplyDateTime(detail) }}</text>
</view>
<view v-if="detail.remark" class="adv-field">
<text class="adv-field__label">备注</text>
<text class="adv-field__value adv-field__value--multiline">{{ detail.remark }}</text>
</view>
</view>
<view class="detail-info-card">
<view class="detail-info-card__title">申请附件</view>
<view v-if="attachments.length" class="att-grid">
<view
v-for="file in attachments"
:key="file.id"
class="att-img"
@click="previewAtt(file)"
>
<image
v-if="file.previewUrl"
:src="file.previewUrl"
mode="aspectFill"
/>
<view v-else class="detail-section-empty" style="border: none; padding: 8px">文件</view>
</view>
</view>
<view v-else class="detail-section-empty">暂无申请附件</view>
</view>
</view>
<view v-else-if="detailTab === 1" class="page-padded advance-detail-stack">
<view class="detail-info-card" style="padding: 14px 16px">
<template v-if="approvals.length">
<view v-for="(a, i) in approvals" :key="a.id ?? i" class="flow-step">
<view
class="flow-avatar"
:class="avatarClass(a)"
>
{{ (a.approverName || a.nodeName || a.nodeLabel || '审')[0] }}
</view>
<view class="flow-info">
<view class="flow-name">{{ a.nodeName || a.nodeLabel || a.approverName || '—' }}</view>
<view v-if="a.approverName" class="flow-role">{{ a.approverName }}</view>
<view v-if="a.approvalTime" class="flow-time">{{ formatDateTime(a.approvalTime) }}</view>
<view v-if="a.approvalOpinion" class="adv-flow-opinion">{{ a.approvalOpinion }}</view>
</view>
</view>
</template>
<view v-else class="detail-section-empty">暂无审批记录</view>
</view>
</view>
<view v-else class="page-padded advance-detail-stack">
<template v-if="isAdvanceIssued">
<view class="detail-info-card">
<view class="adv-field">
<text class="adv-field__label">发放时间</text>
<text class="adv-field__value">{{ formatDateTime(detail.issueTime) || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">发放操作人</text>
<text class="adv-field__value">{{ detail.issueUserName || '—' }}</text>
</view>
<view v-if="detail.issueRemark" class="adv-field">
<text class="adv-field__label">发放备注</text>
<text class="adv-field__value adv-field__value--multiline">{{ detail.issueRemark }}</text>
</view>
</view>
</template>
<view v-else class="detail-info-card" style="padding: 28px 16px">
<view class="detail-section-empty">尚未发放</view>
</view>
</view>
</template>
<view v-else class="page-body-loading">加载中...</view>
<view v-if="showActions" class="bottom-action-bar">
<template v-if="detail?.status === 'draft'">
<button
class="btn btn-primary"
style="flex: 1"
:disabled="actionLoading.submit"
@click="handleSubmit"
>
提交审批
</button>
<button
class="btn btn-danger"
style="flex: 1"
:disabled="actionLoading.delete"
@click="handleDelete"
>
删除
</button>
</template>
<template v-if="detail?.status === 'rejected'">
<button class="btn btn-primary" style="flex: 1" @click="handleResubmit">重新提交</button>
</template>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { PAGE_PATHS } from '@/config/api'
import { advanceApi, attachmentApi } from '@/api/bx'
import {
statusLabel,
ticketHeaderStatusToneClass,
formatAmount,
formatDateTime,
formatApplyDateTime
} from '@/utils/bxFormat'
const detailId = ref('')
const detail = ref(null)
const detailTab = ref(0)
const approvals = ref([])
const attachments = ref([])
const actionLoading = reactive({ submit: false, delete: false })
const isAdvanceIssued = computed(() => {
const d = detail.value
return !!(d && (d.issueStatus === 'issued' || d.status === 'issued'))
})
const showActions = computed(() => {
if (!detail.value) return false
const status = detail.value.status
return status === 'draft' || status === 'rejected'
})
function goBack() {
uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxAdvanceApply }) })
}
function avatarClass(a) {
const s = a.approvalStatus || a.status
if (s === 'approved') return 'av-done'
if (s === 'pending') return 'av-current'
if (s === 'rejected') return 'av-reject'
return 'av-pending'
}
function previewAtt(file) {
if (!file.previewUrl) return
uni.previewImage({ urls: [file.previewUrl], current: file.previewUrl })
}
async function loadDetail() {
const id = detailId.value
if (!id) return
try {
const res = await advanceApi.getById(id)
detail.value = res.data
} catch (e) {
uni.showToast({ title: '加载详情失败', icon: 'none' })
return
}
try {
const histRes = await advanceApi.getApprovalHistory(id)
const hist = histRes.data || []
if (Array.isArray(hist) && hist.length) {
approvals.value = hist
} else {
const apprRes = await advanceApi.getApprovals(id)
const nodes = apprRes.data || []
approvals.value = nodes.slice().sort((a, b) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
}
} catch (e) {
try {
const apprRes = await advanceApi.getApprovals(id)
const nodes = apprRes.data || []
approvals.value = nodes.slice().sort((a, b) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
} catch (err) {
approvals.value = []
}
}
try {
const attRes = await attachmentApi.listByBiz('advance', id)
const list = (attRes.data || []).map((a) => ({ ...a, previewUrl: '' }))
await Promise.all(
list
.filter((a) => (a.fileType || '').startsWith('image/'))
.map(async (a) => {
try {
const pRes = await attachmentApi.preview(a.id)
a.previewUrl = pRes.data?.url || ''
} catch (e) {
// skip
}
})
)
attachments.value = list
} catch (e) {
attachments.value = []
}
}
async function handleSubmit() {
if (!detail.value) return
actionLoading.submit = true
try {
await advanceApi.submit(detail.value.id)
uni.showToast({ title: '提交成功', icon: 'success' })
await loadDetail()
} catch (e) {
// toasted
} finally {
actionLoading.submit = false
}
}
function handleDelete() {
if (!detail.value) return
uni.showModal({
title: '确认删除',
content: '确定要删除该备用金申请吗?删除后不可恢复。',
confirmText: '确定删除',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
actionLoading.delete = true
try {
await advanceApi.delete(detail.value.id)
uni.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => goBack(), 500)
} catch (e) {
// toasted
} finally {
actionLoading.delete = false
}
}
})
}
function handleResubmit() {
if (!detail.value) return
uni.navigateTo({
url: `${PAGE_PATHS.bxAdvanceApplyForm}?id=${detail.value.id}`
})
}
onLoad((query) => {
detailId.value = query?.id ? String(query.id) : ''
loadDetail()
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
</style>
+291 -2
View File
@@ -1,7 +1,296 @@
<template>
<bx-placeholder-page default-title="备用金申请" />
<view class="bx-page">
<view class="topbar">
<view class="topbar-left">
<view class="topbar-back" @click="goBack">
<text class="topbar-back-icon">‹</text>
</view>
</view>
<text class="topbar-title">备用金申请</text>
<view class="topbar-right" />
</view>
<view class="fab-add" @click="goApply()">+</view>
<view class="search-wrap">
<view class="search-box">
<text class="search-box__icon">⌕</text>
<input
class="search-input"
v-model="keyword"
placeholder="搜索备用金申请"
confirm-type="search"
@confirm="onRefresh"
/>
<view v-if="keyword.trim()" class="search-box__clear" @click="clearKeyword">×</view>
</view>
</view>
<view class="search-wrap" style="padding-top: 0">
<scroll-view scroll-x class="search-filter search-filter--scroll" :show-scrollbar="false">
<view
v-for="tab in tabs"
:key="tab.value === '' ? '__all' : tab.value"
class="filter-tag"
:class="{ active: activeTab === tab.value }"
@click="onTabChange(tab.value)"
>
{{ tab.label }}
</view>
</scroll-view>
</view>
<scroll-view
class="page-padded list-scroll"
scroll-y
:style="{ height: listHeight }"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onPullRefresh"
@scrolltolower="onLoadMore"
>
<view v-if="!list.length && !loading" class="empty-state empty-state--fill">
<text>暂无备用金记录</text>
</view>
<view
v-for="item in list"
:key="item.id"
class="expense-card"
@click="goDetail(item.id)"
>
<view class="ec-top">
<view class="ec-type">
<text class="advance-list-card__title">{{ item.reason || '—' }}</text>
</view>
<text class="status-tag" :class="workflowListStatusTagClass(item.status)">
{{ statusLabel(item.status) }}
</text>
</view>
<view class="ec-amount-label">申请金额</view>
<view class="ec-amount">{{ formatAmount(item.amount) }}</view>
<view class="advance-card-subs">
<view class="advance-card-sub-row">
<text class="k">实际发放金额</text>
<text class="v">¥ {{ actualIssuedDisplay(item) }}</text>
</view>
<view class="advance-card-sub-row">
<text class="k">实际冲抵</text>
<text class="v">¥ {{ formatAmount(offsetTotal(item)) }}</text>
</view>
</view>
<view class="list-card-kv">
<view class="list-card-kv__row">
<text class="list-card-kv__k">申请单号</text>
<text class="list-card-kv__v">{{ item.applyCode || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">申请人</text>
<text class="list-card-kv__v">{{ item.applicantName || '-' }}</text>
</view>
<view v-if="item.currentNode" class="list-card-kv__row">
<text class="list-card-kv__k">当前环节</text>
<text class="list-card-kv__v">{{ item.currentNode }}</text>
</view>
<view v-if="item.remark" class="list-card-kv__row">
<text class="list-card-kv__k">备注</text>
<text class="list-card-kv__v">{{ item.remark }}</text>
</view>
</view>
<view class="ec-meta">
<text>{{ formatApplyDate(item) }}</text>
</view>
<view class="ec-bottom" @click.stop>
<view v-if="item.status === 'draft' || item.status === 'rejected'" class="op-actions">
<template v-if="item.status === 'draft'">
<button class="btn btn-sm btn-danger" @click="handleDelete(item)">删除</button>
<button class="btn btn-sm btn-primary" @click="goApply(item.id)">编辑</button>
<button class="btn btn-sm btn-primary" @click="handleSubmit(item)">提交</button>
</template>
<template v-else>
<button class="btn btn-sm btn-primary" @click="goDetail(item.id)">查看</button>
<button class="btn btn-sm btn-primary" @click="goApply(item.id)">编辑</button>
<button class="btn btn-sm btn-primary" @click="handleSubmit(item)">提交</button>
</template>
</view>
<view v-else class="op-actions">
<button class="btn btn-sm btn-primary" @click="goDetail(item.id)">查看</button>
</view>
</view>
</view>
<view v-if="list.length" class="list-footer-tip">
{{ finished ? '没有更多了' : loading ? '加载中...' : '上拉加载更多' }}
</view>
</scroll-view>
</view>
</template>
<script setup>
import BxPlaceholderPage from '@/components/bx-ui/bx-placeholder-page.vue'
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { PAGE_PATHS } from '@/config/api'
import { advanceApi } from '@/api/bx'
import {
statusLabel,
workflowListStatusTagClass,
formatAmount,
formatApplyDate
} from '@/utils/bxFormat'
const tabs = [
{ label: '全部', value: '' },
{ label: '待提交', value: 'draft' },
{ label: '审批中', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已驳回', value: 'rejected' },
{ label: '已发放', value: 'issued' }
]
const keyword = ref('')
const activeTab = ref('')
const list = ref([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const pageSize = 10
const listHeight = ref('60vh')
function computeListHeight() {
try {
const sys = uni.getSystemInfoSync()
const h = (sys.windowHeight || 600) - 210
listHeight.value = `${Math.max(h, 320)}px`
} catch (e) {
listHeight.value = '60vh'
}
}
function actualIssuedDisplay(item) {
const raw = item.actualAmount ?? item.actual_amount
if (raw == null || raw === '') return '—'
const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
if (Number.isNaN(n)) return '—'
return formatAmount(n)
}
function offsetTotal(item) {
const raw = item.offsetAmount ?? item.offset_amount ?? 0
const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
return Number.isNaN(n) ? 0 : n
}
async function fetchPage() {
if (loading.value && !refreshing.value) return
loading.value = true
try {
const params = {
page: currentPage.value,
pageSize
}
if (activeTab.value) params.status = activeTab.value
if (keyword.value.trim()) params.keyword = keyword.value.trim()
const res = await advanceApi.my(params)
const rows = res.data?.rows || []
const total = res.data?.total || 0
if (currentPage.value === 1) {
list.value = rows
} else {
list.value = list.value.concat(rows)
}
currentPage.value += 1
finished.value = rows.length < pageSize || list.value.length >= total
} catch (e) {
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
function onRefresh() {
currentPage.value = 1
finished.value = false
loading.value = false
list.value = []
fetchPage()
}
function onPullRefresh() {
refreshing.value = true
currentPage.value = 1
finished.value = false
fetchPage()
}
function onLoadMore() {
if (finished.value || loading.value) return
fetchPage()
}
function onTabChange(value) {
activeTab.value = value
onRefresh()
}
function clearKeyword() {
keyword.value = ''
onRefresh()
}
function goBack() {
uni.navigateBack({ fail: () => uni.switchTab({ url: PAGE_PATHS.workbench }) })
}
function goApply(id) {
const q = id ? `?id=${id}` : ''
uni.navigateTo({ url: `${PAGE_PATHS.bxAdvanceApplyForm}${q}` })
}
function goDetail(id) {
uni.navigateTo({ url: `${PAGE_PATHS.bxAdvanceDetail}?id=${id}` })
}
async function handleSubmit(item) {
try {
await advanceApi.submit(item.id)
uni.showToast({ title: '提交成功', icon: 'success' })
onRefresh()
} catch (e) {
// bxRequest already toasted
}
}
function handleDelete(item) {
uni.showModal({
title: '删除确认',
content: '确定要删除该申请单吗?删除后不可恢复。',
confirmText: '确定删除',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
try {
await advanceApi.delete(item.id)
uni.showToast({ title: '删除成功', icon: 'success' })
onRefresh()
} catch (e) {
// toasted
}
}
})
}
onShow(() => {
computeListHeight()
onRefresh()
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
.list-scroll {
box-sizing: border-box;
}
</style>
+997
View File
@@ -0,0 +1,997 @@
/* BX Fresh Mint — 备用金 list/apply/detail 所需样式(对齐 bx-mobile)
* 在页面根节点加 class="bx-page",避免污染全局 .btn / .status-tag
*/
.bx-page {
--primary: #00c98d;
--primary-dark: #00a874;
--primary-darker: #008f62;
--primary-bg: #eafff6;
--primary-light: #f0fdf9;
--success: #10b981;
--success-bg: #ecfdf5;
--success-border: #a7f3d0;
--warning: #f59e0b;
--warning-bg: #fffbeb;
--warning-border: #fcd34d;
--danger: #ef4444;
--danger-bg: #fef2f2;
--danger-border: #fecaca;
--info: #3b82f6;
--info-bg: #eff6ff;
--info-border: #93c5fd;
--processing: #6366f1;
--processing-bg: #eef2ff;
--processing-border: #a5b4fc;
--text: #1a1a2e;
--text-sec: #64748b;
--text-muted: #94a3b8;
--bg: #f8fafc;
--surface: #fff;
--border: #e2e8f0;
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
--r: 12px;
--rs: 8px;
--rl: 16px;
--font: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--empty-fill-chrome: 230px;
min-height: 100vh;
padding-bottom: 80px;
box-sizing: border-box;
font-family: var(--font);
font-size: 14px;
color: var(--text);
background: var(--bg);
line-height: 1.5;
}
.bx-page.bx-page--form {
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
}
.bx-page .page-padded {
padding: 0 16px;
}
.bx-page > .topbar + .page-padded {
padding-top: 5px;
}
.bx-page .search-wrap + .page-padded {
padding-top: 5px;
}
/* Topbar */
.bx-page .topbar {
position: sticky;
top: 0;
z-index: 50;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: calc(58px + env(safe-area-inset-top, 0px));
padding: env(safe-area-inset-top, 0px) 12px 0;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 0 rgba(217, 232, 226, 0.85), 0 8px 20px rgba(15, 23, 42, 0.06);
}
.bx-page .topbar::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: calc(58px + env(safe-area-inset-top, 0px));
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.1),
rgba(236, 253, 245, 0.55) 45%,
rgba(255, 255, 255, 0) 100%
);
pointer-events: none;
}
.bx-page .topbar > view,
.bx-page .topbar > text {
position: relative;
}
.bx-page .topbar-left {
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
.bx-page .topbar-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text);
border-radius: 10px;
}
.bx-page .topbar-back:active {
opacity: 0.75;
}
.bx-page .topbar-back-icon {
font-size: 22px;
line-height: 1;
font-weight: 500;
}
.bx-page .topbar-title {
justify-self: center;
text-align: center;
font-size: 17px;
font-weight: 800;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bx-page .topbar-right {
min-width: 0;
}
/* Search */
.bx-page .search-wrap {
padding: 12px 16px;
background: var(--surface);
}
.bx-page .search-box {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: 1.5px solid var(--border);
border-radius: 20px;
padding: 0 14px;
height: 40px;
}
.bx-page .search-box__icon {
color: var(--text-muted);
font-size: 14px;
flex-shrink: 0;
}
.bx-page .search-box input,
.bx-page .search-box .search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: var(--text);
height: 38px;
}
.bx-page .search-box__clear {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 16px;
}
.bx-page .search-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 0;
}
.bx-page .search-filter--scroll {
flex-wrap: nowrap;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 4px;
}
.bx-page .filter-tag {
display: inline-flex;
padding: 4px 12px;
border-radius: 14px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
color: var(--text-sec);
background: var(--surface);
white-space: nowrap;
flex-shrink: 0;
}
.bx-page .filter-tag.active {
background: var(--primary-bg);
border-color: var(--primary);
color: var(--primary-dark);
}
/* Expense card */
.bx-page .expense-card {
background: var(--surface);
border-radius: var(--r);
padding: 16px;
margin-bottom: 10px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.bx-page .expense-card:active {
opacity: 0.96;
}
.bx-page .ec-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.bx-page .ec-type {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.3;
}
.bx-page .advance-list-card__title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bx-page .status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border);
white-space: nowrap;
line-height: 1.25;
background: var(--bg);
color: var(--text-sec);
flex-shrink: 0;
}
.bx-page .status-tag.s-draft {
background: #f1f5f9;
color: #64748b;
border-color: rgba(100, 116, 139, 0.15);
}
.bx-page .status-tag.s-pending {
background: var(--warning-bg);
color: var(--warning);
border-color: var(--warning-border);
}
.bx-page .status-tag.s-approved {
background: var(--primary-bg);
color: var(--primary-dark);
border-color: rgba(0, 201, 141, 0.2);
}
.bx-page .status-tag.s-rejected {
background: #fef2f2;
color: #ef4444;
border-color: rgba(239, 68, 68, 0.2);
}
.bx-page .status-tag.s-withdrawn {
background: #fffbeb;
color: #d97706;
border-color: rgba(245, 158, 11, 0.25);
}
.bx-page .status-tag.s-issued {
background: #eef2ff;
color: #6366f1;
border-color: rgba(99, 102, 241, 0.25);
}
.bx-page .status-tag.s-paid,
.bx-page .status-tag.s-processed {
background: var(--primary-bg);
color: var(--primary-dark);
border-color: rgba(0, 201, 141, 0.2);
}
.bx-page .status-tag.s-offset {
background: #fffbeb;
color: #d97706;
border-color: rgba(245, 158, 11, 0.25);
}
.bx-page .ec-amount-label {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 2px;
}
.bx-page .ec-amount {
font-size: 24px;
font-weight: 800;
color: var(--primary-dark) !important;
margin-bottom: 6px;
font-variant-numeric: tabular-nums;
}
.bx-page .ec-amount::before {
content: '¥';
font-size: 14px;
margin-right: 2px;
}
.bx-page .advance-card-subs {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
padding: 8px 0 2px;
border-top: 1px solid var(--border);
}
.bx-page .advance-card-sub-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
line-height: 1.35;
}
.bx-page .advance-card-sub-row .k {
color: var(--text-sec);
flex-shrink: 0;
margin-right: 8px;
}
.bx-page .advance-card-sub-row .v {
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
text-align: right;
}
.bx-page .list-card-kv {
margin-top: 10px;
padding: 10px 12px;
border-radius: var(--rs);
background: var(--bg);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
}
.bx-page .list-card-kv__row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
line-height: 1.45;
}
.bx-page .list-card-kv__k {
flex-shrink: 0;
color: var(--text-muted);
font-size: 11px;
max-width: 42%;
}
.bx-page .list-card-kv__v {
flex: 1;
min-width: 0;
text-align: right;
color: var(--text);
font-weight: 500;
word-break: break-word;
}
.bx-page .ec-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-muted);
margin-top: 8px;
}
.bx-page .ec-bottom {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.bx-page .op-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Buttons(覆盖全局 .btn) */
.bx-page .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 40px;
padding: 0 20px;
border-radius: var(--rs);
font-size: 14px;
font-weight: 600;
font-family: var(--font);
border: 1px solid transparent;
line-height: 1;
box-sizing: border-box;
}
.bx-page .btn::after {
border: none;
}
.bx-page .btn-primary {
background: var(--primary) !important;
color: #fff !important;
border-color: var(--primary);
}
.bx-page .btn-danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger);
}
.bx-page .btn-secondary {
background: var(--text-sec) !important;
color: #fff !important;
border-color: var(--text-sec);
}
.bx-page .btn-block {
width: 100%;
}
.bx-page .btn-sm,
.bx-page .op-actions .btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
border-radius: 8px;
}
.bx-page .btn[disabled],
.bx-page .btn-loading {
opacity: 0.65;
}
/* Form */
.bx-page .form-card {
background: var(--surface);
border-radius: var(--r);
padding: 16px;
border: 1px solid var(--border);
}
.bx-page .form-card + .form-card {
margin-top: 12px;
}
.bx-page .form-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.bx-page .form-header h3,
.bx-page .form-header .form-header-title {
font-size: 16px;
font-weight: 700;
margin: 0;
}
.bx-page .form-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
background: var(--primary-bg);
color: var(--primary-dark);
}
.bx-page .field {
margin-bottom: 14px;
}
.bx-page .field-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.bx-page .field-label .req {
color: var(--danger);
margin-left: 2px;
}
.bx-page .form-input,
.bx-page .form-textarea {
width: 100%;
padding: 0 12px;
border: 1.5px solid var(--border);
border-radius: var(--rs);
font-size: 14px;
font-family: var(--font);
color: var(--text);
background: var(--surface);
box-sizing: border-box;
}
.bx-page .form-input {
height: 42px;
}
.bx-page .form-textarea {
min-height: 80px;
padding: 10px 12px;
}
.bx-page .form-input.error,
.bx-page .form-textarea.error {
border-color: var(--danger);
}
.bx-page .form-input-prefix {
position: relative;
}
.bx-page .form-input-prefix .form-input {
padding-left: 28px;
}
.bx-page .form-input-prefix::before {
content: '¥';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-sec);
font-weight: 600;
z-index: 1;
}
/* Attachments grid (images) */
.bx-page .att-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.bx-page .att-img {
position: relative;
width: 72px;
height: 72px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
}
.bx-page .att-img image {
width: 100%;
height: 100%;
}
.bx-page .att-img .del {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
}
/* Empty / loading */
.bx-page .empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 15px;
}
.bx-page .empty-state--fill {
min-height: 40vh;
display: flex;
align-items: center;
justify-content: center;
}
.bx-page .page-body-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 40vh;
color: var(--text-muted);
font-size: 14px;
}
.bx-page .list-footer-tip {
text-align: center;
padding: 12px 0 24px;
font-size: 12px;
color: var(--text-muted);
}
/* FAB */
.bx-page .fab-add {
position: fixed;
right: 16px;
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
z-index: 120;
width: 48px;
height: 48px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--primary) !important;
color: #fff !important;
box-shadow: 0 3px 12px rgba(0, 201, 141, 0.38);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
font-weight: 300;
}
.bx-page .fab-add::after {
border: none;
}
/* Bottom bar */
.bx-page .bottom-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: var(--surface);
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
z-index: 100;
display: flex;
gap: 10px;
}
/* Ticket header */
.bx-page .advance-ticket-shell {
--ticket-bg: var(--primary);
--ticket-skin: var(--primary-light);
--ticket-dash: rgba(0, 201, 141, 0.45);
width: 100%;
box-sizing: border-box;
padding: 10px 16px 14px;
background: var(--ticket-bg);
}
.bx-page .advance-ticket-stack {
width: 100%;
filter: drop-shadow(0 10px 24px rgba(0, 168, 116, 0.3));
}
.bx-page .advance-ticket-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 48px;
padding: 14px 16px 12px;
background: var(--ticket-skin);
border-radius: 14px 14px 0 0;
}
.bx-page .advance-ticket-code {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: #475569;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bx-page .advance-ticket-status {
flex-shrink: 0;
padding: 4px 11px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
color: #fff;
}
.bx-page .advance-ticket-status--warn {
background: #ea580c;
}
.bx-page .advance-ticket-status--done {
background: #059669;
}
.bx-page .advance-ticket-status--err {
background: #dc2626;
}
.bx-page .advance-ticket-status--info {
background: #64748b;
}
.bx-page .advance-ticket-status--processing {
background: #6366f1;
}
.bx-page .advance-ticket-tear {
position: relative;
height: 14px;
background: var(--ticket-skin);
}
.bx-page .advance-ticket-tear::before,
.bx-page .advance-ticket-tear::after {
content: '';
position: absolute;
top: 50%;
width: 14px;
height: 14px;
margin-top: -7px;
border-radius: 50%;
background: var(--ticket-bg);
z-index: 1;
}
.bx-page .advance-ticket-tear::before {
left: -7px;
}
.bx-page .advance-ticket-tear::after {
right: -7px;
}
.bx-page .advance-ticket-tear__line {
position: absolute;
left: 14px;
right: 14px;
top: 50%;
margin-top: -0.5px;
border-top: 1px dashed var(--ticket-dash);
}
.bx-page .advance-ticket-bottom {
padding: 10px 16px 20px;
background: var(--ticket-skin);
border-radius: 0 0 14px 14px;
}
.bx-page .advance-ticket-reason {
margin: 0 0 10px;
font-size: 13px;
font-weight: 500;
line-height: 1.45;
color: #64748b;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.bx-page .advance-ticket-amount {
font-size: 36px;
font-weight: 800;
line-height: 1.05;
color: #0f172a !important;
font-variant-numeric: tabular-nums;
}
/* Detail cards / tabs */
.bx-page .detail-tabs {
display: flex;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.bx-page .detail-tab {
flex: 1;
text-align: center;
padding: 12px 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-sec);
position: relative;
}
.bx-page .detail-tab.active {
color: var(--primary-dark);
}
.bx-page .detail-tab.active::after {
content: '';
position: absolute;
left: 20%;
right: 20%;
bottom: 0;
height: 2px;
background: var(--primary);
border-radius: 2px;
}
.bx-page .advance-detail-stack {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 12px;
padding-bottom: 16px;
}
.bx-page .detail-info-card {
background: var(--surface);
border-radius: var(--r);
border: 1px solid var(--border);
box-shadow: var(--shadow);
padding: 6px 16px 10px;
}
.bx-page .detail-info-card__title {
margin: 6px 0 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.bx-page .adv-field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
}
.bx-page .adv-field + .adv-field {
border-top: 1px solid var(--border);
}
.bx-page .adv-field__label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.bx-page .adv-field__value {
font-size: 15px;
font-weight: 500;
color: var(--text);
word-break: break-word;
}
.bx-page .adv-field__value--multiline {
font-size: 14px;
font-weight: 400;
white-space: pre-wrap;
color: var(--text-sec);
}
.bx-page .adv-field__value--accent {
color: var(--primary-dark) !important;
font-weight: 700;
font-size: 16px;
}
.bx-page .detail-section-empty {
margin: 0;
padding: 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
background: var(--bg);
border-radius: var(--rs);
border: 1px dashed var(--border);
}
/* Approval flow */
.bx-page .flow-step {
display: flex;
align-items: flex-start;
gap: 12px;
padding-bottom: 16px;
position: relative;
}
.bx-page .flow-step:last-child {
padding-bottom: 0;
}
.bx-page .flow-step:not(:last-child)::after {
content: '';
position: absolute;
left: 15px;
top: 32px;
bottom: 0;
width: 2px;
background: var(--border);
}
.bx-page .flow-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.bx-page .flow-avatar.av-done {
background: var(--primary);
}
.bx-page .flow-avatar.av-current {
background: var(--warning);
}
.bx-page .flow-avatar.av-reject {
background: var(--danger);
}
.bx-page .flow-avatar.av-pending {
background: var(--border);
color: var(--text-muted);
}
.bx-page .flow-info {
flex: 1;
}
.bx-page .flow-name {
font-size: 13px;
font-weight: 600;
}
.bx-page .flow-role,
.bx-page .flow-time {
font-size: 11px;
color: var(--text-muted);
}
.bx-page .adv-flow-opinion {
font-size: 12px;
color: var(--text-sec);
margin-top: 6px;
line-height: 1.45;
word-break: break-word;
}
+87
View File
@@ -0,0 +1,87 @@
/** 对齐 bx-mobile/src/utils/format.ts(Batch2 所需子集) */
export function statusLabel(status) {
if (status == null || status === '') return '-'
const map = {
1: '启用',
0: '禁用',
draft: '待提交',
pending: '审批中',
approved: '已通过',
rejected: '已驳回',
withdrawn: '已撤回',
issued: '已发放',
paid: '已支付',
offset: '已冲抵',
partial: '部分冲抵',
processed: '已处理'
}
return map[status] ?? String(status)
}
export function statusTone(status) {
if (status == null) return 'info'
const map = {
1: 'done',
0: 'error',
draft: 'info',
pending: 'warn',
approved: 'done',
rejected: 'error',
withdrawn: 'warn',
issued: 'done',
paid: 'done',
offset: 'done',
partial: 'warn',
processed: 'done'
}
return map[status] ?? 'info'
}
export function workflowListStatusTagClass(status) {
const raw = status == null || status === '' ? 'draft' : String(status)
return `s-${raw}`
}
export function ticketHeaderStatusToneClass(status) {
const tone = statusTone(status)
const map = {
info: 'advance-ticket-status--info',
warn: 'advance-ticket-status--warn',
done: 'advance-ticket-status--done',
error: 'advance-ticket-status--err',
processing: 'advance-ticket-status--processing'
}
return map[tone] || 'advance-ticket-status--info'
}
export function formatAmount(amount) {
const n = typeof amount === 'string' ? parseFloat(amount) : amount
if (Number.isNaN(n)) return '0.00'
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function formatDate(dateStr) {
if (!dateStr) return '-'
return String(dateStr).substring(0, 10)
}
export function formatDateTime(dateStr) {
if (!dateStr) return '-'
return String(dateStr).substring(0, 16).replace('T', ' ')
}
export function pickApplyTime(row) {
if (!row || typeof row !== 'object') return undefined
const v = row.applyTime ?? row.createTime ?? row.apply_time ?? row.create_time
if (v == null || v === '') return undefined
return String(v)
}
export function formatApplyDateTime(row) {
return formatDateTime(pickApplyTime(row))
}
export function formatApplyDate(row) {
return formatDate(pickApplyTime(row))
}
+174
View File
@@ -0,0 +1,174 @@
/**
* 报销/备用金模块 HTTP(bx 信封:code === 0)
* 对齐 bx-mobile/src/api/bxClient.ts
*/
import { API_BASE, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY } from '@/config/api'
const BX_COLLECTION_ROOT = /^\/(reimburses|advances|payments|workflows|expense-items)$/
function normalizeBxUrl(url) {
if (!url || url.endsWith('/')) return url
return BX_COLLECTION_ROOT.test(url) ? `${url}/` : url
}
function buildQuery(params) {
if (!params || typeof params !== 'object') return ''
const parts = []
Object.keys(params).forEach((key) => {
const val = params[key]
if (val === undefined || val === null || val === '') return
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`)
})
return parts.length ? `?${parts.join('&')}` : ''
}
function parseDetailMessage(data) {
if (!data || typeof data !== 'object') return ''
if (typeof data.msg === 'string' && data.msg) return data.msg
const detail = data.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail
.map((x) => (x && x.msg) || '')
.filter(Boolean)
.join(';')
}
return data.message || ''
}
async function onUnauthorized() {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(REFRESH_TOKEN_KEY)
uni.removeStorageSync(USER_INFO_KEY)
try {
const { useUserStore } = await import('@/stores/user')
useUserStore().clearLoginInfo()
} catch (e) {
// ignore
}
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/login' })
}, 800)
}
/**
* @param {{ url: string, method?: string, data?: any, params?: any, showError?: boolean }} options
* @returns {Promise<{ code: number, data: any, msg?: string }>}
*/
export function bxRequest(options = {}) {
const method = String(options.method || 'GET').toUpperCase()
const showError = options.showError !== false
const token = uni.getStorageSync(TOKEN_KEY) || ''
let path = normalizeBxUrl(options.url || '')
const query = buildQuery(options.params)
const rawUrl = `${path}${query}`
const url = rawUrl.startsWith('http')
? rawUrl
: `${API_BASE}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`
return new Promise((resolve, reject) => {
uni.request({
url,
method,
data: method === 'GET' ? undefined : options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
success: (res) => {
const status = res.statusCode
const body = res.data
if (status === 401) {
onUnauthorized()
reject(new Error('登录已过期'))
return
}
if (status < 200 || status >= 300) {
const message = parseDetailMessage(body) || `请求失败(${status})`
if (showError) {
uni.showToast({ title: String(message).slice(0, 40), icon: 'none' })
}
reject(new Error(message))
return
}
if (body && body.code !== undefined && body.code !== 0) {
const message = body.msg || '请求失败'
if (showError) {
uni.showToast({ title: String(message).slice(0, 40), icon: 'none' })
}
reject(new Error(message))
return
}
resolve(body)
},
fail: (err) => {
if (showError) {
uni.showToast({ title: '网络异常', icon: 'none' })
}
reject(err)
}
})
})
}
/**
* 附件 multipart 上传(uni.uploadFile)
* @param {string} filePath
* @param {{ bizType?: string, bizId?: string|number, showError?: boolean }} opts
*/
export function bxUploadFile(filePath, opts = {}) {
const showError = opts.showError !== false
const token = uni.getStorageSync(TOKEN_KEY) || ''
const query = buildQuery({
biz_type: opts.bizType || '',
biz_id: opts.bizId != null && opts.bizId !== '' ? opts.bizId : 0
})
const url = `${API_BASE}/attachments/upload${query}`
return new Promise((resolve, reject) => {
uni.uploadFile({
url,
filePath,
name: 'file',
header: {
...(token ? { Authorization: `Bearer ${token}` } : {})
},
success: (res) => {
const status = res.statusCode
let body = res.data
if (typeof body === 'string') {
try {
body = JSON.parse(body)
} catch (e) {
body = {}
}
}
if (status === 401) {
onUnauthorized()
reject(new Error('登录已过期'))
return
}
if (status < 200 || status >= 300) {
const message = parseDetailMessage(body) || `上传失败(${status})`
if (showError) uni.showToast({ title: String(message).slice(0, 40), icon: 'none' })
reject(new Error(message))
return
}
if (body && body.code !== undefined && body.code !== 0) {
const message = body.msg || '上传失败'
if (showError) uni.showToast({ title: String(message).slice(0, 40), icon: 'none' })
reject(new Error(message))
return
}
resolve(body)
},
fail: (err) => {
if (showError) uni.showToast({ title: '上传失败', icon: 'none' })
reject(err)
}
})
})
}
export default bxRequest