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:
1 parent
507823dfce
commit
2ff754d572
9 files changed
+2264
-188
No files matched your search
+33
-186
@@ -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 }
|
||||
@@ -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',
|
||||
|
||||
@@ -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": "报销申请" }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user