Просмотр исходного кода

feat(mobile-oa-h5): collapse payment installment rows on list cards

Render periodBlocks as independent expandable summary rows with footer totals after the period section.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 4 недель назад
Родитель
Сommit
321dd3ca43

+ 123 - 8
mobile-oa-h5/src/market/components/MarketListCard.vue

@@ -33,6 +33,113 @@
       </div>
     </div>
 
+    <div v-if="card.periodBlocks?.length" class="market-list-card__periods">
+      <div
+        v-for="block in card.periodBlocks"
+        :key="block.key"
+        class="market-list-card__period"
+        :class="{ 'is-open': periodOpenKeys[block.key] }"
+      >
+        <button
+          type="button"
+          class="market-list-card__period-toggle"
+          :aria-expanded="periodOpenKeys[block.key] ? 'true' : 'false'"
+          :aria-label="periodOpenKeys[block.key] ? `收起第${block.scheduleNo}期` : `展开第${block.scheduleNo}期`"
+          @click="togglePeriod(block.key)"
+        >
+          <span class="market-list-card__period-no">第{{ block.scheduleNo }}期</span>
+          <span class="market-list-card__period-col">
+            <span>应缴费金额</span>
+            <b class="is-money">{{ block.dueAmount }}</b>
+          </span>
+          <span class="market-list-card__period-col">
+            <span>已缴费金额</span>
+            <b class="is-money">{{ block.paidAmount }}</b>
+          </span>
+          <van-icon class="market-list-card__period-chevron" name="arrow-down" />
+        </button>
+        <div v-show="periodOpenKeys[block.key]" class="market-list-card__period-body">
+          <div class="market-list-card__fields market-list-card__fields--compact">
+            <div
+              v-for="field in block.fields"
+              :key="`${block.key}:${field.label}`"
+              class="market-list-card__field"
+              :class="{ 'is-wide': field.wide }"
+            >
+              <span>{{ field.label }}</span>
+              <b :class="{ 'is-money': field.money }">{{ field.value }}</b>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div v-if="card.footerFields?.length" class="market-list-card__fields">
+      <div
+        v-for="field in card.footerFields"
+        :key="`footer:${field.label}:${field.value}`"
+        class="market-list-card__field"
+        :class="{ 'is-wide': field.wide }"
+      >
+        <span>{{ field.label }}</span>
+        <van-tag v-if="field.tag" class="market-tag" :type="resolveStatusTone(field.value)" size="medium" round :title="field.value">{{ field.value }}</van-tag>
+        <b v-else :class="{ 'is-money': field.money }">{{ field.value }}</b>
+      </div>
+    </div>
+
+    <div
+      v-if="card.sampleBlocks?.length"
+      class="market-list-card__samples"
+      :class="{ 'is-open': samplesOpen }"
+    >
+      <button
+        type="button"
+        class="market-list-card__samples-toggle"
+        :aria-expanded="samplesOpen ? 'true' : 'false'"
+        :aria-label="samplesOpen ? '收起关联样本' : '展开关联样本'"
+        @click="samplesOpen = !samplesOpen"
+      >
+        <span class="market-list-card__samples-icon" aria-hidden="true">样</span>
+        <span class="market-list-card__samples-title">
+          关联样本({{ card.sampleCount || card.sampleBlocks.length }})
+        </span>
+        <span class="market-list-card__samples-meta">
+          {{ samplesOpen ? '点击收起' : '点击展开' }}
+        </span>
+        <van-icon class="market-list-card__samples-chevron" name="arrow-down" />
+      </button>
+      <div v-show="samplesOpen" class="market-list-card__samples-body">
+        <div
+          v-for="block in card.sampleBlocks"
+          :key="block.key"
+          class="market-list-card__sample-block"
+        >
+          <div class="market-list-card__sample-head">
+            <span class="market-list-card__sample-title">{{ block.title }}</span>
+            <van-tag
+              v-if="block.status && block.status !== '—'"
+              class="market-tag"
+              :type="resolveStatusTone(block.status)"
+              size="medium"
+              round
+              :title="block.status"
+            >{{ block.status }}</van-tag>
+          </div>
+          <div class="market-list-card__fields market-list-card__fields--compact">
+            <div
+              v-for="field in block.fields"
+              :key="`${block.key}:${field.label}`"
+              class="market-list-card__field"
+              :class="{ 'is-wide': field.wide }"
+            >
+              <span>{{ field.label }}</span>
+              <b>{{ field.value }}</b>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
     <div v-if="card.progressTags?.length" class="market-list-card__tags">
       <van-tag v-for="value in card.progressTags" :key="value" class="market-tag" :type="resolveStatusTone(value)" round>{{ value }}</van-tag>
     </div>
