fix ProductStockOperationPopup.vue style

This commit is contained in:
Timi
2026-09-03 20:45:31 +08:00
parent 2b060a2536
commit 7a47e3a9f8
7 changed files with 189 additions and 57 deletions
+16 -1
View File
@@ -147,10 +147,25 @@ async function edit(): Promise<void> {
async function viewStock(): Promise<void> { async function viewStock(): Promise<void> {
if (product.value?.id) { if (product.value?.id) {
await router.push(`/management/product-stocks/${product.value.id}`); await router.push({
name: "ProductStockDetail",
params: { productId: product.value.id },
query: { transitionDepth: String(getCurrentRouteDepth() + 1) }
});
} }
} }
function getCurrentRouteDepth(): number {
const transitionDepth = Array.isArray(route.query.transitionDepth)
? route.query.transitionDepth[0]
: route.query.transitionDepth;
const depth = Number(transitionDepth);
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
return depth;
}
return Number(route.meta.depth ?? 0);
}
function disable(): void { function disable(): void {
const id = product.value?.id; const id = product.value?.id;
if (!id) { if (!id) {
+22 -4
View File
@@ -2,12 +2,12 @@
<div v-can="permissionCodes.productStock.read" class="product-stock-detail"> <div v-can="permissionCodes.productStock.read" class="product-stock-detail">
<t-loading v-if="isLoading" class="loading" size="32px" /> <t-loading v-if="isLoading" class="loading" size="32px" />
<template v-else-if="product && stock"> <template v-else-if="product && stock">
<product-profile :product="product"> <product-profile :product="product" v-entrance-transition>
<template #tags> <template #tags>
<t-tag :content="getStockStatusText(stock)" variant="light-outline" :theme="getStockTheme(stock)" /> <t-tag :content="getStockStatusText(stock)" variant="light-outline" :theme="getStockTheme(stock)" />
</template> </template>
</product-profile> </product-profile>
<t-cell-group theme="card" title="库存"> <t-cell-group theme="card" title="库存" v-entrance-transition="70">
<t-cell class="empty-cell-title-margin"> <t-cell class="empty-cell-title-margin">
<template #title> <template #title>
<span></span> <span></span>
@@ -42,7 +42,8 @@
v-if="canOperate && product.stockEnabled" v-if="canOperate && product.stockEnabled"
class="actions-wrap" class="actions-wrap"
title="库存操作" title="库存操作"
description="所有操作必须选择业务单位、上传照片并填写原因或备注" description="所有操作必须选择业务单位、上传照片并填写备注"
v-entrance-transition="140"
> >
<div class="actions"> <div class="actions">
<t-button <t-button
@@ -144,10 +145,27 @@ async function loadAll(): Promise<void> {
async function openRecordList(): Promise<void> { async function openRecordList(): Promise<void> {
if (product.value?.id) { if (product.value?.id) {
await router.push({ path: "/management/product-stock-records", query: { productId: product.value.id } }); await router.push({
name: "ProductStockRecordList",
query: {
productId: product.value.id,
transitionDepth: String(getCurrentRouteDepth() + 1)
}
});
} }
} }
function getCurrentRouteDepth(): number {
const transitionDepth = Array.isArray(route.query.transitionDepth)
? route.query.transitionDepth[0]
: route.query.transitionDepth;
const depth = Number(transitionDepth);
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
return depth;
}
return Number(route.meta.depth ?? 0);
}
function openOperation(type: ManualStockOperation): void { function openOperation(type: ManualStockOperation): void {
operationType.value = type; operationType.value = type;
operationVisible.value = true; operationVisible.value = true;
@@ -2,35 +2,56 @@
<bottom-popup <bottom-popup
v-model="visible" v-model="visible"
:title="getStockOperationText(operation)" :title="getStockOperationText(operation)"
height="min(88vh, 52rem)"
left-button-text="取消" left-button-text="取消"
:right-button-text="isSaving ? '提交中' : '提交'" :right-button-text="isSaving ? '提交中' : '提交'"
@left-button-click="visible = false" @left-button-click="visible = false"
@right-button-click="submit" @right-button-click="submit"
> >
<div class="operation-popup"> <div class="operation-popup">
<div class="summary"> <t-form class="form" label-width="5rem">
<strong v-text="props.product.name || '未命名产品'" /> <t-form-item
<span class="gray" v-text="`当前可用库存:${getProductQuantityText(props.stock?.availableQuantity)} ${getProductBaseUnitName(props.product)}`" /> label="单位"
</div> content-align="right"
<t-form label-align="left" label-width="5rem"> @click="openUnitPicker"
<t-form-item label="业务单位" required-mark @click="openUnitPicker"> arrow
required-mark
>
<span class="picker-text" v-text="selectedUnitName" /> <span class="picker-text" v-text="selectedUnitName" />
</t-form-item> </t-form-item>
<t-form-item label="数量" required-mark> <t-form-item label="数量" required-mark>
<t-input v-model.trim="quantity" type="number" align="right" borderless placeholder="必须大于 0" /> <t-input v-model="quantity" type="number" align="right" borderless />
</t-form-item> </t-form-item>
<t-form-item label="原因"> <t-form-item label="备注" required-mark>
<t-input v-model.trim="reason" borderless maxlength="255" placeholder="原因或备注至少填写一项" /> <div class="remark-wrap">
<t-textarea
v-model="remark"
borderless
maxlength="500"
autosize
placeholder="请输入库存操作备注"
/>
<t-button
v-if="quickRemarkText"
type="button"
size="extra-small"
variant="outline"
:content="quickRemarkText"
@click="applyQuickRemark"
/>
</div>
</t-form-item> </t-form-item>
<t-form-item label="备注"> <t-form-item label-width="0">
<t-textarea v-model.trim="remark" borderless maxlength="500" autosize placeholder="可补充说明" /> <package-photo-field v-model="photoItems" title="操作照片" :required="true" />
</t-form-item> </t-form-item>
<package-photo-field v-model="photoItems" title="操作照片" :required="true" />
</t-form> </t-form>
</div> </div>
</bottom-popup> </bottom-popup>
<t-popup v-model="unitPickerVisible" placement="bottom"> <t-popup
v-model="unitPickerVisible"
placement="bottom"
:z-index="unitPickerZIndex"
:overlay-props="{ zIndex: unitPickerOverlayZIndex }"
>
<t-picker v-model="unitDraft" :columns="unitOptions" title="选择业务单位" @cancel="unitPickerVisible = false" @confirm="confirmUnit" /> <t-picker v-model="unitDraft" :columns="unitOptions" title="选择业务单位" @cancel="unitPickerVisible = false" @confirm="confirmUnit" />
</t-popup> </t-popup>
</template> </template>
@@ -39,9 +60,15 @@
import { watch } from "vue"; import { watch } from "vue";
import { BottomPopup } from "timi-tdesign-mobile"; import { BottomPopup } from "timi-tdesign-mobile";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import type { Product, ProductStock, ProductUnit, ProductStockChangeRequest, StockOperationType } from "@/types/Product"; import type {
Product,
ProductStock,
ProductStockChangeRequest,
ProductUnit,
StockOperationType
} from "@/types/Product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { createProductRequestNo, getProductBaseUnitName, getProductQuantityText, getStockOperationText } from "@/utils/product"; import { createProductRequestNo, getStockOperationText } from "@/utils/product";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
@@ -61,22 +88,23 @@ const emit = defineEmits<{
const visible = defineModel<boolean>("visible", { default: false }); const visible = defineModel<boolean>("visible", { default: false });
const isSaving = ref(false); const isSaving = ref(false);
const quantity = ref(""); const quantity = ref("");
const reason = ref("");
const remark = ref(""); const remark = ref("");
const requestNo = ref(""); const requestNo = ref("");
const photoItems = ref<PhotoItem[]>([]); const photoItems = ref<PhotoItem[]>([]);
const selectedUnitId = ref(""); const selectedUnitId = ref("");
const unitPickerVisible = ref(false); const unitPickerVisible = ref(false);
const unitDraft = ref<PickerValue[]>([]); const unitDraft = ref<PickerValue[]>([]);
const unitPickerZIndex = 12001;
const unitPickerOverlayZIndex = unitPickerZIndex - 1;
const isInbound = computed(() => props.operation === "MANUAL_IN" || props.operation === "STOCKTAKE_GAIN"); const isInbound = computed(() => props.operation === "MANUAL_IN" || props.operation === "STOCKTAKE_GAIN");
const selectableUnits = computed<ProductUnit[]>(() => (props.product.unitList || []).filter(unit => isInbound.value ? unit.allowPurchase : unit.allowSale)); const selectableUnits = computed<ProductUnit[]>(() => (props.product.unitList || []).filter(unit => isInbound.value ? unit.allowPurchase : unit.allowSale));
const unitOptions = computed(() => [selectableUnits.value.map(unit => ({ label: unit.name || "未命名单位", value: unit.id || "" }))]); const unitOptions = computed(() => [selectableUnits.value.map(unit => ({ label: unit.name || "未命名单位", value: unit.id || "" }))]);
const selectedUnitName = computed(() => selectableUnits.value.find(unit => unit.id === selectedUnitId.value)?.name || "请选择业务单位"); const selectedUnitName = computed(() => selectableUnits.value.find(unit => unit.id === selectedUnitId.value)?.name || "选择业务单位");
const quickRemarkText = computed(() => props.operation === "MANUAL_IN" ? "正常入库" : props.operation === "MANUAL_OUT" ? "正常出库" : "");
function resetForm(): void { function resetForm(): void {
quantity.value = ""; quantity.value = "";
reason.value = "";
remark.value = ""; remark.value = "";
requestNo.value = ""; requestNo.value = "";
photoItems.value = []; photoItems.value = [];
@@ -95,6 +123,12 @@ function confirmUnit(value: Array<string | number>): void {
unitPickerVisible.value = false; unitPickerVisible.value = false;
} }
function applyQuickRemark(): void {
if (quickRemarkText.value) {
remark.value = quickRemarkText.value;
}
}
function validate(): boolean { function validate(): boolean {
if (!selectedUnitId.value) { if (!selectedUnitId.value) {
Message.warning("请选择业务单位"); Message.warning("请选择业务单位");
@@ -108,8 +142,8 @@ function validate(): boolean {
Message.warning("该产品不允许输入小数数量"); Message.warning("该产品不允许输入小数数量");
return false; return false;
} }
if (!reason.value.trim() && !remark.value.trim()) { if (!remark.value.trim()) {
Message.warning("原因或备注至少填写一项"); Message.warning("备注不能为空");
return false; return false;
} }
if (!photoItems.value.some(item => !!item.tempFileId)) { if (!photoItems.value.some(item => !!item.tempFileId)) {
@@ -125,8 +159,7 @@ function buildRequest(): ProductStockChangeRequest {
productId: props.product.id || "", productId: props.product.id || "",
productUnitId: selectedUnitId.value, productUnitId: selectedUnitId.value,
quantity: quantity.value, quantity: quantity.value,
reason: reason.value.trim() || undefined, remark: remark.value.trim(),
remark: remark.value.trim() || null,
attachmentList: photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: item.attachType || "PHOTO" }] : []) attachmentList: photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: item.attachType || "PHOTO" }] : [])
}; };
} }
@@ -171,14 +204,17 @@ watch(() => [visible.value, props.operation] as const, ([isVisible]) => {
<style scoped lang="less"> <style scoped lang="less">
.operation-popup { .operation-popup {
.summary { padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0));
gap: .25rem;
display: grid; .form {
padding: .5rem 0 1rem; margin: 0;
} }
:deep(.t-form__item) { .remark-wrap {
padding: .25rem 0; width: 100%;
display: flex;
align-items: start;
justify-content: space-between;
} }
} }
</style> </style>
+83 -14
View File
@@ -1,5 +1,15 @@
<template> <template>
<div v-can="permissionCodes.productStock.read" class="product-stock-record-list"> <div v-can="permissionCodes.productStock.read" class="product-stock-record-list">
<div class="search bg-white">
<t-input
v-model.trim="keyword"
class="keyword"
type="search"
borderless
clearable
placeholder="搜索产品名称或编码"
/>
</div>
<t-cell-group class="summary" theme="card"> <t-cell-group class="summary" theme="card">
<t-cell title="查询范围" :note="scopeText" /> <t-cell title="查询范围" :note="scopeText" />
<t-cell title="记录数量" :note="`共 ${total} 条`" /> <t-cell title="记录数量" :note="`共 ${total} 条`" />
@@ -7,7 +17,7 @@
<t-pull-down-refresh <t-pull-down-refresh
v-model="isRefreshing" v-model="isRefreshing"
:refresh-timeout="30000" :refresh-timeout="30000"
@refresh="reload" @refresh="refresh"
@scrolltolower="loadMore" @scrolltolower="loadMore"
> >
<t-cell-group v-if="recordList.length" class="list" theme="card"> <t-cell-group v-if="recordList.length" class="list" theme="card">
@@ -35,12 +45,12 @@
<div class="description"> <div class="description">
<span v-text="getQuantityText(record)" /> <span v-text="getQuantityText(record)" />
<span v-text="getBalanceText(record)" /> <span v-text="getBalanceText(record)" />
<span v-if="record.reason || record.remark" class="gray" v-text="record.reason || record.remark || ''" /> <span v-if="record.remark" class="gray" v-text="record.remark" />
</div> </div>
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载库存流水' : '暂无库存流水'" /> <div v-else class="empty" v-text="emptyText" />
<div v-if="recordList.length" class="center-tips" v-text="loadStateText" /> <div v-if="recordList.length" class="center-tips" v-text="loadStateText" />
</t-pull-down-refresh> </t-pull-down-refresh>
</div> </div>
@@ -68,14 +78,28 @@ const loading = ref(false);
const isRefreshing = ref(false); const isRefreshing = ref(false);
const noMore = ref(false); const noMore = ref(false);
const productId = computed(() => String(route.query.productId || "")); const productId = computed(() => String(route.query.productId || ""));
const keyword = ref(getQueryText("keyword"));
let keywordTimer: number | undefined;
let requestToken = 0;
const scopeText = computed(() => { const scopeText = computed(() => {
if (!productId.value) { const scope = productId.value
return "全部产品"; ? `指定产品 · ${recordList.value[0]?.productNameSnapshot || "当前产品"}`
: "全部产品";
return keyword.value.trim() ? `${scope} · 搜索:${keyword.value.trim()}` : scope;
});
const emptyText = computed(() => {
if (loading.value) {
return "正在加载库存流水";
} }
return `指定产品 · ${recordList.value[0]?.productNameSnapshot || "当前产品"}`; return keyword.value.trim() ? "未找到匹配的库存流水" : "暂无库存流水";
}); });
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多"); const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
function getQueryText(key: string): string {
const value = route.query[key];
return Array.isArray(value) ? String(value[0] || "") : String(value || "");
}
function getOperationIcon(type?: StockOperationType): string { function getOperationIcon(type?: StockOperationType): string {
return ({ return ({
INITIAL_IN: "download", INITIAL_IN: "download",
@@ -110,36 +134,50 @@ function getBalanceText(record: StockRecord): string {
return `库存 ${getProductQuantityText(record.onHandAfter)} · 预占 ${getProductQuantityText(record.reservedAfter)} · ${operator}`; return `库存 ${getProductQuantityText(record.onHandAfter)} · 预占 ${getProductQuantityText(record.reservedAfter)} · ${operator}`;
} }
async function loadMore(): Promise<void> { async function loadMore(force = false): Promise<void> {
if (loading.value || noMore.value) { if (!force && (loading.value || noMore.value)) {
return; return;
} }
const token = ++requestToken;
loading.value = true; loading.value = true;
try { try {
const result = await ProductAPI.recordList({ const result = await ProductAPI.recordList({
productId: productId.value || undefined, productId: productId.value || undefined,
keyword: keyword.value.trim() || undefined,
index: pageIndex.value, index: pageIndex.value,
size: pageSize size: pageSize
}); });
if (token !== requestToken) {
return;
}
const list = result.list || []; const list = result.list || [];
recordList.value = recordList.value.concat(list); recordList.value = recordList.value.concat(list);
total.value = result.total || 0; total.value = result.total || 0;
pageIndex.value += 1; pageIndex.value += 1;
noMore.value = list.length < pageSize || total.value < recordList.value.length || total.value === recordList.value.length; noMore.value = list.length < pageSize || total.value < recordList.value.length || total.value === recordList.value.length;
} finally { } finally {
loading.value = false; if (token === requestToken) {
loading.value = false;
}
} }
} }
async function reload(): Promise<void> { async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0; pageIndex.value = 0;
recordList.value = []; recordList.value = [];
total.value = 0; total.value = 0;
noMore.value = false; noMore.value = false;
await loadMore(); await loadMore(true);
}
async function refresh(): Promise<void> {
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "库存流水加载失败"));
} finally {
isRefreshing.value = false;
}
} }
onMounted(async () => { onMounted(async () => {
@@ -151,7 +189,22 @@ onMounted(async () => {
} }
}); });
onUnmounted(() => navBarStore.clearRight()); watch(keyword, () => {
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "库存流水加载失败"));
}
}, 300);
});
onUnmounted(() => {
navBarStore.clearRight();
window.clearTimeout(keywordTimer);
requestToken += 1;
});
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -161,6 +214,22 @@ onUnmounted(() => navBarStore.clearRight());
padding: var(--app-nav-height) 0 1rem; padding: var(--app-nav-height) 0 1rem;
flex-direction: column; flex-direction: column;
.search {
top: var(--app-nav-height);
z-index: 10;
padding: .75rem 1rem;
position: sticky;
background: var(--td-bg-color-page);
box-shadow: var(--td-shadow-1);
.keyword {
border: 1px solid var(--td-component-border);
padding: .5rem 1rem;
background: var(--td-bg-color-container);
border-radius: 1rem;
}
}
.summary { .summary {
margin: 0; margin: 0;
} }
-5
View File
@@ -1,10 +1,5 @@
<template> <template>
<div class="product-tab"> <div class="product-tab">
<t-notice-bar
theme="error"
visible
content="产品库设计方案及实现未完成,套餐功能仍在测试中,可以试用反馈可改进问题,但不要实际落到业务使用。产品库实现后套餐模块会联动更新,会清除测试的套餐数据"
/>
<t-cell-group <t-cell-group
v-for="(group, index) in visibleMenuGroupList" v-for="(group, index) in visibleMenuGroupList"
:key="group.title" :key="group.title"
+1 -1
View File
@@ -73,7 +73,7 @@ const productRoutes: RouteRecordRaw[] = [
path: "/management/product-stocks/:productId", path: "/management/product-stocks/:productId",
name: "ProductStockDetail", name: "ProductStockDetail",
component: ProductStockDetail, component: ProductStockDetail,
meta: pageMeta("库存详情") meta: pageMeta("库存详情", 4)
} }
]; ];
+2 -3
View File
@@ -111,7 +111,6 @@ export type StockRecord = {
requestNo?: string; requestNo?: string;
operatorUserId?: string; operatorUserId?: string;
occurredAt?: number; occurredAt?: number;
reason?: string | null;
remark?: string | null; remark?: string | null;
operator?: Partial<User>; operator?: Partial<User>;
attachmentList?: Partial<Attachment>[]; attachmentList?: Partial<Attachment>[];
@@ -120,6 +119,7 @@ export type StockRecord = {
export type StockRecordPage = { export type StockRecordPage = {
storeId?: string; storeId?: string;
productId?: string; productId?: string;
keyword?: string;
bizType?: StockBizType; bizType?: StockBizType;
bizId?: string; bizId?: string;
operationType?: StockOperationType; operationType?: StockOperationType;
@@ -135,8 +135,7 @@ export type ProductStockChangeRequest = {
productId: string; productId: string;
productUnitId: string; productUnitId: string;
quantity: number | string; quantity: number | string;
reason?: string; remark: string;
remark?: string | null;
attachmentList: Partial<Attachment>[]; attachmentList: Partial<Attachment>[];
}; };