Compare commits

...

2 Commits

37 changed files with 2047 additions and 193 deletions

View File

@ -90,3 +90,40 @@ export function getButlerQrcode() {
export function setButlerQrcode(imageUrl: string) {
return request.post('/admin/config/butlerQrcode', { imageUrl })
}
/**
*
*/
export function getMemberIcon() {
return request.get('/admin/config/memberIcon')
}
/**
*
*/
export function setMemberIcon(imageUrl: string) {
return request.post('/admin/config/memberIcon', { imageUrl })
}
/**
*
*/
export interface MemberIconsConfig {
unlimitedMemberIcon?: string
sincereMemberIcon?: string
familyMemberIcon?: string
}
/**
*
*/
export function getMemberIcons() {
return request.get('/admin/config/memberIcons')
}
/**
*
*/
export function setMemberIcons(icons: MemberIconsConfig) {
return request.post('/admin/config/memberIcons', icons)
}

View File

@ -47,6 +47,37 @@ const linkTypeOptions = [
{ label: '小程序', value: 3 }
]
//
const internalPageOptions = [
{ label: '首页', value: '/pages/index/index' },
{ label: '搜索页', value: '/pages/search/index' },
{ label: '消息列表', value: '/pages/message/index' },
{ label: '我的页面', value: '/pages/mine/index' },
{ label: '会员中心', value: '/pages/member/index' },
{ label: '填写资料', value: '/pages/profile/edit' },
{ label: '实名认证', value: '/pages/realname/index' },
{ label: '我的收藏', value: '/pages/favorite/index' },
{ label: '浏览记录', value: '/pages/history/index' },
{ label: '设置页面', value: '/pages/settings/index' },
{ label: '关于我们', value: '/pages/about/index' },
{ label: '用户协议', value: '/pages/agreement/user' },
{ label: '隐私政策', value: '/pages/agreement/privacy' }
]
//
const getLinkTypeTip = (linkType: number) => {
switch (linkType) {
case 1:
return '选择小程序内部页面'
case 2:
return '请输入完整的网页地址https://example.com'
case 3:
return '请输入小程序AppID和路径格式appId:path'
default:
return ''
}
}
//
const dialogVisible = ref(false)
const dialogTitle = ref('添加Banner')
@ -529,6 +560,7 @@ onMounted(() => {
v-model="formData.linkType"
placeholder="请选择链接类型"
style="width: 100%"
@change="formData.linkUrl = ''"
>
<el-option
v-for="item in linkTypeOptions"
@ -539,10 +571,28 @@ onMounted(() => {
</el-select>
</el-form-item>
<el-form-item label="链接地址">
<el-input
<el-select
v-if="formData.linkType === 1"
v-model="formData.linkUrl"
placeholder="请输入链接地址"
placeholder="请选择内部页面"
style="width: 100%"
filterable
>
<el-option
v-for="item in internalPageOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-else
v-model="formData.linkUrl"
:placeholder="getLinkTypeTip(formData.linkType)"
/>
<div class="form-tip">
{{ getLinkTypeTip(formData.linkType) }}
</div>
</el-form-item>
<el-form-item
label="排序"

View File

@ -47,6 +47,37 @@ const linkTypeOptions = [
{ label: '小程序', value: 3 }
]
//
const internalPageOptions = [
{ label: '首页', value: '/pages/index/index' },
{ label: '搜索页', value: '/pages/search/index' },
{ label: '消息列表', value: '/pages/message/index' },
{ label: '我的页面', value: '/pages/mine/index' },
{ label: '会员中心', value: '/pages/member/index' },
{ label: '填写资料', value: '/pages/profile/edit' },
{ label: '实名认证', value: '/pages/realname/index' },
{ label: '我的收藏', value: '/pages/favorite/index' },
{ label: '浏览记录', value: '/pages/history/index' },
{ label: '设置页面', value: '/pages/settings/index' },
{ label: '关于我们', value: '/pages/about/index' },
{ label: '用户协议', value: '/pages/agreement/user' },
{ label: '隐私政策', value: '/pages/agreement/privacy' }
]
//
const getLinkTypeTip = (linkType: number) => {
switch (linkType) {
case 1:
return '选择小程序内部页面'
case 2:
return '请输入完整的网页地址https://example.com'
case 3:
return '请输入小程序AppID和路径格式appId:path'
default:
return ''
}
}
//
const dialogVisible = ref(false)
const dialogTitle = ref('添加金刚位')
@ -488,6 +519,7 @@ onMounted(() => {
v-model="formData.linkType"
placeholder="请选择链接类型"
style="width: 100%"
@change="formData.linkUrl = ''"
>
<el-option
v-for="item in linkTypeOptions"
@ -498,10 +530,28 @@ onMounted(() => {
</el-select>
</el-form-item>
<el-form-item label="链接地址">
<el-input
<el-select
v-if="formData.linkType === 1"
v-model="formData.linkUrl"
placeholder="请输入链接地址"
placeholder="请选择内部页面"
style="width: 100%"
filterable
>
<el-option
v-for="item in internalPageOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-else
v-model="formData.linkUrl"
:placeholder="getLinkTypeTip(formData.linkType)"
/>
<div class="form-tip">
{{ getLinkTypeTip(formData.linkType) }}
</div>
</el-form-item>
<el-form-item
label="排序"

View File

@ -116,12 +116,12 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="现价(元)" prop="price">
<el-input-number v-model="formData.price" :min="0" :precision="0" :controls="false" style="width: 100%" placeholder="1299" />
<el-input-number v-model="formData.price" :min="0" :precision="2" :step="0.01" :controls="false" style="width: 100%" placeholder="1299" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="原价(元)" prop="originalPrice">
<el-input-number v-model="formData.originalPrice" :min="0" :precision="0" :controls="false" style="width: 100%" placeholder="1899" />
<el-input-number v-model="formData.originalPrice" :min="0" :precision="2" :step="0.01" :controls="false" style="width: 100%" placeholder="1899" />
</el-form-item>
</el-col>
</el-row>

View File

@ -80,6 +80,73 @@
</div>
</el-form-item>
<!-- 会员图标设置 -->
<el-form-item label="不限时会员图标">
<div class="icon-upload">
<el-upload
class="icon-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUnlimitedMemberIconSuccess"
:before-upload="beforeAvatarUpload"
accept="image/*"
>
<img v-if="configForm.unlimitedMemberIcon" :src="getFullUrl(configForm.unlimitedMemberIcon)" class="icon-preview" />
<el-icon v-else class="icon-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="avatar-tip">
<p>建议尺寸64x64像素</p>
<p>支持格式PNG透明背景</p>
<p>不限时会员用户显示的图标</p>
</div>
</div>
</el-form-item>
<el-form-item label="诚意会员图标">
<div class="icon-upload">
<el-upload
class="icon-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleSincereMemberIconSuccess"
:before-upload="beforeAvatarUpload"
accept="image/*"
>
<img v-if="configForm.sincereMemberIcon" :src="getFullUrl(configForm.sincereMemberIcon)" class="icon-preview" />
<el-icon v-else class="icon-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="avatar-tip">
<p>建议尺寸64x64像素</p>
<p>支持格式PNG透明背景</p>
<p>诚意会员用户显示的图标</p>
</div>
</div>
</el-form-item>
<el-form-item label="家庭版会员图标">
<div class="icon-upload">
<el-upload
class="icon-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleFamilyMemberIconSuccess"
:before-upload="beforeAvatarUpload"
accept="image/*"
>
<img v-if="configForm.familyMemberIcon" :src="getFullUrl(configForm.familyMemberIcon)" class="icon-preview" />
<el-icon v-else class="icon-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="avatar-tip">
<p>建议尺寸64x64像素</p>
<p>支持格式PNG透明背景</p>
<p>家庭版会员用户显示的图标</p>
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveBasicConfig" :loading="saving">
保存配置
@ -144,7 +211,9 @@ import {
getSearchBanner,
setSearchBanner,
getButlerQrcode,
setButlerQrcode
setButlerQrcode,
getMemberIcons,
setMemberIcons
} from '@/api/config'
import { useUserStore } from '@/stores/user'
@ -157,7 +226,10 @@ const activeTab = ref('basic')
const configForm = ref({
defaultAvatar: '',
searchBanner: '',
butlerQrcode: ''
butlerQrcode: '',
unlimitedMemberIcon: '',
sincereMemberIcon: '',
familyMemberIcon: ''
})
const agreementForm = ref({
@ -183,10 +255,11 @@ const getFullUrl = (url) => {
const loadConfig = async () => {
try {
const [avatarRes, bannerRes, qrcodeRes] = await Promise.all([
const [avatarRes, bannerRes, qrcodeRes, memberIconsRes] = await Promise.all([
getDefaultAvatar(),
getSearchBanner(),
getButlerQrcode()
getButlerQrcode(),
getMemberIcons()
])
if (avatarRes) {
configForm.value.defaultAvatar = avatarRes.avatarUrl || ''
@ -197,6 +270,11 @@ const loadConfig = async () => {
if (qrcodeRes) {
configForm.value.butlerQrcode = qrcodeRes.imageUrl || ''
}
if (memberIconsRes) {
configForm.value.unlimitedMemberIcon = memberIconsRes.unlimitedMemberIcon || ''
configForm.value.sincereMemberIcon = memberIconsRes.sincereMemberIcon || ''
configForm.value.familyMemberIcon = memberIconsRes.familyMemberIcon || ''
}
} catch (error) {
console.error('加载配置失败:', error)
}
@ -248,6 +326,33 @@ const handleQrcodeSuccess = (response) => {
}
}
const handleUnlimitedMemberIconSuccess = (response) => {
if (response.code === 0 && response.data) {
configForm.value.unlimitedMemberIcon = response.data.url
ElMessage.success('上传成功')
} else {
ElMessage.error(response.message || '上传失败')
}
}
const handleSincereMemberIconSuccess = (response) => {
if (response.code === 0 && response.data) {
configForm.value.sincereMemberIcon = response.data.url
ElMessage.success('上传成功')
} else {
ElMessage.error(response.message || '上传失败')
}
}
const handleFamilyMemberIconSuccess = (response) => {
if (response.code === 0 && response.data) {
configForm.value.familyMemberIcon = response.data.url
ElMessage.success('上传成功')
} else {
ElMessage.error(response.message || '上传失败')
}
}
const beforeAvatarUpload = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
@ -276,6 +381,15 @@ const saveBasicConfig = async () => {
if (configForm.value.butlerQrcode) {
promises.push(setButlerQrcode(configForm.value.butlerQrcode))
}
//
const memberIcons = {
unlimitedMemberIcon: configForm.value.unlimitedMemberIcon || undefined,
sincereMemberIcon: configForm.value.sincereMemberIcon || undefined,
familyMemberIcon: configForm.value.familyMemberIcon || undefined
}
if (memberIcons.unlimitedMemberIcon || memberIcons.sincereMemberIcon || memberIcons.familyMemberIcon) {
promises.push(setMemberIcons(memberIcons))
}
if (promises.length > 0) {
await Promise.all(promises)
ElMessage.success('保存成功')
@ -484,6 +598,47 @@ onMounted(() => {
object-fit: cover;
}
.icon-upload {
display: flex;
align-items: flex-start;
gap: 20px;
}
.icon-uploader {
width: 80px;
height: 80px;
}
.icon-uploader :deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-uploader :deep(.el-upload:hover) {
border-color: var(--el-color-primary);
}
.icon-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.icon-preview {
width: 80px;
height: 80px;
display: block;
object-fit: contain;
}
.agreement-editor {
padding: 20px 0;
}

View File

@ -127,6 +127,17 @@ export async function getOrCreateSession(targetUserId) {
return response
}
/**
* 删除会话
*
* @param {number} sessionId - 会话ID
* @returns {Promise<Object>} 删除结果
*/
export async function deleteSession(sessionId) {
const response = await post('/chat/session/delete', { sessionId })
return response
}
/**
* 上传语音文件
*
@ -177,5 +188,6 @@ export default {
respondExchange,
getUnreadCount,
getOrCreateSession,
deleteSession,
uploadVoice
}

View File

@ -11,7 +11,8 @@
<view class="title-row">
<text class="gender-year">{{ genderText }} · {{ birthYear }}</text>
<view class="title-tags">
<text v-if="isMember" class="tag tag-member">会员</text>
<image v-if="isMember && memberIconUrl" class="member-icon" :src="memberIconUrl" mode="aspectFit" />
<text v-else-if="isMember" class="tag tag-member">会员</text>
<text v-if="isRealName" class="tag tag-realname">已实名</text>
</view>
</view>
@ -70,7 +71,8 @@
<view class="title-row">
<text class="gender-year">{{ genderText }} · {{ birthYear }}</text>
<view class="title-tags">
<text v-if="isMember" class="tag tag-member">会员</text>
<image v-if="isMember && memberIconUrl" class="member-icon" :src="memberIconUrl" mode="aspectFit" />
<text v-else-if="isMember" class="tag tag-member">会员</text>
<text v-if="isRealName" class="tag tag-realname">已实名</text>
</view>
</view>
@ -119,6 +121,7 @@
<script>
import { getFullImageUrl } from '@/utils/image.js'
import { useConfigStore } from '@/store/config.js'
export default {
name: 'UserCard',
@ -187,6 +190,10 @@ export default {
type: Boolean,
default: false
},
memberLevel: {
type: Number,
default: 0
},
isRealName: {
type: Boolean,
default: false
@ -221,6 +228,12 @@ export default {
if (this.monthlyIncome < 20000) return '1.5万~2万/月'
if (this.monthlyIncome < 30000) return '2万~3万/月'
return '3万以上/月'
},
memberIconUrl() {
if (!this.isMember || !this.memberLevel) return ''
const configStore = useConfigStore()
const iconUrl = configStore.getMemberIcon(this.memberLevel)
return iconUrl ? getFullImageUrl(iconUrl) : ''
}
},
methods: {
@ -279,6 +292,11 @@ export default {
align-items: center;
gap: 12rpx;
.member-icon {
width: 120rpx;
height: 44rpx;
}
.tag {
font-size: 22rpx;
padding: 6rpx 16rpx;

View File

@ -24,12 +24,7 @@
<!-- 会员广告条 - 固定在底部 -->
<view class="member-ad-section" v-if="showMemberAd">
<view class="member-ad-bar" :style="memberAdBgStyle">
<view class="ad-content" @click="handleMemberAdClick">
<text class="ad-icon">👑</text>
<text class="ad-text">{{ memberAdConfig?.title || '开通会员,解锁更多优质用户' }}</text>
</view>
<text class="ad-btn" @click="handleMemberAdClick">{{ memberAdConfig?.buttonText || '购买' }}</text>
<view class="member-ad-bar" :style="memberAdBgStyle" @click="handleMemberAdClick">
<view class="ad-close" @click.stop="handleCloseMemberAd">
<text>×</text>
</view>
@ -101,6 +96,7 @@
:height="user.height" :weight="user.weight" :education="user.education"
:educationName="user.educationName" :occupation="user.occupation"
:monthlyIncome="user.monthlyIncome" :intro="user.intro" :isMember="user.isMember"
:memberLevel="user.memberLevel"
:isRealName="user.isRealName" :isPhotoPublic="user.isPhotoPublic" :firstPhoto="user.firstPhoto"
:viewedToday="user.viewedToday" @click="handleUserClick" @contact="handleUserContact" />
</view>
@ -310,11 +306,29 @@
const handleBannerClick = (banner) => {
if (!banner.linkUrl) return
// tabbar
const tabbarPages = ['/pages/index/index', '/pages/message/index', '/pages/mine/index']
if (banner.linkType === 1) {
uni.navigateTo({
url: banner.linkUrl
})
//
if (tabbarPages.includes(banner.linkUrl)) {
uni.switchTab({
url: banner.linkUrl
})
} else {
uni.navigateTo({
url: banner.linkUrl,
fail: (err) => {
console.error('页面跳转失败:', err)
uni.showToast({
title: '页面跳转失败',
icon: 'none'
})
}
})
}
} else if (banner.linkType === 2) {
// -
uni.setClipboardData({
data: banner.linkUrl,
success: () => {
@ -324,6 +338,22 @@
})
}
})
} else if (banner.linkType === 3) {
//
const [appId, path] = banner.linkUrl.split(':')
if (appId) {
uni.navigateToMiniProgram({
appId: appId,
path: path || '',
fail: (err) => {
console.error('跳转小程序失败:', err)
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
}
}
@ -743,10 +773,12 @@
z-index: 99;
.member-ad-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
justify-content: center;
min-height: 110rpx;
padding: 0 24rpx;
.ad-content {
display: flex;
@ -776,6 +808,10 @@
}
.ad-close {
position: absolute;
right: 16rpx;
top: 50%;
transform: translateY(-50%);
width: 44rpx;
height: 44rpx;
display: flex;

View File

@ -74,27 +74,43 @@
<!-- 聊天会话列表 -->
<view class="session-list" v-if="sessions.length > 0">
<view class="session-item" v-for="session in sessions" :key="session.sessionId"
@click="handleSessionClick(session)">
<!-- 头像 -->
<view class="session-avatar">
<image class="avatar-img" :src="session.targetAvatar || defaultAvatar" mode="aspectFill" />
<!-- 未读徽章 -->
<view class="unread-badge" v-if="session.unreadCount > 0">
<text>{{ session.unreadCount > 99 ? '99+' : session.unreadCount }}</text>
<view
class="session-item-wrapper"
v-for="session in sessions"
:key="session.sessionId"
>
<view
class="session-item"
:style="{ transform: `translateX(${session.offsetX || 0}px)` }"
@touchstart="handleTouchStart($event, session)"
@touchmove="handleTouchMove($event, session)"
@touchend="handleTouchEnd($event, session)"
@click="handleSessionClick(session)"
>
<!-- 头像 -->
<view class="session-avatar">
<image class="avatar-img" :src="session.targetAvatar || defaultAvatar" mode="aspectFill" />
<!-- 未读徽章 -->
<view class="unread-badge" v-if="session.unreadCount > 0">
<text>{{ session.unreadCount > 99 ? '99+' : session.unreadCount }}</text>
</view>
</view>
<!-- 会话信息 -->
<view class="session-info">
<view class="session-header">
<text
class="session-nickname">{{ session.targetNickname }}{{ session.relationship ? `${session.relationship}` : '' }}</text>
<text class="session-time">{{ formatTime(session.lastMessageTime) }}</text>
</view>
<view class="session-content">
<text class="last-message">{{ session.lastMessage || '暂无消息' }}</text>
</view>
</view>
</view>
<!-- 会话信息 -->
<view class="session-info">
<view class="session-header">
<text
class="session-nickname">{{ session.targetNickname }}{{ session.relationship ? `${session.relationship}` : '' }}</text>
<text class="session-time">{{ formatTime(session.lastMessageTime) }}</text>
</view>
<view class="session-content">
<text class="last-message">{{ session.lastMessage || '暂无消息' }}</text>
</view>
<!-- 删除按钮 -->
<view class="delete-btn" @click.stop="handleDeleteSession(session)">
<text>删除</text>
</view>
</view>
</view>
@ -138,7 +154,8 @@
useConfigStore
} from '@/store/config.js'
import {
getSessions
getSessions,
deleteSession
} from '@/api/chat.js'
import {
getViewedMe,
@ -172,6 +189,10 @@
unlockedMe: 0
})
//
const touchStartX = ref(0)
const deleteWidth = 80 //
// configStore
const defaultAvatar = computed(() => configStore.defaultAvatar || '/static/logo.png')
@ -328,12 +349,81 @@
//
const handleSessionClick = (session) => {
//
if (session.offsetX && session.offsetX < 0) {
session.offsetX = 0
return
}
chatStore.setCurrentSession(session.sessionId)
uni.navigateTo({
url: `/pages/chat/index?sessionId=${session.sessionId}&targetUserId=${session.targetUserId}`
})
}
//
const handleTouchStart = (e, session) => {
touchStartX.value = e.touches[0].clientX
//
sessions.value.forEach(s => {
if (s.sessionId !== session.sessionId && s.offsetX) {
s.offsetX = 0
}
})
}
//
const handleTouchMove = (e, session) => {
const currentX = e.touches[0].clientX
const diff = currentX - touchStartX.value
//
if (diff < 0) {
session.offsetX = Math.max(diff, -deleteWidth)
} else if (session.offsetX < 0) {
session.offsetX = Math.min(0, session.offsetX + diff)
}
}
//
const handleTouchEnd = (e, session) => {
//
if (session.offsetX < -deleteWidth / 2) {
session.offsetX = -deleteWidth
} else {
session.offsetX = 0
}
}
//
const handleDeleteSession = async (session) => {
uni.showModal({
title: '提示',
content: '确定删除该聊天记录吗?',
success: async (res) => {
if (res.confirm) {
try {
const result = await deleteSession(session.sessionId)
if (result?.success || result?.code === 0) {
//
const index = sessions.value.findIndex(s => s.sessionId === session.sessionId)
if (index > -1) {
sessions.value.splice(index, 1)
}
// store
chatStore.setSessions(sessions.value)
uni.showToast({ title: '删除成功', icon: 'success' })
} else {
uni.showToast({ title: result?.message || '删除失败', icon: 'none' })
}
} catch (error) {
console.error('删除会话失败:', error)
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
//
const handleLogin = () => {
uni.navigateTo({
@ -564,11 +654,37 @@
//
.session-list {
.session-item-wrapper {
position: relative;
overflow: hidden;
.delete-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 160rpx;
background-color: #ff4d4f;
display: flex;
align-items: center;
justify-content: center;
text {
color: #fff;
font-size: 28rpx;
}
}
}
.session-item {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
background-color: #F3F3F3;
transition: transform 0.2s ease;
position: relative;
z-index: 1;
&:last-child {
border-bottom: none;

View File

@ -143,10 +143,7 @@
:src="photo.photoUrl"
mode="aspectFill"
/>
<view v-if="!userDetail.isPhotoPublic && !isUnlocked" class="photo-lock-overlay">
<text class="lock-icon">🔒</text>
<text class="lock-text">私密</text>
</view>
<view v-if="!userDetail.isPhotoPublic && !isUnlocked" class="photo-lock-overlay"></view>
</view>
</view>
</view>
@ -160,33 +157,41 @@
</view>
<!-- 孩子的择偶标准 -->
<view class="section-card" v-if="userDetail.requirement">
<view class="section-card">
<view class="section-title">孩子的择偶标准</view>
<view class="requirement-grid">
<view class="req-item" v-if="requirementAgeText">
<view class="req-item">
<text class="label">年龄范围</text>
<text class="value">{{ requirementAgeText }}</text>
</view>
<view class="req-item" v-if="requirementHeightText">
<view class="req-item">
<text class="label">身高要求</text>
<text class="value">{{ requirementHeightText }}</text>
</view>
<view class="req-item" v-if="requirementEducationText">
<view class="req-item">
<text class="label">学历要求</text>
<text class="value">{{ requirementEducationText }}</text>
</view>
<view class="req-item" v-if="requirementCityText">
<view class="req-item">
<text class="label">城市要求</text>
<text class="value">{{ requirementCityText }}</text>
</view>
<view class="req-item" v-if="requirementIncomeText">
<text class="label">收入范围</text>
<view class="req-item">
<text class="label">收入要求</text>
<text class="value">{{ requirementIncomeText }}</text>
</view>
</view>
<view class="req-extra" v-if="requirementExtraText">
<text class="label">其他</text>
<text class="value">{{ requirementExtraText }}</text>
<view class="req-item">
<text class="label">房产要求</text>
<text class="value">{{ requirementHouseText }}</text>
</view>
<view class="req-item">
<text class="label">车产要求</text>
<text class="value">{{ requirementCarText }}</text>
</view>
<view class="req-item">
<text class="label">婚姻要求</text>
<text class="value">{{ requirementMarriageText }}</text>
</view>
</view>
</view>
@ -427,60 +432,65 @@ const zodiacText = computed(() => {
return getZodiac(userDetail.value?.birthYear)
})
//
// - """"
const requirementAgeText = computed(() => {
const req = userDetail.value?.requirement
if (!req) return ''
if (!req) return '不限'
if (req.ageMin && req.ageMax) return `${req.ageMin}-${req.ageMax}`
if (req.ageMin) return `${req.ageMin}岁以上`
if (req.ageMax) return `${req.ageMax}岁以下`
return ''
return '不限'
})
const requirementHeightText = computed(() => {
const req = userDetail.value?.requirement
if (!req) return ''
if (!req) return '不限'
if (req.heightMin && req.heightMax) return `${req.heightMin}cm-${req.heightMax}cm`
if (req.heightMin) return `${req.heightMin}cm以上`
if (req.heightMax) return `${req.heightMax}cm以下`
return ''
return '不限'
})
const requirementEducationText = computed(() => {
const req = userDetail.value?.requirement
if (!req || !req.education || req.education.length === 0) return ''
if (!req || !req.education || req.education.length === 0) return ''
return req.education.map(e => educationMap[e]).filter(Boolean).join('、')
})
const requirementCityText = computed(() => {
const req = userDetail.value?.requirement
if (!req) return ''
if (!req) return ''
const cities = []
if (req.city1City) cities.push(req.city1City)
if (req.city2City) cities.push(req.city2City)
return cities.join('、') || ''
return cities.length > 0 ? cities.join('、') : '无'
})
const requirementIncomeText = computed(() => {
const req = userDetail.value?.requirement
if (!req || !req.monthlyIncomeMin) return ''
return incomeMap[req.monthlyIncomeMin] || ''
if (!req || !req.monthlyIncomeMin) return '不限'
return incomeMap[req.monthlyIncomeMin] || '不限'
})
const requirementExtraText = computed(() => {
//
const requirementHouseText = computed(() => {
const req = userDetail.value?.requirement
if (!req) return ''
const extras = []
if (req.houseStatus && req.houseStatus.length > 0) {
extras.push('房产要求: ' + req.houseStatus.map(h => houseMap[h]).filter(Boolean).join('、'))
}
if (req.carStatus && req.carStatus.length > 0) {
extras.push('车辆要求: ' + req.carStatus.map(c => carMap[c]).filter(Boolean).join('、'))
}
if (req.marriageStatus && req.marriageStatus.length > 0) {
extras.push('婚姻要求: ' + req.marriageStatus.map(m => marriageMap[m]).filter(Boolean).join('、'))
}
return extras.join('')
if (!req || !req.houseStatus || req.houseStatus.length === 0) return '无'
return req.houseStatus.map(h => houseMap[h]).filter(Boolean).join('、')
})
//
const requirementCarText = computed(() => {
const req = userDetail.value?.requirement
if (!req || !req.carStatus || req.carStatus.length === 0) return '无'
return req.carStatus.map(c => carMap[c]).filter(Boolean).join('、')
})
//
const requirementMarriageText = computed(() => {
const req = userDetail.value?.requirement
if (!req || !req.marriageStatus || req.marriageStatus.length === 0) return '无'
return req.marriageStatus.map(m => marriageMap[m]).filter(Boolean).join('、')
})
//
@ -494,8 +504,6 @@ const loadUserDetail = async () => {
isFavorited.value = res.data.isFavorited || false
isUnlocked.value = res.data.isUnlocked || false
remainingUnlockQuota.value = res.data.remainingUnlockQuota || 0
//
}
} catch (error) {
console.error('加载用户详情失败:', error)
@ -944,8 +952,8 @@ onShareAppMessage(() => {
height: 100%;
&.photo-blurred {
filter: blur(20px);
transform: scale(1.1);
filter: blur(6px);
transform: scale(1.02);
}
}
@ -955,22 +963,8 @@ onShareAppMessage(() => {
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
.lock-icon {
font-size: 40rpx;
margin-bottom: 8rpx;
}
.lock-text {
font-size: 24rpx;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(3px);
}
.photo-blur {

View File

@ -315,7 +315,7 @@
<!-- 期望结婚时间 -->
<view class="form-item">
<text class="form-label">期望结婚时间</text>
<text class="form-label required">期望结婚时间</text>
<picker
:value="expectMarryIndex"
:range="expectMarryOptions"
@ -422,7 +422,7 @@
<view class="card-title">意向城市</view>
<view class="form-item">
<text class="form-label">城市1</text>
<text class="form-label required">城市1</text>
<picker
mode="region"
:level="'city'"
@ -520,8 +520,108 @@
</view>
</view>
<!-- 步骤5: 联系方式 -->
<!-- 步骤5: 父母情况 -->
<view v-show="currentStep === 4" class="form-section">
<view class="section-title">父母情况</view>
<!-- 是否独居 -->
<view class="form-item">
<text class="form-label">孩子是否独居</text>
<view class="radio-group">
<view
class="radio-item"
:class="{ active: formData.isLivingAlone }"
@click="formData.isLivingAlone = true"
>
<text></text>
</view>
<view
class="radio-item"
:class="{ active: !formData.isLivingAlone }"
@click="formData.isLivingAlone = false"
>
<text></text>
</view>
</view>
</view>
<!-- 父母情况 -->
<view class="form-item">
<text class="form-label required">父母情况</text>
<picker
mode="selector"
:range="parentStatusOptions"
range-key="label"
@change="handleParentStatusChange"
>
<view class="picker-value">
{{ formData.parentStatus || '请选择' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<!-- 父母是否退休 -->
<view class="form-item">
<text class="form-label">父母是否退休</text>
<view class="radio-group">
<view
class="radio-item"
:class="{ active: formData.isParentRetired }"
@click="formData.isParentRetired = true"
>
<text>已退休</text>
</view>
<view
class="radio-item"
:class="{ active: !formData.isParentRetired }"
@click="formData.isParentRetired = false"
>
<text>未退休</text>
</view>
</view>
</view>
<!-- 父母现居城市 -->
<view class="form-item">
<text class="form-label">父母现居城市</text>
<picker
mode="region"
:level="'city'"
:value="[formData.parentProvince, formData.parentCity]"
@change="handleParentCityChange"
>
<view class="picker-value">
{{ parentCityText || '请选择' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<!-- 父母是否有房 -->
<view class="form-item">
<text class="form-label">父母是否有房</text>
<view class="radio-group">
<view
class="radio-item"
:class="{ active: formData.parentHasHouse }"
@click="formData.parentHasHouse = true"
>
<text></text>
</view>
<view
class="radio-item"
:class="{ active: !formData.parentHasHouse }"
@click="formData.parentHasHouse = false"
>
<text></text>
</view>
</view>
</view>
</view>
<!-- 步骤6: 联系方式 -->
<view v-show="currentStep === 5" class="form-section">
<view class="section-title">联系方式</view>
<!-- 微信号 -->
@ -628,8 +728,9 @@ const handleBack = () => {
const steps = [
{ label: '基础信息' },
{ label: '详细信息' },
{ label: '介绍' },
{ label: '要求' },
{ label: '孩子介绍' },
{ label: '择偶要求' },
{ label: '父母情况' },
{ label: '联系方式' }
]
@ -664,6 +765,13 @@ const formData = reactive({
introduction: '',
weChatNo: '',
phone: '', //
//
isLivingAlone: false, //
parentStatus: '', //
isParentRetired: false, // 退
parentProvince: '', //
parentCity: '', //
parentHasHouse: false, //
requirement: {
ageMin: 0,
ageMax: 0,
@ -733,6 +841,15 @@ const expectMarryOptions = [
{ value: 3, label: '孩子满意就结婚' }
]
//
const parentStatusOptions = [
{ value: '父母健在', label: '父母健在' },
{ value: '父亲去世', label: '父亲去世' },
{ value: '母亲去世', label: '母亲去世' },
{ value: '父母均已去世', label: '父母均已去世' },
{ value: '父母离异', label: '父母离异' }
]
// (Property 9: Birth Year Range)
const birthYearOptions = computed(() => getBirthYearRange())
@ -896,6 +1013,14 @@ const city2Text = computed(() => {
return ''
})
//
const parentCityText = computed(() => {
if (formData.parentProvince && formData.parentCity) {
return `${formData.parentProvince} ${formData.parentCity}`
}
return ''
})
// (Property 8: Nickname Auto-Generation)
watch(
() => [formData.relationship, formData.surname, formData.childGender],
@ -1006,6 +1131,18 @@ const handleCity2Change = (e) => {
formData.requirement.city2City = city
}
//
const handleParentStatusChange = (e) => {
formData.parentStatus = parentStatusOptions[e.detail.value].value
}
//
const handleParentCityChange = (e) => {
const [province, city] = e.detail.value
formData.parentProvince = province
formData.parentCity = city
}
//
const toggleEducationRequirement = (value) => {
const idx = formData.requirement.education.indexOf(value)
@ -1208,6 +1345,10 @@ const validateStep = (step) => {
uni.showToast({ title: '请选择婚姻状态', icon: 'none' })
return false
}
if (!formData.expectMarryTime) {
uni.showToast({ title: '请选择期望结婚时间', icon: 'none' })
return false
}
return true
case 2: //
@ -1218,10 +1359,21 @@ const validateStep = (step) => {
return true
case 3: //
//
// 1
if (!formData.requirement.city1Province || !formData.requirement.city1City) {
uni.showToast({ title: '请至少选择一个意向城市', icon: 'none' })
return false
}
return true
case 4: //
case 4: //
if (!formData.parentStatus) {
uni.showToast({ title: '请选择父母情况', icon: 'none' })
return false
}
return true
case 5: //
if (!formData.weChatNo) {
uni.showToast({ title: '请输入微信号', icon: 'none' })
return false
@ -1326,6 +1478,14 @@ const loadProfile = async () => {
formData.introduction = profile.introduction || ''
formData.weChatNo = profile.weChatNo || ''
//
formData.isLivingAlone = profile.isLivingAlone || false
formData.parentStatus = profile.parentStatus || ''
formData.isParentRetired = profile.isParentRetired || false
formData.parentProvince = profile.parentProvince || ''
formData.parentCity = profile.parentCity || ''
formData.parentHasHouse = profile.parentHasHouse || false
//
if (profile.phone) {
formData.phone = profile.phone

View File

@ -281,11 +281,11 @@
try {
const res = await get('/realname/status')
if (res && res.success && res.data) {
if (res.data.isVerified) {
if (res.data.isRealName) {
userStore.setRealNameStatus(true)
verificationResult.value = {
name: res.data.name,
idNumber: res.data.idNumber
name: res.data.name || res.data.maskedName,
idNumber: res.data.idNumber || res.data.maskedIdCard
}
} else if (res.data.isPaid) {
//
@ -471,34 +471,38 @@
}
/**
* 上传身份证照片
* 上传身份证照片进行实名认证
*/
const uploadIdCards = () => {
return new Promise((resolve, reject) => {
const token = getToken()
//
//
uni.uploadFile({
url: `${BASE_URL}/realname/verify`,
url: `${BASE_URL}/api/app/realname/verify`,
filePath: idCardFront.value,
name: 'frontImage',
formData: {
backImagePath: idCardBack.value
},
header: {
'Authorization': `Bearer ${token}`
},
success: async (uploadRes) => {
if (uploadRes.statusCode === 200) {
try {
const data = JSON.parse(uploadRes.data)
//
if (data.needBackImage) {
const backResult = await uploadBackImage(token)
resolve(backResult)
const response = JSON.parse(uploadRes.data)
// API
if (response.success || response.code === 0) {
const data = response.data
//
if (data.needBackImage) {
const backResult = await uploadBackImage(token)
resolve(backResult)
} else {
resolve(response)
}
} else {
resolve(data)
reject(new Error(response.message || '认证失败'))
}
} catch (e) {
reject(new Error('解析响应失败'))
@ -515,6 +519,7 @@
}
},
fail: (err) => {
console.error('上传失败:', err)
reject(new Error('网络连接失败'))
}
})
@ -527,7 +532,7 @@
const uploadBackImage = (token) => {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${BASE_URL}/realname/verify/back`,
url: `${BASE_URL}/api/app/realname/verify/back`,
filePath: idCardBack.value,
name: 'backImage',
header: {
@ -536,13 +541,23 @@
success: (uploadRes) => {
if (uploadRes.statusCode === 200) {
try {
const data = JSON.parse(uploadRes.data)
resolve(data)
const response = JSON.parse(uploadRes.data)
if (response.success || response.code === 0) {
resolve(response)
} else {
reject(new Error(response.message || '上传失败'))
}
} catch (e) {
reject(new Error('解析响应失败'))
}
} else {
reject(new Error('上传失败'))
try {
const errorData = JSON.parse(uploadRes.data)
reject(new Error(errorData.message || '上传失败'))
} catch (e) {
reject(new Error('上传失败'))
}
}
},
fail: () => {

View File

@ -137,12 +137,12 @@
</scroll-view>
<!-- 年龄选择弹窗 -->
<view class="picker-popup" v-if="showAgePopup" @click.self="showAgePopup = false">
<view class="picker-content">
<view class="picker-popup" v-if="showAgePopup" @click.self="showAgePopup = false" @touchmove.prevent>
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-cancel" @click="showAgePopup = false">取消</text>
<text class="picker-cancel" @click.stop="showAgePopup = false">取消</text>
<text class="picker-title">年龄要求</text>
<text class="picker-confirm" @click="confirmAge">确定</text>
<text class="picker-confirm" @click.stop="confirmAge">确定</text>
</view>
<view class="picker-body">
<picker-view :value="agePickerValue" @change="onAgeChange" class="picker-view">
@ -161,12 +161,12 @@
</view>
<!-- 身高选择弹窗 -->
<view class="picker-popup" v-if="showHeightPopup" @click.self="showHeightPopup = false">
<view class="picker-content">
<view class="picker-popup" v-if="showHeightPopup" @click.self="showHeightPopup = false" @touchmove.prevent>
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-cancel" @click="showHeightPopup = false">取消</text>
<text class="picker-cancel" @click.stop="showHeightPopup = false">取消</text>
<text class="picker-title">身高要求</text>
<text class="picker-confirm" @click="confirmHeight">确定</text>
<text class="picker-confirm" @click.stop="confirmHeight">确定</text>
</view>
<view class="picker-body">
<picker-view :value="heightPickerValue" @change="onHeightChange" class="picker-view">
@ -185,12 +185,12 @@
</view>
<!-- 收入选择弹窗 -->
<view class="picker-popup" v-if="showIncomePopup" @click.self="showIncomePopup = false">
<view class="picker-content">
<view class="picker-popup" v-if="showIncomePopup" @click.self="showIncomePopup = false" @touchmove.prevent>
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-cancel" @click="showIncomePopup = false">取消</text>
<text class="picker-cancel" @click.stop="showIncomePopup = false">取消</text>
<text class="picker-title">月收入要求</text>
<text class="picker-confirm" @click="confirmIncome">确定</text>
<text class="picker-confirm" @click.stop="confirmIncome">确定</text>
</view>
<view class="picker-body">
<picker-view :value="incomePickerValue" @change="onIncomeChange" class="picker-view">
@ -203,12 +203,12 @@
</view>
<!-- 房产选择弹窗 -->
<view class="picker-popup" v-if="showHousePopup" @click.self="showHousePopup = false">
<view class="picker-content">
<view class="picker-popup" v-if="showHousePopup" @click.self="showHousePopup = false" @touchmove.prevent>
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-cancel" @click="showHousePopup = false">取消</text>
<text class="picker-cancel" @click.stop="showHousePopup = false">取消</text>
<text class="picker-title">房产要求</text>
<text class="picker-confirm" @click="confirmHouse">确定</text>
<text class="picker-confirm" @click.stop="confirmHouse">确定</text>
</view>
<view class="picker-body">
<picker-view :value="housePickerValue" @change="onHouseChange" class="picker-view">
@ -221,12 +221,12 @@
</view>
<!-- 车产选择弹窗 -->
<view class="picker-popup" v-if="showCarPopup" @click.self="showCarPopup = false">
<view class="picker-content">
<view class="picker-popup" v-if="showCarPopup" @click.self="showCarPopup = false" @touchmove.prevent>
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-cancel" @click="showCarPopup = false">取消</text>
<text class="picker-cancel" @click.stop="showCarPopup = false">取消</text>
<text class="picker-title">车产要求</text>
<text class="picker-confirm" @click="confirmCar">确定</text>
<text class="picker-confirm" @click.stop="confirmCar">确定</text>
</view>
<view class="picker-body">
<picker-view :value="carPickerValue" @change="onCarChange" class="picker-view">
@ -239,12 +239,12 @@
</view>
<!-- 婚姻选择弹窗 -->
<view class="picker-popup" v-if="showMarriagePopup" @click.self="showMarriagePopup = false">
<view class="picker-content">
<view class="picker-popup" v-if="showMarriagePopup" @click.self="showMarriagePopup = false" @touchmove.prevent>
<view class="picker-content" @click.stop>
<view class="picker-header">
<text class="picker-cancel" @click="showMarriagePopup = false">取消</text>
<text class="picker-cancel" @click.stop="showMarriagePopup = false">取消</text>
<text class="picker-title">婚姻要求</text>
<text class="picker-confirm" @click="confirmMarriage">确定</text>
<text class="picker-confirm" @click.stop="confirmMarriage">确定</text>
</view>
<view class="picker-body">
<picker-view :value="marriagePickerValue" @change="onMarriageChange" class="picker-view">

View File

@ -24,32 +24,70 @@
<!-- 搜索条件标签 -->
<view class="search-tags">
<view class="tag" v-if="searchParams.ageMin || searchParams.ageMax">
年龄{{ searchParams.ageMin || 18 }}-{{ searchParams.ageMax || 60 }}
</view>
<view class="tag" v-if="searchParams.heightMin || searchParams.heightMax">
身高{{ searchParams.heightMin || 150 }}cm及以上
</view>
<view class="tag" v-if="searchParams.cities && searchParams.cities.length > 0">
地区{{ searchParams.cities[0] }}
</view>
<view class="tag" v-if="searchParams.carStatus">
{{ searchParams.carStatus === 1 ? '有车' : '车产不限' }}
</view>
<view class="tag" v-if="searchParams.houseStatus">
{{ searchParams.houseStatus === 1 ? '有房' : '房产不限' }}
</view>
<view class="tag" v-if="searchParams.marriageStatus">
{{ getMarriageText(searchParams.marriageStatus) }}
</view>
<view class="tag" v-if="searchParams.monthlyIncome">
{{ getIncomeText(searchParams.monthlyIncome) }}
</view>
<view class="tag" v-if="searchParams.education && searchParams.education.length > 0">
{{ getEducationText(searchParams.education[0]) }}
</view>
<template v-if="showAllTags">
<!-- 展开状态显示所有标签 -->
<view class="tag" v-if="searchParams.ageMin || searchParams.ageMax">
年龄{{ searchParams.ageMin || 18 }}-{{ searchParams.ageMax || 60 }}
</view>
<view class="tag" v-if="searchParams.heightMin || searchParams.heightMax">
身高{{ searchParams.heightMin || 150 }}cm及以上
</view>
<view class="tag" v-if="searchParams.cities && searchParams.cities.length > 0">
地区{{ searchParams.cities[0] }}
</view>
<view class="tag" v-if="searchParams.carStatus">
{{ getCarText(searchParams.carStatus) }}
</view>
<view class="tag" v-if="searchParams.houseStatus">
{{ getHouseText(searchParams.houseStatus) }}
</view>
<view class="tag" v-if="searchParams.marriageStatus">
{{ getMarriageText(searchParams.marriageStatus) }}
</view>
<view class="tag" v-if="searchParams.monthlyIncome">
{{ getIncomeText(searchParams.monthlyIncome) }}
</view>
<view class="tag" v-if="searchParams.education && searchParams.education.length > 0">
{{ getEducationText(searchParams.education[0]) }}
</view>
<!-- 更多城市 -->
<view class="tag" v-for="(city, index) in searchParams.cities?.slice(1)" :key="'city-' + index">
地区{{ city }}
</view>
<!-- 更多学历 -->
<view class="tag" v-for="(edu, index) in searchParams.education?.slice(1)" :key="'edu-' + index">
{{ getEducationText(edu) }}
</view>
</template>
<template v-else>
<!-- 收起状态只显示前6个标签 -->
<view class="tag" v-if="visibleTags.includes('age') && (searchParams.ageMin || searchParams.ageMax)">
年龄{{ searchParams.ageMin || 18 }}-{{ searchParams.ageMax || 60 }}
</view>
<view class="tag" v-if="visibleTags.includes('height') && (searchParams.heightMin || searchParams.heightMax)">
身高{{ searchParams.heightMin || 150 }}cm及以上
</view>
<view class="tag" v-if="visibleTags.includes('city') && searchParams.cities && searchParams.cities.length > 0">
地区{{ searchParams.cities[0] }}
</view>
<view class="tag" v-if="visibleTags.includes('car') && searchParams.carStatus">
{{ getCarText(searchParams.carStatus) }}
</view>
<view class="tag" v-if="visibleTags.includes('house') && searchParams.houseStatus">
{{ getHouseText(searchParams.houseStatus) }}
</view>
<view class="tag" v-if="visibleTags.includes('marriage') && searchParams.marriageStatus">
{{ getMarriageText(searchParams.marriageStatus) }}
</view>
<view class="tag" v-if="visibleTags.includes('income') && searchParams.monthlyIncome">
{{ getIncomeText(searchParams.monthlyIncome) }}
</view>
<view class="tag" v-if="visibleTags.includes('education') && searchParams.education && searchParams.education.length > 0">
{{ getEducationText(searchParams.education[0]) }}
</view>
</template>
<view class="tag more" v-if="hasMoreTags" @click="showAllTags = !showAllTags">
{{ showAllTags ? '收起' : '更多...' }}
{{ showAllTags ? '收起' : '更多... ' }}
</view>
</view>
</view>
@ -183,17 +221,34 @@ const searchDate = ref('')
//
const showAllTags = ref(false)
//
const allTags = computed(() => {
const tags = []
const sp = searchParams.value
if (sp.ageMin || sp.ageMax) tags.push('age')
if (sp.heightMin || sp.heightMax) tags.push('height')
if (sp.cities?.length > 0) tags.push('city')
if (sp.carStatus) tags.push('car')
if (sp.houseStatus) tags.push('house')
if (sp.marriageStatus) tags.push('marriage')
if (sp.monthlyIncome) tags.push('income')
if (sp.education?.length > 0) tags.push('education')
return tags
})
// 6
const visibleTags = computed(() => {
return allTags.value.slice(0, 6)
})
const hasMoreTags = computed(() => {
let count = 0
if (searchParams.value.ageMin || searchParams.value.ageMax) count++
if (searchParams.value.heightMin || searchParams.value.heightMax) count++
if (searchParams.value.cities?.length > 0) count++
if (searchParams.value.carStatus) count++
if (searchParams.value.houseStatus) count++
if (searchParams.value.marriageStatus) count++
if (searchParams.value.monthlyIncome) count++
if (searchParams.value.education?.length > 0) count++
return count > 6
const sp = searchParams.value
let totalCount = allTags.value.length
//
if (sp.cities?.length > 1) totalCount += sp.cities.length - 1
if (sp.education?.length > 1) totalCount += sp.education.length - 1
return totalCount > 6
})
//
@ -225,13 +280,30 @@ const incomeMap = {
const marriageMap = {
1: '未婚',
2: '离异',
3: '丧偶'
2: '离异未育',
3: '离异已育'
}
const houseMap = {
1: '现居地已购房',
2: '家乡已购房',
3: '婚后购房',
4: '父母同住',
5: '租房',
6: '近期有购房计划'
}
const carMap = {
1: '已购车',
2: '无车',
3: '近期购车'
}
const getEducationText = (value) => educationMap[value] || '学历不限'
const getIncomeText = (value) => incomeMap[value] || '收入不限'
const getMarriageText = (value) => marriageMap[value] || '婚史不限'
const getHouseText = (value) => houseMap[value] || '房产不限'
const getCarText = (value) => carMap[value] || '车产不限'
//
const getSystemInfo = () => {

View File

@ -35,6 +35,13 @@ export const useConfigStore = defineStore('config', {
searchBanner: '',
butlerQrcode: '', // 管家指导二维码
// 会员图标配置
memberIcons: {
unlimitedMemberIcon: '', // 不限时会员图标
sincereMemberIcon: '', // 诚意会员图标
familyMemberIcon: '' // 家庭版会员图标
},
// 弹窗配置
dailyPopup: null,
memberAdConfig: null,
@ -53,7 +60,25 @@ export const useConfigStore = defineStore('config', {
hasKingKongs: (state) => state.kingKongs.length > 0,
hasDailyPopup: (state) => state.dailyPopup !== null,
hasMemberAdConfig: (state) => state.memberAdConfig !== null && state.memberAdConfig.status === 1,
getDefaultAvatar: (state) => state.defaultAvatar || '/static/logo.png'
getDefaultAvatar: (state) => state.defaultAvatar || '/static/logo.png',
/**
* 根据会员等级获取对应的图标URL
* @param {number} memberLevel - 会员等级1不限时会员 2诚意会员 3家庭版会员
* @returns {string} 图标URL
*/
getMemberIcon: (state) => (memberLevel) => {
switch (memberLevel) {
case 1:
return state.memberIcons.unlimitedMemberIcon || ''
case 2:
return state.memberIcons.sincereMemberIcon || ''
case 3:
return state.memberIcons.familyMemberIcon || ''
default:
return ''
}
}
},
actions: {
@ -80,6 +105,15 @@ export const useConfigStore = defineStore('config', {
this.searchBanner = config.searchBanner || ''
this.butlerQrcode = config.butlerQrcode || ''
// 会员图标配置
if (config.memberIcons) {
this.memberIcons = {
unlimitedMemberIcon: config.memberIcons.unlimitedMemberIcon || '',
sincereMemberIcon: config.memberIcons.sincereMemberIcon || '',
familyMemberIcon: config.memberIcons.familyMemberIcon || ''
}
}
// 弹窗配置
this.dailyPopup = config.dailyPopup || null
this.memberAdConfig = config.memberAdPopup || null

View File

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using XiangYi.Application.DTOs.Responses;
using XiangYi.Application.Interfaces;
using XiangYi.Application.Services;
namespace XiangYi.AdminApi.Controllers;
@ -201,6 +202,54 @@ public class AdminConfigController : ControllerBase
var result = await _configService.SetButlerQrcodeAsync(request.ImageUrl);
return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败");
}
/// <summary>
/// 获取会员图标
/// </summary>
[HttpGet("memberIcon")]
public async Task<ApiResponse<MemberIconResponse>> GetMemberIcon()
{
var imageUrl = await _configService.GetMemberIconAsync();
return ApiResponse<MemberIconResponse>.Success(new MemberIconResponse
{
ImageUrl = imageUrl
});
}
/// <summary>
/// 设置会员图标
/// </summary>
[HttpPost("memberIcon")]
public async Task<ApiResponse> SetMemberIcon([FromBody] SetMemberIconRequest request)
{
if (string.IsNullOrWhiteSpace(request.ImageUrl))
{
return ApiResponse.Error(40001, "图片URL不能为空");
}
var result = await _configService.SetMemberIconAsync(request.ImageUrl);
return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败");
}
/// <summary>
/// 获取所有会员图标
/// </summary>
[HttpGet("memberIcons")]
public async Task<ApiResponse<MemberIconsDto>> GetMemberIcons()
{
var icons = await _configService.GetMemberIconsAsync();
return ApiResponse<MemberIconsDto>.Success(icons);
}
/// <summary>
/// 设置所有会员图标
/// </summary>
[HttpPost("memberIcons")]
public async Task<ApiResponse> SetMemberIcons([FromBody] MemberIconsDto request)
{
var result = await _configService.SetMemberIconsAsync(request);
return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败");
}
}
/// <summary>
@ -312,3 +361,25 @@ public class SetButlerQrcodeRequest
/// </summary>
public string ImageUrl { get; set; } = string.Empty;
}
/// <summary>
/// 会员图标响应
/// </summary>
public class MemberIconResponse
{
/// <summary>
/// 图片URL
/// </summary>
public string? ImageUrl { get; set; }
}
/// <summary>
/// 设置会员图标请求
/// </summary>
public class SetMemberIconRequest
{
/// <summary>
/// 图片URL
/// </summary>
public string ImageUrl { get; set; } = string.Empty;
}

View File

@ -317,6 +317,32 @@ public class ChatController : ControllerBase
return ApiResponse<long>.Success(sessionId);
}
/// <summary>
/// 删除会话
/// </summary>
/// <param name="request">删除会话请求</param>
/// <returns>删除结果</returns>
[HttpPost("session/delete")]
public async Task<ApiResponse> DeleteSession([FromBody] DeleteSessionRequest request)
{
if (request.SessionId <= 0)
{
return ApiResponse.Error(ErrorCodes.InvalidParameter, "会话ID无效");
}
var userId = GetCurrentUserId();
// 检查是否有权限访问会话
var canAccess = await _chatService.CanAccessSessionAsync(userId, request.SessionId);
if (!canAccess)
{
return ApiResponse.Error(ErrorCodes.Forbidden, "无权访问该会话");
}
await _chatService.DeleteSessionAsync(userId, request.SessionId);
return ApiResponse.Success("删除成功");
}
/// <summary>
/// 获取当前用户ID
/// </summary>
@ -326,3 +352,14 @@ public class ChatController : ControllerBase
return long.TryParse(userIdClaim, out var userId) ? userId : 0;
}
}
/// <summary>
/// 删除会话请求
/// </summary>
public class DeleteSessionRequest
{
/// <summary>
/// 会话ID
/// </summary>
public long SessionId { get; set; }
}

View File

@ -77,6 +77,112 @@ public class RealNameController : ControllerBase
return ApiResponse<RealNameSubmitResponse>.Success(result);
}
/// <summary>
/// 上传身份证图片进行实名认证(小程序使用)
/// </summary>
/// <param name="frontImage">身份证正面图片</param>
/// <param name="backImage">身份证反面图片(可选,可通过单独接口上传)</param>
/// <returns>认证结果</returns>
[HttpPost("verify")]
public async Task<ApiResponse<RealNameVerifyResponse>> VerifyWithImages(
IFormFile frontImage,
IFormFile? backImage = null)
{
if (frontImage == null || frontImage.Length == 0)
{
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.MissingParameter, "请上传身份证正面照片");
}
// 验证文件类型
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png" };
if (!allowedTypes.Contains(frontImage.ContentType.ToLower()))
{
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.InvalidParameter, "仅支持JPG、PNG格式图片");
}
// 验证文件大小最大5MB
if (frontImage.Length > 5 * 1024 * 1024)
{
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.InvalidParameter, "图片大小不能超过5MB");
}
var userId = GetCurrentUserId();
try
{
// 转换为Base64
string frontBase64;
using (var ms = new MemoryStream())
{
await frontImage.CopyToAsync(ms);
frontBase64 = Convert.ToBase64String(ms.ToArray());
}
string? backBase64 = null;
if (backImage != null && backImage.Length > 0)
{
using var ms = new MemoryStream();
await backImage.CopyToAsync(ms);
backBase64 = Convert.ToBase64String(ms.ToArray());
}
var result = await _realNameService.VerifyWithImagesAsync(userId, frontBase64, backBase64);
return ApiResponse<RealNameVerifyResponse>.Success(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "实名认证图片上传失败: UserId={UserId}", userId);
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.SystemError, "认证失败,请重试");
}
}
/// <summary>
/// 单独上传身份证反面图片(用于分步上传)
/// </summary>
/// <param name="backImage">身份证反面图片</param>
/// <returns>上传结果</returns>
[HttpPost("verify/back")]
public async Task<ApiResponse<RealNameVerifyResponse>> UploadBackImage(IFormFile backImage)
{
if (backImage == null || backImage.Length == 0)
{
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.MissingParameter, "请上传身份证反面照片");
}
// 验证文件类型
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png" };
if (!allowedTypes.Contains(backImage.ContentType.ToLower()))
{
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.InvalidParameter, "仅支持JPG、PNG格式图片");
}
// 验证文件大小最大5MB
if (backImage.Length > 5 * 1024 * 1024)
{
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.InvalidParameter, "图片大小不能超过5MB");
}
var userId = GetCurrentUserId();
try
{
string backBase64;
using (var ms = new MemoryStream())
{
await backImage.CopyToAsync(ms);
backBase64 = Convert.ToBase64String(ms.ToArray());
}
var result = await _realNameService.UploadBackImageAsync(userId, backBase64);
return ApiResponse<RealNameVerifyResponse>.Success(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "身份证反面上传失败: UserId={UserId}", userId);
return ApiResponse<RealNameVerifyResponse>.Error(ErrorCodes.SystemError, "上传失败,请重试");
}
}
/// <summary>
/// 获取实名认证状态
/// </summary>

View File

@ -77,5 +77,10 @@
"AccessKeySecret": "",
"SignName": "",
"TemplateCode": ""
},
"TencentCloud": {
"SecretId": "AKIDVyMfzKZdZP8zkNyOdsFuSsBJDB7EScs0",
"SecretKey": "89GWr7JPWYTL8ueHlAYowGZnvzKZjqs9",
"Region": "ap-shanghai"
}
}

View File

@ -115,6 +115,36 @@ public class ProfileRequest
/// </summary>
public string? Phone { get; set; }
/// <summary>
/// 是否独居
/// </summary>
public bool IsLivingAlone { get; set; }
/// <summary>
/// 父母情况
/// </summary>
public string? ParentStatus { get; set; }
/// <summary>
/// 父母是否退休
/// </summary>
public bool IsParentRetired { get; set; }
/// <summary>
/// 父母现居省份
/// </summary>
public string? ParentProvince { get; set; }
/// <summary>
/// 父母现居城市
/// </summary>
public string? ParentCity { get; set; }
/// <summary>
/// 父母是否有房
/// </summary>
public bool ParentHasHouse { get; set; }
/// <summary>
/// 择偶要求
/// </summary>

View File

@ -200,6 +200,11 @@ public class ProfileResponse
/// </summary>
public bool IsParentRetired { get; set; }
/// <summary>
/// 父母现居省份
/// </summary>
public string? ParentProvince { get; set; }
/// <summary>
/// 父母现居城市
/// </summary>

View File

@ -62,6 +62,48 @@ public class RealNameSubmitResponse
public string? MaskedIdCard { get; set; }
}
/// <summary>
/// 实名认证图片验证响应
/// </summary>
public class RealNameVerifyResponse
{
/// <summary>
/// 是否成功
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 消息
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// 是否需要上传反面(分步上传时使用)
/// </summary>
public bool NeedBackImage { get; set; }
/// <summary>
/// 认证数据(成功时返回)
/// </summary>
public RealNameVerifyData? Data { get; set; }
}
/// <summary>
/// 实名认证数据
/// </summary>
public class RealNameVerifyData
{
/// <summary>
/// 脱敏后的姓名
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 脱敏后的身份证号
/// </summary>
public string IdNumber { get; set; } = string.Empty;
}
/// <summary>
/// 实名认证状态响应
/// </summary>
@ -72,6 +114,11 @@ public class RealNameStatusResponse
/// </summary>
public bool IsRealName { get; set; }
/// <summary>
/// 是否已支付(用于判断是否可以上传身份证)
/// </summary>
public bool IsPaid { get; set; }
/// <summary>
/// 认证状态0未认证 1待审核 2已通过 3已拒绝
/// </summary>
@ -111,4 +158,14 @@ public class RealNameStatusResponse
/// 认证费用
/// </summary>
public decimal? PaymentAmount { get; set; }
/// <summary>
/// 姓名(用于前端显示)
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 身份证号(用于前端显示)
/// </summary>
public string? IdNumber { get; set; }
}

View File

@ -85,6 +85,11 @@ public class RecommendUserResponse
/// </summary>
public bool IsMember { get; set; }
/// <summary>
/// 会员等级0非会员 1不限时会员 2诚意会员 3家庭版会员
/// </summary>
public int MemberLevel { get; set; }
/// <summary>
/// 是否实名认证
/// </summary>

View File

@ -85,4 +85,12 @@ public interface IChatService
/// <param name="sessionId">会话ID</param>
/// <returns>是否有权限</returns>
Task<bool> CanAccessSessionAsync(long userId, long sessionId);
/// <summary>
/// 删除会话(软删除,仅对当前用户隐藏)
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="sessionId">会话ID</param>
/// <returns></returns>
Task DeleteSessionAsync(long userId, long sessionId);
}

View File

@ -1,3 +1,5 @@
using XiangYi.Application.Services;
namespace XiangYi.Application.Interfaces;
/// <summary>
@ -94,6 +96,11 @@ public class AppConfigResponse
/// </summary>
public string? ButlerQrcode { get; set; }
/// <summary>
/// 会员图标配置
/// </summary>
public MemberIconsDto? MemberIcons { get; set; }
/// <summary>
/// 每日弹窗配置
/// </summary>

View File

@ -23,6 +23,23 @@ public interface IRealNameService
/// <returns>认证结果</returns>
Task<RealNameSubmitResponse> SubmitRealNameAsync(long userId, RealNameSubmitRequest request);
/// <summary>
/// 通过身份证图片进行实名认证
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="frontImageBase64">身份证正面图片Base64</param>
/// <param name="backImageBase64">身份证反面图片Base64可选</param>
/// <returns>认证结果</returns>
Task<RealNameVerifyResponse> VerifyWithImagesAsync(long userId, string frontImageBase64, string? backImageBase64);
/// <summary>
/// 上传身份证反面图片(分步上传)
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="backImageBase64">身份证反面图片Base64</param>
/// <returns>认证结果</returns>
Task<RealNameVerifyResponse> UploadBackImageAsync(long userId, string backImageBase64);
/// <summary>
/// 获取实名认证状态
/// </summary>

View File

@ -1,3 +1,5 @@
using XiangYi.Application.Services;
namespace XiangYi.Application.Interfaces;
/// <summary>
@ -79,4 +81,24 @@ public interface ISystemConfigService
/// 设置管家指导二维码URL
/// </summary>
Task<bool> SetButlerQrcodeAsync(string imageUrl);
/// <summary>
/// 获取会员图标URL已废弃请使用GetMemberIconsAsync
/// </summary>
Task<string?> GetMemberIconAsync();
/// <summary>
/// 设置会员图标URL已废弃请使用SetMemberIconsAsync
/// </summary>
Task<bool> SetMemberIconAsync(string imageUrl);
/// <summary>
/// 获取所有会员图标
/// </summary>
Task<MemberIconsDto> GetMemberIconsAsync();
/// <summary>
/// 设置所有会员图标
/// </summary>
Task<bool> SetMemberIconsAsync(MemberIconsDto icons);
}

View File

@ -107,9 +107,9 @@ public class ChatService : IChatService
/// <inheritdoc />
public async Task<List<ChatSessionResponse>> GetSessionsAsync(long userId)
{
// 获取用户参与的所有会话
// 获取用户参与的所有会话,排除已删除的
var sessions = await _sessionRepository.GetListAsync(s =>
s.User1Id == userId || s.User2Id == userId);
(s.User1Id == userId && !s.User1Deleted) || (s.User2Id == userId && !s.User2Deleted));
// 按最后消息时间排序
sessions = sessions.OrderByDescending(s => s.LastMessageTime ?? s.CreateTime).ToList();
@ -191,6 +191,38 @@ public class ChatService : IChatService
return IsUserInSession(userId, session);
}
/// <inheritdoc />
public async Task DeleteSessionAsync(long userId, long sessionId)
{
var session = await _sessionRepository.GetByIdAsync(sessionId);
if (session == null)
{
throw new BusinessException(ErrorCodes.SessionNotFound, "会话不存在");
}
if (!IsUserInSession(userId, session))
{
throw new BusinessException(ErrorCodes.SessionAccessDenied, "无权访问该会话");
}
// 软删除:标记该用户已删除此会话
// 如果是用户1删除设置User1Deleted = true
// 如果是用户2删除设置User2Deleted = true
if (session.User1Id == userId)
{
session.User1Deleted = true;
}
else if (session.User2Id == userId)
{
session.User2Deleted = true;
}
session.UpdateTime = DateTime.Now;
await _sessionRepository.UpdateAsync(session);
_logger.LogInformation("用户删除会话: UserId={UserId}, SessionId={SessionId}", userId, sessionId);
}
#endregion
#region

View File

@ -48,6 +48,7 @@ public class ConfigService : IConfigService
var defaultAvatar = await _systemConfigService.GetDefaultAvatarAsync();
var searchBanner = await _systemConfigService.GetSearchBannerAsync();
var butlerQrcode = await _systemConfigService.GetButlerQrcodeAsync();
var memberIcons = await _systemConfigService.GetMemberIconsAsync();
var dailyPopup = await GetPopupConfigAsync(1); // 每日弹窗
var memberAdPopup = await GetPopupConfigAsync(3); // 会员广告弹窗
@ -58,6 +59,7 @@ public class ConfigService : IConfigService
DefaultAvatar = defaultAvatar,
SearchBanner = searchBanner,
ButlerQrcode = butlerQrcode,
MemberIcons = memberIcons,
DailyPopup = dailyPopup,
MemberAdPopup = memberAdPopup
};

View File

@ -95,6 +95,13 @@ public class ProfileService : IProfileService
HomeProvince = request.HomeProvince,
HomeCity = request.HomeCity,
WeChatNo = request.WeChatNo,
// 父母情况
IsLivingAlone = request.IsLivingAlone,
ParentStatus = request.ParentStatus,
IsParentRetired = request.IsParentRetired,
ParentProvince = request.ParentProvince,
ParentCity = request.ParentCity,
ParentHasHouse = request.ParentHasHouse,
AuditStatus = (int)AuditStatus.Pending, // 新资料设为待审核
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now
@ -129,6 +136,13 @@ public class ProfileService : IProfileService
existingProfile.HomeProvince = request.HomeProvince;
existingProfile.HomeCity = request.HomeCity;
existingProfile.WeChatNo = request.WeChatNo;
// 父母情况
existingProfile.IsLivingAlone = request.IsLivingAlone;
existingProfile.ParentStatus = request.ParentStatus;
existingProfile.IsParentRetired = request.IsParentRetired;
existingProfile.ParentProvince = request.ParentProvince;
existingProfile.ParentCity = request.ParentCity;
existingProfile.ParentHasHouse = request.ParentHasHouse;
existingProfile.AuditStatus = (int)AuditStatus.Pending; // 更新资料重置为待审核
existingProfile.RejectReason = null; // 清除之前的拒绝原因
existingProfile.UpdateTime = DateTime.Now;
@ -238,6 +252,13 @@ public class ProfileService : IProfileService
WeChatNo = profile.WeChatNo,
AuditStatus = profile.AuditStatus,
RejectReason = profile.RejectReason,
// 父母情况
IsLivingAlone = profile.IsLivingAlone,
ParentStatus = profile.ParentStatus,
IsParentRetired = profile.IsParentRetired,
ParentProvince = profile.ParentProvince,
ParentCity = profile.ParentCity,
ParentHasHouse = profile.ParentHasHouse,
IsMember = user.IsMember,
IsRealName = user.IsRealName,
Photos = photos.OrderBy(p => p.Sort).Select(p => new PhotoResponse

View File

@ -9,6 +9,7 @@ using XiangYi.Core.Exceptions;
using XiangYi.Core.Interfaces;
using XiangYi.Infrastructure.RealName;
using XiangYi.Infrastructure.WeChat;
using XiangYi.Infrastructure.Cache;
namespace XiangYi.Application.Services;
@ -22,6 +23,7 @@ public class RealNameService : IRealNameService
private readonly IRepository<RealNameAuth> _realNameAuthRepository;
private readonly IWeChatService _weChatService;
private readonly IRealNameProvider _realNameProvider;
private readonly ICacheService _cacheService;
private readonly ILogger<RealNameService> _logger;
/// <summary>
@ -29,6 +31,16 @@ public class RealNameService : IRealNameService
/// </summary>
private const decimal RealNamePrice = 88m;
/// <summary>
/// 临时OCR数据缓存前缀
/// </summary>
private const string OcrCachePrefix = "realname:ocr:";
/// <summary>
/// 临时OCR数据缓存时间分钟
/// </summary>
private const int OcrCacheMinutes = 30;
/// <summary>
/// 认证状态名称
/// </summary>
@ -46,6 +58,7 @@ public class RealNameService : IRealNameService
IRepository<RealNameAuth> realNameAuthRepository,
IWeChatService weChatService,
IRealNameProvider realNameProvider,
ICacheService cacheService,
ILogger<RealNameService> logger)
{
_userRepository = userRepository;
@ -53,6 +66,7 @@ public class RealNameService : IRealNameService
_realNameAuthRepository = realNameAuthRepository;
_weChatService = weChatService;
_realNameProvider = realNameProvider;
_cacheService = cacheService;
_logger = logger;
}
@ -265,6 +279,18 @@ public class RealNameService : IRealNameService
throw new BusinessException(ErrorCodes.UserNotFound, "用户不存在");
}
// 检查是否已支付实名认证费用
var hasPaidOrder = false;
if (!user.IsMember)
{
var paidOrder = (await _orderRepository.GetListAsync(
o => o.UserId == userId &&
o.OrderType == (int)OrderType.RealName &&
o.Status == (int)OrderStatus.Paid))
.FirstOrDefault();
hasPaidOrder = paidOrder != null;
}
// 查找最新的实名认证记录
var authRecords = await _realNameAuthRepository.GetListAsync(a => a.UserId == userId);
var latestAuth = authRecords.OrderByDescending(a => a.CreateTime).FirstOrDefault();
@ -275,9 +301,10 @@ public class RealNameService : IRealNameService
return new RealNameStatusResponse
{
IsRealName = false,
IsPaid = user.IsMember || hasPaidOrder,
Status = 0,
StatusName = GetStatusName(0),
NeedPayment = !user.IsMember,
NeedPayment = !user.IsMember && !hasPaidOrder,
PaymentAmount = user.IsMember ? null : RealNamePrice
};
}
@ -285,10 +312,13 @@ public class RealNameService : IRealNameService
return new RealNameStatusResponse
{
IsRealName = user.IsRealName,
IsPaid = user.IsMember || hasPaidOrder,
Status = latestAuth.Status,
StatusName = GetStatusName(latestAuth.Status),
MaskedName = latestAuth.RealName,
MaskedIdCard = latestAuth.IdCard,
Name = latestAuth.RealName,
IdNumber = latestAuth.IdCard,
RejectReason = latestAuth.RejectReason,
VerifyTime = latestAuth.VerifyTime,
NeedPayment = !user.IsMember && latestAuth.Status != (int)RealNameAuthStatus.Approved,
@ -296,6 +326,222 @@ public class RealNameService : IRealNameService
};
}
/// <inheritdoc />
public async Task<RealNameVerifyResponse> VerifyWithImagesAsync(long userId, string frontImageBase64, string? backImageBase64)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
throw new BusinessException(ErrorCodes.UserNotFound, "用户不存在");
}
// 已实名用户不能重复认证
if (user.IsRealName)
{
return new RealNameVerifyResponse
{
Success = false,
Message = "您已完成实名认证"
};
}
// 非会员用户需要检查是否已支付
if (!user.IsMember)
{
var paidOrder = (await _orderRepository.GetListAsync(
o => o.UserId == userId &&
o.OrderType == (int)OrderType.RealName &&
o.Status == (int)OrderStatus.Paid))
.FirstOrDefault();
if (paidOrder == null)
{
return new RealNameVerifyResponse
{
Success = false,
Message = "请先完成实名认证费用支付"
};
}
}
// OCR识别身份证正面
var frontOcrResult = await _realNameProvider.OcrIdCardFrontAsync(frontImageBase64);
if (!frontOcrResult.Success)
{
_logger.LogWarning("身份证正面OCR识别失败: UserId={UserId}, Error={Error}", userId, frontOcrResult.ErrorMessage);
return new RealNameVerifyResponse
{
Success = false,
Message = frontOcrResult.ErrorMessage ?? "身份证正面识别失败,请重新拍照"
};
}
var name = frontOcrResult.Name!;
var idNumber = frontOcrResult.IdNumber!;
// 如果没有反面图片,需要分步上传
if (string.IsNullOrEmpty(backImageBase64))
{
// 缓存正面OCR结果等待反面上传
var cacheKey = $"{OcrCachePrefix}{userId}";
await _cacheService.SetAsync(cacheKey, new OcrCacheData
{
Name = name,
IdNumber = idNumber
}, OcrCacheMinutes * 60);
return new RealNameVerifyResponse
{
Success = true,
Message = "身份证正面识别成功,请上传反面",
NeedBackImage = true
};
}
// OCR识别身份证反面
var backOcrResult = await _realNameProvider.OcrIdCardBackAsync(backImageBase64);
if (!backOcrResult.Success)
{
_logger.LogWarning("身份证反面OCR识别失败: UserId={UserId}, Error={Error}", userId, backOcrResult.ErrorMessage);
return new RealNameVerifyResponse
{
Success = false,
Message = backOcrResult.ErrorMessage ?? "身份证反面识别失败,请重新拍照"
};
}
// 调用实名认证接口验证
return await CompleteVerificationAsync(userId, user, name, idNumber);
}
/// <inheritdoc />
public async Task<RealNameVerifyResponse> UploadBackImageAsync(long userId, string backImageBase64)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
throw new BusinessException(ErrorCodes.UserNotFound, "用户不存在");
}
// 已实名用户不能重复认证
if (user.IsRealName)
{
return new RealNameVerifyResponse
{
Success = false,
Message = "您已完成实名认证"
};
}
// 获取缓存的正面OCR数据
var cacheKey = $"{OcrCachePrefix}{userId}";
var cachedData = await _cacheService.GetAsync<OcrCacheData>(cacheKey);
if (cachedData == null)
{
return new RealNameVerifyResponse
{
Success = false,
Message = "请先上传身份证正面"
};
}
// OCR识别身份证反面
var backOcrResult = await _realNameProvider.OcrIdCardBackAsync(backImageBase64);
if (!backOcrResult.Success)
{
_logger.LogWarning("身份证反面OCR识别失败: UserId={UserId}, Error={Error}", userId, backOcrResult.ErrorMessage);
return new RealNameVerifyResponse
{
Success = false,
Message = backOcrResult.ErrorMessage ?? "身份证反面识别失败,请重新拍照"
};
}
// 清除缓存
await _cacheService.RemoveAsync(cacheKey);
// 调用实名认证接口验证
return await CompleteVerificationAsync(userId, user, cachedData.Name, cachedData.IdNumber);
}
/// <summary>
/// 完成实名认证验证
/// </summary>
private async Task<RealNameVerifyResponse> CompleteVerificationAsync(long userId, User user, string name, string idNumber)
{
// 调用第三方实名认证接口验证
var verifyResult = await _realNameProvider.VerifyIdCardAsync(name, idNumber);
if (!verifyResult.IsVerified)
{
_logger.LogWarning("实名认证验证失败: UserId={UserId}, Error={Error}", userId, verifyResult.ErrorMessage);
return new RealNameVerifyResponse
{
Success = false,
Message = verifyResult.ErrorMessage ?? "身份信息验证失败,请确认身份证信息正确"
};
}
// 脱敏存储身份信息
var maskedName = IRealNameService.MaskName(name);
var maskedIdCard = IRealNameService.MaskIdCard(idNumber);
// 查找关联订单
long orderId = 0;
if (!user.IsMember)
{
var order = (await _orderRepository.GetListAsync(
o => o.UserId == userId &&
o.OrderType == (int)OrderType.RealName &&
o.Status == (int)OrderStatus.Paid))
.FirstOrDefault();
orderId = order?.Id ?? 0;
}
// 创建实名认证记录
var realNameAuth = new RealNameAuth
{
UserId = userId,
OrderId = orderId,
RealName = maskedName,
IdCard = maskedIdCard,
Status = (int)RealNameAuthStatus.Approved,
VerifyTime = DateTime.Now,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now
};
await _realNameAuthRepository.AddAsync(realNameAuth);
// 更新用户实名状态
user.IsRealName = true;
user.UpdateTime = DateTime.Now;
await _userRepository.UpdateAsync(user);
_logger.LogInformation("实名认证成功: UserId={UserId}", userId);
return new RealNameVerifyResponse
{
Success = true,
Message = "实名认证成功",
NeedBackImage = false,
Data = new RealNameVerifyData
{
Name = maskedName,
IdNumber = maskedIdCard
}
};
}
/// <summary>
/// OCR缓存数据
/// </summary>
private class OcrCacheData
{
public string Name { get; set; } = string.Empty;
public string IdNumber { get; set; } = string.Empty;
}
#region
/// <summary>

View File

@ -150,6 +150,7 @@ public class RecommendService : IRecommendService
MonthlyIncome = profile.MonthlyIncome,
Intro = profile.Introduction,
IsMember = recommendUser.IsMember,
MemberLevel = recommendUser.MemberLevel,
IsRealName = recommendUser.IsRealName,
IsPhotoPublic = profile.IsPhotoPublic,
FirstPhoto = firstPhoto,
@ -232,6 +233,7 @@ public class RecommendService : IRecommendService
MonthlyIncome = profile.MonthlyIncome,
Intro = profile.Introduction,
IsMember = user.IsMember,
MemberLevel = user.MemberLevel,
IsRealName = user.IsRealName,
IsPhotoPublic = profile.IsPhotoPublic,
FirstPhoto = firstPhoto,

View File

@ -43,6 +43,26 @@ public class SystemConfigService : ISystemConfigService
/// </summary>
public const string ButlerQrcodeKey = "butler_qrcode";
/// <summary>
/// 会员图标配置键
/// </summary>
public const string MemberIconKey = "member_icon";
/// <summary>
/// 不限时会员图标配置键
/// </summary>
public const string UnlimitedMemberIconKey = "unlimited_member_icon";
/// <summary>
/// 诚意会员图标配置键
/// </summary>
public const string SincereMemberIconKey = "sincere_member_icon";
/// <summary>
/// 家庭版会员图标配置键
/// </summary>
public const string FamilyMemberIconKey = "family_member_icon";
public SystemConfigService(
IRepository<SystemConfig> configRepository,
ILogger<SystemConfigService> logger)
@ -177,4 +197,78 @@ public class SystemConfigService : ISystemConfigService
{
return await SetConfigValueAsync(ButlerQrcodeKey, imageUrl, "管家指导二维码URL");
}
/// <inheritdoc />
public async Task<string?> GetMemberIconAsync()
{
return await GetConfigValueAsync(MemberIconKey);
}
/// <inheritdoc />
public async Task<bool> SetMemberIconAsync(string imageUrl)
{
return await SetConfigValueAsync(MemberIconKey, imageUrl, "会员图标URL");
}
/// <inheritdoc />
public async Task<MemberIconsDto> GetMemberIconsAsync()
{
var unlimited = await GetConfigValueAsync(UnlimitedMemberIconKey);
var sincere = await GetConfigValueAsync(SincereMemberIconKey);
var family = await GetConfigValueAsync(FamilyMemberIconKey);
return new MemberIconsDto
{
UnlimitedMemberIcon = unlimited,
SincereMemberIcon = sincere,
FamilyMemberIcon = family
};
}
/// <inheritdoc />
public async Task<bool> SetMemberIconsAsync(MemberIconsDto icons)
{
try
{
if (!string.IsNullOrEmpty(icons.UnlimitedMemberIcon))
{
await SetConfigValueAsync(UnlimitedMemberIconKey, icons.UnlimitedMemberIcon, "不限时会员图标URL");
}
if (!string.IsNullOrEmpty(icons.SincereMemberIcon))
{
await SetConfigValueAsync(SincereMemberIconKey, icons.SincereMemberIcon, "诚意会员图标URL");
}
if (!string.IsNullOrEmpty(icons.FamilyMemberIcon))
{
await SetConfigValueAsync(FamilyMemberIconKey, icons.FamilyMemberIcon, "家庭版会员图标URL");
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "设置会员图标失败");
return false;
}
}
}
/// <summary>
/// 会员图标DTO
/// </summary>
public class MemberIconsDto
{
/// <summary>
/// 不限时会员图标URL
/// </summary>
public string? UnlimitedMemberIcon { get; set; }
/// <summary>
/// 诚意会员图标URL
/// </summary>
public string? SincereMemberIcon { get; set; }
/// <summary>
/// 家庭版会员图标URL
/// </summary>
public string? FamilyMemberIcon { get; set; }
}

View File

@ -38,6 +38,16 @@ public class ChatSession : BaseEntity
/// </summary>
public int User2UnreadCount { get; set; } = 0;
/// <summary>
/// 用户1是否已删除会话
/// </summary>
public bool User1Deleted { get; set; } = false;
/// <summary>
/// 用户2是否已删除会话
/// </summary>
public bool User2Deleted { get; set; } = false;
#region
/// <summary>

View File

@ -148,6 +148,39 @@ public class UserProfile : BaseEntity
[Column(StringLength = 200)]
public string? RejectReason { get; set; }
/// <summary>
/// 是否独居
/// </summary>
public bool IsLivingAlone { get; set; }
/// <summary>
/// 父母情况
/// </summary>
[Column(StringLength = 50)]
public string? ParentStatus { get; set; }
/// <summary>
/// 父母是否退休
/// </summary>
public bool IsParentRetired { get; set; }
/// <summary>
/// 父母现居省份
/// </summary>
[Column(StringLength = 20)]
public string? ParentProvince { get; set; }
/// <summary>
/// 父母现居城市
/// </summary>
[Column(StringLength = 20)]
public string? ParentCity { get; set; }
/// <summary>
/// 父母是否有房
/// </summary>
public bool ParentHasHouse { get; set; }
#region
/// <summary>

View File

@ -21,6 +21,123 @@ public interface IRealNameProvider
/// <param name="photoBase64">人脸照片Base64</param>
/// <returns>验证结果</returns>
Task<RealNameResult> VerifyWithPhotoAsync(string name, string idCard, string photoBase64);
/// <summary>
/// 身份证OCR识别正面
/// </summary>
/// <param name="imageBase64">身份证正面照片Base64</param>
/// <returns>OCR识别结果</returns>
Task<IdCardOcrResult> OcrIdCardFrontAsync(string imageBase64);
/// <summary>
/// 身份证OCR识别反面
/// </summary>
/// <param name="imageBase64">身份证反面照片Base64</param>
/// <returns>OCR识别结果</returns>
Task<IdCardOcrResult> OcrIdCardBackAsync(string imageBase64);
}
/// <summary>
/// 身份证OCR识别结果
/// </summary>
public class IdCardOcrResult
{
/// <summary>
/// 是否识别成功
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 姓名(正面)
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 身份证号(正面)
/// </summary>
public string? IdNumber { get; set; }
/// <summary>
/// 性别(正面)
/// </summary>
public string? Gender { get; set; }
/// <summary>
/// 民族(正面)
/// </summary>
public string? Nation { get; set; }
/// <summary>
/// 出生日期(正面)
/// </summary>
public string? Birth { get; set; }
/// <summary>
/// 住址(正面)
/// </summary>
public string? Address { get; set; }
/// <summary>
/// 签发机关(反面)
/// </summary>
public string? Authority { get; set; }
/// <summary>
/// 有效期限(反面)
/// </summary>
public string? ValidDate { get; set; }
/// <summary>
/// 错误码
/// </summary>
public string? ErrorCode { get; set; }
/// <summary>
/// 错误信息
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// 请求ID
/// </summary>
public string? RequestId { get; set; }
public static IdCardOcrResult SuccessFront(string name, string idNumber, string? gender, string? nation, string? birth, string? address, string? requestId = null)
{
return new IdCardOcrResult
{
Success = true,
Name = name,
IdNumber = idNumber,
Gender = gender,
Nation = nation,
Birth = birth,
Address = address,
RequestId = requestId
};
}
public static IdCardOcrResult SuccessBack(string authority, string validDate, string? requestId = null)
{
return new IdCardOcrResult
{
Success = true,
Authority = authority,
ValidDate = validDate,
RequestId = requestId
};
}
public static IdCardOcrResult Fail(string errorCode, string errorMessage, string? requestId = null)
{
return new IdCardOcrResult
{
Success = false,
ErrorCode = errorCode,
ErrorMessage = errorMessage,
RequestId = requestId
};
}
}
/// <summary>

View File

@ -116,6 +116,158 @@ public class TencentRealNameProvider : IRealNameProvider
}
}
/// <summary>
/// 身份证OCR识别正面
/// </summary>
public async Task<IdCardOcrResult> OcrIdCardFrontAsync(string imageBase64)
{
try
{
var requestBody = new
{
ImageBase64 = imageBase64,
CardSide = "FRONT"
};
var response = await CallOcrApiAsync("IDCardOCR", requestBody);
if (response?.Response?.Error != null)
{
_logger.LogWarning("身份证正面OCR识别失败: {Error}", response.Response.Error.Message);
return IdCardOcrResult.Fail(
response.Response.Error.Code ?? "UNKNOWN",
response.Response.Error.Message ?? "识别失败",
response.Response.RequestId);
}
if (string.IsNullOrEmpty(response?.Response?.Name) || string.IsNullOrEmpty(response?.Response?.IdNum))
{
_logger.LogWarning("身份证正面OCR识别结果为空");
return IdCardOcrResult.Fail("EMPTY_RESULT", "未能识别到身份证信息,请重新拍照");
}
_logger.LogInformation("身份证正面OCR识别成功: {Name}", MaskName(response.Response.Name));
return IdCardOcrResult.SuccessFront(
response.Response.Name,
response.Response.IdNum,
response.Response.Sex,
response.Response.Nation,
response.Response.Birth,
response.Response.Address,
response.Response.RequestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "身份证正面OCR识别异常");
return IdCardOcrResult.Fail("EXCEPTION", ex.Message);
}
}
/// <summary>
/// 身份证OCR识别反面
/// </summary>
public async Task<IdCardOcrResult> OcrIdCardBackAsync(string imageBase64)
{
try
{
var requestBody = new
{
ImageBase64 = imageBase64,
CardSide = "BACK"
};
var response = await CallOcrApiAsync("IDCardOCR", requestBody);
if (response?.Response?.Error != null)
{
_logger.LogWarning("身份证反面OCR识别失败: {Error}", response.Response.Error.Message);
return IdCardOcrResult.Fail(
response.Response.Error.Code ?? "UNKNOWN",
response.Response.Error.Message ?? "识别失败",
response.Response.RequestId);
}
if (string.IsNullOrEmpty(response?.Response?.Authority))
{
_logger.LogWarning("身份证反面OCR识别结果为空");
return IdCardOcrResult.Fail("EMPTY_RESULT", "未能识别到身份证信息,请重新拍照");
}
_logger.LogInformation("身份证反面OCR识别成功");
return IdCardOcrResult.SuccessBack(
response.Response.Authority,
response.Response.ValidDate ?? "",
response.Response.RequestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "身份证反面OCR识别异常");
return IdCardOcrResult.Fail("EXCEPTION", ex.Message);
}
}
private async Task<TencentOcrApiResponse?> CallOcrApiAsync(string action, object requestBody)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var date = DateTime.UtcNow.ToString("yyyy-MM-dd");
var payload = JsonSerializer.Serialize(requestBody);
// OCR使用不同的服务域名
var ocrHost = "ocr.tencentcloudapi.com";
var ocrService = "ocr";
var authorization = GenerateAuthorizationForOcr(action, timestamp, date, payload, ocrHost, ocrService);
var request = new HttpRequestMessage(HttpMethod.Post, $"https://{ocrHost}/")
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", authorization);
request.Headers.Add("X-TC-Action", action);
request.Headers.Add("X-TC-Version", "2018-11-19"); // OCR API版本
request.Headers.Add("X-TC-Timestamp", timestamp.ToString());
request.Headers.Add("X-TC-Region", _options.Region);
var response = await _httpClient.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<TencentOcrApiResponse>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
private string GenerateAuthorizationForOcr(string action, long timestamp, string date, string payload, string host, string service)
{
var algorithm = "TC3-HMAC-SHA256";
var httpRequestMethod = "POST";
var canonicalUri = "/";
var canonicalQueryString = "";
var canonicalHeaders = $"content-type:application/json; charset=utf-8\nhost:{host}\n";
var signedHeaders = "content-type;host";
var hashedRequestPayload = Sha256Hex(payload);
var canonicalRequest = $"{httpRequestMethod}\n{canonicalUri}\n{canonicalQueryString}\n{canonicalHeaders}\n{signedHeaders}\n{hashedRequestPayload}";
var credentialScope = $"{date}/{service}/tc3_request";
var hashedCanonicalRequest = Sha256Hex(canonicalRequest);
var stringToSign = $"{algorithm}\n{timestamp}\n{credentialScope}\n{hashedCanonicalRequest}";
var secretDate = HmacSha256($"TC3{_options.SecretKey}", date);
var secretService = HmacSha256(secretDate, service);
var secretSigning = HmacSha256(secretService, "tc3_request");
var signature = HmacSha256Hex(secretSigning, stringToSign);
return $"{algorithm} Credential={_options.SecretId}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
}
private static string MaskName(string name)
{
if (string.IsNullOrEmpty(name) || name.Length < 2)
return "***";
return $"{name[0]}**";
}
private async Task<TencentApiResponse?> CallApiAsync(string action, object requestBody)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@ -216,4 +368,30 @@ public class TencentRealNameProvider : IRealNameProvider
public string? Code { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// 腾讯云OCR API响应
/// </summary>
private class TencentOcrApiResponse
{
public TencentOcrResponseData? Response { get; set; }
}
private class TencentOcrResponseData
{
// 正面信息
public string? Name { get; set; }
public string? Sex { get; set; }
public string? Nation { get; set; }
public string? Birth { get; set; }
public string? Address { get; set; }
public string? IdNum { get; set; }
// 反面信息
public string? Authority { get; set; }
public string? ValidDate { get; set; }
public string? RequestId { get; set; }
public TencentApiError? Error { get; set; }
}
}