feat(ui):优化视频生成布局

This commit is contained in:
chengqian committed 2026-09-17 18:20:59 +08:00
1 parent 135f8fdb02
commit d0724d50ba
9 files changed
+744 -372

No files matched your search

+5
View File
@@ -220,6 +220,8 @@ func buildOpenAPISpec(ctx context.Context) map[string]any {
"prompt": map[string]any{"type": "string"},
"seconds": map[string]any{"type": "string", "enum": []string{"4", "8", "12"}, "default": "4"},
"size": map[string]any{"type": "string", "enum": []string{"720x1280", "1280x720", "1024x1792", "1792x1024"}, "default": "720x1280"},
"aspect_ratio": map[string]any{"type": "string", "description": "扩展字段:屏幕比例,如 16:9 / 9:16 / 1:1;取值原样透传上游,不传由模型取默认档"},
"sound": map[string]any{"type": "string", "enum": []string{"on", "off"}, "description": "扩展字段:是否包含声音;仅部分模型支持(可灵 / 豆包),其余模型忽略"},
"input_reference": map[string]any{"type": "string", "format": "binary", "description": "可选参考图(图生视频),文件上传或 {\"image_url\": \"...\"} 引用对象;file_id 形态不支持"},
},
},
@@ -233,6 +235,9 @@ func buildOpenAPISpec(ctx context.Context) map[string]any {
"prompt": map[string]any{"type": "string"},
"seconds": map[string]any{"type": "string"},
"size": map[string]any{"type": "string"},
"aspect_ratio": map[string]any{"type": "string", "description": "扩展字段:屏幕比例,如 16:9 / 9:16 / 1:1"},
"sound": map[string]any{"type": "string", "enum": []string{"on", "off"}, "description": "扩展字段:是否包含声音(也可用布尔形态 generate_audio)"},
"generate_audio": map[string]any{"type": "boolean", "description": "扩展字段:sound 的等价布尔形态,sound 缺省时生效"},
"input_reference": map[string]any{"type": "object", "properties": map[string]any{"image_url": map[string]any{"type": "string"}}},
},
},
+112 -14
View File
@@ -18,8 +18,10 @@ import (
// OpenAI Videos 协议(/v1/videos)入站转换层。
// 官方 SDK(openai-python / openai-node)对 POST /v1/videos 一律发送 multipart/form-data,
// 裸 HTTP 客户端常用 JSON——两种编码都要支持;解析后统一转成任务框架的通用请求体
// {model, prompt, seconds, metadata{duration, aspect_ratio, resolution, image}},
// {model, prompt, seconds, ratio, metadata{duration, size, ratio, aspect_ratio, sound, generate_audio, image}},
// 由各 taskchannel 适配器按需消费(kling 读顶层 seconds,volcengine 读 metadata.duration 等)。
// size / aspect_ratio / sound 均为协议扩展字段(官方 Videos 协议无此三项),
// 取值直接透传目标供应商的原生词汇,不做档位换算与白名单校验。
// videosInputReferenceMaxBytes 参考图上传大小上限。
// GoFrame server 默认 ClientMaxBodySize 8MB,base64 膨胀 4/3 后须留余量,取 6MB。
@@ -32,14 +34,18 @@ type videosCreateRequest struct {
Seconds string // 统一归一为字符串(官方协议为 "4"/"8"/"12");空 = 未指定
Size string // 原样保留(如 "1280x720");空 = 未指定
InputReference string // 参考图:data URL 或 http(s) URL;空 = 无
AspectRatio string // 屏幕比例(如 "16:9"/"9:16");空 = 未指定(由适配器取各自默认档)
Sound string // 音频开关,归一为 "on"/"off";空 = 未指定(由上游取默认)
}
// videosRequestEcho 提交时落 PrivateData.request_echo 的请求回显,
// retrieve(GET /v1/videos/{id})时用于回显 prompt/seconds/size(上游响应不含这些字段)。
type videosRequestEcho struct {
Prompt string `json:"prompt"`
Seconds string `json:"seconds,omitempty"`
Size string `json:"size,omitempty"`
Prompt string `json:"prompt"`
Seconds string `json:"seconds,omitempty"`
Size string `json:"size,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
Sound string `json:"sound,omitempty"`
}
// jsonVideosCreateRequest JSON 编码的创建请求结构。
@@ -50,6 +56,9 @@ type jsonVideosCreateRequest struct {
Seconds json.RawMessage `json:"seconds"` // string 或 number
Size string `json:"size"`
InputReference json.RawMessage `json:"input_reference"`
AspectRatio string `json:"aspect_ratio"`
Sound json.RawMessage `json:"sound"` // "on"/"off" 或 true/false
GenerateAudio json.RawMessage `json:"generate_audio"` // true/false(generate_audio 词汇,sound 缺省时生效)
}
// ParseVideosCreateRequest 解析 OpenAI Videos 创建请求(multipart 或 JSON)。
@@ -74,9 +83,10 @@ func parseVideosCreateJSON(body []byte) (*videosCreateRequest, *common.TaskError
}
out := &videosCreateRequest{
Model: strings.TrimSpace(req.Model),
Prompt: req.Prompt,
Size: strings.TrimSpace(req.Size),
Model: strings.TrimSpace(req.Model),
Prompt: req.Prompt,
Size: strings.TrimSpace(req.Size),
AspectRatio: strings.TrimSpace(req.AspectRatio),
}
seconds, terr := normalizeVideosSeconds(req.Seconds)
@@ -85,6 +95,19 @@ func parseVideosCreateJSON(body []byte) (*videosCreateRequest, *common.TaskError
}
out.Seconds = seconds
// 音频开关:sound 更明确,优先;缺省时回退 generate_audio
sound, terr := normalizeVideosSound(req.Sound)
if terr != nil {
return nil, terr
}
if sound == "" {
sound, terr = normalizeVideosSound(req.GenerateAudio)
if terr != nil {
return nil, terr
}
}
out.Sound = sound
ref, terr := parseVideosInputReferenceJSON(req.InputReference)
if terr != nil {
return nil, terr
@@ -115,9 +138,10 @@ func parseVideosCreateMultipart(body []byte, boundary string) (*videosCreateRequ
}
out := &videosCreateRequest{
Model: strings.TrimSpace(formValue("model")),
Prompt: formValue("prompt"),
Size: strings.TrimSpace(formValue("size")),
Model: strings.TrimSpace(formValue("model")),
Prompt: formValue("prompt"),
Size: strings.TrimSpace(formValue("size")),
AspectRatio: strings.TrimSpace(formValue("aspect_ratio")),
}
seconds, terr := normalizeVideosSeconds(json.RawMessage(formValue("seconds")))
@@ -126,6 +150,19 @@ func parseVideosCreateMultipart(body []byte, boundary string) (*videosCreateRequ
}
out.Seconds = seconds
// multipart 的 part 值是裸字符串,包成 JSON 字符串后再走同一归一逻辑
sound, terr := normalizeVideosSound(multipartJSONString(formValue("sound")))
if terr != nil {
return nil, terr
}
if sound == "" {
sound, terr = normalizeVideosSound(multipartJSONString(formValue("generate_audio")))
if terr != nil {
return nil, terr
}
}
out.Sound = sound
// input_reference:优先文件 part,其次字符串 part(引用对象 JSON)
if files := form.File["input_reference"]; len(files) > 0 {
fh := files[0]
@@ -182,6 +219,53 @@ func normalizeVideosSeconds(raw json.RawMessage) (string, *common.TaskError) {
return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid seconds field: must be a string or number"}
}
// multipartJSONString 把 multipart part 的裸字符串包成 JSON 字符串字面量,
// 空值返回 nil(视为未传)。这样 multipart 与 JSON 两种编码共用同一套归一逻辑。
func multipartJSONString(v string) json.RawMessage {
if strings.TrimSpace(v) == "" {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}
// normalizeVideosSound 归一音频开关:接受 "on"/"off"、"true"/"false"、"1"/"0"
// 以及布尔字面量,统一转成 "on"/"off"(可灵的上游词汇)。空 = 未指定。
// 不做厂商白名单校验——各上游对音频开关的词汇与默认值不同,归一后由适配器按需消费。
func normalizeVideosSound(raw json.RawMessage) (string, *common.TaskError) {
if len(raw) == 0 || string(raw) == "null" {
return "", nil
}
var b bool
if err := json.Unmarshal(raw, &b); err == nil {
if b {
return "on", nil
}
return "off", nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
switch strings.ToLower(strings.TrimSpace(s)) {
case "":
return "", nil
case "on", "true", "1", "yes":
return "on", nil
case "off", "false", "0", "no":
return "off", nil
}
}
return "", &common.TaskError{
StatusCode: http.StatusBadRequest,
Message: `invalid sound field: must be "on"/"off" or true/false`,
}
}
// parseVideosInputReferenceJSON 解析引用对象形态的 input_reference:
// - {"image_url": "https://... 或 data:..."} → 原样透传
// - {"file_id": "..."} → 不支持(平台无 Files API),返回 400 引导改用 image_url/文件上传
@@ -212,7 +296,7 @@ func parseVideosInputReferenceJSON(raw json.RawMessage) (string, *common.TaskErr
}
// BuildVideosTaskBody 把归一后的创建请求转成任务框架通用请求体(JSON 字节)。
// 顶层保留 seconds + images,metadata 写入 duration/size/image。
// 顶层保留 seconds + ratio + images,metadata 写入 duration/size/ratio/aspect_ratio/sound/generate_audio/image。
// size 只借 OpenAI 协议的字段形态、不做任何档位换算:调用方直接传目标供应商的
// 原生分辨率词汇(如 MiniMax 的 768P/2K),原值经 metadata.size 透传给适配器。
func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) {
@@ -231,6 +315,18 @@ func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) {
if req.Size != "" {
metadata["size"] = req.Size
}
if req.AspectRatio != "" {
// 比例双写:minimax/volcengine 读 metadata.ratio,kling 读 metadata.aspect_ratio,
// minimax 的 resolveRatio 顶层 ratio 优先级最高,故三处同写
metadata["ratio"] = req.AspectRatio
metadata["aspect_ratio"] = req.AspectRatio
body["ratio"] = req.AspectRatio
}
if req.Sound != "" {
// 音频开关双写:kling 读 metadata.sound("on"/"off"),volcengine 读 metadata.generate_audio(bool)
metadata["sound"] = req.Sound
metadata["generate_audio"] = req.Sound == "on"
}
if req.InputReference != "" {
metadata["image"] = req.InputReference
// volcengine 图生视频读根级 images 数组(kling 亦有该兜底)
@@ -262,8 +358,10 @@ func ValidateVideosCreateRequest(req *videosCreateRequest) *common.TaskError {
// 提交时随 PrivateData 落库,retrieve 时回显 prompt/seconds/size)
func NewVideosRequestEcho(req *videosCreateRequest) videosRequestEcho {
return videosRequestEcho{
Prompt: req.Prompt,
Seconds: req.Seconds,
Size: req.Size,
Prompt: req.Prompt,
Seconds: req.Seconds,
Size: req.Size,
AspectRatio: req.AspectRatio,
Sound: req.Sound,
}
}
+123
View File
@@ -223,3 +223,126 @@ func TestValidateVideosCreateRequest(t *testing.T) {
t.Fatalf("expected prompt required, got %+v", taskErr)
}
}
// TestParseVideosCreateRequest_JSONAspectRatioAndSound 扩展字段(aspect_ratio/sound)解析与归一
func TestParseVideosCreateRequest_JSONAspectRatioAndSound(t *testing.T) {
cases := []struct {
name string
body string
ratio string
sound string
wantErr bool
}{
{name: "string on", body: `{"model":"m","prompt":"p","aspect_ratio":"9:16","sound":"on"}`, ratio: "9:16", sound: "on"},
{name: "string off", body: `{"model":"m","prompt":"p","sound":"off"}`, sound: "off"},
{name: "bool true", body: `{"model":"m","prompt":"p","sound":true}`, sound: "on"},
{name: "bool false", body: `{"model":"m","prompt":"p","sound":false}`, sound: "off"},
{name: "generate_audio fallback", body: `{"model":"m","prompt":"p","generate_audio":true}`, sound: "on"},
// sound 更明确,同时存在时 sound 优先
{name: "sound wins over generate_audio", body: `{"model":"m","prompt":"p","sound":"off","generate_audio":true}`, sound: "off"},
{name: "absent", body: `{"model":"m","prompt":"p"}`},
{name: "invalid sound", body: `{"model":"m","prompt":"p","sound":"loud"}`, wantErr: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req, taskErr := ParseVideosCreateRequest([]byte(c.body), "application/json")
if c.wantErr {
if taskErr == nil || taskErr.StatusCode != 400 {
t.Fatalf("expected 400, got %+v", taskErr)
}
return
}
if taskErr != nil {
t.Fatalf("unexpected error: %+v", taskErr)
}
if req.AspectRatio != c.ratio {
t.Fatalf("expected aspect_ratio %q, got %q", c.ratio, req.AspectRatio)
}
if req.Sound != c.sound {
t.Fatalf("expected sound %q, got %q", c.sound, req.Sound)
}
})
}
}
// TestParseVideosCreateRequest_MultipartAspectRatioAndSound multipart 裸字符串 part 走同一归一逻辑
func TestParseVideosCreateRequest_MultipartAspectRatioAndSound(t *testing.T) {
body, ct := buildMultipartVideosBody(t, map[string]string{
"model": "kling-v2-master",
"prompt": "a cat",
"aspect_ratio": "1:1",
"sound": "true",
}, "", "", nil)
req, taskErr := ParseVideosCreateRequest(body, ct)
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
if req.AspectRatio != "1:1" || req.Sound != "on" {
t.Fatalf("unexpected request: %+v", req)
}
}
// TestBuildVideosTaskBody_AspectRatioAndSoundDualWrite 比例与音频开关需按各上游词汇双写
func TestBuildVideosTaskBody_AspectRatioAndSoundDualWrite(t *testing.T) {
req := &videosCreateRequest{
Model: "kling-v2-master",
Prompt: "a cat",
AspectRatio: "9:16",
Sound: "on",
}
body, err := BuildVideosTaskBody(req)
if err != nil {
t.Fatalf("build: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(body, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// minimax 的 resolveRatio 顶层 ratio 优先,必须写
if parsed["ratio"] != "9:16" {
t.Fatalf("expected top-level ratio, got %v", parsed["ratio"])
}
meta := parsed["metadata"].(map[string]any)
if meta["ratio"] != "9:16" || meta["aspect_ratio"] != "9:16" {
t.Fatalf("expected metadata ratio + aspect_ratio, got %v", meta)
}
if meta["sound"] != "on" {
t.Fatalf("expected metadata.sound on, got %v", meta["sound"])
}
if meta["generate_audio"] != true {
t.Fatalf("expected metadata.generate_audio true, got %v", meta["generate_audio"])
}
}
// TestBuildVideosTaskBody_SoundOff 关闭音频时 generate_audio 必须为 false(而非缺省)
func TestBuildVideosTaskBody_SoundOff(t *testing.T) {
body, _ := BuildVideosTaskBody(&videosCreateRequest{Model: "m", Prompt: "p", Sound: "off"})
var parsed map[string]any
json.Unmarshal(body, &parsed)
meta := parsed["metadata"].(map[string]any)
if meta["generate_audio"] != false {
t.Fatalf("expected generate_audio false, got %v", meta["generate_audio"])
}
if _, ok := parsed["ratio"]; ok {
t.Fatal("expected no ratio without aspect_ratio")
}
}
// TestNewVideosRequestEcho_ExtendedFields 回显需带上扩展字段(submit/retrieve 回填与排障用)
func TestNewVideosRequestEcho_ExtendedFields(t *testing.T) {
echo := NewVideosRequestEcho(&videosCreateRequest{
Prompt: "p", Seconds: "8", Size: "768P", AspectRatio: "16:9", Sound: "off",
})
b, err := json.Marshal(echo)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]any
json.Unmarshal(b, &got)
if got["aspect_ratio"] != "16:9" || got["sound"] != "off" {
t.Fatalf("unexpected echo: %s", b)
}
}
+42
View File
@@ -257,6 +257,48 @@ body.modal-open {
@apply mt-1 text-xs text-red-500;
}
/* ============ Playground 输入容器 ============ */
/* 把附件片、输入框、操作按钮收进同一个圆角盒子,聚焦时整块高亮。
对话、视频两个 Tab 共用,故放在全局而非某个组件的 scoped 里。 */
.input-shell {
border: 1px solid #e5e7eb;
border-radius: 1rem;
background: #fff;
padding: 0.625rem 0.75rem 0.5rem;
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
/* 聚焦与拖拽时整块高亮,而不是只高亮里面的 textarea */
.input-shell:focus-within {
border-color: rgba(20, 184, 166, 0.55);
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.09);
}
.input-shell-dragging {
border-color: #2dd4bf;
border-style: dashed;
background: rgba(240, 253, 250, 0.6);
}
/* 输入区内的小工具按钮(附件 / 清空 等) */
.input-tool {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
color: #6b7280;
font-size: 0.75rem;
line-height: 1.25rem;
transition: background-color 150ms ease, color 150ms ease;
}
.input-tool:hover:not(:disabled) {
background: #f3f4f6;
color: #0d9488;
}
.input-tool:disabled {
cursor: not-allowed;
color: #d1d5db;
}
/* ============ 液态玻璃效果 ============ */
/* 大面积磨砂(header、sticky 栏) */
.glass {
@@ -204,18 +204,17 @@ useSeo({
<div class="relative min-h-screen overflow-x-clip">
<!-- 装饰层:光球 + 网格纹理,只做背景加强,不参与布局 -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/20 blur-3xl"></div>
<div class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-primary-500/15 blur-3xl"></div>
<div class="absolute left-1/2 top-1/3 h-96 w-96 -translate-x-1/2 rounded-full bg-primary-300/10 blur-3xl"></div>
<div class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/15 blur-3xl"></div>
<div class="absolute -left-40 top-1/3 h-80 w-80 rounded-full bg-primary-500/10 blur-3xl"></div>
<div
class="absolute inset-0"
style="background-image: linear-gradient(rgba(20, 184, 166, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(20, 184, 166, 0.035) 1px, transparent 1px); background-size: 64px 64px"
style="background-image: linear-gradient(rgba(20, 184, 166, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(20, 184, 166, 0.03) 1px, transparent 1px); background-size: 64px 64px"
></div>
</div>
<!-- 品牌栏:与落地页语言统一 -->
<header class="glass sticky top-0 z-40 border-b border-white/40">
<div class="flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
<div class="mx-auto flex h-16 max-w-[92rem] items-center justify-between px-4 sm:px-6 lg:px-8">
<router-link :to="{ name: 'TenantHome' }" class="flex items-center gap-2.5" aria-label="返回首页">
<img src="/favicon.png" :alt="siteName" class="h-8 w-8 rounded-lg" />
<span class="text-lg font-bold tracking-tight text-gray-900">{{ siteName }}</span>
@@ -231,188 +230,179 @@ useSeo({
</div>
</header>
<main class="relative">
<!-- 主体:左侧悬浮筛选面板 + 右侧卡片目录 -->
<section ref="catalogSection" class="scroll-mt-24 px-4 pb-10 pt-6 sm:px-6 sm:pt-8 lg:px-8">
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:gap-6">
<!-- 左侧悬浮筛选面板:桌面端吸附跟随 -->
<aside class="shrink-0 lg:sticky lg:top-24 lg:w-72">
<div class="glass rounded-[1.75rem] p-4 shadow-glass lg:max-h-[calc(100dvh-7.5rem)] lg:overflow-y-auto">
<span class="mb-3 block text-xs font-semibold uppercase tracking-wider text-gray-400">筛选</span>
<main class="relative mx-auto w-full max-w-[92rem] px-4 pb-14 sm:px-6 lg:px-8">
<!-- 标题区 -->
<section class="pb-5 pt-7 sm:pb-6 sm:pt-9">
<h1 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-[28px]">模型广场</h1>
<p class="mt-1.5 max-w-2xl text-sm leading-relaxed text-gray-500">
浏览主流 AI 模型,比较能力、上下文窗口与 Token 价格,通过统一的 OpenAI 兼容接口接入。
</p>
</section>
<!-- 搜索 -->
<div class="flex h-11 items-center gap-2.5 rounded-xl border border-gray-200/70 bg-white/80 px-3.5 transition-all duration-200 focus-within:border-primary-400/70 focus-within:ring-2 focus-within:ring-primary-500/20">
<Icon name="search" size="sm" class="shrink-0 text-gray-400" />
<input
ref="searchInputElement"
v-model="keyword"
type="search"
aria-label="搜索模型"
placeholder="搜索名称、ID 或描述"
class="h-full min-w-0 flex-1 border-0 bg-transparent text-sm text-gray-900 outline-none placeholder:text-gray-400"
@input="scheduleSearch"
@keydown.enter.prevent="runSearchNow"
/>
<button
v-if="keyword"
type="button"
aria-label="清除搜索"
class="grid h-6 w-6 shrink-0 place-items-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
@click="clearSearch"
>
<Icon name="x" size="xs" />
</button>
</div>
<div class="my-4 h-px bg-gray-200/60"></div>
<!-- 分类:移动端横向 chips,桌面端菜单列表 -->
<span class="mb-3 hidden text-xs font-semibold uppercase tracking-wider text-gray-400 lg:block">模型类型</span>
<nav class="flex flex-wrap gap-2 lg:flex-col lg:gap-1" aria-label="按模型类别筛选">
<button
v-for="cat in categories"
:key="cat.key"
type="button"
:title="cat.description"
class="inline-flex items-center gap-2 rounded-full border px-3.5 py-2 text-sm font-medium transition-all duration-200 active:scale-[0.98] lg:w-full lg:rounded-xl lg:px-3.5 lg:py-2.5"
:class="
selectedCategory === cat.value
? 'border-transparent text-white shadow-glow lg:font-semibold'
: 'border-gray-200/70 bg-white/70 text-gray-600 hover:border-primary-200 hover:text-primary-700 lg:border-transparent lg:bg-transparent lg:hover:bg-primary-500/5 lg:hover:text-primary-700'
"
:style="
selectedCategory === cat.value
? { background: `linear-gradient(135deg, ${cat.from}, ${cat.to})`, boxShadow: `0 10px 30px rgba(${cat.glow}, 0.35)` }
: undefined
"
@click="selectCategory(cat.value)"
>
<Icon :name="cat.icon" size="sm" class="shrink-0" />
{{ cat.shortLabel }}
<Icon v-if="selectedCategory === cat.value" name="check" size="xs" class="ml-auto hidden shrink-0 lg:block" />
</button>
</nav>
<!-- 厂商:只显示当前条件下有模型的厂商;移动端单行横滑 chips,桌面端菜单列表 -->
<template v-if="visibleVendorOptions.length">
<div class="my-4 h-px bg-gray-200/60"></div>
<span class="mb-3 hidden text-xs font-semibold uppercase tracking-wider text-gray-400 lg:block">研发厂商</span>
<nav class="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 lg:mx-0 lg:flex-col lg:gap-1 lg:overflow-visible lg:px-0 lg:pb-0" aria-label="按厂商筛选">
<button
type="button"
class="inline-flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-[0.98] lg:w-full lg:rounded-xl lg:px-3 lg:py-2"
:class="
selectedVendor === null
? 'border-transparent bg-gray-900 text-white lg:font-semibold'
: 'border-gray-200/70 bg-white/70 text-gray-600 hover:border-primary-200 hover:text-primary-700 lg:border-transparent lg:bg-transparent lg:hover:bg-primary-500/5 lg:hover:text-primary-700'
"
@click="selectVendor(null)"
>
<span class="inline-flex h-5 w-5 items-center justify-center rounded-md bg-gray-100 text-[10px] font-bold text-gray-400 lg:hidden">全部</span>
<span class="hidden lg:inline">全部厂商</span>
</button>
<button
v-for="vendor in visibleVendorOptions"
:key="vendor.value"
type="button"
class="inline-flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-[0.98] lg:w-full lg:rounded-xl lg:px-3 lg:py-2"
:class="
selectedVendor === vendor.value
? 'border-transparent bg-gray-900 text-white lg:font-semibold'
: 'border-gray-200/70 bg-white/70 text-gray-600 hover:border-primary-200 hover:text-primary-700 lg:border-transparent lg:bg-transparent lg:hover:bg-primary-500/5 lg:hover:text-primary-700'
"
@click="selectVendor(vendor.value)"
>
<VendorLogo :vendor="vendor.value" size="sm" />
<span class="whitespace-nowrap">{{ vendor.label }}</span>
<Icon v-if="selectedVendor === vendor.value" name="check" size="xs" class="ml-auto hidden shrink-0 lg:block" />
</button>
</nav>
</template>
<!-- 重置筛选:仅在存在筛选条件时出现 -->
<button v-if="hasActiveFilters" type="button" class="btn btn-secondary btn-sm mt-4 w-full" @click="resetFilters">
<Icon name="refresh" size="sm" />重置筛选
</button>
</div>
</aside>
<!-- 右侧目录 -->
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between pb-4">
<p class="text-sm text-gray-500">
共 <strong class="font-semibold tabular-nums text-gray-900">{{ total }}</strong> 个模型
<template v-if="keyword"> · 搜索“{{ keyword.trim() }}”</template>
</p>
</div>
<!-- 加载骨架 -->
<div v-if="loading" class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4" aria-label="正在加载模型">
<div v-for="i in pageSize" :key="i" class="rounded-[20px] border border-white/80 bg-white/85 p-5 shadow-card">
<div class="flex items-start gap-4">
<div class="skeleton h-14 w-14 rounded-2xl"></div>
<div class="flex-1 space-y-2 pt-1">
<div class="skeleton h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-1/2 rounded"></div>
</div>
</div>
<div class="skeleton mt-4 h-3 w-full rounded"></div>
<div class="skeleton mt-2 h-3 w-2/3 rounded"></div>
<div class="mt-4 flex gap-2">
<div class="skeleton h-12 flex-1 rounded-xl"></div>
<div class="skeleton h-12 flex-1 rounded-xl"></div>
</div>
<div class="skeleton mt-4 h-8 w-full rounded-xl"></div>
</div>
</div>
<!-- 错误态 -->
<div v-else-if="loadError" class="empty-state">
<Icon name="exclamationTriangle" size="xl" class="empty-state-icon" />
<p class="empty-state-title">模型列表加载失败</p>
<p class="empty-state-description">网络可能暂时不可用,请稍后重试。</p>
<button type="button" class="btn btn-secondary btn-sm mt-4" @click="loadModels">
<Icon name="refresh" size="sm" />重新加载
</button>
</div>
<!-- 空态 -->
<div v-else-if="modelList.length === 0" class="empty-state">
<Icon name="search" size="xl" class="empty-state-icon" />
<p class="empty-state-title">没有找到匹配的模型</p>
<p class="empty-state-description">换一个关键词,或者清除当前筛选条件后再试。</p>
<button type="button" class="btn btn-secondary btn-sm mt-4" @click="resetFilters">
<Icon name="refresh" size="sm" />重置筛选
</button>
</div>
<!-- 卡片网格:key 携带分类与页码,翻页 / 切分类时重放入场动画 -->
<div v-else :key="`${selectedCategory ?? 'all'}-${selectedVendor ?? 'all'}-${currentPage}`" class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
<ModelCard
v-for="(model, index) in modelList"
:key="model.model_id"
:model="model"
:index="index"
@open="openDetail"
@copy="copyModelId"
/>
</div>
<!-- 分页 -->
<div v-if="!loading && !loadError && total > 0" class="mt-8">
<BasePagination
v-model="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-size-options="[12, 24, 48]"
@change="handlePageChange"
/>
</div>
<!-- 筛选:搜索 + 类型 + 厂商,统一收在一张卡里 -->
<section class="card mb-5 overflow-hidden" aria-label="模型筛选">
<div class="flex flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div class="flex h-11 w-full items-center gap-2.5 rounded-xl border border-gray-200 bg-white px-3.5 transition-all duration-200 focus-within:border-primary-400 focus-within:ring-2 focus-within:ring-primary-500/20 sm:max-w-md">
<Icon name="search" size="sm" class="shrink-0 text-gray-400" />
<input
ref="searchInputElement"
v-model="keyword"
type="search"
aria-label="搜索模型"
placeholder="搜索模型名称、ID 或描述"
class="h-full min-w-0 flex-1 border-0 bg-transparent text-sm text-gray-900 outline-none placeholder:text-gray-400"
@input="scheduleSearch"
@keydown.enter.prevent="runSearchNow"
/>
<button
v-if="keyword"
type="button"
aria-label="清除搜索"
class="grid h-6 w-6 shrink-0 place-items-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
@click="clearSearch"
>
<Icon name="x" size="xs" />
</button>
</div>
<div class="flex shrink-0 items-center justify-between gap-3 sm:justify-end">
<p class="text-sm text-gray-500">
共 <strong class="font-semibold tabular-nums text-gray-900">{{ total }}</strong> 个模型
<template v-if="keyword.trim()"> · “{{ keyword.trim() }}”</template>
</p>
<button v-if="hasActiveFilters" type="button" class="btn btn-secondary btn-sm" @click="resetFilters">
<Icon name="refresh" size="xs" />重置
</button>
</div>
</div>
<!-- 模型类型 -->
<div class="flex items-center gap-2.5 border-t border-gray-100 px-4 py-2.5 sm:px-5">
<span class="hidden w-8 shrink-0 text-xs font-medium text-gray-400 sm:block">类型</span>
<nav class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto pb-0.5" aria-label="按模型类别筛选">
<button
v-for="cat in categories"
:key="cat.key"
type="button"
:title="cat.description"
:aria-pressed="selectedCategory === cat.value"
class="inline-flex shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all duration-200 active:scale-[0.98]"
:class="
selectedCategory === cat.value
? 'bg-primary-500 text-white shadow-sm shadow-primary-500/30'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
"
@click="selectCategory(cat.value)"
>
<Icon :name="cat.icon" size="sm" class="shrink-0" />
{{ cat.shortLabel }}
</button>
</nav>
</div>
<!-- 研发厂商 -->
<div v-if="visibleVendorOptions.length" class="flex items-center gap-2.5 border-t border-gray-100 px-4 py-2.5 sm:px-5">
<span class="hidden w-8 shrink-0 text-xs font-medium text-gray-400 sm:block">厂商</span>
<nav class="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-0.5" aria-label="按厂商筛选">
<button
type="button"
:aria-pressed="selectedVendor === null"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-all duration-200 active:scale-[0.98]"
:class="
selectedVendor === null
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 hover:text-primary-700'
"
@click="selectVendor(null)"
>
全部
</button>
<button
v-for="vendor in visibleVendorOptions"
:key="vendor.value"
type="button"
:aria-pressed="selectedVendor === vendor.value"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-all duration-200 active:scale-[0.98]"
:class="
selectedVendor === vendor.value
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 hover:text-primary-700'
"
@click="selectVendor(vendor.value)"
>
<VendorLogo :vendor="vendor.value" size="xs" />
<span class="whitespace-nowrap">{{ vendor.label }}</span>
<Icon v-if="selectedVendor === vendor.value" name="check" size="xs" class="shrink-0" />
</button>
</nav>
</div>
</section>
<!-- 目录 -->
<section ref="catalogSection" class="scroll-mt-24">
<!-- 加载骨架 -->
<div v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4" aria-label="正在加载模型">
<div v-for="i in pageSize" :key="i" class="card p-4">
<div class="flex items-start gap-3">
<div class="skeleton h-7 w-7 rounded-lg"></div>
<div class="flex-1 space-y-2 pt-1">
<div class="skeleton h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-1/2 rounded"></div>
</div>
</div>
<div class="skeleton mt-3 h-3 w-full rounded"></div>
<div class="skeleton mt-2 h-3 w-2/3 rounded"></div>
<div class="skeleton mt-3 h-3 w-1/2 rounded"></div>
<div class="mt-5 flex items-end justify-between border-t border-gray-100 pt-3.5">
<div class="skeleton h-6 w-24 rounded"></div>
<div class="skeleton h-6 w-14 rounded-lg"></div>
</div>
</div>
</div>
<!-- 错误态 -->
<div v-else-if="loadError" class="card empty-state">
<Icon name="exclamationTriangle" size="xl" class="empty-state-icon" />
<p class="empty-state-title">模型列表加载失败</p>
<p class="empty-state-description">网络可能暂时不可用,请稍后重试。</p>
<button type="button" class="btn btn-secondary btn-sm mt-4" @click="loadModels">
<Icon name="refresh" size="sm" />重新加载
</button>
</div>
<!-- 空态 -->
<div v-else-if="modelList.length === 0" class="card empty-state">
<Icon name="search" size="xl" class="empty-state-icon" />
<p class="empty-state-title">没有找到匹配的模型</p>
<p class="empty-state-description">换一个关键词,或者清除当前筛选条件后再试。</p>
<button type="button" class="btn btn-secondary btn-sm mt-4" @click="resetFilters">
<Icon name="refresh" size="sm" />重置筛选
</button>
</div>
<!-- 卡片网格:key 携带分类与页码,翻页 / 切分类时重放入场动画 -->
<div v-else :key="`${selectedCategory ?? 'all'}-${selectedVendor ?? 'all'}-${currentPage}`" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
<ModelCard
v-for="(model, index) in modelList"
:key="model.model_id"
:model="model"
:index="index"
@open="openDetail"
@copy="copyModelId"
/>
</div>
<!-- 分页 -->
<div v-if="!loading && !loadError && total > 0" class="mt-7">
<BasePagination
v-model="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-size-options="[12, 24, 48]"
@change="handlePageChange"
/>
</div>
</section>
</main>
<footer class="relative border-t border-white/40 py-8">
@@ -16,7 +16,11 @@ const emit = defineEmits<{
}>()
const meta = computed(() => getCategoryMeta(props.model.category))
const capabilities = computed(() => getCapabilityList(props.model, 4))
const capabilities = computed(() => getCapabilityList(props.model, 3))
// 无简介时用类别说明兜底:十几张卡片重复同一句占位文案纯属噪音,
// 类别说明既稳住两行高度,又顺带交代了这类模型能做什么
const description = computed(() => props.model.description?.trim() || meta.value.description)
// 价格文案按计费模式派生,避免模板里堆叠条件
const priceView = computed(() => {
@@ -32,105 +36,95 @@ const priceView = computed(() => {
}
})
// 类别渐变注入 CSS 变量;入场延迟按序号递增形成 stagger(封顶 11 张)
// 类别徽章沿用详情抽屉的配色规则:类别色文字 + 同色 10% 底
const categoryBadgeStyle = computed(() => ({
color: meta.value.to,
backgroundColor: `rgba(${meta.value.glow}, 0.1)`,
}))
// 类别渐变注入 CSS 变量(卡片顶部指示线);入场延迟按序号递增形成 stagger(封顶 11 张)
const cardStyle = computed(() => ({
'--cat-from': meta.value.from,
'--cat-to': meta.value.to,
'--cat-glow': meta.value.glow,
animationDelay: `${Math.min(props.index, 11) * 45}ms`,
}))
</script>
<template>
<article
class="card-in group relative flex cursor-pointer flex-col overflow-hidden rounded-[20px] border border-white/80 bg-white/85 shadow-card backdrop-blur-xl transition-all duration-300 hover:-translate-y-1.5 hover:border-primary-200/70 hover:shadow-card-hover"
class="card-in card group relative flex cursor-pointer flex-col overflow-hidden p-4 transition-all duration-300 hover:-translate-y-1 hover:border-primary-200/70 hover:shadow-card-hover"
:style="cardStyle"
@click="emit('open', model)"
>
<!-- 顶部类别渐变线:hover 时从左展开 -->
<!-- 顶部类别指示线:hover 时从左展开 -->
<span
aria-hidden="true"
class="absolute inset-x-0 top-0 z-10 h-[3px] origin-left scale-x-0 bg-gradient-to-r from-[var(--cat-from)] to-[var(--cat-to)] transition-transform duration-300 group-hover:scale-x-100"
></span>
<!-- 右上角类别光晕:hover 淡入 -->
<span
aria-hidden="true"
class="pointer-events-none absolute -right-12 -top-12 h-32 w-32 rounded-full opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-25"
style="background: var(--cat-to)"
class="absolute inset-x-0 top-0 h-[2px] origin-left scale-x-0 bg-gradient-to-r from-[var(--cat-from)] to-[var(--cat-to)] transition-transform duration-300 group-hover:scale-x-100"
></span>
<div class="relative flex-1 pb-4">
<!-- 头部:类别磁贴 + 名称 / 模型 ID -->
<div class="flex items-start gap-4 p-5 pb-0">
<div
class="grid h-14 w-14 shrink-0 place-items-center rounded-2xl text-white transition-transform duration-300 group-hover:-rotate-3 group-hover:scale-105"
:style="{ background: 'linear-gradient(135deg, var(--cat-from), var(--cat-to))', boxShadow: '0 8px 24px rgba(var(--cat-glow), 0.35)' }"
<!-- 头部:厂商芯片 + 名称 / 模型 ID,右侧类别与折扣徽章 -->
<div class="flex items-start gap-3">
<VendorLogo :vendor="model.vendor" size="md" class="mt-px" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-[15px] font-semibold leading-6 text-gray-900">{{ model.model_name || model.model_id }}</h3>
<button
type="button"
:aria-label="`复制模型 ID ${model.model_id}`"
title="复制模型 ID"
class="group/copy mt-0.5 flex max-w-full items-center gap-1 text-left"
@click.stop="emit('copy', model.model_id)"
>
<Icon :name="meta.icon" size="lg" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<VendorLogo :vendor="model.vendor" size="sm" class="mt-0.5 shrink-0" />
<h3 class="line-clamp-1 text-base font-semibold text-gray-900">{{ model.model_name || model.model_id }}</h3>
<span v-if="model.discount_label" class="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
{{ model.discount_label }}
</span>
</div>
<button
type="button"
:aria-label="`复制模型 ID ${model.model_id}`"
title="复制模型 ID"
class="group/copy mt-1 flex max-w-full items-center gap-1.5 text-left"
@click.stop="emit('copy', model.model_id)"
>
<code class="truncate font-mono text-xs text-gray-400 transition-colors group-hover/copy:text-primary-600">{{ model.model_id }}</code>
<Icon name="copy" size="xs" class="shrink-0 text-gray-300 transition-colors group-hover/copy:text-primary-600" />
</button>
</div>
<code class="truncate font-mono text-[11px] text-gray-400 transition-colors group-hover/copy:text-primary-600">{{ model.model_id }}</code>
<Icon name="copy" size="xs" class="shrink-0 text-gray-300 transition-colors group-hover/copy:text-primary-600" />
</button>
</div>
<!-- 简介 -->
<p class="line-clamp-2 min-h-[2.5rem] px-5 pt-3 text-sm leading-relaxed text-gray-500">
{{ model.description || '暂无模型描述,点击查看技术规格与计费信息。' }}
</p>
<!-- 规格:上下文 / 最大输出 -->
<div class="flex gap-2 px-5 pt-3">
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
<span class="text-[11px] text-gray-400">上下文</span>
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_context_tokens) }}</strong>
</div>
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
<span class="text-[11px] text-gray-400">最大输出</span>
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_output_tokens) }}</strong>
</div>
</div>
<!-- 能力芯片:最多 3 个 -->
<div v-if="capabilities.length" class="flex flex-wrap gap-1.5 px-5 pt-3">
<span
v-for="item in capabilities"
:key="item"
class="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[11px] font-medium text-primary-700"
>
<Icon name="check" size="xs" />{{ item }}
</span>
<div class="flex shrink-0 flex-col items-end gap-1">
<span class="rounded-full px-2 py-0.5 text-[11px] font-medium" :style="categoryBadgeStyle">{{ meta.label }}</span>
<span v-if="model.discount_label" class="badge badge-warning !px-2 !py-0 !text-[11px]">{{ model.discount_label }}</span>
</div>
</div>
<!-- 底栏:价格 + 详情入口 -->
<div class="flex items-end justify-between gap-3 border-t border-gray-100/80 px-5 pb-4 pt-4">
<!-- 简介:无描述时回退为类别说明,保证卡片高度稳定 -->
<p class="mt-3 line-clamp-2 min-h-[2.75rem] text-[13px] leading-[1.6] text-gray-500">{{ description }}</p>
<!-- 规格:上下文 / 最大输出,紧凑单行 -->
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-gray-400">
<span class="inline-flex items-center gap-1">
<Icon name="document" size="xs" class="text-gray-300" />
<span class="text-gray-500">{{ formatTokens(model.max_context_tokens) }}</span> 上下文
</span>
<span class="text-gray-200">·</span>
<span class="inline-flex items-center gap-1">
<Icon name="expand" size="xs" class="text-gray-300" />
<span class="text-gray-500">{{ formatTokens(model.max_output_tokens) }}</span> 输出
</span>
</div>
<!-- 能力:最多 3 个 -->
<div v-if="capabilities.length" class="mt-2.5 flex min-h-[1.375rem] flex-wrap gap-1.5">
<span
v-for="item in capabilities"
:key="item"
class="inline-flex items-center rounded-md border border-gray-200/80 bg-gray-50/80 px-1.5 py-0.5 text-[11px] font-medium text-gray-500"
>
{{ item }}
</span>
</div>
<!-- 底栏:价格 + 详情入口(mt-auto 保证同行卡片价格底线对齐) -->
<div class="mt-auto flex items-end justify-between gap-3 border-t border-gray-100 pt-3.5">
<div class="min-w-0">
<span class="text-[11px] text-gray-400">{{ priceView.caption }}</span>
<div class="flex items-baseline gap-1">
<strong class="text-lg font-bold tabular-nums text-gray-900">{{ priceView.main }}</strong>
<strong v-if="priceView.secondary" class="text-sm font-semibold tabular-nums text-gray-600">{{ priceView.secondary }}</strong>
<div class="mt-0.5 flex items-baseline gap-2">
<strong class="text-base font-bold tabular-nums text-gray-900">{{ priceView.main }}</strong>
<strong v-if="priceView.secondary" class="text-[13px] font-semibold tabular-nums text-gray-500">{{ priceView.secondary }}</strong>
<span class="whitespace-nowrap text-[11px] text-gray-400">{{ priceView.unit }}</span>
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-medium text-primary-600 transition-all duration-200 hover:bg-primary-50 active:scale-[0.98] group-hover:bg-primary-500 group-hover:text-white group-hover:shadow-glow"
:aria-label="`查看 ${model.model_name || model.model_id} 详情`"
class="inline-flex shrink-0 items-center gap-1 rounded-lg px-2.5 py-1.5 text-[13px] font-medium text-primary-600 transition-all duration-200 group-hover:bg-primary-500 group-hover:text-white group-hover:shadow-glow"
@click.stop="emit('open', model)"
>
详情
@@ -143,13 +137,13 @@ const cardStyle = computed(() => ({
<style scoped>
/* 卡片入场:backwards 填充保证延迟期间隐藏、结束后不覆盖 hover 位移 */
.card-in {
animation: card-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation: card-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(14px);
transform: translateY(12px);
}
to {
opacity: 1;
@@ -751,45 +751,8 @@ function clearChat() {
</template>
<style scoped>
/* ── 输入容器:把附件片、输入框、操作按钮收进同一个圆角盒子 ── */
.input-shell {
border: 1px solid #e5e7eb;
border-radius: 1rem;
background: #fff;
padding: 0.625rem 0.75rem 0.5rem;
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
/* 聚焦与拖拽时整块高亮,而不是只高亮里面的 textarea */
.input-shell:focus-within {
border-color: rgba(20, 184, 166, 0.55);
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.09);
}
.input-shell-dragging {
border-color: #2dd4bf;
border-style: dashed;
background: rgba(240, 253, 250, 0.6);
}
/* ── 输入区内的小工具按钮 ── */
.input-tool {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
color: #6b7280;
font-size: 0.75rem;
line-height: 1.25rem;
transition: background-color 150ms ease, color 150ms ease;
}
.input-tool:hover:not(:disabled) {
background: #f3f4f6;
color: #0d9488;
}
.input-tool:disabled {
cursor: not-allowed;
color: #d1d5db;
}
/* 输入容器(.input-shell / .input-tool)已提到 styles/main.css,
与视频 Tab 共用一份定义 */
/* ── Temperature 滑块:替换浏览器默认的紫色原生样式 ── */
.range-slider {
@@ -5,18 +5,24 @@ import Icon from '@/components/common/Icon.vue'
// 卡片式异步任务加载占位:按成品形状(图片 1:1 / 视频 16:9)占位的动效卡片,
// 中央进度环展示百分比(无进度数值时转为旋转弧的不确定态),
// 底层为浅色渐变底 + 双色光斑漂移 + 斜向扫光,视觉对齐 OpenAI / Gemini 生成中的占位效果。
//
// compact:消息气泡式的小卡片。整块宽度按 16:9 反推,只占容器左半侧,
// 高度随宽度线性缩小——提示词输入区在其下方展开时不会再把它挤出视口。
const props = withDefaults(
defineProps<{
kind?: 'image' | 'video'
progress?: string
label?: string
sublabel?: string
/** 紧凑形态:占位尺寸由「宽度撑满」改为「左半侧小卡片」 */
compact?: boolean
}>(),
{
kind: 'image',
progress: '',
label: '生成中...',
sublabel: '',
compact: false,
},
)
@@ -40,7 +46,13 @@ const gradId = `gen-grad-${++seq}`
</script>
<template>
<div class="gen-card animate-scale-in" :class="kind === 'video' ? 'gen-card--video' : 'gen-card--image'">
<div
class="gen-card animate-scale-in"
:class="[
kind === 'video' ? 'gen-card--video' : 'gen-card--image',
compact ? 'gen-card--compact' : '',
]"
>
<!-- 底层:浅色渐变底 + 光斑漂移 + 斜向扫光 -->
<div class="gen-card__bg"></div>
<div class="gen-blob gen-blob--a"></div>
@@ -81,7 +93,7 @@ const gradId = `gen-grad-${++seq}`
</svg>
<div class="gen-ring__text">
<span v-if="pct !== null" class="gen-pct">{{ pct }}<small>%</small></span>
<Icon v-else name="hourglass" size="lg" class="animate-pulse text-primary-500" />
<Icon v-else name="hourglass" :size="compact ? 'sm' : 'lg'" class="animate-pulse text-primary-500" />
</div>
</div>
<p class="gen-label">{{ label }}</p>
@@ -108,6 +120,38 @@ const gradId = `gen-grad-${++seq}`
aspect-ratio: 16 / 9;
}
/* 紧凑形态:宽度收成左半侧(16:9 反推高度,约 146px @ 260px 宽),
让下方展开的提示词输入区不再挤压生成态与预览窗口 */
.gen-card--compact {
width: min(100%, 260px);
}
.gen-card--compact.gen-card--video,
.gen-card--compact.gen-card--image {
max-width: 260px;
margin-inline: 0;
aspect-ratio: 16 / 9;
}
/* 小卡片下进度环与文案同比缩小,避免视觉上「字比卡片大」 */
.gen-card--compact .gen-center {
gap: 8px;
}
.gen-card--compact .gen-ring {
width: 56px;
height: 56px;
}
.gen-card--compact .gen-pct {
font-size: 16px;
}
.gen-card--compact .gen-pct small {
font-size: 10px;
}
.gen-card--compact .gen-label {
font-size: 12px;
}
.gen-card--compact .gen-sublabel {
font-size: 10px;
}
/* 底层浅色渐变 */
.gen-card__bg {
position: absolute;
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { NInput, NInputNumber } from 'naive-ui'
import { NInput, NInputNumber, NSwitch } from 'naive-ui'
import { createPlaygroundApi } from '@/utils/playgroundApi'
import { createPoller } from '@/composables/usePolling'
import Icon from '@/components/common/Icon.vue'
@@ -8,7 +8,7 @@ import GenerationProgressCard from './GenerationProgressCard.vue'
import BaseSelect from '../../../components/common/BaseSelect.vue'
import AttachmentBar from './AttachmentBar.vue'
import PromptInput from './PromptInput.vue'
import { useAttachments } from './useAttachments'
import { useAttachments, formatBytes } from './useAttachments'
import { compileVideoPrompt } from './attachmentParts'
interface ModelItem {
@@ -34,18 +34,40 @@ const prompt = ref('')
const resolution = ref('1280x720')
const duration = ref(5)
// 屏幕比例:取值原样透传给上游(网关不做换算与白名单),默认 16:9 —— 与 MiniMax-H3
// 纯文本生成时的官方默认档一致,因此对现有请求语义无变化
const aspectRatio = ref('16:9')
const aspectRatioOptions = [
{ value: '16:9', label: '16:9 横屏' },
{ value: '9:16', label: '9:16 竖屏' },
{ value: '1:1', label: '1:1 方形' },
{ value: '4:3', label: '4:3' },
{ value: '3:4', label: '3:4' },
{ value: '21:9', label: '21:9 宽银幕' },
]
// 是否包含声音:始终显式下发 on/off,让开关状态与请求一致(不依赖各上游的默认值)。
// 目前只有可灵(metadata.sound)与豆包(metadata.generate_audio)消费该字段,
// MiniMax / Sora 适配器不读取,传了也会被忽略——界面上已注明。
const withSound = ref(false)
// ── 参考素材 ──
// 前端按多素材形态实现(图/视/音皆可上传、@ 列表按类型命名),但本期只有首帧参考图
// 能随请求发出:/v1/videos 的 input_reference 仅承载一张图,其余素材待后端扩协议。
const {
list: attachmentList,
totalBytes: attachmentBytes,
accept: acceptTypes,
max: attachmentMax,
addFiles: addAttachments,
remove: removeAttachment,
clear: clearAttachments,
} = useAttachments({ kinds: ['image', 'video', 'audio'], max: 6 })
const promptInput = ref<InstanceType<typeof PromptInput> | null>(null)
// bare 变体的附件片不自带上传入口,文件选择器由输入区的「素材」按钮通过 ref 触发
const attachmentBar = ref<InstanceType<typeof AttachmentBar> | null>(null)
// 输入容器的拖拽高亮状态(与对话 Tab 一致,拖放由 input-shell 承接)
const dragging = ref(false)
// 提交时未能随请求发出的素材名,用于结果区提示
const deferredNames = ref<string[]>([])
@@ -53,6 +75,20 @@ function insertMention(name: string) {
promptInput.value?.insertMention(name)
}
function openFilePicker() {
attachmentBar.value?.pick()
}
function clearAllAttachments() {
clearAttachments()
}
// 输入容器承接拖放:与粘贴共用同一条上传链路
function onDropFiles(e: DragEvent) {
dragging.value = false
if (e.dataTransfer?.files?.length) addAttachments(Array.from(e.dataTransfer.files))
}
const modelOptions = computed(() => props.models.map(m => ({ value: m.model_id, label: m.model_name || m.model_id })))
// 空态与任务卡里展示的模型名(列表由父组件异步加载,可能暂时查不到)
@@ -147,7 +183,9 @@ async function submitTask() {
const compiled = compileVideoPrompt(prompt.value, attachmentList.value)
deferredNames.value = compiled.deferred.map(a => a.name)
// OpenAI Videos 协议:size 承载分辨率(像素串或命名档位如 768P/2K),seconds 承载时长
// OpenAI Videos 协议:size 承载分辨率(像素串或命名档位如 768P/2K),seconds 承载时长;
// aspect_ratio / sound 是网关侧的协议扩展字段(官方 Videos 协议无此两项),
// 网关归一后双写进任务体 metadata,由各渠道适配器按自己的词汇消费。
const body: Record<string, any> = {
model: selectedModel.value,
prompt: compiled.prompt,
@@ -158,6 +196,10 @@ async function submitTask() {
if (duration.value) {
body.seconds = String(duration.value)
}
if (aspectRatio.value) {
body.aspect_ratio = aspectRatio.value
}
body.sound = withSound.value ? 'on' : 'off'
if (compiled.inputReference) {
// 走 JSON 引用对象形态:后端 parseVideosInputReferenceJSON 接受 data URL,
// 无需切 multipart,也不用跟 createPlaygroundApi 固定的 Content-Type 打架
@@ -289,7 +331,8 @@ function downloadVideo() {
</script>
<template>
<!-- 与对话 Tab 同一套等高布局:左参数栏内部滚动,底部主操作常驻,右结果区撑满 -->
<!-- 与对话 Tab 同一套等高布局:左参数栏内部滚动,右侧结果区撑满、
提示词与素材入口收在结果卡底部,主操作按钮落在输入区操作行 -->
<div class="flex flex-col lg:h-[calc(100vh-15rem)] lg:overflow-hidden">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] lg:grid-rows-[minmax(0,1fr)] lg:min-h-0 lg:flex-1">
<!-- Left: 参数 -->
@@ -310,32 +353,6 @@ function downloadVideo() {
<BaseSelect v-model="selectedModel" :options="modelOptions" />
</div>
<!-- 输入:参考素材 + 提示词 -->
<div class="space-y-4 border-t border-gray-100 pt-4">
<div>
<label class="mb-2 block text-xs font-semibold text-gray-500">参考素材</label>
<AttachmentBar
:attachments="attachmentList"
:accept="acceptTypes"
:max="attachmentMax"
@add="addAttachments"
@remove="removeAttachment"
@insert="insertMention"
/>
</div>
<div>
<label class="mb-2 block text-xs font-semibold text-gray-500">提示词</label>
<PromptInput
ref="promptInput"
v-model="prompt"
:attachments="attachmentList"
:min-rows="4"
placeholder="描述你想生成的视频,输入 @ 引用已上传的素材..."
@paste="addAttachments"
/>
</div>
</div>
<!-- 输出规格 -->
<div class="space-y-4 border-t border-gray-100 pt-4">
<div>
@@ -359,6 +376,11 @@ function downloadVideo() {
</div>
</div>
<div>
<label class="mb-2 block text-xs font-semibold text-gray-500">屏幕比例</label>
<BaseSelect v-model="aspectRatio" :options="aspectRatioOptions" />
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-xs font-semibold text-gray-500">时长</label>
@@ -369,26 +391,24 @@ function downloadVideo() {
<!-- min=4 覆盖 MiniMax-H3 的 4 秒档;其他模型档位由上游校验 -->
<n-input-number v-model:value="duration" :min="4" :max="15" :step="1" class="w-full" />
</div>
</div>
</div>
<!-- 主操作常驻卡片底部:参数再长也不用来回滚动找按钮 -->
<div class="shrink-0 space-y-2 border-t border-gray-100 px-4 py-3">
<button class="btn btn-primary w-full" :disabled="submitting || polling || !prompt.trim()" @click="submitTask">
<Icon name="play" size="sm" />
{{ submitting ? '提交中...' : polling ? '生成中...' : '生成视频' }}
</button>
<button v-if="currentTask" class="btn btn-secondary w-full" @click="resetTask">
<Icon name="refresh" size="sm" />
重新生成
</button>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-700">包含声音</p>
<p class="mt-0.5 text-[11px] leading-relaxed text-gray-400">
仅可灵 / 豆包支持,其余模型会忽略
</p>
</div>
<n-switch v-model:value="withSound" size="small" class="shrink-0" />
</div>
</div>
</div>
</div>
</div>
<!-- Right: 结果 -->
<div class="lg:min-h-0">
<div class="card flex min-h-[420px] flex-col overflow-hidden lg:h-full">
<!-- Right: 结果 + 输入(提示词与素材入口贴卡片底部,与对话 Tab 同构) -->
<div class="h-[62vh] min-h-[420px] lg:h-auto lg:min-h-0">
<div class="card flex h-full flex-col overflow-hidden">
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 px-5 py-3">
<div class="flex items-center gap-2">
<Icon name="photo" size="sm" class="text-primary-500" />
@@ -408,7 +428,7 @@ function downloadVideo() {
<h3 class="text-lg font-semibold text-gray-900">等待生成</h3>
<p class="mt-1.5 max-w-sm text-sm text-gray-500">
<template v-if="selectedModel">
当前模型 <span class="font-medium text-gray-700">{{ selectedModelName }}</span>,填写提示词后点击「生成视频」
当前模型 <span class="font-medium text-gray-700">{{ selectedModelName }}</span>,在下方输入提示词后点击「生成视频」
</template>
<template v-else>请先在左侧选择一个视频模型</template>
</p>
@@ -429,24 +449,23 @@ function downloadVideo() {
</p>
</div>
<!-- 提交中:卡片式占位 -->
<GenerationProgressCard v-if="submitting" kind="video" label="正在提交生成任务..." />
<!-- 提交中 / 任务进行中:小卡片占位,只占左半侧,给下方展开的提示词留出高度 -->
<GenerationProgressCard
v-if="submitting || currentTask?.status === 'queued' || currentTask?.status === 'in_progress'"
kind="video"
compact
:progress="submitting ? '' : currentTask?.progress"
:label="submitting ? '正在提交生成任务...' : (statusLabel[currentTask!.status] || currentTask!.status)"
:sublabel="!submitting && currentTask?.id ? 'task_id: ' + currentTask.id : ''"
/>
<!-- 任务状态 -->
<div v-if="currentTask" class="space-y-4">
<!-- 任务进行中:按成品 16:9 形状的卡片式进度占位 -->
<GenerationProgressCard
v-if="currentTask.status === 'queued' || currentTask.status === 'in_progress'"
kind="video"
:progress="currentTask.progress"
:label="statusLabel[currentTask.status] || currentTask.status"
:sublabel="currentTask.id ? 'task_id: ' + currentTask.id : ''"
/>
<!-- 视频结果 -->
<div v-if="currentTask.status === 'completed' && videoUrl" class="space-y-3">
<video controls class="w-full rounded-xl border border-gray-200 bg-black" :src="videoUrl" />
<button class="btn btn-secondary btn-sm" @click="downloadVideo">
<!-- 视频结果:与生成态同一尺寸约束(左半侧 + 高度上限),
并让结果区里「视频 + 按钮 + 详情」三块正好放满,不出现滚动条 -->
<div v-if="currentTask.status === 'completed' && videoUrl" class="video-result">
<video controls class="video-result__player" :src="videoUrl" />
<button class="btn btn-secondary btn-sm shrink-0" @click="downloadVideo">
<Icon name="arrowDown" size="sm" />
下载视频
</button>
@@ -455,6 +474,7 @@ function downloadVideo() {
<GenerationProgressCard
v-else-if="currentTask.status === 'completed'"
kind="video"
compact
label="视频加载中..."
/>
@@ -476,8 +496,101 @@ function downloadVideo() {
</div>
</div>
</div>
<!-- 输入区:参考素材片 + 提示词 + 操作行整合进同一个圆角盒子(与对话 Tab 同构) -->
<div class="shrink-0 border-t border-gray-100 p-3 lg:p-4">
<div
class="input-shell"
:class="{ 'input-shell-dragging': dragging }"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDropFiles"
>
<!-- 素材片(bare:无边框无上传按钮,融入本容器);
文件选择器仍挂载于此,由下方「素材」按钮通过 ref 触发 -->
<AttachmentBar
ref="attachmentBar"
variant="bare"
:class="attachmentList.length ? 'mb-2' : ''"
:attachments="attachmentList"
:accept="acceptTypes"
:max="attachmentMax"
@add="addAttachments"
@remove="removeAttachment"
@insert="insertMention"
/>
<PromptInput
ref="promptInput"
v-model="prompt"
bare
:attachments="attachmentList"
:max-rows="3"
placeholder="描述你想生成的视频,输入 @ 引用已上传的素材..."
@submit="submitTask"
@paste="addAttachments"
/>
<!-- 操作行 -->
<div class="mt-1.5 flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-1">
<button
class="input-tool"
title="添加参考素材(图片/视频/音频,也可拖拽到此处或直接粘贴)"
@click="openFilePicker"
>
<Icon name="paperclip" size="xs" />
素材
</button>
<button
v-if="attachmentList.length"
class="input-tool"
title="清空已选素材"
@click="clearAllAttachments"
>
<Icon name="x" size="xs" />
清空素材
</button>
<span class="ml-1 hidden truncate text-[11px] text-gray-400 sm:inline">
Enter 生成 · Shift + Enter 换行<template v-if="attachmentList.length"> · 素材 {{ formatBytes(attachmentBytes) }}</template>
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<button v-if="currentTask" class="btn btn-ghost btn-sm text-gray-500" :disabled="submitting || polling" @click="resetTask">
<Icon name="refresh" size="xs" />
重新生成
</button>
<button class="btn btn-primary btn-sm px-4" :disabled="submitting || polling || !prompt.trim()" @click="submitTask">
<Icon name="play" size="xs" />
{{ submitting ? '提交中...' : polling ? '生成中...' : '生成视频' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 成品视频:宽度取「左半侧」与「高度上限反推」中的较小值,
高度上限用容器高度参与计算(100% 在此指结果区内容高度),
这样无论横屏还是竖屏视频,都不会把结果区撑出滚动条。 */
.video-result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.video-result__player {
display: block;
width: auto;
max-width: min(50%, calc((100% - 7rem) * 16 / 9));
max-height: calc(100% - 3rem);
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
background: #000;
}
</style>