Jelajahi Sumber

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

Reuse /reimburses APIs with bx envelope; support multi-line details and draft actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 bulan lalu
induk
melakukan
006e94ad33

+ 37 - 2
miniprogram/api/bx.js

@@ -11,7 +11,35 @@ export const advanceApi = {
   delete: (id) => bxRequest({ url: `/advances/${id}`, method: 'DELETE' })
 }
 
-/** 附件:Batch2 申请页图片上传所需最小集合 */
+export const reimburseApi = {
+  generateTitle: (params) =>
+    bxRequest({ url: '/reimburses/generate-title', method: 'GET', params }),
+  my: (params) => bxRequest({ url: '/reimburses/my', method: 'GET', params }),
+  getById: (id) => bxRequest({ url: `/reimburses/${id}`, method: 'GET' }),
+  getApprovals: (id) => bxRequest({ url: `/reimburses/${id}/approvals`, method: 'GET' }),
+  getApprovalHistory: (id) =>
+    bxRequest({ url: `/reimburses/${id}/approval-history`, method: 'GET' }),
+  create: (data) => bxRequest({ url: '/reimburses/', method: 'POST', data }),
+  update: (id, data) => bxRequest({ url: `/reimburses/${id}`, method: 'PUT', data }),
+  submit: (id, data) =>
+    bxRequest({ url: `/reimburses/${id}/submit`, method: 'POST', data }),
+  delete: (id) => bxRequest({ url: `/reimburses/${id}`, method: 'DELETE' })
+}
+
+export const expenseItemApi = {
+  tree: () => bxRequest({ url: '/expense-items/tree', method: 'GET' })
+}
+
+export const invoiceWalletApi = {
+  mineSummary: (params) =>
+    bxRequest({ url: '/invoice-wallets/mine/summary', method: 'GET', params })
+}
+
+export const invoiceApi = {
+  list: (params) => bxRequest({ url: '/invoices', method: 'GET', params })
+}
+
+/** 附件:申请页图片上传所需最小集合 */
 export const attachmentApi = {
   upload: (filePath, bizType = '', bizId = 0) =>
     bxUploadFile(filePath, { bizType, bizId }),
@@ -35,4 +63,11 @@ export const attachmentApi = {
     })
 }
 
-export default { advanceApi, attachmentApi }
+export default {
+  advanceApi,
+  reimburseApi,
+  expenseItemApi,
+  invoiceWalletApi,
+  invoiceApi,
+  attachmentApi
+}

+ 2 - 0
miniprogram/config/api.js

@@ -53,6 +53,8 @@ export const PAGE_PATHS = {
   bxAdvanceApplyForm: '/pages/bx/advance/apply',
   bxAdvanceDetail: '/pages/bx/advance/detail',
   bxReimburseApply: '/pages/bx/reimburse/list',
+  bxReimburseApplyForm: '/pages/bx/reimburse/apply',
+  bxReimburseDetail: '/pages/bx/reimburse/detail',
   bxMine: '/pages/bx/mine/list',
   bxAll: '/pages/bx/all/list',
   bxInvoiceMine: '/pages/bx/invoice/mine',

+ 8 - 0
miniprogram/pages.json

@@ -96,6 +96,14 @@
       "path": "pages/bx/reimburse/list",
       "style": { "navigationStyle": "custom", "navigationBarTitleText": "报销申请" }
     },