@@ -50,7 +157,6 @@
         size="small"
         :type="action === 'view' || action === 'view-payment-detail' ? 'primary' : action === 'delete' || action === 'unlink' ? 'danger' : 'default'"
         :plain="action !== 'view' && action !== 'view-payment-detail'"
-        :icon="actionIcon(action)"
         @click="$emit('action', action, item)"
       >{{ actionLabel(action) }}</van-button>
     </footer>
@@ -58,7 +164,7 @@
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, ref, watch } from 'vue'
 import { actionLabel, buildMarketCard } from '../list-presenter.js'
 import { resolveStatusTone } from '../status-tone.js'
 
@@ -76,12 +182,21 @@ defineEmits(['action', 'toggle-select'])
 
 const card = computed(() => buildMarketCard(props.module, props.role, props.item))
 const statusTone = computed(() => resolveStatusTone(card.value.status))
+const samplesOpen = ref(false)
+const periodOpenKeys = ref({})
 
-function actionIcon(action) {
-  return {
-    view: 'eye-o', edit: 'edit', delete: 'delete-o',
-    'view-payment': 'balance-o', 'view-payment-detail': 'balance-list-o', 'view-protocol': 'description',
-    'register-transfer': 'balance-pay', link: 'link-o', unlink: 'disconnect'
-  }[action] || 'records-o'
+function togglePeriod(key) {
+  periodOpenKeys.value = {
+    ...periodOpenKeys.value,
+    [key]: !periodOpenKeys.value[key]
+  }
 }
+
+watch(
+  () => props.item?.id || props.index,
+  () => {
+    samplesOpen.value = false
+    periodOpenKeys.value = {}
+  }
+)
 </script>

+ 97 - 1
mobile-oa-h5/src/market/mobile-parity-ui.test.js

@@ -57,6 +57,13 @@ test('market list visual contract keeps search white, tags filled and actions ri
   assert.match(styles, /\.market-list__filters\s*\{[\s\S]*?background:\s*#fff;/)
   assert.match(styles, /\.presign-order-page__search\s*\{[\s\S]*?top:\s*0;/)
   assert.match(styles, /\.market-list-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/)
+  assert.match(styles, /\.market-list-card__actions \.van-button\s*\{[\s\S]*?padding:\s*0 8px;/)
+  const listCard = source('./components/MarketListCard.vue')
+  assert.doesNotMatch(listCard, /#icon/)
+  assert.doesNotMatch(listCard, /actionIcon/)
+  const presignCard = source('./components/PresignOrderCard.vue')
+  assert.doesNotMatch(presignCard, /icon="edit"/)
+  assert.doesNotMatch(presignCard, /icon="delete-o"/)
   assert.match(styles, /\.presign-order-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/)
   assert.match(styles, /\.market-tag\s*\{[\s\S]*?align-self:\s*flex-start;/)
   assert.match(styles, /\.market-tag\s*\{[\s\S]*?max-width:\s*128px;/)
@@ -123,6 +130,86 @@ test('market lists expose a mobile filter sheet for web-equivalent conditions',
   assert.match(styles, /min-height:\s*44px/)
 })
 
+test('freight list uses inline dropdown filters like presign instead of filter sheet', () => {
+  const freight = source('./modules/freight.js')
+  const genericList = source('../views/market/MarketModuleList.vue')
+  const styles = source('../styles/market.css')
+
+  assert.match(freight, /inlineDropdownFilters/)
+  assert.match(freight, /全部运输方式/)
+  assert.match(freight, /全部样本来源/)
+  assert.doesNotMatch(freight, /createTimeRange/)
+  assert.match(genericList, /inlineDropdownFilters/)
+  assert.match(genericList, /market-list__filter-row/)
+  assert.match(genericList, /van-dropdown-menu/)
+  assert.match(genericList, /applyInlineFilters/)
+  assert.match(styles, /\.market-list__filter-row\s*\{/)
+  assert.match(styles, /\.market-list__filter-row\s+\.van-dropdown-menu__bar/)
+})
+
+test('payment list uses inline dropdown filters like presign instead of filter sheet', () => {
+  const payment = source('./modules/payment.js')
+  const filters = source('./list-filters.js')
+  const genericList = source('../views/market/MarketModuleList.vue')
+
+  assert.match(payment, /inlineDropdownFilters/)
+  assert.match(payment, /全部缴费方式/)
+  assert.match(payment, /全部缴费进度/)
+  assert.doesNotMatch(payment, /dueDateRange/)
+  assert.doesNotMatch(payment, /应缴费时间/)
+  assert.match(filters, /payment: Object\.freeze\(\{[\s\S]*?payMode: ''[\s\S]*?progressStatus: ''/)
+  assert.doesNotMatch(filters, /installmentDueDateStart/)
+  assert.match(genericList, /inlineDropdownFilters/)
+  assert.match(genericList, /market-list__filter-row/)
+})
+
+test('freight list and detail wire cascade delete with confirm', () => {
+  const freight = source('./modules/freight.js')
+  const list = source('../views/market/MarketModuleList.vue')
+  const detail = source('../views/market/MarketModuleDetail.vue')
+  const presenter = source('./list-presenter.js')
+  const api = source('../api/market.js')
+
+  assert.match(freight, /'delete'/)
+  assert.match(presenter, /\['edit', 'delete', 'link', 'unlink'\]/)
+  assert.match(list, /deleteFreight/)
+  assert.match(list, /删除运费单/)
+  assert.match(detail, /async function deleteFreight/)
+  assert.match(api, /module === 'freight'[\s\S]*action === 'delete'/)
+})
+
+test('freight list cards collapse sample blocks by default and allow expand', () => {
+  const card = source('./components/MarketListCard.vue')
+  const presenter = source('./list-presenter.js')
+  const styles = source('../styles/market.css')
+
+  assert.match(presenter, /sampleBlocks:\s*rows\.map\(freightSampleBlock\)/)
+  assert.match(card, /card\.sampleBlocks/)
+  assert.match(card, /samplesOpen/)
+  assert.match(card, /关联样本(/)
+  assert.match(card, /点击展开/)
+  assert.match(card, /点击收起/)
+  assert.match(styles, /\.market-list-card__samples\s*\{/)
+  assert.match(styles, /\.market-list-card__samples-toggle\s*\{[\s\S]*?min-height:\s*44px;/)
+})
+
+test('payment list cards collapse period blocks by default and allow independent expand', () => {
+  const card = source('./components/MarketListCard.vue')
+  const presenter = source('./list-presenter.js')
+  const styles = source('../styles/market.css')
+
+  assert.match(presenter, /periodBlocks:\s*periods\.map\(paymentPeriodBlock\)/)
+  assert.match(presenter, /footerFields:/)
+  assert.match(card, /card\.periodBlocks/)
+  assert.match(card, /card\.footerFields/)
+  assert.match(card, /periodOpenKeys/)
+  assert.match(card, /aria-expanded/)
+  assert.match(card, /应缴费金额/)
+  assert.match(card, /已缴费金额/)
+  assert.match(styles, /\.market-list-card__periods\s*\{/)
+  assert.match(styles, /\.market-list-card__period-toggle\s*\{[\s\S]*?min-height:\s*44px;/)
+})
+
 test('dedicated presign list wires orderStatus dropdown without reset on mobile', () => {
   const presignList = source('../views/market/presign/PresignOrderList.vue')
   const styles = source('../styles/market.css')
@@ -140,15 +227,24 @@ test('dedicated presign list wires orderStatus dropdown without reset on mobile'
 
 test('freight sample selector supports source filters, server paging and confirmations', () => {
   const form = source('../views/market/MarketModuleForm.vue')
+  const freight = source('./modules/freight.js')
+  const formSection = source('./components/MarketFormSection.vue')
   const styles = source('../styles/market.css')
   assert.match(form, /v-model="sampleSource"/)
+  assert.match(form, /market-sample-toolbar__filter-row/)
+  assert.match(form, /shape="round"/)
   assert.match(form, /sampleOptionsPage/)
   assert.match(form, /sampleOptionsRequestPending/)
   assert.match(form, /loadMoreSampleOptions/)
   assert.match(form, /pageSize:\s*SAMPLE_OPTION_PAGE_SIZE/)
   assert.doesNotMatch(form, /page:\s*1,\s*pageSize:\s*100/)
   assert.match(form, /确认关联样本/)
-  assert.match(form, /确认取消运费/)
+  assert.match(form, /确认取消样本关联/)
+  assert.match(freight, /fieldsLayout:\s*'grid-2'/)
+  assert.match(formSection, /market-form-fields--grid-2/)
+  assert.match(styles, /\.market-form-fields--grid-2\s*\{/)
+  assert.match(styles, /\.market-sample-toolbar__filter-row\s*\{/)
+  assert.match(styles, /\.market-sample-toolbar__filter-row\s+\.van-dropdown-menu__bar/)
   assert.match(styles, /\.market-sample-option\s*\{[\s\S]*?min-height:\s*44px;/)
   assert.match(styles, /\.market-sample-option\s*\{[\s\S]*?min-width:\s*0;/)
   assert.match(styles, /\.market-sample-option \.van-checkbox__label\s*\{[\s\S]*?overflow-wrap:\s*anywhere;/)

+ 309 - 5
mobile-oa-h5/src/styles/market.css

@@ -92,6 +92,58 @@
   gap: 8px;
 }
 
+.market-list__filter-row {
+  display: flex;
+  align-items: center;
+  margin-top: 2px;
+  margin-left: -4px;
+}
+
+.market-list__filter-row .van-dropdown-menu {
+  width: auto;
+  height: 40px;
+  background: transparent;
+}
+
+.market-list__filter-row .van-dropdown-menu__bar {
+  height: 40px;
+  box-shadow: none;
+  background: transparent;
+}
+
+.market-list__filter-row .van-dropdown-menu__item {
+  flex: none;
+  justify-content: flex-start;
+  padding: 0 10px;
+}
+
+.market-list__filter-row .van-dropdown-menu__title {
+  padding: 0 14px 0 0;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--market-text, #1f2937);
+}
+
+.market-list__filter-row .van-dropdown-menu__title::after {
+  top: 50%;
+  right: 0;
+  margin-top: 0;
+  border-color: transparent transparent currentColor currentColor;
+  opacity: 0.7;
+  transform: translateY(-50%) rotate(-45deg);
+}
+
+.market-list__filter-row .van-dropdown-menu__title--down::after {
+  margin-top: 0;
+  transform: translateY(-50%) rotate(135deg);
+}
+
+.market-list__filter-row .van-dropdown-menu__title--active,
+.market-list__filter-row .van-dropdown-menu__title--down {
+  color: var(--market-primary, #0a9f6d);
+  font-weight: 600;
+}
+
 .market-list__filter-button {
   position: relative;
   display: inline-flex;
@@ -445,6 +497,176 @@ body.van-toast--unclickable .presign-order-page * {
   font-weight: 700;
 }
 
+.market-list-card__samples {
+  margin: 0 0 12px;
+  overflow: hidden;
+  border: 1px solid #eef4f1;
+  border-radius: 12px;
+  background: #fafcfb;
+}
+
+.market-list-card__samples-toggle {
+  display: flex;
+  width: 100%;
+  min-height: 44px;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 12px;
+  border: 0;
+  background: transparent;
+  color: var(--market-text);
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.market-list-card__samples-toggle:active {
+  background: #f0f7f3;
+}
+
+.market-list-card__samples-icon {
+  display: grid;
+  width: 22px;
+  height: 22px;
+  flex: 0 0 22px;
+  place-items: center;
+  border-radius: 6px;
+  background: var(--market-primary-soft);
+  color: var(--market-primary-dark);
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.market-list-card__samples-title {
+  flex: 1;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.market-list-card__samples-meta {
+  color: var(--market-muted);
+  font-size: 12px;
+}
+
+.market-list-card__samples-chevron {
+  color: var(--market-muted);
+  font-size: 14px;
+  transition: transform 0.2s ease;
+}
+
+.market-list-card__samples.is-open .market-list-card__samples-chevron {
+  transform: rotate(180deg);
+}
+
+.market-list-card__samples-body {
+  padding: 0 10px 10px;
+  border-top: 1px solid #eef4f1;
+}
+
+.market-list-card__sample-block {
+  margin-top: 10px;
+  padding: 10px;
+  border: 1px solid #eef4f1;
+  border-radius: 10px;
+  background: #fff;
+}
+
+.market-list-card__sample-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.market-list-card__sample-title {
+  min-width: 0;
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.market-list-card__fields--compact {
+  padding: 0;
+  gap: 10px 12px;
+}
+
+.market-list-card__periods {
+  margin: 0 0 12px;
+  overflow: hidden;
+  border: 1px solid #eef4f1;
+  border-radius: 12px;
+  background: #fafcfb;
+}
+
+.market-list-card__period + .market-list-card__period {
+  border-top: 1px solid #eef4f1;
+}
+
+.market-list-card__period-toggle {
+  display: grid;
+  width: 100%;
+  min-height: 44px;
+  grid-template-columns: 64px 1fr 1fr 20px;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 12px;
+  border: 0;
+  background: transparent;
+  color: var(--market-text);
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.market-list-card__period-toggle:active {
+  background: #f0f7f3;
+}
+
+.market-list-card__period-no {
+  font-size: 13px;
+  font-weight: 700;
+  color: var(--market-primary-dark);
+}
+
+.market-list-card__period-col {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.market-list-card__period-col > span {
+  color: var(--market-muted);
+  font-size: 11px;
+}
+
+.market-list-card__period-col > b {
+  overflow: hidden;
+  color: #c98312;
+  font-size: 13px;
+  font-weight: 700;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.market-list-card__period-chevron {
+  color: var(--market-muted);
+  font-size: 14px;
+  transition: transform 0.2s ease;
+}
+
+.market-list-card__period.is-open .market-list-card__period-chevron {
+  transform: rotate(180deg);
+}
+
+.market-list-card__period-body {
+  padding: 0 12px 12px;
+  border-top: 1px dashed #eef4f1;
+}
+
 .market-list-card__tags {
   display: flex;
   flex-wrap: wrap;
@@ -469,15 +691,26 @@ body.van-toast--unclickable .presign-order-page * {
   display: flex;
   flex-wrap: wrap;
   justify-content: flex-end;
-  gap: 8px;
-  padding-top: 12px;
+  gap: 6px;
+  padding-top: 8px;
   border-top: 1px solid #eef4f1;
 }
 
 .market-list-card__actions .van-button {
-  min-width: 76px;
-  min-height: 44px;
-  border-radius: 9px;
+  min-width: 0;
+  min-height: 32px;
+  padding: 0 8px;
+  border-radius: 8px;
+  font-size: 12px;
+}
+
+.market-list-card__actions .van-button .van-button__icon {
+  margin-right: 3px;
+  font-size: 14px;
+}
+
+.market-list-card__actions .van-button .van-button__content {
+  gap: 3px;
 }
 
 .market-list__fab {
@@ -632,6 +865,77 @@ body.van-toast--unclickable .presign-order-page * {
   font-size: 15px;
 }
 
+.market-form-fields--grid-2 {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 0 12px;
+}
+
+.market-form-fields--grid-2 .market-form-field {
+  min-width: 0;
+}
+
+.market-sample-toolbar {
+  margin: 0 0 12px;
+}
+
+.market-sample-toolbar .van-search {
+  padding: 0;
+  background: transparent;
+}
+
+.market-sample-toolbar__filter-row {
+  display: flex;
+  align-items: center;
+  margin-top: 2px;
+  margin-left: -4px;
+}
+
+.market-sample-toolbar__filter-row .van-dropdown-menu {
+  width: auto;
+  height: 40px;
+  background: transparent;
+}
+
+.market-sample-toolbar__filter-row .van-dropdown-menu__bar {
+  height: 40px;
+  box-shadow: none;
+  background: transparent;
+}
+
+.market-sample-toolbar__filter-row .van-dropdown-menu__item {
+  flex: none;
+  justify-content: flex-start;
+  padding: 0 10px;
+}
+
+.market-sample-toolbar__filter-row .van-dropdown-menu__title {
+  padding: 0 14px 0 0;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--market-text);
+}
+
+.market-sample-toolbar__filter-row .van-dropdown-menu__title::after {
+  top: 50%;
+  right: 0;
+  margin-top: 0;
+  border-color: transparent transparent currentColor currentColor;
+  opacity: 0.7;
+  transform: translateY(-50%) rotate(-45deg);
+}
+
+.market-sample-toolbar__filter-row .van-dropdown-menu__title--down::after {
+  margin-top: 0;
+  transform: translateY(-50%) rotate(135deg);
+}
+
+.market-sample-toolbar__filter-row .van-dropdown-menu__title--active,
+.market-sample-toolbar__filter-row .van-dropdown-menu__title--down {
+  color: var(--market-primary);
+  font-weight: 600;
+}
+
 .market-protocol-total-sync {
   margin: 0 0 8px;
   color: var(--market-muted);