diff --git a/.env.example b/.env.example index 8f10117..973c142 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ -VITE_AUTH_API_BASE_URL=/api -VITE_OVERSEAS_API_BASE_URL=/api/overseas +VITE_AUTH_API_BASE_URL=http://127.0.0.1:9000/api +VITE_OVERSEAS_API_BASE_URL=http://127.0.0.1:9300/api +# H5 同源前缀,由 Vite 代理转发;App 构建使用上面的绝对域名直连。 +VITE_H5_AUTH_API_BASE_URL=/api +VITE_H5_OVERSEAS_API_BASE_URL=/overseas-api +# H5 开发代理上游;正式 App 构建请在本机环境文件中改为可访问的绝对 API 根地址。 +API_PROXY_TARGET=http://127.0.0.1:9000 +OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300 # 仅开发服务器自动预填;请在不提交的 .env.local 中填写真实开发账号。 VITE_PDA_DEV_USERNAME= VITE_PDA_DEV_PASSWORD= diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js index 91720bb..ede9f27 100644 --- a/src/api/receiving-adapter.js +++ b/src/api/receiving-adapter.js @@ -25,7 +25,9 @@ export function normalizeReceivingCandidate(value = {}) { export function normalizeReceivingSession(value) { if (!value) return null - const photos = Array.isArray(value.photos) ? value.photos : [] + const photos = Array.isArray(value.photos) + ? value.photos.map((photo) => ({ ...photo, fileId: String(photo.fileId) })) + : [] return { ...value, photos: RECEIVING_PHOTO_ROLES.map((role) => photos.find((photo) => photo?.role === role)).filter(Boolean), diff --git a/src/config/runtime.js b/src/config/runtime.js index 5831692..cab2108 100644 --- a/src/config/runtime.js +++ b/src/config/runtime.js @@ -3,8 +3,17 @@ const configuredAuthApiRoot = runtimeEnvironment.VITE_AUTH_API_BASE_URL || '' const configuredOverseasApiRoot = runtimeEnvironment.VITE_OVERSEAS_API_BASE_URL || '' const development = Boolean(runtimeEnvironment.DEV) -export const API_ROOT = String(configuredAuthApiRoot || '/api').replace(/\/$/, '') -export const OVERSEAS_API_ROOT = String(configuredOverseasApiRoot || '/api/overseas').replace(/\/$/, '') +let selectedAuthApiRoot = configuredAuthApiRoot || '/api' +let selectedOverseasApiRoot = configuredOverseasApiRoot || '/overseas-api' + +// H5 must stay same-origin and use Vite's CORS-safe proxy; App builds keep the absolute API roots above. +// #ifdef H5 +selectedAuthApiRoot = runtimeEnvironment.VITE_H5_AUTH_API_BASE_URL || '/api' +selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/overseas-api' +// #endif + +export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '') +export const OVERSEAS_API_ROOT = String(selectedOverseasApiRoot).replace(/\/$/, '') export const DEV_LOGIN = Object.freeze({ username: development ? String(runtimeEnvironment.VITE_PDA_DEV_USERNAME || '') : '', password: development ? String(runtimeEnvironment.VITE_PDA_DEV_PASSWORD || '') : '' diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index 8377202..a271f09 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -165,7 +165,7 @@ cancelDetail() { this.detailOpen = false; receiving.selected = null }, async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } }, choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: async ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (!path) return; try { const file = await this.run('上传签收照片', () => uploadCurrentUnloadPhoto(path)); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true }) } catch (_) {} } }) }, - async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: Number(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); receiving.activeScanTarget = selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku' } catch (_) {} }, + async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); receiving.activeScanTarget = selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku' } catch (_) {} }, chooseInbound(item) { receiving.selectedInboundCode = item.inboundCode; receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; this.packageCount = Math.max(1, Number(item.pendingPackages || 1)); receiving.activeScanTarget = item.destinationType === 'OVERSEAS' ? 'carton' : 'sku' }, async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || this.busy) return; try { const value = await this.run('解析箱唛', () => resolveCurrentCarton(cartonMark)); receiving.resolvedCarton = value; receiving.selectedInboundCode = value.inboundCode; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} }, async resolveSku(raw) { const skuCode = String(raw?.detail?.value ?? raw ?? this.skuInput).trim(); if (!skuCode || this.busy) return; try { const value = await this.run('解析 SKU', () => resolveCurrentSku(skuCode)); receiving.resolvedSku = value; receiving.selectedInboundCode = value.inboundCode; receiving.resolvedPallet = null; this.skuInput = value.skuCode; this.palletInput = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} }, diff --git a/src/stores/session.js b/src/stores/session.js index 5a4ae74..6146c89 100644 --- a/src/stores/session.js +++ b/src/stores/session.js @@ -142,7 +142,7 @@ export function restoreSession() { const pair = validatePdaTokenPair(saved.domesticToken, saved.token) Object.assign(session, saved, { currentUser: userFromClaims(pair), tenantId: claimId(pair.domestic, 'tenant_id'), - apiBase: saved.apiBase || OVERSEAS_API_ROOT + apiBase: OVERSEAS_API_ROOT }) return true } catch (_) { diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs index 7fcde96..0bfd766 100644 --- a/tests/project-contract.test.mjs +++ b/tests/project-contract.test.mjs @@ -5,14 +5,18 @@ import { readFile } from 'node:fs/promises' const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') test('overseas PDA is an independent Vue 3 app with separate packaging identity', async () => { - const [manifest, pages, pkg] = await Promise.all([ - read('../src/manifest.json'), read('../src/pages.json'), read('../package.json') + const [manifest, pages, pkg, vite] = await Promise.all([ + read('../src/manifest.json'), read('../src/pages.json'), read('../package.json'), read('../vite.config.js') ]) assert.match(manifest, /"name"\s*:\s*"柏悦物流\s*PDA\s*海外版"/) assert.match(manifest, /"packagename"\s*:\s*"com\.baiyuelogistics\.pda\.overseas"/) assert.match(manifest, /"vueVersion"\s*:\s*"3"/) assert.doesNotMatch(pages, /pages\/picking|pages\/tasks/) assert.match(pkg, /"build:app"\s*:\s*"uni build -p app"/) + assert.match(vite, /API_PROXY_TARGET/) + assert.match(vite, /OVERSEAS_API_PROXY_TARGET/) + assert.match(vite, /\/overseas-api/) + assert.match(vite, /replace\(\/\^\\\/overseas-api\//) }) test('overseas workbench contains only confirmed inbound and in-warehouse domains', async () => { @@ -34,6 +38,9 @@ test('login has no committed demo credentials and inbound operation implements t assert.doesNotMatch(login, /ChangeMe|platform_admin/) assert.match(login, /DEV_LOGIN\.username|DEV_LOGIN\.password/) assert.match(runtime, /runtimeEnvironment\.DEV|VITE_PDA_DEV_USERNAME|VITE_PDA_DEV_PASSWORD/) + assert.match(runtime, /'\/overseas-api'/) + assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_AUTH_API_BASE_URL/) + assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_OVERSEAS_API_BASE_URL/) assert.match(operation, /startScanBroadcast/) for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentSku', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action)) for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/skus/resolve', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/'))) diff --git a/tests/receiving-adapter.test.mjs b/tests/receiving-adapter.test.mjs index 6f330ec..73f841b 100644 --- a/tests/receiving-adapter.test.mjs +++ b/tests/receiving-adapter.test.mjs @@ -3,12 +3,14 @@ import assert from 'node:assert/strict' import { isNoCurrentReceiving, isReceivingConflict, normalizeReceivingCandidate, normalizeReceivingSession, RECEIVING_PHOTO_ROLES } from '../src/api/receiving-adapter.js' test('receiving adapter preserves the three signed photo roles in workflow order', () => { + const snowflakeFileId = '2092476334483931138' const session = normalizeReceivingSession({ status: 'WAIT_SIGN', totalPackages: 8, receivedPackages: 2, pendingPackages: 6, - photos: [{ role: 'DOOR_OPEN', fileId: 3 }, { role: 'LICENSE_PLATE', fileId: 1 }, { role: 'CONTAINER_SEAL', fileId: 2 }], + photos: [{ role: 'DOOR_OPEN', fileId: '3' }, { role: 'LICENSE_PLATE', fileId: snowflakeFileId }, { role: 'CONTAINER_SEAL', fileId: '2' }], inbounds: [{ inboundCode: 'IN-1', plannedPalletCount: 2, totalPackages: 8, receivedPackages: 2, pendingPackages: 6 }] }) assert.deepEqual(session.photos.map(({ role }) => role), RECEIVING_PHOTO_ROLES) + assert.equal(session.photos[0].fileId, snowflakeFileId) assert.equal(session.inbounds[0].plannedPalletCount, 2) }) diff --git a/vite.config.js b/vite.config.js index 40d233c..1d195ef 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,22 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import uni from '@dcloudio/vite-plugin-uni' -export default defineConfig({ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const domesticTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000' + const overseasTarget = env.OVERSEAS_API_PROXY_TARGET || 'http://127.0.0.1:9300' + return { plugins: [uni()], + server: { + proxy: { + '/api': { target: domesticTarget, changeOrigin: true }, + '/overseas-api': { + target: overseasTarget, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/overseas-api/, '/api') + } + } + }, css: { preprocessorOptions: { scss: { @@ -18,4 +32,5 @@ export default defineConfig({ } } } + } })