Merge branch 'main' into test

This commit is contained in:
yangjianbo
2026-02-03 22:48:04 +08:00
235 changed files with 25155 additions and 7955 deletions

View File

@@ -0,0 +1,538 @@
<template>
<AppLayout>
<TablePageLayout>
<template #actions>
<div class="flex justify-end gap-3">
<button
@click="loadAnnouncements"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="openCreateDialog" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-1" />
{{ t('admin.announcements.createAnnouncement') }}
</button>
</div>
</template>
<template #filters>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="max-w-md flex-1">
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.announcements.searchAnnouncements')"
class="input"
@input="handleSearch"
/>
</div>
<div class="flex gap-2">
<Select
v-model="filters.status"
:options="statusFilterOptions"
class="w-40"
@change="handleStatusChange"
/>
</div>
</div>
</template>
<template #table>
<DataTable :columns="columns" :data="announcements" :loading="loading">
<template #cell-title="{ value, row }">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900 dark:text-white">{{ value }}</span>
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
<span>#{{ row.id }}</span>
<span class="text-gray-300 dark:text-dark-700">·</span>
<span>{{ formatDateTime(row.created_at) }}</span>
</div>
</div>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active'
? 'badge-success'
: value === 'draft'
? 'badge-gray'
: 'badge-warning'
]"
>
{{ statusLabel(value) }}
</span>
</template>
<template #cell-targeting="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ targetingSummary(row.targeting) }}
</span>
</template>
<template #cell-timeRange="{ row }">
<div class="text-sm text-gray-600 dark:text-gray-300">
<div>
<span class="font-medium">{{ t('admin.announcements.form.startsAt') }}:</span>
<span class="ml-1">{{ row.starts_at ? formatDateTime(row.starts_at) : t('admin.announcements.timeImmediate') }}</span>
</div>
<div class="mt-0.5">
<span class="font-medium">{{ t('admin.announcements.form.endsAt') }}:</span>
<span class="ml-1">{{ row.ends_at ? formatDateTime(row.ends_at) : t('admin.announcements.timeNever') }}</span>
</div>
</div>
</template>
<template #cell-createdAt="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center space-x-1">
<button
@click="openReadStatus(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title="t('admin.announcements.readStatus')"
>
<Icon name="eye" size="sm" />
</button>
<button
@click="openEditDialog(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title="t('common.edit')"
>
<Icon name="edit" size="sm" />
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('empty.noData')"
:description="t('admin.announcements.failedToLoad')"
:action-text="t('admin.announcements.createAnnouncement')"
@action="openCreateDialog"
/>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create/Edit Dialog -->
<BaseDialog
:show="showEditDialog"
:title="isEditing ? t('admin.announcements.editAnnouncement') : t('admin.announcements.createAnnouncement')"
width="wide"
@close="closeEdit"
>
<form id="announcement-form" @submit.prevent="handleSave" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.announcements.form.title') }}</label>
<input v-model="form.title" type="text" class="input" required />
</div>
<div>
<label class="input-label">{{ t('admin.announcements.form.content') }}</label>
<textarea v-model="form.content" rows="6" class="input" required></textarea>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.announcements.form.status') }}</label>
<Select v-model="form.status" :options="statusOptions" />
</div>
<div></div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.announcements.form.startsAt') }}</label>
<input v-model="form.starts_at_str" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.announcements.form.startsAtHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.announcements.form.endsAt') }}</label>
<input v-model="form.ends_at_str" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.announcements.form.endsAtHint') }}</p>
</div>
</div>
<AnnouncementTargetingEditor
v-model="form.targeting"
:groups="subscriptionGroups"
/>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="closeEdit" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" form="announcement-form" :disabled="saving" class="btn btn-primary">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.announcements.deleteAnnouncement')"
:message="t('admin.announcements.deleteConfirm')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
danger
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Read Status Dialog -->
<AnnouncementReadStatusDialog
:show="showReadStatusDialog"
:announcement-id="readStatusAnnouncementId"
@close="showReadStatusDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import type { AdminGroup, Announcement, AnnouncementTargeting } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const announcements = ref<Announcement[]>([])
const loading = ref(false)
const filters = reactive({
status: '',
})
const searchQuery = ref('')
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const statusFilterOptions = computed(() => [
{ value: '', label: t('admin.announcements.allStatus') },
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
])
const statusOptions = computed(() => [
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
])
const columns = computed<Column[]>(() => [
{ key: 'title', label: t('admin.announcements.columns.title') },
{ key: 'status', label: t('admin.announcements.columns.status') },
{ key: 'targeting', label: t('admin.announcements.columns.targeting') },
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
{ key: 'actions', label: t('admin.announcements.columns.actions') }
])
const statusLabel = (status: string) => {
if (status === 'draft') return t('admin.announcements.statusLabels.draft')
if (status === 'active') return t('admin.announcements.statusLabels.active')
if (status === 'archived') return t('admin.announcements.statusLabels.archived')
return status
}
const targetingSummary = (targeting: AnnouncementTargeting) => {
const anyOf = targeting?.any_of ?? []
if (!anyOf || anyOf.length === 0) return t('admin.announcements.targetingSummaryAll')
return t('admin.announcements.targetingSummaryCustom', { groups: anyOf.length })
}
// ===== CRUD / list =====
let currentController: AbortController | null = null
async function loadAnnouncements() {
if (currentController) currentController.abort()
currentController = new AbortController()
try {
loading.value = true
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
status: filters.status || undefined,
search: searchQuery.value || undefined
})
announcements.value = res.items
pagination.total = res.total
pagination.pages = res.pages
pagination.page = res.page
pagination.page_size = res.page_size
} catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return
console.error('Error loading announcements:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
pagination.page = page
loadAnnouncements()
}
function handlePageSizeChange(pageSize: number) {
pagination.page_size = pageSize
pagination.page = 1
loadAnnouncements()
}
function handleStatusChange() {
pagination.page = 1
loadAnnouncements()
}
let searchDebounceTimer: number | null = null
function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
pagination.page = 1
loadAnnouncements()
}, 300)
}
// ===== Create/Edit dialog =====
const showEditDialog = ref(false)
const saving = ref(false)
const editingAnnouncement = ref<Announcement | null>(null)
const isEditing = computed(() => !!editingAnnouncement.value)
const form = reactive({
title: '',
content: '',
status: 'draft',
starts_at_str: '',
ends_at_str: '',
targeting: { any_of: [] } as AnnouncementTargeting
})
const subscriptionGroups = ref<AdminGroup[]>([])
async function loadSubscriptionGroups() {
try {
const all = await adminAPI.groups.getAll()
subscriptionGroups.value = (all || []).filter((g) => g.subscription_type === 'subscription')
} catch (error: any) {
console.error('Error loading groups:', error)
// not fatal
}
}
function resetForm() {
form.title = ''
form.content = ''
form.status = 'draft'
form.starts_at_str = ''
form.ends_at_str = ''
form.targeting = { any_of: [] }
}
function fillFormFromAnnouncement(a: Announcement) {
form.title = a.title
form.content = a.content
form.status = a.status
// Backend returns RFC3339 strings
form.starts_at_str = a.starts_at ? formatDateTimeLocalInput(Math.floor(new Date(a.starts_at).getTime() / 1000)) : ''
form.ends_at_str = a.ends_at ? formatDateTimeLocalInput(Math.floor(new Date(a.ends_at).getTime() / 1000)) : ''
form.targeting = a.targeting ?? { any_of: [] }
}
function openCreateDialog() {
editingAnnouncement.value = null
resetForm()
showEditDialog.value = true
}
function openEditDialog(row: Announcement) {
editingAnnouncement.value = row
fillFormFromAnnouncement(row)
showEditDialog.value = true
}
function closeEdit() {
showEditDialog.value = false
editingAnnouncement.value = null
}
function buildCreatePayload() {
const startsAt = parseDateTimeLocalInput(form.starts_at_str)
const endsAt = parseDateTimeLocalInput(form.ends_at_str)
return {
title: form.title,
content: form.content,
status: form.status as any,
targeting: form.targeting,
starts_at: startsAt ?? undefined,
ends_at: endsAt ?? undefined
}
}
function buildUpdatePayload(original: Announcement) {
const payload: any = {}
if (form.title !== original.title) payload.title = form.title
if (form.content !== original.content) payload.content = form.content
if (form.status !== original.status) payload.status = form.status
// starts_at / ends_at: distinguish unchanged vs clear(0) vs set
const originalStarts = original.starts_at ? Math.floor(new Date(original.starts_at).getTime() / 1000) : null
const originalEnds = original.ends_at ? Math.floor(new Date(original.ends_at).getTime() / 1000) : null
const newStarts = parseDateTimeLocalInput(form.starts_at_str)
const newEnds = parseDateTimeLocalInput(form.ends_at_str)
if (newStarts !== originalStarts) {
payload.starts_at = newStarts === null ? 0 : newStarts
}
if (newEnds !== originalEnds) {
payload.ends_at = newEnds === null ? 0 : newEnds
}
// targeting: do shallow compare by JSON
if (JSON.stringify(form.targeting ?? {}) !== JSON.stringify(original.targeting ?? {})) {
payload.targeting = form.targeting
}
return payload
}
async function handleSave() {
// Frontend validation for targeting (to avoid ANNOUNCEMENT_INVALID_TARGET)
const anyOf = form.targeting?.any_of ?? []
if (anyOf.length > 50) {
appStore.showError(t('admin.announcements.failedToCreate'))
return
}
for (const g of anyOf) {
const allOf = g?.all_of ?? []
if (allOf.length > 50) {
appStore.showError(t('admin.announcements.failedToCreate'))
return
}
}
saving.value = true
try {
if (!editingAnnouncement.value) {
const payload = buildCreatePayload()
await adminAPI.announcements.create(payload)
appStore.showSuccess(t('common.success'))
showEditDialog.value = false
await loadAnnouncements()
return
}
const original = editingAnnouncement.value
const payload = buildUpdatePayload(original)
await adminAPI.announcements.update(original.id, payload)
appStore.showSuccess(t('common.success'))
showEditDialog.value = false
editingAnnouncement.value = null
await loadAnnouncements()
} catch (error: any) {
console.error('Failed to save announcement:', error)
appStore.showError(error.response?.data?.detail || (editingAnnouncement.value ? t('admin.announcements.failedToUpdate') : t('admin.announcements.failedToCreate')))
} finally {
saving.value = false
}
}
// ===== Delete =====
const showDeleteDialog = ref(false)
const deletingAnnouncement = ref<Announcement | null>(null)
function handleDelete(row: Announcement) {
deletingAnnouncement.value = row
showDeleteDialog.value = true
}
async function confirmDelete() {
if (!deletingAnnouncement.value) return
try {
await adminAPI.announcements.delete(deletingAnnouncement.value.id)
appStore.showSuccess(t('common.success'))
showDeleteDialog.value = false
deletingAnnouncement.value = null
await loadAnnouncements()
} catch (error: any) {
console.error('Failed to delete announcement:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToDelete'))
}
}
// ===== Read status =====
const showReadStatusDialog = ref(false)
const readStatusAnnouncementId = ref<number | null>(null)
function openReadStatus(row: Announcement) {
readStatusAnnouncementId.value = row.id
showReadStatusDialog.value = true
}
onMounted(async () => {
await loadSubscriptionGroups()
await loadAnnouncements()
})
</script>

