feat(mobile-oa-h5): implement P0 money/upload/receiveAccount/relatedApproval fields
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
f0e687eec9
commit
bb86eb7ad2
12 files changed
+910
-8
No files matched your search
@@ -11,7 +11,14 @@
|
||||
- [ ] 基础字段可填、自研 stub 可见、校验按钮可用
|
||||
- 问题列表:
|
||||
1. (验收时填写)
|
||||
- 下一步:开始 P0 真实控件(`upload` / `money` / `relatedApproval` / `receiveAccount`)移植计划
|
||||
- 下一步:P1(`dept`/`person`)与套件;正式 `/oa-start` 联调
|
||||
|
||||
## P0 真实组件(已落地)
|
||||
|
||||
- `money`:数字输入 + 人民币大写
|
||||
- `upload`:`van-uploader` → `/web/admin/attachment/upload`(需 URL `token`)
|
||||
- `receiveAccount`:底部多选弹层 + options API
|
||||
- `relatedApproval`:关联列表 + 候选弹层(mine/done/cc)
|
||||
|
||||
## 本地联调提示
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { get, post, request } from '../utils/http.js'
|
||||
|
||||
export function fetchReceiveAccountOptions() {
|
||||
return get('/web/admin/oa-approval/receive-accounts/options')
|
||||
}
|
||||
|
||||
export function fetchRelatedCandidates(params) {
|
||||
return get('/web/admin/oa-approval/related-candidates', params)
|
||||
}
|
||||
|
||||
export function fetchRelatedSummaries(ids, mode = 'view') {
|
||||
return get('/web/admin/oa-approval/related-summaries', {
|
||||
ids: (ids || []).filter(Boolean).join(','),
|
||||
mode
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadAttachment(file) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('folder', 'oa-approval')
|
||||
return request({
|
||||
url: '/web/admin/attachment/upload',
|
||||
method: 'POST',
|
||||
data: fd
|
||||
})
|
||||
}
|
||||
|
||||
export { post }
|
||||
@@ -7,6 +7,9 @@ import router from './router'
|
||||
import './styles/tokens.css'
|
||||
import { registerOaComponents } from './oa/register-components'
|
||||
|
||||
// 三墙主色(勿用 web-admin #10b981)
|
||||
document.documentElement.style.setProperty('--van-primary-color', '#0a9f6d')
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(Vant)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="oa-money">
|
||||
<van-field
|
||||
v-model="text"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="onInput"
|
||||
/>
|
||||
<div v-if="showUpper && upperText" class="oa-money__upper">{{ upperText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toRmbUpper } from '../../utils/rmb-upper.js'
|
||||
|
||||
defineOptions({ name: 'money' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Number, String], default: null },
|
||||
disabled: { type: Boolean, default: false },
|
||||
showUpper: { type: Boolean, default: true },
|
||||
placeholder: { type: String, default: '请输入金额' },
|
||||
formCreateInject: { type: Object, default: null }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
function toNum(v) {
|
||||
if (v == null || v === '') return null
|
||||
const n = typeof v === 'number' ? v : Number(String(v).replace(/,/g, ''))
|
||||
if (!Number.isFinite(n) || n < 0) return null
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
const text = ref(props.modelValue == null || props.modelValue === '' ? '' : String(props.modelValue))
|
||||
const numeric = computed(() => toNum(props.modelValue))
|
||||
const upperText = computed(() => {
|
||||
const n = numeric.value
|
||||
return n == null ? '' : toRmbUpper(n)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
const next = v == null || v === '' ? '' : String(v)
|
||||
if (next !== text.value) text.value = next
|
||||
}
|
||||
)
|
||||
|
||||
function onInput(v) {
|
||||
const n = toNum(v)
|
||||
emit('update:modelValue', n)
|
||||
emit('change', n)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oa-money {
|
||||
width: 100%;
|
||||
}
|
||||
.oa-money__upper {
|
||||
margin: 4px 12px 0;
|
||||
color: var(--text-muted, #6b7b74);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="oa-recv">
|
||||
<van-field
|
||||
:model-value="summary"
|
||||
readonly
|
||||
is-link
|
||||
:disabled="disabled"
|
||||
placeholder="请选择收款账户"
|
||||
@click="openPicker"
|
||||
/>
|
||||
<div v-if="normalized.length" class="oa-recv__detail">
|
||||
<div v-for="item in normalized" :key="item.id" class="oa-recv__line">
|
||||
{{ formatReceiveAccountLine(item) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-popup v-model:show="visible" position="bottom" round :style="{ height: '70%' }">
|
||||
<div class="oa-recv__sheet">
|
||||
<div class="oa-recv__hd">
|
||||
<span @click="visible = false">取消</span>
|
||||
<strong>选择收款账户</strong>
|
||||
<span class="oa-recv__ok" @click="confirm">确定</span>
|
||||
</div>
|
||||
<van-search v-model="keyword" placeholder="搜索账户名称或账号" />
|
||||
<div class="oa-recv__list">
|
||||
<van-checkbox-group v-model="pendingIds">
|
||||
<van-cell
|
||||
v-for="a in filtered"
|
||||
:key="a.id"
|
||||
clickable
|
||||
:title="a.label"
|
||||
@click="toggle(a.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-checkbox :name="a.id" @click.stop />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-checkbox-group>
|
||||
<van-empty v-if="!loading && !filtered.length" description="暂无收款账户" />
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { fetchReceiveAccountOptions } from '../../api/oa.js'
|
||||
import {
|
||||
formatReceiveAccountLine,
|
||||
normalizeReceiveAccountModelValue,
|
||||
optionLabelFromRow,
|
||||
snapshotFromRow
|
||||
} from '../../utils/receive-account-value.js'
|
||||
|
||||
defineOptions({ name: 'receiveAccount' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Array, Object], default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
formCreateInject: { type: Object, default: null }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const pendingIds = ref([])
|
||||
const snapshotMap = ref(new Map())
|
||||
const accountOptions = ref([])
|
||||
|
||||
const normalized = computed(() => normalizeReceiveAccountModelValue(props.modelValue))
|
||||
const summary = computed(() => {
|
||||
const n = normalized.value.length
|
||||
if (!n) return ''
|
||||
if (n === 1) return formatReceiveAccountLine(normalized.value[0])
|
||||
return `已选 ${n} 个账户`
|
||||
})
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return accountOptions.value
|
||||
return accountOptions.value.filter((a) => a.label.toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
function emitItems(items) {
|
||||
emit('update:modelValue', items)
|
||||
emit('change', items)
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (props.disabled) return
|
||||
pendingIds.value = normalized.value.map((x) => x.id)
|
||||
visible.value = true
|
||||
if (!accountOptions.value.length) loadOptions()
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const set = new Set(pendingIds.value)
|
||||
if (set.has(id)) set.delete(id)
|
||||
else set.add(id)
|
||||
pendingIds.value = [...set]
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
const items = pendingIds.value
|
||||
.map((id) => snapshotMap.value.get(id))
|
||||
.filter(Boolean)
|
||||
emitItems(items)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchReceiveAccountOptions()
|
||||
const list = res?.list || []
|
||||
const map = new Map()
|
||||
const opts = []
|
||||
for (const row of list) {
|
||||
const snap = snapshotFromRow(row)
|
||||
if (!snap.id) continue
|
||||
map.set(snap.id, snap)
|
||||
opts.push({ id: snap.id, label: optionLabelFromRow(snap) })
|
||||
}
|
||||
for (const x of normalized.value) {
|
||||
if (!map.has(x.id)) map.set(x.id, x)
|
||||
}
|
||||
snapshotMap.value = map
|
||||
accountOptions.value = opts
|
||||
} catch (e) {
|
||||
showToast(e?.message || '加载收款账户失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oa-recv__detail {
|
||||
padding: 0 12px 8px;
|
||||
color: var(--text-muted, #6b7b74);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.oa-recv__line + .oa-recv__line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.oa-recv__sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.oa-recv__hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border, #dcebe4);
|
||||
}
|
||||
.oa-recv__ok {
|
||||
color: var(--brand-green, #0a9f6d);
|
||||
font-weight: 600;
|
||||
}
|
||||
.oa-recv__list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="oa-rel">
|
||||
<div v-if="!ids.length" class="oa-rel__empty">
|
||||
<span>暂未关联审批单</span>
|
||||
<van-button v-if="!disabled" size="small" type="primary" plain @click="openPicker">
|
||||
添加关联
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="oa-rel__list">
|
||||
<div v-for="item in displayItems" :key="item.id" class="oa-rel__card">
|
||||
<div class="oa-rel__head">
|
||||
<div class="oa-rel__title">
|
||||
{{ item.unavailable ? '无法查看' : item.appName || '审批单' }}
|
||||
<template v-if="!item.unavailable && item.applyTime">
|
||||
· {{ item.applyTime }}
|
||||
</template>
|
||||
</div>
|
||||
<van-button
|
||||
v-if="!disabled"
|
||||
size="mini"
|
||||
type="danger"
|
||||
plain
|
||||
@click="removeId(item.id)"
|
||||
>
|
||||
删除
|
||||
</van-button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(f, idx) in (item.fields || []).slice(0, 3)"
|
||||
:key="idx"
|
||||
class="oa-rel__field"
|
||||
>
|
||||
<span class="oa-rel__label">{{ f.label }}</span>
|
||||
<span>{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<van-button
|
||||
v-if="!disabled"
|
||||
block
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openPicker"
|
||||
>
|
||||
添加关联
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-popup v-model:show="visible" position="bottom" round :style="{ height: '80%' }">
|
||||
<div class="oa-rel__sheet">
|
||||
<div class="oa-rel__hd">
|
||||
<span @click="visible = false">取消</span>
|
||||
<strong>选择关联审批单</strong>
|
||||
<span class="oa-rel__ok" @click="confirm">确定</span>
|
||||
</div>
|
||||
<van-tabs v-model:active="activeTab" @change="onTabChange">
|
||||
<van-tab title="我发起的" name="mine" />
|
||||
<van-tab title="我审批的" name="done" />
|
||||
<van-tab title="抄送我的" name="cc" />
|
||||
</van-tabs>
|
||||
<van-search
|
||||
v-model="keyword"
|
||||
placeholder="搜索流程名 / 标题"
|
||||
@search="loadCandidates"
|
||||
/>
|
||||
<div class="oa-rel__picker">
|
||||
<van-checkbox-group v-model="pendingIds">
|
||||
<van-cell
|
||||
v-for="row in candidates"
|
||||
:key="row.id"
|
||||
clickable
|
||||
:title="row.displayTitle || row.appName || '审批单'"
|
||||
:label="pickerLabel(row)"
|
||||
@click="toggle(row.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-checkbox :name="row.id" @click.stop />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-checkbox-group>
|
||||
<van-empty
|
||||
v-if="!pickerLoading && !candidates.length"
|
||||
description="暂无审批单"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { fetchRelatedCandidates, fetchRelatedSummaries } from '../../api/oa.js'
|
||||
|
||||
defineOptions({ name: 'relatedApproval' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Array, String], default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
formCreateInject: { type: Object, default: null }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const visible = ref(false)
|
||||
const pickerLoading = ref(false)
|
||||
const keyword = ref('')
|
||||
const activeTab = ref('mine')
|
||||
const candidates = ref([])
|
||||
const pendingIds = ref([])
|
||||
const summaries = ref([])
|
||||
|
||||
const ids = computed(() => {
|
||||
const raw = props.modelValue
|
||||
if (Array.isArray(raw)) return raw.map((x) => String(x || '').trim()).filter(Boolean)
|
||||
if (raw == null || raw === '') return []
|
||||
return [String(raw).trim()].filter(Boolean)
|
||||
})
|
||||
|
||||
const displayItems = computed(() => {
|
||||
const map = new Map(summaries.value.map((s) => [s.id, s]))
|
||||
return ids.value.map((id) => {
|
||||
const s = map.get(id)
|
||||
if (s) return s
|
||||
return {
|
||||
id,
|
||||
unavailable: false,
|
||||
appName: '加载中…',
|
||||
applyTime: '',
|
||||
fields: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function emitValue(next) {
|
||||
const uniq = []
|
||||
const seen = new Set()
|
||||
for (const id of next) {
|
||||
const t = String(id || '').trim()
|
||||
if (!t || seen.has(t)) continue
|
||||
seen.add(t)
|
||||
uniq.push(t)
|
||||
}
|
||||
emit('update:modelValue', uniq)
|
||||
emit('change', uniq)
|
||||
}
|
||||
|
||||
function removeId(id) {
|
||||
emitValue(ids.value.filter((x) => x !== id))
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (props.disabled) return
|
||||
pendingIds.value = []
|
||||
visible.value = true
|
||||
loadCandidates()
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const set = new Set(pendingIds.value)
|
||||
if (set.has(id)) set.delete(id)
|
||||
else set.add(id)
|
||||
pendingIds.value = [...set]
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emitValue([...ids.value, ...pendingIds.value])
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
loadCandidates()
|
||||
}
|
||||
|
||||
function pickerLabel(row) {
|
||||
const bits = []
|
||||
if (row.initiatorUserName) bits.push(row.initiatorUserName)
|
||||
if (row.statusText || row.status) bits.push(row.statusText || row.status)
|
||||
if (row.applyTime) bits.push(row.applyTime)
|
||||
return bits.join(' · ')
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
const res = await fetchRelatedCandidates({
|
||||
tab: activeTab.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
candidates.value = res?.list || []
|
||||
} catch (e) {
|
||||
candidates.value = []
|
||||
showToast(e?.message || '加载失败')
|
||||
} finally {
|
||||
pickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummaries() {
|
||||
const list = ids.value
|
||||
if (!list.length) {
|
||||
summaries.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetchRelatedSummaries(list, 'view')
|
||||
summaries.value = res?.list || []
|
||||
} catch {
|
||||
summaries.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ids.value.join(','),
|
||||
() => {
|
||||
loadSummaries()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oa-rel {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
.oa-rel__empty {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
color: var(--text-muted, #6b7b74);
|
||||
font-size: 13px;
|
||||
}
|
||||
.oa-rel__card {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border, #dcebe4);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.oa-rel__head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.oa-rel__title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f2a26);
|
||||
}
|
||||
.oa-rel__field {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
color: var(--text-muted, #6b7b74);
|
||||
font-size: 12px;
|
||||
}
|
||||
.oa-rel__label {
|
||||
flex: 0 0 auto;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.oa-rel__sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.oa-rel__hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border, #dcebe4);
|
||||
}
|
||||
.oa-rel__ok {
|
||||
color: var(--brand-green, #0a9f6d);
|
||||
font-weight: 600;
|
||||
}
|
||||
.oa-rel__picker {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="oa-upload">
|
||||
<van-uploader
|
||||
v-model="fileList"
|
||||
:disabled="disabled"
|
||||
:max-count="limit"
|
||||
:after-read="onAfterRead"
|
||||
:before-delete="onBeforeDelete"
|
||||
multiple
|
||||
/>
|
||||
<div v-if="!getToken()" class="oa-upload__tip">未携带登录 token,上传可能失败</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { uploadAttachment } from '../../api/oa.js'
|
||||
import { getToken } from '../../utils/auth.js'
|
||||
|
||||
defineOptions({ name: 'upload' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Array, String, Object], default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
limit: { type: Number, default: 9 },
|
||||
formCreateInject: { type: Object, default: null }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
function toUrlList(raw) {
|
||||
if (raw == null || raw === '') return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((x) => {
|
||||
if (typeof x === 'string') return x
|
||||
if (x && typeof x === 'object') return String(x.url || x.fileUrl || '')
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (typeof raw === 'string') return [raw]
|
||||
if (typeof raw === 'object') {
|
||||
const u = String(raw.url || raw.fileUrl || '')
|
||||
return u ? [u] : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function urlsToFileList(urls) {
|
||||
return urls.map((url, i) => ({
|
||||
url,
|
||||
name: url.split('/').pop() || `file-${i}`,
|
||||
status: 'done',
|
||||
isString: true
|
||||
}))
|
||||
}
|
||||
|
||||
const fileList = ref(urlsToFileList(toUrlList(props.modelValue)))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
const urls = toUrlList(v)
|
||||
const cur = fileList.value
|
||||
.filter((f) => f.status === 'done' || f.isString)
|
||||
.map((f) => f.url)
|
||||
.filter(Boolean)
|
||||
if (urls.join('|') !== cur.join('|')) {
|
||||
fileList.value = urlsToFileList(urls)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function emitUrls() {
|
||||
const urls = fileList.value
|
||||
.filter((f) => f.status === 'done' || f.isString)
|
||||
.map((f) => f.url)
|
||||
.filter(Boolean)
|
||||
const limit = Number(props.limit) || 9
|
||||
const value = limit === 1 ? urls[0] || '' : urls
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
|
||||
async function onAfterRead(item) {
|
||||
const items = Array.isArray(item) ? item : [item]
|
||||
for (const f of items) {
|
||||
f.status = 'uploading'
|
||||
try {
|
||||
const res = await uploadAttachment(f.file)
|
||||
const rawUrl = String(res?.fileUrl || res?.data?.fileUrl || '').trim()
|
||||
if (!rawUrl) throw new Error('未返回文件地址')
|
||||
f.url = rawUrl
|
||||
f.status = 'done'
|
||||
f.isString = true
|
||||
} catch (e) {
|
||||
f.status = 'failed'
|
||||
showToast(e?.message || '上传失败')
|
||||
}
|
||||
}
|
||||
fileList.value = fileList.value.filter((f) => f.status !== 'failed')
|
||||
emitUrls()
|
||||
}
|
||||
|
||||
function onBeforeDelete() {
|
||||
setTimeout(() => emitUrls(), 0)
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oa-upload {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
.oa-upload__tip {
|
||||
margin-top: 4px;
|
||||
color: #cf8a19;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,18 @@
|
||||
import OaStubField from './components/OaStubField.vue'
|
||||
import OaMoneyField from './components/OaMoneyField.vue'
|
||||
import OaUploadField from './components/OaUploadField.vue'
|
||||
import OaReceiveAccountSelect from './components/OaReceiveAccountSelect.vue'
|
||||
import OaRelatedApproval from './components/OaRelatedApproval.vue'
|
||||
|
||||
/** 与 web-admin formCreate.component 名称对齐;阶段一可用 stub */
|
||||
const CUSTOM_TYPES = [
|
||||
'upload',
|
||||
'money',
|
||||
'relatedApproval',
|
||||
'receiveAccount',
|
||||
/** P0 真实组件;其余仍用 stub,阶段二继续替换 */
|
||||
const REAL_COMPONENTS = {
|
||||
money: OaMoneyField,
|
||||
upload: OaUploadField,
|
||||
receiveAccount: OaReceiveAccountSelect,
|
||||
relatedApproval: OaRelatedApproval
|
||||
}
|
||||
|
||||
const STUB_TYPES = [
|
||||
'dept',
|
||||
'person',
|
||||
'ownerPerson',
|
||||
@@ -21,7 +28,10 @@ const CUSTOM_TYPES = [
|
||||
|
||||
export function registerOaComponents(formCreate) {
|
||||
if (!formCreate || typeof formCreate.component !== 'function') return
|
||||
for (const name of CUSTOM_TYPES) {
|
||||
for (const [name, comp] of Object.entries(REAL_COMPONENTS)) {
|
||||
formCreate.component(name, comp)
|
||||
}
|
||||
for (const name of STUB_TYPES) {
|
||||
formCreate.component(name, OaStubField)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 从 URL ?token= 读取登录态(小程序 web-view 传入) */
|
||||
export function getToken() {
|
||||
try {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
return String(q.get('token') || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getToken } from './auth.js'
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE || '/api').replace(/\/$/, '')
|
||||
|
||||
function unwrap(body) {
|
||||
if (body == null || typeof body !== 'object') {
|
||||
throw new Error('网络异常,请稍后重试')
|
||||
}
|
||||
if (body.code === 200) return body.data
|
||||
throw new Error((body.message && String(body.message).trim()) || '请求失败')
|
||||
}
|
||||
|
||||
export async function request({ url, method = 'GET', data, headers = {} }) {
|
||||
const path = url.startsWith('/') ? url : `/${url}`
|
||||
const full = `${API_BASE}${path}`
|
||||
const token = getToken()
|
||||
const init = {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers
|
||||
}
|
||||
}
|
||||
if (data instanceof FormData) {
|
||||
init.body = data
|
||||
} else if (data != null && init.method !== 'GET') {
|
||||
init.headers['Content-Type'] = 'application/json'
|
||||
init.body = JSON.stringify(data)
|
||||
}
|
||||
let finalUrl = full
|
||||
if (init.method === 'GET' && data && typeof data === 'object') {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(data).forEach(([k, v]) => {
|
||||
if (v != null && v !== '') q.set(k, String(v))
|
||||
})
|
||||
const s = q.toString()
|
||||
if (s) finalUrl += (finalUrl.includes('?') ? '&' : '?') + s
|
||||
}
|
||||
const res = await fetch(finalUrl, init)
|
||||
const body = await res.json().catch(() => null)
|
||||
return unwrap(body)
|
||||
}
|
||||
|
||||
export function get(url, params) {
|
||||
return request({ url, method: 'GET', data: params })
|
||||
}
|
||||
|
||||
export function post(url, data) {
|
||||
return request({ url, method: 'POST', data })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export const ACCOUNT_TYPE_LABEL = {
|
||||
alipay: '支付宝',
|
||||
personal: '个人银行卡',
|
||||
corporate: '对公银行账户'
|
||||
}
|
||||
|
||||
function isReceiveAccountType(v) {
|
||||
return v === 'alipay' || v === 'personal' || v === 'corporate'
|
||||
}
|
||||
|
||||
export function isReceiveAccountSnapshot(v) {
|
||||
if (!v || typeof v !== 'object') return false
|
||||
if (!isReceiveAccountType(v.accountType)) return false
|
||||
return String(v.id || '').trim() !== ''
|
||||
}
|
||||
|
||||
function trimSnapshot(v) {
|
||||
return {
|
||||
id: String(v.id).trim(),
|
||||
accountType: v.accountType,
|
||||
accountName: String(v.accountName || '').trim(),
|
||||
accountNo: String(v.accountNo || '').trim(),
|
||||
bankName: String(v.bankName || '').trim(),
|
||||
bankProvince: String(v.bankProvince || '').trim(),
|
||||
bankCity: String(v.bankCity || '').trim(),
|
||||
bankBranch: String(v.bankBranch || '').trim(),
|
||||
remark: String(v.remark || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReceiveAccountModelValue(raw) {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter(isReceiveAccountSnapshot).map(trimSnapshot)
|
||||
}
|
||||
if (isReceiveAccountSnapshot(raw)) return [trimSnapshot(raw)]
|
||||
return []
|
||||
}
|
||||
|
||||
export function formatReceiveAccountLine(item) {
|
||||
const parts = [
|
||||
ACCOUNT_TYPE_LABEL[item.accountType],
|
||||
item.accountName,
|
||||
item.accountNo
|
||||
]
|
||||
if (item.accountType === 'personal' || item.accountType === 'corporate') {
|
||||
if (item.bankName) parts.push(item.bankName)
|
||||
if (item.bankProvince && item.bankCity) {
|
||||
parts.push(`${item.bankProvince} / ${item.bankCity}`)
|
||||
} else if (item.bankProvince || item.bankCity) {
|
||||
parts.push(item.bankProvince || item.bankCity)
|
||||
}
|
||||
if (item.bankBranch) parts.push(item.bankBranch)
|
||||
}
|
||||
if (item.remark) parts.push(item.remark)
|
||||
return parts.filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
export function optionLabelFromRow(row) {
|
||||
const typeLabel = isReceiveAccountType(row.accountType)
|
||||
? ACCOUNT_TYPE_LABEL[row.accountType]
|
||||
: row.accountType
|
||||
return [typeLabel, row.accountName, row.accountNo].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
export function snapshotFromRow(row) {
|
||||
const accountType = row?.accountType
|
||||
return {
|
||||
id: String(row?.id ?? '').trim(),
|
||||
accountType: isReceiveAccountType(accountType) ? accountType : 'personal',
|
||||
accountName: String(row?.accountName ?? '').trim(),
|
||||
accountNo: String(row?.accountNo ?? '').trim(),
|
||||
bankName: String(row?.bankName ?? '').trim(),
|
||||
bankProvince: String(row?.bankProvince ?? '').trim(),
|
||||
bankCity: String(row?.bankCity ?? '').trim(),
|
||||
bankBranch: String(row?.bankBranch ?? '').trim(),
|
||||
remark: String(row?.remark ?? '').trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** 与 web-admin toRmbUpper 对齐的人民币大写 */
|
||||
export function toRmbUpper(value) {
|
||||
if (value == null || !Number.isFinite(value) || value < 0) return ''
|
||||
if (value > 999999999999.99) return '金额过大'
|
||||
|
||||
const fen = Math.round(value * 100)
|
||||
if (fen === 0) return '零元整'
|
||||
|
||||
const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']
|
||||
const units = ['', '拾', '佰', '仟']
|
||||
|
||||
function sectionToChinese(n) {
|
||||
if (n === 0) return ''
|
||||
let result = ''
|
||||
let zeroPending = false
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const digit = Math.floor(n / 10 ** (3 - i)) % 10
|
||||
if (digit === 0) {
|
||||
if (result !== '') zeroPending = true
|
||||
} else {
|
||||
if (zeroPending) {
|
||||
result += '零'
|
||||
zeroPending = false
|
||||
}
|
||||
result += digits[digit] + units[3 - i]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const yuan = Math.floor(fen / 100)
|
||||
const jiao = Math.floor((fen % 100) / 10)
|
||||
const fenDigit = fen % 10
|
||||
let result = ''
|
||||
|
||||
if (yuan > 0) {
|
||||
const yi = Math.floor(yuan / 100000000)
|
||||
const wan = Math.floor(yuan / 10000) % 10000
|
||||
const rest = yuan % 10000
|
||||
if (yi > 0) result += sectionToChinese(yi) + '亿'
|
||||
if (wan > 0) {
|
||||
if (yi > 0 && wan < 1000) result += '零'
|
||||
result += sectionToChinese(wan) + '万'
|
||||
}
|
||||
if (rest > 0) {
|
||||
if (wan > 0) {
|
||||
if (rest < 1000) result += '零'
|
||||
} else if (yi > 0) {
|
||||
result += '零'
|
||||
}
|
||||
result += sectionToChinese(rest)
|
||||
}
|
||||
result += '元'
|
||||
}
|
||||
|
||||
if (jiao > 0) result += digits[jiao] + '角'
|
||||
else if (fenDigit > 0 && yuan > 0) result += '零'
|
||||
if (fenDigit > 0) result += digits[fenDigit] + '分'
|
||||
if (jiao === 0 && fenDigit === 0) result += '整'
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user