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

查看文件

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

查看文件

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

查看文件

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

查看文件

@@ -1,12 +1,262 @@
<template> <template>
<div class="banner"> <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"> <div class="banner-inner">
<a-carousel class="carousel" animation-name="fade"> <a-carousel
<a-carousel-item v-for="item in carouselItem" :key="item.slogan"> class="carousel"
<div :key="item.slogan" class="carousel-item"> animation-name="fade"
<div class="carousel-title">{{ item.slogan }}</div> :auto-play="{ interval: 4500 }"
<div class="carousel-sub-title">{{ item.subSlogan }}</div> indicator-type="line"
<img class="carousel-image" :src="item.image" /> 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> </div>
</a-carousel-item> </a-carousel-item>
</a-carousel> </a-carousel>
@@ -15,70 +265,614 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; // 保持脚本精炼,使用统一国际化多语言与 Arco 全局注册图标
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,
},
]);
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.banner { .banner {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%;
overflow: hidden;
background: linear-gradient(150deg, #070a14 0%, #0e1224 45%, #171131 100%);
&-inner { &-inner {
position: relative;
z-index: 2;
flex: 1; flex: 1;
height: 100%; 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 { .carousel {
height: 100%; 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 { &-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; 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 { &-title {
color: var(--color-fill-1); color: #ffffff;
font-weight: 500; font-weight: 700;
font-size: 20px; font-size: 23px;
line-height: 28px; line-height: 32px;
text-align: center;
letter-spacing: 0.5px;
} }
&-sub-title { &-sub-title {
margin-top: 8px; margin-top: 8px;
color: var(--color-text-3); color: rgba(226, 232, 240, 0.75);
font-size: 14px; font-size: 13px;
line-height: 22px; 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 { .expert-list {
width: 320px; display: flex;
margin-top: 30px; 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> </style>

查看文件

@@ -1,8 +1,22 @@
<template> <template>
<div class="login-form-wrapper"> <div class="login-card">
<div class="login-form-title">{{ $t('login.form.title') }}</div> <!-- 顶部状态标识 -->
<div class="login-form-sub-title">{{ $t('login.form.title') }}</div> <div class="form-tag-badge">
<div class="login-form-error-msg">{{ errorMessage }}</div> <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 <a-form
ref="loginForm" ref="loginForm"
:model="userInfo" :model="userInfo"
@@ -18,13 +32,17 @@
> >
<a-input <a-input
v-model="userInfo.username" v-model="userInfo.username"
size="large"
class="custom-input"
:placeholder="$t('login.form.userName.placeholder')" :placeholder="$t('login.form.userName.placeholder')"
allow-clear
> >
<template #prefix> <template #prefix>
<icon-user /> <icon-user class="field-icon" />
</template> </template>
</a-input> </a-input>
</a-form-item> </a-form-item>
<a-form-item <a-form-item
field="password" field="password"
:rules="[{ required: true, message: $t('login.form.password.errMsg') }]" :rules="[{ required: true, message: $t('login.form.password.errMsg') }]"
@@ -33,32 +51,56 @@
> >
<a-input-password <a-input-password
v-model="userInfo.password" v-model="userInfo.password"
size="large"
class="custom-input"
:placeholder="$t('login.form.password.placeholder')" :placeholder="$t('login.form.password.placeholder')"
allow-clear allow-clear
> >
<template #prefix> <template #prefix>
<icon-lock /> <icon-lock class="field-icon" />
</template> </template>
</a-input-password> </a-input-password>
</a-form-item> </a-form-item>
<a-space :size="16" direction="vertical">
<div class="login-form-password-actions"> <div class="login-form-actions-row">
<a-checkbox <a-checkbox
checked="rememberPassword" :model-value="loginConfig.rememberPassword"
:model-value="loginConfig.rememberPassword" class="custom-checkbox"
@change="setRememberPassword as any" @change="setRememberPassword as any"
> >
{{ $t('login.form.rememberPassword') }} {{ $t('login.form.rememberPassword') }}
</a-checkbox> </a-checkbox>
<a-link>{{ $t('login.form.forgetPassword') }}</a-link> <a-link class="forget-link" @click="handleForgetPassword">
</div> {{ $t('login.form.forgetPassword') }}
<a-button type="primary" html-type="submit" long :loading="loading"> </a-link>
{{ $t('login.form.login') }} </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>
<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') }} {{ $t('login.form.register') }}
</a-button> </a-link>
</a-space> </div>
<!-- 底部安全背书标签 -->
<div class="security-endorse">
<icon-safe class="sec-icon" />
<span>平台已启用 SSL 传输加密与多因子动态审计</span>
</div>
</a-form> </a-form>
</div> </div>
</template> </template>
@@ -100,6 +142,7 @@
if (loading.value) return; if (loading.value) return;
if (!errors) { if (!errors) {
setLoading(true); setLoading(true);
errorMessage.value = '';
try { try {
await userStore.login(values as LoginData); await userStore.login(values as LoginData);
const { redirect, ...othersQuery } = router.currentRoute.value.query; const { redirect, ...othersQuery } = router.currentRoute.value.query;
@@ -112,54 +155,248 @@
Message.success(t('login.form.login.success')); Message.success(t('login.form.login.success'));
const { rememberPassword } = loginConfig.value; const { rememberPassword } = loginConfig.value;
const { username, password } = values; const { username, password } = values;
// 实际生产环境需要进行加密存储。
// The actual production environment requires encrypted storage.
loginConfig.value.username = rememberPassword ? username : ''; loginConfig.value.username = rememberPassword ? username : '';
loginConfig.value.password = rememberPassword ? password : ''; loginConfig.value.password = rememberPassword ? password : '';
} catch (err) { } catch (err) {
errorMessage.value = (err as Error).message; errorMessage.value = (err as Error).message || t('login.form.login.errMsg');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
}; };
const setRememberPassword = (value: boolean) => { const setRememberPassword = (value: boolean) => {
loginConfig.value.rememberPassword = value; loginConfig.value.rememberPassword = value;
}; };
const handleForgetPassword = () => {
Message.info('管理员账号密码找回或重置,请联系系统超级管理员进行安全验证。');
};
const handleApplyAccount = () => {
Message.info('本系统为运营管理总控平台,暂未开放自主注册,需由超级管理员统一开通权限。');
};
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.login-form { .login-card {
&-wrapper { width: 420px;
width: 320px; 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 { &:hover {
color: var(--color-text-1); box-shadow: 0 25px 55px -12px rgba(15, 23, 42, 0.15),
font-weight: 500; 0 0 0 1px rgba(203, 213, 225, 0.9);
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;
} }
} }
.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> </style>

查看文件

@@ -1,18 +1,34 @@
<template> <template>
<div class="container"> <div class="container">
<!-- 顶栏品牌标识 -->
<div class="logo"> <div class="logo">
<img <img
alt="logo" alt="logo"
:src="logoImg" :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> </div>
<LoginBanner />
<!-- 左侧陪玩主题视觉展示栏 -->
<div class="banner">
<LoginBanner />
</div>
<!-- 右侧登录表单与背景质感区 -->
<div class="content"> <div class="content">
<!-- 背景光晕装饰,消除空旷感 -->
<div class="bg-orb orb-top"></div>
<div class="bg-orb orb-bottom"></div>
<div class="content-inner"> <div class="content-inner">
<LoginForm /> <LoginForm />
</div> </div>
<div class="footer"> <div class="footer">
<Footer /> <Footer />
</div> </div>
@@ -31,10 +47,15 @@
.container { .container {
display: flex; display: flex;
height: 100vh; height: 100vh;
width: 100vw;
overflow: hidden;
background-color: #070a14;
.banner { .banner {
width: 550px; width: 560px;
background: linear-gradient(163.85deg, #1d2129 0%, #00308f 100%); height: 100%;
flex-shrink: 0;
position: relative;
} }
.content { .content {
@@ -44,6 +65,15 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-bottom: 40px; 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 { .footer {
@@ -51,32 +81,109 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
z-index: 2;
} }
} }
// 顶栏 Logo 与品牌
.logo { .logo {
position: fixed; position: fixed;
top: 24px; top: 24px;
left: 22px; left: 28px;
z-index: 1; z-index: 20;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px;
user-select: none;
&-text { &-img {
margin-right: 4px; width: 36px;
margin-left: 4px; height: 36px;
color: var(--color-fill-1); border-radius: 10px;
font-size: 20px; 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 .bg-orb {
@media (max-width: @screen-lg) { 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 { .container {
.banner { .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 { 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.userName.errMsg': 'Username cannot be empty',
'login.form.password.errMsg': 'Password cannot be empty', 'login.form.password.errMsg': 'Password cannot be empty',
'login.form.login.errMsg': 'Login error, refresh and try again', 'login.form.login.errMsg': 'Login error, please refresh and try again',
'login.form.login.success': 'welcome to use', 'login.form.login.success': 'Welcome to Companion Admin Console',
'login.form.userName.placeholder': 'Username', 'login.form.userName.placeholder': 'Username or phone number',
'login.form.password.placeholder': 'Password', 'login.form.password.placeholder': 'Password',
'login.form.rememberPassword': 'Remember password', 'login.form.rememberPassword': 'Remember account',
'login.form.forgetPassword': 'Forgot password', 'login.form.forgetPassword': 'Forgot password?',
'login.form.login': 'login', 'login.form.login': 'Sign In',
'login.form.register': 'register account', 'login.form.register': 'Apply for account',
'login.banner.slogan1': 'Out-of-the-box high-quality template', 'login.banner.slogan1': 'Top Companions & Intelligent Dispatching',
'login.banner.subSlogan1': 'login.banner.subSlogan1': 'Gathering esports experts and lifestyle companions with real-time matching',
'Rich page templates, covering most typical business scenarios', 'login.banner.slogan2': 'Escrow Guarantee & Service Compliance',
'login.banner.slogan2': 'Built-in solutions to common problems', 'login.banner.subSlogan2': 'Platform escrow settlement and verified real-name protection for trust and safety',
'login.banner.subSlogan2': 'login.banner.slogan3': 'Omnichannel Analytics & Business Growth',
'Internationalization, routing configuration, state management everything', 'login.banner.subSlogan3': 'Full visibility into GMV, companion ratings, and user retention to empower operations',
'login.banner.slogan3': 'Access visualization enhancement tool AUX',
'login.banner.subSlogan3': 'Realize flexible block development',
}; };

查看文件

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

查看文件

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

查看文件

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

查看文件

@@ -66,7 +66,7 @@
</view> </view>
<!-- 基础信息录入 --> <!-- 基础信息录入 -->
<view class="form-card"> <view class="form-card basic-info-card">
<view class="input-row"> <view class="input-row">
<text class="row-label">活动标题</text> <text class="row-label">活动标题</text>
<input <input
@@ -88,7 +88,7 @@
> >
<view class="picker-display-box btn-active"> <view class="picker-display-box btn-active">
<text class="picker-val" :class="{ placeholder: !form.startTime }"> <text class="picker-val" :class="{ placeholder: !form.startTime }">
{{ form.startTime || '请选择活动开始时段' }} {{ form.startTime || '请选择活动时段' }}
</text> </text>
<view class="picker-right-tag"> <view class="picker-right-tag">
<text class="picker-tag-text">选择时间</text> <text class="picker-tag-text">选择时间</text>
@@ -169,6 +169,22 @@
</view> </view>
</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'"> <view class="input-row" v-if="form.payType === 'AA'">
<text class="row-label">人均分摊 (元/人)</text> <text class="row-label">人均分摊 (元/人)</text>
<input <input
@@ -448,6 +464,7 @@ const form = ref({
latitude: null, latitude: null,
longitude: null, longitude: null,
maxParticipants: 4, maxParticipants: 4,
genderRequirement: 'ALL',
aaFee: '', aaFee: '',
pricingMode: 'PER_PERSON', pricingMode: 'PER_PERSON',
perPersonFee: '', perPersonFee: '',
@@ -456,6 +473,12 @@ const form = ref({
priceAdjustRule: 'UNIFIED_HIGH', priceAdjustRule: 'UNIFIED_HIGH',
}); });
const genderOptions = [
{ label: '仅女生', value: 'FEMALE_ONLY' },
{ label: '仅男生', value: 'MALE_ONLY' },
{ label: '不限', value: 'ALL' }
];
const calcTotalFee = computed(() => { const calcTotalFee = computed(() => {
if (form.value.payType === 'AA') { if (form.value.payType === 'AA') {
return (Number(form.value.aaFee) || 0) * form.value.maxParticipants; return (Number(form.value.aaFee) || 0) * form.value.maxParticipants;
@@ -485,11 +508,11 @@ const changeDispatchHours = (delta) => {
const recommendPublicZones = ref([]); const recommendPublicZones = ref([]);
const presetTimeOptions = ref([ const presetTimeOptions = ref([
'今晚 19:30', '今晚 19:30 - 21:30',
'今晚 20:30', '今晚 20:30 - 22:30',
'明天 14:00', '明天 14:00 - 17:00',
'周六 18:30', '周六 18:30 - 21:30',
'周日 15:00' '周日 15:00 - 18:00'
]); ]);
const timePickerDays = (() => { const timePickerDays = (() => {
@@ -506,7 +529,7 @@ const timePickerDays = (() => {
return baseDays; return baseDays;
})(); })();
const timePickerSlots = [ const timePickerStartSlots = [
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
'12:00', '12:30', '13:00', '13:30', '14:00', '14: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', '15:00', '15:30', '16:00', '16:30', '17:00', '17:30',
@@ -515,28 +538,86 @@ const timePickerSlots = [
'00:00', '00:30', '01:00' '00:00', '00:30', '01:00'
]; ];
const timePickerRange = ref([timePickerDays, timePickerSlots]); const timePickerEndSlots = [
const timePickerIndex = ref([5, 19]); // 默认对应 周六 18:30 '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 onTimePickerChange = (e) => {
const val = (e && e.detail && Array.isArray(e.detail.value)) ? e.detail.value : [0, 0]; const val = (e && e.detail && Array.isArray(e.detail.value)) ? e.detail.value : [0, 0, 0];
const dayIdx = Number(val[0]) || 0; const dayIdx = Math.max(0, Math.min(timePickerDays.length - 1, Number(val[0]) || 0));
const slotIdx = Number(val[1]) || 0; const startIdx = Math.max(0, Math.min(timePickerStartSlots.length - 1, Number(val[1]) || 0));
timePickerIndex.value = [dayIdx, slotIdx]; let endIdx = Math.max(0, Math.min(timePickerEndSlots.length - 1, Number(val[2]) || 0));
const dayStr = timePickerDays[dayIdx] || timePickerDays[0]; const dayStr = timePickerDays[dayIdx] || timePickerDays[0];
const slotStr = timePickerSlots[slotIdx] || timePickerSlots[0]; const startStr = timePickerStartSlots[startIdx] || timePickerStartSlots[0];
form.value.startTime = `${dayStr} ${slotStr}`; 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) => { const selectQuickTime = (timeItem) => {
form.value.startTime = timeItem; form.value.startTime = timeItem;
const parts = String(timeItem).split(' '); const match = String(timeItem).trim().match(/^(\S+)\s+([\d:]+)(?:\s*-\s*([\d:]+))?$/);
if (parts.length === 2) { if (match) {
const dIdx = timePickerDays.indexOf(parts[0]); const dayStr = match[1];
const sIdx = timePickerSlots.indexOf(parts[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 = [ timePickerIndex.value = [
dIdx >= 0 ? dIdx : 0, 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, totalFee: calcTotalFee.value,
aaFee: Number(form.value.aaFee) || 0, aaFee: Number(form.value.aaFee) || 0,
maxParticipants: form.value.maxParticipants, maxParticipants: form.value.maxParticipants,
genderRequirement: form.value.genderRequirement || 'ALL',
startTime: trimmedTime, startTime: trimmedTime,
location: trimmedLocation, location: trimmedLocation,
latitude: form.value.latitude, latitude: form.value.latitude,
@@ -826,6 +908,7 @@ const handleSubmit = async () => {
form.value.title = ''; form.value.title = '';
form.value.startTime = ''; form.value.startTime = '';
form.value.location = ''; form.value.location = '';
form.value.genderRequirement = 'ALL';
form.value.aaFee = ''; form.value.aaFee = '';
form.value.perPersonFee = ''; form.value.perPersonFee = '';
form.value.totalFee = ''; form.value.totalFee = '';
@@ -995,6 +1078,15 @@ const handleSubmit = async () => {
color: #94A3B8; color: #94A3B8;
} }
&.basic-info-card {
.input-row {
.row-label {
width: 140rpx;
min-width: 140rpx;
}
}
}
.input-row { .input-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1035,18 +1127,25 @@ const handleSubmit = async () => {
.time-picker-flex { .time-picker-flex {
flex: 1; flex: 1;
min-width: 0;
.picker-display-box { .picker-display-box {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 52rpx; gap: 16rpx;
min-height: 52rpx;
.picker-val { .picker-val {
flex: 1;
min-width: 0;
font-size: 26rpx; font-size: 26rpx;
font-weight: 600; font-weight: 600;
color: #F8FAFC; color: #F8FAFC;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.placeholder { &.placeholder {
color: #475569; color: #475569;
@@ -1055,6 +1154,8 @@ const handleSubmit = async () => {
} }
.picker-right-tag { .picker-right-tag {
flex-shrink: 0;
white-space: nowrap;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6rpx; gap: 6rpx;
@@ -1067,6 +1168,7 @@ const handleSubmit = async () => {
font-size: 20rpx; font-size: 20rpx;
font-weight: 600; font-weight: 600;
color: #818CF8; color: #818CF8;
white-space: nowrap;
} }
.picker-arrow { .picker-arrow {
@@ -1074,6 +1176,7 @@ const handleSubmit = async () => {
font-weight: 700; font-weight: 700;
color: #818CF8; color: #818CF8;
line-height: 1; line-height: 1;
white-space: nowrap;
} }
} }
} }
@@ -1187,6 +1290,7 @@ const handleSubmit = async () => {
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
padding: 6rpx 16rpx; padding: 6rpx 16rpx;
border-radius: 12rpx; border-radius: 12rpx;
white-space: nowrap;
transition: all 0.15s; transition: all 0.15s;
&.selected { &.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 { .calc-preview {
margin-top: 20rpx; margin-top: 20rpx;
padding: 16rpx 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 { .form-title {
color: #1C1917; color: #1C1917;
} }
@@ -1598,9 +1763,12 @@ const handleSubmit = async () => {
.picker-right-tag { .picker-right-tag {
background: #FFF7ED; background: #FFF7ED;
border-color: #FDBA74; border-color: #FDBA74;
flex-shrink: 0;
white-space: nowrap;
.picker-tag-text, .picker-tag-text,
.picker-arrow { .picker-arrow {
color: #EA580C; color: #EA580C;
white-space: nowrap;
} }
} }
} }

查看文件

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

查看文件

@@ -104,6 +104,12 @@
<view class="mode-tag" :class="act.payType === 'AA' ? 'aa-mode' : 'project-mode'"> <view class="mode-tag" :class="act.payType === 'AA' ? 'aa-mode' : 'project-mode'">
{{ act.payType === 'AA' ? 'AA制平摊' : '房主请客' }} {{ act.payType === 'AA' ? 'AA制平摊' : '房主请客' }}
</view> </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'"> <view class="status-pill" :class="act.status || 'RECRUITING'">
{{ getStatusText(act.status) }} {{ getStatusText(act.status) }}
</view> </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 { .status-pill {
margin-left: auto; margin-left: auto;
font-size: 20rpx; font-size: 20rpx;
@@ -952,6 +977,19 @@ const createActivity = () => {
border-color: #F0ECE4; border-color: #F0ECE4;
box-shadow: 0 6rpx 20rpx rgba(28, 25, 23, 0.05); 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 { .activity-title {
color: #1C1917; color: #1C1917;
} }

查看文件

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

查看文件

@@ -48,6 +48,7 @@ export interface ActivityRow {
totalFeeCents: number; totalFeeCents: number;
perPersonFeeCents: number; perPersonFeeCents: number;
maxParticipants: number; maxParticipants: number;
genderRequirement?: string;
allowDynamicPrice: boolean; allowDynamicPrice: boolean;
priceAdjustRule: string; priceAdjustRule: string;
joinedCount?: number; 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.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.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule, 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, COALESCE(a.status, 'RECRUITING') as status,
a.created_at as createdAt, a.created_at as createdAt,
u.nickname as creatorNickname, 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.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.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule, 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, COALESCE(a.status, 'RECRUITING') as status,
u.nickname as creatorNickname, u.nickname as creatorNickname,
u.avatar as creatorAvatar 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: { async createActivity(data: {
creatorId: number; creatorId: number;
title: string; title: string;
@@ -267,6 +295,7 @@ export class MysqlRepository {
maxParticipants?: number; maxParticipants?: number;
allowDynamicPrice?: number | boolean; allowDynamicPrice?: number | boolean;
priceAdjustRule?: string; priceAdjustRule?: string;
genderRequirement?: string;
}): Promise<any> { }): Promise<any> {
const [userRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [data.creatorId]); 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 === '封禁')) { 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 allowDynamicPrice = data.allowDynamicPrice !== undefined ? (data.allowDynamicPrice ? 1 : 0) : 1;
const priceAdjustRule = data.priceAdjustRule || 'UNIFIED_HIGH'; 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(` await pool.query(`
INSERT INTO activities 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) (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
id, id,
data.creatorId, data.creatorId,
@@ -313,6 +350,7 @@ export class MysqlRepository {
maxParticipants, maxParticipants,
allowDynamicPrice, allowDynamicPrice,
priceAdjustRule, priceAdjustRule,
genderRequirement,
]); ]);
return this.getActivityById(id); return this.getActivityById(id);
@@ -400,7 +438,7 @@ export class MysqlRepository {
await conn.beginTransaction(); await conn.beginTransaction();
// 1. 封禁校验(事务内) // 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 === '封禁')) { if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) {
const err: any = new Error('您的账号已被管理员封禁,无法报名参加活动'); const err: any = new Error('您的账号已被管理员封禁,无法报名参加活动');
err.status = 403; err.status = 403;
@@ -410,10 +448,26 @@ export class MysqlRepository {
// 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖 // 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖
const [actRows]: any = await conn.query( 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] [activityId]
); );
if (!actRows || actRows.length === 0) throw new Error('活动不存在'); 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); const maxParticipants = Number(actRows[0].max_participants || 0);
// 报名费由服务端活动配置决定(pay_type=1 为 AA 收取人均分摊,其余为房主请客 0 元), // 报名费由服务端活动配置决定(pay_type=1 为 AA 收取人均分摊,其余为房主请客 0 元),
// 严禁信任客户端传入的 payAmountCents,否则可"免费报名 → 退款/清算套现"。 // 严禁信任客户端传入的 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.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.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule, 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, COALESCE(a.status, 'RECRUITING') as status,
a.created_at as createdAt, a.created_at as createdAt,
u.nickname as creatorNickname, 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) => { app.get('/api/activities/:id', async (req: Request, res: Response) => {
try { try {
const act = await repo.getActivityById(String(req.params.id)); 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) // 房主/管理员核销 4 位核销码 (防重落盘 MySQL)
app.post('/api/activities/:id/verify', async (req: Request, res: Response) => { app.post('/api/activities/:id/verify', async (req: Request, res: Response) => {
try { 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 raw = fs.readFileSync(pagesJsonPath, 'utf-8');
const config = JSON.parse(raw); const config = JSON.parse(raw);
expect(Array.isArray(config.pages)).toBe(true); 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) { for (const pageItem of config.pages) {
const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`); const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`);
@@ -142,6 +143,19 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
exists: true 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 页面保持精炼且四大主页全部就绪', () => { it('主包核心 TabBar 页面保持精炼且四大主页全部就绪', () => {
@@ -484,7 +498,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
expect(fs.existsSync(applyPath)).toBe(true); expect(fs.existsSync(applyPath)).toBe(true);
const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json'); const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json');
const pagesJson = fs.readFileSync(pagesJsonPath, 'utf-8'); 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('大神入驻认证'); expect(pagesJson).toContain('大神入驻认证');
}); });