feat(activity&admin): 优化组局起止时段与性别限制,PC后台核销看板与登录页体验升级

这个提交包含在:
xcbyy
2026-11-05 16:06:18 +08:00
父节点 a1907f18a8
当前提交 f3da2e4a63
共有 21 个文件被更改,包括 1990 次插入 和 203 次删除

查看文件

@@ -5,7 +5,7 @@
<link rel="icon" type="image/png" href="/admin/favicon.png">
<link rel="shortcut icon" type="image/png" href="/admin/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>陪玩与同城生活陪伴平台 - PC运营管理控制台</title>
<title>同城陪玩管理后台 - 运营管理控制台</title>
</head>
<body>
<div id="app"></div>

查看文件

@@ -1,5 +1,7 @@
<template>
<a-layout-footer class="footer">Arco Pro</a-layout-footer>
<a-layout-footer class="footer">
同城陪玩运营管理平台 © 2026 PeiWan Pro · 生活陪伴与电竞社交运营系统
</a-layout-footer>
</template>
<script lang="ts" setup></script>
@@ -10,7 +12,10 @@
align-items: center;
justify-content: center;
height: 40px;
color: var(--color-text-2);
color: var(--color-text-3);
font-size: 13px;
text-align: center;
letter-spacing: 0.3px;
user-select: none;
}
</style>

查看文件

