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
426 lines
12 KiB
Vue
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>
|