feat!: 账号池改渠道网关,对外协议换成 vivid /v1
- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
概览/文档/测试弹窗适配
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({ account: { type: Object, required: true } })
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
// concurrency is only adjustable for custom upstreams — others are system-fixed
|
||||
// (adobe is set from the plan on every quota refresh: 会员号 5 / 普号 1).
|
||||
const canEditConcurrency = props.account.type === 'custom'
|
||||
const fixedConcurrency =
|
||||
props.account.type === 'adobe'
|
||||
? (props.account.plan === 'free' ? 1 : Number(props.account.concurrency) || 5)
|
||||
: props.account.type === 'grok'
|
||||
? 10
|
||||
: 1
|
||||
|
||||
const weight = ref(Number(props.account.weight) || 0)
|
||||
const concurrency = ref(Number(props.account.concurrency) || 1)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true; status.value = ''; isError.value = false
|
||||
const payload = { weight: Number(weight.value) || 0 }
|
||||
if (canEditConcurrency) payload.concurrency = Math.max(1, Number(concurrency.value) || 1)
|
||||
try {
|
||||
const r = await api(`/tokens/${props.account.pool}/${props.account.id}`, jsonBody('PATCH', payload))
|
||||
if (r.ok) {
|
||||
status.value = '✓ 已保存'; emit('saved', payload)
|
||||
setTimeout(() => emit('close'), 600)
|
||||
} else {
|
||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
status.value = String(e); isError.value = true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">编辑账号</h2>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div>
|
||||
<label class="lbl">账户</label>
|
||||
<input :value="account.email || account.id" disabled class="field font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">类型</label>
|
||||
<input :value="account.type" disabled class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">权重 <span class="text-white/35">(高的优先)</span></label>
|
||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">并发数 <span class="text-white/35">{{ canEditConcurrency ? '(单账号)' : '(系统固定,不可调整)' }}</span></label>
|
||||
<input v-if="canEditConcurrency" v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||
<input v-else :value="fixedConcurrency" disabled class="field" />
|
||||
</div>
|
||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-400' : 'text-emerald-400'">{{ status }}</p>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="emit('close')" class="btn-soft">取消</button>
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary">{{ submitting ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* mirror the 用户管理 edit modal so the two are visually identical */
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
/* disabled inputs — readable, but visually 'cool' so the admin knows they
|
||||
can't change them (matches UsersView). */
|
||||
.field:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
// 账号生成测试:固定使用某个 provider 账号跑一次生成(图像或视频)。
|
||||
// 模型列表由 AccountsView 打开页面时预加载好传入,弹窗即开即用。
|
||||
const props = defineProps({
|
||||
account: { type: Object, required: true }, // { pool, id, email, type }
|
||||
allModels: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 该账号所属 provider 的全部模型(图像 + 视频)。
|
||||
const models = computed(() =>
|
||||
props.allModels.filter((m) => (m.provider || '') === props.account.pool))
|
||||
const selectedModel = ref('')
|
||||
if (models.value.length) selectedModel.value = models.value[0].alias || models.value[0].id
|
||||
|
||||
const modelOptions = computed(() => models.value.map((m) => ({
|
||||
value: m.alias || m.id,
|
||||
label: `${m.alias || m.id}${m.type === 'video' ? ' · 视频' : ''}`,
|
||||
})))
|
||||
const currentModel = computed(() =>
|
||||
models.value.find((m) => (m.alias || m.id) === selectedModel.value) || null)
|
||||
const isVideo = computed(() => currentModel.value?.type === 'video')
|
||||
|
||||
const prompt = ref('a cute cat sitting on a desk, studio lighting')
|
||||
|
||||
const busy = ref(false)
|
||||
const status = ref('')
|
||||
const error = ref('')
|
||||
const resultUrl = ref('')
|
||||
const resultKind = ref('')
|
||||
|
||||
// Gateway-timeout recovery — same policy as TestModal: the backend detaches the
|
||||
// render from the request, so on a 524/504 we keep polling /jobs/mine?source=admin.
|
||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||
let recoverTimer = null
|
||||
let recoverJobId = ''
|
||||
let recoverSubmitTs = 0
|
||||
onUnmounted(() => clearTimeout(recoverTimer))
|
||||
|
||||
function firstDuration(m) {
|
||||
const keys = Object.keys(m?.duration_prices || {})
|
||||
.sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
return keys[0] || '5s'
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!selectedModel.value) { error.value = '请选择模型'; return }
|
||||
if (!prompt.value.trim()) { error.value = '请输入指令'; return }
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
status.value = isVideo.value ? '正在生成视频 (约 1–3 分钟)…' : '正在生成…'
|
||||
resultUrl.value = ''
|
||||
resultKind.value = ''
|
||||
const m = currentModel.value
|
||||
const payload = {
|
||||
model: selectedModel.value,
|
||||
prompt: prompt.value,
|
||||
ratio: (m?.ratios && m.ratios[0]) || (isVideo.value ? '16x9' : '1:1'),
|
||||
resolution: (m?.resolutions && m.resolutions[0]) || '',
|
||||
account_id: props.account.id,
|
||||
}
|
||||
if (isVideo.value) payload.duration = firstDuration(m)
|
||||
recoverSubmitTs = Date.now()
|
||||
const r = await api('/test', jsonBody('POST', payload))
|
||||
if (r.ok && r.data?.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = r.data.url
|
||||
resultKind.value = r.data.kind || (isVideo.value ? 'video' : 'image')
|
||||
status.value = `完成 · ${r.data.provider} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s`
|
||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||
status.value = isVideo.value ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
recoverJobId = ''
|
||||
clearTimeout(recoverTimer)
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
} else {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = r.data?.detail || `失败 (${r.status})`
|
||||
}
|
||||
}
|
||||
|
||||
async function recover() {
|
||||
const r = await api('/jobs/mine?source=admin')
|
||||
if (!r.ok) { recoverTimer = setTimeout(recover, 3000); return }
|
||||
const { pending, latest } = r.data || {}
|
||||
if (pending) {
|
||||
recoverJobId = pending.id
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
return
|
||||
}
|
||||
const mine = latest && (
|
||||
(recoverJobId && latest.id === recoverJobId) ||
|
||||
(!recoverJobId && latest.ts && latest.ts * 1000 >= recoverSubmitTs - 2000)
|
||||
)
|
||||
if (mine && latest.status === 'success' && latest.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = latest.url
|
||||
resultKind.value = latest.kind || (isVideo.value ? 'video' : 'image')
|
||||
status.value = `完成 · ${(latest.elapsed_ms / 1000).toFixed(1)}s`
|
||||
return
|
||||
}
|
||||
if (mine && latest.status === 'failed') {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = latest.error || '生成失败'
|
||||
return
|
||||
}
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold">账号生成测试</h2>
|
||||
<div class="text-xs text-white/45 font-mono truncate">{{ account.email || account.id }} · {{ account.pool }}</div>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="lbl">模型</label>
|
||||
<div v-if="!models.length" class="text-xs text-amber-300">该账号所属 provider ({{ account.pool }}) 没有可用的模型</div>
|
||||
<SelectMenu v-else v-model="selectedModel" :options="modelOptions" placeholder="选择模型" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="lbl">指令</label>
|
||||
<textarea v-model="prompt" rows="3" class="field resize-none" placeholder="输入测试指令…"></textarea>
|
||||
</div>
|
||||
|
||||
<button @click="run" :disabled="busy || !models.length" class="btn-primary w-full">
|
||||
<Icon name="spark" class="w-4 h-4" /> {{ busy ? (isVideo ? '生成中…(请耐心等待)' : '生成中…') : '生成' }}
|
||||
</button>
|
||||
|
||||
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
|
||||
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
|
||||
|
||||
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
|
||||
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
|
||||
class="max-w-full max-h-[420px] object-contain" />
|
||||
<img v-else :src="resultUrl" class="max-w-full max-h-[360px] object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,215 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
// 17 ratios — the union of what our models actually support; kept in sync with
|
||||
// the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it).
|
||||
const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:1', '8:1', '4:5', '3:4', '2:3', '9:16', '1:3', '1:4', '1:8']
|
||||
const IMG_RES = ['1K', '2K', '4K']
|
||||
const VID_RES = ['720p', '768p', '1080p', '2K', '4K']
|
||||
const ALL_RES = ['1K', '2K', '4K', '720p', '768p', '1080p']
|
||||
const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s']
|
||||
|
||||
const id = ref('')
|
||||
const alias = ref('')
|
||||
const type = ref('image')
|
||||
const ratios = ref(['1:1', '16:9', '9:16'])
|
||||
const maxRefs = ref(0)
|
||||
const maxRefVids = ref(0)
|
||||
const maxRefAuds = ref(0)
|
||||
const weight = ref(0)
|
||||
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
||||
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
||||
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }])))
|
||||
// Duration rows shown: the presets above + any custom seconds the admin adds.
|
||||
const durList = ref([...DUR_OPTS])
|
||||
const customDurInput = ref('')
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// Add a custom duration (any positive integer seconds, e.g. 12 → "12s").
|
||||
function addCustomDur() {
|
||||
const n = parseInt(customDurInput.value, 10)
|
||||
if (!(n > 0)) return
|
||||
const key = n + 's'
|
||||
if (!durList.value.includes(key)) {
|
||||
if (!dur.value[key]) dur.value[key] = { price: '', agent: '' }
|
||||
durList.value.push(key)
|
||||
}
|
||||
customDurInput.value = ''
|
||||
}
|
||||
// Custom (non-preset) durations can be removed; presets stay.
|
||||
function removeDur(key) {
|
||||
if (DUR_OPTS.includes(key)) return
|
||||
durList.value = durList.value.filter((k) => k !== key)
|
||||
delete dur.value[key]
|
||||
}
|
||||
|
||||
const isVideo = computed(() => type.value === 'video')
|
||||
// Resolution tiers depend on type: image = 1K/2K/4K, video = 720p/768p/1080p/2K/4K.
|
||||
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
||||
|
||||
function toggleRatio(r) {
|
||||
const i = ratios.value.indexOf(r)
|
||||
if (i >= 0) ratios.value.splice(i, 1)
|
||||
else ratios.value.push(r)
|
||||
}
|
||||
|
||||
// collect checked tiers into { tier: price } and { tier: agentPrice }
|
||||
// blank price = tier not supported (skipped), matching the edit form.
|
||||
function collect(tiers, allowed) {
|
||||
const prices = {}, agent = {}, keys = []
|
||||
for (const [k, v] of Object.entries(tiers)) {
|
||||
if (allowed && !allowed.includes(k)) continue
|
||||
const raw = String(v.price ?? '').trim()
|
||||
if (raw === '') continue
|
||||
const n = Number(raw)
|
||||
if (Number.isNaN(n) || n < 0) continue
|
||||
prices[k] = n; keys.push(k)
|
||||
const ar = String(v.agent ?? '').trim()
|
||||
if (ar !== '') { const a = Number(ar); if (!Number.isNaN(a) && a >= 0) agent[k] = a }
|
||||
}
|
||||
return { prices, agent, keys }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const mid = id.value.trim()
|
||||
if (!mid) { error.value = '请填写模型 id'; return }
|
||||
const r = collect(res.value, resOpts.value)
|
||||
if (!r.keys.length) { error.value = '请至少勾选一个分辨率并填价格'; return }
|
||||
const body = {
|
||||
id: mid,
|
||||
name: mid,
|
||||
alias: alias.value.trim(),
|
||||
type: type.value,
|
||||
provider: 'custom',
|
||||
prices: r.prices,
|
||||
prices_agent: r.agent,
|
||||
ratios: ratios.value.slice(),
|
||||
max_reference_images: Number(maxRefs.value) || 0,
|
||||
max_reference_videos: Number(maxRefVids.value) || 0,
|
||||
max_reference_audios: Number(maxRefAuds.value) || 0,
|
||||
// asset 语义 = 通用参考文件;vivid 上游按 refs[] 直传,模式字段不再做区分
|
||||
reference_mode: (Number(maxRefs.value) + Number(maxRefVids.value) + Number(maxRefAuds.value)) > 0 ? 'asset' : 'none',
|
||||
weight: Number(weight.value) || 0,
|
||||
image_to_image: (Number(maxRefs.value) + Number(maxRefVids.value) + Number(maxRefAuds.value)) > 0,
|
||||
}
|
||||
if (isVideo.value) {
|
||||
body.resolutions = r.keys
|
||||
const d = collect(dur.value)
|
||||
if (!d.keys.length) { error.value = '视频请至少勾选一个时长并填价格'; return }
|
||||
body.duration_prices = d.prices
|
||||
body.duration_prices_agent = d.agent
|
||||
body.durations = d.keys
|
||||
}
|
||||
saving.value = true; error.value = ''
|
||||
try {
|
||||
const resp = await api('/managed-models', jsonBody('POST', body))
|
||||
if (resp.ok || resp.data?.ok || resp.status === 200) emit('saved')
|
||||
else error.value = resp.data?.detail || '创建失败'
|
||||
} catch (e) { error.value = String(e) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-10 mb-10 w-full max-w-xl">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">添加自定义模型(上游 / provider=custom)</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。设了别名后,对外只用别名调用(原 id 调不到),但内部仍按 id 路由到上游,不影响。
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">模型 id <span class="text-rose-500">*</span></label>
|
||||
<input v-model="id" class="field font-mono text-xs h-10" placeholder="gpt-image-2" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="text-xs text-slate-500 block mb-1">别名(选填)</label>
|
||||
<input v-model="alias" class="field font-mono text-xs h-10" placeholder="对外调用名" />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<label class="text-xs text-slate-500 block mb-1">类型</label>
|
||||
<SelectMenu v-model="type" :options="[{value:'image',label:'图像'},{value:'video',label:'视频'}]" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="text-xs text-slate-500 block mb-1">权重</label>
|
||||
<input v-model.number="weight" type="number" class="field h-10" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">比例(多选)</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in RATIO_OPTS" :key="r" type="button" @click="toggleRatio(r)"
|
||||
class="px-2 py-1 rounded text-xs ring-1 transition-colors"
|
||||
:class="ratios.includes(r) ? 'bg-sky-500/15 text-sky-600 ring-sky-300' : 'bg-slate-50 text-slate-500 ring-slate-200 hover:ring-slate-300'">{{ r }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">分辨率 · 价格(填普通价 = 支持该档,<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="r in resOpts" :key="r" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ r }}</span>
|
||||
<input v-model="res[r].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="res[r].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isVideo">
|
||||
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="d in durList" :key="d" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span>
|
||||
<input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
<button v-if="!DUR_OPTS.includes(d)" type="button" @click="removeDur(d)"
|
||||
class="shrink-0 w-7 h-7 grid place-items-center rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50" title="删除该时长">
|
||||
<Icon name="close" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自定义时长:输入任意秒数,Sora 类上游支持的任意时长都能加 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<input v-model="customDurInput" type="number" min="1" @keydown.enter.prevent="addCustomDur"
|
||||
class="field !py-1 w-32" placeholder="自定义秒数" />
|
||||
<button type="button" @click="addCustomDur" class="btn-soft text-xs whitespace-nowrap">+ 添加时长</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1">参考文件上限(各类数量,vivid 上游支持三种;openai 协议只收图)</label>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-[11px] text-slate-400 block mb-1">参考图</label>
|
||||
<input v-model.number="maxRefs" type="number" min="0" class="field h-10" placeholder="0" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-[11px] text-slate-400 block mb-1">参考视频</label>
|
||||
<input v-model.number="maxRefVids" type="number" min="0" class="field h-10" placeholder="0" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-[11px] text-slate-400 block mb-1">参考音频</label>
|
||||
<input v-model.number="maxRefAuds" type="number" min="0" class="field h-10" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="save" :disabled="saving" class="btn-primary w-full">{{ saving ? '创建中…' : '创建模型' }}</button>
|
||||
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,166 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { parseImportInput } from '../utils/import'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const input = ref('')
|
||||
const weight = ref(0)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// type → token pool (for the post-import weight PATCH).
|
||||
const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok', creativefabrica: 'creativefabrica' }
|
||||
|
||||
// Live preview of what the parser would extract — updates as the user types
|
||||
// so they can see whether their paste was understood before clicking import.
|
||||
const detected = computed(() => {
|
||||
const items = parseImportInput(input.value)
|
||||
const openai = items.filter((x) => x.type === 'openai').length
|
||||
const adobe = items.filter((x) => x.type === 'adobe').length
|
||||
const runway = items.filter((x) => x.type === 'runway').length
|
||||
const leonardo = items.filter((x) => x.type === 'leonardo').length
|
||||
const krea = items.filter((x) => x.type === 'krea').length
|
||||
const imagine = items.filter((x) => x.type === 'imagine').length
|
||||
const grok = items.filter((x) => x.type === 'grok').length
|
||||
const cf = items.filter((x) => x.type === 'creativefabrica').length
|
||||
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok, cf }
|
||||
})
|
||||
|
||||
function setStatus(text, err = false) {
|
||||
status.value = text || ''
|
||||
isError.value = err
|
||||
}
|
||||
|
||||
async function doSmartImport() {
|
||||
const items = parseImportInput(input.value)
|
||||
if (!items.length) {
|
||||
setStatus('未识别到任何 Cookie 或 JWT', true)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
let ok = 0, fail = 0
|
||||
const errs = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i]
|
||||
try {
|
||||
const r = it.type === 'openai'
|
||||
? await api('/tokens/import-chatgpt-token', jsonBody('POST', { access_token: it.value }))
|
||||
: it.type === 'grok'
|
||||
? await api('/tokens/import-grok-token', jsonBody('POST', { access_token: it.value }))
|
||||
: it.type === 'runway'
|
||||
? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value }))
|
||||
: it.type === 'leonardo'
|
||||
? await api('/tokens/import-leonardo-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: it.type === 'krea'
|
||||
? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: it.type === 'imagine'
|
||||
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
|
||||
: it.type === 'creativefabrica'
|
||||
? await api('/tokens/import-creativefabrica-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
if (r.ok) {
|
||||
ok++
|
||||
// Apply the chosen weight to the freshly-imported account (best-effort).
|
||||
const w = Number(weight.value) || 0
|
||||
const pool = TYPE_POOL[it.type]
|
||||
if (w !== 0 && pool && r.data?.id) {
|
||||
try { await api(`/tokens/${pool}/${r.data.id}`, jsonBody('PATCH', { weight: w })) } catch (_) {}
|
||||
}
|
||||
} else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
|
||||
} catch (e) {
|
||||
fail++; errs.push(`${it.type}: ${e}`)
|
||||
}
|
||||
}
|
||||
submitting.value = false
|
||||
// Quota isn't checked here — the server probes each token off-thread and the
|
||||
// account list flips pending → active/dead on its own.
|
||||
if (fail === 0) {
|
||||
// Close immediately on success — the account lands in the table right away
|
||||
// and the server backfills quota/email off-thread (pending → active/dead).
|
||||
emit('imported')
|
||||
emit('close')
|
||||
} else {
|
||||
setStatus(`成功 ${ok} · 失败 ${fail} · ${errs.slice(0, 3).join(' | ')}`, true)
|
||||
emit('imported')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-2xl">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">导入账号</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<p class="text-xs text-slate-500 mb-3 leading-relaxed">自动识别:
|
||||
<strong class="text-slate-700">Adobe Cookie 字符串</strong>(<code class="px-1 bg-slate-100 rounded">k=v; k=v; ...</code>)、
|
||||
<strong class="text-slate-700">Cookie JSON 对象</strong>、
|
||||
<strong class="text-slate-700">Cookie 数组</strong>(多 Adobe 批量)、
|
||||
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)、
|
||||
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
|
||||
<strong class="text-slate-700">Leonardo Cookie</strong>(须含 better-auth.session_data)、
|
||||
<strong class="text-slate-700">Krea Cookie</strong>(含 sb-superb-auth)、
|
||||
<strong class="text-slate-700">Creative Fabrica Cookie</strong>(含 cfauth_* 或 wordpress_logged_in_)、
|
||||
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)、
|
||||
<strong class="text-slate-700">Grok SSO</strong>(grok.com 的 <code class="px-1 bg-slate-100 rounded">sso</code> 值,仅含 session_id,自动与 ChatGPT/Runway 区分)、
|
||||
<strong class="text-slate-700">多个 JWT</strong>(换行分隔)。
|
||||
全粘进来即可,无需任何前缀。
|
||||
</p>
|
||||
<p class="text-xs text-rose-500 mb-3 leading-relaxed bg-rose-50 rounded px-3 py-2 border border-rose-200">
|
||||
📢 需要购买 Adobe 号请联系 QQ:28998306
|
||||
</p>
|
||||
<textarea v-model="input" rows="10"
|
||||
class="field font-mono text-xs resize-none"
|
||||
placeholder="直接粘 Cookie 字符串 / JWT / JSON,自动识别"></textarea>
|
||||
<div v-if="input.trim()" class="mt-2 flex items-center gap-2 text-xs">
|
||||
<template v-if="detected.total">
|
||||
<span class="text-emerald-600">✓ 识别到 <strong class="tabular-nums">{{ detected.total }}</strong> 个账号</span>
|
||||
<span v-if="detected.openai" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200">
|
||||
OpenAI · <span class="tabular-nums">{{ detected.openai }}</span>
|
||||
</span>
|
||||
<span v-if="detected.adobe" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
|
||||
Adobe · <span class="tabular-nums">{{ detected.adobe }}</span>
|
||||
</span>
|
||||
<span v-if="detected.runway" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-orange-700 bg-orange-50 ring-1 ring-orange-200">
|
||||
Runway · <span class="tabular-nums">{{ detected.runway }}</span>
|
||||
</span>
|
||||
<span v-if="detected.leonardo" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-amber-700 bg-amber-50 ring-1 ring-amber-200">
|
||||
Leonardo · <span class="tabular-nums">{{ detected.leonardo }}</span>
|
||||
</span>
|
||||
<span v-if="detected.krea" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-sky-700 bg-sky-50 ring-1 ring-sky-200">
|
||||
Krea · <span class="tabular-nums">{{ detected.krea }}</span>
|
||||
</span>
|
||||
<span v-if="detected.imagine" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-teal-700 bg-teal-50 ring-1 ring-teal-200">
|
||||
Imagine · <span class="tabular-nums">{{ detected.imagine }}</span>
|
||||
</span>
|
||||
<span v-if="detected.grok" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-slate-700 bg-slate-100 ring-1 ring-slate-300">
|
||||
Grok · <span class="tabular-nums">{{ detected.grok }}</span>
|
||||
</span>
|
||||
<span v-if="detected.cf" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
|
||||
Creative Fabrica · <span class="tabular-nums">{{ detected.cf }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<label class="text-xs text-slate-500 whitespace-nowrap">权重(本批账号,高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field !w-24" placeholder="0" />
|
||||
</div>
|
||||
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
|
||||
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
|
||||
</button>
|
||||
<p v-if="status" class="text-xs mt-2" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,11 +61,12 @@ const resolutions = computed(() => {
|
||||
// offerable palette — for custom models the palette is just the input grid.
|
||||
const storedResolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
||||
// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用
|
||||
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。
|
||||
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。渠道导入的
|
||||
// duration range 模型(duration_min/max,无固定档)也只能走 per_second。
|
||||
const isPerSecond = computed(() => {
|
||||
const e = entry.value
|
||||
if (!e) return false
|
||||
return !!e.per_second || e.duration_prices?.per_second != null
|
||||
return !!e.per_second || e.duration_prices?.per_second != null || (e.type === 'video' && (e.duration_max || 0) > 0 && !(e.durations || []).length)
|
||||
})
|
||||
|
||||
const durationTiers = computed(() => {
|
||||
@@ -221,7 +222,7 @@ async function save() {
|
||||
placeholder="选择一个支持的模型" />
|
||||
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
|
||||
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
|
||||
所有支持的模型都已添加。
|
||||
模型经「渠道管理 → 拉取上游模型」导入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +260,8 @@ async function save() {
|
||||
<span class="text-white/40">时长</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
|
||||
<span v-if="!(entry.durations || []).length" class="text-white/30">—</span>
|
||||
<span v-if="!(entry.durations || []).length && (entry.duration_max || 0) > 0" class="ro-chip">{{ entry.duration_min }}–{{ entry.duration_max }}s(区间)</span>
|
||||
<span v-if="!(entry.durations || []).length && !(entry.duration_max || 0)" class="text-white/30">—</span>
|
||||
</div>
|
||||
<span class="text-white/40">参考图</span>
|
||||
<div class="text-white/70">
|
||||
|
||||
@@ -16,11 +16,16 @@ const isVideo = props.model.type === 'video'
|
||||
// (core/video_models.FAMILIES), not the user-edited managed-model record
|
||||
// which may have stale max_reference_images / reference_mode values.
|
||||
const familyPreset = ref(null)
|
||||
const channels = ref([]) // 渠道 pin 候选(空 = 自动调度)
|
||||
const channelId = ref('')
|
||||
onMounted(async () => {
|
||||
if (!isVideo) return
|
||||
const r = await api('/video-presets')
|
||||
const list = r.data?.data || []
|
||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
||||
if (isVideo) {
|
||||
const r = await api('/video-presets')
|
||||
const list = r.data?.data || []
|
||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
||||
}
|
||||
const cr = await api('/channels')
|
||||
channels.value = (cr.data?.data || []).filter((c) => c.enabled)
|
||||
})
|
||||
|
||||
const ratios = (props.model.ratios && props.model.ratios.length)
|
||||
@@ -137,6 +142,7 @@ async function run() {
|
||||
// Backend accepts raw base64 only — strip the "data:...;base64," prefix.
|
||||
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
||||
}
|
||||
if (channelId.value) payload.channel_id = channelId.value
|
||||
recoverSubmitTs = Date.now()
|
||||
const r = await api('/test', jsonBody('POST', payload))
|
||||
if (r.ok && r.data?.url) {
|
||||
@@ -239,6 +245,15 @@ async function recover() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 渠道 pin:默认自动调度,选定了就只走该渠道(不故障转移) -->
|
||||
<div v-if="channels.length > 0">
|
||||
<label class="lbl">渠道</label>
|
||||
<select v-model="channelId" class="field">
|
||||
<option value="">自动调度</option>
|
||||
<option v-for="c in channels" :key="c.id" :value="c.id">{{ c.name }}({{ c.base_url }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- reference images -->
|
||||
<div v-if="maxRefs > 0">
|
||||
<label class="lbl">
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({ account: { type: Object, default: null } }) // edit mode when set
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const isEdit = !!props.account
|
||||
const name = ref(props.account?.email || '')
|
||||
const baseUrl = ref(props.account?.base_url || '')
|
||||
const protocol = ref(props.account?.protocol === 'vivid' ? 'vivid' : 'openai')
|
||||
const key = ref('') // edit: blank = keep existing key
|
||||
const allModels = ref([]) // existing models to pick from
|
||||
const selected = ref(props.account?.models ? String(props.account.models).split(',').map((x) => x.trim()).filter(Boolean) : [])
|
||||
const weight = ref(Number(props.account?.weight) || 0)
|
||||
const concurrency = ref(Number(props.account?.concurrency) || 1)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await api('/managed-models')
|
||||
allModels.value = (r.data?.data || []).map((m) => ({ id: m.id, type: m.type, alias: m.alias }))
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
function toggle(id) {
|
||||
const i = selected.value.indexOf(id)
|
||||
if (i >= 0) selected.value.splice(i, 1)
|
||||
else selected.value.push(id)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!baseUrl.value.trim() || (!isEdit && !key.value.trim())) {
|
||||
status.value = isEdit ? '请填写 URL' : '请填写 URL 和 Key'; isError.value = true; return
|
||||
}
|
||||
submitting.value = true; status.value = ''; isError.value = false
|
||||
try {
|
||||
const r = await api('/tokens/import-custom-account', jsonBody('POST', {
|
||||
id: isEdit ? props.account.id : undefined,
|
||||
name: name.value.trim(),
|
||||
base_url: baseUrl.value.trim(),
|
||||
key: key.value.trim(), // blank in edit = keep existing
|
||||
models: selected.value.join(','),
|
||||
protocol: protocol.value,
|
||||
weight: Number(weight.value) || 0,
|
||||
concurrency: Number(concurrency.value) || 1,
|
||||
}))
|
||||
if (r.ok) {
|
||||
status.value = isEdit ? '✓ 已保存' : '✓ 已添加上游'; emit('imported')
|
||||
setTimeout(() => emit('close'), 700)
|
||||
} else {
|
||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
status.value = String(e); isError.value = true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游' }}</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
上游就是一个账号:填 v1 URL + Key。模型按 <strong class="text-slate-700">id 相同</strong>自动路由 ——
|
||||
在「模型管理」加一个 provider=custom、id 与上游一致的模型即可从这个上游调用。调用<strong class="text-slate-700">直连不走代理</strong>。
|
||||
</p>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">备注名</label>
|
||||
<input v-model="name" class="field" placeholder="例如:我的中转 / xx-api" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">协议</label>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="p in [{id:'openai',label:'OpenAI',desc:'/v1/images/* 标准格式'},{id:'vivid',label:'Vivid',desc:'/v1/generate NDJSON 流'}]" :key="p.id" type="button"
|
||||
@click="protocol = p.id"
|
||||
class="flex-1 rounded-lg ring-1 px-3 py-2 text-left transition-colors"
|
||||
:class="protocol === p.id ? 'ring-sky-400 bg-sky-500/10' : 'ring-slate-200 bg-white hover:ring-slate-300'">
|
||||
<div class="text-xs font-medium" :class="protocol === p.id ? 'text-sky-700' : 'text-slate-700'">{{ p.label }}</div>
|
||||
<div class="text-[11px] text-slate-400 font-mono mt-0.5">{{ p.desc }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">v1 URL <span class="text-rose-500">*</span></label>
|
||||
<input v-model="baseUrl" class="field font-mono text-xs" placeholder="https://api.example.com(无需 /v1 结尾)" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Key <span v-if="!isEdit" class="text-rose-500">*</span><span v-else class="text-white/40">(留空=不改)</span></label>
|
||||
<input v-model="key" class="field font-mono text-xs" :placeholder="isEdit ? '留空保持原 key' : 'sk-...'" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs text-slate-500">支持的模型(多选,不选 = 全部)</label>
|
||||
<span class="text-[11px] text-slate-400">{{ selected.length ? `已选 ${selected.length}` : '全部' }}</span>
|
||||
</div>
|
||||
<div v-if="!allModels.length" class="text-xs text-slate-400 rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-3">
|
||||
暂无模型 —— 可先去模型管理加自定义模型
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1.5 max-h-44 overflow-y-auto rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-2">
|
||||
<button v-for="m in allModels" :key="m.id" type="button" @click="toggle(m.id)"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs ring-1 transition-colors"
|
||||
:class="selected.includes(m.id) ? 'bg-sky-500/15 text-sky-700 ring-sky-300 font-medium' : 'bg-white text-slate-600 ring-slate-200 hover:ring-slate-300'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="m.type === 'video' ? 'bg-orange-400' : 'bg-emerald-400'"></span>{{ m.alias || m.id }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">权重(高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">并发数(单账号)</label>
|
||||
<input v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full mt-1">
|
||||
{{ submitting ? '保存中…' : (isEdit ? '保存' : '添加上游') }}
|
||||
</button>
|
||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user