Jelajahi Sumber

feat(web-admin): add page variant for product form in mp webview.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 minggu lalu
induk
melakukan
afad78bf60

+ 177 - 32
web-admin/src/components/business/product-form-dialog/index.vue

@@ -1,22 +1,16 @@
 <!-- eslint-disable vue/no-v-model-argument -->
 <template>
-  <el-dialog
-    v-model="visible"
-    :title="isEdit ? '编辑产品' : '新增产品'"
-    width="960px"
-    class="dialog-form-layout"
-    align-center
-    append-to-body
-    destroy-on-close
-    modal-class="order-module-dialog-mask"
-    style="backdrop-filter: none"
-    :close-on-click-modal="false"
+  <component
+    :is="isPageVariant ? 'div' : ElDialog"
+    v-bind="formShellBind"
     @open="handleOpen"
-    @close="handleClose"
+    @close="handleDialogClose"
+    @update:model-value="onFormShellVisible"
   >
     <el-form
+      v-show="!(isPageVariant && dialogLoading)"
       ref="formRef"
-      v-loading="dialogLoading"
+      v-loading="!isPageVariant && dialogLoading"
       :model="formData"
       :rules="formRules"
       label-position="top"
@@ -24,7 +18,7 @@
       scroll-to-error
       :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
     >
-      <div class="form-grid-3">
+      <div :class="isPageVariant ? 'form-grid-1' : 'form-grid-3'">
         <el-form-item label="产品编号">
           <el-input v-model="formData.productNo" placeholder="留空自动生成(如 SXWL000001)" />
         </el-form-item>
@@ -45,7 +39,19 @@
             </el-button>
           </div>
         </el-form-item>
-        <el-form-item label="产品分类">
+        <el-form-item>
+          <template #label>
+            产品分类
+            <el-button
+              v-if="isPageVariant"
+              class="mp-category-maintain-link"
+              link
+              type="primary"
+              @click="goMaintainCategories"
+            >
+              维护分类
+            </el-button>
+          </template>
           <el-tree-select
             v-model="formData.categoryId"
             :data="categoryOptions"
@@ -178,21 +184,32 @@
         <div v-else class="sub-card-empty">暂无别名,点击上方新增</div>
       </div>
     </el-form>
-    <template #footer>
-      <el-button class="btn-ghost" @click="visible = false">
+
+    <template v-if="!isPageVariant" #footer>
+      <el-button class="btn-ghost" @click="handleCancel">
         <el-icon><Close /></el-icon>取消
       </el-button>
       <el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
         <el-icon><Check /></el-icon>确定
       </el-button>
     </template>
-  </el-dialog>
+
+    <div v-if="isPageVariant && !dialogLoading" class="mp-plan-form-footer">
+      <el-button class="btn-ghost" @click="handleCancel">
+        <el-icon><Close /></el-icon>取消
+      </el-button>
+      <el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
+        <el-icon><Check /></el-icon>确定
+      </el-button>
+    </div>
+  </component>
 </template>
 
 <script setup lang="ts">
   import { Check, Close, Delete, Plus, Refresh } from '@element-plus/icons-vue'
   import type { FormInstance } from 'element-plus'