+    {
+      "path": "pages/bx/reimburse/apply",
+      "style": { "navigationStyle": "custom", "navigationBarTitleText": "报销申请" }
+    },
+    {
+      "path": "pages/bx/reimburse/detail",
+      "style": { "navigationStyle": "custom", "navigationBarTitleText": "报销详情" }
+    },
     {
       "path": "pages/bx/mine/list",
       "style": { "navigationStyle": "custom", "navigationBarTitleText": "我的申请单" }

+ 796 - 0
miniprogram/pages/bx/reimburse/apply.vue

@@ -0,0 +1,796 @@
+<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>
+          <view class="form-input" style="display: flex; align-items: center">
+            {{ applicantLabel || '当前用户' }}
+          </view>
+        </view>
+
+        <view class="field">
+          <text class="field-label">报销日期<text class="req">*</text></text>
+          <picker mode="date" :value="form.reimburseDate" @change="onDateChange">
+            <view class="picker-trigger">
+              <text class="value" :class="{ ph: !form.reimburseDate }">
+                {{ form.reimburseDate || '请选择报销日期' }}
+              </text>
+              <text class="arrow">›</text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="field">
+          <text class="field-label">报销标题</text>
+          <input
+            class="form-input"
+            :value="form.title"
+            placeholder="不填则自动生成"
+            @input="onTitleInput"
+          />
+        </view>
+      </view>
+
+      <view class="form-card">
+        <view class="form-header">
+          <text class="form-header-title">报销明细</text>
+          <text class="form-badge">{{ form.details.length }}行</text>
+        </view>
+
+        <view v-for="(line, idx) in form.details" :key="idx" class="detail-line">
+          <view v-if="idx > 0" class="detail-divider" />
+
+          <view class="field">
+            <text class="field-label">
+              费用项目
+              <text v-if="form.details.length > 1" class="detail-line-no">
+                (第 {{ idx + 1 }} 条)
+              </text>
+              <text class="req">*</text>
+            </text>
+            <view class="picker-trigger" @click="openExpensePicker(idx)">
+              <text
+                class="value"
+                :class="{ ph: !lineExpenseDisplay(line.expenseItemId) }"
+              >
+                {{ lineExpenseDisplay(line.expenseItemId) || '请选择费用项目' }}
+              </text>
+              <text class="arrow">›</text>
+            </view>
+          </view>
+
+          <view class="field">
+            <text class="field-label">金额(元)<text class="req">*</text></text>
+            <view class="form-input-prefix">
+              <input
+                class="form-input"
+                type="digit"
+                :value="line.amount"
+                placeholder="0.00"
+                :data-idx="idx"
+                @input="onLineAmount"
+              />
+            </view>
+          </view>
+
+          <view class="field">
+            <text class="field-label">报销事由<text class="req">*</text></text>
+            <input
+              class="form-input"
+              :value="line.remark"
+              placeholder="请输入报销事由"
+              :data-idx="idx"
+              @input="onLineRemark"
+            />
+          </view>
+
+          <view class="field">
+            <text class="field-label">发生时间</text>
+            <picker
+              mode="date"
+              :value="line.occurDate || form.reimburseDate"
+              :data-idx="idx"
+              @change="onOccurDate"
+            >
+              <view class="picker-trigger">
+                <text class="value" :class="{ ph: !line.occurDate }">
+                  {{ line.occurDate || '选填' }}
+                </text>
+                <text class="arrow">›</text>
+              </view>
+            </picker>
+          </view>
+
+          <view v-if="form.details.length > 1" class="detail-line-actions">
+            <button class="btn btn-sm btn-danger" @click="removeDetailLine(idx)">
+              删除此行
+            </button>
+          </view>
+        </view>
+
+        <view class="detail-actions">
+          <button class="btn btn-primary btn-sm" @click="addDetailLine">+ 新增明细行</button>
+          <text class="total-amount">合计:<text style="font-weight: 700">¥{{ detailTotalAmount }}</text></text>
+        </view>
+      </view>
+
+      <view class="form-card">
+        <view class="form-header">
+          <text class="form-header-title">关联发票(可选)</text>
+        </view>
+        <view class="invoice-balance-tip">
+          发票余额 ¥{{ money(walletBalance) }} · 本次合计 ¥{{ detailTotalAmount }}
+          <text v-if="balanceInsufficient" class="invoice-balance-tip--warn">
+            (余额不足,提交后余额将为负,请后续补发票)
+          </text>
+        </view>
+        <button class="btn btn-primary btn-sm" @click="openInvoicePicker">从发票夹选择</button>
+        <view v-if="selectedInvoicePicks.length" class="invoice-picked-row">
+          <view
+            v-for="pick in selectedInvoicePicks"
+            :key="pick.key"
+            class="invoice-picked-item"
+          >
+            <view class="invoice-picked-item__meta">
+              <text class="invoice-picked-item__name">{{ pick.fileName }}</text>
+              <text class="invoice-picked-item__amt">整票 ¥{{ money(pick.amount) }}</text>
+            </view>
+            <view class="invoice-picked-item__remove" @click="removeInvoicePick(pick.key)">×</view>
+          </view>
+        </view>
+        <view class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</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 class="form-card">
+        <view class="form-header">
+          <text class="form-header-title">报销说明</text>
+        </view>
+        <view class="field">
+          <textarea
+            class="form-textarea"
+            :value="form.remark"
+            placeholder="请输入报销说明"
+            :maxlength="500"
+            @input="onRemarkInput"
+          />
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-action-bar">
+      <button class="btn btn-primary btn-block" :disabled="submitting" @click="onSubmit">
+        {{ submitting ? '保存中…' : '保存' }}
+      </button>
+    </view>
+
+    <!-- 费用项目选择 -->
+    <view v-if="showExpensePicker" class="bx-sheet-mask" @click="showExpensePicker = false">
+      <view class="bx-sheet" @click.stop>
+        <view class="bx-sheet__title">选择费用项目</view>
+        <scroll-view scroll-y class="bx-sheet__body">
+          <view v-if="!expenseOptions.length" class="bx-sheet__empty">暂无可用费用项目</view>
+          <view
+            v-for="opt in expenseOptions"
+            :key="opt.id"
+            class="bx-sheet__item"
+            :class="{ 'is-selected': String(form.details[pickingLineIndex]?.expenseItemId) === opt.id }"
+            @click="confirmExpensePick(opt.id)"
+          >
+            {{ opt.label }}
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <!-- 发票多选(扁平列表) -->
+    <view v-if="showInvoicePicker" class="bx-sheet-mask" @click="showInvoicePicker = false">
+      <view class="bx-sheet" @click.stop>
+        <view class="bx-sheet__title">选择发票附件</view>
+        <scroll-view scroll-y class="bx-sheet__body">
+          <view v-if="!attachItems.length" class="bx-sheet__empty">暂无发票附件</view>
+          <view
+            v-for="item in attachItems"
+            :key="item.key"
+            class="bx-sheet__item"
+            :class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
+            @click="togglePickerKey(item.key)"
+          >
+            <view>
+              <text>{{ item.fileName }}</text>
+              <view class="bx-sheet__item-sub">整票 ¥{{ money(item.amount) }}</view>
+            </view>
+            <text v-if="pickerDraftKeys.includes(item.key)">✓</text>
+          </view>
+        </scroll-view>
+        <view class="bx-sheet__footer">
+          <button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
+            确定(已选 {{ pickerDraftKeys.length }})
+          </button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, reactive, computed } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { PAGE_PATHS } from '@/config/api'
+import { useUserStore } from '@/stores/user'
+import {
+  reimburseApi,
+  attachmentApi,
+  expenseItemApi,
+  invoiceWalletApi,
+  invoiceApi
+} from '@/api/bx'
+import {
+  normalizeExpenseItem,
+  flattenExpenseTree,
+  formatExpenseName
+} from '@/utils/bxFormat'
+
+const MAX_ATTACHMENTS = 10
+const userStore = useUserStore()
+
+const editId = ref('')
+const savedId = ref('')
+const submitting = ref(false)
+const uploading = ref(false)
+const files = ref([])
+
+function todayYmd() {
+  const d = new Date()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${d.getFullYear()}-${m}-${day}`
+}
+
+const form = reactive({
+  title: '',
+  remark: '',
+  applicantId: '',
+  reimburseDate: todayYmd(),
+  details: [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
+})
+
+const flatList = ref([])
+const showExpensePicker = ref(false)
+const pickingLineIndex = ref(0)
+
+const walletBalance = ref(0)
+const attachItems = ref([])
+const selectedInvoicePicks = ref([])
+const pickerDraftKeys = ref([])
+const showInvoicePicker = ref(false)
+
+const isEdit = computed(() => !!editId.value || !!savedId.value)
+
+const applicantLabel = computed(() => {
+  const u = userStore.userInfo || {}
+  return u.realName || u.username || u.nickName || u.name || ''
+})
+
+const expenseOptions = computed(() => {
+  return (flatList.value || [])
+    .filter((i) => i.status === 1 || i.status === '1')
+    .map((i) => ({
+      id: String(i.id),
+      label: formatExpenseName(i.id, flatList.value)
+    }))
+})
+
+const detailTotalNumber = computed(() =>
+  form.details.reduce((sum, d) => sum + Number(d.amount || 0), 0)
+)
+
+const detailTotalAmount = computed(() =>
+  detailTotalNumber.value.toLocaleString('zh-CN', {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2
+  })
+)
+
+const balanceInsufficient = computed(
+  () => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value
+)
+
+function money(v) {
+  return Number(v || 0).toLocaleString('zh-CN', {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2
+  })
+}
+
+function lineExpenseDisplay(id) {
+  if (id === '' || id == null) return ''
+  const name = formatExpenseName(id, flatList.value)
+  return name === '-' ? '' : name
+}
+
+function onTitleInput(e) {
+  form.title = e.detail.value
+}
+function onRemarkInput(e) {
+  form.remark = e.detail.value
+}
+function onLineAmount(e) {
+  const idx = Number(e.currentTarget.dataset.idx)
+  if (Number.isNaN(idx) || !form.details[idx]) return
+  form.details[idx].amount = e.detail.value
+}
+function onLineRemark(e) {
+  const idx = Number(e.currentTarget.dataset.idx)
+  if (Number.isNaN(idx) || !form.details[idx]) return
+  form.details[idx].remark = e.detail.value
+}
+function onOccurDate(e) {
+  const idx = Number(e.currentTarget.dataset.idx)
+  if (Number.isNaN(idx) || !form.details[idx]) return
+  form.details[idx].occurDate = e.detail.value
+}
+function onDateChange(e) {
+  form.reimburseDate = e.detail.value
+  refreshTitle()
+}
+
+function goBack() {
+  uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
+}
+
+function addDetailLine() {
+  form.details.push({ expenseItemId: '', amount: '', remark: '', occurDate: '' })
+}
+
+function removeDetailLine(index) {
+  form.details.splice(index, 1)
+}
+
+function openExpensePicker(idx) {
+  pickingLineIndex.value = idx
+  showExpensePicker.value = true
+}
+
+function confirmExpensePick(id) {
+  form.details[pickingLineIndex.value].expenseItemId = String(id)
+  showExpensePicker.value = false
+}
+
+function invoiceFileList(inv) {
+  const raw = Array.isArray(inv?.attachments) ? inv.attachments : []
+  if (raw.length) {
+    return raw.map((a) => ({
+      id: String(a.id || a.attachmentId),
+      fileName: a.fileName || a.file_name || '发票附件',
+      fileType: a.fileType || a.file_type || ''
+    }))
+  }
+  const aid = inv?.attachmentId || inv?.attachment_id
+  if (aid) {
+    return [
+      {
+        id: String(aid),
+        fileName: inv.fileName || inv.file_name || '发票附件',
+        fileType: inv.fileType || inv.file_type || ''
+      }
+    ]
+  }
+  return []
+}
+
+function buildAttachItems(rows) {
+  const items = []
+  for (const row of rows || []) {
+    const createTime = row.createTime || row.create_time || ''
+    for (const file of invoiceFileList(row)) {
+      if (!file.id) continue
+      items.push({
+        key: `${row.id}:${file.id}`,
+        invoiceId: String(row.id),
+        attachmentId: file.id,
+        amount: row.amount,
+        createTime,
+        fileName: file.fileName,
+        fileType: file.fileType
+      })
+    }
+  }
+  return items
+}
+
+function buildInvoicePayload() {
+  const invoiceLinks = selectedInvoicePicks.value.map((p) => ({
+    invoiceId: String(p.invoiceId),
+    attachmentId: String(p.attachmentId)
+  }))
+  const invoiceIds = Array.from(new Set(invoiceLinks.map((p) => p.invoiceId)))
+  return { invoiceIds, invoiceLinks }
+}
+
+async function refreshTitle() {
+  if (!form.reimburseDate) return
+  try {
+    const params = { reimburseDate: form.reimburseDate }
+    const exclude = editId.value || savedId.value
+    if (exclude) params.excludeApplyId = exclude
+    if (form.applicantId) params.applicantId = form.applicantId
+    const res = await reimburseApi.generateTitle(params)
+    const t = res?.data
+    form.title = typeof t === 'string' ? t : t?.title || form.title
+  } catch (e) {
+    // keep
+  }
+}
+
+async function refreshWallet() {
+  try {
+    const owner = form.applicantId || undefined
+    const [sumRes, invRes] = await Promise.all([
+      invoiceWalletApi.mineSummary(owner ? { userId: owner } : undefined),
+      invoiceApi.list({ page: 1, pageSize: 200, userId: owner })
+    ])
+    walletBalance.value = Number(sumRes?.data?.balance ?? 0)
+    const d = invRes?.data
+    const rows = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : [])
+    attachItems.value = buildAttachItems(rows)
+  } catch (e) {
+    // ignore
+  }
+}
+
+function openInvoicePicker() {
+  pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
+  showInvoicePicker.value = true
+}
+
+function togglePickerKey(key) {
+  const i = pickerDraftKeys.value.indexOf(key)
+  if (i >= 0) pickerDraftKeys.value.splice(i, 1)
+  else pickerDraftKeys.value.push(key)
+}
+
+function confirmInvoicePicker() {
+  const set = new Set(pickerDraftKeys.value)
+  selectedInvoicePicks.value = attachItems.value
+    .filter((i) => set.has(i.key))
+    .map((i) => ({ ...i }))
+  showInvoicePicker.value = false
+}
+
+function removeInvoicePick(key) {
+  selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
+}
+
+function validateReimburseApplyForm() {
+  for (let i = 0; i < form.details.length; i++) {
+    const d = form.details[i]
+    const row = i + 1
+    const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
+    const amountStr = String(d.amount ?? '').trim()
+    const hasAmount = amountStr !== ''
+    const amt = Number(amountStr)
+    const amountOk = hasAmount && !Number.isNaN(amt) && amt > 0
+    const hasRemark = !!d.remark?.trim()
+
+    if (!hasExpense && !hasAmount && !hasRemark) continue
+
+    if (!hasExpense) {
+      uni.showToast({ title: `第 ${row} 行:请选择费用项目`, icon: 'none' })
+      return null
+    }
+    if (!amountOk) {
+      uni.showToast({ title: `第 ${row} 行:请输入大于 0 的金额`, icon: 'none' })
+      return null
+    }
+    if (!d.remark?.trim()) {
+      uni.showToast({ title: `第 ${row} 行:请填写报销事由`, icon: 'none' })
+      return null
+    }
+  }
+
+  const validDetails = form.details.filter((d) => {
+    const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
+    const amt = Number(String(d.amount ?? '').trim())
+    return hasExpense && !Number.isNaN(amt) && amt > 0
+  })
+  if (validDetails.length === 0) {
+    uni.showToast({ title: '请至少填写一条完整的报销明细', icon: 'none' })
+    return null
+  }
+  if (!form.reimburseDate) {
+    uni.showToast({ title: '请选择报销日期', icon: 'none' })
+    return null
+  }
+  return validDetails
+}
+
+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, 'reimburse_apply', bizId)
+  files.value.forEach((f) => {
+    if (newIds.includes(f.id)) f._existing = true
+  })
+}
+
+async function loadExpenseItems() {
+  try {
+    const res = await expenseItemApi.tree()
+    const tree = (res.data || []).map(normalizeExpenseItem)
+    flatList.value = flattenExpenseTree(tree)
+  } catch (e) {
+    flatList.value = []
+  }
+}
+
+async function loadEditData(id) {
+  try {
+    const res = await reimburseApi.getById(id)
+    const data = res?.data || {}
+    form.title = data.title || ''
+    form.remark = data.remark || ''
+    form.applicantId = String(data.applicantId || userStore.userInfo?.id || '')
+    form.reimburseDate = data.reimburseDate || todayYmd()
+    form.details = (data.details || []).map((d) => ({
+      expenseItemId: d.expenseId ?? d.expenseItemId ?? '',
+      amount: String(d.amount ?? ''),
+      remark: d.remark || '',
+      occurDate: d.occurDate || ''
+    }))
+    if (!form.details.length) {
+      form.details = [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
+    }
+
+    await refreshWallet()
+    const linked = data.invoices || []
+    const picks = []
+    for (const inv of linked) {
+      const invoiceId = String(inv.id || inv.invoiceId || '')
+      if (!invoiceId) continue
+      let attachmentId = String(inv.attachmentId || inv.attachment_id || '')
+      let fileName = inv.fileName || inv.file_name || '发票附件'
+      let fileType = inv.fileType || inv.file_type || ''
+      const createTime = inv.createTime || inv.create_time || ''
+      if (!attachmentId) {
+        const src = attachItems.value.find((w) => String(w.invoiceId) === invoiceId)
+        if (src) {
+          attachmentId = src.attachmentId
+          fileName = src.fileName
+          fileType = src.fileType
+        }
+      }
+      if (!attachmentId) continue
+      picks.push({
+        key: `${invoiceId}:${attachmentId}`,
+        invoiceId,
+        attachmentId,
+        amount: inv.amount,
+        createTime,
+        fileName,
+        fileType
+      })
+    }
+    selectedInvoicePicks.value = picks
+
+    try {
+      const attRes = await attachmentApi.listByBiz('reimburse_apply', 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() {
+  const validDetails = validateReimburseApplyForm()
+  if (!validDetails) return
+  if (uploading.value) {
+    uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
+    return
+  }
+
+  submitting.value = true
+  const isEditingAction = !!editId.value || !!savedId.value
+  try {
+    const invoicePayload = buildInvoicePayload()
+    const payload = {
+      title: form.title || undefined,
+      remark: form.remark || undefined,
+      applicantId: form.applicantId || undefined,
+      reimburseDate: form.reimburseDate || undefined,
+      details: validDetails.map((d) => ({
+        expenseItemId: String(d.expenseItemId),
+        amount: Number(d.amount),
+        remark: String(d.remark).trim(),
+        occurDate: d.occurDate ? String(d.occurDate) : undefined
+      })),
+      ...invoicePayload
+    }
+
+    let id
+    if (editId.value || savedId.value) {
+      id = savedId.value || editId.value
+      await reimburseApi.update(id, payload)
+    } else {
+      const res = await reimburseApi.create(payload)
+      id = String(res?.data?.id ?? res?.id ?? '')
+      if (!id) throw new Error('保存成功但未返回报销单ID')
+      savedId.value = id
+      editId.value = id
+    }
+
+    try {
+      await bindNewAttachments(id)
+    } catch (e) {
+      uni.showToast({ title: '报销单已保存,但附件关联失败', icon: 'none' })
+    }
+
+    uni.showModal({
+      title: isEditingAction ? '保存成功' : '创建成功',
+      content: '报销单已保存为草稿',
+      confirmText: '提交审批',
+      confirmColor: '#00a874',
+      cancelText: '继续编辑',
+      success: async (modalRes) => {
+        if (!modalRes.confirm) return
+        try {
+          await reimburseApi.submit(id, invoicePayload)
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(() => goBack(), 500)
+        } catch (e) {
+          // toasted
+        }
+      }
+    })
+  } catch (e) {
+    // toasted by bxRequest
+  } finally {
+    submitting.value = false
+  }
+}
+
+onLoad(async (query) => {
+  const uid = String(userStore.userInfo?.id || userStore.userInfo?.userId || '')
+  form.applicantId = uid
+  await loadExpenseItems()
+  await refreshWallet()
+  const id = query?.id ? String(query.id) : ''
+  if (id) {
+    editId.value = id
+    await loadEditData(id)
+  } else {
+    await refreshTitle()
+  }
+})
+</script>
+
+<style lang="scss">
+@import '@/styles/bx-theme.scss';
+</style>

+ 391 - 0
miniprogram/pages/bx/reimburse/detail.vue

@@ -0,0 +1,391 @@
+<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.title || '—' }}</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>
+
+      <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">{{ detail.title || '—' }}</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">
+              {{ moneyOrDash(detail.offsetAmount ?? detail.offset_amount) }}
+            </text>
+          </view>
+          <view class="adv-field">
+            <text class="adv-field__label">实付金额</text>
+            <text class="adv-field__value">
+              {{
+                moneyOrDash(
+                  detail.payAmount ?? detail.actualAmount ?? detail.actual_amount
+                )
+              }}
+            </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 class="adv-field">
+            <text class="adv-field__label">报销说明</text>
+            <text class="adv-field__value adv-field__value--multiline">
+              {{ detail.remark || '—' }}
+            </text>
+          </view>
+        </view>
+
+        <view
+          v-if="detail.details && detail.details.length"
+          class="detail-info-card"
+        >
+          <view class="detail-info-card__title">费用项信息</view>
+          <view
+            v-for="(d, idx) in detail.details"
+            :key="idx"
+            class="reimburse-detail-group"
+          >
+            <view v-if="detail.details.length > 1" class="reimburse-detail-head">
+              明细 {{ idx + 1 }}
+            </view>
+            <view class="adv-field">
+              <text class="adv-field__label">费用项目</text>
+              <text class="adv-field__value">{{ resolveExpenseDisplay(d, flatList) }}</text>
+            </view>
+            <view class="adv-field">
+              <text class="adv-field__label">金额</text>
+              <text class="adv-field__value adv-field__value--accent">
+                ¥ {{ formatAmount(d.amount) }}
+              </text>
+            </view>
+            <view v-if="d.remark" class="adv-field">
+              <text class="adv-field__label">报销事由</text>
+              <text class="adv-field__value adv-field__value--multiline">{{ d.remark }}</text>
+            </view>
+            <view class="adv-field">
+              <text class="adv-field__label">发生时间</text>
+              <text class="adv-field__value">{{ d.occurDate || '—' }}</text>
+            </view>
+          </view>
+          <view class="adv-field reimburse-detail-total-row">
+            <text class="adv-field__label">合计</text>
+            <text class="adv-field__value adv-field__value--accent">
+              ¥ {{ totalAmount }}
+            </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 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>
+    </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 { reimburseApi, attachmentApi, expenseItemApi } from '@/api/bx'
+import {
+  statusLabel,
+  ticketHeaderStatusToneClass,
+  formatAmount,
+  formatDateTime,
+  formatApplyDateTime,
+  normalizeExpenseItem,
+  flattenExpenseTree,
+  resolveExpenseDisplay
+} from '@/utils/bxFormat'
+
+const detailId = ref('')
+const detail = ref(null)
+const detailTab = ref(0)
+const approvals = ref([])
+const attachments = ref([])
+const flatList = ref([])
+const actionLoading = reactive({ submit: false, delete: false })
+
+const showActions = computed(() => {
+  if (!detail.value) return false
+  const status = detail.value.status
+  return status === 'draft' || status === 'rejected'
+})
+
+const totalAmount = computed(() => {
+  const rows = detail.value?.details || []
+  const sum = rows.reduce((s, d) => s + Number(d.amount || 0), 0)
+  return formatAmount(sum)
+})
+
+function moneyOrDash(raw) {
+  if (raw == null || raw === '') return '—'
+  const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
+  if (Number.isNaN(n) || n === 0) return '—'
+  return '¥ ' + formatAmount(n)
+}
+
+function goBack() {
+  uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
+}
+
+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 loadExpenseFlat() {
+  try {
+    const res = await expenseItemApi.tree()
+    flatList.value = flattenExpenseTree((res.data || []).map(normalizeExpenseItem))
+  } catch (e) {
+    flatList.value = []
+  }
+}
+
+async function loadDetail() {
+  const id = detailId.value
+  if (!id) return
+  try {
+    const res = await reimburseApi.getById(id)
+    detail.value = res.data
+  } catch (e) {
+    uni.showToast({ title: '加载详情失败', icon: 'none' })
+    return
+  }
+
+  try {
+    const histRes = await reimburseApi.getApprovalHistory(id)
+    const hist = histRes.data || []
+    if (Array.isArray(hist) && hist.length) {
+      approvals.value = hist
+    } else {
+      const apprRes = await reimburseApi.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 reimburseApi.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('reimburse_apply', 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 reimburseApi.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 reimburseApi.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.bxReimburseApplyForm}?id=${detail.value.id}`
+  })
+}
+
+onLoad((query) => {
+  detailId.value = query?.id ? String(query.id) : ''
+  loadExpenseFlat()
+  loadDetail()
+})
+</script>
+
+<style lang="scss">
+@import '@/styles/bx-theme.scss';
+</style>

