ソースを参照

feat(web-admin): add mp webview CRM product detail actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 週間 前
親
コミット
1d3a4f5155
1 ファイル変更、364 行追加、4 行削除
  1. 364 4
      web-admin/src/views/m/crm/product/detail.vue

+ 364 - 4
web-admin/src/views/m/crm/product/detail.vue

@@ -1,16 +1,376 @@
 <template>
-  <MpWebviewShell>
+  <MpWebviewShell @ready="onShellReady">
     <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">{{ headerTitle }}</h1>
+        <el-tag v-if="detail && !loading" :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
+      </div>
+      <div v-if="detail && !loading" class="mp-wv-header__sub">
+        {{ detail.productNo || '—' }} · {{ detail.model || '—' }}
       </div>
     </header>
-    <div class="mp-wv-empty">页面建设中</div>
+
+    <div
+      v-if="loading"
+      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">正在加载产品详情</p>
+      <p class="mp-plan-form-loading__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>
+    <div v-else-if="errorText" class="mp-wv-empty">{{ errorText }}</div>
+    <template v-else-if="detail">
+      <div class="mp-wv-detail-body">
+        <section class="mp-wv-section">
+          <h3>基础信息</h3>
+          <div class="mp-wv-kv"><span>产品编号</span><span>{{ detail.productNo || '—' }}</span></div>
+          <div class="mp-wv-kv"><span>产品名称</span><span>{{ detail.productName || '—' }}</span></div>
+          <div class="mp-wv-kv">
+            <span>拼音首字母</span><span>{{ detail.namePinyinAbbr || '—' }}</span>
+          </div>
+          <div class="mp-wv-kv"><span>产品分类</span><span>{{ categoryPathText }}</span></div>
+          <div class="mp-wv-kv"><span>规格型号</span><span>{{ detail.model || '—' }}</span></div>
+          <div class="mp-wv-kv"><span>条码</span><span>{{ detail.barcode || '—' }}</span></div>
+          <div class="mp-wv-kv"><span>税率</span><span>{{ taxRateText }}</span></div>
+          <div class="mp-wv-kv"><span>库存</span><span>{{ stockText }}</span></div>
+          <div class="mp-wv-kv"><span>低库存阈值</span><span>{{ lowStockText }}</span></div>
+          <div class="mp-wv-kv">
+            <span>状态</span>
+            <span>
+              <el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
+            </span>
+          </div>
+          <div class="mp-wv-kv">
+            <span>危化标识</span>
+            <span class="mp-wv-tag-row">
+              <el-tag v-if="showToxic" type="danger" size="small">易制毒</el-tag>
+              <el-tag v-if="showExplosive" type="warning" size="small">易制爆</el-tag>
+              <template v-if="!showToxic && !showExplosive">—</template>
+            </span>
+          </div>
+        </section>
+
+        <section class="mp-wv-section">
+          <h3>描述 / 备注</h3>
+          <div class="mp-wv-kv"><span>产品描述</span><span class="mp-wv-pre">{{ descriptionText }}</span></div>
+          <div class="mp-wv-kv"><span>备注</span><span class="mp-wv-pre">{{ remarkText }}</span></div>
+        </section>
+
+        <section class="mp-wv-section">
+          <h3>图片</h3>
+          <div v-if="imageList.length" class="mp-wv-img-grid">
+            <el-image
+              v-for="(url, idx) in imageList"
+              :key="`${url}-${idx}`"
+              class="mp-wv-img-grid__item"
+              :src="url"
+              :preview-src-list="imageList"
+              :initial-index="idx"
+              fit="cover"
+              preview-teleported
+            />
+          </div>
+          <p v-else class="mp-wv-card__meta">暂无图片</p>
+        </section>
+
+        <section class="mp-wv-section">
+          <h3>品牌列表</h3>
+          <template v-if="brands.length">
+            <div v-for="(b, idx) in brands" :key="b.id || `${b.brandName}-${idx}`" class="mp-wv-kv">
+              <span>{{ b.brandName || '—' }}</span>
+              <span>
+                <el-tag :type="productStatusTagType(b.status)" size="small">
+                  {{ productStatusLabel(b.status) }}
+                </el-tag>
+              </span>
+            </div>
+          </template>
+          <p v-else class="mp-wv-card__meta">暂无品牌</p>
+        </section>
+
+        <section class="mp-wv-section">
+          <h3>别名列表</h3>
+          <template v-if="aliases.length">
+            <div
+              v-for="(a, idx) in aliases"
+              :key="a.id || `${a.aliasName}-${idx}`"
+              class="mp-wv-kv"
+            >
+              <span>别名</span>
+              <span>{{ a.aliasName || '—' }}</span>
+            </div>
+          </template>
+          <p v-else class="mp-wv-card__meta">暂无别名</p>
+        </section>
+      </div>
+
+      <div class="mp-wv-fab-bar">
+        <button
+          class="mp-wv-btn mp-wv-btn--primary"
+          type="button"
+          :disabled="acting"
+          @click="goEdit"
+        >
+          编辑
+        </button>
+        <button class="mp-wv-btn" type="button" :disabled="acting" @click="toggleStatus">
+          {{ statusActionLabel }}
+        </button>
+        <button
+          class="mp-wv-btn is-danger"
+          type="button"
+          :disabled="acting || !canDelete"
+          :title="canDelete ? '' : CREATOR_DELETE_TIP"
+          @click="onDelete"
+        >
+          删除
+        </button>
+      </div>
+    </template>
   </MpWebviewShell>
 </template>