-  import { ElMessage } from 'element-plus'
+  import { ElDialog, ElMessage } from 'element-plus'
+  import { useRouter } from 'vue-router'
   import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
   import {
     fetchCategoryTree,
@@ -206,32 +223,73 @@
 
   defineOptions({ name: 'ProductFormDialog' })
 
-  const props = defineProps<{
-    modelValue: boolean
-    /** 有值则为编辑,无值则为新增 */
-    productId?: string | null
-  }>()
+  const props = withDefaults(
+    defineProps<{
+      modelValue: boolean
+      /** 有值则为编辑,无值则为新增 */
+      productId?: string | null
+      /** 小程序 WebView 全屏页:不使用桌面弹窗遮罩 */
+      variant?: 'dialog' | 'page'
+    }>(),
+    { variant: 'dialog' }
+  )
 
   const emit = defineEmits<{
     'update:modelValue': [value: boolean]
     success: [product: Record<string, any>]
+    close: []
+    'loading-change': [loading: boolean]
   }>()
 
+  const router = useRouter()
   const dictStore = useDictStore()
 
+  const isPageVariant = computed(() => props.variant === 'page')
+
   const visible = computed({
     get: () => props.modelValue,
     set: (value: boolean) => emit('update:modelValue', value)
   })
 
+  function onFormShellVisible(value: boolean) {
+    visible.value = value
+  }
+
+  const formShellBind = computed(() => {
+    if (isPageVariant.value) {
+      return { class: 'mp-product-form-page' }
+    }
+    return {
+      modelValue: visible.value,
+      title: isEdit.value ? '编辑产品' : '新增产品',
+      width: '960px',
+      alignCenter: true,
+      appendToBody: true,
+      destroyOnClose: true,
+      class: 'dialog-form-layout',
+      modalClass: 'order-module-dialog-mask',
+      closeOnClickModal: false,
+      style: 'backdrop-filter: none'
+    }
+  })
+
   const isEdit = computed(() => !!props.productId)
 
   const formRef = ref<FormInstance>()
   const productImagesUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
-  const dialogLoading = ref(false)
+  /** page 模式默认 true,避免挂载到拉数完成前出现空白表单 */
+  const dialogLoading = ref(props.variant === 'page')
   const submitLoading = ref(false)
   const categoryOptions = ref<any[]>([])
 
+  watch(
+    dialogLoading,
+    (loading) => {
+      emit('loading-change', loading)
+    },
+    { immediate: true }
+  )
+
   const formData = reactive({
     id: '',
     productName: '',
@@ -299,6 +357,10 @@
     ElMessage.info('已清空,保存时将按产品名称重新生成拼音首字母')
   }
 
+  function goMaintainCategories() {
+    void router.push('/m/crm/product/categories')
+  }
+
   async function loadCategoryOptions() {
     try {
       const res = await fetchCategoryTree()
@@ -309,13 +371,13 @@
   }
 
   async function handleOpen() {
-    resetFormData()
-    await loadCategoryOptions()
-    if (!props.productId) {
-      return
-    }
     dialogLoading.value = true
+    resetFormData()
     try {
+      await loadCategoryOptions()
+      if (!props.productId) {
+        return
+      }
       const res = await fetchProductDetail({ id: String(props.productId) })
       const data = (res as any)?.data || res || {}
       Object.assign(formData, {
@@ -349,17 +411,29 @@
       })
     } catch {
       ElMessage.error('加载产品详情失败')
-      visible.value = false
+      if (isPageVariant.value) {
+        emit('close')
+      } else {
+        visible.value = false
+      }
     } finally {
       dialogLoading.value = false
     }
   }
 
-  function handleClose() {
+  function handleDialogClose() {
     resetFormData()
     formRef.value?.resetFields()
   }
 
+  function handleCancel() {
+    if (isPageVariant.value) {
+      emit('close')
+      return
+    }
+    visible.value = false
+  }
+
   function handleAddBrand() {
     formData.brands.push({ brandName: '', status: 'on' })
   }
@@ -434,9 +508,80 @@
       submitLoading.value = false
     }
   }
+
+  onMounted(() => {
+    if (isPageVariant.value) void handleOpen()
+  })
 </script>
 
 <style scoped lang="scss">
+  .mp-product-form-page {
+    width: 100%;
+    max-width: 100%;
+    overflow-x: hidden;
+    box-sizing: border-box;
+    padding: 12px 12px calc(56px + env(safe-area-inset-bottom, 0px));
+
+    :deep(.form-grid-1),
+    :deep(.form-grid-3) {
+      display: grid;
+      grid-template-columns: 1fr !important;
+      gap: 12px;
+    }
+
+    :deep(.form-col-span-2),
+    :deep(.el-form-item[style*='grid-column']) {
+      grid-column: 1 / -1;
+    }
+
+    .sub-card-grid {
+      grid-template-columns: minmax(0, 1fr);
+    }
+  }
+
+  .mp-plan-form-footer {
+    position: fixed;
+    left: 50%;
+    bottom: 0;
+    z-index: 200;
+    display: flex;
+    flex-wrap: nowrap;
+    align-items: center;
+    gap: 6px;
+    width: 100%;
+    max-width: min(480px, 100%);
+    box-sizing: border-box;
+    margin: 0;
+    padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px));
+    background: #fff;
+    border-top: 1px solid #dcebe4;
+    box-shadow: 0 -4px 16px rgba(15, 30, 24, 0.08);
+    transform: translateX(-50%);
+    overflow: hidden;
+
+    :deep(.el-button) {
+      flex: 1 1 0;
+      min-width: 0;
+      height: 34px;
+      margin: 0;
+      padding: 0 6px;
+      font-size: 12px;
+
+      span {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    }
+  }
+
+  .mp-category-maintain-link {
+    margin-left: 8px;
+    font-size: 12px;
+    font-weight: 400;
+    vertical-align: baseline;
+  }
+
   .pinyin-abbr-row {
     display: flex;
     gap: 8px;

+ 84 - 3
web-admin/src/views/m/crm/product/form.vue

@@ -2,16 +2,97 @@
   <MpWebviewShell>
     <header class="mp-wv-header">
       <div class="mp-wv-header__row">
-        <h1 class="mp-wv-header__title">{{ title }}</h1>
+        <button class="mp-wv-header__back" type="button" aria-label="返回列表" @click="goList">
+          ←
+        </button>
+        <h1 class="mp-wv-header__title">{{ pageTitle }}</h1>
       </div>
     </header>
-    <div class="mp-wv-empty">页面建设中</div>
+    <div
+      v-if="contentLoading"
+      class="mp-plan-form-loading mp-plan-form-loading--page"
+      role="status"
+      aria-live="polite"
+      aria-busy="true"
+    >
+      <div class="mp-plan-form-loading__orb" aria-hidden="true">
+        <span class="mp-plan-form-loading__dot" />
+      </div>
+      <p class="mp-plan-form-loading__title">{{ loadingCopy.title }}</p>
+      <p class="mp-plan-form-loading__desc">{{ loadingCopy.desc }}</p>
+      <div class="mp-plan-form-loading__skel" aria-hidden="true">
+        <div class="mp-wv-skel mp-wv-skel--block" />
+        <div class="mp-wv-skel mp-wv-skel--mid" />
+        <div class="mp-wv-skel mp-wv-skel--line" />
+        <div class="mp-wv-skel mp-wv-skel--short" />
+      </div>
+    </div>
+    <ProductFormDialog
+      v-show="!contentLoading"
+      :key="formKey"
+      variant="page"
+      :model-value="true"
+      :product-id="productId"
+      @loading-change="onLoadingChange"
+      @success="onSuccess"
+      @close="goList"
+    />
   </MpWebviewShell>
 </template>
+
 <script setup lang="ts">
+  import { computed, ref, watch } from 'vue'
+  import { useRoute, useRouter } from 'vue-router'
   import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import ProductFormDialog from '@/components/business/product-form-dialog/index.vue'
+  import { MP_CRM_PRODUCT_LIST_PATH } from '@/utils/mp-webview'
   import '@/styles/mp-webview.scss'
 
   const route = useRoute()
-  const title = computed(() => String(route.meta.title || '产品'))
+  const router = useRouter()
+  const contentLoading = ref(true)
+
+  const pageTitle = computed(() => String(route.meta.title || '产品'))
+
+  const productId = computed(() => {
+    if (route.name === 'MpCrmProductNew') return undefined
+    const id = String(route.params.id || '').trim()
+    return id || undefined
+  })
+
+  const formKey = computed(() => `${String(route.name || '')}-${productId.value || ''}`)
+
+  const loadingCopy = computed(() => {
+    if (productId.value) {
+      return {
+        title: '正在加载产品',
+        desc: '请稍候,正在获取产品详情…'
+      }
+    }
+    return {
+      title: '正在准备表单',
+      desc: '请稍候,正在初始化分类等主数据…'
+    }
+  })
+
+  watch(formKey, () => {
+    contentLoading.value = true
+  })
+
+  function onLoadingChange(loading: boolean) {
+    contentLoading.value = loading
+  }
+
+  function goList() {
+    void router.replace(MP_CRM_PRODUCT_LIST_PATH)
+  }
+
+  function onSuccess(product?: Record<string, any>) {
+    const id = String(product?.id || '').trim()
+    if (id) {
+      void router.replace(`/m/crm/product/${id}`)
+      return
+    }
+    goList()
+  }
 </script>