mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge branch 'pr-478'
# Conflicts: # apps/aether-gateway/src/data/state/mod.rs # apps/aether-gateway/src/handlers/admin/mod.rs # apps/aether-gateway/src/handlers/admin/routes.rs # crates/aether-data/src/lifecycle/bootstrap/postgres.rs # crates/aether-data/src/lifecycle/migrate/tests.rs # crates/aether-data/src/repository/announcements/postgres.rs # frontend/src/features/auth/components/RegisterDialog.vue
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-foreground">
|
||||
邀请返利
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
查看邀请关系、返利记录和失败返利处理状态
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="loadAll"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-2 h-4 w-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
|
||||
<Card
|
||||
v-for="item in statCards"
|
||||
:key="item.label"
|
||||
class="p-4"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="mt-2 text-xl font-semibold">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-border px-5 py-4">
|
||||
<h2 class="text-base font-semibold">
|
||||
邀请关系
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
|
||||
<Input
|
||||
v-model="relationshipFilters.inviter"
|
||||
placeholder="邀请人"
|
||||
/>
|
||||
<Input
|
||||
v-model="relationshipFilters.invitee"
|
||||
placeholder="被邀请人"
|
||||
/>
|
||||
<Input
|
||||
v-model="relationshipFilters.invite_code"
|
||||
placeholder="邀请码"
|
||||
/>
|
||||
<Select v-model="firstPaidFilter">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="首付状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部
|
||||
</SelectItem>
|
||||
<SelectItem value="true">
|
||||
已首付
|
||||
</SelectItem>
|
||||
<SelectItem value="false">
|
||||
未首付
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
@click="loadRelationships"
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>邀请人</TableHead>
|
||||
<TableHead>被邀请人</TableHead>
|
||||
<TableHead>邀请码</TableHead>
|
||||
<TableHead>绑定时间</TableHead>
|
||||
<TableHead>首付状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="item in relationships"
|
||||
:key="item.id"
|
||||
>
|
||||
<TableCell>{{ item.inviter_username || item.inviter_user_id }}</TableCell>
|
||||
<TableCell>{{ item.invitee_username || item.invitee_user_id }}</TableCell>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{{ item.invite_code_snapshot }}
|
||||
</TableCell>
|
||||
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="item.first_paid_order_id ? 'success' : 'secondary'">
|
||||
{{ item.first_paid_order_id ? '已首付' : '未首付' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="relationships.length === 0">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无邀请关系
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-border px-5 py-4">
|
||||
<h2 class="text-base font-semibold">
|
||||
返利记录
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 border-b border-border/70 p-4 md:grid-cols-5">
|
||||
<Input
|
||||
v-model="rewardFilters.order_id"
|
||||
placeholder="订单号"
|
||||
/>
|
||||
<Select v-model="rewardFilters.reward_type">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="返利类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部类型
|
||||
</SelectItem>
|
||||
<SelectItem value="percent">
|
||||
比例返利
|
||||
</SelectItem>
|
||||
<SelectItem value="headcount">
|
||||
人头返利
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model="rewardFilters.status">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部状态
|
||||
</SelectItem>
|
||||
<SelectItem value="pending">
|
||||
待发
|
||||
</SelectItem>
|
||||
<SelectItem value="failed">
|
||||
失败
|
||||
</SelectItem>
|
||||
<SelectItem value="applied">
|
||||
已发
|
||||
</SelectItem>
|
||||
<SelectItem value="voided">
|
||||
已作废
|
||||
</SelectItem>
|
||||
<SelectItem value="reversed">
|
||||
已冲回
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
class="md:col-start-5"
|
||||
@click="loadRewards"
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>来源订单</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>冲回</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead class="text-right">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="item in rewards"
|
||||
:key="item.id"
|
||||
>
|
||||
<TableCell>{{ getRewardTypeLabel(item.reward_type) }}</TableCell>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{{ item.source_order_id || '-' }}
|
||||
</TableCell>
|
||||
<TableCell>{{ formatUsd(item.amount_usd) }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getRewardStatusVariant(item.status)">
|
||||
{{ getRewardStatusLabel(item.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ formatUsd(item.reversed_amount_usd) }}
|
||||
<span
|
||||
v-if="item.pending_reversal_amount_usd > 0"
|
||||
class="text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
/ 待冲回 {{ formatUsd(item.pending_reversal_amount_usd) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{{ formatUnix(item.created_at_unix_secs) }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
v-if="item.status === 'failed'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="mutatingRewardId === item.id"
|
||||
@click="retryReward(item)"
|
||||
>
|
||||
补发
|
||||
</Button>
|
||||
<Button
|
||||
v-if="item.status === 'failed' || item.status === 'pending'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="mutatingRewardId === item.id"
|
||||
@click="voidReward(item)"
|
||||
>
|
||||
作废
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="rewards.length === 0">
|
||||
<TableCell
|
||||
colspan="7"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无返利记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import {
|
||||
referralApi,
|
||||
type ReferralRelationshipRecord,
|
||||
type ReferralRewardRecord,
|
||||
type ReferralSummary
|
||||
} from '@/api/referrals'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const relationships = ref<ReferralRelationshipRecord[]>([])
|
||||
const rewards = ref<ReferralRewardRecord[]>([])
|
||||
const stats = ref<ReferralSummary>({
|
||||
total_invites: 0,
|
||||
effective_invites: 0,
|
||||
paid_reward_usd: 0,
|
||||
pending_reward_usd: 0,
|
||||
reversed_reward_usd: 0
|
||||
})
|
||||
const loading = ref(false)
|
||||
const mutatingRewardId = ref<string | null>(null)
|
||||
const relationshipFilters = ref({
|
||||
inviter: '',
|
||||
invitee: '',
|
||||
invite_code: ''
|
||||
})
|
||||
const firstPaidFilter = ref('all')
|
||||
const rewardFilters = ref({
|
||||
order_id: '',
|
||||
reward_type: 'all',
|
||||
status: 'all'
|
||||
})
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ label: '总邀请', value: stats.value.total_invites },
|
||||
{ label: '有效邀请', value: stats.value.effective_invites },
|
||||
{ label: '已发返利', value: formatUsd(stats.value.paid_reward_usd) },
|
||||
{ label: '待发返利', value: formatUsd(stats.value.pending_reward_usd) },
|
||||
{ label: '已冲回返利', value: formatUsd(stats.value.reversed_reward_usd) },
|
||||
])
|
||||
|
||||
function formatUsd(value: number): string {
|
||||
return `$${Number(value || 0).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatUnix(value?: number | null): string {
|
||||
if (!value) return '-'
|
||||
return new Date(value * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function getRewardTypeLabel(value: string): string {
|
||||
if (value === 'percent') return '比例返利'
|
||||
if (value === 'headcount') return '人头返利'
|
||||
return value
|
||||
}
|
||||
|
||||
function getRewardStatusLabel(value: string): string {
|
||||
switch (value) {
|
||||
case 'applied':
|
||||
return '已发'
|
||||
case 'pending':
|
||||
return '待发'
|
||||
case 'failed':
|
||||
return '失败'
|
||||
case 'voided':
|
||||
return '已作废'
|
||||
case 'reversed':
|
||||
return '已冲回'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function getRewardStatusVariant(value: string): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark' {
|
||||
switch (value) {
|
||||
case 'applied':
|
||||
return 'success'
|
||||
case 'failed':
|
||||
return 'destructive'
|
||||
case 'pending':
|
||||
return 'warning'
|
||||
case 'voided':
|
||||
return 'secondary'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationships() {
|
||||
const firstPaid =
|
||||
firstPaidFilter.value === 'true' ? true : firstPaidFilter.value === 'false' ? false : null
|
||||
const response = await referralApi.getAdminReferrals({
|
||||
...relationshipFilters.value,
|
||||
first_paid: firstPaid,
|
||||
limit: 100,
|
||||
offset: 0
|
||||
})
|
||||
relationships.value = response.items
|
||||
stats.value = response.stats
|
||||
}
|
||||
|
||||
async function loadRewards() {
|
||||
const response = await referralApi.getAdminReferralRewards({
|
||||
order_id: rewardFilters.value.order_id,
|
||||
reward_type: rewardFilters.value.reward_type === 'all' ? undefined : rewardFilters.value.reward_type,
|
||||
status: rewardFilters.value.status === 'all' ? undefined : rewardFilters.value.status,
|
||||
limit: 100,
|
||||
offset: 0
|
||||
})
|
||||
rewards.value = response.items
|
||||
stats.value = response.stats
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([loadRelationships(), loadRewards()])
|
||||
} catch {
|
||||
showError('加载邀请返利数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryReward(item: ReferralRewardRecord) {
|
||||
mutatingRewardId.value = item.id
|
||||
try {
|
||||
const response = await referralApi.retryReferralReward(item.id, '管理员后台补发')
|
||||
replaceReward(response.reward)
|
||||
success('返利已补发')
|
||||
} catch {
|
||||
showError('补发失败')
|
||||
} finally {
|
||||
mutatingRewardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function voidReward(item: ReferralRewardRecord) {
|
||||
mutatingRewardId.value = item.id
|
||||
try {
|
||||
const response = await referralApi.voidReferralReward(item.id, '管理员后台作废')
|
||||
replaceReward(response.reward)
|
||||
success('返利已作废')
|
||||
} catch {
|
||||
showError('作废失败')
|
||||
} finally {
|
||||
mutatingRewardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function replaceReward(updated: ReferralRewardRecord) {
|
||||
rewards.value = rewards.value.map(item => item.id === updated.id ? updated : item)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll()
|
||||
})
|
||||
</script>
|
||||
@@ -58,6 +58,15 @@
|
||||
:turnstile-secret-key="systemConfig.turnstile_secret_key"
|
||||
:turnstile-secret-configured="systemConfig.turnstile_secret_key_is_set"
|
||||
:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr"
|
||||
:referral-enabled="systemConfig.referral_enabled"
|
||||
:referral-reward-mode="systemConfig.referral_reward_mode"
|
||||
:referral-recharge-percent="systemConfig.referral_recharge_percent"
|
||||
:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd"
|
||||
:referral-headcount-trigger="systemConfig.referral_headcount_trigger"
|
||||
:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled"
|
||||
:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format"
|
||||
:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content"
|
||||
:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version"
|
||||
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
|
||||
:enable-format-conversion="systemConfig.enable_format_conversion"
|
||||
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
|
||||
@@ -73,6 +82,15 @@
|
||||
@update:turnstile-secret-key="systemConfig.turnstile_secret_key = $event"
|
||||
@update:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr = $event"
|
||||
@clear-turnstile-secret="clearTurnstileSecret"
|
||||
@update:referral-enabled="systemConfig.referral_enabled = $event"
|
||||
@update:referral-reward-mode="systemConfig.referral_reward_mode = $event"
|
||||
@update:referral-recharge-percent="systemConfig.referral_recharge_percent = $event"
|
||||
@update:referral-headcount-amount-usd="systemConfig.referral_headcount_amount_usd = $event"
|
||||
@update:referral-headcount-trigger="systemConfig.referral_headcount_trigger = $event"
|
||||
@update:registration-privacy-policy-enabled="systemConfig.registration_privacy_policy_enabled = $event"
|
||||
@update:registration-privacy-policy-format="systemConfig.registration_privacy_policy_format = $event"
|
||||
@update:registration-privacy-policy-content="systemConfig.registration_privacy_policy_content = $event"
|
||||
@update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event"
|
||||
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
|
||||
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
|
||||
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
|
||||
|
||||
@@ -262,6 +262,203 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="referral-enabled"
|
||||
:checked="referralEnabled"
|
||||
@update:checked="$emit('update:referralEnabled', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="referral-enabled"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
邀请返利
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
开启后可按充值比例、人头或两者同时发放赠款返利
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="referral-reward-mode"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
返利方式
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="referralRewardMode"
|
||||
@update:model-value="$emit('update:referralRewardMode', $event)"
|
||||
>
|
||||
<SelectTrigger id="referral-reward-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="percent">
|
||||
按充值比例
|
||||
</SelectItem>
|
||||
<SelectItem value="headcount">
|
||||
按邀请人头
|
||||
</SelectItem>
|
||||
<SelectItem value="both">
|
||||
两者同时启用
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="referral-recharge-percent"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
充值返利比例 (%)
|
||||
</Label>
|
||||
<Input
|
||||
id="referral-recharge-percent"
|
||||
:model-value="referralRechargePercent"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:referralRechargePercent', Number($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="referral-headcount-amount"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
人头返利金额 (美元)
|
||||
</Label>
|
||||
<Input
|
||||
id="referral-headcount-amount"
|
||||
:model-value="referralHeadcountAmountUsd"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:referralHeadcountAmountUsd', Number($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="referral-headcount-trigger"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
人头返利触发时机
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="referralHeadcountTrigger"
|
||||
@update:model-value="$emit('update:referralHeadcountTrigger', $event)"
|
||||
>
|
||||
<SelectTrigger id="referral-headcount-trigger">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="registration">
|
||||
注册成功
|
||||
</SelectItem>
|
||||
<SelectItem value="email_verified">
|
||||
邮箱验证完成
|
||||
</SelectItem>
|
||||
<SelectItem value="first_paid_order">
|
||||
首笔真实支付完成
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="privacy-policy-enabled"
|
||||
:checked="registrationPrivacyPolicyEnabled"
|
||||
@update:checked="$emit('update:registrationPrivacyPolicyEnabled', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="privacy-policy-enabled"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
注册隐私政策确认
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
开启后注册时必须确认当前版本
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="privacy-policy-version"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
隐私政策版本
|
||||
</Label>
|
||||
<Input
|
||||
id="privacy-policy-version"
|
||||
:model-value="registrationPrivacyPolicyVersion"
|
||||
type="text"
|
||||
placeholder="2026-05-16"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:registrationPrivacyPolicyVersion', String($event || '').trim())"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="privacy-policy-format"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
隐私政策格式
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="registrationPrivacyPolicyFormat"
|
||||
@update:model-value="$emit('update:registrationPrivacyPolicyFormat', $event)"
|
||||
>
|
||||
<SelectTrigger id="privacy-policy-format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="markdown">
|
||||
Markdown
|
||||
</SelectItem>
|
||||
<SelectItem value="html">
|
||||
HTML
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<Label
|
||||
for="privacy-policy-content"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
隐私政策内容
|
||||
</Label>
|
||||
<Textarea
|
||||
id="privacy-policy-content"
|
||||
:model-value="registrationPrivacyPolicyContent"
|
||||
rows="8"
|
||||
class="mt-1"
|
||||
placeholder="填写 Markdown 或 HTML 内容"
|
||||
@update:model-value="$emit('update:registrationPrivacyPolicyContent', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
@@ -270,6 +467,7 @@
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Textarea from '@/components/ui/textarea.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
@@ -288,6 +486,15 @@ defineProps<{
|
||||
turnstileSecretKey: string
|
||||
turnstileSecretConfigured: boolean
|
||||
turnstileAllowedHostnamesStr: string
|
||||
referralEnabled: boolean
|
||||
referralRewardMode: string
|
||||
referralRechargePercent: number
|
||||
referralHeadcountAmountUsd: number
|
||||
referralHeadcountTrigger: string
|
||||
registrationPrivacyPolicyEnabled: boolean
|
||||
registrationPrivacyPolicyFormat: string
|
||||
registrationPrivacyPolicyContent: string
|
||||
registrationPrivacyPolicyVersion: string
|
||||
autoDeleteExpiredKeys: boolean
|
||||
enableFormatConversion: boolean
|
||||
enableOpenaiImageSyncHeartbeat: boolean
|
||||
@@ -306,6 +513,15 @@ defineEmits<{
|
||||
'update:turnstileSecretKey': [value: string]
|
||||
'update:turnstileAllowedHostnamesStr': [value: string]
|
||||
clearTurnstileSecret: []
|
||||
'update:referralEnabled': [value: boolean]
|
||||
'update:referralRewardMode': [value: string]
|
||||
'update:referralRechargePercent': [value: number]
|
||||
'update:referralHeadcountAmountUsd': [value: number]
|
||||
'update:referralHeadcountTrigger': [value: string]
|
||||
'update:registrationPrivacyPolicyEnabled': [value: boolean]
|
||||
'update:registrationPrivacyPolicyFormat': [value: string]
|
||||
'update:registrationPrivacyPolicyContent': [value: string]
|
||||
'update:registrationPrivacyPolicyVersion': [value: string]
|
||||
'update:autoDeleteExpiredKeys': [value: boolean]
|
||||
'update:enableFormatConversion': [value: boolean]
|
||||
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
|
||||
|
||||
@@ -20,6 +20,15 @@ export interface SystemConfig {
|
||||
turnstile_secret_key: string
|
||||
turnstile_secret_key_is_set: boolean
|
||||
turnstile_allowed_hostnames: string[]
|
||||
referral_enabled: boolean
|
||||
referral_reward_mode: string
|
||||
referral_recharge_percent: number
|
||||
referral_headcount_amount_usd: number
|
||||
referral_headcount_trigger: string
|
||||
registration_privacy_policy_enabled: boolean
|
||||
registration_privacy_policy_format: string
|
||||
registration_privacy_policy_content: string
|
||||
registration_privacy_policy_version: string
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: boolean
|
||||
// 格式转换
|
||||
@@ -65,6 +74,15 @@ const CONFIG_KEYS = [
|
||||
'turnstile_site_key',
|
||||
'turnstile_secret_key',
|
||||
'turnstile_allowed_hostnames',
|
||||
'referral_enabled',
|
||||
'referral_reward_mode',
|
||||
'referral_recharge_percent',
|
||||
'referral_headcount_amount_usd',
|
||||
'referral_headcount_trigger',
|
||||
'registration_privacy_policy_enabled',
|
||||
'registration_privacy_policy_format',
|
||||
'registration_privacy_policy_content',
|
||||
'registration_privacy_policy_version',
|
||||
// 独立余额 Key 过期管理
|
||||
'auto_delete_expired_keys',
|
||||
// 格式转换
|
||||
@@ -112,6 +130,15 @@ function createDefaultConfig(): SystemConfig {
|
||||
turnstile_secret_key: '',
|
||||
turnstile_secret_key_is_set: false,
|
||||
turnstile_allowed_hostnames: [],
|
||||
referral_enabled: false,
|
||||
referral_reward_mode: 'percent',
|
||||
referral_recharge_percent: 5,
|
||||
referral_headcount_amount_usd: 0,
|
||||
referral_headcount_trigger: 'registration',
|
||||
registration_privacy_policy_enabled: false,
|
||||
registration_privacy_policy_format: 'markdown',
|
||||
registration_privacy_policy_content: '',
|
||||
registration_privacy_policy_version: '1',
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: false,
|
||||
// 格式转换
|
||||
@@ -184,6 +211,19 @@ export function useSystemConfig() {
|
||||
systemConfig.value.turnstile_secret_key.trim() !== '' ||
|
||||
JSON.stringify(systemConfig.value.turnstile_allowed_hostnames) !==
|
||||
JSON.stringify(originalConfig.value.turnstile_allowed_hostnames) ||
|
||||
systemConfig.value.referral_enabled !== originalConfig.value.referral_enabled ||
|
||||
systemConfig.value.referral_reward_mode !== originalConfig.value.referral_reward_mode ||
|
||||
systemConfig.value.referral_recharge_percent !== originalConfig.value.referral_recharge_percent ||
|
||||
systemConfig.value.referral_headcount_amount_usd !== originalConfig.value.referral_headcount_amount_usd ||
|
||||
systemConfig.value.referral_headcount_trigger !== originalConfig.value.referral_headcount_trigger ||
|
||||
systemConfig.value.registration_privacy_policy_enabled !==
|
||||
originalConfig.value.registration_privacy_policy_enabled ||
|
||||
systemConfig.value.registration_privacy_policy_format !==
|
||||
originalConfig.value.registration_privacy_policy_format ||
|
||||
systemConfig.value.registration_privacy_policy_content !==
|
||||
originalConfig.value.registration_privacy_policy_content ||
|
||||
systemConfig.value.registration_privacy_policy_version !==
|
||||
originalConfig.value.registration_privacy_policy_version ||
|
||||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
|
||||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
|
||||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
|
||||
@@ -386,6 +426,51 @@ export function useSystemConfig() {
|
||||
value: systemConfig.value.turnstile_allowed_hostnames,
|
||||
description: 'Cloudflare Turnstile 允许的 hostname 列表',
|
||||
},
|
||||
{
|
||||
key: 'referral_enabled',
|
||||
value: systemConfig.value.referral_enabled,
|
||||
description: '邀请返利开关',
|
||||
},
|
||||
{
|
||||
key: 'referral_reward_mode',
|
||||
value: systemConfig.value.referral_reward_mode,
|
||||
description: '邀请返利方式',
|
||||
},
|
||||
{
|
||||
key: 'referral_recharge_percent',
|
||||
value: systemConfig.value.referral_recharge_percent,
|
||||
description: '邀请充值比例返利百分比',
|
||||
},
|
||||
{
|
||||
key: 'referral_headcount_amount_usd',
|
||||
value: systemConfig.value.referral_headcount_amount_usd,
|
||||
description: '邀请人头返利金额(美元)',
|
||||
},
|
||||
{
|
||||
key: 'referral_headcount_trigger',
|
||||
value: systemConfig.value.referral_headcount_trigger,
|
||||
description: '邀请人头返利触发时机',
|
||||
},
|
||||
{
|
||||
key: 'registration_privacy_policy_enabled',
|
||||
value: systemConfig.value.registration_privacy_policy_enabled,
|
||||
description: '注册隐私政策确认开关',
|
||||
},
|
||||
{
|
||||
key: 'registration_privacy_policy_format',
|
||||
value: systemConfig.value.registration_privacy_policy_format,
|
||||
description: '注册隐私政策内容格式',
|
||||
},
|
||||
{
|
||||
key: 'registration_privacy_policy_content',
|
||||
value: systemConfig.value.registration_privacy_policy_content,
|
||||
description: '注册隐私政策内容',
|
||||
},
|
||||
{
|
||||
key: 'registration_privacy_policy_version',
|
||||
value: systemConfig.value.registration_privacy_policy_version,
|
||||
description: '注册隐私政策版本',
|
||||
},
|
||||
{
|
||||
key: 'auto_delete_expired_keys',
|
||||
value: systemConfig.value.auto_delete_expired_keys,
|
||||
@@ -426,6 +511,21 @@ export function useSystemConfig() {
|
||||
originalConfig.value.turnstile_allowed_hostnames = [
|
||||
...systemConfig.value.turnstile_allowed_hostnames,
|
||||
]
|
||||
originalConfig.value.referral_enabled = systemConfig.value.referral_enabled
|
||||
originalConfig.value.referral_reward_mode = systemConfig.value.referral_reward_mode
|
||||
originalConfig.value.referral_recharge_percent = systemConfig.value.referral_recharge_percent
|
||||
originalConfig.value.referral_headcount_amount_usd =
|
||||
systemConfig.value.referral_headcount_amount_usd
|
||||
originalConfig.value.referral_headcount_trigger =
|
||||
systemConfig.value.referral_headcount_trigger
|
||||
originalConfig.value.registration_privacy_policy_enabled =
|
||||
systemConfig.value.registration_privacy_policy_enabled
|
||||
originalConfig.value.registration_privacy_policy_format =
|
||||
systemConfig.value.registration_privacy_policy_format
|
||||
originalConfig.value.registration_privacy_policy_content =
|
||||
systemConfig.value.registration_privacy_policy_content
|
||||
originalConfig.value.registration_privacy_policy_version =
|
||||
systemConfig.value.registration_privacy_policy_version
|
||||
if (turnstileSecret) {
|
||||
systemConfig.value.turnstile_secret_key = ''
|
||||
systemConfig.value.turnstile_secret_key_is_set = true
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<main class="min-h-screen bg-[#faf9f5] text-[#3d3929] dark:bg-[#191714] dark:text-[#e3e0d3]">
|
||||
<header class="border-b border-[#3d3929]/10 dark:border-white/10">
|
||||
<div class="mx-auto flex max-w-4xl items-center justify-between px-5 py-4">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<HeaderLogo
|
||||
size="h-9 w-9"
|
||||
class-name="text-[#191919] dark:text-white"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-semibold">
|
||||
{{ siteName }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
隐私政策
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
返回首页
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mx-auto max-w-4xl px-5 py-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">
|
||||
隐私政策
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
当前版本:{{ policy.version || '1' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-border bg-background/70 p-6 text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadError"
|
||||
class="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-sm text-destructive"
|
||||
>
|
||||
{{ loadError }}
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<article
|
||||
v-else
|
||||
class="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border bg-background/70 p-6"
|
||||
v-html="renderedPolicy"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
|
||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
const { siteName } = useSiteInfo()
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const policy = ref<RegistrationPrivacyPolicySettings>({
|
||||
enabled: false,
|
||||
format: 'markdown',
|
||||
content: '',
|
||||
version: '1'
|
||||
})
|
||||
|
||||
const renderedPolicy = computed(() => {
|
||||
if (!policy.value.content) return '<p>暂无隐私政策内容。</p>'
|
||||
if (policy.value.format === 'html') {
|
||||
return sanitizeHtml(policy.value.content)
|
||||
}
|
||||
return sanitizeMarkdown(marked(policy.value.content) as string)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const settings = await authApi.getRegistrationSettings()
|
||||
policy.value = settings.privacy_policy ?? policy.value
|
||||
} catch {
|
||||
loadError.value = '隐私政策加载失败,请稍后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -132,6 +132,13 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-foreground">{{ announcement.title }}</span>
|
||||
<Badge
|
||||
v-if="announcement.requires_ack"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
必读
|
||||
</Badge>
|
||||
<Pin
|
||||
v-if="announcement.is_pinned"
|
||||
class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
|
||||
@@ -240,6 +247,13 @@
|
||||
:class="getIconColor(announcement.type)"
|
||||
/>
|
||||
<span class="font-medium text-sm">{{ announcement.title }}</span>
|
||||
<Badge
|
||||
v-if="announcement.requires_ack"
|
||||
variant="outline"
|
||||
class="text-[10px] shrink-0"
|
||||
>
|
||||
必读
|
||||
</Badge>
|
||||
<Pin
|
||||
v-if="announcement.is_pinned"
|
||||
class="w-3.5 h-3.5 text-muted-foreground shrink-0"
|
||||
@@ -433,6 +447,18 @@
|
||||
class="cursor-pointer text-sm"
|
||||
>置顶公告</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="requires-ack"
|
||||
v-model="formData.requires_ack"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
>
|
||||
<Label
|
||||
for="requires-ack"
|
||||
class="cursor-pointer text-sm"
|
||||
>必读确认</Label>
|
||||
</div>
|
||||
<div
|
||||
v-if="editingAnnouncement"
|
||||
class="flex items-center gap-2"
|
||||
@@ -611,7 +637,8 @@ const formData = ref({
|
||||
type: 'info' as 'info' | 'warning' | 'maintenance' | 'important',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
requires_ack: false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -663,7 +690,8 @@ function openCreateDialog() {
|
||||
type: 'info',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
requires_ack: false
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
@@ -676,7 +704,8 @@ function openEditDialog(announcement: Announcement) {
|
||||
type: announcement.type,
|
||||
priority: announcement.priority,
|
||||
is_pinned: announcement.is_pinned,
|
||||
is_active: announcement.is_active
|
||||
is_active: announcement.is_active,
|
||||
requires_ack: !!announcement.requires_ack
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-foreground">
|
||||
我的邀请
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
分享邀请码后,符合规则的返利会进入赠款余额
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
|
||||
<template v-else-if="dashboard">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card class="p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
总邀请
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold">
|
||||
{{ dashboard.summary.total_invites }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
有效邀请
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold">
|
||||
{{ dashboard.summary.effective_invites }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已发返利
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold">
|
||||
{{ formatUsd(dashboard.summary.paid_reward_usd) }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="p-5">
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[240px_1fr]">
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
邀请码
|
||||
</Label>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<code class="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-sm">
|
||||
{{ dashboard.invite_code }}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copyToClipboard(dashboard.invite_code)"
|
||||
>
|
||||
<Copy class="mr-2 h-4 w-4" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
邀请链接
|
||||
</Label>
|
||||
<div class="mt-2 flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
:model-value="dashboard.invitation_link"
|
||||
readonly
|
||||
class="min-w-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="copyToClipboard(dashboard.invitation_link)"
|
||||
>
|
||||
<Copy class="mr-2 h-4 w-4" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card class="p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
待发返利
|
||||
</p>
|
||||
<p class="mt-2 text-xl font-semibold">
|
||||
{{ formatUsd(dashboard.summary.pending_reward_usd) }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已冲回返利
|
||||
</p>
|
||||
<p class="mt-2 text-xl font-semibold">
|
||||
{{ formatUsd(dashboard.summary.reversed_reward_usd) }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground"
|
||||
>
|
||||
邀请数据暂不可用
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Copy } from 'lucide-vue-next'
|
||||
import { referralApi, type ReferralDashboardResponse } from '@/api/referrals'
|
||||
import { Button, Card, Input, Label } from '@/components/ui'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const dashboard = ref<ReferralDashboardResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { error: showError } = useToast()
|
||||
|
||||
function formatUsd(value: number): string {
|
||||
return `$${Number(value || 0).toFixed(2)}`
|
||||
}
|
||||
|
||||
async function loadReferralDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
dashboard.value = await referralApi.getMyReferral()
|
||||
} catch {
|
||||
dashboard.value = null
|
||||
showError('加载邀请数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadReferralDashboard()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user