@lucifer.chao.du/home-schema-components 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ <script lang="ts" setup>
2
+ import type { DiscountItem, ServiceItem } from '../schema/index.js';
3
+
4
+ import { computed } from 'vue';
5
+
6
+ const props = withDefaults(
7
+ defineProps<{
8
+ locationText?: string;
9
+ options?: ServiceItem[];
10
+ showHead?: boolean;
11
+ }>(),
12
+ {
13
+ locationText: '',
14
+ options: () => [],
15
+ showHead: false,
16
+ },
17
+ );
18
+
19
+ const displayList = computed<ServiceItem[]>(() => {
20
+ if (props.options.length > 0) {
21
+ return props.options;
22
+ }
23
+
24
+ return [
25
+ {
26
+ category: '',
27
+ count: '',
28
+ discount: [],
29
+ distance: '',
30
+ image: '',
31
+ path: undefined as never,
32
+ range: '',
33
+ rate: '',
34
+ title: '',
35
+ type: 'home',
36
+ },
37
+ ];
38
+ });
39
+
40
+ function resolveServiceTypeOption(type: string) {
41
+ switch (type) {
42
+ case 'home':
43
+ return {
44
+ backgroundColor: 'var(--custom-color-base, #0f766e)',
45
+ color: '#ffffff',
46
+ text: '上门',
47
+ };
48
+ case 'site':
49
+ return {
50
+ backgroundColor: '#f97316',
51
+ color: '#ffffff',
52
+ text: '到店',
53
+ };
54
+ case 'online':
55
+ return {
56
+ backgroundColor: '#eab308',
57
+ color: '#111827',
58
+ text: '在线',
59
+ };
60
+ case 'self':
61
+ return {
62
+ backgroundColor: '#eab308',
63
+ color: '#111827',
64
+ text: '自提',
65
+ };
66
+ default:
67
+ return {
68
+ backgroundColor: '#cbd5e1',
69
+ color: '#334155',
70
+ text: '服务',
71
+ };
72
+ }
73
+ }
74
+
75
+ function resolveDiscountTypeOption(type: string) {
76
+ switch (type) {
77
+ case 'voucher':
78
+ return { name: '优惠券', shortName: '券' };
79
+ case 'bonus':
80
+ return { name: '神券', shortName: '券' };
81
+ case 'group':
82
+ return { name: '团购', shortName: '团' };
83
+ case 'discount':
84
+ default:
85
+ return { name: '优惠', shortName: '惠' };
86
+ }
87
+ }
88
+
89
+ function resolveDiscountTypeShortName(type: string) {
90
+ return resolveDiscountTypeOption(type).shortName;
91
+ }
92
+
93
+ function resolveDiscountTypeName(type: string) {
94
+ return resolveDiscountTypeOption(type).name;
95
+ }
96
+
97
+ function resolveRateText(rate: string) {
98
+ return rate ? `★ ${rate}` : '★ 暂无评分';
99
+ }
100
+
101
+ function shouldShowDiscountPrice(discount: DiscountItem) {
102
+ return Number(discount.price || 0) > 0 || Number(discount.discounted || 0) > 0;
103
+ }
104
+ </script>
105
+
106
+ <template>
107
+ <div class="service-list" v-if="displayList.length > 0">
108
+ <div class="service-list__head" v-if="showHead">
109
+ <div class="service-list__head-title">
110
+ <div class="service-list__head-line"></div>
111
+ <span class="service-list__head-text">{{ locationText }}生活圈</span>
112
+ </div>
113
+ <div class="service-list__head-action">
114
+ <span class="service-list__head-action-text">申请加入</span>
115
+ </div>
116
+ </div>
117
+ <div class="service-list__body">
118
+ <div
119
+ v-for="(item, index) in displayList"
120
+ :key="`services-${index}`"
121
+ class="service-list__item"
122
+ >
123
+ <div class="service-list__media">
124
+ <img
125
+ v-if="item.image"
126
+ class="image"
127
+ :src="item.image"
128
+ :alt="item.title || `服务 ${index + 1}`"
129
+ />
130
+ <div v-else class="image image--placeholder">服务</div>
131
+ </div>
132
+ <div class="service-list__content">
133
+ <div class="service-list__row">
134
+ <div class="service-list__row-item service-list__row-item--grow">
135
+ <span class="service-list__title-text">
136
+ {{ item.title || `服务 ${index + 1}` }}
137
+ </span>
138
+ </div>
139
+ <div class="service-list__row-item">
140
+ <div
141
+ class="service-list__type-badge"
142
+ :style="{
143
+ backgroundColor: resolveServiceTypeOption(item.type).backgroundColor,
144
+ }"
145
+ >
146
+ <span
147
+ class="service-list__type-text"
148
+ :style="{ color: resolveServiceTypeOption(item.type).color }"
149
+ >
150
+ {{ resolveServiceTypeOption(item.type).text }}
151
+ </span>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ <div class="service-list__row">
156
+ <div class="service-list__row-item service-list__row-item--rates">
157
+ <span class="service-list__rate">{{ resolveRateText(item.rate) }}</span>
158
+ </div>
159
+ <div class="service-list__row-item service-list__row-item--count">
160
+ <span class="service-list__meta-text">
161
+ {{ item.count ? `${item.count}条` : '暂无评价' }}
162
+ </span>
163
+ </div>
164
+ </div>
165
+ <div class="service-list__row">
166
+ <div class="service-list__row-item service-list__row-item--grow">
167
+ <span class="service-list__meta-text">
168
+ {{ item.category || '服务分类' }}
169
+ </span>
170
+ <template v-if="item.range">
171
+ <span class="service-list__separator"></span>
172
+ <span class="service-list__meta-text">{{ item.range }}</span>
173
+ </template>
174
+ </div>
175
+ <div class="service-list__row-item">
176
+ <span class="service-list__distance-icon">📍</span>
177
+ <span class="service-list__distance-text">
178
+ {{ item.distance || '距离待配置' }}
179
+ </span>
180
+ </div>
181
+ </div>
182
+ <div
183
+ class="service-list__discount"
184
+ v-if="Array.isArray(item.discount) && item.discount.length > 0"
185
+ >
186
+ <div
187
+ v-for="(discount, discountIndex) in item.discount"
188
+ :key="`services-${index}-${discountIndex}`"
189
+ class="service-list__discount-row"
190
+ :class="`service-list__discount-row--${discount.type}`"
191
+ >
192
+ <div
193
+ class="service-list__discount-type"
194
+ :style="{ marginRight: discount.price ? '5px' : '8px' }"
195
+ >
196
+ <span class="service-list__discount-type-text">
197
+ {{ resolveDiscountTypeShortName(discount.type) }}
198
+ </span>
199
+ </div>
200
+ <div class="service-list__discount-name" v-if="discount.type === 'bonus'">
201
+ <span class="service-list__discount-name-text">
202
+ {{ resolveDiscountTypeName(discount.type) }}
203
+ </span>
204
+ </div>
205
+ <div
206
+ class="service-list__discount-price"
207
+ v-if="shouldShowDiscountPrice(discount)"
208
+ >
209
+ <div
210
+ class="service-list__discount-final-price"
211
+ v-if="Number(discount.price || 0) > 0"
212
+ >
213
+ <span class="service-list__discount-cny">¥</span>
214
+ <span class="service-list__discount-final-price-text">
215
+ {{ discount.price }}
216
+ </span>
217
+ </div>
218
+ <div
219
+ class="service-list__discount-text"
220
+ v-if="Number(discount.discounted || 0) > 0"
221
+ >
222
+ <span class="service-list__discount-text-content">
223
+ 已减{{ discount.discounted }}
224
+ </span>
225
+ </div>
226
+ </div>
227
+ <div class="service-list__discount-project">
228
+ <span class="service-list__discount-project-text">
229
+ {{ discount.project || resolveDiscountTypeName(discount.type) }}
230
+ </span>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ </template>
239
+
240
+ <style scoped lang="scss">
241
+ @import './services.scss';
242
+ </style>
@@ -0,0 +1,70 @@
1
+ .tag-list {
2
+ display: flex;
3
+ flex-direction: row;
4
+ justify-content: space-between;
5
+ flex-wrap: wrap;
6
+ }
7
+
8
+ .placeholder {
9
+ flex: 1 0 auto;
10
+ }
11
+
12
+ .placeholder-tag {
13
+ border: 0;
14
+ cursor: pointer;
15
+ padding: 6px 8px;
16
+ display: flex;
17
+ flex-direction: row;
18
+ align-items: center;
19
+ background-color: var(--uni-bg-color, #ffffff);
20
+ border-radius: 5px;
21
+ box-shadow: 0 0 5px rgb(15 23 42 / 10%);
22
+ }
23
+
24
+ .placeholder-tag .text {
25
+ font-size: 13px;
26
+ color: var(--uni-text-color-gray, #64748b);
27
+ }
28
+
29
+ .placeholder-tag .icon {
30
+ margin-left: 4px;
31
+ color: var(--custom-color-base, #0f766e);
32
+ font-size: 14px;
33
+ line-height: 1;
34
+ }
35
+
36
+ .tag-item {
37
+ margin-bottom: 15px;
38
+ padding: 6px 8px;
39
+ display: flex;
40
+ flex-direction: row;
41
+ align-items: center;
42
+ background-color: var(--uni-bg-color, #ffffff);
43
+ border-radius: 5px;
44
+ box-shadow: 0 0 5px rgb(15 23 42 / 10%);
45
+ }
46
+
47
+ .tag-item.no-margin-bottom {
48
+ margin-bottom: 0;
49
+ }
50
+
51
+ .tag-item .image {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ margin-right: 6px;
56
+ width: 20px;
57
+ height: 15px;
58
+ border-radius: 999px;
59
+ background: rgb(15 118 110 / 12%);
60
+ color: var(--custom-color-base, #0f766e);
61
+ font-size: 10px;
62
+ font-weight: 700;
63
+ }
64
+
65
+ .tag-item .text {
66
+ font-size: 13px;
67
+ line-height: 1.4;
68
+ color: var(--uni-text-color, #0f172a);
69
+ word-break: break-all;
70
+ }
@@ -14,16 +14,16 @@ import {
14
14
 
15
15
  import { getTextInitial } from '../schema/index.js';
16
16
 
17
- type DisplayLastRowData = {
18
- displayLastRowItemGap: number;
19
- placeholderWidth: number;
20
- };
21
-
22
17
  type DisplayTagData = {
23
18
  displayTagCount: number;
24
19
  showPlaceholderTag: boolean;
25
20
  };
26
21
 
22
+ type DisplayLastRowData = {
23
+ displayLastRowItemGap: number;
24
+ placeholderWidth: number;
25
+ };
26
+
27
27
  type TagLayoutSnapshot = {
28
28
  placeholderTagWidth: number;
29
29
  signature: string;
@@ -105,13 +105,18 @@ const displayTagData = computed<DisplayTagData>(() => {
105
105
 
106
106
  const showPlaceholderTag = computed(() => displayTagData.value.showPlaceholderTag);
107
107
  const displayTagCount = computed(() => displayTagData.value.displayTagCount);
108
+ const displayLastRowStartIndex = computed(() =>
109
+ Math.max(displayTagCount.value - displayLastRowCount.value, 0),
110
+ );
108
111
 
109
112
  const displayLastRowCount = computed(() => {
110
113
  let remainingTagCount = displayTagCount.value;
114
+
111
115
  for (const rowCount of tagListRow.value) {
112
116
  if (remainingTagCount <= 0) {
113
117
  break;
114
118
  }
119
+
115
120
  if (remainingTagCount > rowCount) {
116
121
  remainingTagCount -= rowCount;
117
122
  } else {
@@ -121,10 +126,6 @@ const displayLastRowCount = computed(() => {
121
126
  return 0;
122
127
  });
123
128
 
124
- const displayLastRowStartIndex = computed(() =>
125
- Math.max(displayTagCount.value - displayLastRowCount.value, 0),
126
- );
127
-
128
129
  const displayLastRowData = computed<DisplayLastRowData>(() => {
129
130
  if (displayLastRowCount.value === 0 || displayLastRowCount.value === 3) {
130
131
  return {
@@ -379,17 +380,13 @@ onBeforeUnmount(() => {
379
380
  </script>
380
381
 
381
382
  <template>
382
- <div ref="containerRef" class="home-schema-tags">
383
+ <div ref="containerRef" class="tag-list">
383
384
  <template v-for="(tag, index) in tagList" :key="`tags-${index}`">
384
- <button
385
+ <div
385
386
  v-show="showAllTagsForMeasurement || index < displayTagCount"
386
387
  :ref="(element) => setTagItemRef(element, index)"
387
- type="button"
388
- class="home-schema-tags__item"
389
- :class="{
390
- 'home-schema-tags__item--no-margin-bottom':
391
- !isMeasurePending && isDisplayLastRowTag(index),
392
- }"
388
+ class="tag-item"
389
+ :class="{ 'no-margin-bottom': !isMeasurePending && isDisplayLastRowTag(index) }"
393
390
  :style="{
394
391
  marginRight:
395
392
  isMeasurePending || index < displayLastRowStartIndex
@@ -397,111 +394,37 @@ onBeforeUnmount(() => {
397
394
  : `${displayLastRowItemGap}px`,
398
395
  }"
399
396
  >
400
- <span
401
- v-if="props.mode.showIcon"
402
- class="home-schema-tags__icon"
403
- >
397
+ <span v-if="props.mode.showIcon" class="image">
404
398
  {{ getTextInitial(tag.title) }}
405
399
  </span>
406
- <span class="home-schema-tags__text">
407
- {{ tag.title || `标签 ${index + 1}` }}
408
- </span>
409
- </button>
400
+ <span class="text">{{ tag.title || `标签 ${index + 1}` }}</span>
401
+ </div>
410
402
  </template>
411
-
412
403
  <template v-if="props.options.length > 0">
413
404
  <span
414
405
  v-show="!isMeasurePending && showPlaceholder"
415
- class="home-schema-tags__placeholder"
406
+ class="placeholder"
416
407
  :style="{ flexBasis: `${placeholderWidthValue}px` }"
417
408
  ></span>
418
409
  <button
419
410
  v-show="isMeasurePending || showPlaceholderTag"
420
411
  ref="placeholderTagRef"
421
412
  type="button"
422
- class="home-schema-tags__placeholder-tag"
413
+ class="placeholder-tag"
423
414
  :style="{
424
- marginTop: displayLastRowCount === 3 ? '12px' : '0px',
415
+ marginTop: displayLastRowCount === 3 ? '15px' : '0px',
425
416
  pointerEvents: isMeasurePending ? 'none' : 'auto',
426
417
  visibility: isMeasurePending ? 'hidden' : 'visible',
427
418
  }"
428
419
  @click="toggleExpanded"
429
420
  >
430
- <span>{{ tagListExpanded ? '收起' : '展开' }}</span>
431
- <span class="home-schema-tags__placeholder-icon">
432
- {{ tagListExpanded ? '↑' : '↓' }}
433
- </span>
421
+ <span class="text">{{ tagListExpanded ? '收起' : '展开' }}</span>
422
+ <span class="icon">{{ tagListExpanded ? '↑' : '↓' }}</span>
434
423
  </button>
435
424
  </template>
436
425
  </div>
437
426
  </template>
438
427
 
439
- <style scoped>
440
- .home-schema-tags {
441
- display: flex;
442
- flex-wrap: wrap;
443
- justify-content: space-between;
444
- gap: 0 0;
445
- }
446
-
447
- .home-schema-tags__item,
448
- .home-schema-tags__placeholder-tag {
449
- border: 0;
450
- cursor: default;
451
- display: inline-flex;
452
- align-items: center;
453
- border-radius: 10px;
454
- background: rgb(255 255 255 / 85%);
455
- box-shadow: 0 0 10px rgb(15 23 42 / 10%);
456
- }
457
-
458
- .home-schema-tags__item {
459
- margin-bottom: 12px;
460
- padding: 8px 10px;
461
- background: rgb(248 250 252 / 90%);
462
- }
463
-
464
- .home-schema-tags__item--no-margin-bottom {
465
- margin-bottom: 0;
466
- }
467
-
468
- .home-schema-tags__icon {
469
- display: inline-flex;
470
- align-items: center;
471
- justify-content: center;
472
- width: 18px;
473
- height: 18px;
474
- margin-right: 8px;
475
- border-radius: 999px;
476
- background: hsl(var(--primary) / 12%);
477
- color: hsl(var(--primary));
478
- font-size: 10px;
479
- font-weight: 600;
480
- }
481
-
482
- .home-schema-tags__text {
483
- min-width: 0;
484
- color: hsl(var(--foreground));
485
- font-size: 12px;
486
- line-height: 1.4;
487
- white-space: normal;
488
- word-break: break-all;
489
- }
490
-
491
- .home-schema-tags__placeholder {
492
- flex: 1 0 auto;
493
- }
494
-
495
- .home-schema-tags__placeholder-tag {
496
- padding: 8px 10px;
497
- color: hsl(var(--muted-foreground));
498
- font-size: 12px;
499
- line-height: 1.4;
500
- }
501
-
502
- .home-schema-tags__placeholder-icon {
503
- margin-left: 6px;
504
- color: hsl(var(--primary));
505
- font-size: 12px;
506
- }
428
+ <style scoped lang="scss">
429
+ @import './tags.scss';
507
430
  </style>
@@ -0,0 +1,76 @@
1
+ .swiper {
2
+ overflow-x: auto;
3
+ padding-bottom: 12px;
4
+ box-sizing: content-box;
5
+ scrollbar-width: thin;
6
+ }
7
+
8
+ .swiper-track {
9
+ display: flex;
10
+ gap: 12px;
11
+ }
12
+
13
+ .swiper-item {
14
+ flex: 0 0 100%;
15
+ min-width: 100%;
16
+ }
17
+
18
+ .tool-list {
19
+ display: flex;
20
+ flex-direction: row;
21
+ flex-wrap: wrap;
22
+ }
23
+
24
+ .tool-item {
25
+ display: flex;
26
+ flex-direction: column;
27
+ align-items: center;
28
+ }
29
+
30
+ .tool-item .image {
31
+ width: 34px;
32
+ height: 34px;
33
+ }
34
+
35
+ .tool-item .image__content {
36
+ display: flex;
37
+ width: 100%;
38
+ height: 100%;
39
+ align-items: center;
40
+ justify-content: center;
41
+ border-radius: 999px;
42
+ object-fit: cover;
43
+ background: rgb(15 118 110 / 10%);
44
+ color: var(--custom-color-base, #0f766e);
45
+ font-size: 11px;
46
+ font-weight: 700;
47
+ }
48
+
49
+ .tool-item .image__content--placeholder {
50
+ text-transform: uppercase;
51
+ }
52
+
53
+ .tool-item .text {
54
+ font-size: 13px;
55
+ line-height: 1.4;
56
+ color: var(--uni-text-color, #0f172a);
57
+ text-align: center;
58
+ word-break: break-all;
59
+ }
60
+
61
+ .mt-8rpx {
62
+ margin-top: 4px;
63
+ }
64
+
65
+ .swiper-dots {
66
+ display: flex;
67
+ justify-content: center;
68
+ gap: 6px;
69
+ }
70
+
71
+ .swiper-dot {
72
+ width: 6px;
73
+ height: 6px;
74
+ border-radius: 999px;
75
+ background-color: #cbd5e1;
76
+ }
@@ -0,0 +1,126 @@
1
+ <script lang="ts" setup>
2
+ import type { ToolItem, ToolsMode } from '../schema/index.js';
3
+
4
+ import { computed } from 'vue';
5
+
6
+ import { getTextInitial } from '../schema/index.js';
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ mode?: Partial<ToolsMode>;
11
+ options?: ToolItem[];
12
+ }>(),
13
+ {
14
+ mode: () => ({
15
+ displayMultipleItems: 10,
16
+ displayRowCount: 5,
17
+ }),
18
+ options: () => [],
19
+ },
20
+ );
21
+
22
+ const displayMultipleItems = computed(() =>
23
+ Math.max(props.mode.displayMultipleItems ?? 10, 1),
24
+ );
25
+ const displayRowCount = computed(() => Math.max(props.mode.displayRowCount || 5, 1));
26
+
27
+ const displayList = computed<ToolItem[]>(() => {
28
+ if (props.options.length === 0) {
29
+ return Array.from({ length: displayMultipleItems.value }, () => ({
30
+ icon: '',
31
+ path: undefined as never,
32
+ title: '',
33
+ }));
34
+ }
35
+ return props.options;
36
+ });
37
+
38
+ const swiperList = computed<ToolItem[][]>(() => {
39
+ const pages: ToolItem[][] = [];
40
+ for (let index = 0; index < displayList.value.length; index += displayMultipleItems.value) {
41
+ pages.push(displayList.value.slice(index, index + displayMultipleItems.value));
42
+ }
43
+ return pages;
44
+ });
45
+
46
+ function resolveItemStyle(itemCount: number, index: number) {
47
+ const rowCount = displayRowCount.value;
48
+ const lastRowCount = itemCount % rowCount || rowCount;
49
+ const shouldShowBottomMargin = index < itemCount - lastRowCount;
50
+
51
+ return {
52
+ marginBottom: shouldShowBottomMargin ? '16px' : '0px',
53
+ width: `${100 / rowCount}%`,
54
+ };
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <div>
60
+ <template v-if="swiperList.length > 1">
61
+ <div class="swiper">
62
+ <div class="swiper-track">
63
+ <div
64
+ v-for="(page, pageIndex) in swiperList"
65
+ :key="`tools-page-${pageIndex}`"
66
+ class="swiper-item tool-list"
67
+ >
68
+ <div
69
+ v-for="(tool, index) in page"
70
+ :key="`tools-${pageIndex}-${index}`"
71
+ class="tool-item"
72
+ :style="resolveItemStyle(page.length, index)"
73
+ >
74
+ <div class="image">
75
+ <img
76
+ v-if="tool.icon"
77
+ class="image__content"
78
+ :src="tool.icon"
79
+ :alt="tool.title || `工具 ${index + 1}`"
80
+ />
81
+ <span v-else class="image__content image__content--placeholder">
82
+ {{ getTextInitial(tool.title) }}
83
+ </span>
84
+ </div>
85
+ <span class="text mt-8rpx">{{ tool.title || `工具 ${index + 1}` }}</span>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ <div class="swiper-dots">
91
+ <span
92
+ v-for="(_, index) in swiperList"
93
+ :key="`tools-dot-${index}`"
94
+ class="swiper-dot"
95
+ ></span>
96
+ </div>
97
+ </template>
98
+ <template v-else>
99
+ <div class="tool-list">
100
+ <div
101
+ v-for="(tool, index) in displayList"
102
+ :key="`tools-${index}`"
103
+ class="tool-item"
104
+ :style="resolveItemStyle(displayList.length, index)"
105
+ >
106
+ <div class="image">
107
+ <img
108
+ v-if="tool.icon"
109
+ class="image__content"
110
+ :src="tool.icon"
111
+ :alt="tool.title || `工具 ${index + 1}`"
112
+ />
113
+ <span v-else class="image__content image__content--placeholder">
114
+ {{ getTextInitial(tool.title) }}
115
+ </span>
116
+ </div>
117
+ <span class="text mt-8rpx">{{ tool.title || `工具 ${index + 1}` }}</span>
118
+ </div>
119
+ </div>
120
+ </template>
121
+ </div>
122
+ </template>
123
+
124
+ <style scoped lang="scss">
125
+ @import './tools.scss';
126
+ </style>