Merge pull request 'feat: production blue-green deployment for Admin' (#8) from release/test into main

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-09-20 22:23:33 +08:00
12 changed files with 224 additions and 84 deletions
+6 -2
View File
@@ -9,12 +9,16 @@ services:
- "127.0.0.1:${HOST_PORT:-9001}:80" - "127.0.0.1:${HOST_PORT:-9001}:80"
environment: environment:
CLIENT_WEB_URL: "https://app.buggoglobal.com/" CLIENT_WEB_URL: "https://app.buggoglobal.com/"
API_UPSTREAM: "http://gateway:9000" API_UPSTREAM: "http://gateway-active:9000"
OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300" OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"
networks: networks:
- infra-network - infra-network
- routing-network
networks: networks:
infra-network: infra-network:
external: true external: true
name: firstleg-prod-infra-network name: firstleg-prod-infra-network
routing-network:
external: true
name: firstleg-production-routing
+85 -28
View File
@@ -59,35 +59,92 @@ compose() {
docker compose --project-name "$project_name" -f "$compose_file" "$@" docker compose --project-name "$project_name" -f "$compose_file" "$@"
} }
container_id="$(compose ps --all --quiet "$service" | head -n 1)" case "$service" in
previous_image="" admin-web)
if [ -n "$container_id" ]; then base_port=9001
previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")" nginx_config=/www/server/panel/vhost/nginx/admin.buggoglobal.com.conf
fi legacy_container=firstleg-prod-admin-web
;;
if compose up -d --no-deps --pull never "$service" && \ client-web)
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then base_port=9002
echo "生产前端部署成功: $image_repository:$image_tag" nginx_config=/www/server/panel/vhost/nginx/app.buggoglobal.com.conf
compose ps "$service" legacy_container=firstleg-prod-client-web
exit 0 ;;
fi *) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;;
echo "生产前端健康检查失败,准备回滚" >&2
compose logs --tail=100 "$service" >&2 || true
case "$previous_image" in
"$image_repository:"*) ;;
'') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;;
*) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;;
esac esac
previous_tag="${previous_image#"$image_repository:"}"
if [ "$previous_tag" = "$image_tag" ]; then state_root=/data/firstleg/deploy/blue-green-state
echo "旧镜像与新镜像一致,无法回滚" >&2 state_file="$state_root/frontend-$service.active"
routing_network=firstleg-production-routing
mkdir -p "$state_root"
chmod 750 "$state_root"
docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null
for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do
alias_name="${pair%%:*}"
container="${pair#*:}"
if docker inspect "$container" >/dev/null 2>&1; then
docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \
|| docker network connect --alias "$alias_name" "$routing_network" "$container"
fi
done
active_slot=""
active_container=""
active_port=""
if [ -s "$state_file" ]; then
IFS='|' read -r active_slot active_container active_port active_image < "$state_file"
elif docker inspect "$legacy_container" >/dev/null 2>&1; then
active_slot=legacy
active_container="$legacy_container"
active_port="$base_port"
fi
if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi
[ "$active_slot" = legacy ] && target_slot=green
if [ "$target_slot" = blue ]; then target_port="$base_port"; else target_port=$((base_port + 10000)); fi
candidate="${legacy_container}-${target_slot}"
docker rm -f "$candidate" >/dev/null 2>&1 || true
if ! compose run -d --no-deps --name "$candidate" \
-p "127.0.0.1:$target_port:80" "$service" >/dev/null; then
docker rm -f "$candidate" >/dev/null 2>&1 || true
exit 1 exit 1
fi fi
docker pull "$previous_image"
export IMAGE_TAG="$previous_tag" healthy=false
compose up -d --no-deps --pull never "$service" elapsed=0
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true while [ "$elapsed" -lt 120 ]; do
echo "已回滚至: $previous_image" >&2 if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then
exit 1 healthy=true
break
fi
sleep 2
elapsed=$((elapsed + 2))
done
if [ "$healthy" != true ]; then
docker logs --tail 100 "$candidate" >&2 || true
docker rm -f "$candidate" >/dev/null 2>&1 || true
exit 1
fi
backup="$nginx_config.blue-green-backup"
cp -f "$nginx_config" "$backup"
if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \
|| cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then
cp -f "$backup" "$nginx_config"
nginx -t && nginx -s reload || true
docker rm -f "$candidate" >/dev/null 2>&1 || true
exit 1
fi
rm -f "$backup"
if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then
docker stop --time 30 "$active_container" >/dev/null
docker rm "$active_container" >/dev/null
fi
printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp"
chmod 640 "$state_file.tmp"
mv -f "$state_file.tmp" "$state_file"
echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag"
REMOTE_DEPLOY REMOTE_DEPLOY
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n')
test('production frontend starts and validates the candidate before switching nginx', () => {
const start = script.indexOf('compose run -d --no-deps --name "$candidate"')
const health = script.indexOf('docker exec "$candidate" wget')
const nginx = script.indexOf('nginx -t || ! nginx -s reload')
const stop = script.indexOf('docker stop --time 30 "$active_container"')
const state = script.indexOf('mv -f "$state_file.tmp" "$state_file"')
assert.ok(start >= 0 && start < health && health < nginx && nginx < stop && stop < state)
})
test('production frontend supports both slots and restores nginx on failure', () => {
assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
assert.match(script, /cp -f "\$nginx_config" "\$backup"/)
assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
assert.match(script, /docker rm -f "\$candidate"/)
assert.match(script, /gateway-active:firstleg-production-gateway/)
})
+3 -1
View File
@@ -60,7 +60,9 @@ describe('admin nginx deployment config', () => {
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'") expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
expect(jenkinsfile).toContain('git rev-parse origin/main') expect(jenkinsfile).toContain('git rev-parse origin/main')
expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80') expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80')
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway:9000"') expect(productionCompose).toContain('API_UPSTREAM: "http://gateway-active:9000"')
expect(productionCompose).toContain('OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"')
expect(productionCompose).toContain('name: firstleg-production-routing')
expect(productionCompose).toContain('name: firstleg-prod-infra-network') expect(productionCompose).toContain('name: firstleg-prod-infra-network')
}) })
+1
View File
@@ -104,6 +104,7 @@ describe('RolesView', () => {
it('restores saved permission ids after the dialog tree has mounted', () => { it('restores saved permission ids after the dialog tree has mounted', () => {
expect(rolesSource).toContain('@opened="initializePermissionSelection"') expect(rolesSource).toContain('@opened="initializePermissionSelection"')
expect(rolesSource).toContain('treeRef.value?.setCheckedKeys([...form.permissionIds])') expect(rolesSource).toContain('treeRef.value?.setCheckedKeys([...form.permissionIds])')
expect(rolesSource).not.toContain('<code v-if="data.code">{{ data.code }}</code>')
expect(rolesSource).not.toContain('nextTick(() =>') expect(rolesSource).not.toContain('nextTick(() =>')
}) })
+1 -2
View File
@@ -266,7 +266,7 @@ onMounted(loadRoles)
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item> <el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item> <el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
</FormSection> </FormSection>
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions density-form-section"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span><code v-if="data.code">{{ data.code }}</code></div></template></el-tree></FormSection> <FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions density-form-section"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span></div></template></el-tree></FormSection>
</div> </div>
</el-form> </el-form>
</AppDialog> </AppDialog>
@@ -279,7 +279,6 @@ onMounted(loadRoles)
.role-editor-layout > .role-editor-permissions.density-form-section { min-width: 0; padding: 0 0 0 var(--admin-space-5); border-left: 1px solid var(--cc-border); } .role-editor-layout > .role-editor-permissions.density-form-section { min-width: 0; padding: 0 0 0 var(--admin-space-5); border-left: 1px solid var(--cc-border); }
.permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; } .permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; }
.permission-node span { min-width: 0; } .permission-node span { min-width: 0; }
.permission-node code { max-width: 58%; white-space: normal; text-align: right; }
.permission-node.is-navigation-group span { font-weight: 650; } .permission-node.is-navigation-group span { font-weight: 650; }
:global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; } :global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
:global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; } :global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
@@ -61,7 +61,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
expect(page('DispatchDetailView.vue')).toContain('density-fields') expect(page('DispatchDetailView.vue')).toContain('density-fields')
expect(page('DeliveryOrderDetailView.vue')).toContain('density-fields') expect(page('DeliveryOrderDetailView.vue')).toContain('density-fields')
expect(page('InventoryDetailView.vue')).toContain('density-fields') expect(page('InventoryDetailView.vue')).toContain('density-fields')
expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(2) expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(3)
expect(page('InventoryDetailView.vue')).toContain('.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }') expect(page('InventoryDetailView.vue')).toContain('.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }')
const outbounds = page('OutboundsView.vue') const outbounds = page('OutboundsView.vue')
expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }') expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }')
@@ -31,7 +31,7 @@ function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(
<template><OverseasDetailShell class="admin-density" dense title="库存详情" :reference-label="referenceLabel" :reference-value="referenceValue" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <template><OverseasDetailShell class="admin-density" dense title="库存详情" :reference-label="referenceLabel" :reference-value="referenceValue" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields density-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields density-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section> <template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields density-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields density-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><div class="inventory-table-wrap"><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><div class="inventory-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template> <section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><div class="inventory-table-wrap"><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><div class="inventory-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><div class="inventory-table-wrap"><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></div></section></template>
</OverseasDetailShell></template> </OverseasDetailShell></template>
<style scoped>.overview-panel>section{min-width:0;padding:var(--admin-space-5) var(--admin-space-5) var(--admin-space-5);border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 var(--admin-space-5);color:var(--cc-text);font-size:var(--admin-font-section)}.ledger-section h2,.ledger-note{margin-right:var(--admin-space-5);margin-left:var(--admin-space-5)}.ledger-note{color:var(--cc-muted);font-size:var(--admin-font-caption)}@media(max-width:1100px){.overview-panel,.detail-fields{grid-template-columns:1fr}.overview-panel>section{border-right:0;border-bottom:1px solid var(--cc-border)}} <style scoped>.overview-panel>section{min-width:0;padding:var(--admin-space-5) var(--admin-space-5) var(--admin-space-5);border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 var(--admin-space-5);color:var(--cc-text);font-size:var(--admin-font-section)}.ledger-section h2,.ledger-note{margin-right:var(--admin-space-5);margin-left:var(--admin-space-5)}.ledger-note{color:var(--cc-muted);font-size:var(--admin-font-caption)}@media(max-width:1100px){.overview-panel,.detail-fields{grid-template-columns:1fr}.overview-panel>section{border-right:0;border-bottom:1px solid var(--cc-border)}}
.overview-panel {display:grid;min-width:0; grid-template-columns: minmax(0, 1fr); border: 0;background:#fff; } .overview-panel {display:grid;min-width:0; grid-template-columns: minmax(0, 1fr); border: 0;background:#fff; }
@@ -176,9 +176,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it.each([ it.each([
[[], ['查看'], false], [[], ['查看'], false],
[['inbound-order:create'], ['查看', '入库计划'], false], [['inbound-order:create'], ['查看', '生成入库计划'], false],
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false], [['last-mile-order:edit'], ['查看', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', '暂扣订单', ''], true], [['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '人工修改节点'], false],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => { ])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action)) canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 }) vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -213,9 +213,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
}) })
it.each([ it.each([
['PENDING_ASSIGNMENT', true], ['PENDING_ASSIGNMENT', false],
['INBOUND_COMPLETED', false], ['INBOUND_COMPLETED', true],
])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => { ])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => {
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false, id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false,
@@ -223,8 +223,38 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await mountView() const wrapper = await mountView()
const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button') const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
.find(item => item.text().includes('暂扣订单'))! .find(item => item.text().includes('暂扣订单'))
expect((holdButton.element as HTMLButtonElement).disabled).toBe(disabled) expect(Boolean(holdButton)).toBe(visible)
if (holdButton) expect((holdButton.element as HTMLButtonElement).disabled).toBe(false)
})
it('keeps release hold available for an already detained order', async () => {
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const releaseButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
.find(item => item.text().includes('解除暂扣'))
expect(releaseButton).toBeTruthy()
expect((releaseButton!.element as HTMLButtonElement).disabled).toBe(false)
})
it('removes generation and hold actions after an inbound plan has been submitted', async () => {
canAccess.mockReturnValue(true)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', detained: false,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.text()).toContain('查看')
expect(actions.text()).not.toContain('生成入库计划')
expect(actions.text()).not.toContain('暂扣订单')
await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]])
expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
}) })
it('keeps the original pending order table and does not render a view switch or container table', async () => { it('keeps the original pending order table and does not render a view switch or container table', async () => {
@@ -342,7 +372,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(payload).not.toHaveProperty('plannedWarehouseName') expect(payload).not.toHaveProperty('plannedWarehouseName')
}) })
it('allows a historically held order to generate its inbound plan', async () => { it('blocks a held order from generating its inbound plan', async () => {
const wrapper = await openPending() const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({ await selectRows(wrapper, [selectedRow({
detained: true, detained: true,
@@ -352,10 +382,10 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await button(wrapper, '生成入库计划')!.trigger('click') await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises() await flushPromises()
expect(getContainerInboundPlanPreview).toHaveBeenCalledWith(bookingId) expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
const confirm = [...document.body.querySelectorAll('button')] const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement .find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
expect(confirm.disabled).toBe(false) expect(confirm).toBeUndefined()
}) })
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => { it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
@@ -445,6 +445,7 @@ function isRejectedReturn(source: object) {
return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED' return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED'
} }
function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) } function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) }
function hasSelectedInboundPlan() { return selectedRows.value.some(hasInboundPlan) }
async function loadTransferWarehouseOptions() { async function loadTransferWarehouseOptions() {
transferWarehouseLoading.value = true transferWarehouseLoading.value = true
transferWarehouseError.value = null transferWarehouseError.value = null
@@ -500,6 +501,7 @@ function openInboundPlan(source?: object) {
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null) const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return } if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
if (hasInboundPlan(target)) { ElMessage.warning('当前订单已提交入库计划,不能重复生成'); return }
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return } if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
actionTarget.value = target actionTarget.value = target
const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1 const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1
@@ -811,13 +813,27 @@ function hasRelabelActions(row: LastMileOrderListItem) {
&& Boolean(row.relabelOrderNo) && Boolean(row.relabelOrderNo)
&& (canConfirmRelabel(row) || canReworkRelabel(row)) && (canConfirmRelabel(row) || canReworkRelabel(row))
} }
const inboundPlanCreatedStatuses = new Set([
'INBOUND_PLANNED',
'PENDING_RECEIVING',
'WAIT_PDA_RECEIVING',
'INBOUND_MEASURING',
'WAIT_CUSTOMER_CONFIRMATION',
'PARTIALLY_RECEIVED',
'INBOUND_COMPLETED',
])
function hasInboundPlan(row: LastMileOrderListItem) {
return inboundPlanCreatedStatuses.has(row.currentStatusCode)
|| Boolean(row.inboundOrderNo && row.inboundOrderNo !== '—')
}
function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
const inboundPlanCreated = hasInboundPlan(row)
return [ return [
{ key: 'detail', label: '查看', priority: 500 }, { key: 'detail', label: '查看', priority: 500 },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) }, { key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' }, { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
{ key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) }, { key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) }, { key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) }, { key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) },
@@ -925,7 +941,7 @@ onMounted(() => { void load() })
</div> </div>
</section> </section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()"> <section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template> <template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template> <template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template> <template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
@@ -24,4 +24,12 @@ describe('PDA release workbench', () => {
expect(page).toContain('row.current') expect(page).toContain('row.current')
expect(page).toContain('ElMessageBox.confirm') expect(page).toContain('ElMessageBox.confirm')
}) })
it('uses the standard dense list workspace without repeating the route title', () => {
expect(page).toContain('class="surface-panel table-panel pda-release-panel"')
expect(page).toContain('<template #toolbar-start>')
expect(page).toContain('<template #toolbar-actions>')
expect(page).toContain('class="pda-release-actions density-actions"')
expect(page).not.toContain('<h1>PDA版本</h1>')
})
}) })
@@ -119,38 +119,32 @@ function download(row: PdaRelease) {
</script> </script>
<template> <template>
<AdminPage class="admin-density pda-releases-page" spacing="compact"> <AdminPage class="admin-density pda-releases-page">
<ListPageLayout aria-label="PDA版本"> <ListPageLayout aria-label="PDA版本">
<template #toolbar> <section class="surface-panel table-panel pda-release-panel" :aria-busy="loading">
<div class="pda-release-toolbar"> <el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" />
<div> <DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录">
<h1>PDA版本</h1> <template #toolbar-start>
<el-radio-group v-model="app" aria-label="选择PDA应用"> <div class="pda-release-context">
<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">
<el-radio-button value="domestic">国内 PDA</el-radio-button> <el-radio-button value="domestic">国内 PDA</el-radio-button>
<el-radio-button value="overseas">海外 PDA</el-radio-button> <el-radio-button value="overseas">海外 PDA</el-radio-button>
</el-radio-group> </el-radio-group>
<dl class="pda-release-current">
<div><dt>当前 APK</dt><dd>{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</dd></div>
<div><dt>当前 WGT</dt><dd>{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</dd></div>
</dl>
</div> </div>
<div class="pda-release-commands"> </template>
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button> <template #toolbar-actions>
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button> <el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
</div> <el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
</div> </template>
</template>
<template #metrics>
<div class="pda-release-current">
<span>当前 APK:{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</span>
<span>当前 WGT:{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</span>
</div>
</template>
<template #state>
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
</template>
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录">
<template #actions="{ row }"> <template #actions="{ row }">
<div class="pda-release-actions"> <div class="pda-release-actions density-actions">
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" link @click="download(asRelease(row))"><Download :size="15" />下载 APK</el-button> <el-button v-if="row.packageType === 'apk' && row.downloadUrl" class="density-action" data-tone="neutral" @click="download(asRelease(row))"><Download :size="15" />下载 APK</el-button>
<el-button v-if="canManage && row.status === 'DRAFT'" link :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button> <el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
<el-button v-if="canManage && row.current" link type="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button> <el-button v-if="canManage && row.current" class="density-action" data-tone="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
</div> </div>
</template> </template>
<template #app="{ row }">{{ row.app === 'domestic' ? '国内' : '海外' }}</template> <template #app="{ row }">{{ row.app === 'domestic' ? '国内' : '海外' }}</template>
@@ -160,7 +154,8 @@ function download(row: PdaRelease) {
<template #sizeBytes="{ row }">{{ (row.sizeBytes / 1024 / 1024).toFixed(1) }} MB</template> <template #sizeBytes="{ row }">{{ (row.sizeBytes / 1024 / 1024).toFixed(1) }} MB</template>
<template #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template> <template #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
<template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template> <template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
</DataTable> </DataTable>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload"> <AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload">
@@ -170,11 +165,11 @@ function download(row: PdaRelease) {
</el-form-item> </el-form-item>
<div class="pda-release-fields"> <div class="pda-release-fields">
<el-form-item label="版本号"><el-input v-model.trim="form.versionName" placeholder="例如 0.0.4" /></el-form-item> <el-form-item label="版本号"><el-input v-model.trim="form.versionName" placeholder="例如 0.0.4" /></el-form-item>
<el-form-item label="APK版本码"><el-input-number v-model="form.versionCode" :min="1" :controls="false" /></el-form-item> <el-form-item label="APK版本码"><el-input-number v-model="form.versionCode" :min="1" :controls="false" placeholder="请输入APK版本码" /></el-form-item>
<el-form-item label="最低兼容APK版本码"><el-input-number v-model="form.minAppVersionCode" :min="1" :controls="false" /></el-form-item> <el-form-item label="最低兼容APK版本码"><el-input-number v-model="form.minAppVersionCode" :min="1" :controls="false" placeholder="请输入最低兼容APK版本码" /></el-form-item>
<el-form-item label="强制更新"><el-switch v-model="form.mandatory" /></el-form-item> <el-form-item label="强制更新"><el-switch v-model="form.mandatory" /></el-form-item>
</div> </div>
<el-form-item label="更新说明"><el-input v-model="form.releaseNotes" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item> <el-form-item label="更新说明"><el-input v-model="form.releaseNotes" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入更新说明" /></el-form-item>
<el-form-item :label="`选择 ${form.packageType.toUpperCase()} 文件(最大200MB)`"><input :key="form.packageType" type="file" :accept="`.${form.packageType}`" @change="selectFile" /></el-form-item> <el-form-item :label="`选择 ${form.packageType.toUpperCase()} 文件(最大200MB)`"><input :key="form.packageType" type="file" :accept="`.${form.packageType}`" @change="selectFile" /></el-form-item>
</el-form> </el-form>
</AppDialog> </AppDialog>
@@ -182,11 +177,16 @@ function download(row: PdaRelease) {
</template> </template>
<style scoped> <style scoped>
.pda-release-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; } .pda-release-panel { min-width: 0; overflow: hidden; }
.pda-release-toolbar h1 { margin: 0 0 10px; font-size: var(--admin-font-title, 16px); } .pda-release-error { border: 0; border-bottom: 1px solid var(--el-color-danger-light-7); border-radius: 0; }
.pda-release-commands, .pda-release-actions { display: flex; align-items: center; gap: 8px; } .pda-release-context { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); }
.pda-release-commands :deep(.el-button), .pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; } .pda-release-current { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); margin: 0; }
.pda-release-current { display: flex; gap: 24px; padding: 10px 0; border-bottom: 1px solid var(--cc-border); } .pda-release-current > div { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2); }
.pda-release-current dt { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.pda-release-current dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body); font-variant-numeric: tabular-nums; }
.pda-release-actions { display: flex; align-items: center; gap: var(--admin-space-2); }
.pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
.pda-release-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; } .pda-release-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.pda-release-form :deep(.el-input-number) { width: 100%; } .pda-release-form :deep(.el-input-number) { width: 100%; }
@media (max-width: 900px) { .pda-release-context { align-items: flex-start; flex-direction: column; gap: var(--admin-space-3); } }
</style> </style>