+ 321 - 2
miniprogram/pages/bx/reimburse/list.vue

@@ -1,7 +1,326 @@
 <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="reimburse-list-card__title">{{ cardTitle(item) }}</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="reimburse-card-subs">
+          <view class="reimburse-card-sub-row">
+            <text class="k">冲抵金额</text>
+            <text class="v">¥ {{ moneyOrDash(item.offsetAmount ?? item.offset_amount) }}</text>
+          </view>
+          <view class="reimburse-card-sub-row">
+            <text class="k">实付金额</text>
+            <text class="v">
+              ¥ {{ moneyOrDash(item.payAmount ?? item.actualAmount ?? item.actual_amount) }}
+            </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="resolveExpenseDisplay(item, flatList) !== '-'"
+            class="list-card-kv__row"
+          >
+            <text class="list-card-kv__k">费用项目</text>
+            <text class="list-card-kv__v">{{ resolveExpenseDisplay(item, flatList) }}</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 class="list-card-kv__row">
+            <text class="list-card-kv__k">发票 / 附件</text>
+            <text class="list-card-kv__v">
+              {{ item.invoiceCount ?? 0 }} 张 · {{ item.attachCount ?? 0 }} 个
+            </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>{{ formatApplyDateTime(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 { reimburseApi, expenseItemApi } from '@/api/bx'
+import {
+  statusLabel,
+  workflowListStatusTagClass,
+  formatAmount,
+  formatApplyDateTime,
+  normalizeExpenseItem,
+  flattenExpenseTree,
+  resolveExpenseDisplay
+} from '@/utils/bxFormat'
+
+const tabs = [
+  { label: '全部', value: '' },
+  { label: '待提交', value: 'draft' },
+  { label: '审批中', value: 'pending' },
+  { label: '已通过', value: 'approved' },
+  { label: '已驳回', value: 'rejected' },
+  { label: '已支付', value: 'paid' }
+]
+
+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')
+const flatList = ref([])
+
+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 moneyOrDash(raw) {
+  if (raw == null || raw === '') return '—'
+  const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
+  if (Number.isNaN(n) || n === 0) return '—'
+  return formatAmount(n)
+}
+
+function cardTitle(item) {
+  const t = (item.title ?? '').toString().trim()
+  if (t) return t
+  const ex = resolveExpenseDisplay(item, flatList.value)
+  return ex !== '-' ? ex : '-'
+}
+
+async function loadExpenseFlat() {
+  try {
+    const res = await expenseItemApi.tree()
+    const tree = (res.data || []).map(normalizeExpenseItem)
+    flatList.value = flattenExpenseTree(tree)
+  } catch (e) {
+    flatList.value = []
+  }
+}
+
+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 reimburseApi.my(params)
+    const rows = res.data?.rows || res.data?.items || res.data?.list || []
+    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.bxReimburseApplyForm}${q}` })
+}
+
+function goDetail(id) {
+  uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseDetail}?id=${id}` })
+}
+
+async function handleSubmit(item) {
+  try {
+    await reimburseApi.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 reimburseApi.delete(item.id)
+        uni.showToast({ title: '删除成功', icon: 'success' })
+        onRefresh()
+      } catch (e) {
+        // toasted
+      }
+    }
+  })
+}
+
+onShow(() => {
+  computeListHeight()
+  loadExpenseFlat()
+  onRefresh()
+})
 </script>
