Explorar el Código

fix(web-admin): stick category header tools with MpBusinessHeader.

Move expand-all and add into a header actions slot so they remain visible while scrolling.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong hace 1 semana
padre
commit
ef71e842ee

+ 97 - 0
web-admin/scripts/mp-crm-primitives.test.mjs

@@ -0,0 +1,97 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { readFile } from 'node:fs/promises'
+
+const root = new URL('../', import.meta.url)
+const read = (path) => readFile(new URL(path, root), 'utf8')
+
+test('普通业务头仅提供固定标题与可选搜索,不暴露票根或动态摘要插槽', async () => {
+  const source = await read('src/components/mp/MpBusinessHeader.vue')
+  assert.match(source, /class="mp-wv-header"/)
+  assert.match(source, /class="mp-wv-header__title"[^>]*>\{\{ title \}\}/)
+  assert.match(source, /v-if="searchable"[\s\S]*?class="mp-wv-search\b/)
+  assert.match(source, /name="before-search"/)
+  assert.match(source, /name="after-search"/)
+  assert.match(source, /\$slots\.actions[\s\S]*?name="actions"/)
+  assert.doesNotMatch(source, /ticket|hero|mp-wv-header__sub/i)
+})
+
+test('状态 Tab 与三栏筛选保持独立局部 modifier,并给更多筛选红点', async () => {
+  const [tabs, filters, legacyStyles, styles] = await Promise.all([
+    read('src/components/mp/MpStatusTabs.vue'),
+    read('src/components/mp/MpThreeColumnFilters.vue'),
+    read('src/styles/mp-webview.scss'),
+    read('src/styles/mp-crm-primitives.scss')
+  ])
+  assert.match(tabs, /class="mp-wv-status-tabs"/)
+  assert.match(tabs, /class="filter-tag"/)
+  assert.match(tabs, /<nav[^>]*class="mp-wv-status-tabs"/)
+  assert.match(tabs, /:aria-pressed="tab\.value === modelValue"/)
+  assert.match(filters, /mp-wv-filter-bar--three/)
+  assert.match(filters, /mp-wv-filter-more/)
+  assert.match(filters, /v-if="moreActive"[^>]*class="mp-wv-filter-more__dot"/)
+  assert.match(legacyStyles, /\.mp-wv-filter-bar\s*\{[\s\S]*?grid-template-columns:\s*1fr 1fr;/)
+  assert.match(
+    styles,
+    /\.mp-wv-filter-bar\.mp-wv-filter-bar--three\s*\{[\s\S]*?grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);/
+  )
+  assert.match(
+    styles,
+    /\.mp-wv-filter-wrap--business \.mp-wv-filter-opt\s*\{[\s\S]*?min-height:\s*44px;/
+  )
+  assert.match(
+    styles,
+    /\.mp-wv-filter-wrap--business > \.mp-wv-filter-mask\s*\{[\s\S]*?z-index:\s*7;/,
+    '局部遮罩必须低于下拉面板,否则选项无法点击'
+  )
+})
+
+test('备用金式卡片完整声明六层 DOM,状态标签使用浅底语义色', async () => {
+  const [card, styles] = await Promise.all([
+    read('src/components/mp/MpExpenseCard.vue'),
+    read('src/styles/mp-crm-primitives.scss')
+  ])
+  for (const className of [
+    'expense-card',
+    'ec-top',
+    'status-tag',
+    'ec-amount',
+    'ec-emphasis',
+    'list-card-kv',
+    'ec-time',
+    'ec-bottom',
+    'op-actions'
+  ]) {
+    assert.match(card, new RegExp(`\\b${className}\\b`), `卡片缺少 ${className} 层`)
+  }
+  assert.doesNotMatch(card, /<article[\s\S]*?role="link"/, '卡片容器不得形成嵌套交互语义')
+  assert.match(card, /class="expense-card__open mp-wv-touch-target"/)
+  assert.match(styles, /\.status-tag--success[^{]*\{[\s\S]*?background:\s*#e9f9f2;/)
+  const statusBlocks = [...styles.matchAll(/\.status-tag(?:--\w+)?[^{]*\{([^}]*)\}/g)]
+  assert.ok(statusBlocks.length >= 5, '状态标签应声明基础样式与语义色')
+  for (const [, declarations] of statusBlocks) {
+    assert.doesNotMatch(declarations, /color:\s*#fff;/, '状态标签不得使用实心白字样式')
+  }
+})
+
+test('附件、审批时间轴与通用触达尺寸满足移动端契约', async () => {
+  const [attachments, timeline, styles] = await Promise.all([
+    read('src/components/mp/MpAttachmentGrid.vue'),
+    read('src/components/mp/MpApprovalTimeline.vue'),
+    read('src/styles/mp-crm-primitives.scss')
+  ])
+  assert.match(attachments, /class="att-grid\b/)
+  assert.match(attachments, /class="att-img"/)
+  assert.match(attachments, /class="att-doc-thumb"/)
+  assert.match(attachments, /class="att-add mp-wv-touch-target"/)
+  assert.match(timeline, /class="mp-crm-approval-timeline"/)
+  assert.match(timeline, /class="mp-crm-approval-timeline__avatar"/)
+  assert.match(
+    styles,
+    /\.mp-crm-att-grid \.att-(?:item|img|doc-thumb|add)[^{]*\{[\s\S]*?width:\s*72px;[\s\S]*?height:\s*72px;/
+  )
+  assert.match(
+    styles,
+    /\.mp-wv-touch-target\s*\{[\s\S]*?min-width:\s*44px;[\s\S]*?min-height:\s*44px;/
+  )
+})

+ 61 - 0
web-admin/src/components/mp/MpBusinessHeader.vue

@@ -0,0 +1,61 @@
+<template>
+  <header class="mp-wv-header">
+    <div class="mp-wv-header__row">
+      <button
+        class="mp-wv-header__back mp-wv-touch-target"
+        type="button"
+        :aria-label="backLabel"
+        @click="emit('back')"
+      >
+        ←
+      </button>
+      <h1 class="mp-wv-header__title">{{ title }}</h1>
+      <div v-if="$slots.actions" class="mp-wv-header__actions">
+        <slot name="actions" />
+      </div>
+    </div>
+    <slot name="before-search" />
+    <input
+      v-if="searchable"
+      class="mp-wv-search mp-wv-touch-target"
+      type="search"
+      :value="keyword"
+      :aria-label="searchLabel"
+      :placeholder="searchPlaceholder"
+      autocomplete="off"
+      @input="updateKeyword"
+      @keyup.enter="emit('search')"
+    />
+    <slot name="after-search" />
+  </header>
+</template>
+
+<script setup lang="ts">
+  withDefaults(
+    defineProps<{
+      title: string
+      backLabel?: string
+      searchable?: boolean
+      keyword?: string
+      searchPlaceholder?: string
+      searchLabel?: string
+    }>(),
+    {
+      backLabel: '返回',
+      searchable: false,
+      keyword: '',
+      searchPlaceholder: '搜索',
+      searchLabel: '搜索'
+    }
+  )
+
+  const emit = defineEmits<{
+    back: []
+    search: []
+    'update:keyword': [value: string]
+  }>()
+
+  function updateKeyword(event: Event) {
+    emit('update:keyword', (event.target as HTMLInputElement).value)
+  }
+</script>

+ 9 - 0
web-admin/src/styles/mp-webview.scss

@@ -54,6 +54,15 @@
 
 .mp-wv-header__row .mp-wv-header__title {
   flex: 1;
+  padding-right: 4px;
+}
+
+.mp-wv-header__actions {
+  display: flex;
+  flex-shrink: 0;
+  gap: 8px;
+  align-items: center;
+  margin-left: auto;
 }
 
 .mp-wv-header__row .el-tag {

+ 17 - 26
web-admin/src/views/m/crm/product/categories.vue

@@ -1,18 +1,19 @@
 <template>
   <MpWebviewShell @ready="onShellReady">
     <div class="mp-product-page">
-      <MpBusinessHeader title="产品分类" back-label="返回产品列表" @back="goBack" />
-      <div class="mp-cat-header-tools">
-        <button
-          v-if="flatNodes.length"
-          class="mp-cat-header-btn"
-          type="button"
-          @click="toggleExpandAll"
-        >
-          {{ expandAllLabel }}
-        </button>
-        <button class="mp-cat-header-btn" type="button" @click="openAdd()">新增</button>
-      </div>
+      <MpBusinessHeader title="产品分类" back-label="返回产品列表" @back="goBack">
+        <template #actions>
+          <button
+            v-if="hasExpandableNodes"
+            class="mp-cat-header-btn"
+            type="button"
+            @click="toggleExpandAll"
+          >
+            {{ expandAllLabel }}
+          </button>
+          <button class="mp-cat-header-btn" type="button" @click="openAdd()">新增</button>
+        </template>
+      </MpBusinessHeader>
 
       <div v-if="loading && !tree.length" class="mp-product-loading">
         <van-loading vertical>正在加载分类</van-loading>
@@ -284,6 +285,10 @@
     return 'success'
   }
 
+  const hasExpandableNodes = computed(
+    () => Object.keys(collectExpandIds(tree.value)).length > 0
+  )
+
   const expandAllLabel = computed(() =>
     areAllExpandableExpanded(tree.value, expandedIds.value) ? '全部折叠' : '全部展开'
   )
@@ -461,20 +466,6 @@
 </script>
 
 <style scoped lang="scss">
-  .mp-product-page {
-    position: relative;
-  }
-
-  .mp-cat-header-tools {
-    position: absolute;
-    top: calc(8px + env(safe-area-inset-top, 0px));
-    right: 12px;
-    z-index: 60;
-    display: flex;
-    gap: 8px;
-    align-items: center;
-  }
-
   .mp-cat-header-btn {
     height: 32px;
     padding: 0 10px;