feat(M09-D4): 完成商品选购与运营工作台

This commit is contained in:
Codex
2026-08-11 08:06:13 +08:00
parent 60264cbd11
commit e91b979128
32 changed files with 1065 additions and 60 deletions
+2
View File
@@ -111,6 +111,7 @@ import {
ScrollText,
Sparkles,
Store,
ShoppingBasket,
TicketCheck,
UsersRound,
WalletCards
@@ -158,6 +159,7 @@ const navigationIcons: Record<string, Component> = {
ScrollText,
Sparkles,
Store,
ShoppingBasket,
TicketCheck,
UsersRound,
WalletCards
+83
View File
@@ -36,6 +36,10 @@ import type {
OrderHistoryItem,
OrderStatus,
PaymentAuthorizationStatus,
ProductListing,
ProductOrder,
ProductStorage,
InventoryStock,
PlatformApplication,
ProfitSharingSnapshot,
PayoutStateFilter,
@@ -344,6 +348,85 @@ export function getBusinessStatistics(
return request<BusinessStatistics>(session, `/statistics?${params}`);
}
export function listProductListings(session: ApiSession, storeId: string) {
return request<ProductListing[]>(session,
`/stores/${encodeURIComponent(storeId)}/product-listings?includeInactive=true`);
}
export function listInventoryStocks(
session: ApiSession, input: { storeId: string; page?: number; pageSize?: number; search?: string }
) {
const params = new URLSearchParams({
storeId: input.storeId, page: String(input.page || 1), pageSize: String(input.pageSize || 100)
});
if (input.search) params.set('search', input.search);
return request<PageResult<InventoryStock>>(session, `/inventory/stocks?${params}`);
}
export function mutateInventory(
session: ApiSession, skuId: string,
action: 'inbound' | 'adjust' | 'stocktake' | 'loss' | 'policy',
input: Record<string, unknown>
) {
return request<Record<string, unknown>>(session,
`/inventory/stocks/${encodeURIComponent(skuId)}/${action}`,
{ method: action === 'policy' ? 'PUT' : 'POST', body: JSON.stringify(input) });
}
export function listProductOrders(
session: ApiSession, input: { storeId: string; page?: number; pageSize?: number; status?: string }
) {
const params = new URLSearchParams({
storeId: input.storeId, page: String(input.page || 1), pageSize: String(input.pageSize || 50)
});
if (input.status) params.set('status', input.status);
return request<PageResult<ProductOrder>>(session, `/product-orders?${params}`);
}
export function getProductOrder(session: ApiSession, orderId: string) {
return request<ProductOrder>(session, `/product-orders/${encodeURIComponent(orderId)}`);
}
export function runProductOrderAction(
session: ApiSession, orderId: string,
input: { requestId: string; action: string; reason: string }
) {
return request<ProductOrder>(session, `/product-orders/${encodeURIComponent(orderId)}/actions`, {
method: 'POST', body: JSON.stringify(input)
});
}
export function listProductStorages(
session: ApiSession, input: { storeId: string; page?: number; pageSize?: number; status?: string }
) {
const params = new URLSearchParams({
storeId: input.storeId, page: String(input.page || 1), pageSize: String(input.pageSize || 50)
});
if (input.status) params.set('status', input.status);
return request<PageResult<ProductStorage>>(session, `/product-storages?${params}`);
}
export function getProductStorage(session: ApiSession, storageId: string) {
return request<ProductStorage>(session, `/product-storages/${encodeURIComponent(storageId)}`);
}
export function createOrderProductStorage(
session: ApiSession, input: { requestId: string; sourceType: 'ORDER'; sourceOrderId: string; expiresAt: string }
) {
return request<ProductStorage>(session, '/product-storages', {
method: 'POST', body: JSON.stringify(input)
});
}
export function runProductStorageAction(
session: ApiSession, storageId: string, action: 'retrieve' | 'credential/rotate' | 'cancel',
input: Record<string, unknown>
) {
return request<ProductStorage>(session,
`/product-storages/${encodeURIComponent(storageId)}/${action}`,
{ method: 'POST', body: JSON.stringify(input) });
}
async function request<T>(
session: ApiSession,
path: string,
@@ -0,0 +1,183 @@
<template>
<section class="goods-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="goods-metrics">
<span><small>门店商品</small><strong>{{ listings.length }}</strong></span>
<span><small>低库存</small><strong>{{ lowStockCount }}</strong></span>
<span><small>待处理订单</small><strong>{{ pendingOrderCount }}</strong></span>
<span><small>有效寄存</small><strong>{{ activeStorageCount }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar goods-toolbar">
<div><p class="section-kicker">M09-D4 · 商品运营</p><h3>商品库存订单与寄存</h3></div>
<div class="toolbar-actions">
<el-select v-model="storeId" filterable placeholder="选择授权门店" @change="changeStore">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-button :icon="RefreshCw" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="商品目录" name="catalog" :disabled="!canCatalogRead">
<el-table v-loading="loading" :data="listings" row-key="id" class="data-table">
<el-table-column label="商品" min-width="220"><template #default="{ row }"><div class="stack"><strong>{{ row.productName }}</strong><span>{{ row.productCode }} · {{ row.categoryName }}</span></div></template></el-table-column>
<el-table-column label="履约" width="150"><template #default="{ row }"><el-tag effect="plain">{{ fulfillmentLabel(row.fulfillmentMode) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '上架' : '下架' }}</el-tag></template></el-table-column>
<el-table-column label="售卖时段" min-width="230"><template #default="{ row }">{{ rangeText(row.salesStartAt, row.salesEndAt) }}</template></el-table-column>
<el-table-column prop="version" label="版本" width="90" />
</el-table>
<el-empty v-if="!loading && !listings.length" description="当前门店暂无商品上架配置" />
</el-tab-pane>
<el-tab-pane :label="`库存 (${stocks.total})`" name="inventory" :disabled="!canInventoryRead">
<div class="tab-tools"><el-input v-model="stockSearch" clearable placeholder="搜索商品或 SKU" @keyup.enter="loadStocks" /><el-button @click="loadStocks">查询</el-button></div>
<el-table v-loading="loading" :data="stocks.items" row-key="id" class="data-table">
<el-table-column label="商品 / SKU" min-width="230"><template #default="{ row }"><div class="stack"><strong>{{ row.productName }} · {{ row.skuName }}</strong><span>{{ row.productCode }} / {{ row.skuCode }}</span></div></template></el-table-column>
<el-table-column label="策略" width="120"><template #default="{ row }"><el-tag effect="plain">{{ row.policyType }}</el-tag></template></el-table-column>
<el-table-column prop="availableQuantity" label="可用" width="90" />
<el-table-column prop="lockedQuantity" label="锁定" width="90" />
<el-table-column prop="lossQuantity" label="损耗" width="90" />
<el-table-column label="预警" width="100"><template #default="{ row }"><el-tag :type="row.lowStock ? 'danger' : 'success'">{{ row.lowStock ? '低库存' : '正常' }}</el-tag></template></el-table-column>
<el-table-column v-if="canInventoryWrite" label="操作" min-width="245" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="inventoryQuantity(row, 'inbound')">入库</el-button><el-button size="small" @click="inventoryQuantity(row, 'loss')">损耗</el-button><el-button size="small" @click="stocktake(row)">盘点</el-button></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`商品订单 (${orders.total})`" name="orders" :disabled="!canOrderRead">
<div class="tab-tools"><el-select v-model="orderStatus" clearable placeholder="全部状态" @change="loadOrders"><el-option v-for="status in orderStatuses" :key="status" :label="statusLabel(status)" :value="status" /></el-select><el-button @click="loadOrders">刷新订单</el-button></div>
<el-table v-loading="loading" :data="orders.items" row-key="id" class="data-table">
<el-table-column label="订单" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ row.orderNo }}</strong><span>会员 #{{ row.memberId || '-' }} · {{ dateText(row.createdAt) }}</span></div></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="orderStatusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="履约" width="130"><template #default="{ row }">{{ fulfillmentLabel(row.fulfillmentMode) }}</template></el-table-column>
<el-table-column label="数量 / 金额" min-width="150"><template #default="{ row }">{{ row.totalQuantity }} · {{ money(row.totalAmountCents) }}</template></el-table-column>
<el-table-column label="操作" min-width="300" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showOrder(row)">详情</el-button><template v-if="canOrderWrite"><el-button v-for="action in orderActions(row)" :key="action" size="small" type="primary" plain @click="actOnOrder(row, action)">{{ actionLabel(action) }}</el-button></template></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`寄存 (${storages.total})`" name="storages" :disabled="!canStorageRead">
<div class="tab-tools"><el-select v-model="storageStatus" clearable placeholder="全部状态" @change="loadStorages"><el-option v-for="status in storageStatuses" :key="status" :label="storageStatusLabel(status)" :value="status" /></el-select><el-button v-if="canStorageWrite" type="primary" @click="openCreateStorage">从订单寄存</el-button><el-button @click="loadStorages">刷新寄存</el-button></div>
<el-table v-loading="loading" :data="storages.items" row-key="id" class="data-table">
<el-table-column label="寄存单" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.storageNo }}</strong><span>会员 #{{ row.memberId }} · {{ row.sourceType === 'ORDER' ? `订单 #${row.sourceOrderId}` : '员工手工创建' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="storageStatusType(row.status)">{{ storageStatusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="数量" width="130"><template #default="{ row }">{{ row.remainingQuantity }} / {{ row.totalQuantity }}</template></el-table-column>
<el-table-column label="到期" min-width="165"><template #default="{ row }">{{ dateText(row.expiresAt) }}</template></el-table-column>
<el-table-column label="凭证" width="120"><template #default="{ row }"> {{ row.claimCredentialVersion }} </template></el-table-column>
<el-table-column label="操作" min-width="320" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showStorage(row)">详情</el-button><template v-if="canStorageWrite && isStorageActive(row.status)"><el-button size="small" type="primary" plain @click="openRetrieve(row)">取出</el-button><el-button size="small" @click="rotateStorage(row)">换码</el-button><el-button size="small" type="danger" plain @click="cancelStorage(row)">取消</el-button></template></div></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-drawer v-model="detailDrawer" :title="detailTitle" size="min(720px, 96vw)">
<template v-if="selectedOrder"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ selectedOrder.orderNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selectedOrder.status) }}</el-descriptions-item><el-descriptions-item label="会员">#{{ selectedOrder.memberId }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(selectedOrder.totalAmountCents) }}</el-descriptions-item></el-descriptions><el-table :data="selectedOrder.items || []"><el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" /><el-table-column prop="quantity" label="数量" width="80" /><el-table-column label="小计" width="110"><template #default="{ row }">{{ money(row.subtotalCents) }}</template></el-table-column></el-table></template>
<template v-if="selectedStorage"><el-descriptions :column="2" border><el-descriptions-item label="寄存单">{{ selectedStorage.storageNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ storageStatusLabel(selectedStorage.status) }}</el-descriptions-item><el-descriptions-item label="寄存人">#{{ selectedStorage.memberId }}</el-descriptions-item><el-descriptions-item label="到期">{{ dateText(selectedStorage.expiresAt) }}</el-descriptions-item></el-descriptions><el-table :data="selectedStorage.items || []"><el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" /><el-table-column prop="totalQuantity" label="寄存" width="80" /><el-table-column prop="remainingQuantity" label="剩余" width="80" /></el-table><h4>取出流水</h4><el-table :data="selectedStorage.movements || []"><el-table-column prop="storageItemId" label="明细" /><el-table-column prop="quantity" label="取出" width="80" /><el-table-column prop="remainingAfter" label="取出后" width="90" /></el-table></template>
</el-drawer>
<el-dialog v-model="retrieveDialog" title="核验凭证并取出商品" width="min(560px, 94vw)"><el-alert title="领取码只用于本次核验;后台不会读取或展示数据库中的凭证哈希。" type="warning" show-icon :closable="false" /><el-form label-position="top"><el-form-item label="寄存明细"><el-select v-model="retrieveForm.storageItemId"><el-option v-for="item in retrieveStorage?.items || []" :key="item.id" :label="`${item.productName} · ${item.skuName}(剩余 ${item.remainingQuantity}`" :value="item.id" /></el-select></el-form-item><el-form-item label="取出数量"><el-input-number v-model="retrieveForm.quantity" :min="1" :max="selectedRetrieveItem?.remainingQuantity || 1" /></el-form-item><el-form-item label="一次性领取码"><el-input v-model="retrieveForm.credential" type="password" show-password autocomplete="off" /></el-form-item></el-form><template #footer><el-button @click="retrieveDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitRetrieve">确认取出</el-button></template></el-dialog>
<el-dialog v-model="createStorageDialog" title="从已完成订单创建寄存" width="min(520px, 94vw)"><el-form label-position="top"><el-form-item label="商品订单 ID"><el-input v-model="createStorageForm.orderId" /></el-form-item><el-form-item label="到期时间"><el-date-picker v-model="createStorageForm.expiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss.SSSZ" /></el-form-item></el-form><template #footer><el-button @click="createStorageDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreateStorage">创建寄存</el-button></template></el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { RefreshCw } from '@lucide/vue';
import {
ApiError, createOrderProductStorage, getProductOrder, getProductStorage,
listInventoryStocks, listManagedStores, listProductListings, listProductOrders,
listProductStorages, mutateInventory, runProductOrderAction, runProductStorageAction,
type ApiSession
} from '../api';
import { adminSessionState } from '../session';
import type { InventoryStock, ManagedStore, PageResult, ProductListing, ProductOrder, ProductStorage } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const storeId = ref(''); const activeTab = ref('catalog');
const listings = ref<ProductListing[]>([]); const stocks = reactive<PageResult<InventoryStock>>({ items: [], total: 0, page: 1, pageSize: 100 });
const orders = reactive<PageResult<ProductOrder>>({ items: [], total: 0, page: 1, pageSize: 50 });
const storages = reactive<PageResult<ProductStorage>>({ items: [], total: 0, page: 1, pageSize: 50 });
const loading = ref(false); const saving = ref(false); const lastError = ref(''); const stockSearch = ref('');
const orderStatus = ref(''); const storageStatus = ref(''); const detailDrawer = ref(false);
const selectedOrder = ref<ProductOrder | null>(null); const selectedStorage = ref<ProductStorage | null>(null);
const retrieveDialog = ref(false); const retrieveStorage = ref<ProductStorage | null>(null);
const retrieveForm = reactive({ storageItemId: '', quantity: 1, credential: '' });
const createStorageDialog = ref(false); const createStorageForm = reactive({ orderId: '', expiresAt: '' });
const orderStatuses = ['PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED'];
const storageStatuses = ['STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED'];
const lowStockCount = computed(() => stocks.items.filter((item) => item.lowStock).length);
const pendingOrderCount = computed(() => orders.items.filter((item) => ['PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'REFUNDING'].includes(item.status)).length);
const activeStorageCount = computed(() => storages.items.filter((item) => isStorageActive(item.status)).length);
const hasAccess = (...required: string[]) => adminSessionState.access.roles.includes('PLATFORM_ADMIN')
|| adminSessionState.access.capabilities.some((item) => [...required, 'tenant.manage', 'platform.manage'].includes(item));
const canCatalogRead = computed(() => hasAccess('product.catalog.read', 'product.catalog.write'));
const canInventoryRead = computed(() => hasAccess('inventory.read', 'inventory.adjust'));
const canInventoryWrite = computed(() => hasAccess('inventory.adjust'));
const canOrderRead = computed(() => hasAccess('goods.order.read', 'goods.order.manage'));
const canOrderWrite = computed(() => hasAccess('goods.order.manage'));
const canStorageRead = computed(() => hasAccess('goods.storage.read', 'goods.storage.manage'));
const canStorageWrite = computed(() => hasAccess('goods.storage.manage'));
const selectedRetrieveItem = computed(() => retrieveStorage.value?.items?.find((item) => item.id === retrieveForm.storageItemId));
const detailTitle = computed(() => selectedOrder.value ? `商品订单 ${selectedOrder.value.orderNo}` : selectedStorage.value ? `寄存单 ${selectedStorage.value.storageNo}` : '业务详情');
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); storeId.value ||= stores.value[0]?.id || ''; activeTab.value = canCatalogRead.value ? 'catalog' : canInventoryRead.value ? 'inventory' : canOrderRead.value ? 'orders' : 'storages'; await loadAll(); } catch { /* displayed */ } finally { loading.value = false; } }
async function changeStore() { await loadAll(); }
async function loadAll() { if (!storeId.value) return; loading.value = true; try { const [nextListings, nextStocks, nextOrders, nextStorages] = await Promise.all([canCatalogRead.value ? capture(() => listProductListings(props.session, storeId.value)) : Promise.resolve([] as ProductListing[]), canInventoryRead.value ? capture(() => listInventoryStocks(props.session, { storeId: storeId.value, pageSize: 100 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 100 } as PageResult<InventoryStock>), canOrderRead.value ? capture(() => listProductOrders(props.session, { storeId: storeId.value, pageSize: 50 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 50 } as PageResult<ProductOrder>), canStorageRead.value ? capture(() => listProductStorages(props.session, { storeId: storeId.value, pageSize: 50 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 50 } as PageResult<ProductStorage>)]); listings.value = nextListings; Object.assign(stocks, nextStocks); Object.assign(orders, nextOrders); Object.assign(storages, nextStorages); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadStocks() { if (!storeId.value || !canInventoryRead.value) return; loading.value = true; try { Object.assign(stocks, await capture(() => listInventoryStocks(props.session, { storeId: storeId.value, pageSize: 100, search: stockSearch.value.trim() || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadOrders() { if (!storeId.value || !canOrderRead.value) return; loading.value = true; try { Object.assign(orders, await capture(() => listProductOrders(props.session, { storeId: storeId.value, pageSize: 50, status: orderStatus.value || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadStorages() { if (!storeId.value || !canStorageRead.value) return; loading.value = true; try { Object.assign(storages, await capture(() => listProductStorages(props.session, { storeId: storeId.value, pageSize: 50, status: storageStatus.value || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function inventoryQuantity(row: InventoryStock, action: 'inbound' | 'loss') { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入${action === 'inbound' ? '入库' : '损耗'}数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^[1-9]\d{0,8}$/, inputErrorMessage: '请输入正整数' }); await capture(() => mutateInventory(props.session, row.skuId, action, { storeId: storeId.value, requestId: requestId(`admin-${action}`), reason: action === 'inbound' ? '运营后台商品入库' : '运营后台登记损耗', expectedVersion: row.version, quantity: Number(value) })); ElMessage.success('库存已更新'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function stocktake(row: InventoryStock) { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入盘点后的可用数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^\d{1,9}$/, inputErrorMessage: '请输入非负整数' }); await capture(() => mutateInventory(props.session, row.skuId, 'stocktake', { storeId: storeId.value, requestId: requestId('admin-stocktake'), reason: '运营后台库存盘点', expectedVersion: row.version, availableQuantity: Number(value), lossQuantity: row.lossQuantity })); ElMessage.success('盘点已完成'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function showOrder(row: ProductOrder) { try { selectedStorage.value = null; selectedOrder.value = await capture(() => getProductOrder(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
function orderActions(row: ProductOrder) { if (row.status === 'PAID') return ['ACCEPT', 'REFUND']; if (row.status === 'ACCEPTED') return row.fulfillmentMode === 'DELIVERY' ? ['START_DELIVERY', 'CANCEL'] : ['MARK_READY', 'CANCEL']; if (['DELIVERING', 'READY_FOR_SELF_SERVICE'].includes(row.status)) return ['COMPLETE']; if (row.status === 'COMPLETED') return ['REFUND']; if (row.status === 'PENDING_PAYMENT') return ['CANCEL']; return []; }
async function actOnOrder(row: ProductOrder, action: string) { try { const destructive = ['CANCEL', 'REFUND'].includes(action); let reason = ''; if (destructive) { const result = await ElMessageBox.prompt(`将对订单 ${row.orderNo} 执行“${actionLabel(action)}”,库存与退款状态会按当前履约阶段处理。请输入原因。`, '确认商品订单操作', { inputPattern: /\S{2,512}/, inputErrorMessage: '请填写至少 2 个字符的原因', type: 'warning' }); reason = result.value.trim(); } await capture(() => runProductOrderAction(props.session, row.id, { requestId: requestId(`order-${action.toLowerCase()}`), action, reason })); ElMessage.success('订单状态已更新'); await loadOrders(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function showStorage(row: ProductStorage) { try { selectedOrder.value = null; selectedStorage.value = await capture(() => getProductStorage(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
async function openRetrieve(row: ProductStorage) { try { retrieveStorage.value = await capture(() => getProductStorage(props.session, row.id)); const first = retrieveStorage.value.items?.find((item) => item.remainingQuantity > 0); Object.assign(retrieveForm, { storageItemId: first?.id || '', quantity: 1, credential: '' }); retrieveDialog.value = true; } catch { /* displayed */ } }
async function submitRetrieve() { if (!retrieveStorage.value || !retrieveForm.storageItemId || !retrieveForm.credential.trim()) return ElMessage.warning('请填写寄存明细、数量和领取码'); saving.value = true; try { const result = await capture(() => runProductStorageAction(props.session, retrieveStorage.value!.id, 'retrieve', { requestId: requestId('storage-retrieve'), claimCredential: retrieveForm.credential.trim(), items: [{ storageItemId: retrieveForm.storageItemId, quantity: retrieveForm.quantity }] })); retrieveDialog.value = false; retrieveForm.credential = ''; if (result.nextClaimCredential) await showOneTimeCredential(result.nextClaimCredential, '部分取出已完成,旧领取码已失效'); else ElMessage.success('商品已取出'); await loadStorages(); } catch { /* displayed */ } finally { saving.value = false; } }
async function rotateStorage(row: ProductStorage) { try { await ElMessageBox.confirm(`寄存单 ${row.storageNo} 的当前领取码会立即失效,新码只显示一次。`, '确认轮换领取码', { type: 'warning' }); const result = await capture(() => runProductStorageAction(props.session, row.id, 'credential/rotate', { requestId: requestId('storage-rotate') })); if (result.claimCredential) await showOneTimeCredential(result.claimCredential, '领取码已轮换'); await loadStorages(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function cancelStorage(row: ProductStorage) { try { const { value } = await ElMessageBox.prompt(`取消寄存单 ${row.storageNo} 后不可继续领取,剩余 ${row.remainingQuantity} 件不会自动回库存。请输入原因。`, '确认取消寄存', { inputPattern: /\S{2,512}/, inputErrorMessage: '请填写至少 2 个字符的原因', type: 'warning' }); await capture(() => runProductStorageAction(props.session, row.id, 'cancel', { requestId: requestId('storage-cancel'), reason: value.trim() })); ElMessage.success('寄存已取消'); await loadStorages(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
function openCreateStorage() { Object.assign(createStorageForm, { orderId: '', expiresAt: new Date(Date.now() + 30 * 86400000).toISOString() }); createStorageDialog.value = true; }
async function submitCreateStorage() { if (!/^\d+$/.test(createStorageForm.orderId) || !createStorageForm.expiresAt) return ElMessage.warning('请填写已完成商品订单 ID 和到期时间'); saving.value = true; try { const result = await capture(() => createOrderProductStorage(props.session, { requestId: requestId('storage-create'), sourceType: 'ORDER', sourceOrderId: createStorageForm.orderId, expiresAt: new Date(createStorageForm.expiresAt).toISOString() })); createStorageDialog.value = false; if (result.claimCredential) await showOneTimeCredential(result.claimCredential, '寄存已创建'); await loadStorages(); } catch { /* displayed */ } finally { saving.value = false; } }
async function showOneTimeCredential(value: string, title: string) { await ElMessageBox.alert(`领取码:${value}\n\n请立即交给寄存人安全保存。关闭后后台无法再次查看原码,可通过“换码”签发新码。`, title, { confirmButtonText: '我已安全记录', type: 'success', dangerouslyUseHTMLString: false }); }
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }
function money(value: number) { return `${(Number(value || 0) / 100).toFixed(2)}`; }
function dateText(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
function rangeText(start: string | null, end: string | null) { return start || end ? `${dateText(start)}${dateText(end)}` : '长期有效'; }
function fulfillmentLabel(value: string) { return ({ DELIVERY: '房间配送', SELF_SERVICE: '自助领取', BOTH: '配送 / 自助' } as Record<string, string>)[value] || value; }
function statusLabel(value: string) { return ({ PENDING_PAYMENT: '待支付', PAID: '已支付', ACCEPTED: '已接单', DELIVERING: '配送中', READY_FOR_SELF_SERVICE: '待自取', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退款中', REFUNDED: '已退款', REFUND_FAILED: '退款失败' } as Record<string, string>)[value] || value; }
function actionLabel(value: string) { return ({ ACCEPT: '接单', START_DELIVERY: '开始配送', MARK_READY: '标记待取', COMPLETE: '完成', CANCEL: '取消', REFUND: '退款' } as Record<string, string>)[value] || value; }
function orderStatusType(value: string) { return ['COMPLETED', 'REFUNDED'].includes(value) ? 'success' : ['CANCELLED', 'REFUND_FAILED'].includes(value) ? 'danger' : ['PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'REFUNDING'].includes(value) ? 'warning' : 'info'; }
function storageStatusLabel(value: string) { return ({ STORED: '已寄存', PARTIALLY_RETRIEVED: '部分取出', RETRIEVED: '已取完', EXPIRED: '已过期', CANCELLED: '已取消' } as Record<string, string>)[value] || value; }
function storageStatusType(value: string) { return value === 'RETRIEVED' ? 'success' : value === 'PARTIALLY_RETRIEVED' ? 'warning' : ['EXPIRED', 'CANCELLED'].includes(value) ? 'danger' : 'primary'; }
function isStorageActive(value: string) { return ['STORED', 'PARTIALLY_RETRIEVED'].includes(value); }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
<style scoped>
.goods-page { display: grid; gap: 18px; }
.goods-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.goods-metrics span { display: grid; gap: 5px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: #fff; }
.goods-metrics small { color: var(--muted); }
.goods-metrics strong { font-size: 28px; }
.panel { padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: #fff; }
.goods-toolbar, .toolbar-actions, .tab-tools, .row-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.goods-toolbar { justify-content: space-between; margin-bottom: 12px; }
.goods-toolbar h3 { margin: 2px 0 0; }
.section-kicker { margin: 0; color: #2563eb; font-size: 12px; font-weight: 700; }
.tab-tools { justify-content: flex-end; margin-bottom: 14px; }
.tab-tools .el-input { width: min(320px, 100%); }
.stack { display: grid; gap: 4px; }
.stack span { color: var(--muted); font-size: 12px; }
h4 { margin: 22px 0 10px; }
@media (max-width: 760px) {
.goods-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.panel { padding: 12px; }
.goods-toolbar { align-items: stretch; }
.toolbar-actions, .toolbar-actions .el-select, .toolbar-actions .el-button { width: 100%; }
.tab-tools { justify-content: stretch; }
.tab-tools > * { flex: 1 1 160px; }
}
</style>
+1
View File
@@ -2,6 +2,7 @@ export type AdminMenuKey =
| 'overview'
| 'stores'
| 'orders'
| 'products'
| 'payments'
| 'thirdParty'
| 'people'
+1
View File
@@ -2,6 +2,7 @@ export const ADMIN_NAVIGATION = Object.freeze([
{ menuKey: 'overview', path: '/overview', stage: 'M08-D', title: '平台运营总览', label: '运营总览', icon: 'LayoutDashboard', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'products', path: '/products', stage: 'M09-D4', title: '商品、库存、订单与寄存', label: '商品运营', icon: 'ShoppingBasket', capabilities: ['product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust', 'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
+1
View File
@@ -11,6 +11,7 @@ const routeComponents = {
overview: () => import('./components/OperationsOverviewPanel.vue'),
stores: () => import('./components/StoresRoomsPanel.vue'),
orders: () => import('./components/OrdersPanel.vue'),
products: () => import('./components/ProductOperationsPanel.vue'),
payments: () => import('./components/PaymentsPanel.vue'),
thirdParty: () => import('./components/ThirdPartyPanel.vue'),
people: () => import('./components/MembersStaffPanel.vue'),
+45
View File
@@ -22,6 +22,51 @@ export interface PageResult<T> {
pageSize: number;
}
export interface ProductListing {
id: string; storeId: string; productId: string; categoryId: string;
categoryName: string; productCode: string; productName: string;
status: 'ACTIVE' | 'INACTIVE'; fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE' | 'BOTH';
salesStartAt: string | null; salesEndAt: string | null; version: number;
}
export interface InventoryStock {
id: string; storeId: string; productId: string; productCode: string; productName: string;
skuId: string; skuCode: string; skuName: string; salePriceCents: number;
policyType: 'TRACKED' | 'UNLIMITED'; availableQuantity: number;
lockedQuantity: number; lossQuantity: number; lowStockThreshold: number;
lowStock: boolean; version: number;
}
export interface ProductOrder {
id: string; storeId: string; memberId: string | null; roomOrderId: string | null;
orderNo: string; fulfillmentMode: 'DELIVERY' | 'SELF_SERVICE'; status: string;
itemCount: number; totalQuantity: number; totalAmountCents: number;
paidAmountCents: number; refundedAmountCents: number; note: string;
inventoryStatus: string; createdAt: string; updatedAt: string;
items?: ProductOrderItem[];
}
export interface ProductOrderItem {
id: string; productName: string; skuName: string; quantity: number;
unitPriceCents: number; subtotalCents: number; note?: string;
}
export interface ProductStorage {
id: string; storeId: string; memberId: string; sourceOrderId: string | null;
storageNo: string; sourceType: 'ORDER' | 'MANUAL'; status: string;
itemCount: number; totalQuantity: number; remainingQuantity: number;
expiresAt: string; claimCredentialVersion: number; claimCredentialConsumed: boolean;
createdAt: string; updatedAt: string; cancelReason: string;
items?: ProductStorageItem[]; events?: Array<Record<string, unknown>>;
movements?: Array<Record<string, unknown>>; claimCredential?: string | null;
nextClaimCredential?: string | null;
}
export interface ProductStorageItem {
id: string; productName: string; skuName: string; skuId: string;
totalQuantity: number; remainingQuantity: number;
}
export interface ManagedStore {
id: string;
name: string;
+10 -1
View File
@@ -9,11 +9,12 @@ import {
assert.deepEqual(
ADMIN_NAVIGATION.map(({ path }) => path),
['/overview', '/stores', '/orders', '/payments', '/third-party', '/people',
['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
);
assert.equal(pathForMenu('platformApps'), '/apps');
assert.equal(pathForMenu('thirdParty'), '/third-party');
assert.equal(pathForMenu('products'), '/products');
const storeOperator = {
roles: ['STORE_ADMIN'],
@@ -24,6 +25,14 @@ assert.equal(resolveAuthorizedPath(storeOperator, 'orders'), '/orders');
assert.equal(resolveAuthorizedPath(storeOperator, 'system'), '/overview');
assert.equal(firstAuthorizedPath(storeOperator), '/overview');
const goodsOperator = {
roles: ['STAFF'],
capabilities: ['goods.order.read'],
menus: ['products']
};
assert.equal(resolveAuthorizedPath(goodsOperator, 'products'), '/products');
assert.equal(firstAuthorizedPath(goodsOperator), '/products');
const forgedSystemMenu = {
roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read'],