mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-02 16:32:14 +00:00
Merge remote-tracking branch 'origin/main' into fix/composite-route-prefix-passthrough
This commit is contained in:
commit
1614ae9c99
168 files changed
+7169
-412
No files matched your search
@@ -38,7 +38,7 @@ describe('admin Ollama Cloud usage API', () => {
|
||||
})
|
||||
|
||||
it('uses dedicated global settings endpoints', async () => {
|
||||
const settings = { enabled: false, interval_minutes: 60 }
|
||||
const settings = { enabled: false, interval_minutes: 60, debounce_minutes: 1 }
|
||||
get.mockResolvedValueOnce({ data: settings })
|
||||
put.mockResolvedValueOnce({ data: settings })
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ import type {
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
export interface LiveCapability {
|
||||
supported: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List all groups with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
@@ -81,6 +86,12 @@ export async function getByPlatform(platform: GroupPlatform): Promise<AdminGroup
|
||||
return getAll(platform)
|
||||
}
|
||||
|
||||
/** 获取当前 Sub2API 服务端的 Live 运行环境能力。 */
|
||||
export async function getLiveCapability(): Promise<LiveCapability> {
|
||||
const { data } = await apiClient.get<LiveCapability>('/admin/groups/live-capability')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group by ID
|
||||
* @param id - Group ID
|
||||
@@ -467,6 +478,7 @@ export const groupsAPI = {
|
||||
getAll,
|
||||
getByPlatform,
|
||||
getAllIncludingInactive,
|
||||
getLiveCapability,
|
||||
getById,
|
||||
getModelsListCandidates,
|
||||
create,
|
||||
|
||||
@@ -225,6 +225,7 @@ const formatScopeName = (scope: string): string => {
|
||||
'claude-opus-4-6-thinking': 'COpus46T',
|
||||
'claude-opus-4-7': 'COpus47',
|
||||
'claude-opus-4-8': 'COpus48',
|
||||
'claude-opus-5': 'COpus5',
|
||||
'claude-sonnet-4-6': 'CSon46',
|
||||
'claude-sonnet-4-5': 'CSon45',
|
||||
'claude-sonnet-4-5-thinking': 'CSon45T',
|
||||
|
||||
@@ -263,6 +263,7 @@ const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.a
|
||||
const requestTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
{ value: 'ws_v2', label: t('usage.ws') },
|
||||
{ value: 'live', label: t('usage.live') },
|
||||
{ value: 'stream', label: t('usage.stream') },
|
||||
{ value: 'sync', label: t('usage.sync') },
|
||||
{ value: 'cyber', label: t('usage.cyber') }
|
||||
|
||||
@@ -583,6 +583,7 @@ const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
||||
const getRequestTypeLabel = (row: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(row)
|
||||
if (requestType === 'cyber') return t('usage.cyber')
|
||||
if (requestType === 'live') return t('usage.live')
|
||||
if (requestType === 'ws_v2') return t('usage.ws')
|
||||
if (requestType === 'stream') return t('usage.stream')
|
||||
if (requestType === 'sync') return t('usage.sync')
|
||||
@@ -592,6 +593,7 @@ const getRequestTypeLabel = (row: AdminUsageLog): string => {
|
||||
const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(row)
|
||||
if (requestType === 'cyber') return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
if (requestType === 'live') return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
|
||||
if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
|
||||
if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
|
||||
@@ -322,6 +322,7 @@ import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import '@/styles/announcement-markdown.css'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -471,120 +472,3 @@ watch(
|
||||
background: linear-gradient(to bottom, #6b7280, #4b5563);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Enhanced Markdown Styles */
|
||||
.markdown-body {
|
||||
@apply text-[15px] leading-[1.75];
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
@apply mb-6 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold text-gray-900 dark:border-dark-600 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
@apply mb-4 mt-7 border-b border-gray-100 pb-2 text-2xl font-bold text-gray-900 dark:border-dark-700 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
@apply mb-2 mt-5 text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
@apply font-medium text-blue-600 underline decoration-blue-600/30 decoration-2 underline-offset-2 transition-all hover:decoration-blue-600 dark:text-blue-400 dark:decoration-blue-400/30 dark:hover:decoration-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
@apply mb-4 ml-6 space-y-2;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
@apply leading-relaxed;
|
||||
@apply pl-2;
|
||||
}
|
||||
|
||||
.markdown-body li::marker {
|
||||
@apply text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
@apply relative my-5 border-l-4 border-blue-500 bg-blue-50/50 py-3 pl-5 pr-4 italic text-gray-700 dark:border-blue-400 dark:bg-blue-900/10 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body blockquote::before {
|
||||
content: '"';
|
||||
@apply absolute -left-1 top-0 text-5xl font-serif text-blue-500/20 dark:text-blue-400/20;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply rounded-lg bg-gray-100 px-2 py-1 text-[13px] font-mono text-pink-600 dark:bg-dark-700 dark:text-pink-400;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
@apply my-5 overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-dark-600 dark:bg-dark-900/50;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
@apply bg-transparent p-0 text-[13px] text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
@apply my-8 border-0 border-t-2 border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
@apply mb-5 w-full overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
@apply border-r border-b border-gray-200 px-4 py-3 text-left dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th:last-child,
|
||||
.markdown-body td:last-child {
|
||||
@apply border-r-0;
|
||||
}
|
||||
|
||||
.markdown-body tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
@apply bg-gradient-to-br from-blue-50 to-indigo-50 font-semibold text-gray-900 dark:from-blue-900/20 dark:to-indigo-900/10 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body tbody tr {
|
||||
@apply transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/30;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply my-5 max-w-full rounded-xl border border-gray-200 shadow-md dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
@apply font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body em {
|
||||
@apply italic text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<Teleport to="body">
|
||||
<Transition name="popup-fade">
|
||||
<div
|
||||
v-if="announcementStore.currentPopup"
|
||||
v-if="displayedAnnouncement"
|
||||
class="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-2 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
||||
{{ announcementStore.currentPopup.title }}
|
||||
{{ displayedAnnouncement.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Time -->
|
||||
@@ -43,7 +43,7 @@
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<time>{{ formatRelativeWithDateTime(announcementStore.currentPopup.created_at) }}</time>
|
||||
<time>{{ formatRelativeWithDateTime(displayedAnnouncement.created_at) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,13 +66,17 @@
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
@click="handleDismiss"
|
||||
data-testid="announcement-popup-dismiss"
|
||||
class="rounded-xl bg-gradient-to-r from-amber-500 to-orange-600 px-6 py-2.5 text-sm font-medium text-white shadow-lg shadow-amber-500/30 transition-all hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<svg v-if="preview" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ t('announcements.markRead') }}
|
||||
{{ preview ? t('common.close') : t('announcements.markRead') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -84,15 +88,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { formatRelativeWithDateTime } from '@/utils/format'
|
||||
import type { Announcement, UserAnnouncement } from '@/types'
|
||||
import '@/styles/announcement-markdown.css'
|
||||
|
||||
type PreviewAnnouncement = Pick<Announcement | UserAnnouncement, 'title' | 'content' | 'created_at'>
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
announcement?: PreviewAnnouncement | null
|
||||
preview?: boolean
|
||||
}>(), {
|
||||
announcement: null,
|
||||
preview: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
const displayedAnnouncement = computed(() => (
|
||||
props.preview ? props.announcement : announcementStore.currentPopup
|
||||
))
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
@@ -100,25 +123,38 @@ marked.setOptions({
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
const content = announcementStore.currentPopup?.content
|
||||
const content = displayedAnnouncement.value?.content
|
||||
if (!content) return ''
|
||||
const html = marked.parse(content) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
})
|
||||
|
||||
function handleDismiss() {
|
||||
if (props.preview) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
announcementStore.dismissPopup()
|
||||
}
|
||||
|
||||
// Manage body overflow — only set, never unset (bell component handles restore)
|
||||
watch(
|
||||
() => announcementStore.currentPopup,
|
||||
displayedAnnouncement,
|
||||
(popup) => {
|
||||
if (popup) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else if (props.preview) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.preview) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import AnnouncementPopup from '../AnnouncementPopup.vue'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
|
||||
const announcementMarkdownStyles = readFileSync(
|
||||
resolve(process.cwd(), 'src/styles/announcement-markdown.css'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const announcement = {
|
||||
id: 1,
|
||||
title: 'Preview announcement',
|
||||
content: '## Preview heading\n\n<div>HTML content</div><script>window.__xss = true</script>',
|
||||
status: 'draft' as const,
|
||||
notify_mode: 'popup' as const,
|
||||
targeting: { any_of: [] },
|
||||
created_at: '2026-07-24T07:30:00Z',
|
||||
updated_at: '2026-07-24T07:30:00Z',
|
||||
}
|
||||
|
||||
describe('AnnouncementPopup', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('renders mixed Markdown and HTML inside the shared styled container', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.currentPopup = {
|
||||
id: 1,
|
||||
title: 'Mixed content announcement',
|
||||
content: [
|
||||
'## Markdown heading',
|
||||
'',
|
||||
'<div><h3>HTML heading</h3><ul><li>HTML list item</li></ul></div>',
|
||||
'',
|
||||
'<table><thead><tr><th>Status</th></tr></thead><tbody><tr><td>OK</td></tr></tbody></table>',
|
||||
'<script>window.__announcementXss = true</script>',
|
||||
].join('\n'),
|
||||
notify_mode: 'popup',
|
||||
created_at: '2026-07-24T07:30:00Z',
|
||||
updated_at: '2026-07-24T07:30:00Z',
|
||||
}
|
||||
|
||||
const wrapper = mount(AnnouncementPopup)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const content = document.body.querySelector('.markdown-body')
|
||||
expect(content?.querySelector('h2')?.textContent).toBe('Markdown heading')
|
||||
expect(content?.querySelector('h3')?.textContent).toBe('HTML heading')
|
||||
expect(content?.querySelector('li')?.textContent).toBe('HTML list item')
|
||||
expect(content?.querySelector('table td')?.textContent).toBe('OK')
|
||||
expect(content?.querySelector('script')).toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['h2', 'h3', 'ul', 'li', 'blockquote', 'table', 'th', 'td', 'code'])(
|
||||
'loads a shared style rule for mixed-content <%s> elements',
|
||||
(element) => {
|
||||
expect(announcementMarkdownStyles).toContain(`.markdown-body ${element}`)
|
||||
},
|
||||
)
|
||||
|
||||
it('previews an admin announcement without marking it as read', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
const dismissPopup = vi.spyOn(store, 'dismissPopup')
|
||||
const wrapper = mount(AnnouncementPopup, {
|
||||
props: {
|
||||
announcement,
|
||||
preview: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain('Preview announcement')
|
||||
expect(document.body.querySelector('.markdown-body h2')?.textContent).toBe('Preview heading')
|
||||
expect(document.body.querySelector('.markdown-body script')).toBeNull()
|
||||
expect(document.body.textContent).toContain('common.close')
|
||||
|
||||
const dismissButton = document.body.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="announcement-popup-dismiss"]',
|
||||
)
|
||||
dismissButton?.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
expect(dismissPopup).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.setProps({ announcement: null })
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the existing user popup dismissal behavior', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.currentPopup = announcement
|
||||
const dismissPopup = vi.spyOn(store, 'dismissPopup').mockResolvedValue()
|
||||
const wrapper = mount(AnnouncementPopup)
|
||||
|
||||
const dismissButton = document.body.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="announcement-popup-dismiss"]',
|
||||
)
|
||||
dismissButton?.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(dismissPopup).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ export const claudeModels = [
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-5',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-sonnet-5',
|
||||
'claude-fable-5'
|
||||
@@ -267,6 +268,7 @@ const anthropicPresetMappings = [
|
||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'claude-opus-4-8', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Opus 5', from: 'claude-opus-5', to: 'claude-opus-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Opus->Sonnet', from: 'claude-opus-4-6', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
@@ -360,6 +362,7 @@ const bedrockPresetMappings = [
|
||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'us.anthropic.claude-opus-4-8-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Opus 5', from: 'claude-opus-5', to: 'us.anthropic.claude-opus-5-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'us.anthropic.claude-sonnet-5-v1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'us.anthropic.claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'us.anthropic.claude-opus-4-5-20251101-v1:0', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||
|
||||
@@ -1096,6 +1096,14 @@ export default {
|
||||
targetModelPlaceholder: 'e.g., gpt-5.4',
|
||||
removeExactMapping: 'Remove Exact Mapping'
|
||||
},
|
||||
openaiLive: {
|
||||
title: 'OpenAI Live',
|
||||
allow: 'Allow Live access',
|
||||
hint: 'When enabled, API keys in this OpenAI group can create and control Live voice sessions. Disabled by default. The Sub2API server must run on Apple Silicon macOS with the official ChatGPT app installed; client platforms are unrestricted.',
|
||||
unsupportedTitle: 'Current server does not support Live',
|
||||
unsupportedMessage: 'This Sub2API server cannot generate the required Live attestation. Live will not work even if enabled. Continue anyway?',
|
||||
enableAnyway: 'Enable anyway'
|
||||
},
|
||||
invalidRequestFallback: {
|
||||
title: 'Invalid Request Fallback Group',
|
||||
hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.',
|
||||
|
||||
@@ -433,6 +433,7 @@ export default {
|
||||
timeImmediate: 'Immediate',
|
||||
timeNever: 'Never',
|
||||
readStatus: 'Read Status',
|
||||
preview: 'Preview',
|
||||
eligible: 'Eligible',
|
||||
readAt: 'Read at',
|
||||
unread: 'Unread',
|
||||
|
||||
@@ -335,11 +335,13 @@ export default {
|
||||
},
|
||||
ollamaCloudUsage: {
|
||||
title: 'Ollama Cloud Usage Refresh',
|
||||
description: 'Periodically refresh official Ollama settings-page usage for individually opted-in accounts. Disabled by default.',
|
||||
description: 'Refresh official Ollama settings-page usage driven by model requests for individually opted-in accounts. Disabled by default. Idle accounts are not polled.',
|
||||
enabled: 'Enable global automatic refresh',
|
||||
enabledHint: 'Only accounts with a stored browser session and their own automatic refresh switch enabled are refreshed. Manual refresh remains available.',
|
||||
intervalMinutes: 'Refresh interval (minutes)',
|
||||
intervalHint: 'Range: 15–1440 minutes. Failures use bounded exponential backoff.',
|
||||
enabledHint: 'Only accounts with a stored browser session and their own automatic refresh switch enabled are refreshed, and only after subsequent model requests. Manual refresh remains available.',
|
||||
intervalMinutes: 'Max wait while requests continue (minutes)',
|
||||
intervalHint: 'Range: 15–1440 minutes. When continuous requests keep sliding the debounce, force a refresh after this wait.',
|
||||
debounceMinutes: 'Quiet period after last request (minutes)',
|
||||
debounceHint: 'Range: 1–60 minutes. Refresh after the latest model request has been quiet for this long.',
|
||||
saved: 'Ollama Cloud usage refresh settings saved',
|
||||
saveFailed: 'Failed to save Ollama Cloud usage refresh settings'
|
||||
},
|
||||
|
||||
@@ -317,6 +317,7 @@ export default {
|
||||
stream: 'Stream',
|
||||
sync: 'Sync',
|
||||
cyber: 'Cyber',
|
||||
live: 'Live',
|
||||
unknown: 'Unknown',
|
||||
in: 'In',
|
||||
out: 'Out',
|
||||
|
||||
@@ -1094,6 +1094,14 @@ export default {
|
||||
targetModelPlaceholder: '例如: gpt-5.4',
|
||||
removeExactMapping: '删除精确映射'
|
||||
},
|
||||
openaiLive: {
|
||||
title: 'OpenAI Live',
|
||||
allow: '允许访问 Live',
|
||||
hint: '启用后,此 OpenAI 分组的 API Key 可以创建并控制 Live 语音会话。默认关闭。运行 Sub2API 的服务端必须是 Apple Silicon Mac,并安装官方 ChatGPT App;客户端平台不受限制。',
|
||||
unsupportedTitle: '当前服务端不支持 Live',
|
||||
unsupportedMessage: '当前 Sub2API 服务端无法生成 Live 所需的设备证明,即使开启也不能使用。是否仍然开启?',
|
||||
enableAnyway: '仍然开启'
|
||||
},
|
||||
invalidRequestFallback: {
|
||||
title: '无效请求兜底分组',
|
||||
hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底',
|
||||
|
||||
@@ -430,6 +430,7 @@ export default {
|
||||
timeImmediate: '立即',
|
||||
timeNever: '永久',
|
||||
readStatus: '已读情况',
|
||||
preview: '预览',
|
||||
eligible: '符合条件',
|
||||
readAt: '已读时间',
|
||||
unread: '未读',
|
||||
|
||||
@@ -328,11 +328,13 @@ export default {
|
||||
},
|
||||
ollamaCloudUsage: {
|
||||
title: 'Ollama Cloud 用量刷新',
|
||||
description: '定期刷新账号在 Ollama 官方设置页展示的用量;默认关闭。',
|
||||
description: '在模型请求驱动下刷新账号在 Ollama 官方设置页展示的用量;默认关闭。无新请求时不会自动抓取。',
|
||||
enabled: '启用全局自动刷新',
|
||||
enabledHint: '仅刷新已保存浏览器会话且账号自身也开启自动刷新的账号;手动刷新不受影响。',
|
||||
intervalMinutes: '刷新周期(分钟)',
|
||||
intervalHint: '范围 15–1440 分钟。失败后按有上限的指数退避重试。',
|
||||
enabledHint: '仅刷新已保存浏览器会话且账号自身也开启自动刷新的账号;需有后续模型请求才会触发。手动刷新不受影响。',
|
||||
intervalMinutes: '持续请求最长等待(分钟)',
|
||||
intervalHint: '范围 15–1440 分钟。请求持续不断导致 debounce 一直后移时,最晚在此时间强制刷新。',
|
||||
debounceMinutes: '请求安静等待(分钟)',
|
||||
debounceHint: '范围 1–60 分钟。最后一次模型请求安静满此时长后再抓取用量。',
|
||||
saved: 'Ollama Cloud 用量刷新设置已保存',
|
||||
saveFailed: '保存 Ollama Cloud 用量刷新设置失败'
|
||||
},
|
||||
|
||||
@@ -322,6 +322,7 @@ export default {
|
||||
stream: '流式',
|
||||
sync: '同步',
|
||||
cyber: '安全策略',
|
||||
live: 'Live',
|
||||
unknown: '未知',
|
||||
in: '输入',
|
||||
out: '输出',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Shared rich-text styles for announcement popup and bell detail content. */
|
||||
.markdown-body {
|
||||
@apply text-[15px] leading-[1.75];
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
@apply mb-6 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold text-gray-900 dark:border-dark-600 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
@apply mb-4 mt-7 border-b border-gray-100 pb-2 text-2xl font-bold text-gray-900 dark:border-dark-700 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
@apply mb-2 mt-5 text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
@apply font-medium text-blue-600 underline decoration-blue-600/30 decoration-2 underline-offset-2 transition-all hover:decoration-blue-600 dark:text-blue-400 dark:decoration-blue-400/30 dark:hover:decoration-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
@apply mb-4 ml-6 space-y-2;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
@apply pl-2 leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-body li::marker {
|
||||
@apply text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
@apply relative my-5 border-l-4 border-blue-500 bg-blue-50/50 py-3 pl-5 pr-4 italic text-gray-700 dark:border-blue-400 dark:bg-blue-900/10 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body blockquote::before {
|
||||
content: '"';
|
||||
@apply absolute -left-1 top-0 font-serif text-5xl text-blue-500/20 dark:text-blue-400/20;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply rounded-lg bg-gray-100 px-2 py-1 font-mono text-[13px] text-pink-600 dark:bg-dark-700 dark:text-pink-400;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
@apply my-5 overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-dark-600 dark:bg-dark-900/50;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
@apply bg-transparent p-0 text-[13px] text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
@apply my-8 border-0 border-t-2 border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
@apply mb-5 w-full overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
@apply border-b border-r border-gray-200 px-4 py-3 text-left dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th:last-child,
|
||||
.markdown-body td:last-child {
|
||||
@apply border-r-0;
|
||||
}
|
||||
|
||||
.markdown-body tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
@apply bg-gradient-to-br from-blue-50 to-indigo-50 font-semibold text-gray-900 dark:from-blue-900/20 dark:to-indigo-900/10 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body tbody tr {
|
||||
@apply transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/30;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply my-5 max-w-full rounded-xl border border-gray-200 shadow-md dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
@apply font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body em {
|
||||
@apply italic text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
@@ -551,6 +551,8 @@ export interface Group {
|
||||
fallback_group_id_on_invalid_request: number | null
|
||||
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
|
||||
allow_messages_dispatch?: boolean
|
||||
// OpenAI Live 接口开关
|
||||
allow_live: boolean
|
||||
default_mapped_model?: string
|
||||
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||
require_oauth_only: boolean
|
||||
@@ -742,6 +744,7 @@ export interface CreateGroupRequest {
|
||||
supported_model_scopes?: string[]
|
||||
models_list_config?: ModelsListConfig
|
||||
allow_messages_dispatch?: boolean
|
||||
allow_live?: boolean
|
||||
default_mapped_model?: string
|
||||
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||
model_routing?: Record<string, number[]> | null
|
||||
@@ -792,6 +795,7 @@ export interface UpdateGroupRequest {
|
||||
supported_model_scopes?: string[]
|
||||
models_list_config?: ModelsListConfig
|
||||
allow_messages_dispatch?: boolean
|
||||
allow_live?: boolean
|
||||
default_mapped_model?: string
|
||||
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||
model_routing?: Record<string, number[]> | null
|
||||
@@ -1019,7 +1023,10 @@ export interface OllamaCloudUsageState {
|
||||
|
||||
export interface OllamaCloudUsageSettings {
|
||||
enabled: boolean
|
||||
/** Max wait while model requests keep arriving (minutes). */
|
||||
interval_minutes: number
|
||||
/** Trailing quiet period after the latest model request (minutes). */
|
||||
debounce_minutes: number
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
@@ -1511,7 +1518,7 @@ export interface CodexSessionImportResult {
|
||||
// ==================== Usage & Redeem Types ====================
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||
export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2' | 'cyber'
|
||||
export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2' | 'cyber' | 'live'
|
||||
export type ImageSizeSource = 'output' | 'input' | 'default' | 'legacy'
|
||||
export type ImageSizeBreakdown = Record<string, number>
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ export function statusCodeBadgeClass(code: number): string {
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
|
||||
}
|
||||
|
||||
/** 请求类型徽章配色(cyber 红、ws 紫、stream 蓝、sync 灰、未知琥珀) */
|
||||
/** 请求类型徽章配色(cyber 红、live 绿、ws 紫、stream 蓝、sync 灰、未知琥珀) */
|
||||
export function requestTypeBadgeClass(kind: UsageRequestKind): string {
|
||||
if (kind === 'cyber') return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
if (kind === 'live') return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
|
||||
if (kind === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
|
||||
if (kind === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
if (kind === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
|
||||
@@ -27,6 +28,7 @@ export function requestTypeBadgeClass(kind: UsageRequestKind): string {
|
||||
/** 请求类型 i18n 键(展示方自行 t()) */
|
||||
export function requestTypeLabelKey(kind: UsageRequestKind): string {
|
||||
if (kind === 'cyber') return 'usage.cyber'
|
||||
if (kind === 'live') return 'usage.live'
|
||||
if (kind === 'ws_v2') return 'usage.ws'
|
||||
if (kind === 'stream') return 'usage.stream'
|
||||
if (kind === 'sync') return 'usage.sync'
|
||||
@@ -43,6 +45,7 @@ export function numericRequestTypeKind(
|
||||
): UsageRequestKind | null {
|
||||
const rt = requestType ?? (stream == null ? 0 : stream ? 2 : 1)
|
||||
if (rt === 3) return 'ws_v2'
|
||||
if (rt === 5) return 'live'
|
||||
if (rt === 2) return 'stream'
|
||||
if (rt === 1) return 'sync'
|
||||
return null
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface UsageRequestTypeLike {
|
||||
openai_ws_mode?: boolean | null
|
||||
}
|
||||
|
||||
const VALID_REQUEST_TYPES = new Set<UsageRequestType>(['unknown', 'sync', 'stream', 'ws_v2', 'cyber'])
|
||||
const VALID_REQUEST_TYPES = new Set<UsageRequestType>(['unknown', 'sync', 'stream', 'ws_v2', 'cyber', 'live'])
|
||||
|
||||
export const isUsageRequestType = (value: unknown): value is UsageRequestType => {
|
||||
return typeof value === 'string' && VALID_REQUEST_TYPES.has(value as UsageRequestType)
|
||||
@@ -24,7 +24,7 @@ export const resolveUsageRequestType = (value: UsageRequestTypeLike): UsageReque
|
||||
|
||||
export const requestTypeToLegacyStream = (requestType?: UsageRequestType | null): boolean | null | undefined => {
|
||||
// cyber 与 stream 正交(cyber 可发生在 stream 或非 stream 请求),不映射到 legacy stream 维度。
|
||||
if (!requestType || requestType === 'unknown' || requestType === 'cyber') {
|
||||
if (!requestType || requestType === 'unknown' || requestType === 'cyber' || requestType === 'live') {
|
||||
return null
|
||||
}
|
||||
if (requestType === 'sync') {
|
||||
|
||||
@@ -114,12 +114,19 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
@click="openPreview(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.preview')"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
</button>
|
||||
<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" />
|
||||
<Icon name="chartBar" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEditDialog(row)"
|
||||
@@ -240,6 +247,12 @@
|
||||
:announcement-id="readStatusAnnouncementId"
|
||||
@close="showReadStatusDialog = false"
|
||||
/>
|
||||
|
||||
<AnnouncementPopup
|
||||
:announcement="previewAnnouncement"
|
||||
preview
|
||||
@close="previewAnnouncement = null"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -265,6 +278,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
|
||||
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
|
||||
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -588,6 +602,11 @@ async function confirmDelete() {
|
||||
// ===== Read status =====
|
||||
const showReadStatusDialog = ref(false)
|
||||
const readStatusAnnouncementId = ref<number | null>(null)
|
||||
const previewAnnouncement = ref<Announcement | null>(null)
|
||||
|
||||
function openPreview(row: Announcement) {
|
||||
previewAnnouncement.value = row
|
||||
}
|
||||
|
||||
function openReadStatus(row: Announcement) {
|
||||
readStatusAnnouncementId.value = row.id
|
||||
|
||||
@@ -1394,6 +1394,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Live 开关(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="createForm.platform === 'openai'"
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{{ t("admin.groups.openaiLive.title") }}
|
||||
</h4>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t("admin.groups.openaiLive.allow")
|
||||
}}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleLive('create')"
|
||||
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
|
||||
:class="
|
||||
createForm.allow_live
|
||||
? 'bg-primary-500'
|
||||
: 'bg-gray-300 dark:bg-dark-600'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="createForm.allow_live ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ t("admin.groups.openaiLive.hint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="createForm.platform === 'openai'"
|
||||
@@ -2912,6 +2945,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Live 开关(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="editForm.platform === 'openai'"
|
||||
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
|
||||
>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{{ t("admin.groups.openaiLive.title") }}
|
||||
</h4>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">{{
|
||||
t("admin.groups.openaiLive.allow")
|
||||
}}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleLive('edit')"
|
||||
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
|
||||
:class="
|
||||
editForm.allow_live
|
||||
? 'bg-primary-500'
|
||||
: 'bg-gray-300 dark:bg-dark-600'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="editForm.allow_live ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ t("admin.groups.openaiLive.hint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
|
||||
<div
|
||||
v-if="editForm.platform === 'openai'"
|
||||
@@ -3496,6 +3562,17 @@
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showUnsupportedLiveConfirm"
|
||||
:title="t('admin.groups.openaiLive.unsupportedTitle')"
|
||||
:message="t('admin.groups.openaiLive.unsupportedMessage')"
|
||||
:confirm-text="t('admin.groups.openaiLive.enableAnyway')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmUnsupportedLive"
|
||||
@cancel="cancelUnsupportedLive"
|
||||
/>
|
||||
|
||||
<!-- Sort Order Modal -->
|
||||
<BaseDialog
|
||||
:show="showSortModal"
|
||||
@@ -4436,6 +4513,15 @@ let abortController: AbortController | null = null;
|
||||
const showCreateModal = ref(false);
|
||||
const showEditModal = ref(false);
|
||||
const showDeleteDialog = ref(false);
|
||||
const pendingLiveForm = ref<"create" | "edit" | null>(null);
|
||||
const showUnsupportedLiveConfirm = computed(
|
||||
() => pendingLiveForm.value !== null,
|
||||
);
|
||||
const liveCapability = ref<{ supported: boolean; reason?: string } | null>(null);
|
||||
let liveCapabilityRequest: Promise<{
|
||||
supported: boolean;
|
||||
reason?: string;
|
||||
}> | null = null;
|
||||
const showSortModal = ref(false);
|
||||
const submitting = ref(false);
|
||||
const sortSubmitting = ref(false);
|
||||
@@ -4538,6 +4624,7 @@ const createForm = reactive({
|
||||
fallback_group_id_on_invalid_request: null as number | null,
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
allow_messages_dispatch: false,
|
||||
allow_live: false,
|
||||
opus_mapped_model: createMessagesDispatchDefaults.opus_mapped_model,
|
||||
sonnet_mapped_model: createMessagesDispatchDefaults.sonnet_mapped_model,
|
||||
haiku_mapped_model: createMessagesDispatchDefaults.haiku_mapped_model,
|
||||
@@ -4887,6 +4974,7 @@ const editForm = reactive({
|
||||
fallback_group_id_on_invalid_request: null as number | null,
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
allow_messages_dispatch: false,
|
||||
allow_live: false,
|
||||
default_mapped_model: '',
|
||||
opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model,
|
||||
sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model,
|
||||
@@ -5084,6 +5172,44 @@ const deleteConfirmMessage = computed(() => {
|
||||
return t("admin.groups.deleteConfirm", { name: deletingGroup.value.name });
|
||||
});
|
||||
|
||||
const loadLiveCapability = async () => {
|
||||
if (liveCapability.value) return liveCapability.value;
|
||||
if (!liveCapabilityRequest) {
|
||||
liveCapabilityRequest = adminAPI.groups
|
||||
.getLiveCapability()
|
||||
.catch(() => ({ supported: false }))
|
||||
.finally(() => {
|
||||
liveCapabilityRequest = null;
|
||||
});
|
||||
}
|
||||
liveCapability.value = await liveCapabilityRequest;
|
||||
return liveCapability.value ?? { supported: false };
|
||||
};
|
||||
|
||||
const toggleLive = async (target: "create" | "edit") => {
|
||||
const form = target === "create" ? createForm : editForm;
|
||||
if (form.allow_live) {
|
||||
form.allow_live = false;
|
||||
return;
|
||||
}
|
||||
const capability = await loadLiveCapability();
|
||||
if (capability.supported) {
|
||||
form.allow_live = true;
|
||||
return;
|
||||
}
|
||||
pendingLiveForm.value = target;
|
||||
};
|
||||
|
||||
const confirmUnsupportedLive = () => {
|
||||
if (pendingLiveForm.value === "create") createForm.allow_live = true;
|
||||
if (pendingLiveForm.value === "edit") editForm.allow_live = true;
|
||||
pendingLiveForm.value = null;
|
||||
};
|
||||
|
||||
const cancelUnsupportedLive = () => {
|
||||
pendingLiveForm.value = null;
|
||||
};
|
||||
|
||||
const loadGroups = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
@@ -5290,6 +5416,7 @@ const closeCreateModal = () => {
|
||||
createForm.fallback_group_id = null;
|
||||
createForm.fallback_group_id_on_invalid_request = null;
|
||||
resetMessagesDispatchFormState(createForm);
|
||||
createForm.allow_live = false;
|
||||
createForm.require_oauth_only = false;
|
||||
createForm.require_privacy_set = false;
|
||||
createForm.supported_model_scopes = ["claude", "gemini_text", "gemini_image"];
|
||||
@@ -5477,6 +5604,7 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.allow_messages_dispatch =
|
||||
group.allow_messages_dispatch ||
|
||||
messagesDispatchFormState.allow_messages_dispatch;
|
||||
editForm.allow_live = group.allow_live ?? false;
|
||||
editForm.opus_mapped_model = messagesDispatchFormState.opus_mapped_model;
|
||||
editForm.sonnet_mapped_model = messagesDispatchFormState.sonnet_mapped_model;
|
||||
editForm.haiku_mapped_model = messagesDispatchFormState.haiku_mapped_model;
|
||||
@@ -5533,6 +5661,7 @@ const closeEditModal = () => {
|
||||
editForm.video_price_1080p = null;
|
||||
editForm.web_search_price_per_call = null;
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
editForm.allow_live = false;
|
||||
resetModelsListState(editModelsListState);
|
||||
};
|
||||
|
||||
@@ -5937,6 +6066,7 @@ watch(
|
||||
}
|
||||
if (newVal !== "openai") {
|
||||
resetMessagesDispatchFormState(createForm);
|
||||
createForm.allow_live = false;
|
||||
}
|
||||
createForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
|
||||
newVal,
|
||||
@@ -5979,6 +6109,7 @@ watch(
|
||||
}
|
||||
if (newVal !== "openai") {
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
editForm.allow_live = false;
|
||||
}
|
||||
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
|
||||
newVal,
|
||||
@@ -6023,6 +6154,7 @@ watch(
|
||||
}
|
||||
if (newVal !== 'openai') {
|
||||
editForm.allow_messages_dispatch = false
|
||||
editForm.allow_live = false
|
||||
editForm.default_mapped_model = ''
|
||||
}
|
||||
}
|
||||
@@ -6088,6 +6220,7 @@ const saveSortOrder = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups();
|
||||
void loadLiveCapability();
|
||||
loadModelsListCandidates("create", 0, createForm.platform);
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
@@ -4220,23 +4220,43 @@
|
||||
data-testid="ollama-cloud-usage-global-enabled"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="ollamaCloudUsageForm.enabled" class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" for="ollama-cloud-usage-interval">
|
||||
{{ t("admin.settings.ollamaCloudUsage.intervalMinutes") }}
|
||||
</label>
|
||||
<input
|
||||
id="ollama-cloud-usage-interval"
|
||||
v-model.number="ollamaCloudUsageForm.interval_minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="1440"
|
||||
class="input w-32"
|
||||
data-testid="ollama-cloud-usage-global-interval"
|
||||
@keydown.enter.prevent="saveOllamaCloudUsageSettings"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.ollamaCloudUsage.intervalHint") }}
|
||||
</p>
|
||||
<div v-if="ollamaCloudUsageForm.enabled" class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" for="ollama-cloud-usage-debounce">
|
||||
{{ t("admin.settings.ollamaCloudUsage.debounceMinutes") }}
|
||||
</label>
|
||||
<input
|
||||
id="ollama-cloud-usage-debounce"
|
||||
v-model.number="ollamaCloudUsageForm.debounce_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
class="input w-32"
|
||||
data-testid="ollama-cloud-usage-global-debounce"
|
||||
@keydown.enter.prevent="saveOllamaCloudUsageSettings"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.ollamaCloudUsage.debounceHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" for="ollama-cloud-usage-interval">
|
||||
{{ t("admin.settings.ollamaCloudUsage.intervalMinutes") }}
|
||||
</label>
|
||||
<input
|
||||
id="ollama-cloud-usage-interval"
|
||||
v-model.number="ollamaCloudUsageForm.interval_minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="1440"
|
||||
class="input w-32"
|
||||
data-testid="ollama-cloud-usage-global-interval"
|
||||
@keydown.enter.prevent="saveOllamaCloudUsageSettings"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.ollamaCloudUsage.intervalHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<button
|
||||
@@ -7934,6 +7954,7 @@ const ollamaCloudUsageSaving = ref(false);
|
||||
const ollamaCloudUsageForm = reactive({
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
debounce_minutes: 1,
|
||||
});
|
||||
|
||||
// Overload Cooldown (529) 状态
|
||||
|
||||
@@ -538,6 +538,7 @@ const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
const getRequestTypeLabel = (log: AdminUsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(log)
|
||||
if (requestType === 'cyber') return t('usage.cyber')
|
||||
if (requestType === 'live') return t('usage.live')
|
||||
if (requestType === 'ws_v2') return t('usage.ws')
|
||||
if (requestType === 'stream') return t('usage.stream')
|
||||
if (requestType === 'sync') return t('usage.sync')
|
||||
|
||||
@@ -50,6 +50,7 @@ const {
|
||||
getOllamaCloudUsageSettings: vi.fn().mockResolvedValue({
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
debounce_minutes: 1,
|
||||
}),
|
||||
updateOllamaCloudUsageSettings: vi.fn().mockImplementation(async (payload) => payload),
|
||||
getGroups: vi.fn(),
|
||||
@@ -647,6 +648,7 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
getOllamaCloudUsageSettings.mockResolvedValue({
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
debounce_minutes: 1,
|
||||
});
|
||||
updateOllamaCloudUsageSettings.mockImplementation(async (payload) => payload);
|
||||
getGroups.mockResolvedValue([]);
|
||||
@@ -1007,6 +1009,7 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(card.find('[data-testid="ollama-cloud-usage-global-interval"]').exists()).toBe(false);
|
||||
|
||||
await card.get('[data-testid="ollama-cloud-usage-global-enabled"]').setValue(true);
|
||||
await card.get('[data-testid="ollama-cloud-usage-global-debounce"]').setValue(3);
|
||||
await card.get('[data-testid="ollama-cloud-usage-global-interval"]').setValue(90);
|
||||
await card.get('[data-testid="ollama-cloud-usage-global-save"]').trigger("click");
|
||||
await flushPromises();
|
||||
@@ -1014,6 +1017,7 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(updateOllamaCloudUsageSettings).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
interval_minutes: 90,
|
||||
debounce_minutes: 3,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.yourCode') }}</p>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||
<code class="flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white">{{ detail.aff_code }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copyCode">
|
||||
<div class="flex flex-col items-stretch gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900 sm:flex-row sm:items-center">
|
||||
<code class="min-w-0 break-all text-sm font-semibold text-gray-900 dark:text-white sm:flex-1 sm:truncate">{{ detail.aff_code }}</code>
|
||||
<button class="btn btn-secondary btn-sm w-full sm:w-auto sm:shrink-0" @click="copyCode">
|
||||
<Icon name="copy" size="sm" />
|
||||
<span>{{ t('affiliate.copyCode') }}</span>
|
||||
</button>
|
||||
@@ -62,9 +62,9 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.inviteLink') }}</p>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||
<code class="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">{{ inviteLink }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copyInviteLink">
|
||||
<div class="flex flex-col items-stretch gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900 sm:flex-row sm:items-center">
|
||||
<code class="min-w-0 break-all text-sm text-gray-700 dark:text-gray-300 sm:flex-1 sm:truncate">{{ inviteLink }}</code>
|
||||
<button class="btn btn-secondary btn-sm w-full sm:w-auto sm:shrink-0" @click="copyInviteLink">
|
||||
<Icon name="copy" size="sm" />
|
||||
<span>{{ t('affiliate.copyLink') }}</span>
|
||||
</button>
|
||||
|
||||
@@ -376,6 +376,7 @@ const granularityOptions = computed<SelectOption[]>(() => [
|
||||
const requestTypeOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
{ value: 'ws_v2', label: t('usage.ws') },
|
||||
{ value: 'live', label: t('usage.live') },
|
||||
{ value: 'stream', label: t('usage.stream') },
|
||||
{ value: 'sync', label: t('usage.sync') },
|
||||
])
|
||||
@@ -595,6 +596,7 @@ const handleIpGeoBatchFailed = () => {
|
||||
const getRequestTypeExportText = (log: UsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(log)
|
||||
if (requestType === 'cyber') return 'Cyber'
|
||||
if (requestType === 'live') return 'Live'
|
||||
if (requestType === 'ws_v2') return 'WS'
|
||||
if (requestType === 'stream') return 'Stream'
|
||||
if (requestType === 'sync') return 'Sync'
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AffiliateView from '../AffiliateView.vue'
|
||||
|
||||
const { copyToClipboard, getAffiliateDetail } = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
getAffiliateDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/user', () => ({
|
||||
default: {
|
||||
getAffiliateDetail,
|
||||
transferAffiliateQuota: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
refreshUser: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({ copyToClipboard }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('AffiliateView', () => {
|
||||
const affiliateCode = 'affiliate-code-that-is-long-enough-to-overflow-a-mobile-viewport'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
copyToClipboard.mockResolvedValue(true)
|
||||
getAffiliateDetail.mockResolvedValue({
|
||||
user_id: 1,
|
||||
aff_code: affiliateCode,
|
||||
inviter_id: null,
|
||||
aff_count: 0,
|
||||
aff_quota: 0,
|
||||
aff_frozen_quota: 0,
|
||||
aff_history_quota: 0,
|
||||
effective_rebate_rate_percent: 10,
|
||||
invitees: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('stacks long values and copy controls on mobile while retaining desktop rows', async () => {
|
||||
const wrapper = mount(AffiliateView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<main><slot /></main>' },
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const values = wrapper.findAll('code')
|
||||
expect(values).toHaveLength(2)
|
||||
for (const value of values) {
|
||||
expect(value.classes()).toEqual(expect.arrayContaining([
|
||||
'min-w-0',
|
||||
'break-all',
|
||||
'sm:flex-1',
|
||||
'sm:truncate',
|
||||
]))
|
||||
expect(Array.from(value.element.parentElement?.classList ?? [])).toEqual(expect.arrayContaining([
|
||||
'flex-col',
|
||||
'items-stretch',
|
||||
'sm:flex-row',
|
||||
'sm:items-center',
|
||||
]))
|
||||
}
|
||||
|
||||
const copyButtons = wrapper.findAll('button').filter((button) =>
|
||||
['affiliate.copyCode', 'affiliate.copyLink'].includes(button.text()),
|
||||
)
|
||||
expect(copyButtons).toHaveLength(2)
|
||||
for (const button of copyButtons) {
|
||||
expect(button.classes()).toEqual(expect.arrayContaining([
|
||||
'w-full',
|
||||
'sm:w-auto',
|
||||
'sm:shrink-0',
|
||||
]))
|
||||
}
|
||||
|
||||
await copyButtons[0].trigger('click')
|
||||
await copyButtons[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(copyToClipboard).toHaveBeenNthCalledWith(1, affiliateCode, 'affiliate.codeCopied')
|
||||
expect(copyToClipboard).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${window.location.origin}/register?aff=${encodeURIComponent(affiliateCode)}`,
|
||||
'affiliate.linkCopied',
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user