chore: establish V1.1 recovery baseline

This commit is contained in:
2026-09-09 22:05:00 +08:00
commit b46b48eb33
947 changed files with 155471 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
/**
* 找对象页 U19-U20(重构:用 ui-user-card + matchStore.like + recommendationService.discover)
*
* 不变量(AGENTS.md):
* - 第 2 条:Required 不可自动放宽——Discover 搜索仅透传筛选条件到后端,
* 由后端按 PartnerPreference 做硬约束过滤,前端不做任何"放宽再查"。
* - 第 4 条:Block 即时禁止互动——本页 Like 后由 matchStore 从列表移除。
* - 第 6 条:单向意向不泄露——Pass/Like 未 Mutual 时对方不收到通知。
* - 第 9 条:PostgreSQL 是 Source of Truth——搜索结果不在 Store 持久化,
* 列表状态为页面局部状态(BaseStore 原则 2)。
*/
const { matchStore, appStore, authStore } = require('../../stores');
const { recommendationService } = require('../../services');
const { to } = require('../../constants');
const DEFAULT_AGE_MIN = 18;
const DEFAULT_AGE_MAX = 60;
const PAGE_SIZE = 20;
Page({
data: {
users: [],
loading: false,
loadingMore: false,
cursor: null,
hasMore: true,
// 搜索栏关键词(编辑中)
keywordInput: '',
showFilter: false,
// 临时筛选(编辑中)
tempRegion: '',
tempAgeMin: DEFAULT_AGE_MIN,
tempAgeMax: DEFAULT_AGE_MAX,
// 已应用筛选
applied: { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX },
hasActiveFilter: false,
regions: ['丹阳', '镇江', '南京'],
actionLoadingUserId: '',
emptyActions: [
{ text: '清空筛选', event: 'reset', variant: 'primary' },
{ text: '去今日推荐', event: 'home', variant: 'secondary' },
],
},
onLoad() {
if (!authStore.state.isLoggedIn) return;
this.fetchDiscover(true);
},
/** 拉取搜索结果(首页) */
async fetchDiscover(first) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
const result = await recommendationService.discover(this._buildParams());
const items = result.items || [];
this.setData({
users: first ? items : [...this.data.users, ...items],
cursor: result.nextCursor || null,
hasMore: !!result.nextCursor,
loading: false,
});
} catch (err) {
this.setData({ loading: false });
appStore.toastError(err);
}
},
/** 加载更多 */
async loadMore() {
if (this.data.loadingMore || !this.data.hasMore || !this.data.cursor) return;
this.setData({ loadingMore: true });
try {
const result = await recommendationService.discover({
...this._buildParams(),
cursor: this.data.cursor,
});
const items = result.items || [];
this.setData({
users: [...this.data.users, ...items],
cursor: result.nextCursor || null,
hasMore: !!result.nextCursor,
});
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ loadingMore: false });
}
},
/** 构造请求参数(仅透传,后端守 Required 硬约束) */
_buildParams() {
const { applied } = this.data;
const params = { pageSize: PAGE_SIZE };
if (applied.keyword) params.keyword = applied.keyword;
if (applied.region) params.region = applied.region;
if (applied.ageMin !== DEFAULT_AGE_MIN) params.ageMin = applied.ageMin;
if (applied.ageMax !== DEFAULT_AGE_MAX) params.ageMax = applied.ageMax;
return params;
},
// ============ 搜索栏 ============
onKeywordInput(e) {
this.setData({ keywordInput: e.detail.value });
},
/** 关键词搜索(键盘确认或点搜索图标) */
onSearch() {
const keyword = (this.data.keywordInput || '').trim();
const applied = { ...this.data.applied, keyword };
this.setData({
applied,
hasActiveFilter: this._hasActiveFilter(applied),
});
this.fetchDiscover(true);
},
// ============ 筛选面板 ============
toggleFilter() {
this.setData({ showFilter: !this.data.showFilter });
},
onRegionTap(e) {
const val = e.currentTarget.dataset.val;
this.setData({ tempRegion: this.data.tempRegion === val ? '' : val });
},
onMinAgeChange(e) {
let min = e.detail.value;
if (min > this.data.tempAgeMax) min = this.data.tempAgeMax;
this.setData({ tempAgeMin: min });
},
onMaxAgeChange(e) {
let max = e.detail.value;
if (max < this.data.tempAgeMin) max = this.data.tempAgeMin;
this.setData({ tempAgeMax: max });
},
onApplyFilter() {
const { tempRegion, tempAgeMin, tempAgeMax, keywordInput } = this.data;
const applied = {
keyword: (keywordInput || '').trim(),
region: tempRegion,
ageMin: tempAgeMin,
ageMax: tempAgeMax,
};
this.setData({
applied,
hasActiveFilter: this._hasActiveFilter(applied),
showFilter: false,
});
this.fetchDiscover(true);
},
onResetFilter() {
const applied = { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX };
this.setData({
keywordInput: '',
tempRegion: '',
tempAgeMin: DEFAULT_AGE_MIN,
tempAgeMax: DEFAULT_AGE_MAX,
applied,
hasActiveFilter: false,
showFilter: false,
});
this.fetchDiscover(true);
},
_hasActiveFilter(applied) {
return !!(
applied.keyword ||
applied.region ||
applied.ageMin !== DEFAULT_AGE_MIN ||
applied.ageMax !== DEFAULT_AGE_MAX
);
},
onEmptyAction(e) {
const { event } = e.detail || {};
if (event === 'reset') {
this.onResetFilter();
} else if (event === 'home') {
wx.switchTab({ url: '/pages/index/index' });
}
},
// ============ 卡片操作 ============
/** 卡片点击:查看资料 */
onViewDetail(e) {
const { userId } = e.detail || {};
if (!userId) return;
wx.navigateTo({ url: to.detail(userId) });
},
/** 喜欢(actions slot 按钮,catchtap 阻止冒泡到卡片) */
async onLike(e) {
const userId = e.currentTarget.dataset.userId;
if (!userId || this.data.actionLoadingUserId) return;
this.setData({ actionLoadingUserId: userId });
try {
const result = await matchStore.like(userId);
// 从列表移除(无论是否 Mutual,已做出判断)
this.setData({ users: this.data.users.filter((u) => u.userId !== userId) });
if (result && result.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了');
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else {
appStore.toastSuccess('已表达喜欢');
}
} catch (err) {
appStore.toastError(err);
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
// ============ 列表交互 ============
onPullDownRefresh() {
this.fetchDiscover(true).finally(() => wx.stopPullDownRefresh());
},
onReachBottom() {
this.loadMore();
},
});
+13
View File
@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "找对象",
"enablePullDownRefresh": true,
"backgroundColor": "#F7F8FA",
"backgroundTextStyle": "dark",
"usingComponents": {
"ui-user-card": "/components/user-card/index",
"ui-button": "/components/button/index",
"ui-loading": "/components/loading/index",
"ui-empty": "/components/empty/index",
"ui-section-header": "/components/section-header/index"
}
}
+214
View File
@@ -0,0 +1,214 @@
/**
* 找对象页 U19-U20(TypeScript)
*
* 不变量(AGENTS.md):
* - 第 2 条:Required 不可自动放宽——Discover 搜索仅透传筛选条件到后端
* - 第 4 条:Block 即时禁止互动
* - 第 6 条:单向意向不泄露
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { matchStore, appStore, authStore } from '../../stores';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { recommendationService } from '../../services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { to } from '../../constants';
import type { RecommendationCandidate } from '../../types';
interface DiscoverData {
users: RecommendationCandidate[];
loading: boolean;
loadingMore: boolean;
cursor: string | null;
hasMore: boolean;
keywordInput: string;
showFilter: boolean;
tempRegion: string;
tempAgeMin: number;
tempAgeMax: number;
applied: { keyword: string; region: string; ageMin: number; ageMax: number };
hasActiveFilter: boolean;
regions: string[];
actionLoadingUserId: string;
emptyActions: Array<{ text: string; event: string; variant: string }>;
}
const DEFAULT_AGE_MIN = 18;
const DEFAULT_AGE_MAX = 60;
const PAGE_SIZE = 20;
Page({
data: {
users: [],
loading: false,
loadingMore: false,
cursor: null,
hasMore: true,
keywordInput: '',
showFilter: false,
tempRegion: '',
tempAgeMin: DEFAULT_AGE_MIN,
tempAgeMax: DEFAULT_AGE_MAX,
applied: { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX },
hasActiveFilter: false,
regions: ['丹阳', '镇江', '南京'],
actionLoadingUserId: '',
emptyActions: [
{ text: '清空筛选', event: 'reset', variant: 'primary' },
{ text: '去今日推荐', event: 'home', variant: 'secondary' },
],
},
onLoad() {
if (!authStore.state.isLoggedIn) return;
this.fetchDiscover(true);
},
async fetchDiscover(first: boolean) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
// @ts-ignore - recommendationService 接口待 TypeScript 化
const result = await recommendationService.discover(this._buildParams());
const items: RecommendationCandidate[] = (result?.items as RecommendationCandidate[]) || [];
this.setData({
users: first ? items : [...this.data.users, ...items],
cursor: result?.nextCursor || null,
hasMore: !!result?.nextCursor,
loading: false,
});
} catch (err: unknown) {
this.setData({ loading: false });
appStore.toastError(err instanceof Error ? err.message : '加载失败');
}
},
async loadMore() {
if (this.data.loadingMore || !this.data.hasMore || !this.data.cursor) return;
this.setData({ loadingMore: true });
try {
// @ts-ignore
const result = await recommendationService.discover({
...this._buildParams(),
cursor: this.data.cursor,
});
const items: RecommendationCandidate[] = (result?.items as RecommendationCandidate[]) || [];
this.setData({
users: [...this.data.users, ...items],
cursor: result?.nextCursor || null,
hasMore: !!result?.nextCursor,
});
} catch (err: unknown) {
appStore.toastError(err instanceof Error ? err.message : '加载失败');
} finally {
this.setData({ loadingMore: false });
}
},
_buildParams() {
const { applied } = this.data;
const params: Record<string, unknown> = { pageSize: PAGE_SIZE };
if (applied.keyword) params.keyword = applied.keyword;
if (applied.region) params.region = applied.region;
if (applied.ageMin !== DEFAULT_AGE_MIN) params.ageMin = applied.ageMin;
if (applied.ageMax !== DEFAULT_AGE_MAX) params.ageMax = applied.ageMax;
return params;
},
onKeywordInput(e: any) {
this.setData({ keywordInput: e.detail.value });
},
onSearch() {
const keyword = (this.data.keywordInput || '').trim();
const applied = { ...this.data.applied, keyword };
this.setData({ applied, hasActiveFilter: this._hasActiveFilter(applied) });
this.fetchDiscover(true);
},
toggleFilter() {
this.setData({ showFilter: !this.data.showFilter });
},
onRegionTap(e: WechatMiniprogram.TouchEvent) {
const val = (e.currentTarget.dataset as any).val as string;
this.setData({ tempRegion: this.data.tempRegion === val ? '' : val });
},
onMinAgeChange(e: any) {
let min = Number((e as any).detail.value);
if (min > this.data.tempAgeMax) min = this.data.tempAgeMax;
this.setData({ tempAgeMin: min });
},
onMaxAgeChange(e: any) {
let max = Number((e as any).detail.value);
if (max < this.data.tempAgeMin) max = this.data.tempAgeMin;
this.setData({ tempAgeMax: max });
},
onApplyFilter() {
const { tempRegion, tempAgeMin, tempAgeMax, keywordInput } = this.data;
const applied = { keyword: (keywordInput || '').trim(), region: tempRegion, ageMin: tempAgeMin, ageMax: tempAgeMax };
this.setData({ applied, hasActiveFilter: this._hasActiveFilter(applied), showFilter: false });
this.fetchDiscover(true);
},
onResetFilter() {
const applied = { keyword: '', region: '', ageMin: DEFAULT_AGE_MIN, ageMax: DEFAULT_AGE_MAX };
this.setData({
keywordInput: '', tempRegion: '', tempAgeMin: DEFAULT_AGE_MIN, tempAgeMax: DEFAULT_AGE_MAX,
applied, hasActiveFilter: false, showFilter: false,
});
this.fetchDiscover(true);
},
_hasActiveFilter(applied: { keyword: string; region: string; ageMin: number; ageMax: number }) {
return !!(
applied.keyword || applied.region ||
applied.ageMin !== DEFAULT_AGE_MIN || applied.ageMax !== DEFAULT_AGE_MAX
);
},
onEmptyAction(e: WechatMiniprogram.TouchEvent) {
const { event } = (e.detail as any) || {};
if (event === 'reset') this.onResetFilter();
else if (event === 'home') wx.switchTab({ url: '/pages/index/index' });
},
onViewDetail(e: WechatMiniprogram.TouchEvent) {
const { userId } = (e.detail as any) || {};
if (!userId) return;
wx.navigateTo({ url: to.detail(userId) });
},
async onLike(e: WechatMiniprogram.TouchEvent) {
const userId = (e.currentTarget.dataset as any).userId as string | undefined;
if (!userId || this.data.actionLoadingUserId) return;
this.setData({ actionLoadingUserId: userId });
try {
// @ts-ignore
const result = await matchStore.like(userId);
this.setData({ users: this.data.users.filter((u) => u.userId !== userId) });
if (result?.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了');
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else {
appStore.toastSuccess('已表达喜欢');
}
} catch (err: unknown) {
appStore.toastError(err instanceof Error ? err.message : '操作失败');
} finally {
this.setData({ actionLoadingUserId: '' });
}
},
onPullDownRefresh() {
this.fetchDiscover(true).finally(() => wx.stopPullDownRefresh());
},
onReachBottom() {
this.loadMore();
},
});
+131
View File
@@ -0,0 +1,131 @@
<!-- 找对象页 U19-U20(重构:ui-user-card + matchStore + recommendationService.discover) -->
<view class="page">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input">
<text class="search-icon">🔍</text>
<input
class="search-field"
placeholder="搜索符合条件的对象"
placeholder-class="search-placeholder"
value="{{keywordInput}}"
bindinput="onKeywordInput"
confirm-type="search"
bindconfirm="onSearch"
/>
</view>
<view class="filter-btn {{showFilter ? 'active' : ''}} {{hasActiveFilter ? 'has-filter' : ''}}" bindtap="toggleFilter">
<text>筛选</text>
<view wx:if="{{hasActiveFilter}}" class="filter-dot"></view>
</view>
</view>
<!-- 筛选面板 -->
<view wx:if="{{showFilter}}" class="filter-panel">
<view class="filter-section">
<text class="filter-label">年龄范围</text>
<view class="range-row">
<text class="range-value">{{tempAgeMin}} 岁</text>
<slider
class="range-slider"
min="{{18}}"
max="{{60}}"
value="{{tempAgeMin}}"
activeColor="{{'#E96B6B'}}"
backgroundColor="{{'#F0F0F0'}}"
block-size="{{20}}"
bindchange="onMinAgeChange"
/>
<text class="range-value">{{tempAgeMax}} 岁</text>
</view>
<slider
class="range-slider"
min="{{18}}"
max="{{60}}"
value="{{tempAgeMax}}"
activeColor="{{'#E96B6B'}}"
backgroundColor="{{'#F0F0F0'}}"
block-size="{{20}}"
bindchange="onMaxAgeChange"
/>
</view>
<view class="filter-section">
<text class="filter-label">地区</text>
<view class="tag-group">
<view
wx:for="{{regions}}"
wx:key="*this"
class="tag {{tempRegion === item ? 'selected' : ''}}"
bindtap="onRegionTap"
data-val="{{item}}"
>{{item}}</view>
</view>
</view>
<view class="filter-actions">
<ui-button variant="ghost" size="md" block bind:tap="onResetFilter">重置</ui-button>
<ui-button variant="primary" size="md" block bind:tap="onApplyFilter">应用筛选</ui-button>
</view>
</view>
<!-- 区块标题 -->
<ui-section-header
wx:if="{{users.length > 0 || loading}}"
title="找对象"
subtitle="{{hasActiveFilter ? '已应用筛选' : '共 ' + users.length + ' 位'}}"
/>
<!-- 首次加载(骨架屏) -->
<ui-loading
wx:if="{{loading && users.length === 0}}"
mode="skeleton"
rows="5"
/>
<!-- 用户列表 -->
<block wx:elif="{{users.length > 0}}">
<view class="user-list">
<ui-user-card
wx:for="{{users}}"
wx:key="userId"
user="{{item}}"
bind:tap="onViewDetail"
>
<view
slot="actions"
class="card-action"
catchtap="onLike"
data-user-id="{{item.userId}}"
>
<ui-button
variant="primary"
size="sm"
loading="{{actionLoadingUserId === item.userId}}"
>喜欢</ui-button>
</view>
</ui-user-card>
<!-- 加载更多 -->
<ui-loading
wx:if="{{loadingMore}}"
mode="spinner"
inline
text="加载更多…"
/>
<view wx:if="{{!hasMore && users.length > 0}}" class="list-end-hint">
<text>没有更多了</text>
</view>
</view>
</block>
<!-- 空状态 -->
<ui-empty
wx:elif="{{!loading}}"
illustration="search"
title="暂无符合条件的人选"
description="尝试调整筛选条件,或看看今天为你推荐的人。"
actions="{{emptyActions}}"
bind:action="onEmptyAction"
/>
</view>
+157
View File
@@ -0,0 +1,157 @@
/**
* 找对象页样式(重构后大幅简化,用户卡片主体样式封装在 ui-user-card 内)
*/
.page {
min-height: 100vh;
background: var(--color-bg-page);
}
/* ============ 搜索栏 ============ */
.search-bar {
display: flex;
align-items: center;
padding: var(--spacing-3) var(--spacing-page-x);
background: var(--color-bg-card);
gap: var(--spacing-2);
border-bottom: var(--border-width) solid var(--color-divider);
}
.search-input {
flex: 1;
height: 72rpx;
background: var(--color-bg-page);
border-radius: var(--radius-full);
display: flex;
align-items: center;
padding: 0 var(--spacing-3);
gap: var(--spacing-2);
}
.search-icon {
font-size: var(--font-size-md);
color: var(--color-text-placeholder);
}
.search-field {
flex: 1;
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.search-placeholder {
color: var(--color-text-placeholder);
font-size: var(--font-size-md);
}
.filter-btn {
min-width: 120rpx;
height: 72rpx;
background: var(--color-primary-light);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
color: var(--color-primary);
position: relative;
padding: 0 var(--spacing-3);
}
.filter-btn.active {
background: var(--color-primary);
color: #FFFFFF;
}
.filter-dot {
width: 12rpx;
height: 12rpx;
background: var(--color-primary);
border-radius: 50%;
position: absolute;
top: 12rpx;
right: 12rpx;
}
.filter-btn.active .filter-dot {
background: #FFFFFF;
}
/* ============ 筛选面板 ============ */
.filter-panel {
background: var(--color-bg-card);
padding: var(--spacing-4) var(--spacing-page-x);
border-bottom: var(--border-width) solid var(--color-divider);
}
.filter-section {
margin-bottom: var(--spacing-4);
}
.filter-label {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
display: block;
margin-bottom: var(--spacing-2);
}
.range-row {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.range-value {
min-width: 96rpx;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-align: center;
}
.range-slider {
flex: 1;
}
.tag-group {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.tag {
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
background: var(--color-bg-page);
color: var(--color-text-secondary);
border: 2rpx solid transparent;
}
.tag.selected {
background: var(--color-primary);
color: #FFFFFF;
}
.filter-actions {
display: flex;
gap: var(--spacing-3);
margin-top: var(--spacing-2);
}
/* ============ 用户列表 ============ */
.user-list {
background: var(--color-bg-card);
margin-top: var(--spacing-2);
}
.card-action {
display: flex;
align-items: center;
}
.list-end-hint {
text-align: center;
padding: var(--spacing-3);
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}