+
+<style lang="scss">
+@import '@/styles/bx-theme.scss';
+
+.list-scroll {
+  box-sizing: border-box;
+}
+</style>

+ 242 - 5
miniprogram/styles/bx-theme.scss

@@ -255,7 +255,8 @@
   line-height: 1.3;
 }
 
-.bx-page .advance-list-card__title {
+.bx-page .advance-list-card__title,
+.bx-page .reimburse-list-card__title {
   display: block;
   overflow: hidden;
   text-overflow: ellipsis;
@@ -340,7 +341,8 @@
   margin-right: 2px;
 }
 
-.bx-page .advance-card-subs {
+.bx-page .advance-card-subs,
+.bx-page .reimburse-card-subs {
   display: flex;
   flex-direction: column;
   gap: 6px;
@@ -349,7 +351,8 @@
   border-top: 1px solid var(--border);
 }
 
-.bx-page .advance-card-sub-row {
+.bx-page .advance-card-sub-row,
+.bx-page .reimburse-card-sub-row {
   display: flex;
   justify-content: space-between;
   align-items: center;
@@ -357,13 +360,15 @@
   line-height: 1.35;
 }
 
-.bx-page .advance-card-sub-row .k {
+.bx-page .advance-card-sub-row .k,
+.bx-page .reimburse-card-sub-row .k {
   color: var(--text-sec);
   flex-shrink: 0;
   margin-right: 8px;
 }
 
-.bx-page .advance-card-sub-row .v {
+.bx-page .advance-card-sub-row .v,
+.bx-page .reimburse-card-sub-row .v {
   font-weight: 600;
   color: var(--text);
   font-variant-numeric: tabular-nums;
@@ -995,3 +1000,235 @@
   line-height: 1.45;
   word-break: break-word;
 }
+
+/* Reimburse apply: detail lines + pickers */
+.bx-page .detail-line-no {
+  font-weight: 500;
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
+.bx-page .detail-line .detail-divider {
+  height: 1px;
+  background: var(--border);
+  margin: 12px 0;
+}
+
+.bx-page .detail-line-actions {
+  text-align: right;
+  margin-top: 4px;
+}
+
+.bx-page .detail-actions {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px dashed var(--border);
+  gap: 8px;
+}
+
+.bx-page .total-amount {
+  font-size: 14px;
+  color: var(--text-sec);
+  flex-shrink: 0;
+}
+
+.bx-page .total-amount b {
+  color: var(--primary-dark);
+  font-size: 16px;
+}
+
+.bx-page .picker-trigger {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 42px;
+  padding: 8px 12px;
+  border: 1.5px solid var(--border);
+  border-radius: var(--rs);
+  background: var(--surface);
+  box-sizing: border-box;
+}
+
+.bx-page .picker-trigger:active {
+  background: var(--bg);
+}
+
+.bx-page .picker-trigger .value {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bx-page .picker-trigger .ph {
+  color: var(--text-muted);
+  font-weight: 600;
+}
+
+.bx-page .picker-trigger .arrow {
+  color: var(--text-muted);
+  flex-shrink: 0;
+  margin-left: 8px;
+  font-size: 16px;
+}
+
+.bx-page .form-tip {
+  font-size: 12px;
+  color: var(--text-muted);
+  margin: 8px 0 0;
+  line-height: 1.45;
+}
+
+.bx-page .invoice-balance-tip {
+  font-size: 13px;
+  color: var(--text-sec);
+  margin: 0 0 10px;
+  line-height: 1.5;
+}
+
+.bx-page .invoice-balance-tip--warn {
+  color: #dc2626;
+  margin-left: 4px;
+}
+
+.bx-page .invoice-picked-row {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.bx-page .invoice-picked-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 10px 12px;
+  border: 1px solid var(--border);
+  border-radius: var(--rs);
+  background: var(--bg);
+}
+
+.bx-page .invoice-picked-item__meta {
+  flex: 1;
+  min-width: 0;
+}
+
+.bx-page .invoice-picked-item__name {
+  display: block;
+  font-size: 13px;
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bx-page .invoice-picked-item__amt {
+  font-size: 12px;
+  color: var(--text-sec);
+  margin-top: 2px;
+}
+
+.bx-page .invoice-picked-item__remove {
+  flex-shrink: 0;
+  width: 28px;
+  height: 28px;
+  line-height: 28px;
+  text-align: center;
+  border-radius: 50%;
+  background: rgba(15, 23, 42, 0.45);
+  color: #fff;
+  font-size: 16px;
+}
+
+.bx-page .bx-sheet-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  background: rgba(15, 23, 42, 0.45);
+  display: flex;
+  align-items: flex-end;
+}
+
+.bx-page .bx-sheet {
+  width: 100%;
+  max-height: 70vh;
+  background: var(--surface);
+  border-radius: 16px 16px 0 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.bx-page .bx-sheet__title {
+  padding: 14px 16px;
+  font-size: 16px;
+  font-weight: 700;
+  border-bottom: 1px solid var(--border);
+  text-align: center;
+}
+
+.bx-page .bx-sheet__body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px 0;
+  max-height: 50vh;
+}
+
+.bx-page .bx-sheet__item {
+  padding: 12px 16px;
+  font-size: 14px;
+  border-bottom: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.bx-page .bx-sheet__item.is-selected {
+  background: var(--primary-bg);
+  color: var(--primary-dark);
+  font-weight: 600;
+}
+
+.bx-page .bx-sheet__item-sub {
+  font-size: 12px;
+  color: var(--text-muted);
+  margin-top: 2px;
+}
+
+.bx-page .bx-sheet__footer {
+  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
+  border-top: 1px solid var(--border);
+}
+
+.bx-page .bx-sheet__empty {
+  padding: 28px 16px;
+  text-align: center;
+  color: var(--text-muted);
+  font-size: 13px;
+}
+
+.bx-page .reimburse-detail-group + .reimburse-detail-group {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px dashed var(--border);
+}
+
+.bx-page .reimburse-detail-head {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--text-muted);
+  margin-bottom: 8px;
+}
+
+.bx-page .reimburse-detail-total-row {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid var(--border);
+}

+ 64 - 0
miniprogram/utils/bxFormat.js

@@ -85,3 +85,67 @@ export function formatApplyDateTime(row) {
 export function formatApplyDate(row) {
   return formatDate(pickApplyTime(row))
 }
+
+/** 费用树扁平化(tree → flatList) */
+export function flattenExpenseTree(tree) {
+  const result = []
+  for (const node of tree || []) {
+    result.push(node)
+    if (node.children?.length) result.push(...flattenExpenseTree(node.children))
+  }
+  return result
+}
+
+export function normalizeExpenseItem(raw) {
+  const children = Array.isArray(raw?.children)
+    ? raw.children.map(normalizeExpenseItem)
+    : undefined
+  return {
+    id: String(raw?.id ?? ''),
+    parentId: raw?.parentId == null || raw?.parentId === '' ? '0' : String(raw.parentId),
+    itemName: raw?.itemName ?? '',
+    itemCode: raw?.itemCode ?? '',
+    level: Number(raw?.level ?? 0),
+    sortOrder: Number(raw?.sortOrder ?? 0),
+    status: raw?.status,
+    childCount: Number(raw?.childCount ?? children?.length ?? 0),
+    children
+  }
+}
+
+export function formatExpenseName(id, flatList) {
+  if (id == null || id === '') return '-'
+  const sid = String(id)
+  const parts = []
+  let cur = (flatList || []).find((i) => String(i.id) === sid)
+  const guard = new Set()
+  while (cur && !guard.has(cur.id)) {
+    guard.add(cur.id)
+    parts.unshift(cur.itemName)
+    const pid = cur.parentId
+    cur =
+      pid != null && pid !== '' && pid !== '0'
+        ? (flatList || []).find((i) => String(i.id) === pid)
+        : undefined
+  }
+  return parts.length ? parts.join('/') : '-'
+}
+
+export function pickExpenseItemId(row) {
+  if (!row || typeof row !== 'object') return null
+  const raw = row.expenseId ?? row.expense_id ?? row.expenseItemId ?? row.expense_item_id
+  if (raw == null || raw === '') return null
+  return String(raw)
+}
+
+export function resolveExpenseDisplay(row, flatList) {
+  if (!row) return '-'
+  const id = pickExpenseItemId(row)
+  const nameFb = row.expenseName ?? row.expense_name
+  if (id != null && flatList && flatList.length) {
+    const p = formatExpenseName(id, flatList)
+    if (p && p !== '-') return p
+  }
+  const fb = nameFb != null ? String(nameFb).trim() : ''
+  return fb || '-'
+}