修补分享功能和活动封面上传功能

This commit is contained in:
taiyi 2026-05-21 10:50:48 +08:00
parent 7b50e2768b
commit 8c0bafdbd2
16 changed files with 676 additions and 66 deletions

View File

@ -1,4 +1,4 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://ghxiangqin.com/api/v1' export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://ghxiangqin.com/api/v1'
export async function apiRequest(url, options = {}) { export async function apiRequest(url, options = {}) {
const token = localStorage.getItem('admin_token') || '' const token = localStorage.getItem('admin_token') || ''

View File

@ -2,10 +2,14 @@
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { apiRequest } from '../../utils/api' import { API_BASE_URL, apiRequest } from '../../utils/api'
const router = useRouter() const router = useRouter()
const submitting = ref(false) const submitting = ref(false)
const coverUploading = ref(false)
const descriptionUploading = ref(false)
const descriptionEditor = ref(null)
const resizingState = ref(null)
const form = reactive({ const form = reactive({
title: '', title: '',
description: '', description: '',
@ -24,8 +28,208 @@ const form = reactive({
is_published: false is_published: false
}) })
function getEditorHtml() {
return descriptionEditor.value?.innerHTML || ''
}
function syncDescriptionFromEditor() {
form.description = getEditorHtml()
}
function onDescriptionInput() {
syncDescriptionFromEditor()
}
function insertHtmlAtCursor(html) {
const editor = descriptionEditor.value
if (!editor) return
editor.focus()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
editor.insertAdjacentHTML('beforeend', html)
syncDescriptionFromEditor()
return
}
const range = selection.getRangeAt(0)
if (!editor.contains(range.commonAncestorContainer)) {
editor.insertAdjacentHTML('beforeend', html)
syncDescriptionFromEditor()
return
}
range.deleteContents()
const fragment = range.createContextualFragment(html)
const lastNode = fragment.lastChild
range.insertNode(fragment)
if (lastNode) {
range.setStartAfter(lastNode)
range.setEndAfter(lastNode)
selection.removeAllRanges()
selection.addRange(range)
}
syncDescriptionFromEditor()
}
function parseImageSizeValue(value, fallback) {
const match = String(value || '').match(/\d+/)
return match ? Number(match[0]) : fallback
}
function updateResizableImage(image, widthPx) {
const nextWidth = Math.max(180, Math.min(widthPx, 720))
image.dataset.width = String(nextWidth)
image.style.width = `${nextWidth}px`
image.style.maxWidth = '100%'
image.style.height = 'auto'
image.style.display = 'block'
image.style.margin = '0 auto'
image.style.objectFit = 'contain'
}
function clearImageResizeOverlay() {
const editor = descriptionEditor.value
if (!editor) return
editor.querySelector('.image-resize-overlay')?.remove()
resizingState.value = null
}
function showImageResizeOverlay(image) {
const editor = descriptionEditor.value
if (!editor) return
clearImageResizeOverlay()
const wrapper = image.closest('.activity-image-wrap')
if (!wrapper) return
const overlay = document.createElement('div')
overlay.className = 'image-resize-overlay'
const handle = document.createElement('span')
handle.className = 'image-resize-handle'
overlay.appendChild(handle)
wrapper.appendChild(overlay)
const syncOverlay = () => {
overlay.style.width = `${image.offsetWidth}px`
overlay.style.height = `${image.offsetHeight}px`
}
syncOverlay()
const onPointerDown = (event) => {
event.preventDefault()
event.stopPropagation()
resizingState.value = { image, overlay }
const originX = event.clientX
const originWidth = parseImageSizeValue(image.dataset.width, image.getBoundingClientRect().width)
const moveHandler = (moveEvent) => {
const delta = moveEvent.clientX - originX
updateResizableImage(image, originWidth + delta)
syncOverlay()
}
const upHandler = () => {
document.removeEventListener('pointermove', moveHandler)
document.removeEventListener('pointerup', upHandler)
syncDescriptionFromEditor()
clearImageResizeOverlay()
}
document.addEventListener('pointermove', moveHandler)
document.addEventListener('pointerup', upHandler)
}
handle.addEventListener('pointerdown', onPointerDown)
overlay.addEventListener('pointerdown', (event) => event.stopPropagation())
editor.addEventListener('pointerdown', clearImageResizeOverlay, { once: true })
}
function prepareResizableImage(image) {
if (!image.dataset.width) {
const naturalWidth = image.naturalWidth || image.getBoundingClientRect().width || 360
updateResizableImage(image, Math.min(Math.max(naturalWidth, 280), 720))
} else {
updateResizableImage(image, parseImageSizeValue(image.dataset.width, 360))
}
image.classList.add('activity-image')
image.contentEditable = 'false'
image.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
showImageResizeOverlay(image)
})
}
async function uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
const token = localStorage.getItem('admin_token') || ''
const response = await fetch(`${API_BASE_URL}/upload/activity-image`, {
method: 'POST',
headers: {
Authorization: token ? `Bearer ${token}` : ''
},
body: formData
})
const text = await response.text()
let data = null
if (text) {
try {
data = JSON.parse(text)
} catch {
throw new Error(text || response.statusText || '图片上传失败')
}
}
if (!response.ok || !data || data.code !== 0) {
throw new Error(data?.detail || data?.message || response.statusText || '图片上传失败')
}
return data.data.image_url
}
async function handleCoverChange(event) {
const file = event.target.files?.[0]
if (!file) return
coverUploading.value = true
try {
form.cover_image = await uploadImage(file)
ElMessage.success('封面图上传成功')
} catch (error) {
ElMessage.error(error.message)
} finally {
coverUploading.value = false
event.target.value = ''
}
}
async function handleDescriptionImageUpload(event) {
const file = event.target.files?.[0]
if (!file) return
descriptionUploading.value = true
try {
const url = await uploadImage(file)
insertHtmlAtCursor(`<p class="activity-image-wrap"><img src="${url}" alt="活动图片" class="activity-image" data-width="360" /></p>`)
const editor = descriptionEditor.value
const images = editor?.querySelectorAll('.activity-image')
const image = images?.[images.length - 1]
if (image) {
prepareResizableImage(image)
clearImageResizeOverlay()
}
ElMessage.success('图片插入成功')
} catch (error) {
ElMessage.error(error.message)
} finally {
descriptionUploading.value = false
event.target.value = ''
}
}
async function createActivity() { async function createActivity() {
submitting.value = true submitting.value = true
syncDescriptionFromEditor()
try { try {
await apiRequest('/admin/activities', { await apiRequest('/admin/activities', {
method: 'POST', method: 'POST',
@ -68,7 +272,17 @@ function goBack() {
<el-option label="其他" value="other" /> <el-option label="其他" value="other" />
</el-select> </el-select>
<el-input v-model="form.location" placeholder="活动地点" /> <el-input v-model="form.location" placeholder="活动地点" />
<el-input v-model="form.cover_image" placeholder="封面图 URL" /> <div class="cover-upload full-row">
<div class="field-label">封面图</div>
<div class="cover-upload-row">
<input type="file" accept="image/*" @change="handleCoverChange" />
<span class="upload-note">支持 JPG / PNG上传后自动填入封面地址</span>
</div>
<div v-if="form.cover_image" class="cover-preview">
<img :src="form.cover_image" alt="封面预览" />
</div>
<div v-else class="empty-preview">尚未上传封面图</div>
</div>
<el-date-picker <el-date-picker
v-model="form.start_time" v-model="form.start_time"
type="datetime" type="datetime"
@ -101,15 +315,48 @@ function goBack() {
value-format="YYYY-MM-DDTHH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss"
class="full-width-picker" class="full-width-picker"
/> />
<el-input-number v-model="form.capacity_male" :min="0" /> <div class="numeric-field">
<el-input-number v-model="form.capacity_female" :min="0" /> <div class="field-label">男性名额</div>
<el-input-number v-model="form.match_window_hours" :min="1" :max="168" /> <el-input-number v-model="form.capacity_male" :min="0" />
<el-input-number v-model="form.selection_limit" :min="1" :max="9" /> <!-- <div class="field-hint">活动中男性参与者的最大报名人数0 表示不限制</div>-->
</div>
<div class="numeric-field">
<div class="field-label">女性名额</div>
<el-input-number v-model="form.capacity_female" :min="0" />
<!-- <div class="field-hint">活动中女性参与者的最大报名人数0 表示不限制</div>-->
</div>
<div class="numeric-field">
<div class="field-label">匹配窗口小时</div>
<el-input-number v-model="form.match_window_hours" :min="1" :max="168" />
<!-- <div class="field-hint">活动结束后系统会在这个时间窗口内继续进行匹配处理</div>-->
</div>
<div class="numeric-field">
<div class="field-label">每人可选择人数</div>
<el-input-number v-model="form.selection_limit" :min="1" :max="9" />
<!-- <div class="field-hint">每位用户在匹配阶段最多能选择的候选人数</div>-->
</div>
<div class="switch-row"> <div class="switch-row">
<el-switch v-model="form.require_audit" active-text="需审核" inactive-text="免审核" /> <el-switch v-model="form.require_audit" active-text="需审核" inactive-text="免审核" />
<el-switch v-model="form.is_published" active-text="立即发布" inactive-text="先存草稿" /> <el-switch v-model="form.is_published" active-text="立即发布" inactive-text="先存草稿" />
</div> </div>
<el-input v-model="form.description" type="textarea" :rows="8" placeholder="活动描述" class="full-row" /> <div class="description-box full-row">
<div class="field-header">
<div class="field-label">活动描述</div>
<label class="image-upload-button" :class="{ disabled: descriptionUploading }">
<input type="file" accept="image/*" :disabled="descriptionUploading" @change="handleDescriptionImageUpload" />
{{ descriptionUploading ? '图片上传中...' : '插入图片' }}
</label>
</div>
<div class="field-hint description-hint">点击图片后可拖动右下角手柄调整大小</div>
<div
ref="descriptionEditor"
class="description-editor"
contenteditable="true"
data-placeholder="请输入活动描述,支持直接输入文字,也可以插入图片"
@input="onDescriptionInput"
@blur="syncDescriptionFromEditor"
></div>
</div>
<div class="actions full-row"> <div class="actions full-row">
<el-button @click="goBack">取消</el-button> <el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="submitting" @click="createActivity">创建活动</el-button> <el-button type="primary" :loading="submitting" @click="createActivity">创建活动</el-button>
@ -170,4 +417,148 @@ function goBack() {
.full-row { .full-row {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.field-label {
font-weight: 600;
color: #334155;
margin-bottom: 10px;
}
.cover-upload,
.description-box,
.numeric-field {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 16px;
background: #fafafa;
}
.numeric-field {
display: flex;
flex-direction: column;
gap: 10px;
}
.field-hint {
font-size: 13px;
color: #64748b;
line-height: 1.6;
}
.cover-upload-row,
.field-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.upload-note {
color: #64748b;
font-size: 13px;
}
.cover-preview {
margin-top: 12px;
}
.cover-preview img {
width: 100%;
max-height: 240px;
object-fit: cover;
border-radius: 14px;
display: block;
}
.empty-preview {
margin-top: 12px;
padding: 20px;
border-radius: 14px;
text-align: center;
color: #94a3b8;
background: #fff;
border: 1px dashed #d1d5db;
}
.image-upload-button {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
border-radius: 10px;
background: #0f172a;
color: #fff;
cursor: pointer;
font-size: 14px;
}
.image-upload-button.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.image-upload-button input {
position: absolute;
inset: 0;
opacity: 0;
cursor: inherit;
}
.description-editor {
min-height: 220px;
padding: 14px;
border-radius: 14px;
border: 1px solid #dbe1ea;
background: #fff;
line-height: 1.8;
outline: none;
}
.description-editor :deep(.activity-image-wrap) {
position: relative;
display: block;
width: fit-content;
max-width: 100%;
margin: 12px auto;
}
.description-editor :deep(.activity-image) {
display: block;
width: auto;
max-width: min(70%, 720px);
max-height: 320px;
object-fit: contain;
border-radius: 12px;
margin: 0 auto;
}
.description-editor :deep(.image-resize-overlay) {
position: absolute;
inset: 0;
border: 2px solid #3b82f6;
border-radius: 14px;
pointer-events: none;
box-sizing: border-box;
}
.description-editor :deep(.image-resize-handle) {
position: absolute;
right: -7px;
bottom: -7px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #2563eb;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.35);
cursor: nwse-resize;
pointer-events: auto;
}
.description-editor:empty::before {
content: attr(data-placeholder);
color: #94a3b8;
}
</style> </style>

View File

@ -2,8 +2,12 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.dependencies import get_current_user_id from app.dependencies import get_current_admin, get_current_user_id
from app.services.upload_service import upload_user_avatar, upload_user_profile_image from app.services.upload_service import (
upload_activity_image,
upload_user_avatar,
upload_user_profile_image,
)
from app.services.user_service import get_user_by_id from app.services.user_service import get_user_by_id
router = APIRouter() router = APIRouter()
@ -43,3 +47,17 @@ async def upload_profile_image(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"code": 0, "message": "ok", "data": data} return {"code": 0, "message": "ok", "data": data}
@router.post("/activity-image")
async def upload_activity_cover_image(
file: UploadFile = File(...),
current_user_id: int = Depends(get_current_user_id),
) -> dict:
_ = current_user_id
try:
data = await upload_activity_image(file)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"code": 0, "message": "ok", "data": data}

View File

@ -28,19 +28,11 @@ def _format_datetime(value: datetime | None) -> str | None:
return value.isoformat() if value else None return value.isoformat() if value else None
def _birth_year_range(birth_year: int | None) -> str | None:
if not birth_year:
return None
start = birth_year - 2
end = birth_year + 2
return f"{start}-{end}"
def _candidate_item(user: User, selected: bool = False) -> dict: def _candidate_item(user: User, selected: bool = False) -> dict:
return { return {
"user_id": user.id, "user_id": user.id,
"nickname": user.nickname, "nickname": user.nickname,
"birth_year_range": _birth_year_range(user.birth_year), "birth_year": user.birth_year,
"city": user.city, "city": user.city,
"personality_tags": user.personality_tags if isinstance(user.personality_tags, list) else [], "personality_tags": user.personality_tags if isinstance(user.personality_tags, list) else [],
"avatar_blur_url": user.avatar_blur_url, "avatar_blur_url": user.avatar_blur_url,

View File

@ -17,6 +17,7 @@ from app.models.user import User
ALLOWED_IMAGE_TYPES = {"jpeg", "png"} ALLOWED_IMAGE_TYPES = {"jpeg", "png"}
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "static" / "uploads" UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "static" / "uploads"
MAX_PROFILE_IMAGES = 6 MAX_PROFILE_IMAGES = 6
MAX_ACTIVITY_IMAGE_SIZE = 5 * 1024 * 1024
def _validate_image(content: bytes) -> str: def _validate_image(content: bytes) -> str:
@ -76,10 +77,7 @@ def _append_profile_image(existing_images: list[str] | None, avatar_url: str) ->
async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile) -> dict: async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile) -> dict:
content = await file.read() content = await file.read()
if not content: _validate_upload_content(content)
raise ValueError("上传文件不能为空")
if len(content) > 5 * 1024 * 1024:
raise ValueError("图片大小不能超过 5MB")
main_path, blur_path, timestamp = _save_optimized_variants(content) main_path, blur_path, timestamp = _save_optimized_variants(content)
@ -110,13 +108,44 @@ async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile
Path(blur_path).unlink(missing_ok=True) Path(blur_path).unlink(missing_ok=True)
async def upload_user_profile_image(session: AsyncSession, user: User, file: UploadFile) -> dict: def _validate_upload_content(content: bytes) -> None:
content = await file.read()
if not content: if not content:
raise ValueError("上传文件不能为空") raise ValueError("上传文件不能为空")
if len(content) > 5 * 1024 * 1024: if len(content) > MAX_ACTIVITY_IMAGE_SIZE:
raise ValueError("图片大小不能超过 5MB") raise ValueError("图片大小不能超过 5MB")
def _store_generic_image(content: bytes, folder: str, filename: str) -> str:
_validate_image(content)
target_dir = UPLOAD_ROOT / folder
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / filename
with Image.open(BytesIO(content)) as image:
image = image.convert("RGB")
image.thumbnail((1600, 1600))
image.save(target_path, format="JPEG", quality=88)
return str(target_path)
async def upload_activity_image(file: UploadFile, *, folder: str = "activities") -> dict:
content = await file.read()
_validate_upload_content(content)
timestamp = str(int(time()))
filename = f"{timestamp}_{uuid4().hex}.jpg"
stored_path = _store_generic_image(content, folder, filename)
url_path = f"/static/uploads/{folder}/{filename}"
return {
"image_url": url_path,
"local_file": filename,
"stored_path": stored_path,
}
async def upload_user_profile_image(session: AsyncSession, user: User, file: UploadFile) -> dict:
content = await file.read()
_validate_upload_content(content)
main_path, blur_path, timestamp = _save_optimized_variants(content) main_path, blur_path, timestamp = _save_optimized_variants(content)
try: try:

View File

@ -10,12 +10,39 @@ Page({
submitting: false submitting: false
}, },
parseLaunchOptions(options = {}) {
const normalized = { ...options }
if (normalized.scene) {
const sceneText = decodeURIComponent(normalized.scene)
sceneText.split('&').forEach((segment) => {
const [rawKey, ...rest] = segment.split('=')
const key = rawKey || ''
if (!key) {
return
}
normalized[key] = rest.join('=')
})
}
const rawId = normalized.id || normalized.activity_id || ''
const parsedId = rawId !== '' && !Number.isNaN(Number(rawId)) ? Number(rawId) : null
const shareToken = normalized.share_token || ''
return {
id: parsedId,
shareToken
}
},
onLoad(options) { onLoad(options) {
this.setData({ const { id, shareToken } = this.parseLaunchOptions(options)
id: options.id ? Number(options.id) : null, this.setData({ id, shareToken })
shareToken: options.share_token || '' if (id || shareToken) {
}) this.loadDetail()
this.loadDetail() return
}
wx.showToast({ title: '活动链接无效', icon: 'none' })
}, },
onShow() { onShow() {
@ -96,7 +123,12 @@ Page({
wx.showToast({ title: '当前阶段暂不可进入匹配', icon: 'none' }) wx.showToast({ title: '当前阶段暂不可进入匹配', icon: 'none' })
return return
} }
wx.navigateTo({ url: `/pages/match/match?source=activity&activity_id=${this.data.id}` })
wx.setStorageSync('match_entry_context', {
source: 'activity',
activity_id: this.data.id
})
wx.switchTab({ url: '/pages/match/match' })
}, },
goMatchResults() { goMatchResults() {

View File

@ -31,9 +31,11 @@
</view> </view>
<view class="bottom-bar"> <view class="bottom-bar">
<button class="secondary-btn" bindtap="goMatchResults" wx:if="{{activity.is_bound || activity.is_registered}}">查看结果</button> <view class="bottom-actions">
<button class="secondary-btn" bindtap="goActivityMatch" wx:if="{{activity.can_enter_match}}">进入匹配</button> <button class="secondary-btn action-btn" bindtap="goMatchResults" wx:if="{{activity.is_bound || activity.is_registered}}">查看结果</button>
<button class="primary-btn" bindtap="handleRegister" loading="{{submitting}}"> <button class="secondary-btn action-btn" bindtap="goActivityMatch" wx:if="{{activity.can_enter_match}}">进入匹配</button>
</view>
<button class="primary-btn action-btn primary-action" bindtap="handleRegister" loading="{{submitting}}">
{{activity.actionText}} {{activity.actionText}}
</button> </button>
</view> </view>

View File

@ -76,11 +76,28 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
padding: 24rpx; padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: #ffffff; background: #ffffff;
box-shadow: 0 -12rpx 32rpx rgba(0, 0, 0, 0.06); box-shadow: 0 -12rpx 32rpx rgba(0, 0, 0, 0.06);
} }
.bottom-actions {
display: flex;
gap: 16rpx;
margin-bottom: 16rpx;
}
.action-btn {
flex: 1;
border-radius: 999rpx;
font-size: 28rpx;
line-height: 1.2;
}
.primary-action {
width: 100%;
}
.primary-btn { .primary-btn {
background: #e8593c; background: #e8593c;
color: #ffffff; color: #ffffff;

View File

@ -23,18 +23,18 @@
</view> </view>
<view class="meta">{{item.locationText}}</view> <view class="meta">{{item.locationText}}</view>
<view class="meta">{{item.start_time}}</view> <view class="meta">{{item.start_time}}</view>
<view class="progress-block"> <!-- <view class="progress-block">
<view class="progress-label">名额进度</view> <view class="progress-label">名额进度</view>
<view class="progress-track"> <view class="progress-track">
<view class="progress-value" style="width: {{item.remainPercent}}%;"></view> <view class="progress-value" style="width: {{item.remainPercent}}%;"></view>
</view> </view>
<view class="progress-summary">{{item.remainSummary}}</view> <view class="progress-summary">{{item.remainSummary}}</view>
</view> </view> -->
<view class="footer"> <view class="footer">
<view class="footer-texts"> <!-- <view class="footer-texts">
<text class="register">{{item.registerText}}</text> <text class="register">{{item.registerText}}</text>
<text wx:if="{{item.matchSummary}}" class="match-summary">{{item.matchSummary}}</text> <text wx:if="{{item.matchSummary}}" class="match-summary">{{item.matchSummary}}</text>
</view> </view> -->
<text class="arrow">查看详情</text> <text class="arrow">查看详情</text>
</view> </view>
</view> </view>

View File

@ -37,8 +37,8 @@
<view class="section-card section-card-fill"> <view class="section-card section-card-fill">
<view class="member-header"> <view class="member-header">
<view class="section-title">优质推荐</view> <view class="section-title">报名用户</view>
<text class="section-tip" bindtap="goMatch">更多推荐</text> <text class="section-tip" bindtap="goMatch">更多用户</text>
</view> </view>
<view wx:if="{{featuredMembers && featuredMembers.length}}"> <view wx:if="{{featuredMembers && featuredMembers.length}}">
<swiper class="member-swiper" indicator-dots="{{featuredMembers.length > 1}}" autoplay="{{featuredMembers.length > 1}}" circular="{{featuredMembers.length > 1}}" interval="4500"> <swiper class="member-swiper" indicator-dots="{{featuredMembers.length > 1}}" autoplay="{{featuredMembers.length > 1}}" circular="{{featuredMembers.length > 1}}" interval="4500">
@ -57,7 +57,7 @@
</block> </block>
</swiper> </swiper>
</view> </view>
<view wx:else class="featured-empty">暂无优质推荐</view> <view wx:else class="featured-empty">暂无用户</view>
</view> </view>
<view wx:if="{{activeMember}}" class="member-mask" bindtap="closeMemberCard"> <view wx:if="{{activeMember}}" class="member-mask" bindtap="closeMemberCard">

View File

@ -37,10 +37,22 @@ Page({
}, },
onLoad(options) { onLoad(options) {
const storedEntry = wx.getStorageSync('match_entry_context') || {}
const source = options.source || storedEntry.source || 'manual'
const activityId = options.activity_id
? Number(options.activity_id)
: storedEntry.activity_id
? Number(storedEntry.activity_id)
: null
this.setData({ this.setData({
source: options.source || 'manual', source,
activityId: options.activity_id ? Number(options.activity_id) : null activityId
}) })
if (storedEntry.source || storedEntry.activity_id) {
wx.removeStorageSync('match_entry_context')
}
}, },
onShareAppMessage() { onShareAppMessage() {
@ -63,7 +75,7 @@ Page({
if (this.data.source === 'activity' && this.data.activityId) { if (this.data.source === 'activity' && this.data.activityId) {
await this.loadActivityMatchState() await this.loadActivityMatchState()
} }
this.loadCandidates() await this.loadCandidates()
}, },
syncOperateState() { syncOperateState() {
@ -135,7 +147,8 @@ Page({
this.setData({ loading: true }) this.setData({ loading: true })
try { try {
let candidates = [] let candidates = []
if (this.data.source === 'activity' && this.data.activityId) { if (this.data.activityId) {
this.setData({ source: 'activity' })
candidates = await request({ url: `/activities/${this.data.activityId}/candidates` }) candidates = await request({ url: `/activities/${this.data.activityId}/candidates` })
} else { } else {
candidates = await request({ url: `/matches/candidates?source=${this.data.source}` }) candidates = await request({ url: `/matches/candidates?source=${this.data.source}` })
@ -157,6 +170,9 @@ Page({
aiSuggestionCount: 0 aiSuggestionCount: 0
}) })
this.syncDeckCandidates() this.syncDeckCandidates()
if (this.data.source === 'activity') {
wx.showToast({ title: normalizedCandidates.length ? '已进入活动匹配' : '暂无可匹配对象', icon: 'none' })
}
} catch (err) { } catch (err) {
console.error('Load candidates failed:', err) console.error('Load candidates failed:', err)
wx.showToast({ title: err.message || '候选加载失败', icon: 'none' }) wx.showToast({ title: err.message || '候选加载失败', icon: 'none' })
@ -170,7 +186,7 @@ Page({
if (!this.ensureCanOperate()) { if (!this.ensureCanOperate()) {
return return
} }
if (this.data.aiMatching || this.data.source === 'activity') { if (this.data.aiMatching || this.data.activityId || this.data.source === 'activity') {
return return
} }
this.setData({ loading: true, source: 'ai', aiMatching: true }) this.setData({ loading: true, source: 'ai', aiMatching: true })
@ -233,7 +249,7 @@ Page({
...item, ...item,
avatar_blur_url: resolveImageUrl(item.avatar_blur_url || ''), avatar_blur_url: resolveImageUrl(item.avatar_blur_url || ''),
nicknameText: item.nickname || '匿名用户', nicknameText: item.nickname || '匿名用户',
birthYearRangeText: item.birth_year_range || '年龄未知', birthYearText: item.birth_year != null ? String(item.birth_year) : '出生年份未知',
cityText: item.city || '城市未知', cityText: item.city || '城市未知',
personality_tags: personalityTags, personality_tags: personalityTags,
matchScoreText: hasMatchScore ? safeMatchScore.toFixed(1) : '', matchScoreText: hasMatchScore ? safeMatchScore.toFixed(1) : '',
@ -449,8 +465,8 @@ Page({
}, },
getAiStatusText() { getAiStatusText() {
if (this.data.source === 'activity') { if (this.data.activityId || this.data.source === 'activity') {
return this.data.stageText || '仅展示当前活动内异性嘉宾' return this.data.stageText || '仅展示当前活动内可匹配嘉宾'
} }
if (this.data.aiMatching) { if (this.data.aiMatching) {
return 'AI 正在推荐中' return 'AI 正在推荐中'

View File

@ -1,21 +1,29 @@
<view class="page match-page"> <view class="page match-page {{source === 'activity' ? 'match-page-activity' : ''}}">
<view class="hero-box"> <view wx:if="{{source === 'activity'}}" class="activity-mode-panel">
<view> <view class="activity-mode-pill">活动模式</view>
<view class="hero-title">{{source === 'activity' ? (activityTitle || '活动专属推荐') : '今日推荐'}}</view> <view class="activity-mode-title">{{activityTitle || '活动专属匹配'}}</view>
<view class="hero-desc">{{source === 'activity' ? '仅展示当前活动内的异性嘉宾,右滑加入我的选择。' : '优先展示同城、兴趣重叠和价值观更接近的候选人。'}}</view> <!-- <view class="activity-mode-desc">仅展示本活动内的嘉宾,右滑即加入你的选择。</view> -->
<view class="activity-mode-meta">
<!-- <view wx:if="{{selectionSummaryText}}" class="activity-mode-meta-item">{{selectionSummaryText}}</view> -->
<view wx:if="{{activityMatchText}}" class="activity-mode-meta-item">{{activityMatchText}}</view>
<!-- <view class="activity-mode-meta-item">{{stageText || '匹配进行中'}}</view> -->
</view>
<view class="activity-mode-actions">
<button class="ghost-btn activity-mode-btn" bindtap="goMyMatches">我的选择与结果</button>
</view> </view>
<button wx:if="{{source !== 'activity'}}" class="ai-btn {{aiMatching ? 'ai-btn-loading' : ''}}" bindtap="triggerAi" disabled="{{aiMatching}}">{{aiMatching ? '匹配中...' : '匹配推荐'}}</button>
</view> </view>
<view wx:if="{{source === 'activity' && selectionSummaryText}}" class="activity-banner">{{selectionSummaryText}}</view> <view wx:if="{{source !== 'activity'}}" class="hero-box">
<view wx:if="{{source === 'activity' && activityMatchText}}" class="activity-banner secondary-banner">{{activityMatchText}}</view> <view>
<view class="swipe-tip">左滑跳过 · 右滑选择 · 点击卡片查看资料</view> <view class="hero-title">匹配</view>
<view class="ai-status">{{getAiStatusText()}}<text wx:if="{{aiSuggestionCount}}"> · 共 {{aiSuggestionCount}} 人</text></view> <view class="hero-desc">优先展示同城、兴趣重叠和价值观更接近的候选人。</view>
</view>
<view wx:if="{{source === 'activity' && activityId}}" class="quick-actions"> <button class="ai-btn {{aiMatching ? 'ai-btn-loading' : ''}}" bindtap="triggerAi" disabled="{{aiMatching}}">{{aiMatching ? '匹配中...' : '随机展示'}}</button>
<button class="ghost-btn quick-btn" bindtap="goMyMatches">我的选择与结果</button>
</view> </view>
<view wx:if="{{source !== 'activity' && aiSuggestionCount}}" class="ai-status">{{getAiStatusText()}}<text wx:if="{{aiSuggestionCount}}"> · 共 {{aiSuggestionCount}} 人</text></view>
<view wx:if="{{source !== 'activity'}}" class="swipe-tip">左滑跳过 · 右滑选择 · 点击卡片查看资料</view>
<view wx:if="{{loading}}" class="loading-tip">正在加载推荐候选人...</view> <view wx:if="{{loading}}" class="loading-tip">正在加载推荐候选人...</view>
<view wx:if="{{currentCandidate}}" class="deck-area"> <view wx:if="{{currentCandidate}}" class="deck-area">
<view wx:if="{{nextCandidate}}" class="match-card match-card-shadow {{swipeActive ? 'match-card-shadow-active' : ''}}"> <view wx:if="{{nextCandidate}}" class="match-card match-card-shadow {{swipeActive ? 'match-card-shadow-active' : ''}}">
@ -56,7 +64,7 @@
</view> </view>
<view class="card-summary"> <view class="card-summary">
<view class="card-summary-name">{{currentCandidate.nicknameText}}</view> <view class="card-summary-name">{{currentCandidate.nicknameText}}</view>
<view class="card-summary-meta">{{currentCandidate.birthYearRangeText}} · {{currentCandidate.cityText}}</view> <view class="card-summary-meta">{{currentCandidate.birthYearText}} · {{currentCandidate.cityText}}</view>
</view> </view>
</view> </view>
<view class="card-main-body"> <view class="card-main-body">

View File

@ -7,6 +7,70 @@
flex-direction: column; flex-direction: column;
} }
.match-page-activity {
padding-top: 20rpx;
}
.activity-mode-panel {
margin-bottom: 24rpx;
padding: 28rpx 28rpx 24rpx;
border-radius: 30rpx;
background: linear-gradient(180deg, #fff3eb 0%, #ffffff 100%);
box-shadow: 0 16rpx 40rpx rgba(232, 89, 60, 0.08);
border: 1rpx solid rgba(232, 89, 60, 0.08);
}
.activity-mode-pill {
display: inline-flex;
align-items: center;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(232, 89, 60, 0.1);
color: #d95c3d;
font-size: 22rpx;
font-weight: 600;
}
.activity-mode-title {
margin-top: 14rpx;
font-size: 38rpx;
font-weight: 700;
color: #2f1b16;
}
.activity-mode-desc {
margin-top: 10rpx;
color: #7d655d;
line-height: 1.6;
font-size: 24rpx;
}
.activity-mode-meta {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.activity-mode-meta-item {
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: #fff8f5;
color: #a06a5d;
font-size: 22rpx;
}
.activity-mode-actions {
display: flex;
justify-content: flex-end;
margin-top: 18rpx;
}
.activity-mode-btn {
padding: 0 26rpx;
font-size: 24rpx;
}
.hero-box { .hero-box {
display: flex; display: flex;
align-items: center; align-items: center;
@ -24,6 +88,11 @@
font-size: 24rpx; font-size: 24rpx;
} }
.match-page-activity .swipe-tip,
.match-page-activity .ai-status {
display: none;
}
.ai-status { .ai-status {
margin: 0 8rpx 24rpx; margin: 0 8rpx 24rpx;
color: #b07a69; color: #b07a69;

View File

@ -16,9 +16,20 @@ Page({
}, },
onLoad(options) { onLoad(options) {
const storedEntry = wx.getStorageSync('match_entry_context') || {}
const activityId = options.activity_id
? Number(options.activity_id)
: storedEntry.activity_id
? Number(storedEntry.activity_id)
: null
this.setData({ this.setData({
activityId: options.activity_id ? Number(options.activity_id) : null activityId
}) })
if (storedEntry.activity_id) {
wx.removeStorageSync('match_entry_context')
}
}, },
onShow() { onShow() {

View File

@ -1,4 +1,9 @@
<view class="page matches-page"> <view class="page matches-page">
<view wx:if="{{activityId}}" class="activity-header">
<view class="activity-header-title">活动专属结果</view>
<view class="activity-header-desc">这里展示你在当前活动中的选择、收到的选择和互选结果。</view>
</view>
<view wx:if="{{activityId}}" class="tabs"> <view wx:if="{{activityId}}" class="tabs">
<view <view
wx:for="{{tabs}}" wx:for="{{tabs}}"

View File

@ -2,6 +2,26 @@
padding: 24rpx; padding: 24rpx;
} }
.activity-header {
margin-bottom: 20rpx;
padding: 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #fff4ee 0%, #ffffff 100%);
}
.activity-header-title {
font-size: 32rpx;
font-weight: 700;
color: #7a3523;
}
.activity-header-desc {
margin-top: 8rpx;
font-size: 24rpx;
color: #8d6c61;
line-height: 1.6;
}
.tabs { .tabs {
display: flex; display: flex;
gap: 16rpx; gap: 16rpx;