Unverified Commit ac114738 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1850 from touwaeriol/feat/channel-insights

feat(monitor): channel monitor with available channels & feature flags
parents 0a80ec80 09fd83ab
/**
* Admin Channel Monitor API endpoints
* Handles channel monitor (uptime/health) management for administrators
*/
import { apiClient } from '../client'
export type Provider = 'openai' | 'anthropic' | 'gemini'
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
export interface ChannelMonitor {
id: number
name: string
provider: Provider
endpoint: string
api_key_masked: string
/**
* True when the stored encrypted API key cannot be decrypted (e.g. the
* encryption key has changed). Admin must re-edit the monitor to provide
* a fresh key. Backend skips checks for these monitors.
*/
api_key_decrypt_failed?: boolean
primary_model: string
extra_models: string[]
group_name: string
enabled: boolean
interval_seconds: number
last_checked_at: string | null
created_by: number
created_at: string
updated_at: string
/** Latest status of the primary model (empty when no history yet) */
primary_status: MonitorStatus | ''
/** Latest latency of the primary model in ms (null when no history yet) */
primary_latency_ms: number | null
/** Primary model 7-day availability percentage (0-100) */
availability_7d: number
/** Latest status per extra model (used for hover tooltip) */
extra_models_status: ExtraModelStatus[]
/** 请求自定义快照字段(高级设置) */
template_id: number | null
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
}
export interface ExtraModelStatus {
model: string
status: MonitorStatus | ''
latency_ms: number | null
}
export interface ListParams {
page?: number
page_size?: number
provider?: Provider
enabled?: boolean
search?: string
}
export interface ListResponse {
items: ChannelMonitor[]
total: number
page: number
page_size: number
pages: number
}
export interface CreateParams {
name: string
provider: Provider
endpoint: string
api_key: string
primary_model: string
extra_models?: string[]
group_name?: string
enabled?: boolean
interval_seconds: number
template_id?: number | null
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空
export type UpdateParams = Partial<CreateParams> & {
clear_template?: boolean
}
export interface CheckResult {
model: string
status: MonitorStatus
latency_ms: number | null
ping_latency_ms: number | null
message: string
checked_at: string
}
export interface RunNowResponse {
results: CheckResult[]
}
export interface HistoryItem {
id: number
model: string
status: MonitorStatus
latency_ms: number | null
ping_latency_ms: number | null
message: string
checked_at: string
}
export interface HistoryParams {
model?: string
limit?: number
}
export interface HistoryResponse {
items: HistoryItem[]
}
/**
* List channel monitors with pagination and filters
*/
export async function list(
params: ListParams = {},
options?: { signal?: AbortSignal }
): Promise<ListResponse> {
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitors', {
params,
signal: options?.signal,
})
return data
}
/**
* Get a channel monitor by ID
*/
export async function get(id: number): Promise<ChannelMonitor> {
const { data } = await apiClient.get<ChannelMonitor>(`/admin/channel-monitors/${id}`)
return data
}
/**
* Create a new channel monitor
*/
export async function create(params: CreateParams): Promise<ChannelMonitor> {
const { data } = await apiClient.post<ChannelMonitor>('/admin/channel-monitors', params)
return data
}
/**
* Update an existing channel monitor.
* api_key field: empty string means "do not modify".
*/
export async function update(id: number, params: UpdateParams): Promise<ChannelMonitor> {
const { data } = await apiClient.put<ChannelMonitor>(`/admin/channel-monitors/${id}`, params)
return data
}
/**
* Delete a channel monitor
*/
export async function del(id: number): Promise<void> {
await apiClient.delete(`/admin/channel-monitors/${id}`)
}
/**
* Trigger an immediate manual check for a channel monitor.
* Returns the latest check results for primary + extra models.
*/
export async function runNow(id: number): Promise<RunNowResponse> {
const { data } = await apiClient.post<RunNowResponse>(`/admin/channel-monitors/${id}/run`)
return data
}
/**
* List historical check results for a monitor.
*/
export async function listHistory(
id: number,
params: HistoryParams = {}
): Promise<HistoryResponse> {
const { data } = await apiClient.get<HistoryResponse>(
`/admin/channel-monitors/${id}/history`,
{ params }
)
return data
}
export const channelMonitorAPI = {
list,
get,
create,
update,
del,
runNow,
listHistory,
}
export default channelMonitorAPI
/**
* Admin Channel Monitor Request Template API.
*
* 模板 = 一组可复用的 headers + 可选 body 覆盖配置。
* 应用到监控 = 拷贝快照;模板后续变动不自动同步,需手动点「应用到关联监控」刷新。
*/
import { apiClient } from '../client'
import type { BodyOverrideMode, Provider } from './channelMonitor'
export interface ChannelMonitorTemplate {
id: number
name: string
provider: Provider
description: string
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
created_at: string
updated_at: string
/** 关联的监控数量(快照来自此模板,仅 template_id 匹配即可) */
associated_monitors: number
}
export interface ListParams {
provider?: Provider
}
export interface ListResponse {
items: ChannelMonitorTemplate[]
}
export interface CreateParams {
name: string
provider: Provider
description?: string
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
export interface UpdateParams {
name?: string
description?: string
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
export interface ApplyResponse {
affected: number
}
export interface AssociatedMonitorBrief {
id: number
name: string
provider: Provider
enabled: boolean
}
export interface AssociatedMonitorsResponse {
items: AssociatedMonitorBrief[]
}
export async function list(params: ListParams = {}): Promise<ListResponse> {
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitor-templates', {
params,
})
return data
}
export async function get(id: number): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.get<ChannelMonitorTemplate>(
`/admin/channel-monitor-templates/${id}`,
)
return data
}
export async function create(params: CreateParams): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.post<ChannelMonitorTemplate>(
'/admin/channel-monitor-templates',
params,
)
return data
}
export async function update(id: number, params: UpdateParams): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.put<ChannelMonitorTemplate>(
`/admin/channel-monitor-templates/${id}`,
params,
)
return data
}
export async function del(id: number): Promise<void> {
await apiClient.delete(`/admin/channel-monitor-templates/${id}`)
}
/**
* Apply the template to the specified associated monitors (overwrite snapshot fields).
* monitorIds must be a non-empty subset of the template's associated monitors.
* Returns count of actually affected monitors.
*/
export async function apply(id: number, monitorIds: number[]): Promise<ApplyResponse> {
const { data } = await apiClient.post<ApplyResponse>(
`/admin/channel-monitor-templates/${id}/apply`,
{ monitor_ids: monitorIds },
)
return data
}
/**
* List monitors currently associated to this template (used by apply picker).
*/
export async function listAssociatedMonitors(id: number): Promise<AssociatedMonitorsResponse> {
const { data } = await apiClient.get<AssociatedMonitorsResponse>(
`/admin/channel-monitor-templates/${id}/monitors`,
)
return data
}
export const channelMonitorTemplateAPI = {
list,
get,
create,
update,
del,
apply,
listAssociatedMonitors,
}
export default channelMonitorTemplateAPI
......@@ -4,8 +4,9 @@
*/
import { apiClient } from '../client'
import type { BillingMode, ChannelStatus, BillingModelSource } from '@/constants/channel'
export type BillingMode = 'token' | 'per_request' | 'image'
export type { BillingMode } from '@/constants/channel'
export interface PricingInterval {
id?: number
......@@ -46,8 +47,8 @@ export interface Channel {
id: number
name: string
description: string
status: string
billing_model_source: string // "requested" | "upstream"
status: ChannelStatus
billing_model_source: BillingModelSource
restrict_models: boolean
features_config?: Record<string, unknown>
group_ids: number[]
......
......@@ -26,6 +26,8 @@ import scheduledTestsAPI from './scheduledTests'
import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment'
/**
......@@ -55,6 +57,8 @@ export const adminAPI = {
backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI,
channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI,
payment: adminPaymentAPI
}
......@@ -82,6 +86,8 @@ export {
backupAPI,
tlsFingerprintProfileAPI,
channelsAPI,
channelMonitorAPI,
channelMonitorTemplateAPI,
adminPaymentAPI
}
......
......@@ -470,6 +470,13 @@ export interface SystemSettings {
balance_low_notify_recharge_url: string;
account_quota_notify_enabled: boolean;
account_quota_notify_emails: NotifyEmailEntry[];
// Channel Monitor feature switch
channel_monitor_enabled: boolean;
channel_monitor_default_interval_seconds: number;
// Available Channels feature switch
available_channels_enabled: boolean;
}
export interface UpdateSettingsRequest {
......@@ -620,6 +627,13 @@ export interface UpdateSettingsRequest {
balance_low_notify_recharge_url?: string;
account_quota_notify_enabled?: boolean;
account_quota_notify_emails?: NotifyEmailEntry[];
// Channel Monitor feature switch
channel_monitor_enabled?: boolean;
channel_monitor_default_interval_seconds?: number;
// Available Channels feature switch
available_channels_enabled?: boolean;
}
/**
......
/**
* User-facing Channel Monitor API endpoints
* Read-only views for end users to inspect channel availability/status.
*/
import { apiClient } from './client'
import type { Provider, MonitorStatus } from './admin/channelMonitor'
export type { Provider, MonitorStatus } from './admin/channelMonitor'
export interface UserMonitorExtraModel {
model: string
status: MonitorStatus
latency_ms: number | null
}
export interface MonitorTimelinePoint {
status: MonitorStatus
latency_ms: number | null
ping_latency_ms: number | null
checked_at: string
}
export interface UserMonitorView {
id: number
name: string
provider: Provider
group_name: string
primary_model: string
primary_status: MonitorStatus
primary_latency_ms: number | null
primary_ping_latency_ms: number | null
availability_7d: number
extra_models: UserMonitorExtraModel[]
timeline: MonitorTimelinePoint[]
}
export interface UserMonitorListResponse {
items: UserMonitorView[]
}
export interface UserMonitorModelDetail {
model: string
latest_status: MonitorStatus
latest_latency_ms: number | null
availability_7d: number
availability_15d: number
availability_30d: number
avg_latency_7d_ms: number | null
}
export interface UserMonitorDetail {
id: number
name: string
provider: Provider
group_name: string
models: UserMonitorModelDetail[]
}
/**
* List all monitor views available to the current user.
*/
export async function list(options?: { signal?: AbortSignal }): Promise<UserMonitorListResponse> {
const { data } = await apiClient.get<UserMonitorListResponse>('/channel-monitors', {
signal: options?.signal,
})
return data
}
/**
* Get detailed status (multi-window availability + latency) for a single monitor.
*/
export async function status(id: number): Promise<UserMonitorDetail> {
const { data } = await apiClient.get<UserMonitorDetail>(`/channel-monitors/${id}/status`)
return data
}
export const channelMonitorUserAPI = {
list,
status,
}
export default channelMonitorUserAPI
/**
* User Channels API endpoints (non-admin)
* 用户侧「可用渠道」聚合查询:渠道 + 用户可访问的分组 + 支持模型(含定价)。
*/
import { apiClient } from './client'
import type { BillingMode } from '@/constants/channel'
export interface UserAvailableGroup {
id: number
name: string
platform: string
/** 'standard' | 'subscription' — 订阅分组视觉加深,和 API 密钥页保持一致。 */
subscription_type: string
/** 分组默认倍率。用户专属倍率(若有)通过 /groups/rates 获取后在前端 join。 */
rate_multiplier: number
/** true = 专属分组(小范围授权);false = 公开分组。 */
is_exclusive: boolean
}
export interface UserPricingInterval {
min_tokens: number
max_tokens: number | null
tier_label?: string
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
per_request_price: number | null
}
export interface UserSupportedModelPricing {
billing_mode: BillingMode
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
image_output_price: number | null
per_request_price: number | null
intervals: UserPricingInterval[]
}
export interface UserSupportedModel {
name: string
platform: string
pricing: UserSupportedModelPricing | null
}
/**
* 渠道下单个平台的子视图:用户可访问的分组 + 该平台支持的模型。
* 后端把一个渠道按平台聚合成 sections,前端可以把渠道名作为 row-group
* 一次渲染,后面按 sections 顺序用 rowspan 铺开。
*/
export interface UserChannelPlatformSection {
platform: string
groups: UserAvailableGroup[]
supported_models: UserSupportedModel[]
}
export interface UserAvailableChannel {
name: string
description: string
platforms: UserChannelPlatformSection[]
}
/** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */
export async function getAvailable(options?: { signal?: AbortSignal }): Promise<UserAvailableChannel[]> {
const { data } = await apiClient.get<UserAvailableChannel[]>('/channels/available', {
signal: options?.signal
})
return data
}
export const userChannelsAPI = { getAvailable }
export default userChannelsAPI
......@@ -16,8 +16,10 @@ export { userAPI } from './user'
export { redeemAPI, type RedeemHistoryItem } from './redeem'
export { paymentAPI } from './payment'
export { userGroupsAPI } from './groups'
export { userChannelsAPI } from './channels'
export { totpAPI } from './totp'
export { default as announcementsAPI } from './announcements'
export { channelMonitorUserAPI } from './channelMonitor'
// Admin APIs
export { adminAPI } from './admin'
......
......@@ -27,6 +27,7 @@
@keydown.tab.prevent="addModel"
@keydown.delete="handleBackspace"
@paste="handlePaste"
@blur="addModel"
/>
</div>
<p class="mt-1 text-xs text-gray-400">
......
......@@ -187,3 +187,14 @@ export function getPlatformTagClass(platform: string): string {
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
}
/** 平台对应的模型文字色(仅 text-*,用于 input/text 场景)— 与 getPlatformTagClass 同色系 */
export function getPlatformTextClass(platform: string): string {
switch (platform) {
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
case 'gemini': return 'text-blue-700 dark:text-blue-400'
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
default: return ''
}
}
<template>
<div class="flex items-center gap-1">
<button
@click="$emit('run', row)"
:disabled="running"
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-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="refresh" size="sm" :class="running ? 'animate-spin' : ''" />
<span class="text-xs">{{ t('admin.channelMonitor.runNow') }}</span>
</button>
<button
@click="$emit('edit', 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-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t('common.edit') }}</span>
</button>
<button
@click="$emit('delete', 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"
>
<Icon name="trash" size="sm" />
<span class="text-xs">{{ t('common.delete') }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import Icon from '@/components/icons/Icon.vue'
defineProps<{
row: ChannelMonitor
running: boolean
}>()
defineEmits<{
(e: 'run', row: ChannelMonitor): void
(e: 'edit', row: ChannelMonitor): void
(e: 'delete', row: ChannelMonitor): void
}>()
const { t } = useI18n()
</script>
<template>
<div class="space-y-4">
<!-- Headers key-value rows -->
<div>
<label class="input-label">{{ t('admin.channelMonitor.advanced.headers') }}</label>
<div class="space-y-1.5">
<div
v-for="(row, i) in headerRows"
:key="i"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
spellcheck="false"
:placeholder="t('admin.channelMonitor.advanced.headerNamePlaceholder')"
class="input w-52 flex-none font-mono text-xs"
@blur="commitHeaders"
/>
<input
v-model="row.value"
type="text"
spellcheck="false"
:placeholder="t('admin.channelMonitor.advanced.headerValuePlaceholder')"
class="input flex-1 font-mono text-xs"
@blur="commitHeaders"
/>
<button
type="button"
class="flex-none rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/10 dark:hover:text-red-400"
:title="t('common.delete')"
@click="removeRow(i)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
type="button"
class="inline-flex items-center gap-1 rounded border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-500 hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="addRow"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('admin.channelMonitor.advanced.headerAddRow') }}
</button>
</div>
<p v-if="headersError" class="mt-1 text-xs text-red-500">{{ headersError }}</p>
<p v-else class="mt-1 text-xs text-gray-400">
{{ t('admin.channelMonitor.advanced.headersHint') }}
</p>
</div>
<!-- Body mode radio -->
<div>
<label class="input-label">{{ t('admin.channelMonitor.advanced.bodyMode') }}</label>
<div class="grid grid-cols-3 gap-3">
<button
v-for="opt in bodyModeOptions"
:key="opt.value"
type="button"
class="rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors"
:class="bodyModeButtonClass(opt.value)"
@click="updateBodyMode(opt.value)"
>
{{ opt.label }}
</button>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ bodyModeHint }}
</p>
</div>
<!-- Body JSON (仅当 mode != off) -->
<div v-if="bodyOverrideMode !== 'off'">
<div class="mb-1 flex items-center justify-between">
<label class="input-label !mb-0">{{ t('admin.channelMonitor.advanced.bodyJson') }}</label>
<button
type="button"
class="text-xs text-primary-600 hover:underline disabled:cursor-not-allowed disabled:text-gray-400 disabled:no-underline dark:text-primary-400"
:disabled="!bodyText.trim()"
@click="formatBody"
>
{{ t('admin.channelMonitor.advanced.bodyJsonFormat') }}
</button>
</div>
<textarea
v-model="bodyText"
rows="10"
:placeholder="bodyPlaceholder"
class="input font-mono text-xs"
style="white-space: pre; overflow-wrap: normal; overflow-x: auto;"
spellcheck="false"
@blur="commitBody"
/>
<p v-if="bodyError" class="mt-1 text-xs text-red-500">{{ bodyError }}</p>
<p v-else class="mt-1 text-xs text-gray-400">
{{ t('admin.channelMonitor.advanced.bodyJsonHint') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { BodyOverrideMode } from '@/api/admin/channelMonitor'
const props = defineProps<{
extraHeaders: Record<string, string>
bodyOverrideMode: BodyOverrideMode
bodyOverride: Record<string, unknown> | null
}>()
const emit = defineEmits<{
(e: 'update:extraHeaders', value: Record<string, string>): void
(e: 'update:bodyOverrideMode', value: BodyOverrideMode): void
(e: 'update:bodyOverride', value: Record<string, unknown> | null): void
}>()
const { t } = useI18n()
// ---- Headers key-value rows ----
interface HeaderRow {
name: string
value: string
}
const headerRows = ref<HeaderRow[]>(toRows(props.extraHeaders))
const headersError = ref('')
watch(
() => props.extraHeaders,
(v) => {
// 外部重置时(切换平台 / 应用模板)同步行。
// 同值不回写,避免每次 commit 都把行重排。
if (!isSameHeaderMap(toMap(headerRows.value), v)) {
headerRows.value = toRows(v)
}
headersError.value = ''
},
)
function toRows(h: Record<string, string>): HeaderRow[] {
const entries = Object.entries(h || {})
if (entries.length === 0) return [{ name: '', value: '' }]
return entries.map(([name, value]) => ({ name, value }))
}
function toMap(rows: HeaderRow[]): Record<string, string> {
const out: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
if (name === '') continue
out[name] = row.value
}
return out
}
function isSameHeaderMap(a: Record<string, string>, b: Record<string, string>): boolean {
const ak = Object.keys(a)
const bk = Object.keys(b || {})
if (ak.length !== bk.length) return false
for (const k of ak) {
if (a[k] !== b[k]) return false
}
return true
}
function commitHeaders() {
// 空白 name + 空白 value 的行允许保留作为"占位新行",不报错;
// name 非空但 value 为空(或反之)都视为用户正在编辑,同样不报错。
// 只在 name 里含冒号这种明显不合法时兜一下。
for (const row of headerRows.value) {
const name = row.name.trim()
if (name === '') continue
if (name.includes(':') || /\s/.test(name)) {
headersError.value = t('admin.channelMonitor.advanced.headerNameInvalid', { name })
return
}
}
headersError.value = ''
emit('update:extraHeaders', toMap(headerRows.value))
}
function addRow() {
headerRows.value.push({ name: '', value: '' })
}
function removeRow(index: number) {
headerRows.value.splice(index, 1)
if (headerRows.value.length === 0) {
headerRows.value.push({ name: '', value: '' })
}
commitHeaders()
}
// ---- Body mode + JSON ----
const bodyText = ref(serializeBody(props.bodyOverride))
const bodyError = ref('')
watch(
() => props.bodyOverride,
(v) => {
bodyText.value = serializeBody(v)
bodyError.value = ''
},
)
function commitBody() {
if (props.bodyOverrideMode === 'off') {
return
}
const trimmed = bodyText.value.trim()
if (trimmed === '') {
emit('update:bodyOverride', null)
bodyError.value = ''
return
}
try {
const parsed = JSON.parse(trimmed)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
bodyError.value = t('admin.channelMonitor.advanced.bodyJsonObjectError')
return
}
emit('update:bodyOverride', parsed as Record<string, unknown>)
bodyError.value = ''
} catch (e) {
bodyError.value =
t('admin.channelMonitor.advanced.bodyJsonError') +
': ' +
(e instanceof Error ? e.message : String(e))
}
}
function formatBody() {
const trimmed = bodyText.value.trim()
if (trimmed === '') return
try {
const parsed = JSON.parse(trimmed)
bodyText.value = JSON.stringify(parsed, null, 2)
bodyError.value = ''
// 同步把校验过的对象提交,避免格式化后焦点未移走时父组件读到旧值
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
emit('update:bodyOverride', parsed as Record<string, unknown>)
}
} catch (e) {
bodyError.value =
t('admin.channelMonitor.advanced.bodyJsonError') +
': ' +
(e instanceof Error ? e.message : String(e))
}
}
function serializeBody(body: Record<string, unknown> | null): string {
if (!body || Object.keys(body).length === 0) return ''
return JSON.stringify(body, null, 2)
}
function updateBodyMode(mode: BodyOverrideMode) {
emit('update:bodyOverrideMode', mode)
// 切换到 off 时清掉 body(提示用户)
if (mode === 'off') {
emit('update:bodyOverride', null)
}
}
const bodyModeOptions = computed<{ value: BodyOverrideMode; label: string }[]>(() => [
{ value: 'off', label: t('admin.channelMonitor.advanced.bodyModeOff') },
{ value: 'merge', label: t('admin.channelMonitor.advanced.bodyModeMerge') },
{ value: 'replace', label: t('admin.channelMonitor.advanced.bodyModeReplace') },
])
function bodyModeButtonClass(mode: BodyOverrideMode): string {
const active = props.bodyOverrideMode === mode
if (active) {
return 'border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300 dark:border-primary-400'
}
return 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400'
}
const bodyModeHint = computed(() => {
switch (props.bodyOverrideMode) {
case 'merge':
return t('admin.channelMonitor.advanced.bodyModeHintMerge')
case 'replace':
return t('admin.channelMonitor.advanced.bodyModeHintReplace')
default:
return t('admin.channelMonitor.advanced.bodyModeHintOff')
}
})
const bodyPlaceholder = computed(() => {
if (props.bodyOverrideMode === 'merge') {
return '{\n "system": "You are Claude Code..."\n}'
}
return '{\n "model": "claude-x",\n "messages": [{"role":"user","content":"hi"}],\n "max_tokens": 10\n}'
})
</script>
<template>
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
<!-- Left: Search + Filters -->
<div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-64">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model="search"
type="text"
:placeholder="t('admin.channelMonitor.searchPlaceholder')"
class="input pl-10"
@input="$emit('search-input')"
/>
</div>
<Select
v-model="provider"
:options="providerFilterOptions"
:placeholder="t('admin.channelMonitor.allProviders')"
class="w-44"
@change="$emit('reload')"
/>
<Select
v-model="enabled"
:options="enabledFilterOptions"
:placeholder="t('admin.channelMonitor.enabledFilter')"
class="w-40"
@change="$emit('reload')"
/>
</div>
<!-- Right: Actions -->
<div class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto">
<button
@click="$emit('reload')"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button
@click="$emit('manage-templates')"
class="btn btn-secondary"
:title="t('admin.channelMonitor.template.manageButton')"
>
<Icon name="cog" size="md" class="mr-2" />
{{ t('admin.channelMonitor.template.manageButton') }}
</button>
<button @click="$emit('create')" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.channelMonitor.createButton') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Provider } from '@/api/admin/channelMonitor'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import {
PROVIDER_OPENAI,
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
} from '@/constants/channelMonitor'
defineProps<{
loading: boolean
}>()
defineEmits<{
(e: 'reload'): void
(e: 'create'): void
(e: 'manage-templates'): void
(e: 'search-input'): void
}>()
const search = defineModel<string>('search', { required: true })
const provider = defineModel<Provider | ''>('provider', { required: true })
const enabled = defineModel<'' | 'true' | 'false'>('enabled', { required: true })
const { t } = useI18n()
const providerFilterOptions = computed(() => [
{ value: '', label: t('admin.channelMonitor.allProviders') },
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
])
const enabledFilterOptions = computed(() => [
{ value: '', label: t('admin.channelMonitor.allStatus') },
{ value: 'true', label: t('admin.channelMonitor.onlyEnabled') },
{ value: 'false', label: t('admin.channelMonitor.onlyDisabled') },
])
</script>
<template>
<BaseDialog
:show="show"
:title="editing ? t('admin.channelMonitor.editTitle') : t('admin.channelMonitor.createTitle')"
width="wide"
@close="$emit('close')"
>
<form id="channel-monitor-form" @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.name') }} <span class="text-red-500">*</span></label>
<input v-model="form.name" type="text" required class="input" :placeholder="t('admin.channelMonitor.form.namePlaceholder')" />
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.provider') }} <span class="text-red-500">*</span></label>
<div class="grid grid-cols-3 gap-3">
<button
v-for="opt in providerOptions"
:key="opt.value"
type="button"
:aria-pressed="form.provider === opt.value"
class="flex items-center justify-center gap-2 rounded-lg border-2 px-3 py-2.5 text-sm font-medium transition-colors"
:class="providerPickerClass(opt.value, form.provider === opt.value)"
@click="form.provider = opt.value"
>
<ProviderIcon :provider="opt.value" :size="18" />
<span>{{ opt.label }}</span>
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.endpoint') }} <span class="text-red-500">*</span></label>
<div class="flex gap-2">
<input v-model="form.endpoint" type="text" required class="input flex-1" :placeholder="t('admin.channelMonitor.form.endpointPlaceholder')" />
<button type="button" @click="useCurrentDomain" class="btn btn-secondary whitespace-nowrap">
{{ t('admin.channelMonitor.form.useCurrentDomain') }}
</button>
</div>
</div>
<div>
<label class="input-label">
{{ t('admin.channelMonitor.form.apiKey') }}<span v-if="!editing" class="text-red-500"> *</span>
</label>
<div class="flex gap-2">
<input
v-model="form.api_key"
type="password"
:required="!editing"
class="input flex-1"
:placeholder="editing ? t('admin.channelMonitor.form.apiKeyEditPlaceholder') : t('admin.channelMonitor.form.apiKeyPlaceholder')"
/>
<button type="button" @click="openMyKeyPicker" class="btn btn-secondary whitespace-nowrap">
{{ t('admin.channelMonitor.form.useMyKey') }}
</button>
</div>
<p v-if="editing && editing.api_key_masked" class="mt-1 text-xs text-gray-400">{{ editing.api_key_masked }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
<input
v-model="form.primary_model"
type="text"
required
class="input font-medium"
:class="getPlatformTextClass(form.provider)"
:placeholder="t('admin.channelMonitor.form.primaryModelPlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label>
<ModelTagInput
:models="form.extra_models"
:platform="form.provider"
:placeholder="t('admin.channelMonitor.form.extraModelsPlaceholder')"
@update:models="form.extra_models = $event"
/>
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.groupName') }}</label>
<input v-model="form.group_name" type="text" class="input" :placeholder="t('admin.channelMonitor.form.groupNamePlaceholder')" />
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.intervalSeconds') }} <span class="text-red-500">*</span></label>
<input v-model.number="form.interval_seconds" type="number" min="15" max="3600" required class="input" />
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.form.intervalSecondsHint') }}</p>
</div>
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.channelMonitor.form.enabled') }}</label>
<Toggle v-model="form.enabled" />
</div>
<!-- 高级设置区:请求模板 + 自定义 headers/body -->
<details class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channelMonitor.advanced.section') }}
</summary>
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.advanced.sectionHint') }}</p>
<div class="mt-4 space-y-4">
<div>
<label class="input-label">{{ t('admin.channelMonitor.templateField.label') }}</label>
<Select
v-model="templateSelectValue"
:options="templateOptions"
:placeholder="t('admin.channelMonitor.templateField.placeholder')"
/>
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.templateField.applyHint') }}</p>
</div>
<MonitorAdvancedRequestConfig
:extra-headers="form.extra_headers"
:body-override-mode="form.body_override_mode"
:body-override="form.body_override"
@update:extra-headers="form.extra_headers = $event"
@update:body-override-mode="form.body_override_mode = $event"
@update:body-override="form.body_override = $event"
/>
</div>
</details>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
form="channel-monitor-form"
:disabled="submitting"
class="btn btn-primary"
>
{{ submitting
? t('common.submitting')
: editing ? t('common.update') : t('common.create') }}
</button>
</div>
</template>
</BaseDialog>
<MonitorKeyPickerDialog
:show="showKeyPicker"
:loading="myKeysLoading"
:keys="myActiveKeys"
:provider="form.provider"
:user-group-rates="userGroupRates"
@close="showKeyPicker = false"
@pick="pickMyKey"
/>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import { keysAPI } from '@/api/keys'
import { userGroupsAPI } from '@/api/groups'
import type {
BodyOverrideMode,
ChannelMonitor,
CreateParams,
Provider,
UpdateParams,
} from '@/api/admin/channelMonitor'
import type { ChannelMonitorTemplate } from '@/api/admin/channelMonitorTemplate'
import type { ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Toggle from '@/components/common/Toggle.vue'
import Select from '@/components/common/Select.vue'
import ModelTagInput from '@/components/admin/channel/ModelTagInput.vue'
import { getPlatformTextClass } from '@/components/admin/channel/types'
import MonitorKeyPickerDialog from '@/components/admin/monitor/MonitorKeyPickerDialog.vue'
import MonitorAdvancedRequestConfig from '@/components/admin/monitor/MonitorAdvancedRequestConfig.vue'
import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
import {
PROVIDER_OPENAI,
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
DEFAULT_INTERVAL_SECONDS,
} from '@/constants/channelMonitor'
const props = defineProps<{
show: boolean
monitor: ChannelMonitor | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const { providerPickerClass } = useChannelMonitorFormat()
// System-configured default interval for new monitors. Falls back to the static
// constant when public settings haven't loaded yet or store the legacy 0 value.
const systemDefaultInterval = computed<number>(() => {
const configured = appStore.cachedPublicSettings?.channel_monitor_default_interval_seconds
return configured && configured > 0 ? configured : DEFAULT_INTERVAL_SECONDS
})
// editing is true when we have an existing monitor
const editing = computed<ChannelMonitor | null>(() => props.monitor)
const submitting = ref(false)
// API key picker
const showKeyPicker = ref(false)
const myKeysLoading = ref(false)
const myActiveKeys = ref<ApiKey[]>([])
const userGroupRates = ref<Record<number, number>>({})
interface MonitorForm {
name: string
provider: Provider
endpoint: string
api_key: string
primary_model: string
extra_models: string[]
group_name: string
interval_seconds: number
enabled: boolean
// 高级设置快照
template_id: number | null
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
}
const form = reactive<MonitorForm>({
name: '',
provider: PROVIDER_ANTHROPIC,
endpoint: '',
api_key: '',
primary_model: '',
extra_models: [],
group_name: '',
interval_seconds: systemDefaultInterval.value,
enabled: true,
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
})
// 可用模板列表(进入 dialog 时一次性拉取 cache;按 provider 过滤)。
const templatesCache = ref<ChannelMonitorTemplate[]>([])
const templatesLoading = ref(false)
const templateOptions = computed(() => {
const items = templatesCache.value.filter((t) => t.provider === form.provider)
return [
{ value: '', label: t('admin.channelMonitor.templateField.none') },
...items.map((t) => ({ value: String(t.id), label: t.name })),
]
})
async function loadTemplates() {
if (templatesCache.value.length > 0) return
templatesLoading.value = true
try {
const { items } = await adminAPI.channelMonitorTemplate.list()
templatesCache.value = items
} catch (err: unknown) {
// 模板拉取失败不阻塞监控表单,用户可以不选模板
console.warn('load monitor templates failed', err)
} finally {
templatesLoading.value = false
}
}
// 模板下拉绑定:value 是 string(Select 组件约束),需要与 number | null 互转。
const templateSelectValue = computed<string>({
get: () => (form.template_id == null ? '' : String(form.template_id)),
set: (raw: string) => {
if (raw === '') {
form.template_id = null
return
}
const id = Number(raw)
if (!Number.isFinite(id)) return
form.template_id = id
// 应用模板 = 拷贝快照
const tpl = templatesCache.value.find((t) => t.id === id)
if (tpl) {
form.extra_headers = { ...(tpl.extra_headers || {}) }
form.body_override_mode = tpl.body_override_mode
form.body_override = tpl.body_override ? { ...tpl.body_override } : null
}
},
})
interface ProviderOption {
value: Provider
label: string
}
const providerOptions = computed<ProviderOption[]>(() => [
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
])
// Clear api_key whenever provider changes to avoid cross-provider key mismatch.
// Editing mode loads api_key='' via loadFromMonitor and only sets it on user
// typing, so clearing on provider change is always a safe no-op until the user
// picks a new key.
// 同时清空 template_id(模板有 provider 归属,跨平台不通用)。
watch(() => form.provider, () => {
form.api_key = ''
form.template_id = null
})
function resetForm() {
form.name = ''
form.provider = PROVIDER_ANTHROPIC
form.endpoint = ''
form.api_key = ''
form.primary_model = ''
form.extra_models = []
form.group_name = ''
form.interval_seconds = systemDefaultInterval.value
form.enabled = true
form.template_id = null
form.extra_headers = {}
form.body_override_mode = 'off'
form.body_override = null
}
function loadFromMonitor(m: ChannelMonitor) {
form.name = m.name
form.provider = m.provider
form.endpoint = m.endpoint
form.api_key = ''
form.primary_model = m.primary_model
form.extra_models = [...(m.extra_models || [])]
form.group_name = m.group_name || ''
form.interval_seconds = m.interval_seconds || systemDefaultInterval.value
form.enabled = m.enabled
form.template_id = m.template_id ?? null
form.extra_headers = { ...(m.extra_headers || {}) }
form.body_override_mode = m.body_override_mode || 'off'
form.body_override = m.body_override ? { ...m.body_override } : null
}
// Re-sync form whenever the dialog is opened or the target monitor changes.
// 同时拉取模板列表(cache 过的话一次性返回)。
watch(
() => [props.show, props.monitor] as const,
([show, m]) => {
if (!show) return
void loadTemplates()
if (m) loadFromMonitor(m)
else resetForm()
},
{ immediate: true },
)
function useCurrentDomain() {
form.endpoint = window.location.origin
}
async function openMyKeyPicker() {
showKeyPicker.value = true
if (myActiveKeys.value.length > 0) return
myKeysLoading.value = true
try {
const [res, rates] = await Promise.all([
keysAPI.list(1, 100, { status: 'active' }),
userGroupsAPI.getUserGroupRates(),
])
const items = res.items || []
const now = Date.now()
myActiveKeys.value = items.filter(k => {
if (k.status !== 'active') return false
if (!k.expires_at) return true
return new Date(k.expires_at).getTime() > now
})
userGroupRates.value = rates
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.form.noActiveKey')))
} finally {
myKeysLoading.value = false
}
}
function pickMyKey(k: ApiKey) {
form.api_key = k.key
showKeyPicker.value = false
}
function buildPayload(): CreateParams {
return {
name: form.name.trim(),
provider: form.provider,
endpoint: form.endpoint.trim(),
api_key: form.api_key.trim(),
primary_model: form.primary_model.trim(),
extra_models: form.extra_models,
group_name: form.group_name.trim(),
enabled: form.enabled,
interval_seconds: form.interval_seconds,
template_id: form.template_id,
extra_headers: form.extra_headers,
body_override_mode: form.body_override_mode,
body_override: form.body_override,
}
}
async function handleSubmit() {
if (submitting.value) return
if (!form.name.trim()) {
appStore.showError(t('admin.channelMonitor.nameRequired'))
return
}
if (!form.primary_model.trim()) {
appStore.showError(t('admin.channelMonitor.primaryModelRequired'))
return
}
submitting.value = true
try {
const target = editing.value
if (target) {
const { api_key, ...rest } = buildPayload()
const req: UpdateParams = { ...rest }
// Only send api_key if user typed a new value
if (api_key) req.api_key = api_key
// template_id=null 用 clear_template=true 明确告诉后端清空(pointer 语义)
if (form.template_id == null) {
req.clear_template = true
delete req.template_id
}
await adminAPI.channelMonitor.update(target.id, req)
appStore.showSuccess(t('admin.channelMonitor.updateSuccess'))
} else {
await adminAPI.channelMonitor.create(buildPayload())
appStore.showSuccess(t('admin.channelMonitor.createSuccess'))
}
emit('saved')
emit('close')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
submitting.value = false
}
}
</script>
<template>
<BaseDialog
:show="show"
:title="t('admin.channelMonitor.form.selectKeyTitle')"
width="wide"
@close="$emit('close')"
>
<div class="space-y-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channelMonitor.form.selectKeyHint') }}
</p>
<div class="relative">
<input
v-model="search"
type="text"
class="input pl-9"
:placeholder="t('keys.searchPlaceholder')"
/>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
</div>
<div v-if="loading" class="py-6 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</div>
<div v-else-if="filteredKeys.length === 0" class="py-6 text-center text-sm text-gray-500">
{{ t('admin.channelMonitor.form.noActiveKey') }}
</div>
<div v-else class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-dark-800 sticky top-0 z-10">
<tr class="text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th class="px-3 py-2">{{ t('common.name') }}</th>
<th class="px-3 py-2">{{ t('keys.apiKey') }}</th>
<th class="px-3 py-2">{{ t('keys.group') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-dark-700">
<tr
v-for="k in filteredKeys"
:key="k.id"
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700"
@click="$emit('pick', k)"
>
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">{{ k.name }}</td>
<td class="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">{{ maskApiKey(k.key) }}</td>
<td class="px-3 py-2">
<GroupBadge
v-if="k.group"
:name="k.group.name"
:platform="k.group.platform"
:subscription-type="k.group.subscription_type"
:rate-multiplier="k.group.rate_multiplier"
:user-rate-multiplier="userGroupRates[k.group.id]"
/>
<span v-else class="text-xs text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="$emit('close')" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ApiKey } from '@/types'
import type { Provider } from '@/api/admin/channelMonitor'
import BaseDialog from '@/components/common/BaseDialog.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import { maskApiKey } from '@/utils/maskApiKey'
const props = withDefaults(defineProps<{
show: boolean
loading: boolean
keys: ApiKey[]
provider: Provider
userGroupRates?: Record<number, number>
}>(), {
userGroupRates: () => ({}),
})
defineEmits<{
(e: 'close'): void
(e: 'pick', key: ApiKey): void
}>()
const { t } = useI18n()
const search = ref('')
watch(() => props.show, (shown) => {
if (!shown) search.value = ''
})
const filteredKeys = computed<ApiKey[]>(() => {
const q = search.value.trim().toLowerCase()
return props.keys.filter((k) => {
if (k.group?.platform !== props.provider) return false
if (!q) return true
return (
k.name.toLowerCase().includes(q) ||
k.key.toLowerCase().includes(q) ||
(k.group?.name || '').toLowerCase().includes(q)
)
})
})
</script>
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
<HelpTooltip>
<template #trigger>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
:class="statusBadgeClass(row.primary_status)"
>
{{ statusLabel(row.primary_status) }}
</span>
</template>
<div class="space-y-2">
<div class="text-xs font-semibold text-gray-100">
{{ row.primary_model }}
<span
class="ml-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium"
:class="statusBadgeClass(row.primary_status)"
>
{{ statusLabel(row.primary_status) }}
</span>
</div>
<div v-if="(row.extra_models?.length ?? 0) === 0" class="text-[11px] text-gray-300">
{{ t('monitorCommon.extraModelsEmpty') }}
</div>
<div v-else class="space-y-1">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-400">
{{ t('monitorCommon.extraModelsHeader') }}
</div>
<table class="w-full text-left text-[11px]">
<thead>
<tr class="text-gray-400">
<th class="py-0.5 pr-2 font-medium">{{ t('admin.channelMonitor.columns.primaryModel') }}</th>
<th class="py-0.5 pr-2 font-medium">{{ t('admin.channelMonitor.columns.actions') }}</th>
<th class="py-0.5 font-medium">{{ t('admin.channelMonitor.columns.latency') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in (row.extra_models_status || [])" :key="m.model">
<td class="py-0.5 pr-2 text-gray-100">{{ m.model }}</td>
<td class="py-0.5 pr-2">
<span
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px]"
:class="statusBadgeClass(m.status)"
>
{{ statusLabel(m.status) }}
</span>
</td>
<td class="py-0.5 text-gray-100">{{ formatLatency(m.latency_ms) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</HelpTooltip>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
defineProps<{
row: ChannelMonitor
}>()
const { t } = useI18n()
const { statusLabel, statusBadgeClass, formatLatency } = useChannelMonitorFormat()
</script>
<template>
<BaseDialog
:show="show"
:title="t('admin.channelMonitor.runResultTitle')"
width="normal"
@close="$emit('close')"
>
<div class="space-y-2">
<div
v-for="r in results"
:key="r.model"
class="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-dark-600"
>
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ r.model }}</span>
<span v-if="r.message" class="text-xs text-gray-500 dark:text-gray-400">{{ r.message }}</span>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
:class="statusBadgeClass(r.status)"
>
{{ statusLabel(r.status) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatLatency(r.latency_ms) }} ms</span>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="$emit('close')" class="btn btn-primary">
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { CheckResult } from '@/api/admin/channelMonitor'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
defineProps<{
show: boolean
results: CheckResult[]
}>()
defineEmits<{
(e: 'close'): void
}>()
const { t } = useI18n()
const { statusLabel, statusBadgeClass, formatLatency } = useChannelMonitorFormat()
</script>
<template>
<BaseDialog
:show="show"
:title="t('admin.channelMonitor.template.applyPickerTitle', { name: templateName })"
@close="$emit('close')"
>
<p class="mb-3 text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.channelMonitor.template.applyPickerHint') }}
</p>
<div v-if="loading" class="py-6 text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="monitors.length === 0" class="py-6 text-center text-sm text-gray-400">
{{ t('admin.channelMonitor.template.applyPickerEmpty') }}
</div>
<div v-else>
<!-- 全选/全不选 -->
<div class="mb-2 flex items-center gap-3 text-xs">
<button
type="button"
class="text-primary-600 hover:underline dark:text-primary-400"
@click="selectAll"
>
{{ t('common.selectAll') }}
</button>
<button
type="button"
class="text-gray-500 hover:underline dark:text-gray-400"
@click="selectNone"
>
{{ t('admin.channelMonitor.template.selectNone') }}
</button>
<span class="ml-auto text-gray-500 dark:text-gray-400">
{{ t('admin.channelMonitor.template.selectedCount', {
n: selectedIds.length,
total: monitors.length,
}) }}
</span>
</div>
<ul class="max-h-80 divide-y divide-gray-100 overflow-y-auto rounded-lg border border-gray-200 dark:divide-dark-700 dark:border-dark-700">
<li
v-for="m in monitors"
:key="m.id"
class="flex cursor-pointer items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-800"
@click="toggle(m.id)"
>
<input
type="checkbox"
:checked="selectedSet.has(m.id)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@click.stop="toggle(m.id)"
/>
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<span class="text-xs text-gray-400">{{ m.provider }}</span>
<span
v-if="!m.enabled"
class="ml-auto rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400"
>
{{ t('admin.channelMonitor.onlyDisabled').replace(/^仅|^Only /, '') }}
</span>
</li>
</ul>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" @click="$emit('close')">
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
:disabled="submitting || selectedIds.length === 0"
@click="handleApply"
>
{{ submitting
? t('common.submitting')
: t('admin.channelMonitor.template.applyPickerConfirm', { n: selectedIds.length }) }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import type { AssociatedMonitorBrief } from '@/api/admin/channelMonitorTemplate'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{
show: boolean
templateId: number | null
templateName: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'applied', affected: number): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const submitting = ref(false)
const monitors = ref<AssociatedMonitorBrief[]>([])
const selectedIds = ref<number[]>([])
const selectedSet = computed(() => new Set(selectedIds.value))
watch(
() => [props.show, props.templateId] as const,
([show, id]) => {
if (!show || id == null) return
void fetchMonitors(id)
},
{ immediate: true },
)
async function fetchMonitors(id: number) {
loading.value = true
monitors.value = []
selectedIds.value = []
try {
const { items } = await adminAPI.channelMonitorTemplate.listAssociatedMonitors(id)
monitors.value = items
// 默认全选
selectedIds.value = items.map((m) => m.id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
function toggle(id: number) {
const idx = selectedIds.value.indexOf(id)
if (idx >= 0) selectedIds.value.splice(idx, 1)
else selectedIds.value.push(id)
}
function selectAll() {
selectedIds.value = monitors.value.map((m) => m.id)
}
function selectNone() {
selectedIds.value = []
}
async function handleApply() {
if (props.templateId == null || selectedIds.value.length === 0 || submitting.value) return
submitting.value = true
try {
const { affected } = await adminAPI.channelMonitorTemplate.apply(
props.templateId,
[...selectedIds.value],
)
appStore.showSuccess(t('admin.channelMonitor.template.applySuccess', { n: affected }))
emit('applied', affected)
emit('close')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
submitting.value = false
}
}
</script>
<template>
<BaseDialog
:show="show"
:title="t('admin.channelMonitor.template.managerTitle')"
width="wide"
@close="$emit('close')"
>
<!-- provider tabs -->
<div class="mb-4 border-b border-gray-200 dark:border-dark-700">
<div role="tablist" class="flex gap-1">
<button
v-for="tab in providerTabs"
:key="tab.value"
type="button"
role="tab"
:aria-selected="activeProvider === tab.value"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tabClass(tab.value)"
@click="activeProvider = tab.value"
>
{{ tab.label }}
<span
v-if="countByProvider[tab.value] > 0"
class="ml-1.5 rounded-full bg-gray-100 px-2 py-0.5 text-xs dark:bg-dark-700"
>
{{ countByProvider[tab.value] }}
</span>
</button>
</div>
</div>
<!-- active provider list -->
<div v-if="!editing" class="space-y-2">
<div class="flex justify-end">
<button class="btn btn-primary btn-sm" @click="openCreateForm">
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.channelMonitor.template.createButton') }}
</button>
</div>
<div v-if="loading" class="py-8 text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div
v-else-if="templatesForActiveProvider.length === 0"
class="py-8 text-center text-sm text-gray-400"
>
{{ t('admin.channelMonitor.template.emptyState') }}
</div>
<div
v-for="tpl in templatesForActiveProvider"
v-else
:key="tpl.id"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">{{ tpl.name }}</span>
<span
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs"
:class="modeBadgeClass(tpl.body_override_mode)"
>
{{ modeLabel(tpl.body_override_mode) }}
</span>
<span
v-if="tpl.associated_monitors > 0"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.channelMonitor.template.associatedCount', { n: tpl.associated_monitors }) }}
</span>
</div>
<p v-if="tpl.description" class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ tpl.description }}
</p>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.channelMonitor.template.headersSummary', {
n: Object.keys(tpl.extra_headers || {}).length,
}) }}
</p>
</div>
<div class="flex flex-shrink-0 gap-2">
<button
class="btn btn-secondary btn-sm"
:disabled="tpl.associated_monitors === 0"
:title="t('admin.channelMonitor.template.applyTooltip')"
@click="confirmApply(tpl)"
>
<Icon name="refresh" size="sm" class="mr-1" />
{{ t('admin.channelMonitor.template.applyButton') }}
</button>
<button class="btn btn-secondary btn-sm" @click="openEditForm(tpl)">
{{ t('common.edit') }}
</button>
<button class="btn btn-secondary btn-sm text-red-600" @click="handleDelete(tpl)">
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
<!-- edit / create form -->
<div v-else class="space-y-4">
<div>
<label class="input-label">
{{ t('admin.channelMonitor.template.form.name') }}
<span class="text-red-500">*</span>
</label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.channelMonitor.template.form.namePlaceholder')"
/>
</div>
<div v-if="editing === 'new'">
<label class="input-label">
{{ t('admin.channelMonitor.form.provider') }}
<span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-3 gap-3">
<button
v-for="opt in providerTabs"
:key="opt.value"
type="button"
class="rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors"
:class="providerPickerClass(opt.value, form.provider === opt.value)"
@click="form.provider = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div>
<label class="input-label">
{{ t('admin.channelMonitor.template.form.description') }}
</label>
<input
v-model="form.description"
type="text"
class="input"
:placeholder="t('admin.channelMonitor.template.form.descriptionPlaceholder')"
/>
</div>
<MonitorAdvancedRequestConfig
:extra-headers="form.extra_headers"
:body-override-mode="form.body_override_mode"
:body-override="form.body_override"
@update:extra-headers="form.extra_headers = $event"
@update:body-override-mode="form.body_override_mode = $event"
@update:body-override="form.body_override = $event"
/>
</div>
<template #footer>
<div class="flex w-full items-center justify-between">
<!-- Left: back to list / nothing -->
<div>
<button v-if="editing" class="btn btn-secondary" @click="backToList">
{{ t('common.back') }}
</button>
</div>
<!-- Right: save or close -->
<div class="flex gap-2">
<button class="btn btn-secondary" @click="$emit('close')">
{{ t('common.close') }}
</button>
<button v-if="editing" class="btn btn-primary" :disabled="submitting" @click="handleSubmit">
{{ submitting ? t('common.submitting') : editing === 'new' ? t('common.create') : t('common.update') }}
</button>
</div>
</div>
</template>
</BaseDialog>
<MonitorTemplateApplyPickerDialog
:show="applyPicker.show"
:template-id="applyPicker.tpl ? applyPicker.tpl.id : null"
:template-name="applyPicker.tpl ? applyPicker.tpl.name : ''"
@close="applyPicker.show = false"
@applied="onApplied"
/>
<ConfirmDialog
:show="confirmDelete.show"
:title="t('common.delete')"
:message="confirmDeleteMessage"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="doDelete"
@cancel="confirmDelete.show = false"
/>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import type {
BodyOverrideMode,
Provider,
} from '@/api/admin/channelMonitor'
import type { ChannelMonitorTemplate } from '@/api/admin/channelMonitorTemplate'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import MonitorAdvancedRequestConfig from '@/components/admin/monitor/MonitorAdvancedRequestConfig.vue'
import MonitorTemplateApplyPickerDialog from '@/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
import {
PROVIDER_ANTHROPIC,
PROVIDER_OPENAI,
PROVIDER_GEMINI,
} from '@/constants/channelMonitor'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
/** Fired when any template changed (create / update / delete / apply). */
(e: 'updated'): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const { providerPickerClass } = useChannelMonitorFormat()
const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
])
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
const templates = ref<ChannelMonitorTemplate[]>([])
const loading = ref(false)
const templatesForActiveProvider = computed(() =>
templates.value.filter((t) => t.provider === activeProvider.value),
)
const countByProvider = computed<Record<Provider, number>>(() => {
const out: Record<Provider, number> = {
anthropic: 0,
openai: 0,
gemini: 0,
}
for (const t of templates.value) out[t.provider]++
return out
})
// --- form state ---
interface TemplateForm {
id: number | null
name: string
provider: Provider
description: string
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
}
const editing = ref<null | 'new' | number>(null) // null = list view; 'new' = create; <id> = edit
const submitting = ref(false)
const form = reactive<TemplateForm>(emptyForm(PROVIDER_ANTHROPIC))
function emptyForm(provider: Provider): TemplateForm {
return {
id: null,
name: '',
provider,
description: '',
extra_headers: {},
body_override_mode: 'off',
body_override: null,
}
}
function loadForm(tpl: ChannelMonitorTemplate) {
form.id = tpl.id
form.name = tpl.name
form.provider = tpl.provider
form.description = tpl.description
form.extra_headers = { ...(tpl.extra_headers || {}) }
form.body_override_mode = tpl.body_override_mode
form.body_override = tpl.body_override ? { ...tpl.body_override } : null
}
function openCreateForm() {
Object.assign(form, emptyForm(activeProvider.value))
editing.value = 'new'
}
function openEditForm(tpl: ChannelMonitorTemplate) {
loadForm(tpl)
editing.value = tpl.id
}
function backToList() {
editing.value = null
}
// --- data fetch ---
async function fetchTemplates() {
loading.value = true
try {
const { items } = await adminAPI.channelMonitorTemplate.list()
templates.value = items
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
watch(
() => props.show,
(show) => {
if (show) {
editing.value = null
fetchTemplates()
}
},
{ immediate: true },
)
// --- submit ---
async function handleSubmit() {
if (submitting.value) return
if (!form.name.trim()) {
appStore.showError(t('admin.channelMonitor.template.missingName'))
return
}
submitting.value = true
try {
if (editing.value === 'new') {
await adminAPI.channelMonitorTemplate.create({
name: form.name.trim(),
provider: form.provider,
description: form.description.trim(),
extra_headers: form.extra_headers,
body_override_mode: form.body_override_mode,
body_override: form.body_override,
})
appStore.showSuccess(t('admin.channelMonitor.template.createSuccess'))
} else if (typeof editing.value === 'number') {
await adminAPI.channelMonitorTemplate.update(editing.value, {
name: form.name.trim(),
description: form.description.trim(),
extra_headers: form.extra_headers,
body_override_mode: form.body_override_mode,
body_override: form.body_override,
})
appStore.showSuccess(t('admin.channelMonitor.template.updateSuccess'))
}
await fetchTemplates()
emit('updated')
editing.value = null
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
submitting.value = false
}
}
// --- apply to monitors (picker 流程) ---
const applyPicker = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
show: false,
tpl: null,
})
function confirmApply(tpl: ChannelMonitorTemplate) {
applyPicker.tpl = tpl
applyPicker.show = true
}
// picker 提交后触发:刷新模板列表(拿最新 associated_monitors)+ 通知父组件
async function onApplied(_affected: number) {
await fetchTemplates()
emit('updated')
}
// --- delete ---
const confirmDelete = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
show: false,
tpl: null,
})
function handleDelete(tpl: ChannelMonitorTemplate) {
confirmDelete.tpl = tpl
confirmDelete.show = true
}
const confirmDeleteMessage = computed(() => {
const tpl = confirmDelete.tpl
if (!tpl) return ''
return t('admin.channelMonitor.template.deleteConfirm', {
name: tpl.name,
n: tpl.associated_monitors,
})
})
async function doDelete() {
const tpl = confirmDelete.tpl
confirmDelete.show = false
if (!tpl) return
try {
await adminAPI.channelMonitorTemplate.del(tpl.id)
appStore.showSuccess(t('admin.channelMonitor.template.deleteSuccess'))
await fetchTemplates()
emit('updated')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
// --- misc ---
function tabClass(value: Provider): string {
return activeProvider.value === value
? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}
function modeBadgeClass(mode: BodyOverrideMode): string {
switch (mode) {
case 'merge':
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
case 'replace':
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-300'
default:
return 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
}
}
function modeLabel(mode: BodyOverrideMode): string {
return t(`admin.channelMonitor.advanced.bodyMode${mode.charAt(0).toUpperCase()}${mode.slice(1)}`)
}
</script>
<template>
<div class="card overflow-hidden">
<table class="w-full table-fixed border-collapse text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50/50 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:bg-dark-800/50 dark:text-gray-400">
<th class="w-[180px] px-4 py-3 text-center">{{ columns.name }}</th>
<th class="w-[200px] px-4 py-3 text-left">{{ columns.description }}</th>
<th class="w-[140px] px-4 py-3 text-left">{{ columns.platform }}</th>
<th class="px-4 py-3 text-left">{{ columns.groups }}</th>
<th class="px-4 py-3 text-left">{{ columns.supportedModels }}</th>
</tr>
</thead>
<tbody v-if="loading">
<tr>
<td colspan="5" class="py-10 text-center">
<Icon name="refresh" size="lg" class="inline-block animate-spin text-gray-400" />
</td>
</tr>
</tbody>
<tbody v-else-if="rows.length === 0">
<tr>
<td colspan="5" class="py-12 text-center">
<Icon name="inbox" size="xl" class="mx-auto mb-3 h-12 w-12 text-gray-400" />
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
</td>
</tr>
</tbody>
<!-- 每个渠道一个 tbody:首行 td rowspan 渠道名,后续行只渲染其余三列。
tbody 之间强分隔线表达"渠道边界",tbody 内部用淡分隔线区分平台。 -->
<tbody
v-else
v-for="(channel, chIdx) in rows"
:key="`${channel.name}-${chIdx}`"
class="border-b-2 border-gray-200 last:border-b-0 dark:border-dark-600"
>
<tr
v-for="(section, secIdx) in channel.platforms"
:key="`${channel.name}-${section.platform}`"
class="transition-colors hover:bg-gray-50/40 dark:hover:bg-dark-800/40"
:class="{ 'border-t border-gray-100/70 dark:border-dark-700/50': secIdx > 0 }"
>
<!-- 渠道名:只在第一行渲染并用 rowspan 纵向合并 -->
<td
v-if="secIdx === 0"
:rowspan="channel.platforms.length"
class="px-4 py-3 text-center align-middle font-medium text-gray-900 dark:text-white"
>
{{ channel.name }}
</td>
<!-- 描述:独立一列,同样用 rowspan 纵向合并 -->
<td
v-if="secIdx === 0"
:rowspan="channel.platforms.length"
class="px-4 py-3 align-middle text-xs text-gray-500 dark:text-gray-400"
>
<template v-if="channel.description">{{ channel.description }}</template>
<span v-else class="text-gray-400">-</span>
</td>
<!-- 平台徽章 -->
<td class="align-top px-4 py-3">
<span
:class="[
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium uppercase',
platformBadgeClass(section.platform),
]"
>
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
{{ section.platform }}
</span>
</td>
<!-- 分组:专属分组在前(紫色 shield 行),公开分组在后(灰色 globe 行)。 -->
<td class="align-top px-4 py-3">
<div class="flex flex-col gap-1.5">
<div
v-if="exclusiveGroups(section).length > 0"
class="flex flex-wrap items-center gap-1.5"
>
<span
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-purple-600 dark:text-purple-400"
:title="t('availableChannels.exclusiveTooltip')"
>
<Icon name="shield" size="xs" class="h-3 w-3" />
{{ t('availableChannels.exclusive') }}
</span>
<GroupBadge
v-for="g in exclusiveGroups(section)"
:key="`ex-${g.id}`"
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
</div>
<div
v-if="publicGroups(section).length > 0"
class="flex flex-wrap items-center gap-1.5"
>
<span
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-gray-500 dark:text-gray-400"
:title="t('availableChannels.publicTooltip')"
>
<Icon name="globe" size="xs" class="h-3 w-3" />
{{ t('availableChannels.public') }}
</span>
<GroupBadge
v-for="g in publicGroups(section)"
:key="`pub-${g.id}`"
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
</div>
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
</div>
</td>
<!-- 支持模型 -->
<td class="align-top px-4 py-3">
<div class="flex flex-wrap gap-1">
<SupportedModelChip
v-for="m in section.supported_models"
:key="`${section.platform}-${m.name}`"
:model="m"
:pricing-key-prefix="pricingKeyPrefix"
:no-pricing-label="noPricingLabel"
:show-platform="false"
:platform-hint="section.platform"
/>
<span v-if="section.supported_models.length === 0" class="text-xs text-gray-400">
{{ noModelsLabel }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import SupportedModelChip from './SupportedModelChip.vue'
import type { UserAvailableChannel, UserAvailableGroup, UserChannelPlatformSection } from '@/api/channels'
import type { GroupPlatform, SubscriptionType } from '@/types'
import { platformBadgeClass } from '@/utils/platformColors'
const props = defineProps<{
columns: {
name: string
description: string
platform: string
groups: string
supportedModels: string
}
rows: UserAvailableChannel[]
loading: boolean
pricingKeyPrefix: string
noPricingLabel: string
noModelsLabel: string
emptyLabel: string
/** 用户专属倍率(group_id → multiplier);无专属时由 GroupBadge 仅显示默认倍率。 */
userGroupRates: Record<number, number>
}>()
// Suppress unused warning — props is accessed via template automatically but
// the explicit reference here keeps the linter from flagging userGroupRates.
void props.userGroupRates
const { t } = useI18n()
function exclusiveGroups(section: UserChannelPlatformSection): UserAvailableGroup[] {
return section.groups.filter((g) => g.is_exclusive)
}
function publicGroups(section: UserChannelPlatformSection): UserAvailableGroup[] {
return section.groups.filter((g) => !g.is_exclusive)
}
</script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment