细节优化.

This commit is contained in:
18631081161 2026-01-04 21:34:50 +08:00
parent 27eed3913b
commit e56f3738c2
51 changed files with 4392 additions and 1485 deletions

View File

@ -5,3 +5,6 @@ VITE_APP_TITLE=相宜相亲后台管理系统(开发)
# API基础地址
VITE_API_BASE_URL=http://localhost:5000/api
# 静态资源服务器地址AppApi用于图片等静态资源
VITE_STATIC_BASE_URL=http://localhost:5001

View File

@ -5,3 +5,6 @@ VITE_APP_TITLE=相宜相亲后台管理系统
# API基础地址 - 生产环境请修改为实际地址
VITE_API_BASE_URL=https://api.example.com
# 静态资源服务器地址 - 生产环境请修改为实际地址
VITE_STATIC_BASE_URL=https://static.example.com

22
admin/src/api/config.ts Normal file
View File

@ -0,0 +1,22 @@
import request from '@/utils/request'
/**
*
*/
export function getDefaultAvatar() {
return request.get('/admin/config/defaultAvatar')
}
/**
*
*/
export function setDefaultAvatar(avatarUrl: string) {
return request.post('/admin/config/defaultAvatar', { avatarUrl })
}
/**
*
*/
export function getAllConfigs() {
return request.get('/admin/config/all')
}

View File

@ -57,3 +57,12 @@ export function getUserStatistics(): Promise<UserStatistics> {
export function createTestUsers(count: number, gender?: number): Promise<number[]> {
return request.post('/admin/users/test-users', { count, gender })
}
/**
*
* @param id ID
* @returns
*/
export function deleteUser(id: number): Promise<void> {
return request.delete(`/admin/users/${id}`)
}

View File

@ -234,6 +234,12 @@ export const asyncRoutes: RouteRecordRaw[] = [
name: 'OperationLog',
component: () => import('@/views/system/log.vue'),
meta: { title: '操作日志' }
},
{
path: 'config',
name: 'SystemConfig',
component: () => import('@/views/system/config.vue'),
meta: { title: '系统配置' }
}
]
},

View File

