chore: initial import
This commit is contained in:
137
pkgService/submit_request/components/DateRangeSelector.vue
Normal file
137
pkgService/submit_request/components/DateRangeSelector.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<view class="date-range-selector">
|
||||
<view class="date-item">
|
||||
<view class="date-label">{{ resolvedStartLabel }}</view>
|
||||
<picker mode="date" @change="onStartChange">
|
||||
<view class="date-input" :class="{ 'is-empty': !start }">
|
||||
{{ start || resolvedStartPlaceholder }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="date-divider">{{ t('dateDivider') }}</view>
|
||||
<view class="date-item">
|
||||
<view class="date-label">{{ resolvedEndLabel }}</view>
|
||||
<picker mode="date" @change="onEndChange">
|
||||
<view class="date-input" :class="{ 'is-empty': !end }">
|
||||
{{ end || resolvedEndPlaceholder }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DateRangeSelector',
|
||||
i18n: {
|
||||
zh: {
|
||||
startLabel: '预计入住',
|
||||
endLabel: '预计离开',
|
||||
startPlaceholder: '请选择入住日期',
|
||||
endPlaceholder: '请选择离开日期',
|
||||
dateDivider: '至'
|
||||
},
|
||||
en: {
|
||||
startLabel: 'Expected Check-in',
|
||||
endLabel: 'Expected Check-out',
|
||||
startPlaceholder: 'Select check-in date',
|
||||
endPlaceholder: 'Select check-out date',
|
||||
dateDivider: 'to'
|
||||
}
|
||||
},
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
endLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startPlaceholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
endPlaceholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resolvedStartLabel() {
|
||||
return this.startLabel || this.t('startLabel')
|
||||
},
|
||||
resolvedEndLabel() {
|
||||
return this.endLabel || this.t('endLabel')
|
||||
},
|
||||
resolvedStartPlaceholder() {
|
||||
return this.startPlaceholder || this.t('startPlaceholder')
|
||||
},
|
||||
resolvedEndPlaceholder() {
|
||||
return this.endPlaceholder || this.t('endPlaceholder')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t(key) {
|
||||
return this.$tm('pkgService.submit_request.components.DateRangeSelector', key)
|
||||
},
|
||||
onStartChange(e) {
|
||||
this.$emit('update:start', e.detail.value)
|
||||
},
|
||||
onEndChange(e) {
|
||||
this.$emit('update:end', e.detail.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.date-range-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
border: 2rpx solid rgba(226, 232, 240, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.date-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
margin: 0 24rpx;
|
||||
color: #94A3B8;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-size: 22rpx;
|
||||
color: #64748B;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
font-size: 28rpx;
|
||||
color: #1A2B4C;
|
||||
font-weight: 600;
|
||||
|
||||
&.is-empty {
|
||||
color: #CBD5E1;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
326
pkgService/submit_request/components/RoomTypeSelector.vue
Normal file
326
pkgService/submit_request/components/RoomTypeSelector.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<view class="room-type-selector">
|
||||
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="showPopup = true">
|
||||
<view class="selected-info">
|
||||
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
|
||||
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : t('selectRoomType') }}
|
||||
</view>
|
||||
<view class="selected-area tn-text-ellipsis" v-if="selectedArea">
|
||||
{{ getAreaDisplayName(selectedArea) }}
|
||||
</view>
|
||||
</view>
|
||||
<text class="tn-icon-right tn-color-gray"></text>
|
||||
</view>
|
||||
|
||||
<tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true">
|
||||
<view class="popup-content">
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">{{ t('popupTitle') }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="area-list-scroll">
|
||||
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
|
||||
<tn-loading mode="circle"></tn-loading>
|
||||
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingRoomTypes') }}</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!areaList.length" class="empty-state tn-padding-xl tn-text-center">
|
||||
<text class="tn-color-gray">{{ t('emptyRoomTypes') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="area-group" v-for="(area, index) in areaList" :key="area.code">
|
||||
<view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)">
|
||||
<view class="tn-flex tn-flex-col-center">
|
||||
<text class="area-dot"></text>
|
||||
<text>{{ getAreaDisplayName(area) }}</text>
|
||||
</view>
|
||||
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
|
||||
</view>
|
||||
<view class="room-type-grid" v-show="!collapsedAreas[index]">
|
||||
<view
|
||||
class="room-type-item"
|
||||
v-for="room in area.roomTypes"
|
||||
:key="room.code"
|
||||
:class="{ 'is-active': selectedRoomType && selectedRoomType.code === room.code }"
|
||||
@click="handleSelect(area, room)"
|
||||
>
|
||||
<view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view>
|
||||
<view class="room-price" v-if="room.price">¥{{ getPriceValue(room.price) }}{{ t('priceUnit') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</tn-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { areaPagination } from '@/api/index.js'
|
||||
import { normalizeAreaItem } from '../../../utils/select-room-normalizers'
|
||||
|
||||
export default {
|
||||
name: 'RoomTypeSelector',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '' // roomTypeCode
|
||||
},
|
||||
initialAreaCode: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showPopup: false,
|
||||
isLoading: false,
|
||||
areaList: [],
|
||||
selectedArea: null,
|
||||
selectedRoomType: null,
|
||||
collapsedAreas: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
language() {
|
||||
return this.$getLanguage ? this.$getLanguage() : 'zh'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.syncSelection()
|
||||
},
|
||||
language() {
|
||||
this.loadData(true)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadData()
|
||||
},
|
||||
methods: {
|
||||
t(key) {
|
||||
return this.$translate(this.i18n, key)
|
||||
},
|
||||
loadData(force = false) {
|
||||
if (!force && this.areaList.length > 0) return
|
||||
|
||||
this.isLoading = true
|
||||
return areaPagination({ PageIndex: 1, PageSize: 100 }, this.language)
|
||||
.then((res) => {
|
||||
const rawList = Array.isArray(res?.data?.data) ? res.data.data : (Array.isArray(res?.data) ? res.data : [])
|
||||
|
||||
this.areaList = rawList
|
||||
.map(item => normalizeAreaItem(item))
|
||||
.filter(area => area.roomTypes && area.roomTypes.length > 0)
|
||||
|
||||
// 初始化全部不折叠
|
||||
const collapsed = {}
|
||||
this.areaList.forEach((_, index) => {
|
||||
collapsed[index] = false
|
||||
})
|
||||
this.collapsedAreas = collapsed
|
||||
|
||||
this.syncSelection()
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('load room types error', e)
|
||||
uni.showToast({ title: this.t('loadFailed'), icon: 'none' })
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
},
|
||||
syncSelection() {
|
||||
if (!this.areaList.length) return
|
||||
|
||||
let foundArea = null
|
||||
let foundRoom = null
|
||||
|
||||
// Try to find by room type code
|
||||
if (this.value) {
|
||||
for (const area of this.areaList) {
|
||||
const room = area.roomTypes.find(r => r.code === this.value)
|
||||
if (room) {
|
||||
foundArea = area
|
||||
foundRoom = room
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by room, try to find by initial area code
|
||||
if (!foundRoom && this.initialAreaCode) {
|
||||
foundArea = this.areaList.find(a => a.code === this.initialAreaCode)
|
||||
}
|
||||
|
||||
if (foundArea) {
|
||||
this.selectedArea = foundArea
|
||||
if (foundRoom) {
|
||||
this.selectedRoomType = foundRoom
|
||||
}
|
||||
|
||||
// If we found the room, or we just want to initialize the area but no room yet
|
||||
if (foundRoom && this.value !== foundRoom.code) {
|
||||
this.$emit('input', foundRoom.code)
|
||||
this.$emit('change', { area: foundArea, roomType: foundRoom })
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSelect(area, roomType) {
|
||||
this.selectedArea = area
|
||||
this.selectedRoomType = roomType
|
||||
this.$emit('input', roomType.code)
|
||||
this.$emit('change', { area, roomType })
|
||||
this.showPopup = false
|
||||
},
|
||||
getAreaDisplayName(area = {}) {
|
||||
if (this.language === 'en') {
|
||||
return area.nameEnDisplay || area.nameEn || area.name || ''
|
||||
}
|
||||
return area.name || area.nameEn || ''
|
||||
},
|
||||
getRoomTypeDisplayName(roomType = {}) {
|
||||
if (this.language === 'en') {
|
||||
return roomType.nameEnDisplay || roomType.nameEn || roomType.name || ''
|
||||
}
|
||||
return roomType.name || roomType.nameEn || ''
|
||||
},
|
||||
toggleArea(index) {
|
||||
this.$set(this.collapsedAreas, index, !this.collapsedAreas[index])
|
||||
},
|
||||
getPriceValue(priceText) {
|
||||
if (!priceText) return '--'
|
||||
const matched = String(priceText).match(/(\d+(\.\d+)?)/)
|
||||
return matched ? matched[1] : '--'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.room-type-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selector-header {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
padding: 26rpx 32rpx;
|
||||
border: 1rpx solid rgba($brand-primary, 0.15);
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
|
||||
min-height: 100rpx;
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.selected-name {
|
||||
font-size: 28rpx;
|
||||
color: #1A2233;
|
||||
font-weight: 600;
|
||||
|
||||
&.is-empty {
|
||||
color: #A0B0C0;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-area {
|
||||
font-size: 22rpx;
|
||||
color: #6A7A99;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #F8FAFF;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
padding: 40rpx 30rpx 20rpx;
|
||||
text-align: center;
|
||||
border-bottom: 1px dashed rgba($brand-primary, 0.15);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1A2233;
|
||||
}
|
||||
|
||||
.area-list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 20rpx 30rpx 60rpx;
|
||||
}
|
||||
|
||||
.area-group {
|
||||
margin-bottom: 30rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04);
|
||||
border: 1rpx solid rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.area-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1A2233;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.area-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: $brand-primary;
|
||||
margin-right: 12rpx;
|
||||
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
|
||||
}
|
||||
|
||||
.room-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.room-type-item {
|
||||
padding: 20rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #FFFFFF;
|
||||
border: 1rpx solid rgba($brand-primary, 0.15);
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.is-active {
|
||||
background: #FFFFFF;
|
||||
border-color: $brand-primary;
|
||||
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1);
|
||||
|
||||
.room-name {
|
||||
color: $brand-primary;
|
||||
}
|
||||
.room-price {
|
||||
color: $brand-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #1A2233;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.room-price {
|
||||
font-size: 22rpx;
|
||||
color: #6A7A99;
|
||||
}
|
||||
</style>
|
||||
105
pkgService/submit_request/components/TagSelector.vue
Normal file
105
pkgService/submit_request/components/TagSelector.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<view class="tag-selector">
|
||||
<view v-if="options.length" class="option-row">
|
||||
<view
|
||||
v-for="item in options"
|
||||
:key="item.key"
|
||||
class="option-chip"
|
||||
:class="{ 'option-chip--active': value.includes(item.key) }"
|
||||
@click="toggleTag(item.key)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-tips">{{ loading ? t('loadingText') : resolvedEmptyText }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TagSelector',
|
||||
i18n: {
|
||||
zh: {
|
||||
loadingText: '标签加载中...',
|
||||
emptyText: '暂无标签'
|
||||
},
|
||||
en: {
|
||||
loadingText: 'Loading tags...',
|
||||
emptyText: 'No tags available'
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resolvedEmptyText() {
|
||||
return this.emptyText || this.t('emptyText')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.submit_request.components.TagSelector', key, params)
|
||||
},
|
||||
toggleTag(key) {
|
||||
const currentList = [...this.value]
|
||||
const index = currentList.indexOf(key)
|
||||
if (index > -1) {
|
||||
currentList.splice(index, 1)
|
||||
} else {
|
||||
currentList.push(key)
|
||||
}
|
||||
this.$emit('input', currentList)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.option-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.option-chip {
|
||||
padding: 16rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: #64748B;
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
border: 2rpx solid rgba(226, 232, 240, 0.8);
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.02);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.option-chip--active {
|
||||
background: #1A2B4C;
|
||||
border-color: #1A2B4C;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 24rpx rgba(26, 43, 76, 0.2);
|
||||
}
|
||||
|
||||
.empty-tips {
|
||||
color: #94A3B8;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
1304
pkgService/submit_request/submit_request.vue
Normal file
1304
pkgService/submit_request/submit_request.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user