修补分享功能和活动封面上传功能
This commit is contained in:
parent
7b50e2768b
commit
8c0bafdbd2
@ -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 = {}) {
|
||||
const token = localStorage.getItem('admin_token') || ''
|
||||
|
||||
@ -2,10 +2,14 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { apiRequest } from '../../utils/api'
|
||||
import { API_BASE_URL, apiRequest } from '../../utils/api'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const coverUploading = ref(false)
|
||||
const descriptionUploading = ref(false)
|
||||
const descriptionEditor = ref(null)
|
||||
const resizingState = ref(null)
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
@ -24,8 +28,208 @@ const form = reactive({
|
||||
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() {
|
||||
submitting.value = true
|
||||
syncDescriptionFromEditor()
|
||||
try {
|
||||
await apiRequest('/admin/activities', {
|
||||
method: 'POST',
|
||||
@ -68,7 +272,17 @@ function goBack() {
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
<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
|
||||
v-model="form.start_time"
|
||||
type="datetime"
|
||||
@ -101,15 +315,48 @@ function goBack() {
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
class="full-width-picker"
|
||||
/>
|
||||
<div class="numeric-field">
|
||||
<div class="field-label">男性名额</div>
|
||||
<el-input-number v-model="form.capacity_male" :min="0" />
|
||||
<!-- <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">
|
||||
<el-switch v-model="form.require_audit" active-text="需审核" inactive-text="免审核" />
|
||||
<el-switch v-model="form.is_published" active-text="立即发布" inactive-text="先存草稿" />
|
||||
</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">
|
||||
<el-button @click="goBack">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="createActivity">创建活动</el-button>
|
||||
@ -170,4 +417,148 @@ function goBack() {
|
||||
.full-row {
|
||||
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>
|
||||
|
||||
@ -2,8 +2,12 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user_id
|
||||
from app.services.upload_service import upload_user_avatar, upload_user_profile_image
|
||||
from app.dependencies import get_current_admin, get_current_user_id
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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}
|
||||
|
||||
@ -28,19 +28,11 @@ def _format_datetime(value: datetime | None) -> str | 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:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"nickname": user.nickname,
|
||||
"birth_year_range": _birth_year_range(user.birth_year),
|
||||
"birth_year": user.birth_year,
|
||||
"city": user.city,
|
||||
"personality_tags": user.personality_tags if isinstance(user.personality_tags, list) else [],
|
||||
"avatar_blur_url": user.avatar_blur_url,
|
||||
|
||||
@ -17,6 +17,7 @@ from app.models.user import User
|
||||
ALLOWED_IMAGE_TYPES = {"jpeg", "png"}
|
||||
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "static" / "uploads"
|
||||
MAX_PROFILE_IMAGES = 6
|
||||
MAX_ACTIVITY_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
|
||||
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:
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise ValueError("上传文件不能为空")
|
||||
if len(content) > 5 * 1024 * 1024:
|
||||
raise ValueError("图片大小不能超过 5MB")
|
||||
_validate_upload_content(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)
|
||||
|
||||
|
||||
async def upload_user_profile_image(session: AsyncSession, user: User, file: UploadFile) -> dict:
|
||||
content = await file.read()
|
||||
def _validate_upload_content(content: bytes) -> None:
|
||||
if not content:
|
||||
raise ValueError("上传文件不能为空")
|
||||
if len(content) > 5 * 1024 * 1024:
|
||||
if len(content) > MAX_ACTIVITY_IMAGE_SIZE:
|
||||
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)
|
||||
|
||||
try:
|
||||
|
||||
@ -10,12 +10,39 @@ Page({
|
||||
submitting: false
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
this.setData({
|
||||
id: options.id ? Number(options.id) : null,
|
||||
shareToken: options.share_token || ''
|
||||
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) {
|
||||
const { id, shareToken } = this.parseLaunchOptions(options)
|
||||
this.setData({ id, shareToken })
|
||||
if (id || shareToken) {
|
||||
this.loadDetail()
|
||||
return
|
||||
}
|
||||
wx.showToast({ title: '活动链接无效', icon: 'none' })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@ -96,7 +123,12 @@ Page({
|
||||
wx.showToast({ title: '当前阶段暂不可进入匹配', icon: 'none' })
|
||||
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() {
|
||||
|
||||
@ -31,9 +31,11 @@
|
||||
</view>
|
||||
|
||||
<view class="bottom-bar">
|
||||
<button class="secondary-btn" bindtap="goMatchResults" wx:if="{{activity.is_bound || activity.is_registered}}">查看结果</button>
|
||||
<button class="secondary-btn" bindtap="goActivityMatch" wx:if="{{activity.can_enter_match}}">进入匹配</button>
|
||||
<button class="primary-btn" bindtap="handleRegister" loading="{{submitting}}">
|
||||
<view class="bottom-actions">
|
||||
<button class="secondary-btn action-btn" bindtap="goMatchResults" wx:if="{{activity.is_bound || activity.is_registered}}">查看结果</button>
|
||||
<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}}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@ -76,11 +76,28 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 24rpx;
|
||||
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: #ffffff;
|
||||
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 {
|
||||
background: #e8593c;
|
||||
color: #ffffff;
|
||||
|
||||
@ -23,18 +23,18 @@
|
||||
</view>
|
||||
<view class="meta">{{item.locationText}}</view>
|
||||
<view class="meta">{{item.start_time}}</view>
|
||||
<view class="progress-block">
|
||||
<!-- <view class="progress-block">
|
||||
<view class="progress-label">名额进度</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-value" style="width: {{item.remainPercent}}%;"></view>
|
||||
</view>
|
||||
<view class="progress-summary">{{item.remainSummary}}</view>
|
||||
</view>
|
||||
</view> -->
|
||||
<view class="footer">
|
||||
<view class="footer-texts">
|
||||
<!-- <view class="footer-texts">
|
||||
<text class="register">{{item.registerText}}</text>
|
||||
<text wx:if="{{item.matchSummary}}" class="match-summary">{{item.matchSummary}}</text>
|
||||
</view>
|
||||
</view> -->
|
||||
<text class="arrow">查看详情</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -37,8 +37,8 @@
|
||||
|
||||
<view class="section-card section-card-fill">
|
||||
<view class="member-header">
|
||||
<view class="section-title">优质推荐</view>
|
||||
<text class="section-tip" bindtap="goMatch">更多推荐</text>
|
||||
<view class="section-title">报名用户</view>
|
||||
<text class="section-tip" bindtap="goMatch">更多用户</text>
|
||||
</view>
|
||||
<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">
|
||||
@ -57,7 +57,7 @@
|
||||
</block>
|
||||
</swiper>
|
||||
</view>
|
||||
<view wx:else class="featured-empty">暂无优质推荐</view>
|
||||
<view wx:else class="featured-empty">暂无用户</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{activeMember}}" class="member-mask" bindtap="closeMemberCard">
|
||||
|
||||
@ -37,10 +37,22 @@ Page({
|
||||
},
|
||||
|
||||
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({
|
||||
source: options.source || 'manual',
|
||||
activityId: options.activity_id ? Number(options.activity_id) : null
|
||||
source,
|
||||
activityId
|
||||
})
|
||||
|
||||
if (storedEntry.source || storedEntry.activity_id) {
|
||||
wx.removeStorageSync('match_entry_context')
|
||||
}
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
@ -63,7 +75,7 @@ Page({
|
||||
if (this.data.source === 'activity' && this.data.activityId) {
|
||||
await this.loadActivityMatchState()
|
||||
}
|
||||
this.loadCandidates()
|
||||
await this.loadCandidates()
|
||||
},
|
||||
|
||||
syncOperateState() {
|
||||
@ -135,7 +147,8 @@ Page({
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
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` })
|
||||
} else {
|
||||
candidates = await request({ url: `/matches/candidates?source=${this.data.source}` })
|
||||
@ -157,6 +170,9 @@ Page({
|
||||
aiSuggestionCount: 0
|
||||
})
|
||||
this.syncDeckCandidates()
|
||||
if (this.data.source === 'activity') {
|
||||
wx.showToast({ title: normalizedCandidates.length ? '已进入活动匹配' : '暂无可匹配对象', icon: 'none' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load candidates failed:', err)
|
||||
wx.showToast({ title: err.message || '候选加载失败', icon: 'none' })
|
||||
@ -170,7 +186,7 @@ Page({
|
||||
if (!this.ensureCanOperate()) {
|
||||
return
|
||||
}
|
||||
if (this.data.aiMatching || this.data.source === 'activity') {
|
||||
if (this.data.aiMatching || this.data.activityId || this.data.source === 'activity') {
|
||||
return
|
||||
}
|
||||
this.setData({ loading: true, source: 'ai', aiMatching: true })
|
||||
@ -233,7 +249,7 @@ Page({
|
||||
...item,
|
||||
avatar_blur_url: resolveImageUrl(item.avatar_blur_url || ''),
|
||||
nicknameText: item.nickname || '匿名用户',
|
||||
birthYearRangeText: item.birth_year_range || '年龄未知',
|
||||
birthYearText: item.birth_year != null ? String(item.birth_year) : '出生年份未知',
|
||||
cityText: item.city || '城市未知',
|
||||
personality_tags: personalityTags,
|
||||
matchScoreText: hasMatchScore ? safeMatchScore.toFixed(1) : '',
|
||||
@ -449,8 +465,8 @@ Page({
|
||||
},
|
||||
|
||||
getAiStatusText() {
|
||||
if (this.data.source === 'activity') {
|
||||
return this.data.stageText || '仅展示当前活动内异性嘉宾'
|
||||
if (this.data.activityId || this.data.source === 'activity') {
|
||||
return this.data.stageText || '仅展示当前活动内可匹配嘉宾'
|
||||
}
|
||||
if (this.data.aiMatching) {
|
||||
return 'AI 正在推荐中'
|
||||
|
||||
@ -1,20 +1,28 @@
|
||||
<view class="page match-page">
|
||||
<view class="hero-box">
|
||||
<view class="page match-page {{source === 'activity' ? 'match-page-activity' : ''}}">
|
||||
<view wx:if="{{source === 'activity'}}" class="activity-mode-panel">
|
||||
<view class="activity-mode-pill">活动模式</view>
|
||||
<view class="activity-mode-title">{{activityTitle || '活动专属匹配'}}</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>
|
||||
|
||||
<view wx:if="{{source !== 'activity'}}" class="hero-box">
|
||||
<view>
|
||||
<view class="hero-title">{{source === 'activity' ? (activityTitle || '活动专属推荐') : '今日推荐'}}</view>
|
||||
<view class="hero-desc">{{source === 'activity' ? '仅展示当前活动内的异性嘉宾,右滑加入我的选择。' : '优先展示同城、兴趣重叠和价值观更接近的候选人。'}}</view>
|
||||
<view class="hero-title">匹配</view>
|
||||
<view class="hero-desc">优先展示同城、兴趣重叠和价值观更接近的候选人。</view>
|
||||
</view>
|
||||
<button wx:if="{{source !== 'activity'}}" class="ai-btn {{aiMatching ? 'ai-btn-loading' : ''}}" bindtap="triggerAi" disabled="{{aiMatching}}">{{aiMatching ? '匹配中...' : '匹配推荐'}}</button>
|
||||
<button class="ai-btn {{aiMatching ? 'ai-btn-loading' : ''}}" bindtap="triggerAi" disabled="{{aiMatching}}">{{aiMatching ? '匹配中...' : '随机展示'}}</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{source === 'activity' && selectionSummaryText}}" class="activity-banner">{{selectionSummaryText}}</view>
|
||||
<view wx:if="{{source === 'activity' && activityMatchText}}" class="activity-banner secondary-banner">{{activityMatchText}}</view>
|
||||
<view class="swipe-tip">左滑跳过 · 右滑选择 · 点击卡片查看资料</view>
|
||||
<view class="ai-status">{{getAiStatusText()}}<text wx:if="{{aiSuggestionCount}}"> · 共 {{aiSuggestionCount}} 人</text></view>
|
||||
|
||||
<view wx:if="{{source === 'activity' && activityId}}" class="quick-actions">
|
||||
<button class="ghost-btn quick-btn" bindtap="goMyMatches">我的选择与结果</button>
|
||||
</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="{{currentCandidate}}" class="deck-area">
|
||||
@ -56,7 +64,7 @@
|
||||
</view>
|
||||
<view class="card-summary">
|
||||
<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 class="card-main-body">
|
||||
|
||||
@ -7,6 +7,70 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -24,6 +88,11 @@
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.match-page-activity .swipe-tip,
|
||||
.match-page-activity .ai-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-status {
|
||||
margin: 0 8rpx 24rpx;
|
||||
color: #b07a69;
|
||||
|
||||
@ -16,9 +16,20 @@ Page({
|
||||
},
|
||||
|
||||
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({
|
||||
activityId: options.activity_id ? Number(options.activity_id) : null
|
||||
activityId
|
||||
})
|
||||
|
||||
if (storedEntry.activity_id) {
|
||||
wx.removeStorageSync('match_entry_context')
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
<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:for="{{tabs}}"
|
||||
|
||||
@ -2,6 +2,26 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user