feat(ui):优化视频生成布局
This commit is contained in:
1 parent
135f8fdb02
commit
d0724d50ba
9 files changed
+744
-372
No files matched your search
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user