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>
This commit is contained in:
zlwuandCursor committed 2026-10-14 14:41:34 +08:00
1 parent 4e586e905b
commit 321dd3ca43
3 files changed
+529 -14

No files matched your search

@@ -33,6 +33,113 @@
</div> </div>
</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"> <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> <van-tag v-for="value in card.progressTags" :key="value" class="market-tag" :type="resolveStatusTone(value)" round>{{ value }}</van-tag>
</div> </div>
@@ -50,7 +157,6 @@
size="small" size="small"
:type="action === 'view' || action === 'view-payment-detail' ? 'primary' : action === 'delete' || action === 'unlink' ? 'danger' : 'default'" :type="action === 'view' || action === 'view-payment-detail' ? 'primary' : action === 'delete' || action === 'unlink' ? 'danger' : 'default'"
:plain="action !== 'view' && action !== 'view-payment-detail'" :plain="action !== 'view' && action !== 'view-payment-detail'"
:icon="actionIcon(action)"
@click="$emit('action', action, item)" @click="$emit('action', action, item)"
>{{ actionLabel(action) }}</van-button> >{{ actionLabel(action) }}</van-button>
</footer> </footer>
@@ -58,7 +164,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { actionLabel, buildMarketCard } from '../list-presenter.js' import { actionLabel, buildMarketCard } from '../list-presenter.js'
import { resolveStatusTone } from '../status-tone.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 card = computed(() => buildMarketCard(props.module, props.role, props.item))
const statusTone = computed(() => resolveStatusTone(card.value.status)) const statusTone = computed(() => resolveStatusTone(card.value.status))
const samplesOpen = ref(false)
const periodOpenKeys = ref({})
function actionIcon(action) { function togglePeriod(key) {
return { periodOpenKeys.value = {
view: 'eye-o', edit: 'edit', delete: 'delete-o', ...periodOpenKeys.value,
'view-payment': 'balance-o', 'view-payment-detail': 'balance-list-o', 'view-protocol': 'description', [key]: !periodOpenKeys.value[key]
'register-transfer': 'balance-pay', link: 'link-o', unlink: 'disconnect' }
}[action] || 'records-o'
} }
watch(
() => props.item?.id || props.index,
() => {
samplesOpen.value = false
periodOpenKeys.value = {}
}
)
</script> </script>
@@ -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, /\.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, /\.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\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, /\.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]*?align-self:\s*flex-start;/)
assert.match(styles, /\.market-tag\s*\{[\s\S]*?max-width:\s*128px;/) 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/) 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', () => { test('dedicated presign list wires orderStatus dropdown without reset on mobile', () => {
const presignList = source('../views/market/presign/PresignOrderList.vue') const presignList = source('../views/market/presign/PresignOrderList.vue')
const styles = source('../styles/market.css') 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', () => { test('freight sample selector supports source filters, server paging and confirmations', () => {
const form = source('../views/market/MarketModuleForm.vue') 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') const styles = source('../styles/market.css')
assert.match(form, /v-model="sampleSource"/) 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, /sampleOptionsPage/)
assert.match(form, /sampleOptionsRequestPending/) assert.match(form, /sampleOptionsRequestPending/)
assert.match(form, /loadMoreSampleOptions/) assert.match(form, /loadMoreSampleOptions/)
assert.match(form, /pageSize:\s*SAMPLE_OPTION_PAGE_SIZE/) assert.match(form, /pageSize:\s*SAMPLE_OPTION_PAGE_SIZE/)
assert.doesNotMatch(form, /page:\s*1,\s*pageSize:\s*100/) assert.doesNotMatch(form, /page:\s*1,\s*pageSize:\s*100/)
assert.match(form, /确认关联样本/) 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-height:\s*44px;/)
assert.match(styles, /\.market-sample-option\s*\{[\s\S]*?min-width:\s*0;/) 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;/) assert.match(styles, /\.market-sample-option \.van-checkbox__label\s*\{[\s\S]*?overflow-wrap:\s*anywhere;/)
+309 -5
View File
@@ -92,6 +92,58 @@
gap: 8px; 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 { .market-list__filter-button {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -445,6 +497,176 @@ body.van-toast--unclickable .presign-order-page * {
font-weight: 700; 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 { .market-list-card__tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -469,15 +691,26 @@ body.van-toast--unclickable .presign-order-page * {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 6px;
padding-top: 12px; padding-top: 8px;
border-top: 1px solid #eef4f1; border-top: 1px solid #eef4f1;
} }
.market-list-card__actions .van-button { .market-list-card__actions .van-button {
min-width: 76px; min-width: 0;
min-height: 44px; min-height: 32px;
border-radius: 9px; 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 { .market-list__fab {
@@ -632,6 +865,77 @@ body.van-toast--unclickable .presign-order-page * {
font-size: 15px; 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 { .market-protocol-total-sync {
margin: 0 0 8px; margin: 0 0 8px;
color: var(--market-muted); color: var(--market-muted);