feat(activity&admin): 优化组局起止时段与性别限制,PC后台核销看板与登录页体验升级
这个提交包含在:
@@ -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('大神入驻认证');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户