feat: production blue-green deployment for Admin #8
@@ -9,12 +9,16 @@ services:
|
||||
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "https://app.buggoglobal.com/"
|
||||
API_UPSTREAM: "http://gateway:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300"
|
||||
API_UPSTREAM: "http://gateway-active:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"
|
||||
networks:
|
||||
- infra-network
|
||||
- routing-network
|
||||
|
||||
networks:
|
||||
infra-network:
|
||||
external: true
|
||||
name: firstleg-prod-infra-network
|
||||
routing-network:
|
||||
external: true
|
||||
name: firstleg-production-routing
|
||||
|
||||
@@ -59,35 +59,92 @@ compose() {
|
||||
docker compose --project-name "$project_name" -f "$compose_file" "$@"
|
||||
}
|
||||
|
||||
container_id="$(compose ps --all --quiet "$service" | head -n 1)"
|
||||
previous_image=""
|
||||
if [ -n "$container_id" ]; then
|
||||
previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")"
|
||||
fi
|
||||
|
||||
if compose up -d --no-deps --pull never "$service" && \
|
||||
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then
|
||||
echo "生产前端部署成功: $image_repository:$image_tag"
|
||||
compose ps "$service"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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 ;;
|
||||
case "$service" in
|
||||
admin-web)
|
||||
base_port=9001
|
||||
nginx_config=/www/server/panel/vhost/nginx/admin.buggoglobal.com.conf
|
||||
legacy_container=firstleg-prod-admin-web
|
||||
;;
|
||||
client-web)
|
||||
base_port=9002
|
||||
nginx_config=/www/server/panel/vhost/nginx/app.buggoglobal.com.conf
|
||||
legacy_container=firstleg-prod-client-web
|
||||
;;
|
||||
*) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;;
|
||||
esac
|
||||
previous_tag="${previous_image#"$image_repository:"}"
|
||||
if [ "$previous_tag" = "$image_tag" ]; then
|
||||
echo "旧镜像与新镜像一致,无法回滚" >&2
|
||||
|
||||
state_root=/data/firstleg/deploy/blue-green-state
|
||||
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
|
||||
fi
|
||||
docker pull "$previous_image"
|
||||
export IMAGE_TAG="$previous_tag"
|
||||
compose up -d --no-deps --pull never "$service"
|
||||
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true
|
||||
echo "已回滚至: $previous_image" >&2
|
||||
exit 1
|
||||
|
||||
healthy=false
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt 120 ]; do
|
||||
if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then
|
||||
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
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -60,7 +60,9 @@ describe('admin nginx deployment config', () => {
|
||||
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
|
||||
expect(jenkinsfile).toContain('git rev-parse origin/main')
|
||||
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')
|
||||
})
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('RolesView', () => {
|
||||
it('restores saved permission ids after the dialog tree has mounted', () => {
|
||||
expect(rolesSource).toContain('@opened="initializePermissionSelection"')
|
||||
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(() =>')
|
||||
})
|
||||
|
||||
|
||||
@@ -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="角色说明"><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 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>
|
||||
</el-form>
|
||||
</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); }
|
||||
.permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; }
|
||||
.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; }
|
||||
: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; }
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
expect(page('DispatchDetailView.vue')).toContain('density-fields')
|
||||
expect(page('DeliveryOrderDetailView.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); }')
|
||||
const outbounds = page('OutboundsView.vue')
|
||||
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 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>
|
||||
<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; }
|
||||
|
||||
@@ -176,9 +176,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it.each([
|
||||
[[], ['查看'], false],
|
||||
[['inbound-order:create'], ['查看', '入库计划'], false],
|
||||
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false],
|
||||
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', '暂扣订单', ''], true],
|
||||
[['inbound-order:create'], ['查看', '生成入库计划'], false],
|
||||
[['last-mile-order:edit'], ['查看', '人工修改节点'], false],
|
||||
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '人工修改节点'], false],
|
||||
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
|
||||
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 })
|
||||
@@ -213,9 +213,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PENDING_ASSIGNMENT', true],
|
||||
['INBOUND_COMPLETED', false],
|
||||
])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => {
|
||||
['PENDING_ASSIGNMENT', false],
|
||||
['INBOUND_COMPLETED', true],
|
||||
])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => {
|
||||
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
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 holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
|
||||
.find(item => item.text().includes('暂扣订单'))!
|
||||
expect((holdButton.element as HTMLButtonElement).disabled).toBe(disabled)
|
||||
.find(item => item.text().includes('暂扣订单'))
|
||||
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 () => {
|
||||
@@ -342,7 +372,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
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()
|
||||
await selectRows(wrapper, [selectedRow({
|
||||
detained: true,
|
||||
@@ -352,10 +382,10 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getContainerInboundPlanPreview).toHaveBeenCalledWith(bookingId)
|
||||
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
.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 () => {
|
||||
|
||||
@@ -445,6 +445,7 @@ function isRejectedReturn(source: object) {
|
||||
return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED'
|
||||
}
|
||||
function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) }
|
||||
function hasSelectedInboundPlan() { return selectedRows.value.some(hasInboundPlan) }
|
||||
async function loadTransferWarehouseOptions() {
|
||||
transferWarehouseLoading.value = true
|
||||
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)
|
||||
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
||||
if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
|
||||
if (hasInboundPlan(target)) { ElMessage.warning('当前订单已提交入库计划,不能重复生成'); return }
|
||||
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
|
||||
actionTarget.value = target
|
||||
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)
|
||||
&& (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[] {
|
||||
const inboundPlanCreated = hasInboundPlan(row)
|
||||
return [
|
||||
{ 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', 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: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' },
|
||||
{ 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) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
|
||||
{ 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: '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>
|
||||
</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()">
|
||||
<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 #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>
|
||||
|
||||
@@ -24,4 +24,12 @@ describe('PDA release workbench', () => {
|
||||
expect(page).toContain('row.current')
|
||||
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>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density pda-releases-page" spacing="compact">
|
||||
<AdminPage class="admin-density pda-releases-page">
|
||||
<ListPageLayout aria-label="PDA版本">
|
||||
<template #toolbar>
|
||||
<div class="pda-release-toolbar">
|
||||
<div>
|
||||
<h1>PDA版本</h1>
|
||||
<el-radio-group v-model="app" aria-label="选择PDA应用">
|
||||
<section class="surface-panel table-panel pda-release-panel" :aria-busy="loading">
|
||||
<el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" />
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录">
|
||||
<template #toolbar-start>
|
||||
<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="overseas">海外 PDA</el-radio-button>
|
||||
</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 class="pda-release-commands">
|
||||
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<template #toolbar-actions>
|
||||
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="pda-release-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="canManage && row.status === 'DRAFT'" link :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>
|
||||
<div class="pda-release-actions density-actions">
|
||||
<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'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</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>
|
||||
</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 #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
|
||||
<template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
|
||||
</DataTable>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<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>
|
||||
<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="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.minAppVersionCode" :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" placeholder="请输入最低兼容APK版本码" /></el-form-item>
|
||||
<el-form-item label="强制更新"><el-switch v-model="form.mandatory" /></el-form-item>
|
||||
</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>
|
||||
</AppDialog>
|
||||
@@ -182,11 +177,16 @@ function download(row: PdaRelease) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pda-release-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
|
||||
.pda-release-toolbar h1 { margin: 0 0 10px; font-size: var(--admin-font-title, 16px); }
|
||||
.pda-release-commands, .pda-release-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.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; gap: 24px; padding: 10px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.pda-release-panel { min-width: 0; overflow: hidden; }
|
||||
.pda-release-error { border: 0; border-bottom: 1px solid var(--el-color-danger-light-7); border-radius: 0; }
|
||||
.pda-release-context { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); }
|
||||
.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 > 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-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>
|
||||
|
||||
Reference in New Issue
Block a user