@ -2,9 +2,8 @@
* URL处理工具
*/
// 获取后端基础地址(去掉 /api 后缀)
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
const SERVER_BASE = API_BASE.replace(/\/api$/, '')
// 获取静态资源服务器地址AppApi用于图片等静态资源
const STATIC_BASE = import.meta.env.VITE_STATIC_BASE_URL || 'http://localhost:5001'
/**
* URLURL
@ -17,8 +16,8 @@ export function getFullImageUrl(url: string | undefined | null): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
// 相对路径,拼接服务器地址
return `${SERVER_BASE}${url.startsWith('/') ? '' : '/'}${url}`
// 相对路径,拼接静态资源服务器地址
return `${STATIC_BASE}${url.startsWith('/') ? '' : '/'}${url}`
}
/**

View File

@ -0,0 +1,202 @@
<template>
<div class="config-container">
<el-card class="config-card">
<template #header>
<div class="card-header">
<span>系统配置</span>
</div>
</template>
<el-form :model="configForm" label-width="120px" class="config-form">
<!-- 默认头像设置 -->
<el-form-item label="默认头像">
<div class="avatar-upload">
<el-upload
class="avatar-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
accept="image/*"
>
<img v-if="configForm.defaultAvatar" :src="getFullUrl(configForm.defaultAvatar)" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="avatar-tip">
<p>建议尺寸200x200像素</p>
<p>支持格式JPGPNG</p>
<p>新用户注册时将使用此头像作为默认头像</p>
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig" :loading="saving">
保存配置
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { getDefaultAvatar, setDefaultAvatar } from '@/api/config'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'
// /api
const serverUrl = apiBaseUrl.replace(/\/api$/, '')
const configForm = ref({
defaultAvatar: ''
})
const saving = ref(false)
const uploadUrl = computed(() => `${apiBaseUrl}/admin/upload`)
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${userStore.token}`
}))
const getFullUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${serverUrl}${url}`
}
const loadConfig = async () => {
try {
const res = await getDefaultAvatar()
// request code data
if (res) {
configForm.value.defaultAvatar = res.avatarUrl || ''
}
} catch (error) {
console.error('加载配置失败:', error)
}
}
const handleAvatarSuccess = (response) => {
if (response.code === 0 && response.data) {
configForm.value.defaultAvatar = 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
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return false
}
return true
}
const saveConfig = async () => {
if (!configForm.value.defaultAvatar) {
ElMessage.warning('请先上传默认头像')
return
}
saving.value = true
try {
// request
await setDefaultAvatar(configForm.value.defaultAvatar)
ElMessage.success('保存成功')
} catch (error) {
//
console.error('保存失败:', error)
} finally {
saving.value = false
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.config-container {
padding: 20px;
}
.config-card {
max-width: 800px;
}
.card-header {
font-size: 16px;
font-weight: 600;
}
.config-form {
padding: 20px 0;
}
.avatar-upload {
display: flex;
align-items: flex-start;
gap: 20px;
}
.avatar-uploader {
width: 120px;
height: 120px;
}
.avatar-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: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-uploader :deep(.el-upload:hover) {
border-color: var(--el-color-primary);
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.avatar {
width: 120px;
height: 120px;
display: block;
object-fit: cover;
}
.avatar-tip {
color: #909399;
font-size: 12px;
line-height: 1.8;
}
.avatar-tip p {
margin: 0;
}
</style>

View File

@ -89,6 +89,78 @@ const formatMoney = (amount: number) => {
return `¥${amount.toFixed(2)}`
}
//
const educationMap: Record<number, string> = {
1: '高中',
2: '中专',
3: '大专',
4: '本科',
5: '研究生',
6: '博士及以上'
}
//
const marriageStatusMap: Record<number, string> = {
1: '未婚',
2: '离异未育',
3: '离异已育'
}
//
const formatEducation = (education: string | number[] | undefined) => {
if (!education) return '-'
//
let eduArray: number[] = []
if (typeof education === 'string') {
try {
eduArray = JSON.parse(education)
} catch {
return education || '-'
}
} else if (Array.isArray(education)) {
eduArray = education
}
if (!eduArray || eduArray.length === 0) return '-'
return eduArray.map(e => educationMap[e] || e).join('、')
}
//
const formatMarriageStatus = (status: string | number[] | undefined) => {
if (!status) return '-'
//
let statusArray: number[] = []
if (typeof status === 'string') {
try {
statusArray = JSON.parse(status)
} catch {
return status || '-'
}
} else if (Array.isArray(status)) {
statusArray = status
}
if (!statusArray || statusArray.length === 0) return '-'
return statusArray.map(s => marriageStatusMap[s] || s).join('、')
}
//
const formatIncome = (min: number | undefined, max: number | undefined) => {
const incomeMap: Record<number, string> = {
1: '5000以下',
2: '5000-10000',
3: '10000-20000',
4: '20000-50000',
5: '50000以上'
}
//
if (min) return incomeMap[min] || '-'
if (max) return incomeMap[max] || '-'
return '不限'
}
onMounted(() => {
fetchUserDetail()
})
@ -193,7 +265,7 @@ onMounted(() => {
{{ userDetail.genderText || '-' }}
</el-descriptions-item>
<el-descriptions-item label="城市">
{{ userDetail.city || '-' }}
{{ userDetail.profile?.workCity || userDetail.city || '-' }}
</el-descriptions-item>
<el-descriptions-item label="会员等级">
<el-tag
@ -364,16 +436,16 @@ onMounted(() => {
{{ userDetail.requirement.heightMin || '-' }} ~ {{ userDetail.requirement.heightMax || '-' }}cm
</el-descriptions-item>
<el-descriptions-item label="期望学历">
{{ userDetail.requirement.education || '-' }}
{{ formatEducation(userDetail.requirement.education) }}
</el-descriptions-item>
<el-descriptions-item label="期望工作城市">
{{ userDetail.requirement.workCity || '-' }}
</el-descriptions-item>
<el-descriptions-item label="期望月收入">
{{ userDetail.requirement.incomeMin || '-' }} ~ {{ userDetail.requirement.incomeMax || '-' }}
{{ formatIncome(userDetail.requirement.incomeMin, userDetail.requirement.incomeMax) }}
</el-descriptions-item>
<el-descriptions-item label="期望婚姻状态">
{{ userDetail.requirement.marriageStatus || '-' }}
{{ formatMarriageStatus(userDetail.requirement.marriageStatus) }}
</el-descriptions-item>
<el-descriptions-item label="是否要求有房">
{{ userDetail.requirement.requireHouse ? '是' : '否' }}

View File

@ -6,11 +6,11 @@
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View, Edit, Plus } from '@element-plus/icons-vue'
import { View, Edit, Plus, Delete } from '@element-plus/icons-vue'
import SearchForm from '@/components/SearchForm/index.vue'
import Pagination from '@/components/Pagination/index.vue'
import StatusTag from '@/components/StatusTag/index.vue'
import { getUserList, updateUserStatus, createTestUsers } from '@/api/user'
import { getUserList, updateUserStatus, createTestUsers, deleteUser } from '@/api/user'
import { getFullImageUrl } from '@/utils/image'
import type { UserListItem, UserQueryParams } from '@/types/user.d'
@ -151,6 +151,29 @@ const handleToggleStatus = async (row: UserListItem) => {
}
}
//
const handleDeleteUser = async (row: UserListItem) => {
try {
await ElMessageBox.confirm(
`确定要删除用户「${row.nickname || row.xiangQinNo}」吗?此操作不可恢复!`,
'警告',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'error'
}
)
await deleteUser(row.userId)
ElMessage.success('删除成功')
fetchUserList()
} catch (error) {
if (error !== 'cancel') {
console.error('删除用户失败:', error)
}
}
}
//
const formatTime = (time: string) => {
if (!time) return '-'
@ -438,7 +461,7 @@ onMounted(() => {
</el-table-column>
<el-table-column
label="操作"
width="150"
width="240"
fixed="right"
align="center"
>
@ -459,6 +482,14 @@ onMounted(() => {
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button
type="danger"
link
:icon="Delete"
@click="handleDeleteUser(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>

View File

@ -7,7 +7,9 @@ import { get, post, del } from './request'
import { getToken } from '../utils/storage'
// API 基础地址
const BASE_URL = 'http://localhost:5000/api/app'
const BASE_URL = 'http://localhost:5001/api/app'
// 静态资源服务器地址
const STATIC_URL = 'http://localhost:5001'
/**
* 提交/更新用户资料
@ -59,54 +61,79 @@ export async function getMyProfile() {
* Requirements: 4.4
*
* @param {Array<string>} filePaths - 本地文件路径数组
* @returns {Promise<Object>} 上传结果
* @returns {Promise<Object>} 上传结果包含 photos 数组 [{url: string}]
*/
export async function uploadPhotos(filePaths) {
return new Promise((resolve, reject) => {
const token = getToken()
// 使用 uni.uploadFile 上传文件
const uploadTasks = filePaths.map((filePath, index) => {
return new Promise((res, rej) => {
const token = getToken()
// 逐个上传文件uni.uploadFile 不支持一次上传多个文件)
const uploadedPhotos = []
for (const filePath of filePaths) {
try {
const result = await new Promise((resolve, reject) => {
uni.uploadFile({
url: `${BASE_URL}/profile/photos`,
filePath: filePath,
name: 'files',
name: 'files', // 后端期望的参数名
header: {
'Authorization': `Bearer ${token}`
},
success: (uploadRes) => {
console.log('上传响应:', uploadRes)
if (uploadRes.statusCode === 200) {
try {
const data = JSON.parse(uploadRes.data)
res(data)
// 后端返回格式: { code: 0, message: "success", data: {...} }
if (data.code === 0 && data.data) {
resolve(data.data)
} else {
reject(new Error(data.message || '上传失败'))
}
} catch (e) {
rej(new Error('解析响应失败'))
console.error('解析响应失败:', e, uploadRes.data)
reject(new Error('解析响应失败'))
}
} else if (uploadRes.statusCode === 401) {
rej(new Error('未授权,请重新登录'))
reject(new Error('未授权,请重新登录'))
} else {
rej(new Error('上传失败'))
// 尝试解析错误信息
try {
const errData = JSON.parse(uploadRes.data)
reject(new Error(errData.message || '上传失败'))
} catch {
reject(new Error(`上传失败: ${uploadRes.statusCode}`))
}
}
},
fail: (err) => {
rej(new Error('网络连接失败'))
console.error('上传网络错误:', err)
reject(new Error('网络连接失败'))
}
})
})
})
// 并行上传所有文件
Promise.all(uploadTasks)
.then(results => {
// 合并所有上传结果
const photos = results.flatMap(r => r.data?.photos || [])
resolve({ success: true, data: { photos } })
})
.catch(err => {
reject(err)
})
})
// 后端返回 { photos: [{id, photoUrl}], totalCount: number }
// 转换为前端需要的格式拼接完整URL
if (result.photos && result.photos.length > 0) {
result.photos.forEach(photo => {
// 如果是相对路径,拼接服务器地址
const fullUrl = photo.photoUrl.startsWith('http') ? photo.photoUrl : `${STATIC_URL}${photo.photoUrl}`
uploadedPhotos.push({ id: photo.id, url: fullUrl })
})
}
} catch (error) {
console.error('上传照片失败:', error)
throw error
}
}
return {
success: true,
data: {
photos: uploadedPhotos
}
}
}
/**
@ -120,9 +147,33 @@ export async function deletePhoto(photoId) {
return response
}
/**
* 清理未提交资料的孤立照片
* 进入填写资料页面时调用如果用户没有Profile记录清理之前上传的照片
*
* @returns {Promise<Object>} 清理结果
*/
export async function cleanupOrphanPhotos() {
const response = await post('/profile/photos/cleanup')
return response
}
/**
* 更新头像
*
* @param {string} avatarUrl - 头像URL
* @returns {Promise<Object>} 更新结果
*/
export async function updateAvatar(avatarUrl) {
const response = await post('/profile/avatar', { avatarUrl })
return response
}
export default {
createOrUpdate,
getMyProfile,
uploadPhotos,
deletePhoto
deletePhoto,
cleanupOrphanPhotos,
updateAvatar
}

View File

@ -10,10 +10,12 @@ import { get, post } from './request'
* WHEN a user visits the home page, THE XiangYi_MiniApp SHALL display recommended user list
* Requirements: 2.3
*
* @param {number} pageIndex - 页码
* @param {number} pageSize - 每页数量
* @returns {Promise<Object>} 推荐用户列表
*/
export async function getRecommend() {
const response = await get('/users/recommend')
export async function getRecommend(pageIndex = 1, pageSize = 10) {
const response = await get('/users/recommend', { pageIndex, pageSize }, { needAuth: false })
return response
}
@ -57,8 +59,44 @@ export async function search(params = {}) {
return response
}
/**
* 更新用户头像
*
* @param {string} avatar - 头像URL
* @returns {Promise<Object>} 更新结果
*/
export async function updateAvatar(avatar) {
const response = await post('/users/avatar', { avatar })
return response
}
/**
* 更新用户昵称
*
* @param {string} nickname - 昵称
* @returns {Promise<Object>} 更新结果
*/
export async function updateNickname(nickname) {
const response = await post('/users/nickname', { nickname })
return response
}
/**
* 解密微信手机号
*
* @param {string} code - 微信返回的 code
* @returns {Promise<Object>} 解密结果包含 phone 字段
*/
export async function decryptPhone(code) {
const response = await post('/users/phone/decrypt', { code })
return response
}
export default {
getRecommend,
getUserDetail,
search
search,
updateAvatar,
updateNickname,
decryptPhone
}

View File

@ -1,56 +1,125 @@
<template>
<view class="user-card" @click="handleCardClick">
<!-- 照片区域 -->
<view class="photo-wrapper">
<image
v-if="isPhotoPublic && firstPhoto"
class="user-photo"
:src="firstPhoto"
mode="aspectFill"
/>
<view v-else class="photo-placeholder">
<view class="blur-overlay"></view>
<text class="private-text">私密照片</text>
<!-- 今天看过标识 -->
<view v-if="viewedToday" class="viewed-tag">
<text>今天看过</text>
</view>
<!-- 不公开照片样式两列信息布局 -->
<view v-if="!isPhotoPublic || !firstPhoto" class="card-no-photo">
<!-- 标题行 -->
<view class="title-row">
<text class="gender-year">{{ genderText }} · {{ birthYear }}</text>
<view class="title-tags">
<text v-if="isRealName" class="tag tag-realname">已实名</text>
<text v-if="isMember" class="tag tag-member">会员</text>
</view>
</view>
<!-- 徽章区域 -->
<view class="badges">
<view v-if="isMember" class="badge member-badge">会员</view>
<view v-if="isRealName" class="badge realname-badge">已实名</view>
<!-- 信息区域两列布局 -->
<view class="info-grid">
<view class="info-item">
<text class="label">年龄</text>
<text class="value">{{ age }}</text>
</view>
<view class="info-item">
<text class="label">身高</text>
<text class="value">{{ height }}cm</text>
</view>
<view class="info-item">
<text class="label">学历</text>
<text class="value">{{ educationName }}</text>
</view>
<view class="info-item">
<text class="label">体重</text>
<text class="value">{{ weight ? weight + 'kg' : '未填写' }}</text>
</view>
<view class="info-item">
<text class="label">收入</text>
<text class="value">{{ incomeText }}</text>
</view>
<view class="info-item">
<text class="label">职业</text>
<text class="value">{{ occupation || '未填写' }}</text>
</view>
<view class="info-item">
<text class="label">现居</text>
<text class="value">{{ workCity || '未填写' }}</text>
</view>
<view class="info-item">
<text class="label">家乡</text>
<text class="value">{{ hometown || '未填写' }}</text>
</view>
</view>
<!-- 今天看过标识 -->
<view v-if="viewedToday" class="viewed-today">
<text>今天看过</text>
<!-- 简介 -->
<view class="intro-section" v-if="intro">
<text class="intro-text">{{ intro }}</text>
</view>
<!-- 操作按钮 -->
<view class="action-buttons" @click.stop>
<button class="btn-detail" @click="handleCardClick">查看详细资料</button>
<button class="btn-contact" @click="handleContact">联系对方</button>
</view>
</view>
<!-- 用户信息区域 -->
<view class="user-info">
<view class="info-row">
<text class="nickname">{{ nickname }}</text>
<text class="age">{{ age }}</text>
<!-- 公开照片样式左信息右照片 -->
<view v-else class="card-with-photo">
<!-- 标题行 -->
<view class="title-row">
<text class="gender-year">{{ genderText }} · {{ birthYear }}</text>
<view class="title-tags">
<text v-if="isRealName" class="tag tag-realname">已实名</text>
<text v-if="isMember" class="tag tag-member">会员</text>
</view>
</view>
<view class="info-row secondary">
<text class="location">{{ workCity }}</text>
<text class="divider">|</text>
<text class="height">{{ height }}cm</text>
<!-- 内容区域 -->
<view class="content-row">
<!-- 左侧信息 -->
<view class="info-left">
<view class="info-item">
<text class="label">现居</text>
<text class="value">{{ workCity || '未填写' }}</text>
</view>
<view class="info-item">
<text class="label">学历</text>
<text class="value">{{ educationName }}</text>
</view>
<view class="info-item">
<text class="label">身高</text>
<text class="value">{{ height }}cm</text>
</view>
<view class="info-item">
<text class="label">职业</text>
<text class="value">{{ occupation || '未填写' }}</text>
</view>
</view>
<!-- 右侧照片 -->
<view class="photo-right">
<image class="user-photo" :src="fullPhotoUrl" mode="aspectFill" />
</view>
</view>
<view class="info-row secondary">
<text class="education">{{ educationName }}</text>
<text class="divider">|</text>
<text class="occupation">{{ occupation }}</text>
<!-- 简介 -->
<view class="intro-section" v-if="intro">
<text class="intro-text">{{ intro }}</text>
</view>
<!-- 操作按钮 -->
<view class="action-buttons" @click.stop>
<button class="btn-detail" @click="handleCardClick">查看详细资料</button>
<button class="btn-contact" @click="handleContact">联系对方</button>
</view>
</view>
<!-- 操作按钮区域 -->
<view class="action-buttons" @click.stop>
<button class="btn-contact" @click="handleContact">联系TA</button>
</view>
</view>
</template>
<script>
import { getFullImageUrl } from '@/utils/image.js'
export default {
name: 'UserCard',
props: {
@ -66,18 +135,34 @@ export default {
type: String,
default: ''
},
gender: {
type: Number,
default: 0
},
age: {
type: Number,
default: 0
},
birthYear: {
type: Number,
default: 0
},
workCity: {
type: String,
default: ''
},
hometown: {
type: String,
default: ''
},
height: {
type: Number,
default: 0
},
weight: {
type: Number,
default: 0
},
education: {
type: Number,
default: 0
@ -90,6 +175,14 @@ export default {
type: String,
default: ''
},
monthlyIncome: {
type: Number,
default: 0
},
intro: {
type: String,
default: ''
},
isMember: {
type: Boolean,
default: false
@ -112,6 +205,24 @@ export default {
}
},
emits: ['click', 'contact'],
computed: {
genderText() {
return this.gender === 1 ? '男' : '女'
},
fullPhotoUrl() {
return getFullImageUrl(this.firstPhoto)
},
incomeText() {
if (!this.monthlyIncome) return '未填写'
if (this.monthlyIncome < 5000) return '5千以下'
if (this.monthlyIncome < 8000) return '5千~8千/月'
if (this.monthlyIncome < 10000) return '8千~1万/月'
if (this.monthlyIncome < 15000) return '1万~1.5万/月'
if (this.monthlyIncome < 20000) return '1.5万~2万/月'
if (this.monthlyIncome < 30000) return '2万~3万/月'
return '3万以上/月'
}
},
methods: {
handleCardClick() {
this.$emit('click', this.userId)
@ -126,139 +237,187 @@ export default {
<style lang="scss" scoped>
.user-card {
background-color: #fff;
border-radius: 16rpx;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
margin-bottom: 20rpx;
}
.photo-wrapper {
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
margin-bottom: 24rpx;
position: relative;
width: 100%;
height: 400rpx;
.user-photo {
width: 100%;
height: 100%;
}
.photo-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0 0%, #c0c0c0 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
.blur-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
.private-text {
position: relative;
z-index: 1;
color: #666;
font-size: 28rpx;
}
}
.badges {
position: absolute;
top: 16rpx;
left: 16rpx;
display: flex;
gap: 8rpx;
.badge {
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
color: #fff;
}
.member-badge {
background: linear-gradient(135deg, #ffd700 0%, #ffb800 100%);
}
.realname-badge {
background: linear-gradient(135deg, #4cd964 0%, #34c759 100%);
}
}
.viewed-today {
position: absolute;
bottom: 16rpx;
right: 16rpx;
background: rgba(0, 0, 0, 0.5);
padding: 4rpx 12rpx;
border-radius: 8rpx;
text {
color: #fff;
font-size: 22rpx;
}
}
padding: 32rpx;
}
.user-info {
padding: 20rpx;
//
.viewed-tag {
position: absolute;
top: 24rpx;
right: 24rpx;
background: #ff6b6b;
padding: 8rpx 20rpx;
border-radius: 20rpx;
.info-row {
display: flex;
align-items: center;
margin-bottom: 8rpx;
&:last-child {
margin-bottom: 0;
}
&.secondary {
color: #999;
font-size: 26rpx;
}
.nickname {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-right: 12rpx;
}
.age {
font-size: 28rpx;
color: #ff6b6b;
}
.divider {
margin: 0 8rpx;
color: #ddd;
}
}
}
.action-buttons {
padding: 0 20rpx 20rpx;
.btn-contact {
width: 100%;
height: 72rpx;
line-height: 72rpx;
background: linear-gradient(135deg, #ff6b6b 0%, #ff5252 100%);
text {
color: #fff;
font-size: 24rpx;
}
}
//
.title-row {
margin-bottom: 24rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
.gender-year {
font-size: 40rpx;
font-weight: 600;
color: #ff6b6b;
}
.title-tags {
display: flex;
align-items: center;
gap: 12rpx;
.tag {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
&.tag-realname {
background: #e8f5e9;
color: #4caf50;
}
&.tag-member {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
color: #ff9800;
}
}
}
}
//
.card-no-photo {
.info-grid {
display: flex;
flex-wrap: wrap;
.info-item {
width: 50%;
display: flex;
align-items: center;
margin-bottom: 20rpx;
.label {
font-size: 28rpx;
color: #999;
width: 80rpx;
flex-shrink: 0;
}
.value {
font-size: 28rpx;
color: #333;
margin-left: 16rpx;
}
}
}
}
//
.card-with-photo {
.content-row {
display: flex;
justify-content: space-between;
.info-left {
flex: 1;
.info-item {
display: flex;
align-items: center;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 28rpx;
color: #999;
width: 80rpx;
flex-shrink: 0;
}
.value {
font-size: 28rpx;
color: #333;
margin-left: 16rpx;
}
}
}
.photo-right {
width: 200rpx;
height: 200rpx;
margin-left: 24rpx;
flex-shrink: 0;
.user-photo {
width: 100%;
height: 100%;
border-radius: 16rpx;
}
}
}
}
//
.intro-section {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #f5f5f5;
.intro-text {
font-size: 28rpx;
border-radius: 36rpx;
color: #666;
line-height: 1.6;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
}
//
.action-buttons {
display: flex;
gap: 24rpx;
margin-top: 32rpx;
button {
flex: 1;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
border-radius: 40rpx;
border: none;
&::after {
border: none;
}
}
.btn-detail {
background: #fff;
color: #333;
border: 2rpx solid #ddd;
}
.btn-contact {
background: linear-gradient(135deg, #ff8a8a 0%, #ff6b6b 100%);
color: #fff;
}
}
</style>

View File

@ -1,5 +1,5 @@
{
"name" : "src",
"name" : "相宜亲家",
"appid" : "__UNI__39EAECC",
"description" : "",
"versionName" : "1.0.0",
@ -50,7 +50,7 @@
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"appid" : "wx21b4110b18b31831",
"setting" : {
"urlCheck" : false
},

View File

@ -4,31 +4,44 @@
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "相宜相亲"
"navigationBarTitleText": "相宜相亲",
"enablePullDownRefresh": true
}
},
{
"path": "pages/message/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "消息"
}
},
{
"path": "pages/mine/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/profile/edit",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "填写资料"
}
},
{
"path": "pages/profile/personal",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "个人资料",
"disableScroll": true
}
},
{
"path": "pages/profile/detail",
"style": {
"navigationBarTitleText": "用户详情"
"navigationStyle": "custom",
"navigationBarTitleText": "详情资料"
}
},
{
@ -96,6 +109,13 @@
"style": {
"navigationBarTitleText": "我解锁的"
}
},
{
"path": "pages/login/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
}
],
"globalStyle": {

View File

@ -52,9 +52,6 @@
<!-- 推荐标题栏 -->
<view class="section-header">
<text class="section-title">今日推荐</text>
<view class="section-more" @click="handleRefreshRecommend">
<text>换一批</text>
</view>
</view>
</view>
@ -73,16 +70,25 @@
</view>
<!-- 可滚动的用户列表区域 -->
<scroll-view class="user-scroll-area" scroll-y :style="{ height: scrollHeight }"
@scrolltolower="handleScrollToLower">
<scroll-view
class="user-scroll-area"
scroll-y
:style="{ height: scrollHeight }"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="handleRefresh"
@scrolltolower="handleScrollToLower"
>
<!-- 用户列表 -->
<view class="user-list" v-if="recommendList.length > 0">
<UserCard v-for="user in recommendList" :key="user.userId" :userId="user.userId"
:nickname="user.nickname" :avatar="user.avatar" :age="user.age" :workCity="user.workCity"
:height="user.height" :education="user.education" :educationName="user.educationName"
:occupation="user.occupation" :isMember="user.isMember" :isRealName="user.isRealName"
:isPhotoPublic="user.isPhotoPublic" :firstPhoto="user.firstPhoto" :viewedToday="user.viewedToday"
@click="handleUserClick" @contact="handleUserContact" />
:nickname="user.nickname" :avatar="user.avatar" :gender="user.gender" :age="user.age"
:birthYear="user.birthYear" :workCity="user.workCity" :hometown="user.hometown"
:height="user.height" :weight="user.weight" :education="user.education"
:educationName="user.educationName" :occupation="user.occupation"
:monthlyIncome="user.monthlyIncome" :intro="user.intro" :isMember="user.isMember"
:isRealName="user.isRealName" :isPhotoPublic="user.isPhotoPublic" :firstPhoto="user.firstPhoto"
:viewedToday="user.viewedToday" @click="handleUserClick" @contact="handleUserContact" />
</view>
<!-- 空状态 -->
@ -140,6 +146,12 @@
const listLoading = ref(false)
const noMoreData = ref(false)
const scrollHeight = ref('300px')
const isRefreshing = ref(false)
//
const pageIndex = ref(1)
const pageSize = 10
const total = ref(0)
//
const recommendList = ref([])
@ -228,13 +240,32 @@
}
//
const loadRecommendList = async () => {
const loadRecommendList = async (isLoadMore = false) => {
if (listLoading.value) return
if (isLoadMore && noMoreData.value) return
listLoading.value = true
try {
const res = await getRecommend()
if (res && res.code === 0) {
recommendList.value = res.data?.list || res.data || []
noMoreData.value = true //
if (!isLoadMore) {
pageIndex.value = 1
recommendList.value = []
noMoreData.value = false
}
const res = await getRecommend(pageIndex.value, pageSize)
if (res && res.code === 0 && res.data) {
const items = res.data.items || []
total.value = res.data.total || 0
if (isLoadMore) {
recommendList.value = [...recommendList.value, ...items]
} else {
recommendList.value = items
}
//
noMoreData.value = recommendList.value.length >= total.value
pageIndex.value++
}
} catch (error) {
console.error('加载推荐列表失败:', error)
@ -266,10 +297,8 @@
loadMemberAdConfig()
])
//
if (userStore.isLoggedIn) {
await loadRecommendList()
}
//
await loadRecommendList()
//
checkPopups()
@ -435,10 +464,21 @@
})
}
//
// -
const handleScrollToLower = () => {
//
console.log('滚动到底部')
if (!noMoreData.value && !listLoading.value) {
loadRecommendList(true)
}
}
//
const handleRefresh = async () => {
isRefreshing.value = true
try {
await loadRecommendList(false)
} finally {
isRefreshing.value = false
}
}
//
@ -451,6 +491,7 @@
listLoading,
noMoreData,
scrollHeight,
isRefreshing,
banners,
kingKongs,
showMemberAd,
@ -460,6 +501,8 @@
showDailyPopup,
dailyPopup,
recommendList,
initPage,
loadRecommendList,
handleSearchClick,
handleBannerClick,
handleKingKongClick,
@ -471,14 +514,16 @@
handleRefreshRecommend,
handleUserClick,
handleUserContact,
handleScrollToLower
handleScrollToLower,
handleRefresh
}
},
//
onPullDownRefresh() {
this.initPage && this.initPage().finally(() => {
uni.stopPullDownRefresh()
})
async onPullDownRefresh() {
if (this.loadRecommendList) {
await this.loadRecommendList()
}
uni.stopPullDownRefresh()
},
//
onShow() {

View File

@ -0,0 +1,267 @@
<template>
<view class="login-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="navbar-back" @click="handleBack">
<text class="back-icon"></text>
</view>
<text class="navbar-title">登录</text>
<view class="navbar-placeholder"></view>
</view>
</view>
<!-- Logo区域 -->
<view class="logo-section" :style="{ marginTop: (statusBarHeight + 44) + 'px' }">
<view class="logo-wrapper">
<image src="/static/logo.png" mode="aspectFit" class="logo-img" />
</view>
</view>
<!-- 底部区域 -->
<view class="bottom-section">
<!-- 登录按钮 -->
<button class="btn-login" @click="handleLogin">一键注册/登录</button>
<!-- 协议勾选 -->
<view class="agreement-row" @click="toggleAgreement">
<image
:src="isAgreed ? '/static/ic_check_s.png' : '/static/ic_check.png'"
mode="aspectFit"
class="check-icon"
/>
<text class="agreement-text">
注册即同意<text class="link" @click.stop="handleUserAgreement">用户协议</text><text class="link" @click.stop="handlePrivacyPolicy">隐私政策</text>
</text>
</view>
</view>
</view>
</template>
<script>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/store/user.js'
import { login } from '@/api/auth.js'
export default {
name: 'LoginPage',
setup() {
const userStore = useUserStore()
const statusBarHeight = ref(20)
const isAgreed = ref(false)
//
const getSystemInfo = () => {
uni.getSystemInfo({
success: (res) => {
statusBarHeight.value = res.statusBarHeight || 20
}
})
}
//
const handleBack = () => {
uni.navigateBack()
}
//
const toggleAgreement = () => {
isAgreed.value = !isAgreed.value
}
//
const handleLogin = async () => {
if (!isAgreed.value) {
uni.showToast({ title: '请先同意用户协议和隐私政策', icon: 'none' })
return
}
try {
const loginRes = await new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: resolve,
fail: reject
})
})
if (loginRes.code) {
uni.showLoading({ title: '登录中...' })
const res = await login(loginRes.code)
uni.hideLoading()
if (res && res.code === 0 && res.data) {
userStore.login({
token: res.data.token,
userInfo: {
userId: res.data.userId,
nickname: res.data.nickname,
avatar: res.data.avatar,
xiangQinNo: res.data.xiangQinNo,
isProfileCompleted: res.data.isProfileCompleted,
isMember: res.data.isMember,
memberLevel: res.data.memberLevel,
isRealName: res.data.isRealName
}
})
uni.showToast({ title: '登录成功', icon: 'success' })
//
setTimeout(() => {
uni.reLaunch({ url: '/pages/index/index' })
}, 1000)
} else {
uni.showToast({ title: res?.message || '登录失败', icon: 'none' })
}
}
} catch (error) {
uni.hideLoading()
console.error('登录失败:', error)
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
}
}
//
const handleUserAgreement = () => {
uni.showToast({ title: '功能开发中', icon: 'none' })
}
//
const handlePrivacyPolicy = () => {
uni.showToast({ title: '功能开发中', icon: 'none' })
}
onMounted(() => {
getSystemInfo()
})
return {
statusBarHeight,
isAgreed,
handleBack,
toggleAgreement,
handleLogin,
handleUserAgreement,
handlePrivacyPolicy
}
}
}
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background-color: #f5f6fa;
display: flex;
flex-direction: column;
}
//
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background-color: #f5f6fa;
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
.navbar-back {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
font-size: 48rpx;
color: #333;
font-weight: 300;
}
}
.navbar-title {
font-size: 34rpx;
font-weight: 600;
color: #333;
}
.navbar-placeholder {
width: 60rpx;
}
}
}
// Logo
.logo-section {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 120rpx;
.logo-wrapper {
width: 360rpx;
height: 360rpx;
background: #fff;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.logo-img {
width: 200rpx;
height: 200rpx;
}
}
}
//
.bottom-section {
padding: 60rpx 48rpx 80rpx;
.btn-login {
width: 100%;
height: 96rpx;
line-height: 96rpx;
background: linear-gradient(135deg, #FFBDC2 0%, #FF8A93 100%);
border-radius: 48rpx;
font-size: 34rpx;
color: #fff;
border: none;
font-weight: 500;
&::after {
border: none;
}
}
.agreement-row {
display: flex;
align-items: center;
justify-content: center;
margin-top: 32rpx;
.check-icon {
width: 36rpx;
height: 36rpx;
margin-right: 12rpx;
}
.agreement-text {
font-size: 26rpx;
color: #666;
.link {
color: #FF8A93;
}
}
}
}
</style>

View File

@ -2,106 +2,147 @@
<view class="message-page">
<!-- 页面加载状态 -->
<Loading type="page" :loading="pageLoading" />
<!-- 互动统计区域 -->
<view class="interact-section">
<view class="interact-grid">
<view class="interact-item" @click="navigateTo('/pages/interact/viewedMe')">
<view class="interact-count">{{ interactCounts.viewedMe || 0 }}</view>
<view class="interact-label">看过我</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/interact/favoritedMe')">
<view class="interact-count">{{ interactCounts.favoritedMe || 0 }}</view>
<view class="interact-label">收藏我</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/interact/unlockedMe')">
<view class="interact-count">{{ interactCounts.unlockedMe || 0 }}</view>
<view class="interact-label">解锁我</view>
</view>
<!-- 顶部背景图 -->
<view class="top-bg">
<image src="/static/title_bg.png" mode="aspectFill" class="bg-img" />
</view>
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<text class="navbar-title">消息</text>
</view>
</view>
<!-- 聊天会话列表 -->
<view class="session-section">
<view class="section-header">
<text class="section-title">聊天消息</text>
</view>
<!-- 会话列表 -->
<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-image"
: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="fixed-header" :style="{ top: navbarHeight + 'px' }">
<!-- 互动统计区域 -->
<view class="interact-section">
<view class="interact-grid">
<view class="interact-item" @click="navigateTo('/pages/interact/viewedMe')">
<view class="interact-icon viewed">
<image src="/static/ic_seen.png" mode="aspectFit" class="icon-img" />
</view>
<text class="interact-label">看过我</text>
<view class="interact-badge" v-if="interactCounts.viewedMe > 0">
<text>+{{ interactCounts.viewedMe }}</text>
</view>
</view>
<!-- 会话信息 -->
<view class="session-info">
<view class="session-header">
<text class="session-nickname">{{ session.targetNickname }}</text>
<text class="session-time">{{ formatTime(session.lastMessageTime) }}</text>
<view class="interact-item" @click="navigateTo('/pages/interact/favoritedMe')">
<view class="interact-icon favorited">
<image src="/static/ic_collection.png" mode="aspectFit" class="icon-img" />
</view>
<view class="session-content">
<text class="last-message">{{ session.lastMessage || '暂无消息' }}</text>
<text class="interact-label">收藏我</text>
<view class="interact-badge" v-if="interactCounts.favoritedMe > 0">
<text>+{{ interactCounts.favoritedMe }}</text>
</view>
</view>
<view class="interact-item" @click="navigateTo('/pages/interact/unlockedMe')">
<view class="interact-icon unlocked">
<image src="/static/ic_unlock.png" mode="aspectFit" class="icon-img" />
</view>
<text class="interact-label">解锁我</text>
<view class="interact-badge" v-if="interactCounts.unlockedMe > 0">
<text>+{{ interactCounts.unlockedMe }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<Empty
v-else-if="!listLoading"
text="暂无聊天消息"
buttonText="去相亲"
buttonUrl="/pages/index/index"
/>
<!-- 加载更多 -->
<Loading
type="more"
:loading="listLoading"
:noMore="noMoreData && sessions.length > 0"
/>
</view>
<!-- 消息列表区域可滚动 -->
<scroll-view
class="message-scroll"
scroll-y
:style="{ height: scrollHeight + 'px' }"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="handleRefresh"
>
<view class="message-section">
<!-- 系统消息入口 -->
<view class="system-message-item" @click="navigateTo('/pages/message/system')">
<view class="system-avatar">
<image :src="defaultAvatar" mode="aspectFill" class="avatar-img" />
</view>
<view class="system-info">
<text class="system-title">系统消息</text>
</view>
<view class="system-arrow">
<text class="arrow-icon"></text>
</view>
</view>
<!-- 聊天会话列表 -->
<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>
</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>
</view>
</scroll-view>
</view>
</template>
<script>
import { ref, onMounted } from 'vue'
import { useChatStore } from '@/store/chat.js'
import { useUserStore } from '@/store/user.js'
import { getSessions } from '@/api/chat.js'
import { getViewedMe, getFavoritedMe, getUnlockedMe } from '@/api/interact.js'
import { formatTimestamp } from '@/utils/format.js'
import Loading from '@/components/Loading/index.vue'
import Empty from '@/components/Empty/index.vue'
export default {
name: 'MessagePage',
components: {
Loading,
Empty
Loading
},
setup() {
const chatStore = useChatStore()
const userStore = useUserStore()
//
const pageLoading = ref(true)
const listLoading = ref(false)
const noMoreData = ref(false)
const isRefreshing = ref(false)
//
const statusBarHeight = ref(20)
const navbarHeight = ref(64)
const scrollHeight = ref(500)
//
const sessions = ref([])
const interactCounts = ref({
@ -109,20 +150,51 @@ export default {
favoritedMe: 0,
unlockedMe: 0
})
//
const defaultAvatar = '/static/logo.png'
//
const getSystemInfo = () => {
uni.getSystemInfo({
success: (res) => {
statusBarHeight.value = res.statusBarHeight || 20
// = + 44px
navbarHeight.value = statusBarHeight.value + 44
//
//
setTimeout(() => {
calcScrollHeight(res.windowHeight)
}, 100)
}
})
}
//
const calcScrollHeight = (windowHeight) => {
const query = uni.createSelectorQuery()
query.select('.fixed-header').boundingClientRect((rect) => {
if (rect) {
// - - - tabbar
const tabbarHeight = 50
scrollHeight.value = windowHeight - navbarHeight.value - rect.height - tabbarHeight
}
}).exec()
}
//
const loadInteractCounts = async () => {
//
if (!userStore.isLoggedIn) return
try {
//
const [viewedRes, favoritedRes, unlockedRes] = await Promise.all([
getViewedMe(1, 1),
getFavoritedMe(1, 1),
getUnlockedMe(1, 1)
])
interactCounts.value = {
viewedMe: viewedRes?.data?.total || 0,
favoritedMe: favoritedRes?.data?.total || 0,
@ -132,9 +204,12 @@ export default {
console.error('加载互动统计失败:', error)
}
}
//
const loadSessions = async () => {
//
if (!userStore.isLoggedIn) return
listLoading.value = true
try {
const res = await getSessions()
@ -149,7 +224,7 @@ export default {
listLoading.value = false
}
}
//
const initPage = async () => {
pageLoading.value = true
@ -164,50 +239,65 @@ export default {
pageLoading.value = false
}
}
//
const handleRefresh = async () => {
isRefreshing.value = true
try {
await Promise.all([
loadInteractCounts(),
loadSessions()
])
} finally {
isRefreshing.value = false
}
}
//
const formatTime = (timestamp) => {
return formatTimestamp(timestamp)
}
//
const navigateTo = (url) => {
uni.navigateTo({ url })
}
//
const handleSessionClick = (session) => {
chatStore.setCurrentSession(session.sessionId)
uni.navigateTo({
url: `/pages/chat/index?sessionId=${session.sessionId}&targetUserId=${session.targetUserId}`
uni.navigateTo({
url: `/pages/chat/index?sessionId=${session.sessionId}&targetUserId=${session.targetUserId}`
})
}
onMounted(() => {
getSystemInfo()
initPage()
})
return {
pageLoading,
listLoading,
noMoreData,
isRefreshing,
statusBarHeight,
navbarHeight,
scrollHeight,
sessions,
interactCounts,
defaultAvatar,
formatTime,
navigateTo,
handleSessionClick
handleSessionClick,
handleRefresh,
initPage,
loadInteractCounts,
loadSessions
}
},
//
onPullDownRefresh() {
this.initPage && this.initPage().finally(() => {
uni.stopPullDownRefresh()
})
},
//
onShow() {
//
if (this.loadInteractCounts) {
this.loadInteractCounts()
}
@ -220,142 +310,280 @@ export default {
<style lang="scss" scoped>
.message-page {
min-height: 100vh;
background-color: #f8f8f8;
padding-bottom: 120rpx;
height: 100vh;
background-color: #f5f6fa;
display: flex;
flex-direction: column;
overflow: hidden;
}
//
.interact-section {
background-color: #fff;
padding: 30rpx 20rpx;
margin-bottom: 20rpx;
//
.top-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 400rpx;
z-index: 1;
.interact-grid {
display: flex;
justify-content: space-around;
.interact-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 40rpx;
.interact-count {
font-size: 44rpx;
font-weight: 600;
color: #ff6b6b;
margin-bottom: 8rpx;
}
.interact-label {
font-size: 26rpx;
color: #666;
}
}
.bg-img {
width: 100%;
height: 100%;
}
}
//
.session-section {
background-color: #fff;
.section-header {
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
.section-title {
font-size: 30rpx;
//
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
.navbar-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
}
.session-list {
.session-item {
}
//
.fixed-header {
position: fixed;
left: 0;
right: 0;
z-index: 10;
}
//
.interact-section {
position: relative;
padding: 24rpx 40rpx 32rpx;
.interact-grid {
display: flex;
justify-content: space-between;
.interact-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f8f8f8;
}
.session-avatar {
position: relative;
width: 100rpx;
height: 100rpx;
margin-right: 24rpx;
flex-shrink: 0;
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
position: relative;
.interact-icon {
width: 120rpx;
height: 120rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
.icon-img {
width: 56rpx;
height: 56rpx;
}
.unread-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 36rpx;
height: 36rpx;
padding: 0 8rpx;
background-color: #ff4d4f;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 22rpx;
color: #fff;
line-height: 1;
}
&.viewed {
background: linear-gradient(135deg, #e8e0ff 0%, #d4c4ff 100%);
}
&.favorited {
background: linear-gradient(135deg, #ffe0e8 0%, #ffc4d4 100%);
}
&.unlocked {
background: linear-gradient(135deg, #fff3e0 0%, #ffe4c4 100%);
}
}
.session-info {
flex: 1;
overflow: hidden;
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
.session-nickname {
font-size: 30rpx;
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
.session-time {
font-size: 24rpx;
color: #999;
flex-shrink: 0;
}
.interact-label {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.interact-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 44rpx;
height: 44rpx;
padding: 0 12rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 24rpx;
color: #fff;
font-weight: 500;
}
.session-content {
.last-message {
font-size: 26rpx;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
}
&:nth-child(1) .interact-badge {
background: #9b7bff;
}
&:nth-child(2) .interact-badge {
background: #ff6b8a;
}
&:nth-child(3) .interact-badge {
background: #ffb347;
}
}
}
}
//
.message-scroll {
flex: 1;
position: fixed;
left: 0;
right: 0;
bottom: 100rpx;
}
//
.message-section {
background-color: #F3F3F3;
border-radius: 24rpx 24rpx 0 0;
min-height: 100%;
}
//
.system-message-item {
display: flex;
align-items: center;
padding: 32rpx;
border-bottom: 1rpx solid #f5f5f5;
// background: linear-gradient(90deg, #f8fbff 0%, #fff 100%);
border-radius: 24rpx 24rpx 0 0;
.system-avatar {
width: 96rpx;
height: 96rpx;
margin-right: 24rpx;
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
.system-info {
flex: 1;
.system-title {
font-size: 32rpx;
font-weight: 600;
color: #52c41a;
}
}
.system-arrow {
.arrow-icon {
font-size: 32rpx;
color: #ccc;
}
}
}
//
.session-list {
.session-item {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f8f8f8;
}
.session-avatar {
position: relative;
width: 96rpx;
height: 96rpx;
margin-right: 24rpx;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.unread-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 36rpx;
height: 36rpx;
padding: 0 8rpx;
background-color: #ff4d4f;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 22rpx;
color: #fff;
line-height: 1;
}
}
}
.session-info {
flex: 1;
overflow: hidden;
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
.session-nickname {
font-size: 30rpx;
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
.session-time {
font-size: 24rpx;
color: #999;
flex-shrink: 0;
}
}
.session-content {
.last-message {
font-size: 26rpx;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,35 @@
<template>
<view class="profile-edit-page">
<!-- 步骤指示器 -->
<view class="step-indicator">
<view
v-for="(step, index) in steps"
:key="index"
class="step-item"
:class="{ active: currentStep === index, completed: currentStep > index }"
>
<view class="step-dot">{{ index + 1 }}</view>
<text class="step-label">{{ step.label }}</text>
<!-- 固定头部区域 -->
<view class="fixed-header">
<!-- 自定义导航栏带渐变背景 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="navbar-back" @click="handleBack">
<text class="back-icon"></text>
</view>
<text class="navbar-title">填写资料</text>
<view class="navbar-placeholder"></view>
</view>
</view>
<!-- 步骤指示器白色背景不在渐变内 -->
<view class="step-indicator">
<view
v-for="(step, index) in steps"
:key="index"
class="step-item"
:class="{ active: currentStep === index, completed: currentStep > index }"
>
<view class="step-dot">{{ index + 1 }}</view>
<text class="step-label">{{ step.label }}</text>
</view>
</view>
</view>
<!-- 占位区域 -->
<view class="header-placeholder" :style="{ height: headerHeight + 'px' }"></view>
<!-- 步骤内容 -->
<view class="step-content">
<!-- 步骤1: 基础信息 -->
@ -99,6 +116,7 @@
:value="formData.nickname"
disabled
placeholder="填写关系和姓氏后自动生成"
placeholder-class="input-placeholder"
/>
</view>
@ -139,6 +157,21 @@
</view>
</picker>
</view>
<!-- 籍贯 -->
<view class="form-item">
<text class="form-label">籍贯</text>
<picker
mode="region"
:value="[formData.homeProvince, formData.homeCity, formData.homeDistrict]"
@change="handleHomeLocationChange"
>
<view class="picker-value">
{{ homeLocationText || '请选择' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<!-- 步骤2: 详细信息 -->
@ -458,6 +491,21 @@
</view>
</view>
</view>
<!-- 期望月收入 -->
<view class="form-item">
<text class="form-label">期望月收入</text>
<picker
:value="requirementIncomeMinIndex"
:range="incomeRangeOptions"
@change="handleIncomeMinChange"
>
<view class="picker-value">
{{ getIncomeLabel(formData.requirement.monthlyIncomeMin) }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<!-- 步骤5: 联系方式 -->
@ -474,17 +522,21 @@
/>
</view>
<!-- 手机号验证提示 -->
<!-- 手机号验证 -->
<view class="form-item">
<text class="form-label">手机号验证</text>
<button
v-if="!phoneVerified"
class="verify-btn"
:disabled="!formData.weChatNo"
@click="handlePhoneVerify"
open-type="getPhoneNumber"
@getphonenumber="handleGetPhoneNumber"
>
{{ phoneVerified ? '已验证' : '点击验证手机号' }}
点击验证手机号
</button>
<text v-if="!formData.weChatNo" class="form-tip">请先填写微信号</text>
<view v-else class="phone-verified">
<text class="phone-number">{{ formData.phone }}</text>
<text class="verified-tag">已验证</text>
</view>
</view>
</view>
</view>
@ -520,11 +572,31 @@
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useUserStore } from '@/store/user.js'
import { createOrUpdate, getMyProfile, uploadPhotos, deletePhoto } from '@/api/profile.js'
import { createOrUpdate, getMyProfile, uploadPhotos, deletePhoto, cleanupOrphanPhotos } from '@/api/profile.js'
import { generateNickname, getBirthYearRange } from '@/utils/format.js'
const userStore = useUserStore()
//
const statusBarHeight = ref(20)
const headerHeight = ref(120)
//
const getSystemInfo = () => {
uni.getSystemInfo({
success: (res) => {
statusBarHeight.value = res.statusBarHeight || 20
// + (44px) + (100px)
headerHeight.value = statusBarHeight.value + 44 + 100
}
})
}
//
const handleBack = () => {
uni.navigateBack()
}
//
const steps = [
{ label: '基础信息' },
@ -542,25 +614,29 @@ const phoneVerified = ref(false)
const formData = reactive({
photos: [],
isPhotoPublic: true,
relationship: 0,
relationship: 1, // ""
surname: '',
nickname: '',
childGender: 0,
childGender: 1, // ""
birthYear: 0,
education: 0,
education: 1, // ""
workProvince: '',
workCity: '',
workDistrict: '',
homeProvince: '',
homeCity: '',
homeDistrict: '',
occupation: '',
monthlyIncome: 0,
monthlyIncome: 1, // "5000"
height: 0,
weight: 0,
houseStatus: 0,
carStatus: 0,
marriageStatus: 0,
houseStatus: 5, // ""
carStatus: 2, // ""
marriageStatus: 1, // ""
expectMarryTime: 0,
introduction: '',
weChatNo: '',
phone: '', //
requirement: {
ageMin: 0,
ageMax: 0,
@ -587,11 +663,12 @@ const relationshipOptions = [
]
const educationOptions = [
{ value: 1, label: '高中及以下' },
{ value: 2, label: '大专' },
{ value: 3, label: '本科' },
{ value: 4, label: '硕士' },
{ value: 5, label: '博士' }
{ value: 1, label: '高中' },
{ value: 2, label: '中专' },
{ value: 3, label: '大专' },
{ value: 4, label: '本科' },
{ value: 5, label: '研究生' },
{ value: 6, label: '博士及以上' }
]
const incomeOptions = [
@ -603,30 +680,30 @@ const incomeOptions = [
]
const houseOptions = [
{ value: 1, label: '无房' },
{ value: 2, label: '有房贷' },
{ value: 3, label: '有房无贷' },
{ value: 4, label: '多套房' }
{ value: 1, label: '现居地已购房' },
{ value: 2, label: '家乡已购房' },
{ value: 3, label: '婚后购房' },
{ value: 4, label: '父母同住' },
{ value: 5, label: '租房' },
{ value: 6, label: '近期有购房计划' }
]
const carOptions = [
{ value: 1, label: '车' },
{ value: 2, label: '有车贷' },
{ value: 3, label: '有车无贷' }
{ value: 1, label: '已购车' },
{ value: 2, label: '无车' },
{ value: 3, label: '近期购车' }
]
const marriageOptions = [
{ value: 1, label: '未婚' },
{ value: 2, label: '离异无孩' },
{ value: 3, label: '离异有孩' },
{ value: 4, label: '丧偶' }
{ value: 2, label: '离异未育' },
{ value: 3, label: '离异已育' }
]
const expectMarryOptions = [
{ value: 1, label: '1年内' },
{ value: 2, label: '2年内' },
{ value: 3, label: '3年内' },
{ value: 4, label: '看缘分' }
{ value: 1, label: '尽快结婚' },
{ value: 2, label: '一到两年内' },
{ value: 3, label: '孩子满意就结婚' }
]
// (Property 9: Birth Year Range)
@ -668,6 +745,22 @@ const heightRangeOptions = computed(() => {
return options
})
//
const incomeRangeOptions = ['不限', '5000以下', '5000-10000', '10000-20000', '20000-50000', '50000以上']
//
const getIncomeLabel = (value) => {
if (!value) return '不限'
const labels = {
1: '5000以下',
2: '5000-10000',
3: '10000-20000',
4: '20000-50000',
5: '50000以上'
}
return labels[value] || '不限'
}
//
const relationshipIndex = computed(() => {
const idx = relationshipOptions.findIndex(o => o.value === formData.relationship)
@ -740,6 +833,11 @@ const requirementHeightMaxIndex = computed(() => {
return heightRangeOptions.value.indexOf(formData.requirement.heightMax)
})
const requirementIncomeMinIndex = computed(() => {
if (!formData.requirement.monthlyIncomeMin) return 0
return formData.requirement.monthlyIncomeMin
})
//
const workLocationText = computed(() => {
if (formData.workProvince && formData.workCity) {
@ -748,6 +846,13 @@ const workLocationText = computed(() => {
return ''
})
const homeLocationText = computed(() => {
if (formData.homeProvince && formData.homeCity) {
return `${formData.homeProvince} ${formData.homeCity} ${formData.homeDistrict || ''}`
}
return ''
})
const city1Text = computed(() => {
if (formData.requirement.city1Province && formData.requirement.city1City) {
return `${formData.requirement.city1Province} ${formData.requirement.city1City}`
@ -792,6 +897,13 @@ const handleWorkLocationChange = (e) => {
formData.workDistrict = district || ''
}
const handleHomeLocationChange = (e) => {
const [province, city, district] = e.detail.value
formData.homeProvince = province
formData.homeCity = city
formData.homeDistrict = district || ''
}
const handleIncomeChange = (e) => {
formData.monthlyIncome = incomeOptions[e.detail.value].value
}
@ -848,6 +960,11 @@ const handleHeightMaxChange = (e) => {
formData.requirement.heightMax = val === '不限' ? 0 : val
}
const handleIncomeMinChange = (e) => {
const idx = e.detail.value
formData.requirement.monthlyIncomeMin = idx === 0 ? 0 : idx
}
const handleCity1Change = (e) => {
const [province, city] = e.detail.value
formData.requirement.city1Province = province
@ -929,14 +1046,17 @@ const handleChoosePhoto = () => {
uploadRes.data.photos.forEach(photo => {
if (formData.photos.length < 5) {
formData.photos.push({
id: photo.id,
url: photo.photoUrl
id: photo.id || null, // id
url: photo.url || photo.photoUrl
})
}
})
} else {
uni.showToast({ title: '上传失败', icon: 'none' })
}
} catch (error) {
uni.showToast({ title: '上传失败', icon: 'none' })
console.error('上传照片错误:', error)
uni.showToast({ title: error.message || '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
@ -958,16 +1078,39 @@ const handleDeletePhoto = async (index) => {
formData.photos.splice(index, 1)
}
// (Property 11: WeChat Field Validation)
const handlePhoneVerify = () => {
if (!formData.weChatNo) {
uni.showToast({ title: '请先填写微信号', icon: 'none' })
// -
const handleGetPhoneNumber = async (e) => {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
uni.showToast({ title: '取消授权', icon: 'none' })
return
}
//
// 使 button open-type="getPhoneNumber"
uni.showToast({ title: '请使用微信授权获取手机号', icon: 'none' })
// code
const code = e.detail.code
if (!code) {
uni.showToast({ title: '获取手机号失败', icon: 'none' })
return
}
uni.showLoading({ title: '验证中...' })
try {
const { decryptPhone } = await import('@/api/user.js')
const res = await decryptPhone(code)
if (res && res.code === 0 && res.data?.phone) {
formData.phone = res.data.phone
phoneVerified.value = true
uni.showToast({ title: '验证成功', icon: 'success' })
} else {
uni.showToast({ title: res?.message || '验证失败', icon: 'none' })
}
} catch (error) {
console.error('验证手机号失败:', error)
uni.showToast({ title: '验证失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
//
@ -1081,9 +1224,9 @@ const handleSubmit = async () => {
const res = await createOrUpdate(submitData)
if (res && res.success) {
if (res && res.code === 0) {
//
userStore.setProfileCompleted(true)
userStore.updateUserInfo({ isProfileCompleted: true })
uni.showToast({ title: '资料提交成功', icon: 'success' })
@ -1094,6 +1237,7 @@ const handleSubmit = async () => {
uni.showToast({ title: res?.message || '提交失败', icon: 'none' })
}
} catch (error) {
console.error('提交资料失败:', error)
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
} finally {
submitting.value = false
@ -1104,36 +1248,40 @@ const handleSubmit = async () => {
const loadProfile = async () => {
try {
const res = await getMyProfile()
if (res && res.success && res.data) {
if (res && res.code === 0 && res.data) {
const profile = res.data
//
formData.isPhotoPublic = profile.isPhotoPublic ?? true
formData.relationship = profile.relationship || 0
formData.relationship = profile.relationship || 1
formData.surname = profile.surname || ''
formData.nickname = profile.nickname || ''
formData.childGender = profile.childGender || 0
formData.childGender = profile.childGender || 1
formData.birthYear = profile.birthYear || 0
formData.education = profile.education || 0
formData.education = profile.education || 1
formData.workProvince = profile.workProvince || ''
formData.workCity = profile.workCity || ''
formData.workDistrict = profile.workDistrict || ''
formData.homeProvince = profile.homeProvince || ''
formData.homeCity = profile.homeCity || ''
formData.homeDistrict = profile.homeDistrict || ''
formData.occupation = profile.occupation || ''
formData.monthlyIncome = profile.monthlyIncome || 0
formData.monthlyIncome = profile.monthlyIncome || 1
formData.height = profile.height || 0
formData.weight = profile.weight || 0
formData.houseStatus = profile.houseStatus || 0
formData.carStatus = profile.carStatus || 0
formData.marriageStatus = profile.marriageStatus || 0
formData.houseStatus = profile.houseStatus || 5
formData.carStatus = profile.carStatus || 2
formData.marriageStatus = profile.marriageStatus || 1
formData.expectMarryTime = profile.expectMarryTime || 0
formData.introduction = profile.introduction || ''
formData.weChatNo = profile.weChatNo || ''
//
// - URL
const STATIC_URL = 'http://localhost:5001'
if (profile.photos && profile.photos.length > 0) {
formData.photos = profile.photos.map(p => ({
id: p.id,
url: p.photoUrl
url: p.photoUrl.startsWith('http') ? p.photoUrl : `${STATIC_URL}${p.photoUrl}`
}))
}
@ -1156,13 +1304,27 @@ const loadProfile = async () => {
marriageStatus: profile.requirement.marriageStatus || []
}
}
} else {
// Profile
try {
await cleanupOrphanPhotos()
} catch (e) {
console.log('清理孤立照片:', e.message)
}
}
} catch (error) {
console.error('加载资料失败:', error)
//
try {
await cleanupOrphanPhotos()
} catch (e) {
console.log('清理孤立照片:', e.message)
}
}
}
onMounted(() => {
getSystemInfo()
loadProfile()
})
</script>
@ -1174,8 +1336,63 @@ onMounted(() => {
padding-bottom: 140rpx;
}
//
//
.fixed-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
}
//
.custom-navbar {
position: relative;
z-index: 1;
background: linear-gradient(90deg, #FFDEE0 0%, #FF939C 100%);
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
.navbar-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
font-size: 64rpx;
color: #333;
font-weight: 300;
}
}
.navbar-title {
font-size: 34rpx;
font-weight: 600;
color: #333;
}
.navbar-placeholder {
width: 80rpx;
}
}
}
//
.header-placeholder {
width: 100%;
}
//
.step-indicator {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
padding: 30rpx 40rpx;
@ -1202,16 +1419,17 @@ onMounted(() => {
.step-label {
font-size: 22rpx;
color: #999;
color: #666;
}
&.active {
.step-dot {
background: linear-gradient(135deg, #ff6b6b 0%, #ff5252 100%);
background: #FF6B6B;
color: #fff;
}
.step-label {
color: #ff6b6b;
color: #FF6B6B;
font-weight: 500;
}
}
@ -1230,6 +1448,8 @@ onMounted(() => {
//
.step-content {
padding: 20rpx;
position: relative;
z-index: 1;
}
.form-section {
@ -1271,6 +1491,11 @@ onMounted(() => {
border-radius: 12rpx;
font-size: 28rpx;
box-sizing: border-box;
&[disabled] {
color: #999;
background-color: #f0f0f0;
}
}
.form-tip {
@ -1503,12 +1728,37 @@ onMounted(() => {
}
}
//
.phone-verified {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
padding: 0 20rpx;
background-color: #f0fff0;
border-radius: 12rpx;
.phone-number {
font-size: 28rpx;
color: #333;
}
.verified-tag {
font-size: 24rpx;
color: #4cd964;
padding: 4rpx 12rpx;
background-color: #e8f8e8;
border-radius: 8rpx;
}
}
//
.bottom-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 999;
display: flex;
gap: 20rpx;
padding: 20rpx 30rpx;
@ -1539,3 +1789,11 @@ onMounted(() => {
}
}
</style>
<!-- scoped 样式用于 placeholder-class -->
<style lang="scss">
.input-placeholder {
color: #999 !important;
font-size: 28rpx !important;
}
</style>

View File

@ -0,0 +1,432 @@
<template>
<view class="personal-page">
<!-- 顶部背景图 -->
<view class="top-bg">
<image src="/static/title_bg.png" mode="aspectFill" class="bg-img" />
</view>
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="navbar-back" @click="handleBack">
<text class="back-icon"></text>
</view>
<text class="navbar-title">个人资料</text>
<view class="navbar-placeholder"></view>
</view>
</view>
<!-- 头像区域 -->
<view class="avatar-section" :style="{ marginTop: (statusBarHeight + 44) + 'px' }">
<view class="avatar-wrapper" @click="handleChangeAvatar">
<image
class="avatar-img"
:src="userInfo.avatar || defaultAvatar"
mode="aspectFill"
/>
<view class="avatar-edit-icon">
<text></text>
</view>
</view>
</view>
<!-- 表单区域 -->
<view class="form-section">
<view class="form-item">
<text class="form-label">用户名</text>
<view class="form-input">
<input
type="text"
:value="nickname"
@input="e => nickname = e.detail.value"
placeholder="请输入用户名"
maxlength="20"
class="input-field"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">相亲编号</text>
<view class="form-input disabled" @click="handleCopyXiangQinNo">
<text>{{ userInfo.xiangQinNo || '未设置' }}</text>
<text class="copy-icon">复制</text>
</view>
</view>
</view>
<!-- 保存按钮 -->
<view class="btn-section">
<button class="btn-save" @click="handleSave">保存</button>
</view>
</view>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { useUserStore } from '@/store/user.js'
import { updateAvatar, updateNickname } from '@/api/user.js'
import { getMyProfile } from '@/api/profile.js'
export default {
name: 'PersonalPage',
setup() {
const userStore = useUserStore()
const defaultAvatar = '/static/logo.png'
const statusBarHeight = ref(20)
const nickname = ref('')
const userInfo = computed(() => ({
avatar: userStore.avatar,
xiangQinNo: userStore.xiangQinNo,
nickname: userStore.nickname
}))
// store nickname
watch(() => userStore.nickname, (newVal) => {
if (newVal && !nickname.value) {
nickname.value = newVal
}
}, { immediate: true })
//
const getSystemInfo = () => {
uni.getSystemInfo({
success: (res) => {
statusBarHeight.value = res.statusBarHeight || 20
}
})
}
//
const handleBack = () => {
uni.navigateBack()
}
//
const handleChangeAvatar = () => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePath = res.tempFilePaths[0]
uni.showLoading({ title: '上传中...' })
try {
//
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
url: `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001/api'}/app/upload`,
filePath: tempFilePath,
name: 'file',
header: {
Authorization: `Bearer ${userStore.token}`
},
success: (res) => {
if (res.statusCode === 200) {
resolve(JSON.parse(res.data))
} else {
reject(new Error('上传失败'))
}
},
fail: reject
})
})
if (uploadRes.code === 0 && uploadRes.data?.url) {
// API
const saveRes = await updateAvatar(uploadRes.data.url)
if (saveRes && saveRes.code === 0) {
userStore.updateUserInfo({ avatar: uploadRes.data.url })
uni.showToast({ title: '头像更新成功', icon: 'success' })
} else {
uni.showToast({ title: saveRes?.message || '保存失败', icon: 'none' })
}
} else {
uni.showToast({ title: uploadRes.message || '上传失败', icon: 'none' })
}
} catch (error) {
console.error('上传头像失败:', error)
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
})
}
//
const handleCopyXiangQinNo = () => {
const xiangQinNo = userStore.xiangQinNo
if (!xiangQinNo) {
uni.showToast({ title: '暂无编号', icon: 'none' })
return
}
uni.setClipboardData({
data: xiangQinNo,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
}
//
const handleSave = async () => {
//
if (nickname.value && nickname.value !== userStore.nickname) {
uni.showLoading({ title: '保存中...' })
try {
const res = await updateNickname(nickname.value)
if (res && res.code === 0) {
userStore.updateUserInfo({ nickname: nickname.value })
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1000)
} else {
uni.hideLoading()
uni.showToast({ title: res?.message || '保存失败', icon: 'none' })
}
} catch (error) {
uni.hideLoading()
console.error('保存失败:', error)
uni.showToast({ title: '保存失败', icon: 'none' })
}
} else {
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1000)
}
}
// - API
const initNickname = async () => {
userStore.restoreFromStorage()
// API
try {
const res = await getMyProfile()
if (res && res.code === 0 && res.data) {
// 使 nickname使 surname
const profileNickname = res.data.nickname || res.data.surname || ''
if (profileNickname) {
nickname.value = profileNickname
userStore.updateUserInfo({ nickname: profileNickname })
}
}
} catch (error) {
console.error('获取用户资料失败:', error)
// API store
if (userStore.nickname) {
nickname.value = userStore.nickname
}
}
}
onMounted(() => {
getSystemInfo()
initNickname()
})
return {
userInfo,
defaultAvatar,
statusBarHeight,
nickname,
handleBack,
handleChangeAvatar,
handleCopyXiangQinNo,
handleSave,
initNickname
}
},
//
onShow() {
if (this.initNickname) {
this.initNickname()
}
}
}
</script>
<style lang="scss" scoped>
.personal-page {
height: 100vh;
background-color: #f5f6fa;
overflow: hidden;
}
//
.top-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 400rpx;
z-index: 0;
.bg-img {
width: 100%;
height: 100%;
}
}
//
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
.navbar-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
font-size: 64rpx;
color: #333;
font-weight: 300;
}
}
.navbar-title {
font-size: 34rpx;
font-weight: 600;
color: #333;
}
.navbar-placeholder {
width: 60rpx;
}
}
}
//
.avatar-section {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
padding: 60rpx 0;
.avatar-wrapper {
position: relative;
width: 180rpx;
height: 180rpx;
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(135deg, #87ceeb 0%, #5fb3d4 100%);
}
.avatar-edit-icon {
position: absolute;
right: 0;
bottom: 0;
width: 56rpx;
height: 56rpx;
background: linear-gradient(135deg, #ffb5b5 0%, #ff9a9a 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid #fff;
text {
font-size: 28rpx;
color: #fff;
}
}
}
}
//
.form-section {
position: relative;
z-index: 1;
padding: 0 32rpx;
.form-item {
margin-bottom: 32rpx;
.form-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 16rpx;
}
.form-input {
background: #f0f0f0;
border-radius: 16rpx;
padding: 28rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
text {
font-size: 32rpx;
color: #333;
}
.input-field {
flex: 1;
font-size: 32rpx;
color: #333;
background: transparent;
}
.copy-icon {
font-size: 26rpx;
color: #ff9a9a;
}
&.disabled {
background: #f0f0f0;
text {
color: #333;
}
}
}
}
}
//
.btn-section {
position: relative;
z-index: 1;
padding: 60rpx 32rpx;
.btn-save {
width: 100%;
height: 96rpx;
line-height: 96rpx;
background: linear-gradient(135deg, #ffb5b5 0%, #ff9a9a 100%);
border-radius: 48rpx;
font-size: 34rpx;
color: #fff;
border: none;
&::after {
border: none;
}
}
}
</style>

BIN
miniapp/static/butler.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
miniapp/static/ic_check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
miniapp/static/ic_exit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
miniapp/static/ic_seen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
miniapp/static/title_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using XiangYi.Application.DTOs.Responses;
using XiangYi.Application.Interfaces;
namespace XiangYi.AdminApi.Controllers;
/// <summary>
/// 后台系统配置控制器
/// </summary>
[ApiController]
[Route("api/admin/config")]
[Authorize]
public class AdminConfigController : ControllerBase
{
private readonly ISystemConfigService _configService;
private readonly ILogger<AdminConfigController> _logger;
public AdminConfigController(
ISystemConfigService configService,
ILogger<AdminConfigController> logger)
{
_configService = configService;
_logger = logger;
}
/// <summary>
/// 获取默认头像
/// </summary>
[HttpGet("defaultAvatar")]
public async Task<ApiResponse<DefaultAvatarResponse>> GetDefaultAvatar()
{
var avatarUrl = await _configService.GetDefaultAvatarAsync();
return ApiResponse<DefaultAvatarResponse>.Success(new DefaultAvatarResponse
{
AvatarUrl = avatarUrl
});
}
/// <summary>
/// 设置默认头像
/// </summary>
[HttpPost("defaultAvatar")]
public async Task<ApiResponse> SetDefaultAvatar([FromBody] SetDefaultAvatarRequest request)
{
if (string.IsNullOrWhiteSpace(request.AvatarUrl))
{
return ApiResponse.Error(40001, "头像URL不能为空");
}
var result = await _configService.SetDefaultAvatarAsync(request.AvatarUrl);
return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败");
}
/// <summary>
/// 获取所有系统配置
/// </summary>
[HttpGet("all")]
public async Task<ApiResponse<Dictionary<string, string>>> GetAllConfigs()
{
var configs = await _configService.GetAllConfigsAsync();
return ApiResponse<Dictionary<string, string>>.Success(configs);
}
}
/// <summary>
/// 默认头像响应
/// </summary>
public class DefaultAvatarResponse
{
/// <summary>
/// 头像URL
/// </summary>
public string? AvatarUrl { get; set; }
}
/// <summary>
/// 设置默认头像请求
/// </summary>
public class SetDefaultAvatarRequest
{
/// <summary>
/// 头像URL
/// </summary>
public string AvatarUrl { get; set; } = string.Empty;
}

View File

@ -98,6 +98,19 @@ public class AdminUserController : ControllerBase
var adminIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return long.TryParse(adminIdClaim, out var adminId) ? adminId : 0;
}
/// <summary>
/// 删除用户(硬删除)
/// </summary>
/// <param name="id">用户ID</param>
/// <returns>操作结果</returns>
[HttpDelete("{id}")]
public async Task<ApiResponse> DeleteUser(long id)
{
var adminId = GetCurrentAdminId();
var result = await _adminUserService.DeleteUserAsync(id, adminId);
return result ? ApiResponse.Success("删除成功") : ApiResponse.Error(40001, "删除失败");
}
}
/// <summary>

View File

@ -101,6 +101,19 @@ public class ProfileController : ControllerBase
}
}
/// <summary>
/// 清理未提交资料用户的照片
/// 当用户进入填写资料页面时调用,清理之前未提交的孤立照片
/// </summary>
/// <returns>清理结果</returns>
[HttpPost("photos/cleanup")]
public async Task<ApiResponse> CleanupPhotos()
{
var userId = GetCurrentUserId();
var count = await _profileService.CleanupPhotosIfNoProfileAsync(userId);
return ApiResponse.Success($"已清理{count}张照片");
}
/// <summary>
/// 删除照片
/// </summary>

View File

@ -0,0 +1,135 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using XiangYi.Application.DTOs.Responses;
using XiangYi.Infrastructure.Storage;
namespace XiangYi.AppApi.Controllers;
/// <summary>
/// 小程序文件上传控制器
/// </summary>
[ApiController]
[Route("api/app/upload")]
[Authorize]
public class UploadController : ControllerBase
{
private readonly IStorageProvider _storageProvider;
private readonly ILogger<UploadController> _logger;
private readonly IWebHostEnvironment _environment;
// 允许的图片类型
private static readonly string[] AllowedImageTypes = { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
// 最大文件大小 5MB
private const long MaxFileSize = 5 * 1024 * 1024;
public UploadController(
IStorageProvider storageProvider,
ILogger<UploadController> logger,
IWebHostEnvironment environment)
{
_storageProvider = storageProvider;
_logger = logger;
_environment = environment;
}
/// <summary>
/// 上传图片
/// </summary>
/// <param name="file">图片文件</param>
/// <returns>图片URL</returns>
[HttpPost]
public async Task<ApiResponse<UploadResult>> Upload(IFormFile file)
{
if (file == null || file.Length == 0)
{
return ApiResponse<UploadResult>.Error(40001, "请选择要上传的文件");
}
// 检查文件大小
if (file.Length > MaxFileSize)
{
return ApiResponse<UploadResult>.Error(40002, "文件大小不能超过5MB");
}
// 检查文件类型
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedImageTypes.Contains(extension))
{
return ApiResponse<UploadResult>.Error(40003, "只支持 jpg、jpeg、png、gif、webp 格式的图片");
}
try
{
// 生成文件名
var fileName = $"{Guid.NewGuid():N}{extension}";
var folder = $"app/{DateTime.Now:yyyyMMdd}";
// 上传文件
string url;
using var stream = file.OpenReadStream();
var relativePath = await _storageProvider.UploadAsync(stream, fileName, folder);
// 如果返回的是相对路径转换为完整URL
if (relativePath.StartsWith("/"))
{
var request = HttpContext.Request;
var baseUrl = $"{request.Scheme}://{request.Host}";
url = $"{baseUrl}{relativePath}";
}
else
{
url = relativePath;
}
_logger.LogInformation("文件上传成功: {FileName} -> {Url}", file.FileName, url);
return ApiResponse<UploadResult>.Success(new UploadResult
{
Url = url,
FileName = file.FileName
});
}
catch (Exception ex)
{
_logger.LogError(ex, "文件上传失败: {FileName}", file.FileName);
return ApiResponse<UploadResult>.Error(50001, "文件上传失败");
}
}
/// <summary>
/// 保存到本地
/// </summary>
private async Task<string> SaveToLocalAsync(IFormFile file, string fileName, string folder)
{
var uploadPath = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", folder);
if (!Directory.Exists(uploadPath))
{
Directory.CreateDirectory(uploadPath);
}
var filePath = Path.Combine(uploadPath, fileName);
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
// 返回完整URL
var request = HttpContext.Request;
var baseUrl = $"{request.Scheme}://{request.Host}";
return $"{baseUrl}/uploads/{folder}/{fileName}";
}
}
/// <summary>
/// 上传结果
/// </summary>
public class UploadResult
{
/// <summary>
/// 文件URL
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// 原始文件名
/// </summary>
public string FileName { get; set; } = string.Empty;
}

View File

@ -4,6 +4,7 @@ using XiangYi.Application.DTOs.Requests;
using XiangYi.Application.DTOs.Responses;
using XiangYi.Application.Interfaces;
using XiangYi.Core.Constants;
using XiangYi.Infrastructure.WeChat;
using System.Security.Claims;
namespace XiangYi.AppApi.Controllers;
@ -20,6 +21,7 @@ public class UserController : ControllerBase
private readonly ISearchService _searchService;
private readonly IProfileService _profileService;
private readonly IInteractService _interactService;
private readonly IWeChatService _weChatService;
private readonly ILogger<UserController> _logger;
public UserController(
@ -27,25 +29,30 @@ public class UserController : ControllerBase
ISearchService searchService,
IProfileService profileService,
IInteractService interactService,
IWeChatService weChatService,
ILogger<UserController> logger)
{
_recommendService = recommendService;
_searchService = searchService;
_profileService = profileService;
_interactService = interactService;
_weChatService = weChatService;
_logger = logger;
}
/// <summary>
/// 获取每日推荐列表
/// </summary>
/// <param name="pageIndex">页码</param>
/// <param name="pageSize">每页数量</param>
/// <returns>推荐用户列表</returns>
[HttpGet("recommend")]
public async Task<ApiResponse<List<RecommendUserResponse>>> GetRecommend()
[AllowAnonymous]
public async Task<ApiResponse<PagedResult<RecommendUserResponse>>> GetRecommend([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 10)
{
var userId = GetCurrentUserId();
var result = await _recommendService.GetDailyRecommendAsync(userId);
return ApiResponse<List<RecommendUserResponse>>.Success(result);
var result = await _recommendService.GetDailyRecommendAsync(userId, pageIndex, pageSize);
return ApiResponse<PagedResult<RecommendUserResponse>>.Success(result);
}
/// <summary>
@ -106,6 +113,90 @@ public class UserController : ControllerBase
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return long.TryParse(userIdClaim, out var userId) ? userId : 0;
}
/// <summary>
/// 更新头像
/// </summary>
/// <param name="request">头像请求</param>
/// <returns>更新结果</returns>
[HttpPost("avatar")]
public async Task<ApiResponse> UpdateAvatar([FromBody] UpdateAvatarRequest request)
{
if (string.IsNullOrEmpty(request.Avatar))
{
return ApiResponse.Error(ErrorCodes.InvalidParameter, "头像URL不能为空");
}
var userId = GetCurrentUserId();
var success = await _profileService.UpdateAvatarAsync(userId, request.Avatar);
if (!success)
{
return ApiResponse.Error(ErrorCodes.UserNotFound, "用户不存在");
}
return ApiResponse.Success("头像更新成功");
}
/// <summary>
/// 更新昵称
/// </summary>
/// <param name="request">昵称请求</param>
/// <returns>更新结果</returns>
[HttpPost("nickname")]
public async Task<ApiResponse> UpdateNickname([FromBody] UpdateNicknameRequest request)
{
if (string.IsNullOrEmpty(request.Nickname))
{
return ApiResponse.Error(ErrorCodes.InvalidParameter, "昵称不能为空");
}
if (request.Nickname.Length > 20)
{
return ApiResponse.Error(ErrorCodes.InvalidParameter, "昵称不能超过20个字符");
}
var userId = GetCurrentUserId();
var success = await _profileService.UpdateNicknameAsync(userId, request.Nickname);
if (!success)
{
return ApiResponse.Error(ErrorCodes.UserNotFound, "用户不存在");
}
return ApiResponse.Success("昵称更新成功");
}
/// <summary>
/// 解密微信手机号
/// </summary>
/// <param name="request">解密请求</param>
/// <returns>手机号</returns>
[HttpPost("phone/decrypt")]
public async Task<ApiResponse<DecryptPhoneResponse>> DecryptPhone([FromBody] DecryptPhoneRequest request)
{
if (string.IsNullOrEmpty(request.Code))
{
return ApiResponse<DecryptPhoneResponse>.Error(ErrorCodes.InvalidParameter, "code不能为空");
}
try
{
var phone = await _weChatService.GetPhoneNumberAsync(request.Code);
if (string.IsNullOrEmpty(phone))
{
return ApiResponse<DecryptPhoneResponse>.Error(ErrorCodes.WeChatServiceError, "获取手机号失败");
}
return ApiResponse<DecryptPhoneResponse>.Success(new DecryptPhoneResponse { Phone = phone });
}
catch (Exception ex)
{
_logger.LogError(ex, "解密手机号失败");
return ApiResponse<DecryptPhoneResponse>.Error(ErrorCodes.WeChatServiceError, "获取手机号失败");
}
}
}
/// <summary>
@ -118,3 +209,47 @@ public class UserDetailRequest
/// </summary>
public long UserId { get; set; }
}
/// <summary>
/// 更新头像请求
/// </summary>
public class UpdateAvatarRequest
{
/// <summary>
/// 头像URL
/// </summary>
public string Avatar { get; set; } = string.Empty;
}
/// <summary>
/// 更新昵称请求
/// </summary>
public class UpdateNicknameRequest
{
/// <summary>
/// 昵称
/// </summary>
public string Nickname { get; set; } = string.Empty;
}
/// <summary>
/// 解密手机号请求
/// </summary>
public class DecryptPhoneRequest
{
/// <summary>
/// 微信返回的code
/// </summary>
public string Code { get; set; } = string.Empty;
}
/// <summary>
/// 解密手机号响应
/// </summary>
public class DecryptPhoneResponse
{
/// <summary>
/// 手机号
/// </summary>
public string Phone { get; set; } = string.Empty;
}

View File

@ -17,10 +17,10 @@
},
"ConnectionStrings": {
"BizDb": "Server=192.168.195.15;Database=XiangYi_Biz;User Id=sa;Password=Dbt@com@123;TrustServerCertificate=true;",
"Redis": "localhost:6379,defaultDatabase=0"
"Redis": "192.168.195.15:6379,defaultDatabase=0"
},
"Redis": {
"ConnectionString": "localhost:6379,defaultDatabase=0",
"ConnectionString": "192.168.195.15:6379,defaultDatabase=0",
"DefaultDatabase": 0,
"KeyPrefix": "xiangyi:app:",
"DefaultExpireSeconds": 3600,
@ -34,14 +34,23 @@
"ExpireMinutes": 10080
},
"WeChat": {
"AppId": "",
"AppSecret": ""
"MiniProgram": {
"AppId": "wx21b4110b18b31831",
"AppSecret": "fe3b5aa5715820cd66af3d42d55efad6"
}
},
"TencentCos": {
"SecretId": "",
"SecretKey": "",
"Region": "ap-guangzhou",
"BucketName": ""
"Storage": {
"Provider": "Local",
"Local": {
"BasePath": "wwwroot/uploads",
"BaseUrl": "/uploads"
},
"TencentCos": {
"SecretId": "",
"SecretKey": "",
"Region": "ap-guangzhou",
"BucketName": ""
}
},
"AliyunSms": {
"AccessKeyId": "",

View File

@ -264,12 +264,28 @@ public class RequirementResponse
public class UploadPhotoResponse
{
/// <summary>
/// 上传成功的照片URL列表
/// 上传成功的照片列表包含ID和URL
/// </summary>
public List<string> PhotoUrls { get; set; } = new();
public List<UploadedPhotoInfo> Photos { get; set; } = new();
/// <summary>
/// 当前照片总数
/// </summary>
public int TotalCount { get; set; }
}
/// <summary>
/// 上传的照片信息
/// </summary>
public class UploadedPhotoInfo
{
/// <summary>
/// 照片ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 照片URL
/// </summary>
public string PhotoUrl { get; set; } = string.Empty;
}

View File

@ -20,21 +20,41 @@ public class RecommendUserResponse
/// </summary>
public string? Avatar { get; set; }
/// <summary>
/// 性别 1=男 2=女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 年龄
/// </summary>
public int Age { get; set; }
/// <summary>
/// 出生年份
/// </summary>
public int BirthYear { get; set; }
/// <summary>
/// 工作城市
/// </summary>
public string? WorkCity { get; set; }
/// <summary>
/// 家乡
/// </summary>
public string? Hometown { get; set; }
/// <summary>
/// 身高(cm)
/// </summary>
public int Height { get; set; }
/// <summary>
/// 体重(kg)
/// </summary>
public int Weight { get; set; }
/// <summary>
/// 学历
/// </summary>
@ -50,6 +70,16 @@ public class RecommendUserResponse
/// </summary>
public string? Occupation { get; set; }
/// <summary>
/// 月收入
/// </summary>
public int MonthlyIncome { get; set; }
/// <summary>
/// 个人简介
/// </summary>
public string? Intro { get; set; }
/// <summary>
/// 是否会员
/// </summary>

View File

@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReportService, ReportService>();
services.AddScoped<ISearchService, SearchService>();
services.AddScoped<ISensitiveWordService, SensitiveWordService>();
services.AddScoped<ISystemConfigService, SystemConfigService>();
return services;
}

View File

@ -44,4 +44,12 @@ public interface IAdminUserService
/// <param name="gender">性别1男 2女null则随机</param>
/// <returns>创建的用户ID列表</returns>
Task<List<long>> CreateTestUsersAsync(int count, int? gender = null);
/// <summary>
/// 删除用户(硬删除,仅用于测试数据清理)
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="adminId">操作管理员ID</param>
/// <returns>是否成功</returns>
Task<bool> DeleteUserAsync(long userId, long adminId);
}

View File

@ -62,4 +62,27 @@ public interface IProfileService
/// <param name="uploadCount">要上传的数量</param>
/// <returns>是否可以上传</returns>
Task<bool> CanUploadPhotosAsync(long userId, int uploadCount);
/// <summary>
/// 更新用户头像
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="avatarUrl">头像URL</param>
/// <returns>是否成功</returns>
Task<bool> UpdateAvatarAsync(long userId, string avatarUrl);
/// <summary>
/// 更新用户昵称
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="nickname">昵称</param>
/// <returns>是否成功</returns>
Task<bool> UpdateNicknameAsync(long userId, string nickname);
/// <summary>
/// 清理没有关联Profile的孤立照片
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>清理的照片数量</returns>
Task<int> CleanupPhotosIfNoProfileAsync(long userId);
}

View File

@ -8,11 +8,13 @@ namespace XiangYi.Application.Interfaces;
public interface IRecommendService
{
/// <summary>
/// 获取用户的每日推荐列表
/// 获取用户的每日推荐列表(分页)
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>推荐用户列表</returns>
Task<List<RecommendUserResponse>> GetDailyRecommendAsync(long userId);
/// <param name="userId">用户ID0表示未登录</param>
/// <param name="pageIndex">页码</param>
/// <param name="pageSize">每页数量</param>
/// <returns>分页推荐用户列表</returns>
Task<PagedResult<RecommendUserResponse>> GetDailyRecommendAsync(long userId, int pageIndex = 1, int pageSize = 10);
/// <summary>
/// 为指定用户生成每日推荐列表

View File

@ -0,0 +1,32 @@
namespace XiangYi.Application.Interfaces;
/// <summary>
/// 系统配置服务接口
/// </summary>
public interface ISystemConfigService
{
/// <summary>
/// 获取配置值
/// </summary>
Task<string?> GetConfigValueAsync(string key);
/// <summary>
/// 设置配置值
/// </summary>
Task<bool> SetConfigValueAsync(string key, string value, string? description = null);
/// <summary>
/// 获取默认头像URL
/// </summary>
Task<string?> GetDefaultAvatarAsync();
/// <summary>
/// 设置默认头像URL
/// </summary>
Task<bool> SetDefaultAvatarAsync(string avatarUrl);
/// <summary>
/// 获取所有配置
/// </summary>
Task<Dictionary<string, string>> GetAllConfigsAsync();
}

View File

@ -418,6 +418,27 @@ public class AdminUserService : IAdminUserService
return createdIds;
}
/// <inheritdoc />
public async Task<bool> DeleteUserAsync(long userId, long adminId)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
throw new BusinessException(ErrorCodes.UserNotFound, "用户不存在");
}
// 删除用户相关数据
await _photoRepository.DeleteAsync(p => p.UserId == userId);
await _requirementRepository.DeleteAsync(r => r.UserId == userId);
await _profileRepository.DeleteAsync(p => p.UserId == userId);
await _userRepository.DeleteAsync(userId);
_logger.LogInformation("管理员删除用户: AdminId={AdminId}, UserId={UserId}, Nickname={Nickname}",
adminId, userId, user.Nickname);
return true;
}
#region Private Helper Methods
private static AdminUserListDto MapToUserListDto(User user, UserProfile? profile)

View File

@ -24,6 +24,7 @@ public class AuthService : IAuthService
private readonly IRepository<User> _userRepository;
private readonly IWeChatService _weChatService;
private readonly ICacheService _cacheService;
private readonly ISystemConfigService _systemConfigService;
private readonly JwtOptions _jwtOptions;
private readonly ILogger<AuthService> _logger;
private static readonly Random _random = new();
@ -32,12 +33,14 @@ public class AuthService : IAuthService
IRepository<User> userRepository,
IWeChatService weChatService,
ICacheService cacheService,
ISystemConfigService systemConfigService,
IOptions<JwtOptions> jwtOptions,
ILogger<AuthService> logger)
{
_userRepository = userRepository;
_weChatService = weChatService;
_cacheService = cacheService;
_systemConfigService = systemConfigService;
_jwtOptions = jwtOptions.Value;
_logger = logger;
}
@ -66,11 +69,15 @@ public class AuthService : IAuthService
isNewUser = true;
var xiangQinNo = await GenerateXiangQinNoAsync();
// 获取默认头像
var defaultAvatar = await _systemConfigService.GetDefaultAvatarAsync();
existingUser = new User
{
OpenId = weChatResult.OpenId,
UnionId = weChatResult.UnionId,
XiangQinNo = xiangQinNo,
Avatar = defaultAvatar,
Status = 1,
ContactCount = 2,
CreateTime = DateTime.Now,

View File

@ -165,14 +165,32 @@ public class ProfileService : IProfileService
// 获取资料
var profiles = await _profileRepository.GetListAsync(p => p.UserId == userId);
var profile = profiles.FirstOrDefault();
// 获取照片(无论是否有 Profile 都获取)
var photos = await _photoRepository.GetListAsync(p => p.UserId == userId);
// 如果没有 Profile但有照片返回只包含照片的响应
if (profile == null)
{
// 如果有照片,返回包含照片的空资料
if (photos.Any())
{
return new ProfileResponse
{
UserId = userId,
Nickname = user.Nickname,
XiangQinNo = user.XiangQinNo,
Photos = photos.OrderBy(p => p.Sort).Select(p => new PhotoResponse
{
Id = p.Id,
PhotoUrl = p.PhotoUrl,
Sort = p.Sort
}).ToList()
};
}
return null;
}
// 获取照片
var photos = await _photoRepository.GetListAsync(p => p.UserId == userId);
// 获取择偶要求
var requirements = await _requirementRepository.GetListAsync(r => r.UserId == userId);
var requirement = requirements.FirstOrDefault();
@ -236,7 +254,7 @@ public class ProfileService : IProfileService
}
// 3. 上传照片
var uploadedUrls = new List<string>();
var uploadedPhotos = new List<UploadedPhotoInfo>();
var sort = currentCount;
foreach (var stream in photoStreams)
@ -256,15 +274,19 @@ public class ProfileService : IProfileService
};
await _photoRepository.AddAsync(photo);
uploadedUrls.Add(photoUrl);
uploadedPhotos.Add(new UploadedPhotoInfo
{
Id = photo.Id,
PhotoUrl = photoUrl
});
}
_logger.LogInformation("用户上传照片成功: UserId={UserId}, Count={Count}", userId, uploadedUrls.Count);
_logger.LogInformation("用户上传照片成功: UserId={UserId}, Count={Count}", userId, uploadedPhotos.Count);
return new UploadPhotoResponse
{
PhotoUrls = uploadedUrls,
TotalCount = currentCount + uploadedUrls.Count
Photos = uploadedPhotos,
TotalCount = currentCount + uploadedPhotos.Count
};
}
@ -443,4 +465,69 @@ public class ProfileService : IProfileService
// 注:即使是会员,也需要通过交换请求才能查看不公开的照片
return new List<string>();
}
/// <inheritdoc />
public async Task<bool> UpdateAvatarAsync(long userId, string avatarUrl)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
return false;
}
user.Avatar = avatarUrl;
user.UpdateTime = DateTime.Now;
await _userRepository.UpdateAsync(user);
_logger.LogInformation("用户头像更新成功: UserId={UserId}, Avatar={Avatar}", userId, avatarUrl);
return true;
}
/// <inheritdoc />
public async Task<bool> UpdateNicknameAsync(long userId, string nickname)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
return false;
}
user.Nickname = nickname;
user.UpdateTime = DateTime.Now;
await _userRepository.UpdateAsync(user);
_logger.LogInformation("用户昵称更新成功: UserId={UserId}, Nickname={Nickname}", userId, nickname);
return true;
}
/// <inheritdoc />
public async Task<int> CleanupPhotosIfNoProfileAsync(long userId)
{
// 检查用户是否有 Profile 记录
var profiles = await _profileRepository.GetListAsync(p => p.UserId == userId);
if (profiles.Any())
{
// 有 Profile不需要清理
return 0;
}
// 没有 Profile清理所有照片
var photos = await _photoRepository.GetListAsync(p => p.UserId == userId);
if (!photos.Any())
{
return 0;
}
var count = photos.Count;
foreach (var photo in photos)
{
// 删除云存储文件
await _storageProvider.DeleteAsync(photo.PhotoUrl);
// 删除数据库记录
await _photoRepository.DeleteAsync(photo.Id);
}
_logger.LogInformation("清理用户孤立照片: UserId={UserId}, Count={Count}", userId, count);
return count;
}
}

View File

@ -76,10 +76,16 @@ public class RecommendService : IRecommendService
}
/// <inheritdoc />
public async Task<List<RecommendUserResponse>> GetDailyRecommendAsync(long userId)
public async Task<PagedResult<RecommendUserResponse>> GetDailyRecommendAsync(long userId, int pageIndex = 1, int pageSize = 10)
{
var today = DateTime.Today;
// 未登录用户,返回默认推荐(随机推荐已审核通过的用户)
if (userId <= 0)
{
return await GetDefaultRecommendAsync(pageIndex, pageSize);
}
// 获取今日推荐列表
var recommends = await _recommendRepository.GetListAsync(
r => r.UserId == userId && r.RecommendDate == today);
@ -92,9 +98,16 @@ public class RecommendService : IRecommendService
r => r.UserId == userId && r.RecommendDate == today);
}
var total = recommends.Count;
var pagedRecommends = recommends
.OrderBy(r => r.Sort)
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToList();
var result = new List<RecommendUserResponse>();
foreach (var recommend in recommends.OrderBy(r => r.Sort))
foreach (var recommend in pagedRecommends)
{
var recommendUser = await _userRepository.GetByIdAsync(recommend.RecommendUserId);
if (recommendUser == null || recommendUser.Status != 1)
@ -117,12 +130,18 @@ public class RecommendService : IRecommendService
UserId = recommendUser.Id,
Nickname = recommendUser.Nickname,
Avatar = recommendUser.Avatar,
Gender = recommendUser.Gender ?? 0,
Age = DateTime.Now.Year - profile.BirthYear,
BirthYear = profile.BirthYear,
WorkCity = profile.WorkCity,
Hometown = profile.HomeCity,
Height = profile.Height,
Weight = profile.Weight,
Education = profile.Education,
EducationName = GetEducationName(profile.Education),
Occupation = profile.Occupation,
MonthlyIncome = profile.MonthlyIncome,
Intro = profile.Introduction,
IsMember = recommendUser.IsMember,
IsRealName = recommendUser.IsRealName,
IsPhotoPublic = profile.IsPhotoPublic,
@ -134,7 +153,85 @@ public class RecommendService : IRecommendService
});
}
return result;
return new PagedResult<RecommendUserResponse>
{
Items = result,
Total = total,
PageIndex = pageIndex,
PageSize = pageSize
};
}
/// <summary>
/// 获取默认推荐列表(未登录用户)
/// </summary>
private async Task<PagedResult<RecommendUserResponse>> GetDefaultRecommendAsync(int pageIndex, int pageSize)
{
// 获取已完成资料、状态正常的用户
var allUsers = await _userRepository.GetListAsync(
u => u.IsProfileCompleted && u.Status == 1);
// 过滤出审核通过的用户
var validUsers = new List<(Core.Entities.Biz.User User, Core.Entities.Biz.UserProfile Profile)>();
foreach (var user in allUsers.OrderByDescending(u => u.CreateTime))
{
var profiles = await _profileRepository.GetListAsync(p => p.UserId == user.Id);
var profile = profiles.FirstOrDefault();
if (profile != null && profile.AuditStatus == (int)AuditStatus.Approved)
{
validUsers.Add((user, profile));
}
}
var total = validUsers.Count;
var pagedUsers = validUsers
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToList();
var result = new List<RecommendUserResponse>();
var sort = (pageIndex - 1) * pageSize;
foreach (var (user, profile) in pagedUsers)
{
var photos = await _photoRepository.GetListAsync(p => p.UserId == user.Id);
var firstPhoto = profile.IsPhotoPublic ? photos.OrderBy(p => p.Sort).FirstOrDefault()?.PhotoUrl : null;
result.Add(new RecommendUserResponse
{
UserId = user.Id,
Nickname = user.Nickname,
Avatar = user.Avatar,
Gender = user.Gender ?? 0,
Age = DateTime.Now.Year - profile.BirthYear,
BirthYear = profile.BirthYear,
WorkCity = profile.WorkCity,
Hometown = profile.HomeCity,
Height = profile.Height,
Weight = profile.Weight,
Education = profile.Education,
EducationName = GetEducationName(profile.Education),
Occupation = profile.Occupation,
MonthlyIncome = profile.MonthlyIncome,
Intro = profile.Introduction,
IsMember = user.IsMember,
IsRealName = user.IsRealName,
IsPhotoPublic = profile.IsPhotoPublic,
FirstPhoto = firstPhoto,
ViewedToday = false,
MatchScore = 0,
Sort = sort++,
IsNewUser = user.CreateTime > DateTime.Now.AddMinutes(-NewUserPriorityMinutes)
});
}
return new PagedResult<RecommendUserResponse>
{
Items = result,
Total = total,
PageIndex = pageIndex,
PageSize = pageSize
};
}
/// <inheritdoc />

View File

@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
using XiangYi.Application.Interfaces;
using XiangYi.Core.Entities.Biz;
using XiangYi.Core.Interfaces;
namespace XiangYi.Application.Services;
/// <summary>
/// 系统配置服务实现
/// </summary>
public class SystemConfigService : ISystemConfigService
{
private readonly IRepository<SystemConfig> _configRepository;
private readonly ILogger<SystemConfigService> _logger;
/// <summary>
/// 默认头像配置键
/// </summary>
public const string DefaultAvatarKey = "default_avatar";
public SystemConfigService(
IRepository<SystemConfig> configRepository,
ILogger<SystemConfigService> logger)
{
_configRepository = configRepository;
_logger = logger;
}
/// <inheritdoc />
public async Task<string?> GetConfigValueAsync(string key)
{
var configs = await _configRepository.GetListAsync(c => c.ConfigKey == key);
return configs.FirstOrDefault()?.ConfigValue;
}
/// <inheritdoc />
public async Task<bool> SetConfigValueAsync(string key, string value, string? description = null)
{
try
{
var configs = await _configRepository.GetListAsync(c => c.ConfigKey == key);
var config = configs.FirstOrDefault();
if (config != null)
{
config.ConfigValue = value;
config.UpdateTime = DateTime.Now;
if (description != null)
{
config.Description = description;
}
await _configRepository.UpdateAsync(config);
}
else
{
config = new SystemConfig
{
ConfigKey = key,
ConfigValue = value,
ConfigType = "system",
Description = description,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now
};
await _configRepository.AddAsync(config);
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "设置配置失败: {Key}", key);
return false;
}
}
/// <inheritdoc />
public async Task<string?> GetDefaultAvatarAsync()
{
return await GetConfigValueAsync(DefaultAvatarKey);
}
/// <inheritdoc />
public async Task<bool> SetDefaultAvatarAsync(string avatarUrl)
{
return await SetConfigValueAsync(DefaultAvatarKey, avatarUrl, "新用户默认头像");
}
/// <inheritdoc />
public async Task<Dictionary<string, string>> GetAllConfigsAsync()
{
var configs = await _configRepository.GetListAsync(c => true);
return configs.ToDictionary(c => c.ConfigKey, c => c.ConfigValue);
}
}