diff --git a/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md b/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md index f522095b..3e622169 100644 --- a/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md +++ b/docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md @@ -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) ## 本地联调提示 diff --git a/mobile-oa-h5/src/api/oa.js b/mobile-oa-h5/src/api/oa.js new file mode 100644 index 00000000..dea52050 --- /dev/null +++ b/mobile-oa-h5/src/api/oa.js @@ -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 } diff --git a/mobile-oa-h5/src/main.js b/mobile-oa-h5/src/main.js index d7fb723c..be52fe4d 100644 --- a/mobile-oa-h5/src/main.js +++ b/mobile-oa-h5/src/main.js @@ -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) diff --git a/mobile-oa-h5/src/oa/components/OaMoneyField.vue b/mobile-oa-h5/src/oa/components/OaMoneyField.vue new file mode 100644 index 00000000..252505ba --- /dev/null +++ b/mobile-oa-h5/src/oa/components/OaMoneyField.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/mobile-oa-h5/src/oa/components/OaReceiveAccountSelect.vue b/mobile-oa-h5/src/oa/components/OaReceiveAccountSelect.vue new file mode 100644 index 00000000..5b16efc0 --- /dev/null +++ b/mobile-oa-h5/src/oa/components/OaReceiveAccountSelect.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/mobile-oa-h5/src/oa/components/OaRelatedApproval.vue b/mobile-oa-h5/src/oa/components/OaRelatedApproval.vue new file mode 100644 index 00000000..daccaeb7 --- /dev/null +++ b/mobile-oa-h5/src/oa/components/OaRelatedApproval.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/mobile-oa-h5/src/oa/components/OaUploadField.vue b/mobile-oa-h5/src/oa/components/OaUploadField.vue new file mode 100644 index 00000000..1a659e88 --- /dev/null +++ b/mobile-oa-h5/src/oa/components/OaUploadField.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/mobile-oa-h5/src/oa/register-components.js b/mobile-oa-h5/src/oa/register-components.js index 7a7ac34f..626900a5 100644 --- a/mobile-oa-h5/src/oa/register-components.js +++ b/mobile-oa-h5/src/oa/register-components.js @@ -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) } } diff --git a/mobile-oa-h5/src/utils/auth.js b/mobile-oa-h5/src/utils/auth.js new file mode 100644 index 00000000..3906d343 --- /dev/null +++ b/mobile-oa-h5/src/utils/auth.js @@ -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 '' + } +} diff --git a/mobile-oa-h5/src/utils/http.js b/mobile-oa-h5/src/utils/http.js new file mode 100644 index 00000000..4687141a --- /dev/null +++ b/mobile-oa-h5/src/utils/http.js @@ -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 }) +} diff --git a/mobile-oa-h5/src/utils/receive-account-value.js b/mobile-oa-h5/src/utils/receive-account-value.js new file mode 100644 index 00000000..b2f118aa --- /dev/null +++ b/mobile-oa-h5/src/utils/receive-account-value.js @@ -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() + } +} diff --git a/mobile-oa-h5/src/utils/rmb-upper.js b/mobile-oa-h5/src/utils/rmb-upper.js new file mode 100644 index 00000000..0cbee9db --- /dev/null +++ b/mobile-oa-h5/src/utils/rmb-upper.js @@ -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 +}