release: prepare v0.1.132

This commit is contained in:
kone
2026-05-15 22:33:43 +08:00
parent 41e60b20d6
commit b430cd4aa9
47 changed files with 1107 additions and 213 deletions
+2
View File
@@ -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[];
+11
View File
@@ -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,
+10
View File
@@ -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.',
+10
View File
@@ -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: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
+8
View File
@@ -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[]
+15
View File
@@ -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)
}
+6 -6
View File
@@ -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
+18
View File
@@ -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,
+59 -1
View File
@@ -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()
+24 -7
View File
@@ -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()
}