修复主页显示问题

This commit is contained in:
taiyi 2026-04-17 21:28:56 +08:00
parent 3108a7b903
commit 5c64931308
15 changed files with 511 additions and 178 deletions

View File

@ -98,30 +98,32 @@ onMounted(loadAdmins)
<el-button type="primary" :loading="submitting" @click="createAdmin">创建管理员</el-button> <el-button type="primary" :loading="submitting" @click="createAdmin">创建管理员</el-button>
</div> </div>
<div class="panel"> <div class="panel table-panel">
<div class="toolbar"> <div class="toolbar">
<h2>管理员列表</h2> <h2>管理员列表</h2>
<el-button @click="loadAdmins">刷新</el-button> <el-button @click="loadAdmins">刷新</el-button>
</div> </div>
<el-table :data="admins" v-loading="loading" border> <div class="table-wrap">
<el-table-column prop="id" label="ID" width="80" /> <el-table :data="admins" v-loading="loading" border table-layout="auto" :fit="true">
<el-table-column prop="username" label="用户名" min-width="140" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="roleText" label="角色" width="140" /> <el-table-column prop="username" label="用户名" min-width="160" />
<el-table-column prop="statusText" label="状态" width="100" /> <el-table-column prop="roleText" label="角色" width="140" />
<el-table-column prop="last_login" label="最后登录" min-width="180" /> <el-table-column prop="statusText" label="状态" width="100" />
<el-table-column prop="created_at" label="创建时间" min-width="180" /> <el-table-column prop="last_login" label="最后登录" min-width="180" />
<el-table-column label="操作" width="220" fixed="right"> <el-table-column prop="created_at" label="创建时间" min-width="180" />
<template #default="scope"> <el-table-column label="操作" width="240" fixed="right">
<div class="row-actions"> <template #default="scope">
<el-button size="small" @click="resetPassword(scope.row)">重置密码</el-button> <div class="row-actions">
<el-button size="small" type="warning" @click="toggleStatus(scope.row)"> <el-button size="small" @click="resetPassword(scope.row)">重置密码</el-button>
{{ scope.row.is_active === 1 ? '停用' : '启用' }} <el-button size="small" type="warning" @click="toggleStatus(scope.row)">
</el-button> {{ scope.row.is_active === 1 ? '停用' : '启用' }}
</div> </el-button>
</template> </div>
</el-table-column> </template>
</el-table> </el-table-column>
</el-table>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -134,11 +136,13 @@ onMounted(loadAdmins)
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 320px 1fr; grid-template-columns: minmax(320px, 360px) minmax(0, 1fr);
gap: 24px; gap: 20px;
align-items: start;
} }
.panel { .panel {
min-width: 0;
padding: 24px; padding: 24px;
border-radius: 20px; border-radius: 20px;
background: #ffffff; background: #ffffff;
@ -151,19 +155,30 @@ onMounted(loadAdmins)
gap: 16px; gap: 16px;
} }
.table-panel {
min-width: 0;
}
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.table-wrap {
width: 100%;
overflow-x: auto;
}
.row-actions { .row-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
@media (max-width: 1100px) { @media (max-width: 1200px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -171,13 +186,12 @@ onMounted(loadAdmins)
@media (max-width: 720px) { @media (max-width: 720px) {
.page { .page {
padding: 20px; padding: 16px;
} }
.toolbar { .toolbar {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 12px;
} }
} }
</style> </style>

View File

@ -29,8 +29,11 @@ class MatchLikeResponse(BaseModel):
class MyMatchItem(BaseModel): class MyMatchItem(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
match_id: int match_id: int | str
matched_at: str matched_at: str
match_status: str
match_status_text: str
fail_reason: str | None = None
other_user: dict other_user: dict

View File

@ -360,7 +360,7 @@ async def like_user(
async def list_my_matches(session: AsyncSession, current_user: User) -> list[dict]: async def list_my_matches(session: AsyncSession, current_user: User) -> list[dict]:
other_user = aliased(User) other_user = aliased(User)
stmt = ( match_stmt = (
select(Match, other_user) select(Match, other_user)
.join( .join(
other_user, other_user,
@ -371,17 +371,30 @@ async def list_my_matches(session: AsyncSession, current_user: User) -> list[dic
) )
.where(or_(Match.user_a_id == current_user.id, Match.user_b_id == current_user.id)) .where(or_(Match.user_a_id == current_user.id, Match.user_b_id == current_user.id))
) )
result = await session.execute(stmt) match_result = await session.execute(match_stmt)
rows = result.all() match_rows = match_result.all()
like_stmt = (
select(MatchLike, other_user)
.join(other_user, other_user.id == MatchLike.to_user_id)
.where(MatchLike.from_user_id == current_user.id)
)
like_result = await session.execute(like_stmt)
like_rows = like_result.all()
items: list[dict] = [] items: list[dict] = []
for match, joined_user in rows: matched_user_ids: set[int] = set()
for match, joined_user in match_rows:
if joined_user.id == current_user.id: if joined_user.id == current_user.id:
continue continue
matched_user_ids.add(joined_user.id)
items.append( items.append(
{ {
"match_id": match.id, "match_id": match.id,
"matched_at": match.matched_at.isoformat(), "matched_at": match.matched_at.isoformat(),
"match_status": "success",
"match_status_text": "匹配成功",
"fail_reason": None,
"other_user": { "other_user": {
"user_id": joined_user.id, "user_id": joined_user.id,
"nickname": joined_user.nickname, "nickname": joined_user.nickname,
@ -392,6 +405,30 @@ async def list_my_matches(session: AsyncSession, current_user: User) -> list[dic
}, },
} }
) )
for like, joined_user in like_rows:
if joined_user.id == current_user.id or joined_user.id in matched_user_ids:
continue
reverse_like = await _has_liked(session, joined_user.id, current_user.id)
items.append(
{
"match_id": f"like-{like.id}",
"matched_at": like.created_at.isoformat(),
"match_status": "success" if reverse_like else "pending",
"match_status_text": "匹配成功" if reverse_like else "待回应",
"fail_reason": None if reverse_like else "等待对方回应",
"other_user": {
"user_id": joined_user.id,
"nickname": joined_user.nickname,
"avatar_url": joined_user.avatar_url,
"education": joined_user.education,
"city": joined_user.city,
"hobbies": joined_user.hobbies,
},
}
)
items.sort(key=lambda item: item["matched_at"], reverse=True)
return items return items

View File

@ -14,7 +14,7 @@ from app.models.user import User
ALLOWED_IMAGE_TYPES = {"jpeg", "png"} ALLOWED_IMAGE_TYPES = {"jpeg", "png"}
UPLOAD_ROOT = Path(__file__).resolve().parents[2] / "static" / "uploads" UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "static" / "uploads"
def _validate_image(content: bytes) -> str: def _validate_image(content: bytes) -> str:

View File

@ -38,13 +38,26 @@ Page({
applyHomeData(homeData) { applyHomeData(homeData) {
const { activities = [], announcements = [], members = [] } = homeData || {} const { activities = [], announcements = [], members = [] } = homeData || {}
const safeActivities = activities.slice(0, 5).map((item) => ({
...item,
id: item.id != null ? String(item.id) : '',
title: item.title || '未命名活动',
start_time: item.start_time || '时间待定',
cover_image: item.cover_image || '',
remainText: this.getRemainText(item)
}))
const safeAnnouncements = announcements.slice(0, 3).map((item) => ({
...item,
id: item.id != null ? String(item.id) : ''
}))
const safeMembers = members.slice(0, 6).map((item) => this.normalizeMember(item))
this.setData({ this.setData({
activities, activities: safeActivities,
announcements, announcements: safeAnnouncements,
members, members: safeMembers,
featuredMembers: members.slice(0, 3), featuredMembers: safeMembers.slice(0, 3),
heroAnnouncementText: announcements.length ? announcements[0].title : '暂无公告', heroAnnouncementText: safeAnnouncements.length ? safeAnnouncements[0].title : '暂无公告',
heroAnnouncementId: announcements.length ? announcements[0].id : null heroAnnouncementId: safeAnnouncements.length ? safeAnnouncements[0].id : null
}) })
}, },
@ -72,19 +85,9 @@ Page({
const safeAnnouncements = Array.isArray(announcements) ? announcements : [] const safeAnnouncements = Array.isArray(announcements) ? announcements : []
const safeMembers = Array.isArray(members) ? members : [] const safeMembers = Array.isArray(members) ? members : []
const nextData = { const nextData = {
activities: safeActivities.map((item) => ({ activities: safeActivities.slice(0, 5),
...item,
remainText: this.getRemainText(item)
})),
announcements: safeAnnouncements, announcements: safeAnnouncements,
members: safeMembers.slice(0, 6).map((item) => ({ members: safeMembers.slice(0, 6).map((item) => this.normalizeMember(item))
...item,
nicknameText: item.nickname || '匿名用户',
cityText: item.city || '城市未知',
personalityTagsText: (Array.isArray(item.personality_tags) ? item.personality_tags : []).slice(0, 3).join(' / ') || '性格待完善',
introText: item.self_intro || '对方还没有填写更多公开信息',
birthYearRangeText: item.birth_year_range || '年龄未知'
}))
} }
this.applyHomeData(nextData) this.applyHomeData(nextData)
this.saveCachedHomeData(nextData) this.saveCachedHomeData(nextData)
@ -98,6 +101,23 @@ Page({
} }
}, },
normalizeMember(item) {
const member = item || {}
const personalityTags = Array.isArray(member.personality_tags) ? member.personality_tags : []
const userId = member.user_id ?? member.id ?? member.member_id
return {
...member,
user_id: userId != null ? String(userId) : '',
nicknameText: member.nickname || member.name || '匿名用户',
cityText: member.city || member.location || '城市未知',
personality_tags: personalityTags,
personalityTagsText: personalityTags.slice(0, 3).join(' / ') || '性格待完善',
introText: member.self_intro || member.intro || '对方还没有填写更多公开信息',
birthYearRangeText: member.birth_year_range || member.birth_year || '年龄未知',
avatar_blur_url: member.avatar_blur_url || member.avatar_url || ''
}
},
getRemainText(item) { getRemainText(item) {
const maleRemain = Math.max((item.capacity_male || 0) - (item.registered_male || 0), 0) const maleRemain = Math.max((item.capacity_male || 0) - (item.registered_male || 0), 0)
const femaleRemain = Math.max((item.capacity_female || 0) - (item.registered_female || 0), 0) const femaleRemain = Math.max((item.capacity_female || 0) - (item.registered_female || 0), 0)
@ -117,11 +137,11 @@ Page({
}, },
openMemberCard(e) { openMemberCard(e) {
const userId = e.currentTarget.dataset.id const userId = e.currentTarget.dataset.id != null ? String(e.currentTarget.dataset.id) : ''
const activeMember = this.data.members.find((item) => item.user_id === userId) || null const activeMember = this.data.members.find((item) => String(item.user_id) === userId) || null
this.setData({ this.setData({
activeMember, activeMember,
activeMemberTagsText: activeMember && activeMember.personality_tags ? activeMember.personality_tags.join(' / ') : '' activeMemberTagsText: activeMember && Array.isArray(activeMember.personality_tags) ? activeMember.personality_tags.join(' / ') : ''
}) })
}, },

View File

@ -1,17 +1,19 @@
<view class="page home-page"> <view class="page home-page">
<swiper class="banner-swiper" indicator-dots="{{activities.length > 1}}" autoplay="{{activities.length > 1}}" circular="{{activities.length > 1}}" interval="4000"> <swiper class="banner-swiper" indicator-dots="{{activities.length > 1}}" autoplay="{{activities.length > 1}}" circular="{{activities.length > 1}}" interval="4000">
<swiper-item wx:for="{{activities}}" wx:key="id"> <block wx:for="{{activities}}" wx:for-item="activity" wx:key="id">
<view class="banner-card" data-id="{{item.id}}" bindtap="goActivityDetail"> <swiper-item>
<image wx:if="{{item.cover_image}}" class="banner-image" src="{{item.cover_image}}" mode="aspectFill" /> <view class="banner-card" data-id="{{activity.id}}" bindtap="goActivityDetail">
<view wx:else class="banner-image banner-image-placeholder">活动</view> <image wx:if="{{activity.cover_image}}" class="banner-image" src="{{activity.cover_image}}" mode="aspectFill" />
<view class="banner-overlay"> <view wx:else class="banner-image banner-image-placeholder">活动</view>
<view class="banner-tag">热度活动</view> <view class="banner-overlay">
<view class="banner-title">{{item.title}}</view> <view class="banner-tag">热度活动</view>
<view class="banner-meta">{{item.start_time}}</view> <view class="banner-title">{{activity.title}}</view>
<view class="banner-remain">{{item.remainText}}</view> <view class="banner-meta">{{activity.start_time}}</view>
<view class="banner-remain">{{activity.remainText}}</view>
</view>
</view> </view>
</view> </swiper-item>
</swiper-item> </block>
</swiper> </swiper>
<view class="entry-grid"> <view class="entry-grid">
@ -38,19 +40,24 @@
<view class="section-title">优质推荐</view> <view class="section-title">优质推荐</view>
<text class="section-tip" bindtap="goMatch">更多推荐</text> <text class="section-tip" bindtap="goMatch">更多推荐</text>
</view> </view>
<swiper class="member-swiper" indicator-dots="{{featuredMembers.length > 1}}" autoplay="{{featuredMembers.length > 1}}" circular="{{featuredMembers.length > 1}}" interval="4500"> <view wx:if="{{featuredMembers && featuredMembers.length}}">
<swiper-item wx:for="{{featuredMembers}}" wx:key="user_id"> <swiper class="member-swiper" indicator-dots="{{featuredMembers.length > 1}}" autoplay="{{featuredMembers.length > 1}}" circular="{{featuredMembers.length > 1}}" interval="4500">
<view class="featured-member" data-id="{{item.user_id}}" bindtap="openMemberCard"> <block wx:for="{{featuredMembers}}" wx:for-item="member" wx:key="user_id">
<image wx:if="{{item.avatar_blur_url}}" class="featured-member-image" src="{{item.avatar_blur_url}}" mode="aspectFill" /> <swiper-item>
<view wx:else class="featured-member-image featured-member-placeholder">用户</view> <view wx:if="{{member && member.user_id}}" class="featured-member" data-id="{{member.user_id}}" bindtap="openMemberCard">
<view class="featured-member-overlay"> <image wx:if="{{member.avatar_blur_url}}" class="featured-member-image" src="{{member.avatar_blur_url}}" mode="aspectFill" />
<view class="featured-member-name">{{item.nicknameText}}</view> <view wx:else class="featured-member-image featured-member-placeholder">用户</view>
<view class="featured-member-meta">{{item.cityText}}</view> <view class="featured-member-overlay">
<view class="featured-member-intro">{{item.introText}}</view> <view class="featured-member-name">{{member.nicknameText}}</view>
</view> <view class="featured-member-meta">{{member.cityText}}</view>
</view> <view class="featured-member-intro">{{member.introText}}</view>
</swiper-item> </view>
</swiper> </view>
</swiper-item>
</block>
</swiper>
</view>
<view wx:else class="featured-empty">暂无优质推荐</view>
</view> </view>
<view wx:if="{{activeMember}}" class="member-mask" bindtap="closeMemberCard"> <view wx:if="{{activeMember}}" class="member-mask" bindtap="closeMemberCard">

View File

@ -165,22 +165,28 @@
} }
.member-swiper { .member-swiper {
flex: 1; width: 100%;
min-height: 420rpx; height: 520rpx;
margin-bottom: 0; margin-bottom: 0;
} }
.member-swiper swiper-item {
width: 100%;
height: 520rpx;
}
.featured-member { .featured-member {
position: relative; position: relative;
height: 100%; width: 100%;
min-height: 420rpx; height: 520rpx;
overflow: hidden; overflow: hidden;
border-radius: 28rpx; border-radius: 28rpx;
} }
.featured-member-image { .featured-member-image {
display: block;
width: 100%; width: 100%;
height: 100%; height: 520rpx;
background: #f0f0f0; background: #f0f0f0;
} }
@ -299,7 +305,22 @@
@media (max-height: 700px) { @media (max-height: 700px) {
.member-swiper, .member-swiper,
.featured-member { .member-swiper swiper-item,
min-height: 360rpx; .featured-member,
.featured-member-image {
height: 420rpx;
}
}
@media (max-width: 380px) {
.section-card-fill {
min-height: 0;
}
.member-swiper,
.member-swiper swiper-item,
.featured-member,
.featured-member-image {
height: 420rpx;
} }
} }

View File

@ -1,5 +1,60 @@
const request = require('../../utils/request') const request = require('../../utils/request')
const DEMO_MATCH_DETAILS = {
'demo-success-001': {
matched_at: '2026-04-16 19:25',
other_user: {
nickname: '林晓晴',
city: '上海',
education: '硕士',
hobbies: ['摄影', '咖啡', '旅行'],
job_industry: '互联网',
job_company: '星云科技',
income_range: '20k-30k',
self_intro: '喜欢用镜头记录生活,周末常去城市周边拍照或找一家安静的咖啡馆。'
}
},
'demo-success-002': {
matched_at: '2026-04-15 21:10',
other_user: {
nickname: '周子然',
city: '杭州',
education: '本科',
hobbies: ['跑步', '桌游'],
job_industry: '设计',
job_company: '原点工作室',
income_range: '15k-20k',
self_intro: '白天做视觉设计,晚上会去夜跑。喜欢轻松但有想法的交流。'
}
},
'demo-failed-001': {
matched_at: '2026-04-14 18:42',
other_user: {
nickname: '陈予安',
city: '深圳',
education: '博士',
hobbies: ['音乐', '滑雪'],
job_industry: '医疗',
job_company: '南山医院',
income_range: '30k以上',
self_intro: '工作日主要在医院,周末喜欢听现场音乐和去滑雪场放松。'
}
},
'demo-failed-002': {
matched_at: '2026-04-13 12:08',
other_user: {
nickname: '许静宜',
city: '成都',
education: '本科',
hobbies: ['烘焙', '读书'],
job_industry: '教育',
job_company: '启航学院',
income_range: '10k-15k',
self_intro: '喜欢研究甜品配方,也会在周末去图书馆看书。'
}
}
}
Page({ Page({
data: { data: {
loading: true, loading: true,
@ -8,7 +63,7 @@ Page({
}, },
onLoad(options) { onLoad(options) {
const matchId = options.match_id ? Number(options.match_id) : null const matchId = options.match_id ? String(options.match_id) : null
this.setData({ matchId }) this.setData({ matchId })
if (!matchId) { if (!matchId) {
wx.showToast({ title: '缺少匹配信息', icon: 'none' }) wx.showToast({ title: '缺少匹配信息', icon: 'none' })
@ -18,9 +73,17 @@ Page({
this.loadDetail() this.loadDetail()
}, },
getDemoDetail(matchId) {
return DEMO_MATCH_DETAILS[matchId] || null
},
async loadDetail() { async loadDetail() {
try { try {
const detail = await request({ url: `/matches/${this.data.matchId}/detail` }) const isDemoMatch = String(this.data.matchId).startsWith('demo-')
const detail = isDemoMatch ? this.getDemoDetail(this.data.matchId) : await request({ url: `/matches/${this.data.matchId}/detail` })
if (!detail) {
throw new Error('暂无详情数据')
}
const otherUser = detail.other_user || {} const otherUser = detail.other_user || {}
this.setData({ this.setData({
userInfo: { userInfo: {

View File

@ -5,6 +5,8 @@ const GENDER_MAP = {
2: 'female' 2: 'female'
} }
const MATCH_STORAGE_KEY = 'my_matches_records'
Page({ Page({
data: { data: {
source: 'manual', source: 'manual',
@ -195,6 +197,40 @@ Page({
}) })
}, },
getMatchRecordsFromStorage() {
return wx.getStorageSync(MATCH_STORAGE_KEY) || []
},
saveMatchRecord(record) {
const records = this.getMatchRecordsFromStorage()
const existingIndex = records.findIndex((item) => item.match_id === record.match_id)
const nextRecords = existingIndex >= 0 ? records.map((item, index) => (index === existingIndex ? record : item)) : [record, ...records]
wx.setStorageSync(MATCH_STORAGE_KEY, nextRecords)
},
syncMatchRecord(candidate, status, extra = {}) {
const matchId = String(candidate.user_id)
const baseRecord = {
match_id: matchId,
matched_at: new Date().toLocaleString().replaceAll('/', '-').slice(0, 16),
match_status: status,
match_status_text: status === 'success' ? '匹配成功' : '匹配失败',
fail_reason: extra.fail_reason || '',
other_user: {
nickname: candidate.nickname,
city: candidate.city,
education: candidate.education,
hobbies: candidate.personality_tags || [],
job_industry: candidate.job_industry,
job_company: candidate.job_company,
income_range: candidate.income_range,
self_intro: candidate.self_intro,
avatar_url: candidate.avatar_url || ''
}
}
this.saveMatchRecord(baseRecord)
},
openCard(e) { openCard(e) {
const candidate = this.getCurrentCandidate() || this.data.candidates.find((item) => item.user_id === e.currentTarget.dataset.id) const candidate = this.getCurrentCandidate() || this.data.candidates.find((item) => item.user_id === e.currentTarget.dataset.id)
this.setData({ this.setData({
@ -353,11 +389,13 @@ Page({
}) })
if (result.is_mutual) { if (result.is_mutual) {
this.syncMatchRecord(currentCandidate, 'success')
this.setData({ this.setData({
mutualMatchVisible: true, mutualMatchVisible: true,
mutualMatchName: result.match_info.nickname || '对方' mutualMatchName: result.match_info.nickname || '对方'
}) })
} else { } else {
this.syncMatchRecord(currentCandidate, 'failed', { fail_reason: '对方暂未回应喜欢' })
wx.showToast({ title: '已发送喜欢', icon: 'success' }) wx.showToast({ title: '已发送喜欢', icon: 'success' })
} }

View File

@ -1,5 +1,7 @@
const request = require('../../utils/request') const request = require('../../utils/request')
const MATCH_STORAGE_KEY = 'my_matches_records'
Page({ Page({
data: { data: {
matches: [] matches: []
@ -9,35 +11,79 @@ Page({
this.loadMatches() this.loadMatches()
}, },
normalizeMatch(item) {
const otherUser = item.other_user || {}
const hobbies = Array.isArray(otherUser.hobbies) ? otherUser.hobbies : []
const matchStatus = item.match_status || (item.fail_reason ? 'pending' : 'success')
const statusMap = {
success: { text: '匹配成功', statusClass: 'success-badge', itemClass: 'success-item' },
pending: { text: '待回应', statusClass: 'pending-badge', itemClass: 'pending-item' },
failed: { text: '匹配失败', statusClass: 'failed-badge', itemClass: 'failed-item' }
}
const statusConfig = statusMap[matchStatus] || statusMap.pending
return {
...item,
match_status: matchStatus,
match_status_text: item.match_status_text || statusConfig.text,
match_status_class: statusConfig.statusClass,
match_item_class: statusConfig.itemClass,
other_user: {
...otherUser,
nicknameText: otherUser.nickname ? otherUser.nickname : '匿名用户',
cityText: otherUser.city ? otherUser.city : '城市未知',
educationText: otherUser.education ? otherUser.education : '学历未知',
hobbiesText: hobbies.length ? hobbies.join('、') : '未填写爱好',
hobbies_text: hobbies.length ? hobbies.join('、') : ''
}
}
},
getStorageMatches() {
return wx.getStorageSync(MATCH_STORAGE_KEY) || []
},
mergeMatches(records, remoteMatches) {
const localRecords = Array.isArray(records) ? records : []
const remoteRecords = Array.isArray(remoteMatches) ? remoteMatches : []
const merged = [...remoteRecords]
localRecords.forEach((record) => {
const index = merged.findIndex((item) => item.match_id === record.match_id)
if (index >= 0) {
merged[index] = { ...merged[index], ...record }
} else {
merged.unshift(record)
}
})
return merged
},
async loadMatches() { async loadMatches() {
try { try {
const matches = await request({ url: '/matches/my' }) const remoteMatches = await request({ url: '/matches/my' })
const safeMatches = Array.isArray(matches) ? matches : [] const storedMatches = this.getStorageMatches()
this.setData({ const mergedMatches = this.mergeMatches(storedMatches, remoteMatches)
matches: safeMatches.map((item) => ({ const normalizedMatches = mergedMatches.length ? mergedMatches.map((item) => this.normalizeMatch(item)) : []
...item, this.setData({ matches: normalizedMatches })
other_user: { if (!remoteMatches || !remoteMatches.length) {
...(item.other_user || {}), wx.setStorageSync(MATCH_STORAGE_KEY, mergedMatches)
nicknameText: item.other_user && item.other_user.nickname ? item.other_user.nickname : '匿名用户', }
cityText: item.other_user && item.other_user.city ? item.other_user.city : '城市未知',
educationText: item.other_user && item.other_user.education ? item.other_user.education : '学历未知',
hobbiesText: item.other_user && Array.isArray(item.other_user.hobbies) ? item.other_user.hobbies.join('、') : '未填写爱好',
hobbies_text: item.other_user && Array.isArray(item.other_user.hobbies) ? item.other_user.hobbies.join('、') : ''
}
}))
})
} catch (err) { } catch (err) {
console.error('Load my matches failed:', err) console.error('Load my matches failed:', err)
wx.showToast({ title: '我的匹配加载失败', icon: 'none' }) const storedMatches = this.getStorageMatches()
this.setData({ matches: [] }) this.setData({ matches: storedMatches.map((item) => this.normalizeMatch(item)) })
} }
}, },
viewDetail(e) { viewDetail(e) {
const matchId = e.currentTarget.dataset.matchId const matchId = e.currentTarget.dataset.matchId
const status = e.currentTarget.dataset.status
if (!matchId) { if (!matchId) {
return return
} }
if (status === 'pending') {
wx.showToast({ title: '待回应记录暂不可查看详情', icon: 'none' })
return
}
wx.navigateTo({ wx.navigateTo({
url: `/pages/match-detail/match-detail?match_id=${matchId}` url: `/pages/match-detail/match-detail?match_id=${matchId}`
}) })

View File

@ -1,15 +1,17 @@
<view class="page matches-page"> <view class="page matches-page">
<view wx:if="{{matches.length}}"> <view wx:if="{{matches.length}}">
<view wx:for="{{matches}}" wx:key="match_id" class="match-item" bindtap="viewDetail" data-match-id="{{item.match_id}}"> <view wx:for="{{matches}}" wx:key="match_id" class="match-item {{item.match_item_class}}" bindtap="viewDetail" data-match-id="{{item.match_id}}" data-status="{{item.match_status}}">
<image wx:if="{{item.other_user.avatar_url}}" class="avatar" src="{{item.other_user.avatar_url}}" mode="aspectFill" /> <image wx:if="{{item.other_user.avatar_url}}" class="avatar" src="{{item.other_user.avatar_url}}" mode="aspectFill" />
<view wx:else class="avatar avatar-placeholder">用户</view> <view wx:else class="avatar avatar-placeholder">用户</view>
<view class="content"> <view class="content">
<view class="name-row"> <view class="name-row">
<view class="name">{{item.other_user.nicknameText}}</view> <view class="name">{{item.other_user.nicknameText}}</view>
<view class="detail-link">查看信息</view> <view class="status-badge {{item.match_status_class}}">{{item.match_status_text}}</view>
</view> </view>
<view class="meta">{{item.other_user.cityText}} · {{item.other_user.educationText}}</view> <view class="meta">{{item.other_user.cityText}} · {{item.other_user.educationText}}</view>
<view class="meta">{{item.other_user.hobbiesText}}</view> <view class="meta">{{item.other_user.hobbiesText}}</view>
<view wx:if="{{item.match_status === 'pending'}}" class="pending-reason">状态说明:{{item.fail_reason}}</view>
<view wx:elif="{{item.fail_reason}}" class="fail-reason">失败原因:{{item.fail_reason}}</view>
<view class="time">匹配时间:{{item.matched_at}}</view> <view class="time">匹配时间:{{item.matched_at}}</view>
</view> </view>
</view> </view>

View File

@ -2,14 +2,44 @@
padding: 24rpx; padding: 24rpx;
} }
.section {
margin-bottom: 32rpx;
}
.section-title {
margin: 0 0 16rpx;
font-size: 28rpx;
font-weight: 600;
}
.success-title {
color: #1f8a4c;
}
.failed-title {
color: #d64545;
}
.match-item { .match-item {
display: flex; display: flex;
padding: 24rpx; padding: 24rpx;
margin-bottom: 24rpx; margin-bottom: 20rpx;
background: #ffffff; background: #ffffff;
border-radius: 24rpx; border-radius: 24rpx;
} }
.success-item {
border-left: 8rpx solid #1f8a4c;
}
.pending-item {
border-left: 8rpx solid #d18b00;
}
.failed-item {
border-left: 8rpx solid #d64545;
}
.avatar { .avatar {
width: 120rpx; width: 120rpx;
height: 120rpx; height: 120rpx;
@ -41,17 +71,48 @@
font-weight: 600; font-weight: 600;
} }
.detail-link { .status-badge {
font-size: 24rpx; padding: 6rpx 14rpx;
color: #E8593C; border-radius: 999rpx;
font-size: 22rpx;
}
.success-badge {
color: #1f8a4c;
background: #e6f6ed;
}
.pending-badge {
color: #d18b00;
background: #fff4db;
}
.failed-badge {
color: #d64545;
background: #fdecec;
} }
.meta, .meta,
.time { .time,
.fail-reason,
.pending-reason {
color: #666666; color: #666666;
margin-bottom: 8rpx; margin-bottom: 8rpx;
} }
.fail-reason {
color: #d64545;
}
.pending-reason {
color: #d18b00;
}
.inline-empty {
padding: 24rpx 0 8rpx;
text-align: left;
}
.empty-state { .empty-state {
padding: 80rpx 24rpx; padding: 80rpx 24rpx;
text-align: center; text-align: center;

View File

@ -9,12 +9,39 @@ function addCacheVersion(url, version) {
return `${url}${joiner}v=${encodeURIComponent(version)}` return `${url}${joiner}v=${encodeURIComponent(version)}`
} }
function getProfileCache() { function isLocalDevUrl(url) {
return wx.getStorageSync('profile_page_cache') || null return /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?\//i.test(url)
} }
function setProfileCache(userInfo) { function upgradeToHttpsIfNeeded(url) {
wx.setStorageSync('profile_page_cache', userInfo) if (/^http:\/\//i.test(url) && !isLocalDevUrl(url)) {
return url.replace(/^http:\/\//i, 'https://')
}
return url
}
function resolveImageUrl(url) {
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return upgradeToHttpsIfNeeded(url)
}
const baseUrl = getApiBaseUrl().replace(/\/api\/v1\/?$/, '')
const resolved = url.startsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/${url}`
return upgradeToHttpsIfNeeded(resolved)
}
function normalizeAvatarState(userInfo) {
const avatarUrl = resolveImageUrl(userInfo.avatar_url || '')
const avatarBlurUrl = resolveImageUrl(userInfo.avatar_blur_url || '')
return {
...userInfo,
avatar_url: avatarUrl,
avatar_blur_url: avatarBlurUrl
}
} }
const AUDIT_REQUIRED_FIELDS = ['personality_tags', 'hobbies', 'wechat_id', 'phone'] const AUDIT_REQUIRED_FIELDS = ['personality_tags', 'hobbies', 'wechat_id', 'phone']
@ -39,7 +66,7 @@ function cloneProfileCache() {
} }
function saveProfileCache(userInfo) { function saveProfileCache(userInfo) {
wx.setStorageSync('profile_page_cache', userInfo) wx.setStorageSync('profile_page_cache', normalizeAvatarState(userInfo))
} }
Page({ Page({
@ -126,7 +153,8 @@ Page({
hobbies: data.hobbies || [], hobbies: data.hobbies || [],
wechat_id: data.wechat_id || '', wechat_id: data.wechat_id || '',
phone: data.phone || '', phone: data.phone || '',
avatar_url: data.avatar_url || '' avatar_url: resolveImageUrl(data.avatar_url || ''),
avatar_blur_url: resolveImageUrl(data.avatar_blur_url || '')
} }
const avatarVersion = Date.now().toString() const avatarVersion = Date.now().toString()
this.setData({ this.setData({
@ -266,52 +294,6 @@ Page({
return labels[field] || field return labels[field] || field
}, },
validateProfilePayload(payload) {
const errors = []
const checkTextLength = (field, maxLength) => {
const value = payload[field]
if (typeof value === 'string' && value.length > maxLength) {
errors.push(`${this.getValidationLabel(field)}不能超过${maxLength}个字符`)
}
}
checkTextLength('nickname', PROFILE_LIMITS.nickname)
checkTextLength('real_name', PROFILE_LIMITS.real_name)
checkTextLength('city', PROFILE_LIMITS.city)
checkTextLength('job_industry', PROFILE_LIMITS.job_industry)
checkTextLength('job_company', PROFILE_LIMITS.job_company)
checkTextLength('prefer_city', PROFILE_LIMITS.prefer_city)
checkTextLength('self_intro', PROFILE_LIMITS.self_intro)
checkTextLength('wechat_id', PROFILE_LIMITS.wechat_id)
checkTextLength('phone', PROFILE_LIMITS.phone)
;['personality_tags', 'hobbies'].forEach((field) => {
const list = payload[field]
const label = this.getValidationLabel(field)
if (!Array.isArray(list)) {
errors.push(`${label}格式不正确`)
return
}
if (list.length > PROFILE_LIMITS.listItems) {
errors.push(`${label}最多${PROFILE_LIMITS.listItems}`)
}
list.forEach((item) => {
if (item.length > PROFILE_LIMITS.tagItem) {
errors.push(`${label}单项不能超过${PROFILE_LIMITS.tagItem}个字符`)
}
})
})
;['gender', 'birth_year', 'height', 'education', 'income_range'].forEach((field) => {
const value = payload[field]
if (value !== null && value !== undefined && !Number.isFinite(value)) {
errors.push(`${this.getValidationLabel(field)}格式不正确`)
}
})
return errors
},
chooseAvatar() { chooseAvatar() {
wx.chooseMedia({ wx.chooseMedia({
count: 1, count: 1,
@ -353,20 +335,20 @@ Page({
throw new Error(body.message || '上传失败') throw new Error(body.message || '上传失败')
} }
const rawAvatarUrl = body.data.avatar_url || '' const rawAvatarUrl = body.data.avatar_url || ''
const avatarUrl = rawAvatarUrl.startsWith('http') const rawAvatarBlurUrl = body.data.avatar_blur_url || ''
? rawAvatarUrl const avatarUrl = resolveImageUrl(rawAvatarUrl)
: `${getApiBaseUrl()}${rawAvatarUrl}` const avatarBlurUrl = resolveImageUrl(rawAvatarBlurUrl)
const avatarVersion = String(Date.now()) const avatarVersion = String(Date.now())
const form = { ...this.data.form, avatar_url: avatarUrl } const form = { ...this.data.form, avatar_url: avatarUrl, avatar_blur_url: avatarBlurUrl }
const cachedProfile = cloneProfileCache() || {} const cachedProfile = cloneProfileCache() || {}
const nextProfile = { const nextProfile = normalizeAvatarState({
...cachedProfile, ...cachedProfile,
...form, ...form,
avatar_url: avatarUrl, avatar_url: avatarUrl,
avatar_blur_url: body.data.avatar_blur_url || cachedProfile.avatar_blur_url || '', avatar_blur_url: avatarBlurUrl,
nicknameText: form.nickname || cachedProfile.nicknameText || '未设置昵称', nicknameText: form.nickname || cachedProfile.nicknameText || '未设置昵称',
auditStatusText: cachedProfile.auditStatusText || '待审核' auditStatusText: cachedProfile.auditStatusText || '待审核'
} })
saveProfileCache(nextProfile) saveProfileCache(nextProfile)
this.setData({ form, avatarUrl: addCacheVersion(avatarUrl, avatarVersion), avatarVersion }) this.setData({ form, avatarUrl: addCacheVersion(avatarUrl, avatarVersion), avatarVersion })
const pages = getCurrentPages() const pages = getCurrentPages()
@ -458,16 +440,16 @@ Page({
const nextForm = { ...this.data.form, ...payload } const nextForm = { ...this.data.form, ...payload }
const avatarVersion = this.data.avatarVersion || Date.now().toString() const avatarVersion = this.data.avatarVersion || Date.now().toString()
const cachedProfile = cloneProfileCache() || {} const cachedProfile = cloneProfileCache() || {}
const nextProfile = { const nextProfile = normalizeAvatarState({
...cachedProfile, ...cachedProfile,
...nextForm, ...nextForm,
avatar_url: nextForm.avatar_url || cachedProfile.avatar_url || '', avatar_url: nextForm.avatar_url || cachedProfile.avatar_url || '',
avatar_blur_url: nextForm.avatar_blur_url || cachedProfile.avatar_blur_url || '' avatar_blur_url: nextForm.avatar_blur_url || cachedProfile.avatar_blur_url || ''
} })
saveProfileCache(nextProfile) saveProfileCache(nextProfile)
this.setData({ this.setData({
form: nextForm, form: nextForm,
avatarUrl: addCacheVersion(nextForm.avatar_url || '', avatarVersion), avatarUrl: addCacheVersion(nextProfile.avatar_url || '', avatarVersion),
avatarVersion avatarVersion
}) })
const pages = getCurrentPages() const pages = getCurrentPages()

View File

@ -1,4 +1,5 @@
const request = require('../../utils/request') const request = require('../../utils/request')
const { getApiBaseUrl } = require('../../utils/constants')
function addCacheVersion(url, version) { function addCacheVersion(url, version) {
if (!url) { if (!url) {
@ -8,6 +9,38 @@ function addCacheVersion(url, version) {
return `${url}${joiner}v=${encodeURIComponent(version)}` return `${url}${joiner}v=${encodeURIComponent(version)}`
} }
function isLocalDevUrl(url) {
return /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?\//i.test(url)
}
function upgradeToHttpsIfNeeded(url) {
if (/^http:\/\//i.test(url) && !isLocalDevUrl(url)) {
return url.replace(/^http:\/\//i, 'https://')
}
return url
}
function resolveImageUrl(url) {
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return upgradeToHttpsIfNeeded(url)
}
const baseUrl = getApiBaseUrl().replace(/\/api\/v1\/?$/, '')
const resolved = url.startsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/${url}`
return upgradeToHttpsIfNeeded(resolved)
}
function normalizeAvatarState(userInfo) {
return {
...userInfo,
avatar_url: resolveImageUrl(userInfo.avatar_url || ''),
avatar_blur_url: resolveImageUrl(userInfo.avatar_blur_url || '')
}
}
Page({ Page({
data: { data: {
loading: false, loading: false,
@ -32,7 +65,7 @@ Page({
try { try {
const userInfo = await request({ url: '/users/me' }) const userInfo = await request({ url: '/users/me' })
this.applyUserInfo(userInfo) this.applyUserInfo(userInfo)
wx.setStorageSync('profile_page_cache', userInfo) wx.setStorageSync('profile_page_cache', normalizeAvatarState(userInfo))
} catch (err) { } catch (err) {
console.error('Load profile failed:', err) console.error('Load profile failed:', err)
if (!this.data.userInfo) { if (!this.data.userInfo) {
@ -47,13 +80,14 @@ Page({
applyUserInfo(userInfo) { applyUserInfo(userInfo) {
const avatarVersion = Date.now().toString() const avatarVersion = Date.now().toString()
const normalized = normalizeAvatarState(userInfo)
this.setData({ this.setData({
userInfo: { userInfo: {
...userInfo, ...normalized,
avatar_url: addCacheVersion(userInfo.avatar_url || '', avatarVersion), avatar_url: addCacheVersion(normalized.avatar_url, avatarVersion),
avatar_blur_url: addCacheVersion(userInfo.avatar_blur_url || '', avatarVersion), avatar_blur_url: addCacheVersion(normalized.avatar_blur_url, avatarVersion),
nicknameText: userInfo.nickname || '未设置昵称', nicknameText: normalized.nickname || '未设置昵称',
auditStatusText: this.data.auditTextMap[userInfo.audit_status] || '未知' auditStatusText: this.data.auditTextMap[normalized.audit_status] || '未知'
} }
}) })
}, },

View File

@ -1,3 +1,8 @@
{ {
"rules": [] "rules": [
} {
"action": "allow",
"page": "*"
}
]
}