feat(referrals): 添加邀请返利和注册确认功能

This commit is contained in:
Entropy.Xu
2026-05-16 17:37:58 +08:00
parent 328ac721ce
commit 973eb1a614
56 changed files with 6246 additions and 52 deletions

View File

@@ -8,6 +8,7 @@ export interface Announcement {
priority: number
is_pinned: boolean
is_active: boolean
requires_ack: boolean
author: {
id: string // UUID
username: string
@@ -31,6 +32,7 @@ export interface CreateAnnouncementRequest {
type?: 'info' | 'warning' | 'maintenance' | 'important'
priority?: number
is_pinned?: boolean
requires_ack?: boolean
start_time?: string
end_time?: string
}
@@ -42,6 +44,7 @@ export interface UpdateAnnouncementRequest {
priority?: number
is_active?: boolean
is_pinned?: boolean
requires_ack?: boolean
start_time?: string
end_time?: string
}
@@ -88,6 +91,11 @@ export const announcementApi = {
return response.data
},
async getRequiredUnreadAnnouncements(): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements/users/me/required-unread')
return response.data
},
// 管理员方法
// 创建公告
async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> {
@@ -106,4 +114,4 @@ export const announcementApi = {
const response = await apiClient.delete(`/api/announcements/${id}`)
return response.data
}
}
}

View File

