diff --git a/miniprogram/api/bx.js b/miniprogram/api/bx.js
index 04e9d1c74..cf4aaa182 100644
--- a/miniprogram/api/bx.js
+++ b/miniprogram/api/bx.js
@@ -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 }
diff --git a/miniprogram/config/api.js b/miniprogram/config/api.js
index 89a4e7728..e16fcfb98 100644
--- a/miniprogram/config/api.js
+++ b/miniprogram/config/api.js
@@ -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',
diff --git a/miniprogram/pages.json b/miniprogram/pages.json
index 1472aba61..b86cbbe3f 100644
--- a/miniprogram/pages.json
+++ b/miniprogram/pages.json
@@ -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": "报销申请" }
diff --git a/miniprogram/pages/bx/advance/apply.vue b/miniprogram/pages/bx/advance/apply.vue
new file mode 100644
index 000000000..7abc4a1b0
--- /dev/null
+++ b/miniprogram/pages/bx/advance/apply.vue
@@ -0,0 +1,326 @@
+
+
+
+
+
+ ‹
+
+
+ 备用金申请
+
+
+
+
+
+
+
+
+ 申请原因*
+
+
+
+
+ 申请金额*
+
+
+
+
+
+
+ 备注
+
+
+
+
+
+
+
+
+
+
+ ×
+
+
+ 暂无附件(可选)
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/miniprogram/pages/bx/advance/detail.vue b/miniprogram/pages/bx/advance/detail.vue
new file mode 100644
index 000000000..ff5bcd7fb
--- /dev/null
+++ b/miniprogram/pages/bx/advance/detail.vue
@@ -0,0 +1,346 @@
+
+
+
+
+
+ ‹
+
+
+ 备用金详情
+
+
+
+
+
+
+
+ {{ detail.applyCode || '—' }}
+
+ {{ statusLabel(detail.status) }}
+
+
+
+
+
+
+ {{ detail.reason || '—' }}
+ {{ formatAmount(detail.amount) }}
+
+
+
+
+
+
+ 基础信息
+
+
+ 审批信息
+
+
+ 发放信息
+
+
+
+
+
+
+ 申请单号
+ {{ detail.applyCode || '—' }}
+
+
+ 申请原因
+ {{ detail.reason || '—' }}
+
+
+ 实际发放金额
+
+ {{
+ detail.actualAmount != null
+ ? '¥ ' + formatAmount(detail.actualAmount)
+ : '—'
+ }}
+
+
+
+ 申请人
+ {{ detail.applicantName || '—' }}
+
+
+ 当前节点
+ {{ detail.currentNode || '—' }}
+
+
+ 申请时间
+ {{ formatApplyDateTime(detail) }}
+
+
+ 备注
+ {{ detail.remark }}
+
+
+
+
+ 申请附件
+
+
+
+ 文件
+
+
+ 暂无申请附件
+
+
+
+
+
+
+
+
+ {{ (a.approverName || a.nodeName || a.nodeLabel || '审')[0] }}
+
+
+ {{ a.nodeName || a.nodeLabel || a.approverName || '—' }}
+ {{ a.approverName }}
+ {{ formatDateTime(a.approvalTime) }}
+ {{ a.approvalOpinion }}
+
+
+
+ 暂无审批记录
+
+
+
+
+
+
+
+ 发放时间
+ {{ formatDateTime(detail.issueTime) || '—' }}
+
+
+ 发放操作人
+ {{ detail.issueUserName || '—' }}
+
+
+ 发放备注
+ {{ detail.issueRemark }}
+
+
+
+
+ 尚未发放
+
+
+
+
+ 加载中...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/miniprogram/pages/bx/advance/list.vue b/miniprogram/pages/bx/advance/list.vue
index 31bf70db4..884794847 100644
--- a/miniprogram/pages/bx/advance/list.vue
+++ b/miniprogram/pages/bx/advance/list.vue
@@ -1,7 +1,296 @@
-
+
+
+
+
+ ‹
+
+
+ 备用金申请
+
+
+
+ +
+
+
+
+ ⌕
+
+ ×
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+ 暂无备用金记录
+
+
+
+
+
+ {{ item.reason || '—' }}
+
+
+ {{ statusLabel(item.status) }}
+
+
+ 申请金额
+ {{ formatAmount(item.amount) }}
+
+
+ 实际发放金额
+ ¥ {{ actualIssuedDisplay(item) }}
+
+
+ 实际冲抵
+ ¥ {{ formatAmount(offsetTotal(item)) }}
+
+
+
+
+ 申请单号
+ {{ item.applyCode || '—' }}
+
+
+ 申请人
+ {{ item.applicantName || '-' }}
+
+
+ 当前环节
+ {{ item.currentNode }}
+
+
+ 备注
+ {{ item.remark }}
+
+
+
+ {{ formatApplyDate(item) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/miniprogram/styles/bx-theme.scss b/miniprogram/styles/bx-theme.scss
new file mode 100644
index 000000000..fc0c4dfde
--- /dev/null
+++ b/miniprogram/styles/bx-theme.scss
@@ -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;
+}
diff --git a/miniprogram/utils/bxFormat.js b/miniprogram/utils/bxFormat.js
new file mode 100644
index 000000000..28dd7bed1
--- /dev/null
+++ b/miniprogram/utils/bxFormat.js
@@ -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))
+}
diff --git a/miniprogram/utils/bxRequest.js b/miniprogram/utils/bxRequest.js
new file mode 100644
index 000000000..7be078f01
--- /dev/null
+++ b/miniprogram/utils/bxRequest.js
@@ -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