release: prepare v0.1.132
This commit is contained in:
@@ -329,6 +329,7 @@ export interface SystemSettings {
|
||||
affiliate_rebate_freeze_hours: number;
|
||||
affiliate_rebate_duration_days: number;
|
||||
affiliate_rebate_per_invitee_cap: number;
|
||||
affiliate_invite_balance_reward: number;
|
||||
default_concurrency: number;
|
||||
default_user_rpm_limit: number;
|
||||
default_subscriptions: DefaultSubscriptionSetting[];
|
||||
@@ -548,6 +549,7 @@ export interface UpdateSettingsRequest {
|
||||
affiliate_rebate_freeze_hours?: number;
|
||||
affiliate_rebate_duration_days?: number;
|
||||
affiliate_rebate_per_invitee_cap?: number;
|
||||
affiliate_invite_balance_reward?: number;
|
||||
default_concurrency?: number;
|
||||
default_user_rpm_limit?: number;
|
||||
default_subscriptions?: DefaultSubscriptionSetting[];
|
||||
|
||||
@@ -166,6 +166,16 @@ export async function updateBalance(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh registration IP location for a user.
|
||||
* @param id - User ID
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function refreshRegisterIPLocation(id: number): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>(`/admin/users/${id}/register-ip-location`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user concurrency
|
||||
* @param id - User ID
|
||||
@@ -304,6 +314,7 @@ export const usersAPI = {
|
||||
update,
|
||||
delete: deleteUser,
|
||||
updateBalance,
|
||||
refreshRegisterIPLocation,
|
||||
updateConcurrency,
|
||||
toggleStatus,
|
||||
getUserApiKeys,
|
||||
|
||||
@@ -1006,6 +1006,8 @@ export default {
|
||||
stats: {
|
||||
rebateRate: 'My Rebate Rate',
|
||||
rebateRateHint: 'What you earn each time an invitee recharges',
|
||||
inviteBalanceReward: 'Signup Balance Reward',
|
||||
inviteBalanceRewardHint: 'Credited to your balance when a new user signs up through your invite',
|
||||
invitedUsers: 'Invited Users',
|
||||
availableQuota: 'Available Rebate Quota',
|
||||
frozenQuota: 'Frozen',
|
||||
@@ -1033,6 +1035,7 @@ export default {
|
||||
tips: {
|
||||
title: 'How It Works',
|
||||
line1: 'Share your affiliate code or invite link with new users.',
|
||||
signupReward: 'When a new user signs up through your invite link, you receive {amount} in balance.',
|
||||
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
|
||||
line3: 'Transfer rebate quota to balance at any time.',
|
||||
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
|
||||
@@ -1729,6 +1732,9 @@ export default {
|
||||
copyPassword: 'Copy password',
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
fetchRegisterIpLocation: 'Fetch signup IP location',
|
||||
registerIpLocationUpdated: 'Signup IP location updated',
|
||||
failedToFetchRegisterIpLocation: 'Failed to fetch signup IP location',
|
||||
form: {
|
||||
rpmLimit: 'Requests Per Minute (RPM)',
|
||||
rpmLimitPlaceholder: '0 = unlimited',
|
||||
@@ -1740,6 +1746,8 @@ export default {
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
notes: 'Notes',
|
||||
registerIp: 'Signup IP',
|
||||
registerIpLocation: 'Signup Location',
|
||||
role: 'Role',
|
||||
groups: 'Groups',
|
||||
subscriptions: 'Subscriptions',
|
||||
@@ -5136,6 +5144,8 @@ export default {
|
||||
durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.',
|
||||
perInviteeCap: 'Per-Invitee Rebate Cap',
|
||||
perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.',
|
||||
inviteBalanceReward: 'Invite Signup Balance Reward',
|
||||
inviteBalanceRewardDesc: 'Fixed amount credited directly to the inviter balance after an invitee registers and binds. 0 = disabled.',
|
||||
customUsers: {
|
||||
title: 'Per-User Overrides',
|
||||
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
|
||||
|
||||
@@ -1010,6 +1010,8 @@ export default {
|
||||
stats: {
|
||||
rebateRate: '我的返利比例',
|
||||
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
|
||||
inviteBalanceReward: '邀请注册赠送余额',
|
||||
inviteBalanceRewardHint: '新用户通过你的邀请注册后直接进入余额',
|
||||
invitedUsers: '邀请人数',
|
||||
availableQuota: '可转返利额度',
|
||||
frozenQuota: '冻结中',
|
||||
@@ -1037,6 +1039,7 @@ export default {
|
||||
tips: {
|
||||
title: '使用说明',
|
||||
line1: '将邀请码或邀请链接分享给新用户。',
|
||||
signupReward: '新用户通过你的邀请链接注册后,你将获得 {amount} 余额奖励。',
|
||||
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
|
||||
line3: '返利额度可随时转入账户余额。',
|
||||
line4: '新产生的返利需要经过冻结期后才能提现。'
|
||||
@@ -1755,12 +1758,17 @@ export default {
|
||||
copyPassword: '复制密码',
|
||||
creating: '创建中...',
|
||||
updating: '更新中...',
|
||||
fetchRegisterIpLocation: '获取注册 IP 归属地',
|
||||
registerIpLocationUpdated: '注册 IP 归属地已更新',
|
||||
failedToFetchRegisterIpLocation: '获取注册 IP 归属地失败',
|
||||
columns: {
|
||||
user: '用户',
|
||||
id: 'ID',
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
notes: '备注',
|
||||
registerIp: '注册 IP',
|
||||
registerIpLocation: '注册归属地',
|
||||
role: '角色',
|
||||
groups: '分组',
|
||||
subscriptions: '订阅分组',
|
||||
@@ -5299,6 +5307,8 @@ export default {
|
||||
durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。',
|
||||
perInviteeCap: '单人返利上限',
|
||||
perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。',
|
||||
inviteBalanceReward: '邀请注册奖励余额',
|
||||
inviteBalanceRewardDesc: '被邀请用户成功注册并绑定后,直接进入邀请人账户余额的固定金额。0 = 关闭。',
|
||||
customUsers: {
|
||||
title: '专属用户配置',
|
||||
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
|
||||
|
||||
@@ -90,6 +90,12 @@ export interface User {
|
||||
rpm_limit?: number // User-level RPM cap (0 = unlimited); effective as fallback when group has no rpm_limit
|
||||
status: 'active' | 'disabled' // Account status
|
||||
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
|
||||
register_ip_address?: string
|
||||
register_ip_country?: string
|
||||
register_ip_country_code?: string
|
||||
register_ip_region?: string
|
||||
register_ip_city?: string
|
||||
register_ip_location?: string
|
||||
balance_notify_enabled: boolean
|
||||
balance_notify_threshold: number | null
|
||||
balance_notify_extra_emails: NotifyEmailEntry[]
|
||||
@@ -141,6 +147,8 @@ export interface UserAffiliateDetail {
|
||||
aff_quota: number
|
||||
aff_frozen_quota: number
|
||||
aff_history_quota: number
|
||||
/** 新用户通过邀请注册后,直接进入邀请人余额的固定金额。0 表示关闭。 */
|
||||
invite_balance_reward: number
|
||||
/** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
|
||||
effective_rebate_rate_percent: number
|
||||
invitees: AffiliateInvitee[]
|
||||
|
||||
@@ -44,3 +44,18 @@ export function isHomeHeaderMenuPlacement(
|
||||
export function getCustomMenuRoute(id: string): string {
|
||||
return `/custom/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
export function getHomeHeaderMenuHref(
|
||||
item: Pick<CustomMenuItem, 'id' | 'url' | 'page_slug'>,
|
||||
): string {
|
||||
if (item.page_slug || item.url?.startsWith('md:')) {
|
||||
return getCustomMenuRoute(item.id)
|
||||
}
|
||||
|
||||
const rawUrl = item.url?.trim()
|
||||
if (rawUrl) {
|
||||
return rawUrl
|
||||
}
|
||||
|
||||
return getCustomMenuRoute(item.id)
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@
|
||||
v-if="homeHeaderMenuItems.length > 0"
|
||||
class="hidden items-center gap-1 lg:flex"
|
||||
>
|
||||
<router-link
|
||||
<a
|
||||
v-for="item in homeHeaderMenuItems"
|
||||
:key="item.id"
|
||||
:to="customMenuRoute(item.id)"
|
||||
:href="customMenuHref(item)"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-gray-200/70 bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-dark-700/80 dark:bg-dark-900/70 dark:text-dark-100 dark:hover:border-primary-500/50 dark:hover:text-white"
|
||||
>
|
||||
<span
|
||||
@@ -64,7 +64,7 @@
|
||||
v-html="sanitizeSvg(item.icon_svg)"
|
||||
></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
@@ -431,7 +431,7 @@ import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
import {
|
||||
getCustomMenuRoute,
|
||||
getHomeHeaderMenuHref,
|
||||
isHomeHeaderMenuPlacement,
|
||||
normalizeCustomMenuItems,
|
||||
} from '@/utils/custom-menu'
|
||||
@@ -478,8 +478,8 @@ const userInitial = computed(() => {
|
||||
// Current year for footer
|
||||
const currentYear = computed(() => new Date().getFullYear())
|
||||
|
||||
function customMenuRoute(id: string) {
|
||||
return getCustomMenuRoute(id)
|
||||
function customMenuHref(item: { id: string; url: string; page_slug?: string }) {
|
||||
return getHomeHeaderMenuHref(item)
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
|
||||
@@ -4906,6 +4906,22 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.settings.features.affiliate.inviteBalanceReward') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.affiliate_invite_balance_reward"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.settings.features.affiliate.inviteBalanceRewardDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 专属用户管理 -->
|
||||
<div class="border-t border-gray-100 pt-6 dark:border-dark-700">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -6474,6 +6490,7 @@ const form = reactive<SettingsForm>({
|
||||
affiliate_rebate_freeze_hours: 0,
|
||||
affiliate_rebate_duration_days: 0,
|
||||
affiliate_rebate_per_invitee_cap: 0,
|
||||
affiliate_invite_balance_reward: 0,
|
||||
default_concurrency: 1,
|
||||
default_subscriptions: [],
|
||||
force_email_on_third_party_signup: false,
|
||||
@@ -7593,6 +7610,7 @@ async function saveSettings() {
|
||||
affiliate_rebate_freeze_hours: Math.max(0, Math.min(720, Number(form.affiliate_rebate_freeze_hours) || 0)),
|
||||
affiliate_rebate_duration_days: Math.max(0, Math.min(3650, Math.floor(Number(form.affiliate_rebate_duration_days) || 0))),
|
||||
affiliate_rebate_per_invitee_cap: Math.max(0, Number(form.affiliate_rebate_per_invitee_cap) || 0),
|
||||
affiliate_invite_balance_reward: Math.max(0, Number(form.affiliate_invite_balance_reward) || 0),
|
||||
default_concurrency: form.default_concurrency,
|
||||
default_subscriptions: normalizedDefaultSubscriptions,
|
||||
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
|
||||
|
||||
@@ -276,6 +276,39 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-register_ip="{ row }">
|
||||
<div class="max-w-[160px] truncate font-mono text-sm text-gray-700 dark:text-gray-300" :title="row.register_ip_address || '-'">
|
||||
{{ row.register_ip_address || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-register_ip_location="{ row }">
|
||||
<div class="max-w-[180px] text-sm">
|
||||
<div
|
||||
v-if="row.register_ip_location"
|
||||
class="truncate text-gray-700 dark:text-gray-300"
|
||||
:title="row.register_ip_location"
|
||||
>
|
||||
{{ row.register_ip_location }}
|
||||
</div>
|
||||
<button
|
||||
v-else-if="row.register_ip_address"
|
||||
type="button"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded border border-gray-200 text-gray-500 transition-colors hover:border-primary-300 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:text-dark-300 dark:hover:border-primary-500 dark:hover:text-primary-400"
|
||||
:disabled="refreshingRegisterIPUserIds.has(row.id)"
|
||||
:title="t('admin.users.fetchRegisterIpLocation')"
|
||||
@click.stop="refreshRegisterIPLocation(row)"
|
||||
>
|
||||
<Icon
|
||||
name="refresh"
|
||||
size="xs"
|
||||
:class="refreshingRegisterIPUserIds.has(row.id) ? 'animate-spin' : ''"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Dynamic attribute columns -->
|
||||
<template
|
||||
v-for="def in attributeDefinitions.filter(d => d.enabled)"
|
||||
@@ -703,6 +736,8 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'id', label: t('admin.users.columns.id'), sortable: true },
|
||||
{ key: 'username', label: t('admin.users.columns.username'), sortable: true },
|
||||
{ key: 'notes', label: t('admin.users.columns.notes'), sortable: false },
|
||||
{ key: 'register_ip', label: t('admin.users.columns.registerIp'), sortable: false },
|
||||
{ key: 'register_ip_location', label: t('admin.users.columns.registerIpLocation'), sortable: false },
|
||||
// Dynamic attribute columns
|
||||
...attributeColumns.value,
|
||||
{ key: 'role', label: t('admin.users.columns.role'), sortable: true },
|
||||
@@ -728,7 +763,7 @@ const toggleableColumns = computed(() =>
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
|
||||
// Default hidden columns (columns hidden by default on first load)
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'groups', 'subscriptions', 'usage', 'concurrency']
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'register_ip', 'register_ip_location', 'groups', 'subscriptions', 'usage', 'concurrency']
|
||||
const REMOVED_COLUMNS = new Set(['last_login_at'])
|
||||
const FORCED_VISIBLE_COLUMNS = new Set(['last_active_at'])
|
||||
|
||||
@@ -1124,6 +1159,7 @@ const balanceOperation = ref<'add' | 'subtract'>('add')
|
||||
// Balance History modal state
|
||||
const showBalanceHistoryModal = ref(false)
|
||||
const balanceHistoryUser = ref<AdminUser | null>(null)
|
||||
const refreshingRegisterIPUserIds = ref(new Set<number>())
|
||||
|
||||
// 计算剩余天数
|
||||
const getDaysRemaining = (expiresAt: string): number => {
|
||||
@@ -1211,6 +1247,28 @@ const loadUsers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshRegisterIPLocation = async (user: AdminUser) => {
|
||||
if (!user?.id || refreshingRegisterIPUserIds.value.has(user.id)) {
|
||||
return
|
||||
}
|
||||
refreshingRegisterIPUserIds.value = new Set(refreshingRegisterIPUserIds.value).add(user.id)
|
||||
try {
|
||||
const updated = await adminAPI.users.refreshRegisterIPLocation(user.id)
|
||||
const index = users.value.findIndex(item => item.id === updated.id)
|
||||
if (index !== -1) {
|
||||
users.value.splice(index, 1, { ...users.value[index], ...updated })
|
||||
}
|
||||
appStore.showSuccess(t('admin.users.registerIpLocationUpdated'))
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.detail || error.response?.data?.message || error.message || t('admin.users.failedToFetchRegisterIpLocation')
|
||||
appStore.showError(message)
|
||||
} finally {
|
||||
const next = new Set(refreshingRegisterIPUserIds.value)
|
||||
next.delete(user.id)
|
||||
refreshingRegisterIPUserIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
|
||||
@@ -147,6 +147,7 @@ describe('admin UsersView', () => {
|
||||
const visibleColumns = columns.split(',')
|
||||
expect(visibleColumns.slice(-4, -1)).toEqual(['last_active_at', 'last_used_at', 'created_at'])
|
||||
expect(visibleColumns).not.toContain('last_login_at')
|
||||
expect(visibleColumns).not.toContain('register_ip')
|
||||
|
||||
await wrapper.get('[data-test="sort-last-used"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div class="card p-5">
|
||||
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
|
||||
<Icon name="dollar" size="sm" class="text-primary-500" />
|
||||
@@ -21,6 +21,18 @@
|
||||
{{ t('affiliate.stats.rebateRateHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
|
||||
<Icon name="gift" size="sm" class="text-emerald-500" />
|
||||
{{ t('affiliate.stats.inviteBalanceReward') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
{{ formatCurrency(detail.invite_balance_reward || 0) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ t('affiliate.stats.inviteBalanceRewardHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
@@ -74,12 +86,15 @@
|
||||
|
||||
<div class="mt-5 rounded-xl border border-primary-200 bg-primary-50 p-4 dark:border-primary-900/40 dark:bg-primary-900/20">
|
||||
<p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300">
|
||||
<li>1. {{ t('affiliate.tips.line1') }}</li>
|
||||
<li>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
|
||||
<li>3. {{ t('affiliate.tips.line3') }}</li>
|
||||
<li v-if="detail.aff_frozen_quota > 0">4. {{ t('affiliate.tips.line4') }}</li>
|
||||
</ul>
|
||||
<ol class="mt-2 list-decimal space-y-1 pl-5 text-sm text-primary-700 dark:text-primary-300">
|
||||
<li>{{ t('affiliate.tips.line1') }}</li>
|
||||
<li v-if="hasInviteBalanceReward">
|
||||
{{ t('affiliate.tips.signupReward', { amount: formatCurrency(detail.invite_balance_reward) }) }}
|
||||
</li>
|
||||
<li>{{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
|
||||
<li>{{ t('affiliate.tips.line3') }}</li>
|
||||
<li v-if="detail.aff_frozen_quota > 0">{{ t('affiliate.tips.line4') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,6 +190,8 @@ const formattedRebateRate = computed(() => {
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toString()
|
||||
})
|
||||
|
||||
const hasInviteBalanceReward = computed(() => (detail.value?.invite_balance_reward ?? 0) > 0)
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user