View File

@@ -240,9 +240,73 @@
v-model="createForm.platform"
:options="platformOptions"
data-tour="group-form-platform"
@change="createForm.copy_accounts_from_group_ids = []"
/>
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
</div>
<!-- 从分组复制账号 -->
<div v-if="copyAccountsGroupOptions.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div v-if="createForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="groupId in createForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptions.find(o => o.value === groupId)?.label || `#${groupId}` }}
<button
type="button"
@click="createForm.copy_accounts_from_group_ids = createForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !createForm.copy_accounts_from_group_ids.includes(val)) {
createForm.copy_accounts_from_group_ids.push(val)
}
(e.target as HTMLSelectElement).value = ''
}"
>
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
<option
v-for="opt in copyAccountsGroupOptions"
:key="opt.value"
:value="opt.value"
:disabled="createForm.copy_accounts_from_group_ids.includes(opt.value)"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input
@@ -738,6 +802,69 @@
/>
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
</div>
<!-- 从分组复制账号编辑时 -->
<div v-if="copyAccountsGroupOptionsForEdit.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltipEdit') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div v-if="editForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="groupId in editForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptionsForEdit.find(o => o.value === groupId)?.label || `#${groupId}` }}
<button
type="button"
@click="editForm.copy_accounts_from_group_ids = editForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !editForm.copy_accounts_from_group_ids.includes(val)) {
editForm.copy_accounts_from_group_ids.push(val)
}
(e.target as HTMLSelectElement).value = ''
}"
>
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
<option
v-for="opt in copyAccountsGroupOptionsForEdit"
:key="opt.value"
:value="opt.value"
:disabled="editForm.copy_accounts_from_group_ids.includes(opt.value)"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hintEdit') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input
@@ -1320,6 +1447,29 @@ const fallbackGroupOptionsForEdit = computed(() => {
return options
})
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const copyAccountsGroupOptions = computed(() => {
const eligibleGroups = groups.value.filter(
(g) => g.platform === createForm.platform && (g.account_count || 0) > 0
)
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`
}))
})
// 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身
const copyAccountsGroupOptionsForEdit = computed(() => {
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) => g.platform === editForm.platform && (g.account_count || 0) > 0 && g.id !== currentId
)
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`
}))
})
const groups = ref<AdminGroup[]>([])
const loading = ref(false)
const searchQuery = ref('')
@@ -1367,7 +1517,9 @@ const createForm = reactive({
claude_code_only: false,
fallback_group_id: null as number | null,
// 模型路由开关
model_routing_enabled: false
model_routing_enabled: false,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
})
// 简单账号类型(用于模型路由选择)
@@ -1543,7 +1695,9 @@ const editForm = reactive({
claude_code_only: false,
fallback_group_id: null as number | null,
// 模型路由开关
model_routing_enabled: false
model_routing_enabled: false,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
})
// 根据分组类型返回不同的删除确认消息
@@ -1629,6 +1783,7 @@ const closeCreateModal = () => {
createForm.sora_video_price_per_request_hd = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
createForm.copy_accounts_from_group_ids = []
createModelRoutingRules.value = []
}
@@ -1683,6 +1838,7 @@ const handleEdit = async (group: AdminGroup) => {
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
editForm.model_routing_enabled = group.model_routing_enabled || false
editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
showEditModal.value = true
@@ -1692,6 +1848,7 @@ const closeEditModal = () => {
showEditModal.value = false
editingGroup.value = null
editModelRoutingRules.value = []
editForm.copy_accounts_from_group_ids = []
}
const handleUpdateGroup = async () => {

View File

@@ -213,7 +213,7 @@
<Select v-model="generateForm.type" :options="typeOptions" />
</div>
<!-- 余额/并发类型显示数值输入 -->
<div v-if="generateForm.type !== 'subscription'">
<div v-if="generateForm.type !== 'subscription' && generateForm.type !== 'invitation'">
<label class="input-label">
{{
generateForm.type === 'balance'
@@ -230,6 +230,12 @@
class="input"
/>
</div>
<!-- 邀请码类型显示提示信息 -->
<div v-if="generateForm.type === 'invitation'" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.redeem.invitationHint') }}
</p>
</div>
<!-- 订阅类型显示分组选择和有效天数 -->
<template v-if="generateForm.type === 'subscription'">
<div>
@@ -387,7 +393,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
@@ -499,14 +505,16 @@ const columns = computed<Column[]>(() => [
const typeOptions = computed(() => [
{ value: 'balance', label: t('admin.redeem.balance') },
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
{ value: 'subscription', label: t('admin.redeem.subscription') }
{ value: 'subscription', label: t('admin.redeem.subscription') },
{ value: 'invitation', label: t('admin.redeem.invitation') }
])
const filterTypeOptions = computed(() => [
{ value: '', label: t('admin.redeem.allTypes') },
{ value: 'balance', label: t('admin.redeem.balance') },
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
{ value: 'subscription', label: t('admin.redeem.subscription') }
{ value: 'subscription', label: t('admin.redeem.subscription') },
{ value: 'invitation', label: t('admin.redeem.invitation') }
])
const filterStatusOptions = computed(() => [
@@ -546,6 +554,18 @@ const generateForm = reactive({
validity_days: 30
})
// 监听类型变化,邀请码类型时自动设置 value 为 0
watch(
() => generateForm.type,
(newType) => {
if (newType === 'invitation') {
generateForm.value = 0
} else if (generateForm.value === 0) {
generateForm.value = 10
}
}
)
const loadCodes = async () => {
if (abortController) {
abortController.abort()

View File

@@ -338,6 +338,62 @@
</div>
<Toggle v-model="form.promo_code_enabled" />
</div>
<!-- Invitation Code -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.invitationCode')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.invitationCodeHint') }}
</p>
</div>
<Toggle v-model="form.invitation_code_enabled" />
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<div
v-if="form.email_verify_enabled"
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.passwordReset')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.passwordResetHint') }}
</p>
</div>
<Toggle v-model="form.password_reset_enabled" />
</div>
<!-- TOTP 2FA -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.totp')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.totpHint') }}
</p>
<!-- Warning when encryption key not configured -->
<p
v-if="!form.totp_encryption_key_configured"
class="mt-2 text-sm text-amber-600 dark:text-amber-400"
>
{{ t('admin.settings.registration.totpKeyNotConfigured') }}
</p>
</div>
<Toggle
v-model="form.totp_enabled"
:disabled="!form.totp_encryption_key_configured"
/>
</div>
</div>
</div>
@@ -894,6 +950,51 @@
</div>
</div>
<!-- Purchase Subscription Page -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.purchase.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.description') }}
</p>
</div>
<div class="space-y-6 p-6">
<!-- Enable Toggle -->
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.purchase.enabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.enabledHint') }}
</p>
</div>
<Toggle v-model="form.purchase_subscription_enabled" />
</div>
<!-- URL -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.purchase.url') }}
</label>
<input
v-model="form.purchase_subscription_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.purchase.urlPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.urlHint') }}
</p>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.settings.purchase.iframeWarning') }}
</p>
</div>
</div>
</div>
<!-- Send Test Email - Only show when email verification is enabled -->
<div v-if="form.email_verify_enabled" class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
@@ -1029,6 +1130,10 @@ const form = reactive<SettingsForm>({
registration_enabled: true,
email_verify_enabled: false,
promo_code_enabled: true,
invitation_code_enabled: false,
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
default_balance: 0,
default_concurrency: 1,
site_name: 'Sub2API',
@@ -1039,6 +1144,8 @@ const form = reactive<SettingsForm>({
doc_url: '',
home_content: '',
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
smtp_host: '',
smtp_port: 587,
smtp_username: '',
@@ -1152,6 +1259,9 @@ async function saveSettings() {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
promo_code_enabled: form.promo_code_enabled,
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
default_balance: form.default_balance,
default_concurrency: form.default_concurrency,
site_name: form.site_name,
@@ -1162,6 +1272,8 @@ async function saveSettings() {
doc_url: form.doc_url,
home_content: form.home_content,
hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url,
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_username: form.smtp_username,

View File

@@ -154,7 +154,13 @@
<!-- Subscriptions Table -->
<template #table>
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
<DataTable
:columns="columns"
:data="subscriptions"
:loading="loading"
:server-side-sort="true"
@sort="handleSort"
>
<template #cell-user="{ row }">
<div class="flex items-center gap-2">
<div
@@ -357,7 +363,7 @@
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
v-if="row.status === 'active'"
v-if="row.status === 'active' || row.status === 'expired'"
@click="handleExtend(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
@@ -683,9 +689,9 @@ const allColumns = computed<Column[]>(() => [
label: userColumnMode.value === 'email'
? t('admin.subscriptions.columns.user')
: t('admin.users.columns.username'),
sortable: true
sortable: false
},
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: false },
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
@@ -785,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const filters = reactive({
status: '',
status: 'active',
group_id: '',
user_id: null as number | null
})
// Sorting state
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const pagination = reactive({
page: 1,
page_size: 20,
@@ -854,7 +867,9 @@ const loadSubscriptions = async () => {
{
status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
user_id: filters.user_id || undefined
user_id: filters.user_id || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
},
{
signal
@@ -995,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadSubscriptions()
}
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadSubscriptions()
}
const closeAssignModal = () => {
showAssignModal.value = false
assignForm.user_id = null
@@ -1053,11 +1075,11 @@ const closeExtendModal = () => {
const handleExtendSubscription = async () => {
if (!extendingSubscription.value) return
// 前端验证:调整后剩余天数必须 > 0
// 前端验证:调整后的过期时间必须在未来
if (extendingSubscription.value.expires_at) {
const currentDaysRemaining = getDaysRemaining(extendingSubscription.value.expires_at) ?? 0
const newDaysRemaining = currentDaysRemaining + extendForm.days
if (newDaysRemaining <= 0) {
const expiresAt = new Date(extendingSubscription.value.expires_at)
const newExpiresAt = new Date(expiresAt.getTime() + extendForm.days * 24 * 60 * 60 * 1000)
if (newExpiresAt <= new Date()) {
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
return
}

View File

@@ -35,12 +35,13 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import { formatReasoningEffort } from '@/utils/format'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
@@ -104,7 +105,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx')
const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('admin.usage.group'),
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
@@ -120,6 +121,7 @@ const exportToExcel = async () => {
log.api_key?.name || '',
log.account?.name || '',
log.model,
formatReasoningEffort(log.reasoning_effort),
log.group?.name || '',
log.stream ? t('usage.stream') : t('usage.sync'),
log.input_tokens,

View File

@@ -300,8 +300,29 @@
</span>
</template>
<template #cell-balance="{ value }">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
<template #cell-balance="{ value, row }">
<div class="flex items-center gap-2">
<div class="group relative">
<button
class="font-medium text-gray-900 underline decoration-dashed decoration-gray-300 underline-offset-4 transition-colors hover:text-primary-600 dark:text-white dark:decoration-dark-500 dark:hover:text-primary-400"
@click="handleBalanceHistory(row)"
>
${{ value.toFixed(2) }}
</button>
<!-- Instant tooltip -->
<div class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover:opacity-100 dark:bg-dark-600">
{{ t('admin.users.balanceHistoryTip') }}
<div class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-dark-600"></div>
</div>
</div>
<button
@click.stop="handleDeposit(row)"
class="rounded px-2 py-0.5 text-xs font-medium text-emerald-600 transition-colors hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20"
:title="t('admin.users.deposit')"
>
{{ t('admin.users.deposit') }}
</button>
</div>
</template>
<template #cell-usage="{ row }">
@@ -456,6 +477,15 @@
{{ t('admin.users.withdraw') }}
</button>
<!-- Balance History -->
<button
@click="handleBalanceHistory(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<Icon name="dollar" size="sm" class="text-gray-400" :stroke-width="2" />
{{ t('admin.users.balanceHistory') }}
</button>
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<!-- Delete (not for admin) -->
@@ -479,6 +509,7 @@
<UserApiKeysModal :show="showApiKeysModal" :user="viewingUser" @close="closeApiKeysModal" />
<UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" />
<UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @success="loadUsers" />
<UserBalanceHistoryModal :show="showBalanceHistoryModal" :user="balanceHistoryUser" @close="closeBalanceHistoryModal" @deposit="handleDepositFromHistory" @withdraw="handleWithdrawFromHistory" />
<UserAttributesConfigModal :show="showAttributesModal" @close="handleAttributesModalClose" />
</AppLayout>
</template>
@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue'
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
const appStore = useAppStore()
@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
const balanceUser = ref<AdminUser | null>(null)
const balanceOperation = ref<'add' | 'subtract'>('add')
// Balance History modal state
const showBalanceHistoryModal = ref(false)
const balanceHistoryUser = ref<AdminUser | null>(null)
// 计算剩余天数
const getDaysRemaining = (expiresAt: string): number => {
const now = new Date()
@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
balanceUser.value = null
}
const handleBalanceHistory = (user: AdminUser) => {
balanceHistoryUser.value = user
showBalanceHistoryModal.value = true
}
const closeBalanceHistoryModal = () => {
showBalanceHistoryModal.value = false
balanceHistoryUser.value = null
}
// Handle deposit from balance history modal
const handleDepositFromHistory = () => {
if (balanceHistoryUser.value) {
handleDeposit(balanceHistoryUser.value)
}
}
// Handle withdraw from balance history modal
const handleWithdrawFromHistory = () => {
if (balanceHistoryUser.value) {
handleWithdraw(balanceHistoryUser.value)
}
}
// 滚动时关闭菜单
const handleScroll = () => {
closeActionMenu()

View File

@@ -49,6 +49,7 @@ interface SummaryRow {
total_accounts: number
available_accounts: number
rate_limited_accounts: number
scope_rate_limit_count?: Record<string, number>
error_accounts: number
// 并发统计
total_concurrency: number
@@ -102,6 +103,7 @@ const platformRows = computed((): SummaryRow[] => {
total_accounts: totalAccounts,
available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count),
scope_rate_limit_count: avail.scope_rate_limit_count,
error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency,
@@ -141,6 +143,7 @@ const groupRows = computed((): SummaryRow[] => {
total_accounts: totalAccounts,
available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count),
scope_rate_limit_count: avail.scope_rate_limit_count,
error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency,
@@ -269,6 +272,15 @@ function formatDuration(seconds: number): string {
return `${hours}h`
}
function formatScopeName(scope: string): string {
const names: Record<string, string> = {
claude: 'Claude',
gemini_text: 'Gemini',
gemini_image: 'Image'
}
return names[scope] || scope
}
watch(
() => realtimeEnabled.value,
async (enabled) => {
@@ -387,6 +399,18 @@ watch(
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
</span>
<!-- Scope 限流 ( Antigravity) -->
<template v-if="row.scope_rate_limit_count && Object.keys(row.scope_rate_limit_count).length > 0">
<span
v-for="(count, scope) in row.scope_rate_limit_count"
:key="scope"
class="rounded-full bg-orange-100 px-1.5 py-0.5 font-semibold text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
:title="t('admin.ops.concurrency.scopeRateLimitedTooltip', { scope, count })"
>
{{ formatScopeName(scope as string) }} {{ count }}
</span>
</template>
<!-- 异常账号 -->
<span
v-if="row.error_accounts > 0"

View File

@@ -505,6 +505,16 @@ async function saveAllSettings() {
</div>
<Toggle v-model="advancedSettings.ignore_no_available_accounts" />
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreInvalidApiKeyErrors') }}</label>
<p class="mt-1 text-xs text-gray-500">
{{ t('admin.ops.settings.ignoreInvalidApiKeyErrorsHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.ignore_invalid_api_key_errors" />
</div>
</div>
<!-- Auto Refresh -->