feat(invitation): 实现邀请返利系统、专属注册落地页与分成管理
- 数据库与数据模型:新增 migration 000081,支持用户专属邀请码、被邀请人绑定与实名时间戳;新增邀请返利配置表与结算明细表 - 业务与结算逻辑:实现首月 60%、次月 30%、往后 15% 阶梯返利算法;买卖双向独立寻源并根据订单平台利润 50% 单边基准核算;与终态订单结算和钱包可用余额强幂等联动 - 分销站点支持:支持主站与加盟分站独立配置返利比例;跨站交易按照收号端与售卖端独立出资,资金互不侵蚀 - 前端交互与入口: - 新增专属短链注册落地页 /i/:code,自动带入并锁定邀请码,引导实名认证 - 个人中心与移动端新增邀请返利看板(专属码、推广链接、统计面板、好友记录与返利流水明细) - 管理后台新增邀请返利管理(比例配置、邀请关系与返利流水审计) - 验证方式: - 通过 repository_test.go 单元测试(阶梯区间、实名激活、跨站多方分流验证) - 通过 npm run format、npm run check 与 scripts/check.sh 全量门禁校验
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Setting, User, Coin, Refresh, Search, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchAdminRebateConfig,
|
||||
updateAdminRebateConfig,
|
||||
type InvitationRebateConfig,
|
||||
} from '@/features/invitation/api/invitation'
|
||||
import {
|
||||
fetchDistributionSites,
|
||||
type DistributionSite,
|
||||
} from '@/features/admin/api/adminDistribution'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const activeTab = ref<'config' | 'invitations' | 'rebates'>('config')
|
||||
|
||||
// 站点列表
|
||||
const sites = ref<DistributionSite[]>([])
|
||||
const selectedSiteId = ref(0)
|
||||
const loadingConfig = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
|
||||
const configForm = reactive({
|
||||
firstMonthRate: 60,
|
||||
secondMonthRate: 30,
|
||||
subsequentRate: 15,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// 邀请关系列表
|
||||
const loadingInvitations = ref(false)
|
||||
const invitationQuery = reactive({
|
||||
phone: '',
|
||||
invite_code: '',
|
||||
distribution_site_id: undefined as number | undefined,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
const invitations = ref<any[]>([])
|
||||
const invitationsTotal = ref(0)
|
||||
|
||||
// 返利流水列表
|
||||
const loadingRebates = ref(false)
|
||||
const rebateQuery = reactive({
|
||||
order_no: '',
|
||||
inviter_phone: '',
|
||||
invitee_phone: '',
|
||||
distribution_site_id: undefined as number | undefined,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
const rebates = ref<any[]>([])
|
||||
const rebatesTotal = ref(0)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites()
|
||||
await loadConfig()
|
||||
})
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const list = await fetchDistributionSites()
|
||||
sites.value = list
|
||||
} catch {
|
||||
// 忽略加载分站失败
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loadingConfig.value = true
|
||||
try {
|
||||
const cfg: InvitationRebateConfig = await fetchAdminRebateConfig(selectedSiteId.value)
|
||||
configForm.firstMonthRate = Math.round((cfg.first_month_rate || 0.6) * 100)
|
||||
configForm.secondMonthRate = Math.round((cfg.second_month_rate || 0.3) * 100)
|
||||
configForm.subsequentRate = Math.round((cfg.subsequent_rate || 0.15) * 100)
|
||||
configForm.enabled = cfg.enabled !== false
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '获取返利配置失败'))
|
||||
} finally {
|
||||
loadingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfig() {
|
||||
if (
|
||||
configForm.firstMonthRate < 0 ||
|
||||
configForm.firstMonthRate > 100 ||
|
||||
configForm.secondMonthRate < 0 ||
|
||||
configForm.secondMonthRate > 100 ||
|
||||
configForm.subsequentRate < 0 ||
|
||||
configForm.subsequentRate > 100
|
||||
) {
|
||||
ElMessage.warning('分成比例必须在 0% 到 100% 之间')
|
||||
return
|
||||
}
|
||||
|
||||
savingConfig.value = true
|
||||
try {
|
||||
await updateAdminRebateConfig(selectedSiteId.value, {
|
||||
first_month_rate: Number((configForm.firstMonthRate / 100).toFixed(4)),
|
||||
second_month_rate: Number((configForm.secondMonthRate / 100).toFixed(4)),
|
||||
subsequent_rate: Number((configForm.subsequentRate / 100).toFixed(4)),
|
||||
enabled: configForm.enabled,
|
||||
})
|
||||
ElMessage.success('返利比例配置已保存')
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '保存配置失败'))
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvitations() {
|
||||
loadingInvitations.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: invitationQuery.page,
|
||||
page_size: invitationQuery.pageSize,
|
||||
}
|
||||
if (invitationQuery.phone.trim()) params.phone = invitationQuery.phone.trim()
|
||||
if (invitationQuery.invite_code.trim()) params.invite_code = invitationQuery.invite_code.trim()
|
||||
if (invitationQuery.distribution_site_id !== undefined) {
|
||||
params.distribution_site_id = invitationQuery.distribution_site_id
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get('/invitations', { params })
|
||||
invitations.value = data.data.items || []
|
||||
invitationsTotal.value = data.data.total || 0
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '获取邀请关系失败'))
|
||||
} finally {
|
||||
loadingInvitations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRebates() {
|
||||
loadingRebates.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: rebateQuery.page,
|
||||
page_size: rebateQuery.pageSize,
|
||||
}
|
||||
if (rebateQuery.order_no.trim()) params.order_no = rebateQuery.order_no.trim()
|
||||
if (rebateQuery.inviter_phone.trim()) params.inviter_phone = rebateQuery.inviter_phone.trim()
|
||||
if (rebateQuery.invitee_phone.trim()) params.invitee_phone = rebateQuery.invitee_phone.trim()
|
||||
if (rebateQuery.distribution_site_id !== undefined) {
|
||||
params.distribution_site_id = rebateQuery.distribution_site_id
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get('/invitations/rebates', { params })
|
||||
rebates.value = data.data.items || []
|
||||
rebatesTotal.value = data.data.total || 0
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '获取返利流水失败'))
|
||||
} finally {
|
||||
loadingRebates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(tab: 'config' | 'invitations' | 'rebates') {
|
||||
activeTab.value = tab
|
||||
if (tab === 'invitations') {
|
||||
void loadInvitations()
|
||||
} else if (tab === 'rebates') {
|
||||
void loadRebates()
|
||||
}
|
||||
}
|
||||
|
||||
function cycleLabel(cycle: string) {
|
||||
switch (cycle) {
|
||||
case 'first_month':
|
||||
return '首月高额'
|
||||
case 'second_month':
|
||||
return '次月奖励'
|
||||
default:
|
||||
return '长期分成'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-invitations-page">
|
||||
<div class="page-header">
|
||||
<h2>邀请返利与分成管理</h2>
|
||||
<p class="page-desc">
|
||||
配置主站及各分销站点的返利阶梯比例(首月、次月、往后永久),监控全网邀请关系与返利流水。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 选项卡导航 -->
|
||||
<div class="nav-tabs">
|
||||
<button
|
||||
class="nav-tab-btn"
|
||||
:class="{ active: activeTab === 'config' }"
|
||||
@click="handleTabChange('config')"
|
||||
>
|
||||
<el-icon><Setting /></el-icon> 返利比例配置
|
||||
</button>
|
||||
<button
|
||||
class="nav-tab-btn"
|
||||
:class="{ active: activeTab === 'invitations' }"
|
||||
@click="handleTabChange('invitations')"
|
||||
>
|
||||
<el-icon><User /></el-icon> 全网邀请关系 ({{ invitationsTotal }})
|
||||
</button>
|
||||
<button
|
||||
class="nav-tab-btn"
|
||||
:class="{ active: activeTab === 'rebates' }"
|
||||
@click="handleTabChange('rebates')"
|
||||
>
|
||||
<el-icon><Coin /></el-icon> 返利结算流水 ({{ rebatesTotal }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 配置面板 -->
|
||||
<div v-if="activeTab === 'config'" class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div class="site-selector-row">
|
||||
<label>选择配置生效目标:</label>
|
||||
<el-select v-model="selectedSiteId" style="width: 260px" @change="loadConfig">
|
||||
<el-option :value="0" label="主站(默认基础配置)" />
|
||||
<el-option
|
||||
v-for="s in sites"
|
||||
:key="s.id"
|
||||
:value="s.id"
|
||||
:label="`分销站: ${s.site_name} (${s.site_code})`"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" circle @click="loadConfig" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loadingConfig" class="config-form-body">
|
||||
<el-form label-position="top" style="max-width: 600px">
|
||||
<el-form-item label="功能开关">
|
||||
<el-switch
|
||||
v-model="configForm.enabled"
|
||||
active-text="启用邀请返利"
|
||||
inactive-text="暂停该站点返利"
|
||||
/>
|
||||
<div class="field-hint">关闭后该站点产生的新订单将不计算并发放邀请返利</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="首月分成比例(实名生效 1 ~ 30 天)">
|
||||
<div class="rate-input-box">
|
||||
<el-input-number v-model="configForm.firstMonthRate" :min="0" :max="100" :step="5" />
|
||||
<span class="rate-unit">%</span>
|
||||
</div>
|
||||
<div class="field-hint">默认 60%,按平台每笔订单单边毛利计算分成基数</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="次月分成比例(实名生效 31 ~ 60 天)">
|
||||
<div class="rate-input-box">
|
||||
<el-input-number v-model="configForm.secondMonthRate" :min="0" :max="100" :step="5" />
|
||||
<span class="rate-unit">%</span>
|
||||
</div>
|
||||
<div class="field-hint">默认 30%</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="往后长期分成比例(实名生效 60 天后永久)">
|
||||
<div class="rate-input-box">
|
||||
<el-input-number v-model="configForm.subsequentRate" :min="0" :max="100" :step="1" />
|
||||
<span class="rate-unit">%</span>
|
||||
</div>
|
||||
<div class="field-hint">默认 15%</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Check"
|
||||
:loading="savingConfig"
|
||||
@click="handleSaveConfig"
|
||||
>
|
||||
保存比例配置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请关系面板 -->
|
||||
<div v-if="activeTab === 'invitations'" class="panel-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="invitationQuery.phone"
|
||||
placeholder="搜索手机号"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadInvitations"
|
||||
/>
|
||||
<el-input
|
||||
v-model="invitationQuery.invite_code"
|
||||
placeholder="搜索邀请码"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="loadInvitations"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="loadInvitations">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="loadInvitations">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loadingInvitations"
|
||||
:data="invitations"
|
||||
border
|
||||
style="width: 100%; margin-top: 16px"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="邀请人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.inviter_nickname || '-' }}</div>
|
||||
<code style="font-size: 12px; color: #64748b">{{ row.inviter_phone }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="被邀请人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.invitee_nickname || '-' }}</div>
|
||||
<code style="font-size: 12px; color: #64748b">{{ row.invitee_phone }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="invite_code" label="使用邀请码" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.invite_code }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="认证生效状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'warning'" size="small">
|
||||
{{ row.status === 'active' ? '已实名 (生效中)' : '待实名 (未生效)' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="verified_at" label="实名生效时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.verified_at ? row.verified_at.slice(0, 19).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="绑定时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.created_at ? row.created_at.slice(0, 19).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-box">
|
||||
<el-pagination
|
||||
v-model:current-page="invitationQuery.page"
|
||||
v-model:page-size="invitationQuery.pageSize"
|
||||
:total="invitationsTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadInvitations"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返利流水面板 -->
|
||||
<div v-if="activeTab === 'rebates'" class="panel-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="rebateQuery.order_no"
|
||||
placeholder="订单号"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadRebates"
|
||||
/>
|
||||
<el-input
|
||||
v-model="rebateQuery.inviter_phone"
|
||||
placeholder="邀请人手机号"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="loadRebates"
|
||||
/>
|
||||
<el-input
|
||||
v-model="rebateQuery.invitee_phone"
|
||||
placeholder="被邀请人手机号"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="loadRebates"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="loadRebates">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="loadRebates">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loadingRebates"
|
||||
:data="rebates"
|
||||
border
|
||||
style="width: 100%; margin-top: 16px"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="关联订单号" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.order_no }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="inviter_phone" label="收益人(邀请人)" width="130" />
|
||||
<el-table-column prop="invitee_phone" label="交易人(被邀请人)" width="130" />
|
||||
<el-table-column prop="trade_role" label="交易端" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.trade_role === 'buyer' ? 'primary' : 'danger'" size="small">
|
||||
{{ row.trade_role === 'buyer' ? '买家端' : '卖家端' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收益基数" width="110">
|
||||
<template #default="{ row }">
|
||||
¥{{ formatCent(row.single_side_basis_cent || 0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提成比例" width="90">
|
||||
<template #default="{ row }"> {{ Math.round((row.rate_applied || 0) * 100) }}% </template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cycle_stage" label="周期阶段" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ cycleLabel(row.cycle_stage) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="返利金额" width="110">
|
||||
<template #default="{ row }">
|
||||
<strong style="color: #dc2626">+¥{{ formatCent(row.rebate_amount_cent || 0) }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settled_at" label="入账时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.settled_at ? row.settled_at.slice(0, 19).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-box">
|
||||
<el-pagination
|
||||
v-model:current-page="rebateQuery.page"
|
||||
v-model:page-size="rebateQuery.pageSize"
|
||||
:total="rebatesTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadRebates"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-invitations-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 6px 0 18px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nav-tab-btn {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-tab-btn:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.nav-tab-btn.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.site-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rate-input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rate-unit {
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-box {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -69,12 +69,20 @@ export async function loginWithPassword(phone: string, password: string) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function registerWithPassword(phone: string, code: string, password: string) {
|
||||
export async function registerWithPassword(
|
||||
phone: string,
|
||||
code: string,
|
||||
password: string,
|
||||
inviteCode?: string
|
||||
) {
|
||||
const trackCode = getTrackCode()
|
||||
const payload: Record<string, unknown> = { phone, code, password }
|
||||
if (trackCode) {
|
||||
payload.track_code = trackCode
|
||||
}
|
||||
if (inviteCode) {
|
||||
payload.invite_code = inviteCode
|
||||
}
|
||||
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/password/register', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -467,6 +467,13 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell
|
||||
title="邀请返利"
|
||||
icon="gold-coin-o"
|
||||
is-link
|
||||
to="/m/invitations"
|
||||
value="首月60%收益"
|
||||
/>
|
||||
<van-cell title="租后须知" icon="info-o" is-link @click="openPostRentalNotice" />
|
||||
<van-cell title="系统设置" icon="setting-o" is-link @click="showSettings = true" />
|
||||
</van-cell-group>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
|
||||
export interface InviteCodeInfo {
|
||||
valid: boolean
|
||||
invite_code: string
|
||||
inviter_nickname?: string
|
||||
inviter_avatar?: string
|
||||
}
|
||||
|
||||
export interface UserInvitationSummary {
|
||||
user_id: number
|
||||
has_invite_code: boolean
|
||||
invite_code: string
|
||||
is_realname_verified: boolean
|
||||
total_rebate_cent: number
|
||||
month_rebate_cent: number
|
||||
total_invited_count: number
|
||||
active_invited_count: number
|
||||
first_month_rate: number
|
||||
second_month_rate: number
|
||||
subsequent_rate: number
|
||||
}
|
||||
|
||||
export interface InvitedUserRecord {
|
||||
id: number
|
||||
invitee_id: number
|
||||
invitee_phone: string
|
||||
invitee_nickname: string
|
||||
status: string
|
||||
verified_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RebateLedger {
|
||||
id: number
|
||||
order_id: number
|
||||
order_no: string
|
||||
trade_role: 'buyer' | 'seller'
|
||||
order_profit_basis_cent: number
|
||||
single_side_basis_cent: number
|
||||
rate_applied: number
|
||||
days_since_verified: number
|
||||
cycle_stage: 'first_month' | 'second_month' | 'subsequent'
|
||||
rebate_amount_cent: number
|
||||
status: string
|
||||
settled_at: string
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface InvitationRebateConfig {
|
||||
distribution_site_id: number
|
||||
first_month_rate: number
|
||||
second_month_rate: number
|
||||
subsequent_rate: number
|
||||
enabled: boolean
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 公开获取邀请码信息(用于注册落地页) */
|
||||
export async function fetchInviteCodeInfo(code: string): Promise<InviteCodeInfo> {
|
||||
const { data } = await apiClient.get<ApiResponse<InviteCodeInfo>>(
|
||||
`/invitations/code-info/${encodeURIComponent(code)}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 获取当前用户的邀请看板汇总数据 */
|
||||
export async function fetchUserInvitationSummary(): Promise<UserInvitationSummary> {
|
||||
const { data } = await apiClient.get<ApiResponse<UserInvitationSummary>>(
|
||||
'/user/invitations/summary'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 获取当前用户邀请的好友列表 */
|
||||
export async function fetchMyInvitedUsers(
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<PaginatedResult<InvitedUserRecord>> {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<InvitedUserRecord>>>(
|
||||
'/user/invitations/records',
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 获取当前用户的返利流水明细 */
|
||||
export async function fetchMyRebates(
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<PaginatedResult<RebateLedger>> {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<RebateLedger>>>(
|
||||
'/user/invitations/rebates',
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 管理端获取返利比例配置(siteId 为 0 时为主站) */
|
||||
export async function fetchAdminRebateConfig(siteId = 0): Promise<InvitationRebateConfig> {
|
||||
const { data } = await apiClient.get<ApiResponse<InvitationRebateConfig>>(
|
||||
'/invitations/configs',
|
||||
{
|
||||
params: { distribution_site_id: siteId },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 管理端更新返利比例配置 */
|
||||
export async function updateAdminRebateConfig(
|
||||
siteId: number,
|
||||
config: {
|
||||
first_month_rate: number
|
||||
second_month_rate: number
|
||||
subsequent_rate: number
|
||||
enabled?: boolean
|
||||
}
|
||||
): Promise<InvitationRebateConfig> {
|
||||
const { data } = await apiClient.put<ApiResponse<InvitationRebateConfig>>(
|
||||
'/invitations/configs',
|
||||
config,
|
||||
{
|
||||
params: { distribution_site_id: siteId },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Lock,
|
||||
Iphone,
|
||||
Key,
|
||||
View,
|
||||
Hide,
|
||||
Present,
|
||||
WarningFilled,
|
||||
Check,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchAuthCaptcha, sendSmsCode } from '@/features/auth/api/auth'
|
||||
import { fetchInviteCodeInfo, type InviteCodeInfo } from '@/features/invitation/api/invitation'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useDistributionSiteStore } from '@/stores/distributionSite'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const siteStore = useDistributionSiteStore()
|
||||
|
||||
const inviteCode = computed(() => String(route.params.code || '').trim())
|
||||
const loadingInfo = ref(true)
|
||||
const codeInfo = ref<InviteCodeInfo | null>(null)
|
||||
|
||||
// 注册表单状态
|
||||
const form = reactive({
|
||||
phone: '',
|
||||
captchaCode: '',
|
||||
smsCode: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const showPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 图形验证码状态
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const loadingCaptcha = ref(false)
|
||||
|
||||
// 短信倒计时
|
||||
const smsCountdown = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCodeInfo(), refreshCaptcha()])
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
async function loadCodeInfo() {
|
||||
if (!inviteCode.value) {
|
||||
loadingInfo.value = false
|
||||
return
|
||||
}
|
||||
loadingInfo.value = true
|
||||
try {
|
||||
const res = await fetchInviteCodeInfo(inviteCode.value)
|
||||
codeInfo.value = res
|
||||
} catch (_err) {
|
||||
ElMessage.warning('邀请码查询失败,请核对邀请链接')
|
||||
} finally {
|
||||
loadingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCaptcha() {
|
||||
loadingCaptcha.value = true
|
||||
try {
|
||||
const res = await fetchAuthCaptcha()
|
||||
captchaId.value = res.captcha_id
|
||||
captchaImage.value = res.image
|
||||
} catch (_err) {
|
||||
ElMessage.error('获取图形验证码失败,请点击刷新')
|
||||
} finally {
|
||||
loadingCaptcha.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendSms() {
|
||||
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
|
||||
ElMessage.warning('请输入正确的11位手机号码')
|
||||
return
|
||||
}
|
||||
if (!form.captchaCode) {
|
||||
ElMessage.warning('请先输入图形验证码')
|
||||
return
|
||||
}
|
||||
if (smsCountdown.value > 0) return
|
||||
|
||||
try {
|
||||
await sendSmsCode(form.phone, captchaId.value, form.captchaCode)
|
||||
ElMessage.success('短信验证码已发送')
|
||||
smsCountdown.value = 60
|
||||
timer = setInterval(() => {
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '发送验证码失败'))
|
||||
await refreshCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
|
||||
ElMessage.warning('请输入正确的11位手机号码')
|
||||
return
|
||||
}
|
||||
if (!form.smsCode || form.smsCode.length !== 6) {
|
||||
ElMessage.warning('请输入6位短信验证码')
|
||||
return
|
||||
}
|
||||
if (!form.password || form.password.length < 8 || form.password.length > 20) {
|
||||
ElMessage.warning('密码长度必须为8-20位')
|
||||
return
|
||||
}
|
||||
if (form.password !== form.confirmPassword) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await session.register(form.phone, form.smsCode, form.password, inviteCode.value)
|
||||
|
||||
ElMessageBox.confirm(
|
||||
'账号注册成功并已绑定邀请人!根据平台规定,完成实名认证后方可正式激活会员特权与邀请返利资格。是否立即前往实名认证?',
|
||||
'注册成功',
|
||||
{
|
||||
confirmButtonText: '去实名认证',
|
||||
cancelButtonText: '稍后再说',
|
||||
type: 'success',
|
||||
distinguishCancelAndClose: true,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
router.replace('/realname')
|
||||
})
|
||||
.catch(() => {
|
||||
router.replace('/')
|
||||
})
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '注册失败'))
|
||||
await refreshCaptcha()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="invite-register-page">
|
||||
<div class="invite-card-container">
|
||||
<!-- 顶部邀请人特权卡片 -->
|
||||
<div class="invite-header-banner">
|
||||
<div class="banner-badge">
|
||||
<el-icon><Present /></el-icon>
|
||||
<span>专属邀请专享特权</span>
|
||||
</div>
|
||||
<h1 class="banner-title">加入 {{ siteStore.siteName || '哈夫币' }}</h1>
|
||||
<p class="banner-sub">安全便捷的游戏资产租赁与寄售平台 · 租客号主双边返利</p>
|
||||
|
||||
<div v-if="loadingInfo" class="inviter-box loading">
|
||||
<span>正在核对邀请信息...</span>
|
||||
</div>
|
||||
<div v-else-if="codeInfo && codeInfo.valid" class="inviter-box valid">
|
||||
<div class="inviter-avatar">
|
||||
<img v-if="codeInfo.inviter_avatar" :src="codeInfo.inviter_avatar" alt="avatar" />
|
||||
<span v-else>{{ (codeInfo.inviter_nickname || '友').slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="inviter-meta">
|
||||
<div class="inviter-name">
|
||||
来自 <strong>{{ codeInfo.inviter_nickname || '平台认证号主' }}</strong> 的邀请
|
||||
</div>
|
||||
<div class="inviter-desc">
|
||||
专属邀请码:<code class="code-pill">{{ inviteCode }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="inviter-box invalid">
|
||||
<el-icon color="#e6a23c"><WarningFilled /></el-icon>
|
||||
<span
|
||||
>邀请码
|
||||
<strong>{{ inviteCode }}</strong> 暂未生效或邀请人尚未实名,注册仍可正常完成</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返利权益亮点条 -->
|
||||
<div class="rebate-perks-row">
|
||||
<div class="perk-item">
|
||||
<span class="perk-rate">60%</span>
|
||||
<span class="perk-label">首月高额分成</span>
|
||||
</div>
|
||||
<div class="perk-divider"></div>
|
||||
<div class="perk-item">
|
||||
<span class="perk-rate">30%</span>
|
||||
<span class="perk-label">次月奖励分成</span>
|
||||
</div>
|
||||
<div class="perk-divider"></div>
|
||||
<div class="perk-item">
|
||||
<span class="perk-rate">15%</span>
|
||||
<span class="perk-label">往后终身永久</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单卡片 -->
|
||||
<div class="register-card-body">
|
||||
<div class="form-title">创建新账号</div>
|
||||
|
||||
<form class="register-form" @submit.prevent="handleSubmit">
|
||||
<!-- 手机号 -->
|
||||
<div class="form-group">
|
||||
<label>手机号码</label>
|
||||
<div class="input-wrapper">
|
||||
<el-icon class="input-icon"><Iphone /></el-icon>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
maxlength="11"
|
||||
placeholder="请输入11位手机号码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图形验证码 -->
|
||||
<div class="form-group">
|
||||
<label>图形验证码</label>
|
||||
<div class="input-wrapper captcha-wrapper">
|
||||
<el-icon class="input-icon"><Key /></el-icon>
|
||||
<input
|
||||
v-model="form.captchaCode"
|
||||
type="text"
|
||||
maxlength="4"
|
||||
placeholder="请输入图形验证码"
|
||||
required
|
||||
/>
|
||||
<div class="captcha-img-box" @click="refreshCaptcha">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="captcha" />
|
||||
<span v-else>点击获取</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 短信验证码 -->
|
||||
<div class="form-group">
|
||||
<label>短信验证码</label>
|
||||
<div class="input-wrapper sms-wrapper">
|
||||
<el-icon class="input-icon"><Key /></el-icon>
|
||||
<input
|
||||
v-model="form.smsCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="6位短信验证码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sms-btn"
|
||||
:disabled="smsCountdown > 0"
|
||||
@click="handleSendSms"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}秒后重试` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div class="form-group">
|
||||
<label>登录密码</label>
|
||||
<div class="input-wrapper">
|
||||
<el-icon class="input-icon"><Lock /></el-icon>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="设置登录密码(8-20位)"
|
||||
required
|
||||
/>
|
||||
<button type="button" class="pwd-toggle" @click="showPassword = !showPassword">
|
||||
<el-icon><View v-if="showPassword" /><Hide v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<div class="form-group">
|
||||
<label>确认密码</label>
|
||||
<div class="input-wrapper">
|
||||
<el-icon class="input-icon"><Lock /></el-icon>
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
placeholder="再次输入登录密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="pwd-toggle"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<el-icon><View v-if="showConfirmPassword" /><Hide v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请码锁定提示 -->
|
||||
<div class="invite-bind-notice">
|
||||
<div class="bind-badge">
|
||||
<el-icon color="#67c23a"><Check /></el-icon>
|
||||
<span
|
||||
>已绑定邀请码:<strong>{{ inviteCode }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<p class="bind-tip">注册成功后将自动建立推荐返利关联</p>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<button type="submit" class="submit-btn" :disabled="submitting">
|
||||
{{ submitting ? '正在注册并绑定...' : '立即注册并领取返利特权' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-redirect">
|
||||
已有账号?
|
||||
<RouterLink to="/login" class="login-link">直接登录</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.invite-register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8edf9 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.invite-card-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 12px 40px rgba(30, 58, 138, 0.08);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
|
||||
.invite-header-banner {
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%);
|
||||
color: #ffffff;
|
||||
padding: 28px 24px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.banner-sub {
|
||||
margin: 8px 0 16px;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.inviter-box {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.inviter-box.invalid {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inviter-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inviter-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.inviter-meta {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inviter-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.inviter-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.code-pill {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rebate-perks-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background: #f8fafc;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.perk-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.perk-rate {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.perk-label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.perk-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.register-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: #2563eb;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.captcha-img-box {
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e2e8f0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.captcha-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sms-btn {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sms-btn:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pwd-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.invite-bind-notice {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bind-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.bind-tip {
|
||||
margin: 4px 0 0 22px;
|
||||
font-size: 11px;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-redirect {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user