Files
danyang-match/h5-demo/src/pages/discover/DiscoverPage.vue
T
WorkBuddy 69ebf33e18
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
release: freeze demo v1.1.0 2026-10-19
2026-10-19 20:36:22 +08:00

426 lines
12 KiB
Vue

<template>
<div class="discover">
<!-- 搜索栏 -->
<van-search
v-model="keywordInput"
shape="round"
placeholder="搜索昵称 / 关键词"
@search="onSearch"
@clear="onSearch"
/>
<!-- 快捷筛选条 -->
<div class="discover__chips">
<span
class="discover__chip"
:class="{ 'discover__chip--active': applied.regionCode === '321181' }"
@click="toggleLocal"
>
丹阳本地
</span>
<span
class="discover__chip"
:class="{ 'discover__chip--active': hasActiveFilter }"
@click="showFilter = true"
>
<van-icon name="filter-o" /> 筛选
</span>
</div>
<!-- 列表 -->
<div class="discover__body">
<UiErrorState v-if="error" @retry="reload" />
<template v-else>
<UiLoading v-if="firstLoading" />
<template v-else>
<van-list
v-model:loading="loadingMore"
:finished="finished"
finished-text="没有更多了"
@load="onLoadMore"
>
<UiUserCard
v-for="u in users"
:key="u.userId"
:user="u"
show-actions
:loading="actionLoading === u.userId"
@tap="goDetail"
@like="onLike"
@pass="onPass"
/>
</van-list>
<UiEmpty v-if="!users.length" description="没有符合筛选条件的人" />
</template>
</template>
</div>
<!-- 筛选面板 -->
<van-popup v-model:show="showFilter" position="bottom" round :style="{ maxHeight: '80%' }">
<div class="filter">
<div class="filter__title">筛选条件</div>
<div class="filter__group">
<div class="filter__label">所在地区</div>
<div class="filter__chips">
<span
v-for="r in regions"
:key="r.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.regionCode === r.code }"
@click="toggleTemp('regionCode', r.code)"
>
{{ r.label }}
</span>
</div>
</div>
<div class="filter__group">
<div class="filter__label">年龄范围</div>
<div class="filter__range">
<van-field v-model="temp.minAge" type="digit" label="" placeholder="最小" />
<span class="filter__range-sep">—</span>
<van-field v-model="temp.maxAge" type="digit" label="" placeholder="最大" />
</div>
</div>
<div class="filter__group">
<div class="filter__label">学历</div>
<div class="filter__chips">
<span
v-for="e in educations"
:key="e.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.educationCode === e.code }"
@click="toggleTemp('educationCode', e.code)"
>
{{ e.label }}
</span>
</div>
</div>
<div class="filter__group">
<div class="filter__label">婚姻状况</div>
<div class="filter__chips">
<span
v-for="m in maritalStatuses"
:key="m.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.maritalStatus === m.code }"
@click="toggleTemp('maritalStatus', m.code)"
>
{{ m.label }}
</span>
</div>
</div>
<div class="filter__actions">
<van-button block plain @click="resetFilter">重置</van-button>
<van-button block type="primary" @click="applyFilter">应用筛选</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import UiUserCard from '@/components/UiUserCard.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const DEFAULT_MIN_AGE = '18'
const DEFAULT_MAX_AGE = '60'
const PAGE_SIZE = 20
const regions = [
{ code: '321181', label: '丹阳' },
{ code: '321100', label: '镇江' },
{ code: '320100', label: '南京' },
]
const educations = [
{ code: 'high_school', label: '高中及以下' },
{ code: 'college', label: '大专' },
{ code: 'bachelor', label: '本科' },
{ code: 'master', label: '硕士及以上' },
]
const maritalStatuses = [
{ code: 'never_married', label: '未婚' },
{ code: 'divorced', label: '离异' },
]
const keywordInput = ref('')
const applied = reactive({
keyword: '',
regionCode: '',
minAge: DEFAULT_MIN_AGE,
maxAge: DEFAULT_MAX_AGE,
educationCode: '',
maritalStatus: '',
})
const temp = reactive({ ...applied })
const users = ref<any[]>([])
const page = ref(1)
const firstLoading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const error = ref(false)
const actionLoading = ref('')
const showFilter = ref(false)
const hasActiveFilter = computed(
() =>
!!applied.keyword ||
!!applied.regionCode ||
applied.minAge !== DEFAULT_MIN_AGE ||
applied.maxAge !== DEFAULT_MAX_AGE ||
!!applied.educationCode ||
!!applied.maritalStatus,
)
let storeCursor: string | null = null
function buildParams(): Record<string, any> {
const params: Record<string, any> = { pageSize: PAGE_SIZE, page: page.value }
if (applied.keyword) params.keyword = applied.keyword
if (applied.regionCode) params.regionCode = applied.regionCode
if (applied.minAge !== DEFAULT_MIN_AGE) params.minAge = Number(applied.minAge)
if (applied.maxAge !== DEFAULT_MAX_AGE) params.maxAge = Number(applied.maxAge)
if (applied.educationCode) params.educationCode = applied.educationCode
if (applied.maritalStatus) params.maritalStatus = applied.maritalStatus
return params
}
async function fetchPage(reset: boolean) {
if (reset) {
page.value = 1
storeCursor = null
finished.value = false
firstLoading.value = true
error.value = false
}
try {
const result: any = await matchStore.discover(buildParams())
const items: any[] = result?.items || result?.records || []
if (reset) users.value = items
else users.value = [...users.value, ...items]
const totalPages = result?.totalPages || 0
const nextCursor = result?.nextCursor
if (nextCursor) {
// 游标分页:后端给出 nextCursor 时以游标续页
finished.value = false
storeCursor = nextCursor
} else if (page.value >= totalPages || items.length < PAGE_SIZE) {
finished.value = true
} else {
page.value += 1
}
} catch (err: any) {
if (reset) error.value = true
else finished.value = true
appStore.toastError(err?.message || '加载失败')
} finally {
firstLoading.value = false
loadingMore.value = false
}
}
function reload() {
fetchPage(true)
}
function onLoadMore() {
if (finished.value || firstLoading.value) return
loadingMore.value = true
if (storeCursor) {
// 使用游标续页
;(async () => {
try {
const result: any = await matchStore.discover({ ...buildParams(), cursor: storeCursor })
const items: any[] = result?.items || result?.records || []
users.value = [...users.value, ...items]
storeCursor = result?.nextCursor || null
if (!storeCursor && items.length < PAGE_SIZE) finished.value = true
} catch (err: any) {
finished.value = true
appStore.toastError(err?.message || '加载更多失败')
} finally {
loadingMore.value = false
}
})()
return
}
fetchPage(false)
}
function onSearch() {
applied.keyword = (keywordInput.value || '').trim()
reload()
}
function toggleLocal() {
applied.regionCode = applied.regionCode === '321181' ? '' : '321181'
temp.regionCode = applied.regionCode
reload()
}
function toggleTemp(field: 'regionCode' | 'educationCode' | 'maritalStatus', code: string) {
temp[field] = temp[field] === code ? '' : code
}
function applyFilter() {
applied.keyword = (keywordInput.value || '').trim()
applied.regionCode = temp.regionCode
applied.minAge = temp.minAge || DEFAULT_MIN_AGE
applied.maxAge = temp.maxAge || DEFAULT_MAX_AGE
applied.educationCode = temp.educationCode
applied.maritalStatus = temp.maritalStatus
showFilter.value = false
storeCursor = null
reload()
}
function resetFilter() {
temp.keyword = ''
temp.regionCode = ''
temp.minAge = DEFAULT_MIN_AGE
temp.maxAge = DEFAULT_MAX_AGE
temp.educationCode = ''
temp.maritalStatus = ''
keywordInput.value = ''
applied.keyword = ''
applied.regionCode = ''
applied.minAge = DEFAULT_MIN_AGE
applied.maxAge = DEFAULT_MAX_AGE
applied.educationCode = ''
applied.maritalStatus = ''
showFilter.value = false
storeCursor = null
reload()
}
function goDetail(u: any) {
if (u?.userId) router.push({ path: '/profile/detail', query: { id: u.userId } })
}
async function onLike(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
const result: any = await matchStore.like(u.userId)
users.value = users.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
async function onPass(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
await matchStore.pass(u.userId)
users.value = users.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess('已略过')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
onMounted(reload)
</script>
<style scoped>
.discover {
min-height: 100vh;
background: var(--mini-bg);
}
.discover__chips {
display: flex;
gap: 8px;
padding: 0 16px 8px;
}
.discover__chip {
font-size: 12px;
padding: 5px 12px;
border-radius: 14px;
background: var(--mini-surface);
color: var(--mini-text-secondary);
border: 1px solid var(--mini-border);
}
.discover__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.discover__body {
padding: 4px 16px 16px;
}
.filter {
padding: 20px 16px 24px;
}
.filter__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 16px;
}
.filter__group {
margin-bottom: 18px;
}
.filter__label {
font-size: 13px;
color: var(--mini-text-secondary);
margin-bottom: 8px;
}
.filter__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter__chip {
font-size: 12px;
padding: 6px 14px;
border-radius: 14px;
background: var(--mini-bg);
color: var(--mini-text);
border: 1px solid var(--mini-border);
}
.filter__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.filter__range {
display: flex;
align-items: center;
gap: 10px;
}
.filter__range :deep(.van-field) {
flex: 1;
background: var(--mini-bg);
border-radius: 8px;
}
.filter__range-sep {
color: var(--mini-text-muted);
}
.filter__actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
</style>