+
 <script setup lang="ts">
+  import { computed, ref } from 'vue'
+  import { useRoute, useRouter } from 'vue-router'
+  import { ElMessage, ElMessageBox } from 'element-plus'
   import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import {
+    fetchProductDetail,
+    fetchUpdateProductStatus,
+    fetchDeleteProduct,
+    fetchCategoryTree,
+    type ProductAliasItem,
+    type ProductBrandItem,
+    type ProductDetail
+  } from '@/api/crm/product'
+  import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import { MP_CRM_PRODUCT_LIST_PATH } from '@/utils/mp-webview'
+  import { productStatusLabel, productStatusTagType } from '@/utils/mp-crm-product-card'
+  import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
   import '@/styles/mp-webview.scss'
 
-  const title = '产品详情'
+  const route = useRoute()
+  const router = useRouter()
+  const loading = ref(true)
+  const acting = ref(false)
+  const errorText = ref('')
+  const detail = ref<ProductDetail | null>(null)
+  const categoryPathMap = ref<Map<string, string>>(new Map())
+  let categoryMapLoaded = false
+
+  const headerTitle = computed(() => {
+    if (loading.value) return '产品详情'
+    return detail.value?.productName || '产品详情'
+  })
+  const statusLabel = computed(() => productStatusLabel(detail.value?.status))
+  const statusTagType = computed(() => productStatusTagType(detail.value?.status))
+  const statusActionLabel = computed(() =>
+    detail.value?.status === 'on' ? '下架' : '上架'
+  )
+  const showToxic = computed(() => Number(detail.value?.isToxic) === 1)
+  const showExplosive = computed(() => Number(detail.value?.isExplosive) === 1)
+  const canDelete = computed(() => canDeleteByCreator(detail.value as Record<string, unknown>))
+
+  const categoryPathText = computed(() => {
+    const d = detail.value
+    if (!d) return '—'
+    const cid = String(d.categoryId || '').trim()
+    const path = cid ? categoryPathMap.value.get(cid) : ''
+    return path || d.categoryName || '—'
+  })
+
+  const taxRateText = computed(() => {
+    const n = detail.value?.taxRate
+    if (n === undefined || n === null || Number.isNaN(Number(n))) return '—'
+    return `${n}%`
+  })
+  const stockText = computed(() => {
+    const n = detail.value?.stock
+    return n === undefined || n === null ? '—' : String(n)
+  })
+  const lowStockText = computed(() => {
+    const n = detail.value?.lowStock
+    return n === undefined || n === null ? '—' : String(n)
+  })
+  const descriptionText = computed(() => detail.value?.description?.trim() || '—')
+  const remarkText = computed(() => detail.value?.remark?.trim() || '—')
+
+  const imageList = computed(() => {
+    const images = detail.value?.images
+    if (!images) return [] as string[]
+    return String(images)
+      .split(',')
+      .map((s) => s.trim())
+      .filter(Boolean)
+  })
+
+  const brands = computed<ProductBrandItem[]>(() => detail.value?.brands || [])
+  const aliases = computed<ProductAliasItem[]>(() => detail.value?.aliases || [])
+
+  async function ensureCategoryPathMap() {
+    if (categoryMapLoaded) return
+    try {
+      const res = await fetchCategoryTree()
+      const roots = (res as { data?: unknown })?.data || res || []
+      categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
+        idKey: 'id',
+        nameKey: 'categoryName',
+        childrenKey: 'children'
+      })
+    } catch {
+      categoryPathMap.value = new Map()
+    } finally {
+      categoryMapLoaded = true
+    }
+  }
+
+  async function loadDetail() {
+    const id = String(route.params.id || '')
+    if (!id) {
+      errorText.value = '缺少产品 ID'
+      loading.value = false
+      return
+    }
+    loading.value = true
+    try {
+      await ensureCategoryPathMap()
+      const res = await fetchProductDetail({ id })
+      detail.value = ((res as { data?: ProductDetail }).data ?? res) as ProductDetail
+      errorText.value = ''
+    } catch (error) {
+      detail.value = null
+      errorText.value = error instanceof Error ? error.message : '加载失败'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function goList() {
+    void router.push(MP_CRM_PRODUCT_LIST_PATH)
+  }
+
+  function goEdit() {
+    const id = detail.value?.id
+    if (!id) return
+    void router.push(`/m/crm/product/${id}/edit`)
+  }
+
+  async function toggleStatus() {
+    const d = detail.value
+    if (!d?.id) return
+    const nextStatus = d.status === 'on' ? 'off' : 'on'
+    const label = nextStatus === 'on' ? '上架' : '下架'
+    try {
+      await ElMessageBox.confirm(`确认${label}该产品?`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      })
+    } catch {
+      return
+    }
+    acting.value = true
+    try {
+      await fetchUpdateProductStatus({ ids: [d.id], status: nextStatus })
+      ElMessage.success(`${label}成功`)
+      await loadDetail()
+    } catch (error) {
+      ElMessage.error(error instanceof Error ? error.message : `${label}失败`)
+    } finally {
+      acting.value = false
+    }
+  }
+
+  async function onDelete() {
+    const d = detail.value
+    if (!d?.id) return
+    if (!canDeleteByCreator(d as Record<string, unknown>)) {
+      ElMessage.warning(CREATOR_DELETE_TIP)
+      return
+    }
+    try {
+      await ElMessageBox.confirm('确认删除该产品?删除后不可恢复。', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      })
+    } catch {
+      return
+    }
+    acting.value = true
+    try {
+      await fetchDeleteProduct({ id: d.id })
+      ElMessage.success('删除成功')
+      void router.push(MP_CRM_PRODUCT_LIST_PATH)
+    } catch (error) {
+      ElMessage.error(error instanceof Error ? error.message : '删除失败')
+    } finally {
+      acting.value = false
+    }
+  }
+
+  function onShellReady() {
+    void loadDetail()
+  }
 </script>
+
+<style scoped lang="scss">
+  .mp-wv-header__sub {
+    margin-top: 4px;
+    font-size: 12px;
+    color: rgba(255, 255, 255, 0.9);
+  }
+
+  .mp-wv-detail-body {
+    padding-bottom: 12px;
+    width: 100%;
+    max-width: 100%;
+    overflow-x: hidden;
+    box-sizing: border-box;
+  }
+
+  .mp-wv-tag-row {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    align-items: center;
+  }
+
+  .mp-wv-pre {
+    white-space: pre-wrap;
+    word-break: break-word;
+  }
+
+  .mp-wv-img-grid {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 8px;
+  }
+
+  .mp-wv-img-grid__item {
+    width: 100%;
+    aspect-ratio: 1;
+    border-radius: 8px;
+    overflow: hidden;
+    background: #f7faf8;
+  }
+
+  .is-danger {
+    color: #d14343 !important;
+  }
+</style>