修补分享功能和活动封面上传功能
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 = {}) {
|
export async function apiRequest(url, options = {}) {
|
||||||
const token = localStorage.getItem('admin_token') || ''
|
const token = localStorage.getItem('admin_token') || ''
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 正在推荐中'
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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}}"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user