@@ -12,8 +12,12 @@
<a-select
v-model="selectedActivityId"
placeholder="请选择核销对应的组局活动"
style="width: 100%"
@change="loadParticipants"
>
<a-option value="all">
<span style="font-weight: 600; color: #165dff">全部 (查看所有活动核销状态)</span>
</a-option>
<a-option
v-for="act in activityOptions"
:key="act.id"
@@ -29,13 +33,14 @@
placeholder="输入现场4位纯数字核销码 (如 8392)"
maxlength="4"
allow-clear
@press-enter="() => handleManualVerify()"
>
<template #prefix><icon-safe /></template>
</a-input>
</a-col>
<a-col :span="6">
<a-col :span="8">
<a-space>
<a-button type="primary" :loading="verifying" @click="handleManualVerify">
<a-button type="primary" :loading="verifying" @click="() => handleManualVerify()">
<template #icon><icon-check-circle /></template>
立即核销到场
</a-button>
@@ -52,7 +57,18 @@
<a-card class="general-card" title="参与者4位核销码全景列表与到场签到追踪">
<template #extra>
<a-space>
<a-tag color="blue">当前活动: {{ currentActivity?.title || '未选择' }}</a-tag>
<a-radio-group
v-model="filterStatus"
type="button"
size="small"
>
<a-radio value="ALL">全部 ({{ participantsList.length }})</a-radio>
<a-radio value="UNCHECKED">待核销 ({{ uncheckedCount }})</a-radio>
<a-radio value="CHECKED">已核销 ({{ verifiedCount }})</a-radio>
</a-radio-group>
<a-tag :color="selectedActivityId === 'all' ? 'arcoblue' : 'blue'">
当前查看: {{ currentActivityText }}
</a-tag>
<a-tag color="green">已核销: {{ verifiedCount }} / {{ participantsList.length }}人</a-tag>
<a-button size="small" @click="loadParticipants">
<template #icon><icon-refresh /></template>
@@ -62,18 +78,37 @@
</template>
<a-table
:data="participantsList"
:data="filteredParticipants"
:loading="loading"
:pagination="false"
:pagination="{ pageSize: 10, showTotal: true, showJumper: true }"
row-key="id"
>
<template #columns>
<!-- 全量视图下展示所属活动列 -->
<a-table-column
v-if="selectedActivityId === 'all'"
title="所属活动 / 组局"
:width="180"
>
<template #cell="{ record }">
<div style="font-weight: 600; color: var(--color-text-1)">
{{ record.activityTitle || '未知组局' }}
</div>
<div style="font-size: 12px; color: var(--color-text-3)">
{{ record.gameCategory || '同城组局' }}
</div>
</template>
</a-table-column>
<a-table-column title="参与者用户" :width="160">
<template #cell="{ record }">
<div style="font-weight: 600">用户 ID: {{ record.userId }}</div>
<div style="font-size: 12px; color: var(--color-text-3)">同城组局参与者</div>
<div style="font-size: 12px; color: var(--color-text-3)">
{{ record.nickname || '同城组局参与者' }}
</div>
</template>
</a-table-column>
<a-table-column title="支付报名费" :width="120">
<template #cell="{ record }">
<span style="color: #ff5722; font-weight: 600">
@@ -81,7 +116,8 @@
</span>
</template>
</a-table-column>
<a-table-column title="4位纯数字核销码" :width="200">
<a-table-column title="4位纯数字核销码" :width="180">
<template #cell="{ record }">
<div class="verify-badge">
<span class="code-number">{{ record.verifyCode }}</span>
@@ -89,7 +125,8 @@
</div>
</template>
</a-table-column>
<a-table-column title="核销到场状态" :width="150">
<a-table-column title="核销到场状态" :width="140">
<template #cell="{ record }">
<a-badge
:status="record.checkinStatus ? 'success' : 'processing'"
@@ -97,15 +134,19 @@
/>
</template>
</a-table-column>
<a-table-column title="核销时间 / 操作人" :width="220">
<a-table-column title="核销时间 / 操作人" :width="200">
<template #cell="{ record }">
<div v-if="record.checkinStatus">
<div>{{ formatDate(record.verifiedAt) }}</div>
<div style="font-size: 12px; color: var(--color-text-3)">核销人ID: {{ record.verifiedBy || 1001 }}</div>
<div style="font-size: 12px; color: var(--color-text-3)">
核销人ID: {{ record.verifiedBy || 1001 }}
</div>
</div>
<div v-else style="color: var(--color-text-4)">暂未签到</div>
</template>
</a-table-column>
<a-table-column title="操作" :width="130" fixed="right">
<template #cell="{ record }">
<a-button
@@ -113,7 +154,7 @@
type="outline"
status="success"
size="small"
@click="verifyDirect(record.verifyCode)"
@click="verifyDirect(record.verifyCode, record.activityId)"
>
直接核销
</a-button>
@@ -132,6 +173,17 @@
@cancel="joinModalVisible = false"
>
<a-form :model="joinForm" layout="vertical">
<a-form-item v-if="selectedActivityId === 'all'" label="选择报名的组局活动" required>
<a-select v-model="joinForm.activityId" placeholder="请选择报名活动">
<a-option
v-for="act in activityOptions"
:key="act.id"
:value="act.id"
>
{{ act.title }} ({{ act.gameCategory }})
</a-option>
</a-select>
</a-form-item>
<a-form-item label="模拟报名用户ID" required>
<a-input-number v-model="joinForm.userId" :min="1000" :max="99999" />
</a-form-item>
@@ -153,25 +205,43 @@
const loading = ref(false);
const verifying = ref(false);
const activityOptions = ref<any[]>([]);
const selectedActivityId = ref<string>('');
const selectedActivityId = ref<string>('all');
const participantsList = ref<any[]>([]);
const inputVerifyCode = ref('');
const filterStatus = ref<'ALL' | 'UNCHECKED' | 'CHECKED'>('ALL');
// 模拟报名
const joinModalVisible = ref(false);
const joinForm = ref({
activityId: '',
userId: 2008,
payAmount: 50,
});
const currentActivity = computed(() => {
return activityOptions.value.find((act) => act.id === selectedActivityId.value);
const currentActivityText = computed(() => {
if (selectedActivityId.value === 'all') return '全部活动';
const found = activityOptions.value.find((act) => act.id === selectedActivityId.value);
return found ? `${found.title} (${found.gameCategory})` : '未选择';
});
const verifiedCount = computed(() => {
return participantsList.value.filter((p) => p.checkinStatus).length;
});
const uncheckedCount = computed(() => {
return participantsList.value.filter((p) => !p.checkinStatus).length;
});
const filteredParticipants = computed(() => {
if (filterStatus.value === 'UNCHECKED') {
return participantsList.value.filter((p) => !p.checkinStatus);
}
if (filterStatus.value === 'CHECKED') {
return participantsList.value.filter((p) => p.checkinStatus);
}
return participantsList.value;
});
const fetchActivities = async () => {
try {
const res: any = await axios.get('/api/activities');
@@ -181,7 +251,8 @@
if (queryId && activityOptions.value.some((a) => a.id === queryId)) {
selectedActivityId.value = queryId;
} else {
selectedActivityId.value = activityOptions.value[0].id;
// 默认选中"全部",可直接查看所有活动的核销状态
selectedActivityId.value = 'all';
}
loadParticipants();
}
@@ -205,18 +276,27 @@
}
};
const handleManualVerify = async () => {
if (!inputVerifyCode.value || inputVerifyCode.value.length !== 4) {
const handleManualVerify = async (codeToVerify?: string, targetActId?: string) => {
const code = codeToVerify || inputVerifyCode.value;
if (!code || code.length !== 4) {
Message.warning('请输入4位纯数字核销码');
return;
}
verifying.value = true;
try {
await axios.post(`/api/activities/${selectedActivityId.value}/verify`, {
operatorId: 1001,
verifyCode: inputVerifyCode.value,
});
Message.success(`核销成功!4位核销码【${inputVerifyCode.value}】已通过到场核验`);
const actId = targetActId || selectedActivityId.value;
if (!actId || actId === 'all') {
await axios.post('/api/verify/universal', {
operatorId: 1001,
verifyCode: code,
});
} else {
await axios.post(`/api/activities/${actId}/verify`, {
operatorId: 1001,
verifyCode: code,
});
}
Message.success(`核销成功!4位核销码【${code}】已通过到场核验`);
inputVerifyCode.value = '';
loadParticipants();
} catch (err: any) {
@@ -226,24 +306,34 @@
}
};
const verifyDirect = (code: string) => {
const verifyDirect = (code: string, actId?: string) => {
inputVerifyCode.value = code;
handleManualVerify();
handleManualVerify(code, actId || selectedActivityId.value);
};
const openJoinModal = () => {
const defaultActId = selectedActivityId.value === 'all'
? (activityOptions.value[0]?.id || '')
: selectedActivityId.value;
const targetAct = activityOptions.value.find((a) => a.id === defaultActId);
joinForm.value = {
activityId: defaultActId,
userId: Math.floor(2000 + Math.random() * 7000),
payAmount: (currentActivity.value?.perPersonFeeCents || 5000) / 100,
payAmount: (targetAct?.perPersonFeeCents || 5000) / 100,
};
joinModalVisible.value = true;
};
const handleJoinSubmit = async () => {
try {
await axios.post(`/api/activities/${selectedActivityId.value}/join`, {
const actId = joinForm.value.activityId || selectedActivityId.value;
if (!actId || actId === 'all') {
Message.warning('请选择需要报名的活动');
return;
}
await axios.post(`/api/activities/${actId}/join`, {
userId: joinForm.value.userId,
payAmountCents: joinForm.value.payAmount * 100,
payAmountCents: Math.round(joinForm.value.payAmount * 100),
});
Message.success('用户报名成功,已分配独占4位纯数字核销码');
joinModalVisible.value = false;

查看文件

@@ -10,7 +10,7 @@
</a-tag>
</div>
<div class="banner-desc">
陪玩与同城陪伴运营平台 · 在线接单大神 86 位 · 今日支付订单 142 笔 · 4位纯数字核销防重履约中
同城陪玩运营管理平台 · 在线接单大神 86 位 · 今日支付订单 142 笔 · 4位纯数字核销防重履约中
</div>
</div>

查看文件

@@ -1,12 +1,262 @@
<template>
<div class="banner">
<!-- 背景流光氛围光斑 -->
<div class="ambient-glow glow-1"></div>
<div class="ambient-glow glow-2"></div>
<div class="ambient-glow glow-3"></div>
<div class="banner-inner">
<a-carousel class="carousel" animation-name="fade">
<a-carousel-item v-for="item in carouselItem" :key="item.slogan">
<div :key="item.slogan" class="carousel-item">
<div class="carousel-title">{{ item.slogan }}</div>
<div class="carousel-sub-title">{{ item.subSlogan }}</div>
<img class="carousel-image" :src="item.image" />
<a-carousel
class="carousel"
animation-name="fade"
:auto-play="{ interval: 4500 }"
indicator-type="line"
indicator-position="bottom"
>
<!-- Slide 1: 金牌陪玩与智能调度 -->
<a-carousel-item>
<div class="carousel-item">
<div class="carousel-badge">
<span class="badge-dot pulse"></span>
<span>智能派单调度引擎</span>
</div>
<div class="carousel-title">{{ $t('login.banner.slogan1') }}</div>
<div class="carousel-sub-title">{{ $t('login.banner.subSlogan1') }}</div>
<!-- 陪玩师调度卡片演示 -->
<div class="glass-card dispatcher-card">
<div class="card-header">
<div class="header-left">
<span class="status-dot"></span>
<span class="header-title">实时调度中心</span>
<span class="tag-live">LIVE</span>
</div>
<div class="header-right">860+ 导师待命中</div>
</div>
<div class="expert-list">
<div class="expert-row">
<div class="expert-avatar pink">
<icon-fire />
</div>
<div class="expert-info">
<div class="expert-name-row">
<span class="expert-name">小鹿酱</span>
<span class="expert-game">王者荣耀 · LOL</span>
<span class="status-pill free">空闲中</span>
</div>
<div class="expert-meta">
<span class="rating">★ 5.0 (1,280单)</span>
<span class="tag">甜美声优</span>
<span class="tag">意识带飞</span>
</div>
</div>
</div>
<div class="expert-row">
<div class="expert-avatar indigo">
<icon-trophy />
</div>
<div class="expert-info">
<div class="expert-name-row">
<span class="expert-name">狂澜大神</span>
<span class="expert-game">无畏契约 · 永劫</span>
<span class="status-pill busy">连麦中</span>
</div>
<div class="expert-meta">
<span class="rating">★ 4.9 (2,150单)</span>
<span class="tag">千分绝活</span>
<span class="tag">极速带包</span>
</div>
</div>
</div>
<div class="expert-row">
<div class="expert-avatar amber">
<icon-star-fill />
</div>
<div class="expert-info">
<div class="expert-name-row">
<span class="expert-name">晴天向导</span>
<span class="expert-game">同城桌游 · 密室向导</span>
<span class="status-pill verified">已认证</span>
</div>
<div class="expert-meta">
<span class="rating">★ 5.0 (740次)</span>
<span class="tag">细心体贴</span>
<span class="tag">线下同城</span>
</div>
</div>
</div>
</div>
<div class="card-footer-chips">
<span class="chip">#电竞开黑</span>
<span class="chip">#语音连麦</span>
<span class="chip">#同城向导</span>
<span class="chip">#叫醒陪伴</span>
<span class="chip">#聊天倾听</span>
</div>
</div>
</div>
</a-carousel-item>
<!-- Slide 2: 资金担保与服务履约 -->
<a-carousel-item>
<div class="carousel-item">
<div class="carousel-badge safe-badge">
<span class="badge-dot safe-dot"></span>
<span>全链路交易风控矩阵</span>
</div>
<div class="carousel-title">{{ $t('login.banner.slogan2') }}</div>
<div class="carousel-sub-title">{{ $t('login.banner.subSlogan2') }}</div>
<!-- 安全保障卡片演示 -->
<div class="glass-card security-card">
<div class="security-shield-hero">
<div class="shield-icon-wrap">
<icon-safe class="shield-icon" />
</div>
<div class="shield-text">
<div class="shield-title">银行级资金托管与双因子风控体系</div>
<div class="shield-sub">全流程交易担保 · 杜绝私下飞单 · 履约验收结算</div>
</div>
</div>
<div class="security-grid">
<div class="sec-item">
<div class="sec-item-icon green">
<icon-check-circle-fill />
</div>
<div class="sec-item-body">
<div class="sec-name">实名活体认证</div>
<div class="sec-desc">公安数据库核验,100% 真人认证</div>
</div>
</div>
<div class="sec-item">
<div class="sec-item-icon blue">
<icon-lock />
</div>
<div class="sec-item-body">
<div class="sec-name">资金官方存管</div>
<div class="sec-desc">订单资金双向托管,完单核销结算</div>
</div>
</div>
<div class="sec-item">
<div class="sec-item-icon purple">
<icon-thunderbolt />
</div>
<div class="sec-item-body">
<div class="sec-name">AI 实时风控防飞单</div>
<div class="sec-desc">7×24H 敏感拦截与异常巡检</div>
</div>
</div>
<div class="sec-item">
<div class="sec-item-icon orange">
<icon-customer-service />
</div>
<div class="sec-item-body">
<div class="sec-name">极速纠纷仲裁</div>
<div class="sec-desc">专属运营团队,10分钟极速响应</div>
</div>
</div>
</div>
<div class="security-footer-banner">
<icon-check-circle-fill class="sec-foot-icon" />
<span>平台设立 100 万元专项履约保障金,全程护航双方权益</span>
</div>
</div>
</div>
</a-carousel-item>
<!-- Slide 3: 全链路数据大盘 -->
<a-carousel-item>
<div class="carousel-item">
<div class="carousel-badge analytics-badge">
<span class="badge-dot analytics-dot"></span>
<span>实时运营决策中心</span>
</div>
<div class="carousel-title">{{ $t('login.banner.slogan3') }}</div>
<div class="carousel-sub-title">{{ $t('login.banner.subSlogan3') }}</div>
<!-- 数据大盘卡片演示 -->
<div class="glass-card analytics-card">
<div class="metrics-row">
<div class="metric-col">
<div class="metric-label">今日流水 GMV</div>
<div class="metric-val">¥98,420</div>
<div class="metric-trend up"><icon-arrow-rise /> +28.4%</div>
</div>
<div class="metric-divider"></div>
<div class="metric-col">
<div class="metric-label">履约订单量</div>
<div class="metric-val">1,532 笔</div>
<div class="metric-trend up"><icon-arrow-rise /> +15.2%</div>
</div>
<div class="metric-divider"></div>
<div class="metric-col">
<div class="metric-label">综合满意度</div>
<div class="metric-val">99.8%</div>
<div class="metric-trend star"><icon-star-fill /> 卓越</div>
</div>
</div>
<!-- 迷你趋势图柱形 -->
<div class="mini-chart-box">
<div class="chart-title-bar">
<span>近 7 日订单趋势监控</span>
<span class="chart-badge">持续高增长</span>
</div>
<div class="bars-container">
<div class="bar-col">
<div class="bar-fill" style="height: 48%;"></div>
<span class="bar-day">周一</span>
</div>
<div class="bar-col">
<div class="bar-fill" style="height: 60%;"></div>
<span class="bar-day">周二</span>
</div>
<div class="bar-col">
<div class="bar-fill" style="height: 54%;"></div>
<span class="bar-day">周三</span>
</div>
<div class="bar-col">
<div class="bar-fill" style="height: 72%;"></div>
<span class="bar-day">周四</span>
</div>
<div class="bar-col">
<div class="bar-fill" style="height: 85%;"></div>
<span class="bar-day">周五</span>
</div>
<div class="bar-col highlight">
<div class="bar-fill" style="height: 94%;"></div>
<span class="bar-day">周六</span>
</div>
<div class="bar-col highlight-main">
<div class="bar-fill" style="height: 100%;"></div>
<span class="bar-day">周日</span>
</div>
</div>
</div>
<!-- 品类流水分步条 -->
<div class="category-distribution">
<div class="distrib-bar">
<div class="seg seg-1" style="width: 55%;" title="电竞开黑 55%"></div>
<div class="seg seg-2" style="width: 27%;" title="语音连麦 27%"></div>
<div class="seg seg-3" style="width: 18%;" title="同城向导 18%"></div>
</div>
<div class="distrib-labels">
<span class="legend"><i class="dot dot-1"></i>电竞开黑 55%</span>
<span class="legend"><i class="dot dot-2"></i>语音连麦 27%</span>
<span class="legend"><i class="dot dot-3"></i>同城向导 18%</span>
</div>
</div>
</div>
</div>
</a-carousel-item>
</a-carousel>
@@ -15,70 +265,614 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import bannerImage from '@/assets/images/login-banner.png';
const { t } = useI18n();
const carouselItem = computed(() => [
{
slogan: t('login.banner.slogan1'),
subSlogan: t('login.banner.subSlogan1'),
image: bannerImage,
},
{
slogan: t('login.banner.slogan2'),
subSlogan: t('login.banner.subSlogan2'),
image: bannerImage,
},
{
slogan: t('login.banner.slogan3'),
subSlogan: t('login.banner.subSlogan3'),
image: bannerImage,
},
]);
// 保持脚本精炼,使用统一国际化多语言与 Arco 全局注册图标
</script>
<style lang="less" scoped>
.banner {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
overflow: hidden;
background: linear-gradient(150deg, #070a14 0%, #0e1224 45%, #171131 100%);
&-inner {
position: relative;
z-index: 2;
flex: 1;
height: 100%;
padding-top: 60px;
}
}
// 氛围光斑
.ambient-glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
z-index: 1;
&.glow-1 {
width: 380px;
height: 380px;
top: -60px;
left: -60px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.22) 0%, transparent 70%);
filter: blur(50px);
}
&.glow-2 {
width: 440px;
height: 440px;
bottom: -100px;
right: -80px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.18) 0%, transparent 70%);
filter: blur(60px);
}
&.glow-3 {
width: 260px;
height: 260px;
top: 40%;
left: 30%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.15) 0%, transparent 70%);
filter: blur(55px);
}
}
.carousel {
height: 100%;
:deep(.arco-carousel-indicator-wrapper-bottom) {
bottom: 28px;
}
:deep(.arco-carousel-indicator-item) {
width: 16px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.arco-carousel-indicator-item-active {
width: 32px;
background: linear-gradient(90deg, #ec4899 0%, #8b5cf6 100%);
}
}
&-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 40px;
box-sizing: border-box;
user-select: none;
}
&-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(139, 92, 246, 0.18);
border: 1px solid rgba(139, 92, 246, 0.35);
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: #c4b5fd;
margin-bottom: 14px;
&.safe-badge {
background: rgba(16, 185, 129, 0.16);
border-color: rgba(16, 185, 129, 0.35);
color: #6ee7b7;
}
&.analytics-badge {
background: rgba(236, 72, 153, 0.16);
border-color: rgba(236, 72, 153, 0.35);
color: #f9a8d4;
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #a78bfa;
&.safe-dot {
background-color: #34d399;
}
&.analytics-dot {
background-color: #f472b6;
}
&.pulse {
box-shadow: 0 0 0 0 rgba(167, 139, 250, 0.7);
animation: pulse-ring 2s infinite;
}
}
}
&-title {
color: var(--color-fill-1);
font-weight: 500;
font-size: 20px;
line-height: 28px;
color: #ffffff;
font-weight: 700;
font-size: 23px;
line-height: 32px;
text-align: center;
letter-spacing: 0.5px;
}
&-sub-title {
margin-top: 8px;
color: var(--color-text-3);
font-size: 14px;
line-height: 22px;
color: rgba(226, 232, 240, 0.75);
font-size: 13px;
line-height: 20px;
text-align: center;
max-width: 440px;
}
}
// 玻璃态展示卡片基础
.glass-card {
width: 410px;
margin-top: 26px;
padding: 20px;
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
backdrop-filter: blur(16px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.1);
box-sizing: border-box;
}
// 卡片 1: 调度池卡片
.dispatcher-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 14px;
.header-left {
display: flex;
align-items: center;
gap: 6px;
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background-color: #10b981;
box-shadow: 0 0 8px #10b981;
}
.header-title {
font-size: 13px;
font-weight: 600;
color: #f1f5f9;
}
.tag-live {
font-size: 10px;
font-weight: 700;
background: rgba(16, 185, 129, 0.2);
color: #34d399;
padding: 1px 5px;
border-radius: 4px;
}
}
.header-right {
font-size: 12px;
color: #94a3b8;
}
}
&-image {
width: 320px;
margin-top: 30px;
.expert-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.expert-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
transition: background 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.07);
}
.expert-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
font-size: 18px;
color: #ffffff;
flex-shrink: 0;
&.pink {
background: linear-gradient(135deg, #f43f5e 0%, #ec4899 100%);
box-shadow: 0 4px 10px rgba(236, 72, 153, 0.35);
}
&.indigo {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.35);
}
&.amber {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
box-shadow: 0 4px 10px rgba(245, 158, 11, 0.35);
}
}
.expert-info {
flex: 1;
min-width: 0;
.expert-name-row {
display: flex;
align-items: center;
gap: 6px;
.expert-name {
font-size: 13px;
font-weight: 600;
color: #ffffff;
}
.expert-game {
font-size: 11px;
color: #94a3b8;
}
.status-pill {
margin-left: auto;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
&.free {
background: rgba(16, 185, 129, 0.18);
color: #34d399;
}
&.busy {
background: rgba(245, 158, 11, 0.18);
color: #fbbf24;
}
&.verified {
background: rgba(99, 102, 241, 0.18);
color: #818cf8;
}
}
}
.expert-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
.rating {
font-size: 11px;
color: #fbbf24;
font-weight: 500;
}
.tag {
font-size: 10px;
color: rgba(226, 232, 240, 0.7);
background: rgba(255, 255, 255, 0.06);
padding: 0 5px;
border-radius: 3px;
}
}
}
}
.card-footer-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
.chip {
font-size: 11px;
color: rgba(203, 213, 225, 0.85);
background: rgba(255, 255, 255, 0.05);
padding: 3px 8px;
border-radius: 6px;
}
}
}
// 卡片 2: 安全风控卡片
.security-card {
.security-shield-hero {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(236, 72, 153, 0.12) 100%);
border: 1px solid rgba(139, 92, 246, 0.25);
border-radius: 12px;
margin-bottom: 14px;
.shield-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
font-size: 22px;
color: #fff;
flex-shrink: 0;
}
.shield-text {
.shield-title {
font-size: 13px;
font-weight: 600;
color: #ffffff;
}
.shield-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
}
}
.security-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.sec-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
.sec-item-icon {
font-size: 16px;
margin-top: 1px;
flex-shrink: 0;
&.green { color: #34d399; }
&.blue { color: #60a5fa; }
&.purple { color: #c084fc; }
&.orange { color: #fb923c; }
}
.sec-name {
font-size: 12px;
font-weight: 600;
color: #f1f5f9;
}
.sec-desc {
font-size: 10px;
color: #94a3b8;
margin-top: 2px;
line-height: 14px;
}
}
.security-footer-banner {
display: flex;
align-items: center;
gap: 6px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 11px;
color: #6ee7b7;
.sec-foot-icon {
font-size: 14px;
}
}
}
// 卡片 3: 数据运营大盘卡片
.analytics-card {
.metrics-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.metric-col {
flex: 1;
text-align: center;
.metric-label {
font-size: 11px;
color: #94a3b8;
}
.metric-val {
font-size: 17px;
font-weight: 700;
color: #ffffff;
margin: 3px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.metric-trend {
font-size: 11px;
font-weight: 600;
&.up {
color: #34d399;
}
&.star {
color: #fbbf24;
}
}
}
.metric-divider {
width: 1px;
height: 28px;
background: rgba(255, 255, 255, 0.08);
}
}
.mini-chart-box {
margin-top: 14px;
.chart-title-bar {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #cbd5e1;
margin-bottom: 12px;
.chart-badge {
font-size: 10px;
color: #f472b6;
background: rgba(244, 114, 182, 0.15);
padding: 1px 6px;
border-radius: 4px;
}
}
.bars-container {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 80px;
padding: 0 4px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.bar-col {
display: flex;
flex-direction: column;
align-items: center;
width: 28px;
height: 100%;
justify-content: flex-end;
.bar-fill {
width: 14px;
border-radius: 4px 4px 1px 1px;
background: linear-gradient(180deg, #6366f1 0%, rgba(99, 102, 241, 0.25) 100%);
transition: height 0.4s ease;
}
.bar-day {
margin-top: 6px;
font-size: 10px;
color: #94a3b8;
}
&.highlight {
.bar-fill {
background: linear-gradient(180deg, #a855f7 0%, rgba(168, 85, 247, 0.3) 100%);
}
.bar-day {
color: #d8b4fe;
}
}
&.highlight-main {
.bar-fill {
background: linear-gradient(180deg, #ec4899 0%, rgba(236, 72, 153, 0.35) 100%);
box-shadow: 0 0 10px rgba(236, 72, 153, 0.5);
}
.bar-day {
color: #f472b6;
font-weight: 600;
}
}
}
}
}
.category-distribution {
margin-top: 14px;
.distrib-bar {
display: flex;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
.seg-1 { background: #6366f1; }
.seg-2 { background: #ec4899; }
.seg-3 { background: #f59e0b; }
}
.distrib-labels {
display: flex;
justify-content: space-between;
margin-top: 8px;
.legend {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #94a3b8;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
&.dot-1 { background: #6366f1; }
&.dot-2 { background: #ec4899; }
&.dot-3 { background: #f59e0b; }
}
}
}
}
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(167, 139, 250, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(167, 139, 250, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(167, 139, 250, 0);
}
}
</style>

查看文件

@@ -1,8 +1,22 @@
<template>
<div class="login-form-wrapper">
<div class="login-form-title">{{ $t('login.form.title') }}</div>
<div class="login-form-sub-title">{{ $t('login.form.title') }}</div>
<div class="login-form-error-msg">{{ errorMessage }}</div>
<div class="login-card">
<!-- 顶部状态标识 -->
<div class="form-tag-badge">
<span class="badge-dot"></span>
<span>运营总控端 · 管理员验证</span>
</div>
<h2 class="login-form-title">{{ $t('login.form.title') }}</h2>
<p class="login-form-sub-title">{{ $t('login.form.subTitle') }}</p>
<!-- 错误信息反馈 -->
<transition name="fade">
<div v-if="errorMessage" class="login-form-error-msg">
<icon-close-circle-fill class="error-icon" />
<span>{{ errorMessage }}</span>
</div>
</transition>
<a-form
ref="loginForm"
:model="userInfo"
@@ -18,13 +32,17 @@
>
<a-input
v-model="userInfo.username"
size="large"
class="custom-input"
:placeholder="$t('login.form.userName.placeholder')"
allow-clear
>
<template #prefix>
<icon-user />
<icon-user class="field-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item
field="password"
:rules="[{ required: true, message: $t('login.form.password.errMsg') }]"
@@ -33,32 +51,56 @@
>
<a-input-password
v-model="userInfo.password"
size="large"
class="custom-input"
:placeholder="$t('login.form.password.placeholder')"
allow-clear
>
<template #prefix>
<icon-lock />
<icon-lock class="field-icon" />
</template>
</a-input-password>
</a-form-item>
<a-space :size="16" direction="vertical">
<div class="login-form-password-actions">
<a-checkbox
checked="rememberPassword"
:model-value="loginConfig.rememberPassword"
@change="setRememberPassword as any"
>
{{ $t('login.form.rememberPassword') }}
</a-checkbox>
<a-link>{{ $t('login.form.forgetPassword') }}</a-link>
</div>
<a-button type="primary" html-type="submit" long :loading="loading">
{{ $t('login.form.login') }}
<div class="login-form-actions-row">
<a-checkbox
:model-value="loginConfig.rememberPassword"
class="custom-checkbox"
@change="setRememberPassword as any"
>
{{ $t('login.form.rememberPassword') }}
</a-checkbox>
<a-link class="forget-link" @click="handleForgetPassword">
{{ $t('login.form.forgetPassword') }}
</a-link>
</div>
<div class="submit-wrap">
<a-button
type="primary"
html-type="submit"
size="large"
long
:loading="loading"
class="submit-btn"
>
<span class="btn-text">{{ $t('login.form.login') }}</span>
<icon-arrow-right class="btn-arrow" />
</a-button>
<a-button type="text" long class="login-form-register-btn">
</div>
<div class="form-footer-tips">
<span class="tips-label">暂无运营管理账号?</span>
<a-link class="apply-link" @click="handleApplyAccount">
{{ $t('login.form.register') }}
</a-button>
</a-space>
</a-link>
</div>
<!-- 底部安全背书标签 -->
<div class="security-endorse">
<icon-safe class="sec-icon" />
<span>平台已启用 SSL 传输加密与多因子动态审计</span>
</div>
</a-form>
</div>
</template>
@@ -100,6 +142,7 @@
if (loading.value) return;
if (!errors) {
setLoading(true);
errorMessage.value = '';
try {
await userStore.login(values as LoginData);
const { redirect, ...othersQuery } = router.currentRoute.value.query;
@@ -112,54 +155,248 @@
Message.success(t('login.form.login.success'));
const { rememberPassword } = loginConfig.value;
const { username, password } = values;
// 实际生产环境需要进行加密存储。
// The actual production environment requires encrypted storage.
loginConfig.value.username = rememberPassword ? username : '';
loginConfig.value.password = rememberPassword ? password : '';
} catch (err) {
errorMessage.value = (err as Error).message;
errorMessage.value = (err as Error).message || t('login.form.login.errMsg');
} finally {
setLoading(false);
}
}
};
const setRememberPassword = (value: boolean) => {
loginConfig.value.rememberPassword = value;
};
const handleForgetPassword = () => {
Message.info('管理员账号密码找回或重置,请联系系统超级管理员进行安全验证。');
};
const handleApplyAccount = () => {
Message.info('本系统为运营管理总控平台,暂未开放自主注册,需由超级管理员统一开通权限。');
};
</script>
<style lang="less" scoped>
.login-form {
&-wrapper {
width: 320px;
}
.login-card {
width: 420px;
max-width: 90vw;
padding: 40px 36px 32px;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 20px 45px -12px rgba(15, 23, 42, 0.12),
0 0 0 1px rgba(226, 232, 240, 0.85);
box-sizing: border-box;
transition: transform 0.3s ease, box-shadow 0.3s ease;
&-title {
color: var(--color-text-1);
font-weight: 500;
font-size: 24px;
line-height: 32px;
}
&-sub-title {
color: var(--color-text-3);
font-size: 16px;
line-height: 24px;
}
&-error-msg {
height: 32px;
color: rgb(var(--red-6));
line-height: 32px;
}
&-password-actions {
display: flex;
justify-content: space-between;
}
&-register-btn {
color: var(--color-text-3) !important;
&:hover {
box-shadow: 0 25px 55px -12px rgba(15, 23, 42, 0.15),
0 0 0 1px rgba(203, 213, 225, 0.9);
}
}
.form-tag-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: #6366f1;
margin-bottom: 12px;
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #6366f1;
}
}
.login-form-title {
margin: 0;
color: #0f172a;
font-weight: 700;
font-size: 24px;
line-height: 32px;
letter-spacing: -0.3px;
}
.login-form-sub-title {
margin: 6px 0 16px;
color: #64748b;
font-size: 13px;
line-height: 20px;
}
.login-form-error-msg {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: 16px;
border-radius: 8px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #ef4444;
font-size: 12px;
line-height: 18px;
.error-icon {
font-size: 14px;
flex-shrink: 0;
}
}
.custom-input {
height: 44px;
border-radius: 10px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
font-size: 14px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: #ffffff;
border-color: #cbd5e1;
}
&:focus-within {
background-color: #ffffff;
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
}
.field-icon {
color: #94a3b8;
font-size: 16px;
margin-right: 4px;
transition: color 0.2s;
}
&:focus-within .field-icon {
color: #8b5cf6;
}
}
.login-form-actions-row {
display: flex;
justify-content: space-between;
align-items: center;
margin: 4px 0 20px;
.custom-checkbox {
color: #64748b;
font-size: 13px;
user-select: none;
}
.forget-link {
color: #64748b;
font-size: 13px;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #8b5cf6;
}
}
}
.submit-wrap {
margin-bottom: 16px;
.submit-btn {
height: 46px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%) !important;
border: none !important;
box-shadow: 0 8px 20px -4px rgba(139, 92, 246, 0.45);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&:hover {
opacity: 0.95;
transform: translateY(-1px);
box-shadow: 0 12px 24px -4px rgba(139, 92, 246, 0.55);
}
&:active {
transform: translateY(0);
box-shadow: 0 4px 12px -2px rgba(139, 92, 246, 0.4);
}
.btn-arrow {
font-size: 14px;
transition: transform 0.2s;
}
&:hover .btn-arrow {
transform: translateX(3px);
}
}
}
.form-footer-tips {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 14px;
font-size: 13px;
.tips-label {
color: #94a3b8;
}
.apply-link {
color: #8b5cf6;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #7c3aed;
text-decoration: underline;
}
}
}
.security-endorse {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
font-size: 11px;
color: #94a3b8;
user-select: none;
.sec-icon {
font-size: 13px;
color: #10b981;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

查看文件

@@ -1,18 +1,34 @@
<template>
<div class="container">
<!-- 顶栏品牌标识 -->
<div class="logo">
<img
alt="logo"
:src="logoImg"
style="width: 32px; height: 32px; border-radius: 8px; object-fit: cover;"
class="logo-img"
/>
<div class="logo-text">同城生活陪伴 · 运营管理平台</div>
<div class="logo-title-group">
<span class="logo-main-title">同城陪玩</span>
<span class="logo-sub-title">运营管理总控平台</span>
</div>
<span class="logo-badge">PRO</span>
</div>
<LoginBanner />
<!-- 左侧陪玩主题视觉展示栏 -->
<div class="banner">
<LoginBanner />
</div>
<!-- 右侧登录表单与背景质感区 -->
<div class="content">
<!-- 背景光晕装饰,消除空旷感 -->
<div class="bg-orb orb-top"></div>
<div class="bg-orb orb-bottom"></div>
<div class="content-inner">
<LoginForm />
</div>
<div class="footer">
<Footer />
</div>
@@ -31,10 +47,15 @@
.container {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: #070a14;
.banner {
width: 550px;
background: linear-gradient(163.85deg, #1d2129 0%, #00308f 100%);
width: 560px;
height: 100%;
flex-shrink: 0;
position: relative;
}
.content {
@@ -44,6 +65,15 @@
align-items: center;
justify-content: center;
padding-bottom: 40px;
background-color: #f8fafc;
background-image: radial-gradient(#cbd5e1 1.2px, transparent 1.2px);
background-size: 26px 26px;
overflow: hidden;
}
.content-inner {
position: relative;
z-index: 2;
}
.footer {
@@ -51,32 +81,109 @@
right: 0;
bottom: 0;
width: 100%;
z-index: 2;
}
}
// 顶栏 Logo 与品牌
.logo {
position: fixed;
top: 24px;
left: 22px;
z-index: 1;
left: 28px;
z-index: 20;
display: inline-flex;
align-items: center;
gap: 10px;
user-select: none;
&-text {
margin-right: 4px;
margin-left: 4px;
color: var(--color-fill-1);
font-size: 20px;
&-img {
width: 36px;
height: 36px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
}
&-title-group {
display: flex;
flex-direction: column;
}
&-main-title {
color: #ffffff;
font-size: 16px;
font-weight: 700;
line-height: 20px;
letter-spacing: 0.5px;
}
&-sub-title {
color: rgba(226, 232, 240, 0.7);
font-size: 11px;
line-height: 14px;
}
&-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
color: #ffffff;
box-shadow: 0 2px 6px rgba(236, 72, 153, 0.35);
letter-spacing: 0.5px;
}
}
</style>
<style lang="less" scoped>
// responsive
@media (max-width: @screen-lg) {
// 背景装饰氛围
.bg-orb {
position: absolute;
border-radius: 50%;
pointer-events: none;
z-index: 1;
&.orb-top {
width: 520px;
height: 520px;
top: -140px;
right: -100px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.08) 0%, transparent 70%);
filter: blur(70px);
}
&.orb-bottom {
width: 480px;
height: 480px;
bottom: -120px;
left: 10%;
background: radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, transparent 70%);
filter: blur(65px);
}
}
// 响应式适配
@media (max-width: 1080px) {
.container {
.banner {
width: 25%;
width: 440px;
}
}
}
@media (max-width: 768px) {
.container {
.banner {
display: none;
}
.logo {
&-main-title {
color: #0f172a;
}
&-sub-title {
color: #64748b;
}
}
}
}

查看文件

@@ -1,21 +1,20 @@
export default {
'login.form.title': 'Login to Arco Design Pro',
'login.form.title': 'Companion Admin Console',
'login.form.subTitle': 'One-stop management platform for esports & lifestyle companionship',
'login.form.userName.errMsg': 'Username cannot be empty',
'login.form.password.errMsg': 'Password cannot be empty',
'login.form.login.errMsg': 'Login error, refresh and try again',
'login.form.login.success': 'welcome to use',
'login.form.userName.placeholder': 'Username',
'login.form.login.errMsg': 'Login error, please refresh and try again',
'login.form.login.success': 'Welcome to Companion Admin Console',
'login.form.userName.placeholder': 'Username or phone number',
'login.form.password.placeholder': 'Password',
'login.form.rememberPassword': 'Remember password',
'login.form.forgetPassword': 'Forgot password',
'login.form.login': 'login',
'login.form.register': 'register account',
'login.banner.slogan1': 'Out-of-the-box high-quality template',
'login.banner.subSlogan1':
'Rich page templates, covering most typical business scenarios',
'login.banner.slogan2': 'Built-in solutions to common problems',
'login.banner.subSlogan2':
'Internationalization, routing configuration, state management everything',
'login.banner.slogan3': 'Access visualization enhancement tool AUX',
'login.banner.subSlogan3': 'Realize flexible block development',
'login.form.rememberPassword': 'Remember account',
'login.form.forgetPassword': 'Forgot password?',
'login.form.login': 'Sign In',
'login.form.register': 'Apply for account',
'login.banner.slogan1': 'Top Companions & Intelligent Dispatching',
'login.banner.subSlogan1': 'Gathering esports experts and lifestyle companions with real-time matching',
'login.banner.slogan2': 'Escrow Guarantee & Service Compliance',
'login.banner.subSlogan2': 'Platform escrow settlement and verified real-name protection for trust and safety',
'login.banner.slogan3': 'Omnichannel Analytics & Business Growth',
'login.banner.subSlogan3': 'Full visibility into GMV, companion ratings, and user retention to empower operations',
};

查看文件

@@ -1,19 +1,20 @@
export default {
'login.form.title': '登录同城陪伴运营控制台',
'login.form.title': '同城陪玩运营控制台',
'login.form.subTitle': '同城电竞陪玩与生活陪伴一站式管理平台',
'login.form.userName.errMsg': '用户名不能为空',
'login.form.password.errMsg': '密码不能为空',
'login.form.login.errMsg': '登录出错,轻刷新重试',
'login.form.login.success': '欢迎使用',
'login.form.userName.placeholder': '请输入管理员用户名',
'login.form.password.placeholder': '请输入管理员密码',
'login.form.rememberPassword': '记住密码',
'login.form.forgetPassword': '忘记密码',
'login.form.login': '登录',
'login.form.register': '注册账号',
'login.banner.slogan1': '开箱即用的高质量模板',
'login.banner.subSlogan1': '丰富的的页面模板,覆盖大多数典型业务场景',
'login.banner.slogan2': '内置了常见问题的解决方案',
'login.banner.subSlogan2': '国际化,路由配置,状态管理应有尽有',
'login.banner.slogan3': '接入可视化增强工具AUX',
'login.banner.subSlogan3': '实现灵活的区块式开发',
'login.form.login.errMsg': '登录出错,请刷新重试',
'login.form.login.success': '欢迎登录同城陪玩运营控制台',
'login.form.userName.placeholder': '请输入管理员账号 / 手机号',
'login.form.password.placeholder': '请输入管理员登录密码',
'login.form.rememberPassword': '记住账号',
'login.form.forgetPassword': '忘记密码?',
'login.form.login': '立即登录系统',
'login.form.register': '申请开通账号',
'login.banner.slogan1': '全城金牌陪玩 · 智能精准调度',
'login.banner.subSlogan1': '汇聚高技术电竞大神与温馨生活陪伴,多维标签实时高效匹配',
'login.banner.slogan2': '资金双向担保 · 履约服务保障',
'login.banner.subSlogan2': '平台资金存管结算、实名双重认证核验,构筑健康可信赖的陪玩生态',
'login.banner.slogan3': '全链路数据大盘 · 驱动业务增长',
'login.banner.subSlogan3': '订单流水分成、陪玩师画像热度与用户留存全景可视,赋能精细化决策',
};

查看文件

@@ -339,7 +339,7 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="弹窗主标题">
<a-input v-model="homePopupForm.valueJson.title" placeholder="如:新人专享同城陪伴好礼" />
<a-input v-model="homePopupForm.valueJson.title" placeholder="如:新人专享同城陪玩好礼" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -1233,7 +1233,7 @@ const homePopupForm = reactive({
status: 1,
valueJson: {
enabled: true,
title: '新人专享同城陪伴好礼',
title: '新人专享同城陪玩好礼',
subtitle: '注册即送20元无门槛立减券,首单专享特惠',
imageUrl: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=800',
link: '/pages/activity/list',

查看文件

@@ -44,6 +44,7 @@ export function formatActivityData(raw) {
status: raw.status || (joinedCount >= (raw.maxParticipants || 4) ? 'FULL' : 'RECRUITING'),
allowDynamicPrice: Boolean(raw.allowDynamicPrice),
priceAdjustRule: raw.priceAdjustRule || 'UNIFIED_HIGH',
genderRequirement: raw.genderRequirement || raw.gender_requirement || 'ALL',
participants,
priceAdjustHistory: raw.priceAdjustHistory || [],
};
@@ -117,6 +118,7 @@ export async function createActivity(payload) {
maxParticipants: Number(payload.maxParticipants) || 4,
allowDynamicPrice: payload.allowDynamicPrice !== undefined ? (payload.allowDynamicPrice ? 1 : 0) : 1,
priceAdjustRule: payload.priceAdjustRule || 'UNIFIED_HIGH',
genderRequirement: payload.genderRequirement || 'ALL',
};
try {

查看文件

@@ -66,7 +66,7 @@
</view>
<!-- 基础信息录入 -->
<view class="form-card">
<view class="form-card basic-info-card">
<view class="input-row">
<text class="row-label">活动标题</text>
<input
@@ -88,7 +88,7 @@
>
<view class="picker-display-box btn-active">
<text class="picker-val" :class="{ placeholder: !form.startTime }">
{{ form.startTime || '请选择活动开始时段' }}
{{ form.startTime || '请选择活动时段' }}
</text>
<view class="picker-right-tag">
<text class="picker-tag-text">选择时间</text>
@@ -169,6 +169,22 @@
</view>
</view>
<!-- 凑局性别限制 -->
<view class="gender-limit-block">
<text class="row-label">凑局性别限制</text>
<view class="gender-pill-group">
<view
v-for="opt in genderOptions"
:key="opt.value"
class="gender-pill btn-active"
:class="{ active: form.genderRequirement === opt.value }"
@click="form.genderRequirement = opt.value"
>
{{ opt.label }}
</view>
</view>
</view>
<view class="input-row" v-if="form.payType === 'AA'">
<text class="row-label">人均分摊 (元/人)</text>
<input
@@ -448,6 +464,7 @@ const form = ref({
latitude: null,
longitude: null,
maxParticipants: 4,
genderRequirement: 'ALL',
aaFee: '',
pricingMode: 'PER_PERSON',
perPersonFee: '',
@@ -456,6 +473,12 @@ const form = ref({
priceAdjustRule: 'UNIFIED_HIGH',
});
const genderOptions = [
{ label: '仅女生', value: 'FEMALE_ONLY' },
{ label: '仅男生', value: 'MALE_ONLY' },
{ label: '不限', value: 'ALL' }
];
const calcTotalFee = computed(() => {
if (form.value.payType === 'AA') {
return (Number(form.value.aaFee) || 0) * form.value.maxParticipants;
@@ -485,11 +508,11 @@ const changeDispatchHours = (delta) => {
const recommendPublicZones = ref([]);
const presetTimeOptions = ref([
'今晚 19:30',
'今晚 20:30',
'明天 14:00',
'周六 18:30',
'周日 15:00'
'今晚 19:30 - 21:30',
'今晚 20:30 - 22:30',
'明天 14:00 - 17:00',
'周六 18:30 - 21:30',
'周日 15:00 - 18:00'
]);
const timePickerDays = (() => {
@@ -506,7 +529,7 @@ const timePickerDays = (() => {
return baseDays;
})();
const timePickerSlots = [
const timePickerStartSlots = [
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
'12:00', '12:30', '13:00', '13:30', '14:00', '14:30',
'15:00', '15:30', '16:00', '16:30', '17:00', '17:30',
@@ -515,28 +538,86 @@ const timePickerSlots = [
'00:00', '00:30', '01:00'
];
const timePickerRange = ref([timePickerDays, timePickerSlots]);
const timePickerIndex = ref([5, 19]); // 默认对应 周六 18:30
const timePickerEndSlots = [
'09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '12:30',
'13:00', '13:30', '14:00', '14:30', '15:00', '15:30',
'16:00', '16:30', '17:00', '17:30', '18:00', '18:30',
'19:00', '19:30', '20:00', '20:30', '21:00', '21:30',
'22:00', '22:30', '23:00', '23:30', '00:00', '00:30',
'01:00', '01:30', '02:00', '02:30', '03:00', '03:30',
'04:00', '05:00'
];
const timePickerRange = ref([timePickerDays, timePickerStartSlots, timePickerEndSlots]);
const timePickerIndex = ref([5, 19, 24]); // 默认对应 周六 18:30 - 21:30
const toSlotMinutes = (timeStr) => {
const [h, m] = String(timeStr || '').split(':').map(Number);
return (h || 0) * 60 + (m || 0);
};
const onTimePickerChange = (e) => {
const val = (e && e.detail && Array.isArray(e.detail.value)) ? e.detail.value : [0, 0];
const dayIdx = Number(val[0]) || 0;
const slotIdx = Number(val[1]) || 0;
timePickerIndex.value = [dayIdx, slotIdx];
const val = (e && e.detail && Array.isArray(e.detail.value)) ? e.detail.value : [0, 0, 0];
const dayIdx = Math.max(0, Math.min(timePickerDays.length - 1, Number(val[0]) || 0));
const startIdx = Math.max(0, Math.min(timePickerStartSlots.length - 1, Number(val[1]) || 0));
let endIdx = Math.max(0, Math.min(timePickerEndSlots.length - 1, Number(val[2]) || 0));
const dayStr = timePickerDays[dayIdx] || timePickerDays[0];
const slotStr = timePickerSlots[slotIdx] || timePickerSlots[0];
form.value.startTime = `${dayStr} ${slotStr}`;
const startStr = timePickerStartSlots[startIdx] || timePickerStartSlots[0];
let endStr = timePickerEndSlots[endIdx] || timePickerEndSlots[0];
const startMin = toSlotMinutes(startStr);
let endMin = toSlotMinutes(endStr);
// 凌晨 00:00 - 05:00 视为跨夜次日
if (endMin <= startMin && endMin <= 300) {
endMin += 1440;
}
// 结束时间须晚于开始时间,若不合理则智能纠偏为开始后 2 小时
if (endMin <= startMin) {
const targetMin = startMin + 120;
let bestIdx = -1;
for (let i = 0; i < timePickerEndSlots.length; i++) {
let m = toSlotMinutes(timePickerEndSlots[i]);
if (m <= startMin && m <= 300) m += 1440;
if (m >= targetMin) {
bestIdx = i;
break;
}
}
if (bestIdx >= 0) {
endIdx = bestIdx;
endStr = timePickerEndSlots[bestIdx];
} else {
endIdx = timePickerEndSlots.length - 1;
endStr = timePickerEndSlots[endIdx];
}
uni.showToast({ title: '已自动为您将活动结束时间设为 2 小时后', icon: 'none' });
}
timePickerIndex.value = [dayIdx, startIdx, endIdx];
form.value.startTime = `${dayStr} ${startStr} - ${endStr}`;
};
const selectQuickTime = (timeItem) => {
form.value.startTime = timeItem;
const parts = String(timeItem).split(' ');
if (parts.length === 2) {
const dIdx = timePickerDays.indexOf(parts[0]);
const sIdx = timePickerSlots.indexOf(parts[1]);
const match = String(timeItem).trim().match(/^(\S+)\s+([\d:]+)(?:\s*-\s*([\d:]+))?$/);
if (match) {
const dayStr = match[1];
const startStr = match[2];
const endStr = match[3];
const dIdx = timePickerDays.indexOf(dayStr);
const sIdx = timePickerStartSlots.indexOf(startStr);
let eIdx = endStr ? timePickerEndSlots.indexOf(endStr) : -1;
if (eIdx < 0) {
eIdx = Math.min(timePickerEndSlots.length - 1, Math.max(0, sIdx + 4));
}
timePickerIndex.value = [
dIdx >= 0 ? dIdx : 0,
sIdx >= 0 ? sIdx : 0
sIdx >= 0 ? sIdx : 0,
eIdx >= 0 ? eIdx : 0
];
}
};
@@ -809,6 +890,7 @@ const handleSubmit = async () => {
totalFee: calcTotalFee.value,
aaFee: Number(form.value.aaFee) || 0,
maxParticipants: form.value.maxParticipants,
genderRequirement: form.value.genderRequirement || 'ALL',
startTime: trimmedTime,
location: trimmedLocation,
latitude: form.value.latitude,
@@ -826,6 +908,7 @@ const handleSubmit = async () => {
form.value.title = '';
form.value.startTime = '';
form.value.location = '';
form.value.genderRequirement = 'ALL';
form.value.aaFee = '';
form.value.perPersonFee = '';
form.value.totalFee = '';
@@ -995,6 +1078,15 @@ const handleSubmit = async () => {
color: #94A3B8;
}
&.basic-info-card {
.input-row {
.row-label {
width: 140rpx;
min-width: 140rpx;
}
}
}
.input-row {
display: flex;
align-items: center;
@@ -1035,18 +1127,25 @@ const handleSubmit = async () => {
.time-picker-flex {
flex: 1;
min-width: 0;
.picker-display-box {
display: flex;
align-items: center;
justify-content: space-between;
height: 52rpx;
gap: 16rpx;
min-height: 52rpx;
.picker-val {
flex: 1;
min-width: 0;
font-size: 26rpx;
font-weight: 600;
color: #F8FAFC;
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.placeholder {
color: #475569;
@@ -1055,6 +1154,8 @@ const handleSubmit = async () => {
}
.picker-right-tag {
flex-shrink: 0;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6rpx;
@@ -1067,6 +1168,7 @@ const handleSubmit = async () => {
font-size: 20rpx;
font-weight: 600;
color: #818CF8;
white-space: nowrap;
}
.picker-arrow {
@@ -1074,6 +1176,7 @@ const handleSubmit = async () => {
font-weight: 700;
color: #818CF8;
line-height: 1;
white-space: nowrap;
}
}
}
@@ -1187,6 +1290,7 @@ const handleSubmit = async () => {
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 6rpx 16rpx;
border-radius: 12rpx;
white-space: nowrap;
transition: all 0.15s;
&.selected {
@@ -1258,6 +1362,44 @@ const handleSubmit = async () => {
}
}
.gender-limit-block {
padding: 24rpx 0 20rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
.row-label {
font-size: 26rpx;
color: #CBD5E1;
font-weight: 600;
margin-bottom: 18rpx;
display: block;
}
.gender-pill-group {
display: flex;
gap: 16rpx;
.gender-pill {
flex: 1;
height: 72rpx;
line-height: 72rpx;
text-align: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16rpx;
font-size: 26rpx;
color: #94A3B8;
transition: all 0.15s ease-out;
&.active {
background: rgba(99, 102, 241, 0.2);
border-color: #6366F1;
color: #818CF8;
font-weight: 700;
}
}
}
}
.calc-preview {
margin-top: 20rpx;
padding: 16rpx 20rpx;
@@ -1572,6 +1714,29 @@ const handleSubmit = async () => {
}
}
.gender-limit-block {
border-bottom-color: #F5F2EC;
.row-label {
color: #1C1917;
}
.gender-pill-group {
.gender-pill {
background: #F8F5F0;
border-color: #E7E5E4;
color: #57534E;
&.active {
background: #FFF7ED;
border-color: #EA580C;
color: #EA580C;
font-weight: 700;
}
}
}
}
.form-title {
color: #1C1917;
}
@@ -1598,9 +1763,12 @@ const handleSubmit = async () => {
.picker-right-tag {
background: #FFF7ED;
border-color: #FDBA74;
flex-shrink: 0;
white-space: nowrap;
.picker-tag-text,
.picker-arrow {
color: #EA580C;
white-space: nowrap;
}
}
}

查看文件

@@ -88,7 +88,7 @@
<view class="info-list">
<view class="info-row">
<text class="info-label">开始时间</text>
<text class="info-label">活动时段</text>
<text class="info-val">{{ activity.startTime }}</text>
</view>
<view class="info-row">
@@ -98,6 +98,18 @@
<text class="copy-btn">复制</text>
</view>
</view>
<view class="info-row">
<text class="info-label">性别限制</text>
<text
class="info-val"
:class="{
'female-text': activity.genderRequirement === 'FEMALE_ONLY' || activity.genderRequirement === '仅女生',
'male-text': activity.genderRequirement === 'MALE_ONLY' || activity.genderRequirement === '仅男生'
}"
>
{{ formatGenderRequirement(activity.genderRequirement) }}
</text>
</view>
<view class="info-row">
<text class="info-label">核销机制</text>
<text class="info-val green-text">现场出示4位动态门票码核销签到</text>
@@ -502,6 +514,7 @@ const activity = ref({
perPersonFee: 0,
maxParticipants: 0,
currentParticipants: 0,
genderRequirement: 'ALL',
startTime: '',
location: '',
creatorId: 0,
@@ -511,6 +524,12 @@ const activity = ref({
priceAdjustHistory: []
});
const formatGenderRequirement = (req) => {
if (req === 'FEMALE_ONLY' || req === '仅女生') return '仅女生';
if (req === 'MALE_ONLY' || req === '仅男生') return '仅男生';
return '不限性别';
};
useShare(() => {
const act = activity.value;
const title = act && act.title ? `【组局】${act.title}` : '同城热门组局活动邀你加入';
@@ -841,6 +860,17 @@ const handleJoin = () => {
if (!ensureLoginOrRedirect(`/pages/activity/detail?id=${currentActId.value}`)) {
return;
}
const cachedUser = uni.getStorageSync('userInfo') || uni.getStorageSync('USER_INFO') || {};
const userGender = String(cachedUser.gender || '').trim();
const reqGender = activity.value.genderRequirement;
if ((reqGender === 'FEMALE_ONLY' || reqGender === '仅女生') && userGender === '男') {
uni.showToast({ title: '该组局仅限女生报名参与', icon: 'none' });
return;
}
if ((reqGender === 'MALE_ONLY' || reqGender === '仅男生') && userGender === '女') {
uni.showToast({ title: '该组局仅限男生报名参与', icon: 'none' });
return;
}
uni.showModal({
title: '确认报名',
content: `报名该【${activity.value.payType === 'AA' ? 'AA制' : '房主请客'}】组局,${activity.value.payType === 'AA' ? `需支付分摊款 ¥${activity.value.aaFee}` : '由房主全额买单请客'}。报名后自动生成4位入局核销码。`,
@@ -1244,6 +1274,16 @@ const handleJoin = () => {
&.green-text {
color: #10B981;
}
&.female-text {
color: #EC4899;
font-weight: 600;
}
&.male-text {
color: #38BDF8;
font-weight: 600;
}
}
.copy-val {
@@ -2194,6 +2234,14 @@ const handleJoin = () => {
&.green-text {
color: #059669;
}
&.female-text {
color: #DB2777;
font-weight: 600;
}
&.male-text {
color: #2563EB;
font-weight: 600;
}
}
.copy-val .copy-btn {
color: #EA580C;

查看文件

@@ -104,6 +104,12 @@
<view class="mode-tag" :class="act.payType === 'AA' ? 'aa-mode' : 'project-mode'">
{{ act.payType === 'AA' ? 'AA制平摊' : '房主请客' }}
</view>
<view class="gender-req-tag female" v-if="act.genderRequirement === 'FEMALE_ONLY' || act.genderRequirement === '仅女生'">
仅限女生
</view>
<view class="gender-req-tag male" v-else-if="act.genderRequirement === 'MALE_ONLY' || act.genderRequirement === '仅男生'">
仅限男生
</view>
<view class="status-pill" :class="act.status || 'RECRUITING'">
{{ getStatusText(act.status) }}
</view>
@@ -705,6 +711,25 @@ const createActivity = () => {
}
}
.gender-req-tag {
font-size: 20rpx;
padding: 4rpx 14rpx;
border-radius: 12rpx;
font-weight: 600;
&.female {
background: rgba(236, 72, 153, 0.14);
border: 1px solid rgba(236, 72, 153, 0.35);
color: #F472B6;
}
&.male {
background: rgba(59, 130, 246, 0.14);
border: 1px solid rgba(59, 130, 246, 0.35);
color: #60A5FA;
}
}
.status-pill {
margin-left: auto;
font-size: 20rpx;
@@ -952,6 +977,19 @@ const createActivity = () => {
border-color: #F0ECE4;
box-shadow: 0 6rpx 20rpx rgba(28, 25, 23, 0.05);
.gender-req-tag {
&.female {
background: #FDF2F8;
border-color: #FBCFE8;
color: #DB2777;
}
&.male {
background: #EFF6FF;
border-color: #BFDBFE;
color: #2563EB;
}
}
.activity-title {
color: #1C1917;
}

查看文件

@@ -36,6 +36,7 @@ export async function initDatabase(): Promise<void> {
max_participants INT DEFAULT 4,
allow_dynamic_price TINYINT DEFAULT 1,
price_adjust_rule VARCHAR(32) DEFAULT 'UNIFIED_HIGH',
gender_requirement VARCHAR(32) DEFAULT 'ALL',
status VARCHAR(32) DEFAULT 'RECRUITING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -47,6 +48,12 @@ export async function initDatabase(): Promise<void> {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN gender_requirement VARCHAR(32) DEFAULT 'ALL' AFTER price_adjust_rule`);
} catch (_) {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN latitude DECIMAL(10,6) DEFAULT NULL AFTER location`);
} catch (_) {

查看文件

@@ -48,6 +48,7 @@ export interface ActivityRow {
totalFeeCents: number;
perPersonFeeCents: number;
maxParticipants: number;
genderRequirement?: string;
allowDynamicPrice: boolean;
priceAdjustRule: string;
joinedCount?: number;
@@ -143,6 +144,7 @@ export class MysqlRepository {
a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents,
a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule,
COALESCE(a.gender_requirement, 'ALL') as genderRequirement,
COALESCE(a.status, 'RECRUITING') as status,
a.created_at as createdAt,
u.nickname as creatorNickname,
@@ -217,6 +219,7 @@ export class MysqlRepository {
a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents,
a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule,
COALESCE(a.gender_requirement, 'ALL') as genderRequirement,
COALESCE(a.status, 'RECRUITING') as status,
u.nickname as creatorNickname,
u.avatar as creatorAvatar
@@ -253,6 +256,31 @@ export class MysqlRepository {
};
}
async getAllActivityParticipants(): Promise<any[]> {
const [ptRows]: any = await pool.query(`
SELECT
p.id, p.activity_id as activityId, p.user_id as userId,
COALESCE(u.nickname, '同城组局参与者') as nickname,
COALESCE(u.avatar, '') as avatar,
p.pay_amount_cents as payAmountCents, p.verify_code as verifyCode,
p.checkin_status as checkinStatus, p.verified_at as verifiedAt,
p.verified_by as verifiedBy, p.created_at as createdAt,
COALESCE(a.title, '未知活动') as activityTitle,
COALESCE(a.game_category, '组局') as gameCategory,
COALESCE(a.status, 'RECRUITING') as activityStatus,
COALESCE(a.location, '') as activityLocation
FROM participants p
LEFT JOIN users u ON u.id = p.user_id
LEFT JOIN activities a ON a.id = p.activity_id
ORDER BY p.created_at DESC
`);
return (ptRows || []).map((p: any) => ({
...p,
checkinStatus: Boolean(p.checkinStatus),
}));
}
async createActivity(data: {
creatorId: number;
title: string;
@@ -267,6 +295,7 @@ export class MysqlRepository {
maxParticipants?: number;
allowDynamicPrice?: number | boolean;
priceAdjustRule?: string;
genderRequirement?: string;
}): Promise<any> {
const [userRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [data.creatorId]);
if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) {
@@ -294,10 +323,18 @@ export class MysqlRepository {
const allowDynamicPrice = data.allowDynamicPrice !== undefined ? (data.allowDynamicPrice ? 1 : 0) : 1;
const priceAdjustRule = data.priceAdjustRule || 'UNIFIED_HIGH';
let genderRequirement = 'ALL';
const rawGender = String(data.genderRequirement || '').trim();
if (rawGender === 'FEMALE_ONLY' || rawGender === '仅女生' || rawGender === '女') {
genderRequirement = 'FEMALE_ONLY';
} else if (rawGender === 'MALE_ONLY' || rawGender === '仅男生' || rawGender === '男') {
genderRequirement = 'MALE_ONLY';
}
await pool.query(`
INSERT INTO activities
(id, creator_id, title, game_category, location, latitude, longitude, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, creator_id, title, game_category, location, latitude, longitude, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule, gender_requirement)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
data.creatorId,
@@ -313,6 +350,7 @@ export class MysqlRepository {
maxParticipants,
allowDynamicPrice,
priceAdjustRule,
genderRequirement,
]);
return this.getActivityById(id);
@@ -400,7 +438,7 @@ export class MysqlRepository {
await conn.beginTransaction();
// 1. 封禁校验(事务内)
const [userRows]: any = await conn.query('SELECT status FROM users WHERE id = ?', [params.userId]);
const [userRows]: any = await conn.query('SELECT status, gender FROM users WHERE id = ?', [params.userId]);
if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) {
const err: any = new Error('您的账号已被管理员封禁,无法报名参加活动');
err.status = 403;
@@ -410,10 +448,26 @@ export class MysqlRepository {
// 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖
const [actRows]: any = await conn.query(
'SELECT id, max_participants, pay_type, per_person_fee_cents FROM activities WHERE id = ? FOR UPDATE',
'SELECT id, max_participants, pay_type, per_person_fee_cents, COALESCE(gender_requirement, "ALL") as genderRequirement FROM activities WHERE id = ? FOR UPDATE',
[activityId]
);
if (!actRows || actRows.length === 0) throw new Error('活动不存在');
// 组局性别限制合规校验
const genderReq = String(actRows[0].genderRequirement || 'ALL');
const userGender = String(userRows?.[0]?.gender || '').trim();
if (genderReq === 'FEMALE_ONLY' && userGender === '男') {
const err: any = new Error('该组局仅限女生报名参与');
err.status = 400;
err.code = 40011;
throw err;
}
if (genderReq === 'MALE_ONLY' && userGender === '女') {
const err: any = new Error('该组局仅限男生报名参与');
err.status = 400;
err.code = 40012;
throw err;
}
const maxParticipants = Number(actRows[0].max_participants || 0);
// 报名费由服务端活动配置决定(pay_type=1 为 AA 收取人均分摊,其余为房主请客 0 元),
// 严禁信任客户端传入的 payAmountCents,否则可"免费报名 → 退款/清算套现"。
@@ -847,6 +901,7 @@ export class MysqlRepository {
a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents,
a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule,
COALESCE(a.gender_requirement, 'ALL') as genderRequirement,
COALESCE(a.status, 'RECRUITING') as status,
a.created_at as createdAt,
u.nickname as creatorNickname,

查看文件

@@ -1104,6 +1104,28 @@ app.get('/api/activities', async (req: Request, res: Response) => {
}
});
// 管理端全量活动核销状态大盘数据
app.get('/api/activities/all', async (req: Request, res: Response) => {
try {
const isAdmin = isVerifiedAdminRequest(req);
if (!isAdmin) {
return res.status(403).json({ code: 40300, message: '仅管理员可查看全部活动核销状态' });
}
const participants = await repo.getAllActivityParticipants();
return res.json({
code: 20000,
data: {
id: 'all',
title: '全部活动',
gameCategory: '全品类',
participants,
},
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.get('/api/activities/:id', async (req: Request, res: Response) => {
try {
const act = await repo.getActivityById(String(req.params.id));
@@ -1214,6 +1236,20 @@ app.post('/api/activities/:id/join', async (req: Request, res: Response) => {
}
});
// 管理端全量视图下4位核销码核销
app.post('/api/activities/all/verify', async (req: Request, res: Response) => {
try {
const { operatorId, verifyCode } = req.body;
let operator = resolveSecureUserId(req, Number(operatorId || 0), '核销组局门票');
const result = await repo.universalVerifyCheckin(String(verifyCode), operator);
res.json({ code: 20000, data: result, message: '核销成功,已完成现场到场签到' });
} catch (err: any) {
const statusCode = err.status || 400;
const errCode = err.code || 40000;
res.status(statusCode).json({ code: errCode, message: err.message });
}
});
// 房主/管理员核销 4 位核销码 (防重落盘 MySQL)
app.post('/api/activities/:id/verify', async (req: Request, res: Response) => {
try {

查看文件

@@ -0,0 +1,73 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第一百零二批回归闸门:组局性别限制功能全链路闭环测试
*/
describe('第一百零二批组局性别限制闭环测试', () => {
const rootDir = path.resolve(__dirname, '../../');
const mysqlTsPath = path.join(rootDir, 'server/src/db/mysql.ts');
const repoTsPath = path.join(rootDir, 'server/src/db/repository.ts');
const apiJsPath = path.join(rootDir, 'pwuniapp/common/api.js');
const createVuePath = path.join(rootDir, 'pwuniapp/pages/activity/create.vue');
const detailVuePath = path.join(rootDir, 'pwuniapp/pages/activity/detail.vue');
const listVuePath = path.join(rootDir, 'pwuniapp/pages/activity/list.vue');
const mysqlContent = fs.readFileSync(mysqlTsPath, 'utf-8');
const repoContent = fs.readFileSync(repoTsPath, 'utf-8');
const apiJsContent = fs.readFileSync(apiJsPath, 'utf-8');
const createVueContent = fs.readFileSync(createVuePath, 'utf-8');
const detailVueContent = fs.readFileSync(detailVuePath, 'utf-8');
const listVueContent = fs.readFileSync(listVuePath, 'utf-8');
it('数据库结构必须包含 gender_requirement 字段及迁移补丁', () => {
expect(mysqlContent).toContain('gender_requirement VARCHAR(32) DEFAULT \'ALL\'');
expect(mysqlContent).toContain('ADD COLUMN gender_requirement VARCHAR(32)');
});
it('仓储层 createActivity 必须支持并归一化 genderRequirement', () => {
expect(repoContent).toContain('genderRequirement?: string;');
expect(repoContent).toContain("genderRequirement = 'FEMALE_ONLY'");
expect(repoContent).toContain("genderRequirement = 'MALE_ONLY'");
expect(repoContent).toContain('gender_requirement');
});
it('仓储层 joinActivity 必须严格执行性别限制校验,违规时拦截', () => {
expect(repoContent).toContain('该组局仅限女生报名参与');
expect(repoContent).toContain('该组局仅限男生报名参与');
expect(repoContent).toContain('genderRequirement || \'ALL\'');
});
it('小程序 API 层必须透传与格式化 genderRequirement', () => {
expect(apiJsContent).toContain('genderRequirement: payload.genderRequirement || \'ALL\'');
expect(apiJsContent).toContain('raw.genderRequirement || raw.gender_requirement || \'ALL\'');
});
it('组局发布页 create.vue 必须提供「凑局性别限制」配置项及三选一胶囊', () => {
expect(createVueContent).toContain('凑局性别限制');
expect(createVueContent).toContain('genderOptions');
expect(createVueContent).toContain("'仅女生'");
expect(createVueContent).toContain("'仅男生'");
expect(createVueContent).toContain("'不限'");
expect(createVueContent).toContain('genderRequirement: \'ALL\'');
expect(createVueContent).toContain('.gender-limit-block');
});
it('详情页与大厅列表必须展示对应性别限制标签', () => {
expect(detailVueContent).toContain('性别限制');
expect(detailVueContent).toContain('formatGenderRequirement');
expect(listVueContent).toContain('仅限女生');
expect(listVueContent).toContain('仅限男生');
expect(listVueContent).toContain('gender-req-tag');
});
it('全量修改文件必须严格遵守零 Emoji 门禁规范', () => {
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
expect(emojiRegex.test(mysqlContent)).toBe(false);
expect(emojiRegex.test(repoContent)).toBe(false);
expect(emojiRegex.test(createVueContent)).toBe(false);
expect(emojiRegex.test(detailVueContent)).toBe(false);
expect(emojiRegex.test(listVueContent)).toBe(false);
});
});

查看文件

@@ -0,0 +1,68 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第一百零一批回归闸门:活动时段与快捷时段起止时间闭环校验
*/
describe('第一百零一批活动时段与快捷时段起止时间闭环', () => {
const rootDir = path.resolve(__dirname, '../../');
const createVuePath = path.join(rootDir, 'pwuniapp/pages/activity/create.vue');
const detailVuePath = path.join(rootDir, 'pwuniapp/pages/activity/detail.vue');
const createVueContent = fs.readFileSync(createVuePath, 'utf-8');
const detailVueContent = fs.readFileSync(detailVuePath, 'utf-8');
it('快捷时段必须包含起止时间,不得仅有单点开始时间', () => {
expect(createVueContent).toContain("'今晚 19:30 - 21:30'");
expect(createVueContent).toContain("'今晚 20:30 - 22:30'");
expect(createVueContent).toContain("'明天 14:00 - 17:00'");
expect(createVueContent).toContain("'周六 18:30 - 21:30'");
expect(createVueContent).toContain("'周日 15:00 - 18:00'");
// 确保不再包含旧的无结束时间单点快捷项
expect(createVueContent).not.toMatch(/'今晚 19:30',/);
expect(createVueContent).not.toMatch(/'周六 18:30',/);
});
it('时间选择器必须升级为三列多列选择器(日期、开始时间、结束时间)', () => {
expect(createVueContent).toContain('const timePickerStartSlots =');
expect(createVueContent).toContain('const timePickerEndSlots =');
expect(createVueContent).toContain('const timePickerRange = ref([timePickerDays, timePickerStartSlots, timePickerEndSlots]);');
expect(createVueContent).toContain('const timePickerIndex = ref([5, 19, 24]);');
});
it('时间选择变动必须输出带有起止时间范围的完整时段字符串', () => {
expect(createVueContent).toContain('${dayStr} ${startStr} - ${endStr}');
expect(createVueContent).toContain('toSlotMinutes');
});
it('快捷时段点击必须支持解析起止时间并同步更新三列索引', () => {
expect(createVueContent).toContain('const match = String(timeItem).trim().match(/^(\\S+)\\s+([\\d:]+)(?:\\s*-\\s*([\\d:]+))?$/);');
expect(createVueContent).toContain('timePickerIndex.value = [');
});
it('活动详情页必须将展示文案统一为「活动时段」', () => {
expect(detailVueContent).toContain('<text class="info-label">活动时段</text>');
expect(detailVueContent).not.toContain('<text class="info-label">开始时间</text>');
});
it('时段选择器布局必须具备防挤压换行保护(无换行、省略截断与标签不压缩)', () => {
// 占位符必须精炼,避免超长换行
expect(createVueContent).toContain("{{ form.startTime || '请选择活动时段' }}");
expect(createVueContent).not.toContain('请选择活动时段 (起止时间)');
// 标签必须 flex-shrink: 0 且 white-space: nowrap 防止文字折行
expect(createVueContent).toContain('flex-shrink: 0;');
expect(createVueContent).toContain('white-space: nowrap;');
expect(createVueContent).toContain('text-overflow: ellipsis;');
// 基础信息卡片针对短标签优化宽度
expect(createVueContent).toContain('&.basic-info-card');
});
it('修改文件必须严格遵循零 Emoji 门禁规范', () => {
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
expect(emojiRegex.test(createVueContent)).toBe(false);
expect(emojiRegex.test(detailVueContent)).toBe(false);
});
});

查看文件

@@ -0,0 +1,45 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第一百二十二批全量活动核销状态看板闸门 (查看所有活动的核销状态)
*/
describe('第一百二十二批全量活动核销状态看板闸门', () => {
const rootDir = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8');
const verifyVuePath = path.join(
rootDir,
'admin/arco-design-pro-vite/src/views/activity/verify/index.vue'
);
const verifyVueCode = fs.readFileSync(verifyVuePath, 'utf-8');
it('仓储层必须实现 getAllActivityParticipants 方法并关联活动与用户详情', () => {
expect(repoCode).toContain('getAllActivityParticipants(): Promise<any[]>');
expect(repoCode).toContain('FROM participants p');
expect(repoCode).toContain('LEFT JOIN activities a ON a.id = p.activity_id');
expect(repoCode).toContain('checkinStatus: Boolean(p.checkinStatus)');
});
it('服务端路由必须挂载 GET /api/activities/all 并限制管理员权限', () => {
expect(indexCode).toContain("app.get('/api/activities/all'");
expect(indexCode).toContain('isVerifiedAdminRequest(req)');
expect(indexCode).toContain('repo.getAllActivityParticipants()');
expect(indexCode).toContain("app.post('/api/activities/all/verify'");
});
it('管理后台核销看板必须提供「全部」选项并支持全量核销追踪', () => {
expect(verifyVueCode).toContain('value="all"');
expect(verifyVueCode).toContain('全部 (查看所有活动核销状态)');
expect(verifyVueCode).toContain('所属活动 / 组局');
expect(verifyVueCode).toContain('filterStatus');
expect(verifyVueCode).toContain('selectedActivityId.value === \'all\'');
});
it('新增的前后端代码与测试严禁包含任何系统 Emoji 字符', () => {
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
expect(emojiRegex.test(indexCode)).toBe(false);
expect(emojiRegex.test(verifyVueCode)).toBe(false);
});
});

查看文件

@@ -130,7 +130,8 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
const raw = fs.readFileSync(pagesJsonPath, 'utf-8');
const config = JSON.parse(raw);
expect(Array.isArray(config.pages)).toBe(true);
expect(config.pages.length).toBeGreaterThanOrEqual(28);
const totalPages = config.pages.length + (config.subPackages || []).reduce((acc: number, sub: any) => acc + (sub.pages || []).length, 0);
expect(totalPages).toBeGreaterThanOrEqual(28);
for (const pageItem of config.pages) {
const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`);
@@ -142,6 +143,19 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
exists: true
});
}
for (const sub of (config.subPackages || [])) {
for (const pageItem of sub.pages) {
const vueFilePath = path.join(rootDir, 'pwuniapp', sub.root, `${pageItem.path}.vue`);
expect({
path: `${sub.root}/${pageItem.path}`,
exists: fs.existsSync(vueFilePath)
}).toEqual({
path: `${sub.root}/${pageItem.path}`,
exists: true
});
}
}
});
it('主包核心 TabBar 页面保持精炼且四大主页全部就绪', () => {
@@ -484,7 +498,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
expect(fs.existsSync(applyPath)).toBe(true);
const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json');
const pagesJson = fs.readFileSync(pagesJsonPath, 'utf-8');
expect(pagesJson).toContain('pages/expert/apply');
expect(pagesJson.includes('pages/expert/apply') || (pagesJson.includes('pages/expert') && pagesJson.includes('"path": "apply"'))).toBe(true);
expect(pagesJson).toContain('大神入驻认证');
});