@@ -69,6 +69,9 @@ export interface RegisterRequest {
username: string
password: string
turnstile_token?: string
invite_code?: string
privacy_policy_accepted?: boolean
privacy_policy_version?: string
}
export interface RegisterResponse {
@@ -86,6 +89,14 @@ export interface RegistrationSettingsResponse {
turnstile_enabled?: boolean
turnstile_site_key?: string | null
turnstile_required_actions?: string[]
privacy_policy?: RegistrationPrivacyPolicySettings
}
export interface RegistrationPrivacyPolicySettings {
enabled: boolean
format: 'markdown' | 'html'
content: string
version: string
}
export interface AuthSettingsResponse {

View File

@@ -0,0 +1,114 @@
import apiClient from './client'
export interface ReferralSummary {
total_invites: number
effective_invites: number
paid_reward_usd: number
pending_reward_usd: number
reversed_reward_usd: number
}
export interface ReferralDashboardResponse {
invite_code: string
invitation_link: string
summary: ReferralSummary
}
export interface ReferralRelationshipRecord {
id: string
inviter_user_id: string
inviter_username?: string | null
invitee_user_id: string
invitee_username?: string | null
invite_code_snapshot: string
first_paid_order_id?: string | null
first_paid_at_unix_secs?: number | null
source?: Record<string, unknown> | null
created_at_unix_secs: number
}
export interface ReferralRewardRecord {
id: string
referral_id: string
inviter_user_id: string
invitee_user_id: string
reward_type: string
source_order_id?: string | null
trigger_point: string
amount_usd: number
status: string
wallet_transaction_id?: string | null
idempotency_key: string
reversed_amount_usd: number
pending_reversal_amount_usd: number
admin_operator_id?: string | null
admin_note?: string | null
created_at_unix_secs: number
updated_at_unix_secs: number
}
export interface ReferralListResponse<T> {
items: T[]
total: number
limit: number
offset: number
stats: ReferralSummary
}
export interface ReferralRelationshipQuery {
inviter?: string
invitee?: string
invite_code?: string
first_paid?: boolean | null
limit?: number
offset?: number
}
export interface ReferralRewardQuery {
order_id?: string
reward_type?: string
status?: string
limit?: number
offset?: number
}
function cleanParams<T extends Record<string, unknown>>(params: T): Partial<T> {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
) as Partial<T>
}
export const referralApi = {
async getMyReferral(): Promise<ReferralDashboardResponse> {
const response = await apiClient.get<ReferralDashboardResponse>('/api/users/me/referral')
return response.data
},
async getAdminReferrals(
params: ReferralRelationshipQuery = {}
): Promise<ReferralListResponse<ReferralRelationshipRecord>> {
const response = await apiClient.get('/api/admin/referrals', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
},
async getAdminReferralRewards(
params: ReferralRewardQuery = {}
): Promise<ReferralListResponse<ReferralRewardRecord>> {
const response = await apiClient.get('/api/admin/referral-rewards', {
params: cleanParams(params as Record<string, unknown>)
})
return response.data
},
async retryReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/retry`, { note })
return response.data
},
async voidReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> {
const response = await apiClient.post(`/api/admin/referral-rewards/${id}/void`, { note })
return response.data
}
}

View File

@@ -229,6 +229,7 @@
:password-policy-level="passwordPolicyLevel"
:turnstile-enabled="turnstileEnabled"
:turnstile-site-key="turnstileSiteKey"
:privacy-policy="privacyPolicy"
@success="handleRegisterSuccess"
@switch-to-login="handleSwitchToLogin"
/>
@@ -236,7 +237,7 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -247,7 +248,7 @@ import { useSiteInfo } from '@/composables/useSiteInfo'
import { normalizePasswordPolicyLevel, type PasswordPolicyLevel } from '@/utils/passwordPolicy'
import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
import RegisterDialog from './RegisterDialog.vue'
import { authApi } from '@/api/auth'
import { authApi, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getClientDeviceId } from '@/utils/deviceId'
import { getApiUrl } from '@/utils/url'
@@ -262,6 +263,7 @@ const emit = defineEmits<{
}>()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { success: showSuccess, warning: showWarning, error: showError } = useToast()
const { siteName } = useSiteInfo()
@@ -275,6 +277,12 @@ const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref<string | null>(null)
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
const privacyPolicy = ref<RegistrationPrivacyPolicySettings>({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
// LDAP authentication settings
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
@@ -394,6 +402,12 @@ onMounted(async () => {
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
turnstileEnabled.value = !!regSettings.turnstile_enabled
turnstileSiteKey.value = regSettings.turnstile_site_key || null
privacyPolicy.value = regSettings.privacy_policy ?? {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
localEnabled.value = authSettings.local_enabled
ldapEnabled.value = authSettings.ldap_enabled
@@ -413,6 +427,10 @@ onMounted(async () => {
}
oauthProviders.value = providers
if (allowRegistration.value && (route.path === '/register' || typeof route.query.invite === 'string')) {
isOpen.value = false
showRegisterDialog.value = true
}
} catch {
// If获取失败保持默认关闭注册 & 关闭邮箱验证 & 使用本地认证
allowRegistration.value = false
@@ -421,6 +439,12 @@ onMounted(async () => {
passwordPolicyLevel.value = 'weak'
turnstileEnabled.value = false
turnstileSiteKey.value = null
privacyPolicy.value = {
enabled: false,
format: 'markdown',
content: '',
version: ''
}
localEnabled.value = true
ldapEnabled.value = false
ldapExclusive.value = false

View File

@@ -212,6 +212,45 @@
</p>
</div>
<div
v-if="inviteCode"
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"
>
已识别邀请码 <span class="font-mono font-semibold text-foreground">{{ inviteCode }}</span>
</div>
<div
v-if="privacyPolicyEnabled"
class="rounded-lg border border-border bg-muted/30 p-3"
>
<label class="flex items-start gap-2 text-sm">
<Checkbox
:checked="privacyAccepted"
class="mt-0.5"
@update:checked="privacyAccepted = !!$event"
/>
<span class="leading-6">
我已阅读并同意
<button
type="button"
class="font-medium text-primary underline-offset-4 hover:underline"
@click="privacyDialogOpen = true"
>
隐私政策
</button>
<RouterLink
to="/privacy-policy"
target="_blank"
class="ml-1 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
新窗口打开
</RouterLink>
</span>
</label>
<p class="mt-2 text-xs text-muted-foreground">
当前版本{{ privacyPolicyVersion }}
</p>
</div>
</form>
<!-- 登录链接 -->
@@ -246,13 +285,37 @@
</Button>
</template>
</Dialog>
<Dialog
v-model="privacyDialogOpen"
size="2xl"
title="隐私政策"
>
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-sm dark:prose-invert max-h-[60vh] max-w-none overflow-y-auto"
v-html="renderedPrivacyPolicy"
/>
<!-- eslint-enable vue/no-v-html -->
<template #footer>
<Button
type="button"
@click="privacyDialogOpen = false"
>
我知道了
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { authApi, type RegisterRequest } from '@/api/auth'
import { RouterLink } from 'vue-router'
import { marked } from 'marked'
import { authApi, type RegisterRequest, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
import {
getPasswordPolicyHint,
getPasswordPolicyPlaceholder,
@@ -261,10 +324,13 @@ import {
} from '@/utils/passwordPolicy'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import TurnstileWidget from './TurnstileWidget.vue'
const INVITE_CODE_STORAGE_KEY = 'aether_invite_code'
interface Props {
open?: boolean
requireEmailVerification?: boolean
@@ -272,6 +338,7 @@ interface Props {
passwordPolicyLevel?: PasswordPolicyLevel
turnstileEnabled?: boolean
turnstileSiteKey?: string | null
privacyPolicy?: RegistrationPrivacyPolicySettings
}
interface Emits {
@@ -286,7 +353,13 @@ const props = withDefaults(defineProps<Props>(), {
emailConfigured: true,
passwordPolicyLevel: 'weak',
turnstileEnabled: false,
turnstileSiteKey: null
turnstileSiteKey: null,
privacyPolicy: () => ({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
})
const emit = defineEmits<Emits>()
@@ -423,6 +496,32 @@ const handleTurnstileError = (message: string) => {
showError(message, '人机验证失败')
}
const inviteCode = ref<string | null>(null)
const privacyAccepted = ref(false)
const privacyDialogOpen = ref(false)
const privacyPolicyEnabled = computed(() => !!props.privacyPolicy?.enabled)
const privacyPolicyVersion = computed(() => props.privacyPolicy?.version || '1')
const renderedPrivacyPolicy = computed(() => {
const policy = props.privacyPolicy
if (!policy?.content) return '<p>暂无隐私政策内容</p>'
if (policy.format === 'html') {
return sanitizeHtml(policy.content)
}
const rawHtml = marked(policy.content) as string
return sanitizeMarkdown(rawHtml)
})
function loadInviteCode(): string | null {
if (typeof window === 'undefined') return null
const fromQuery = new URLSearchParams(window.location.search).get('invite')
const normalized = (fromQuery || localStorage.getItem(INVITE_CODE_STORAGE_KEY) || '')
.trim()
.toUpperCase()
if (!normalized) return null
localStorage.setItem(INVITE_CODE_STORAGE_KEY, normalized)
return normalized
}
// Send code cooldown timer
const canSendCode = computed(() => {
if (!formData.value.email) return false
@@ -502,6 +601,10 @@ const canSubmit = computed(() => {
return false
}
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
return false
}
return true
})
@@ -619,7 +722,9 @@ const resetForm = () => {
isSendingCode.value = false
codeSentAt.value = null
cooldownSeconds.value = 0
resetTurnstile()
inviteCode.value = loadInviteCode()
privacyAccepted.value = false
privacyDialogOpen.value = false
// Reset password field nonce
formNonce.value = createFormNonce()
@@ -743,6 +848,11 @@ const handleSubmit = async () => {
return
}
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
showError('请先阅读并同意隐私政策')
return
}
isLoading.value = true
loadingText.value = '注册中...'
@@ -759,6 +869,13 @@ const handleSubmit = async () => {
if (turnstileRequired.value && currentTurnstileAction.value === 'register') {
registerData.turnstile_token = turnstileToken.value
}
if (inviteCode.value) {
registerData.invite_code = inviteCode.value
}
if (privacyPolicyEnabled.value) {
registerData.privacy_policy_accepted = privacyAccepted.value
registerData.privacy_policy_version = privacyPolicyVersion.value
}
const response = await authApi.register(registerData)

View File

@@ -339,6 +339,43 @@
<RouterView />
<Dialog
v-model="requiredAnnouncementOpen"
persistent
size="lg"
title="必读公告"
description="请确认后继续使用"
>
<div
v-if="currentRequiredAnnouncement"
class="space-y-4"
>
<div>
<h3 class="text-lg font-semibold text-foreground">
{{ currentRequiredAnnouncement.title }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
{{ formatRequiredAnnouncementDate(currentRequiredAnnouncement.created_at) }}
</p>
</div>
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-sm dark:prose-invert max-h-[50vh] max-w-none overflow-y-auto"
v-html="renderRequiredAnnouncement(currentRequiredAnnouncement.content)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<template #footer>
<Button
type="button"
:disabled="acknowledgingRequiredAnnouncement"
@click="acknowledgeRequiredAnnouncement"
>
{{ acknowledgingRequiredAnnouncement ? '确认中...' : '确认已读' }}
</Button>
</template>
</Dialog>
<!-- 更新提示弹窗 -->
<UpdateDialog
v-if="updateInfo"
@@ -355,13 +392,16 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { marked } from 'marked'
import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
import { announcementApi, type Announcement } from '@/api/announcements'
import Button from '@/components/ui/button.vue'
import { Dialog } from '@/components/ui'
import AppShell from '@/components/layout/AppShell.vue'
import SidebarNav from '@/components/layout/SidebarNav.vue'
import HeaderLogo from '@/components/HeaderLogo.vue'
@@ -393,6 +433,7 @@ import {
Wallet,
CreditCard,
Package,
Gift,
Menu,
X,
Puzzle,
@@ -406,6 +447,7 @@ import {
import GithubIcon from '@/components/icons/GithubIcon.vue'
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
import { sanitizeMarkdown } from '@/utils/sanitize'
const router = useRouter()
const route = useRoute()
@@ -418,6 +460,15 @@ const isAdmin = computed(() => authStore.user?.role === 'admin')
const showAuthError = ref(false)
const mobileMenuOpen = ref(false)
const requiredAnnouncements = ref<Announcement[]>([])
const acknowledgingRequiredAnnouncement = ref(false)
const requiredAnnouncementOpen = computed({
get: () => requiredAnnouncements.value.length > 0,
set: (value) => {
if (value) void loadRequiredAnnouncements()
}
})
const currentRequiredAnnouncement = computed(() => requiredAnnouncements.value[0] ?? null)
// 更新检查相关
const showUpdateDialog = ref(false)
@@ -559,10 +610,45 @@ watch(
() => [authStore.user, authStore.token] as const,
() => {
showAuthError.value = !!authStore.user && !authStore.token
if (authStore.user && authStore.token) {
void loadRequiredAnnouncements()
} else {
requiredAnnouncements.value = []
}
},
{ immediate: true }
)
async function loadRequiredAnnouncements() {
if (!authStore.user || !authStore.token) return
try {
const response = await announcementApi.getRequiredUnreadAnnouncements()
requiredAnnouncements.value = response.items.filter(item => item.requires_ack && !item.is_read)
} catch {
requiredAnnouncements.value = []
}
}
function renderRequiredAnnouncement(content: string): string {
return sanitizeMarkdown(marked(content || '') as string)
}
function formatRequiredAnnouncementDate(value: string): string {
return new Date(value).toLocaleString('zh-CN')
}
async function acknowledgeRequiredAnnouncement() {
const announcement = currentRequiredAnnouncement.value
if (!announcement) return
acknowledgingRequiredAnnouncement.value = true
try {
await announcementApi.markAsRead(announcement.id)
requiredAnnouncements.value = requiredAnnouncements.value.slice(1)
} finally {
acknowledgingRequiredAnnouncement.value = false
}
}
onMounted(() => {
window.addEventListener('storage', handleStorageChange)
document.addEventListener('visibilitychange', handleVisibilityChange)
@@ -573,6 +659,7 @@ onMounted(() => {
moduleStore.fetchModules()
}
void loadVersionStatus()
void loadRequiredAnnouncements()
// 延迟检查更新,避免影响页面加载
setTimeout(() => {
@@ -640,6 +727,7 @@ const navigation = computed(() => {
items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '套餐中心', href: '/dashboard/billing', icon: Package },
{ name: '我的邀请', href: '/dashboard/referral', icon: Gift },
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
]
}
@@ -701,6 +789,7 @@ const navigation = computed(() => {
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
{ name: '支付配置', href: '/admin/payment-gateways', icon: CreditCard },
{ name: '套餐管理', href: '/admin/billing-plans', icon: Package },
{ name: '邀请返利', href: '/admin/referrals', icon: Gift },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]

View File

@@ -18,6 +18,18 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
meta: { requiresAuth: false }
},
{
path: '/register',
name: 'RegisterEntry',
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
meta: { requiresAuth: false }
},
{
path: '/privacy-policy',
name: 'PrivacyPolicy',
component: () => importWithRetry(() => import('@/views/public/PrivacyPolicy.vue')),
meta: { requiresAuth: false }
},
{
path: '/guide',
@@ -132,6 +144,11 @@ const routes: RouteRecordRaw[] = [
name: 'BillingPlans',
component: () => importWithRetry(() => import('@/views/user/BillingPlans.vue'))
},
{
path: 'referral',
name: 'ReferralCenter',
component: () => importWithRetry(() => import('@/views/user/ReferralCenter.vue'))
},
{
path: 'models',
name: 'ModelCatalog',
@@ -179,6 +196,11 @@ const routes: RouteRecordRaw[] = [
name: 'BillingPlansManagement',
component: () => importWithRetry(() => import('@/views/admin/BillingPlansManagement.vue'))
},
{
path: 'referrals',
name: 'ReferralManagement',
component: () => importWithRetry(() => import('@/views/admin/ReferralManagement.vue'))
},
{
path: 'management-tokens',
name: 'AdminManagementTokens',

View File

@@ -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>

View File

@@ -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"

View File

@@ -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]

View File

@@ -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

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>