mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-05-11 04:04:29 +08:00
Jimeng VirtualHuman and actionTransfer is ready
This commit is contained in:
@@ -46,14 +46,17 @@ const (
|
||||
type JimengTaskRequest struct {
|
||||
TaskType JMTaskType `json:"type"` // 任务类型
|
||||
ReqKey string `json:"req_key"` // 请求Key
|
||||
Action string `json:"action"` // 请求Action
|
||||
Power int `json:"power"` // 消耗算力
|
||||
// 公共参数
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
ImageUrls []string `json:"image_urls,omitempty"`
|
||||
|
||||
// 图片生成参数
|
||||
Size string `json:"size,omitempty"`
|
||||
UsePreLLM bool `json:"use_pre_llm,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
UsePreLLM bool `json:"use_pre_llm,omitempty"`
|
||||
Scale float64 `json:"scale,omitempty"`
|
||||
ForceSingle bool `json:"force_single,omitempty"`
|
||||
|
||||
// 视频生成参数
|
||||
Duration int `json:"duration,omitempty"` // 视频时长,单位:秒
|
||||
@@ -62,7 +65,8 @@ type JimengTaskRequest struct {
|
||||
CameraStrength string `json:"camera_strength,omitempty"` // 运镜强度
|
||||
|
||||
// 数字人视频生成参数
|
||||
AudioURL string `json:"audio_url,omitempty"` // 音频URL
|
||||
AudioURL string `json:"audio_url,omitempty"` // 音频URL
|
||||
RecognizeKey string `json:"recognize_key,omitempty"` // 识别主体请求Key
|
||||
|
||||
// 视频动作迁移参数
|
||||
VideoURL string `json:"video_url,omitempty"` // 动作视频URL
|
||||
|
||||
@@ -97,7 +97,7 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
|
||||
// 获取算力消耗
|
||||
powerCost, err := h.getTaskPower(req)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "计算任务消耗积分失败")
|
||||
resp.ERROR(c, "计算任务消耗积分失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
|
||||
resp.ERROR(c, fmt.Sprintf("算力不足,需要%d算力", powerCost))
|
||||
return
|
||||
}
|
||||
req.Power = powerCost
|
||||
|
||||
job, err := h.jimengService.CreateTask(user.Id, &req)
|
||||
if err != nil {
|
||||
@@ -116,12 +117,27 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
|
||||
h.userService.DecreasePower(user.Id, powerCost, model.PowerLog{
|
||||
Type: types.PowerConsume,
|
||||
Model: job.ReqKey,
|
||||
Remark: fmt.Sprintf("%s,任务ID:%d", req.ReqKey, job.Id),
|
||||
Remark: h.getTaskRemark(req, job.Id),
|
||||
})
|
||||
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
func (h *JimengHandler) getTaskRemark(req types.JimengTaskRequest, jobId uint) string {
|
||||
remark := fmt.Sprintf("即梦任务%s,任务ID:%d", req.ReqKey, jobId)
|
||||
switch req.TaskType {
|
||||
case types.JMTaskTypeImage:
|
||||
remark = fmt.Sprintf("即梦图片生成,任务ID:%d,%d积分/张", jobId, h.App.SysConfig.Jimeng.Power.Image)
|
||||
case types.JMTaskTypeVideo:
|
||||
remark = fmt.Sprintf("即梦视频生成,任务ID:%d,%d积分/秒, %d秒", jobId, h.App.SysConfig.Jimeng.Power.Video, req.Power/h.App.SysConfig.Jimeng.Power.Video)
|
||||
case types.JMTaskTypeVirtualHuman:
|
||||
remark = fmt.Sprintf("即梦数字人视频生成,任务ID:%d,%d积分/秒, %d秒", jobId, h.App.SysConfig.Jimeng.Power.VirtualHuman, req.Power/h.App.SysConfig.Jimeng.Power.VirtualHuman)
|
||||
case types.JMTaskTypeActionTransfer:
|
||||
remark = fmt.Sprintf("即梦视频动作迁移,任务ID:%d,%d积分/秒, %d秒", jobId, h.App.SysConfig.Jimeng.Power.ActionTransfer, req.Power/h.App.SysConfig.Jimeng.Power.ActionTransfer)
|
||||
}
|
||||
return remark
|
||||
}
|
||||
|
||||
// Jobs 获取任务列表
|
||||
func (h *JimengHandler) Jobs(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
@@ -219,7 +235,8 @@ func (h *JimengHandler) Remove(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 失败任务删除后退回算力
|
||||
if job.Status != types.JMTaskStatusFailed {
|
||||
if job.Status == types.JMTaskStatusFailed {
|
||||
logger.Infof("delete jimeng job failed, refund power: %d", job.Power)
|
||||
err = h.userService.IncreasePower(user.Id, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.ReqKey,
|
||||
@@ -284,6 +301,7 @@ func (h *JimengHandler) Retry(c *gin.Context) {
|
||||
|
||||
// getPowerFromConfig 从配置中获取指定类型的算力消耗
|
||||
func (h *JimengHandler) getTaskPower(req types.JimengTaskRequest) (int, error) {
|
||||
logger.Debugf("getTaskPower req: %+v", req)
|
||||
config := h.App.SysConfig.Jimeng
|
||||
switch req.TaskType {
|
||||
case types.JMTaskTypeImage:
|
||||
@@ -295,10 +313,24 @@ func (h *JimengHandler) getTaskPower(req types.JimengTaskRequest) (int, error) {
|
||||
return config.Power.Video * req.Duration, nil
|
||||
case types.JMTaskTypeVirtualHuman:
|
||||
// TODO 计算音频时长
|
||||
return config.Power.VirtualHuman, nil
|
||||
if req.AudioURL == "" {
|
||||
return 0, errors.New("音频URL不能为空")
|
||||
}
|
||||
audioDuration, err := utils.AudioDurationFromURL(req.AudioURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return config.Power.VirtualHuman * int(audioDuration.Seconds()), nil
|
||||
case types.JMTaskTypeActionTransfer:
|
||||
// TODO 计算视频时长
|
||||
return config.Power.ActionTransfer, nil
|
||||
if req.VideoURL == "" {
|
||||
return 0, errors.New("视频URL不能为空")
|
||||
}
|
||||
videoDuration, err := utils.VideoDurationMP4FromURL(req.VideoURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return config.Power.ActionTransfer * int(videoDuration.Seconds()), nil
|
||||
default:
|
||||
return 0, errors.New("任务类型不支持")
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package jimeng
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/volcengine/volc-sdk-golang/base"
|
||||
"github.com/volcengine/volc-sdk-golang/service/visual"
|
||||
@@ -54,6 +56,22 @@ func (c *Client) UpdateConfig(config types.JimengConfig) error {
|
||||
"Version": []string{"2022-08-31"},
|
||||
},
|
||||
},
|
||||
"CVSubmitTask": {
|
||||
Method: http.MethodPost,
|
||||
Path: "/",
|
||||
Query: url.Values{
|
||||
"Action": []string{"CVSubmitTask"},
|
||||
"Version": []string{"2022-08-31"},
|
||||
},
|
||||
},
|
||||
"CVGetResult": {
|
||||
Method: http.MethodPost,
|
||||
Path: "/",
|
||||
Query: url.Values{
|
||||
"Action": []string{"CVGetResult"},
|
||||
"Version": []string{"2022-08-31"},
|
||||
},
|
||||
},
|
||||
"CVProcess": {
|
||||
Method: http.MethodPost,
|
||||
Path: "/",
|
||||
@@ -75,6 +93,22 @@ func (c *Client) UpdateConfig(config types.JimengConfig) error {
|
||||
return c.testConnection()
|
||||
}
|
||||
|
||||
// GetErrorMessage 根据错误代码获取对应的错误信息
|
||||
func GetErrorMessage(code int) string {
|
||||
if message, exists := errorCodeMessages[code]; exists {
|
||||
return message
|
||||
}
|
||||
return fmt.Sprintf("未知错误代码: %d", code)
|
||||
}
|
||||
|
||||
// HandleResponseError 处理响应错误,根据错误代码返回详细的错误信息
|
||||
func HandleResponseError(code int, message string) error {
|
||||
if code == ECSuccess {
|
||||
return nil
|
||||
}
|
||||
return errors.New(GetErrorMessage(code))
|
||||
}
|
||||
|
||||
// testConnection 测试即梦AI连接
|
||||
func (c *Client) testConnection() error {
|
||||
|
||||
@@ -84,7 +118,7 @@ func (c *Client) testConnection() error {
|
||||
TaskId: "test_task_id_12345",
|
||||
}
|
||||
|
||||
_, err := c.QueryTask(testReq)
|
||||
_, err := c.QueryTask(testReq, ASyncActionGetResult)
|
||||
// 即使任务不存在,只要不是认证错误就说明连接正常
|
||||
if err != nil {
|
||||
// 检查是否是认证错误
|
||||
@@ -105,17 +139,16 @@ func (c *Client) SubmitTask(req map[string]any) (*SubmitTaskResponse, error) {
|
||||
return nil, fmt.Errorf("marshal request failed: %w", err)
|
||||
}
|
||||
|
||||
// 单独处理图片特效任务
|
||||
if req["req_key"] == ImageEffectReqKey {
|
||||
req["image_input1"] = req["image_urls"].([]any)[0]
|
||||
delete(req, "image_urls")
|
||||
}
|
||||
|
||||
// 直接使用序列化后的字节
|
||||
jsonBody := reqBodyBytes
|
||||
action := ASyncActionSubmit
|
||||
if v, ok := req["action"]; ok {
|
||||
action = v.(string)
|
||||
delete(req, "action")
|
||||
}
|
||||
|
||||
// 调用SDK的JSON方法
|
||||
respBody, statusCode, err := c.visual.Client.Json("CVSync2AsyncSubmitTask", nil, string(jsonBody))
|
||||
respBody, statusCode, err := c.visual.Client.Json(action, nil, string(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submit task failed (status: %d): %w", statusCode, err)
|
||||
}
|
||||
@@ -128,11 +161,70 @@ func (c *Client) SubmitTask(req map[string]any) (*SubmitTaskResponse, error) {
|
||||
return nil, fmt.Errorf("unmarshal response failed: %w", err)
|
||||
}
|
||||
|
||||
// 检查响应错误代码
|
||||
if err := HandleResponseError(result.Code, result.Message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// 识别数字人主体
|
||||
func (c *Client) AvatarRecognition(imgUrl string, reqKey string) error {
|
||||
params := map[string]any{
|
||||
"image_url": imgUrl,
|
||||
"req_key": reqKey,
|
||||
}
|
||||
reqBodyBytes, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request failed: %w", err)
|
||||
}
|
||||
// 调用SDK的JSON方法
|
||||
respBody, statusCode, err := c.visual.Client.Json(SyncActionSubmit, nil, string(reqBodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("submit task failed (status: %d): %w", statusCode, err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result SubmitTaskResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return fmt.Errorf("unmarshal response failed: %w", err)
|
||||
}
|
||||
|
||||
// 检查响应错误代码
|
||||
if err := HandleResponseError(result.Code, result.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 等待任务完成
|
||||
for {
|
||||
resp, err := c.QueryTask(&QueryTaskRequest{
|
||||
ReqKey: reqKey,
|
||||
TaskId: result.Data.TaskId,
|
||||
}, SyncActionGetResult)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query task failed: %w", err)
|
||||
}
|
||||
if resp.Data.Status != types.JMTaskStatusDone {
|
||||
time.Sleep(time.Second * 3)
|
||||
continue
|
||||
}
|
||||
var respData map[string]int
|
||||
if err := json.Unmarshal([]byte(resp.Data.RespData), &respData); err != nil {
|
||||
return fmt.Errorf("unmarshal response failed: %w", err)
|
||||
}
|
||||
logger.Debugf("Jimeng AvatarRecognition Response: %+v", resp)
|
||||
if respData["status"] == 1 {
|
||||
return nil
|
||||
} else {
|
||||
return errors.New("不包含人、类人、拟人等主体")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// QueryTask 查询任务结果
|
||||
func (c *Client) QueryTask(req *QueryTaskRequest) (*QueryTaskResponse, error) {
|
||||
func (c *Client) QueryTask(req *QueryTaskRequest, action string) (*QueryTaskResponse, error) {
|
||||
// 序列化请求
|
||||
jsonBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -140,7 +232,7 @@ func (c *Client) QueryTask(req *QueryTaskRequest) (*QueryTaskResponse, error) {
|
||||
}
|
||||
|
||||
// 调用SDK的JSON方法
|
||||
respBody, statusCode, err := c.visual.Client.Json("CVSync2AsyncGetResult", nil, string(jsonBody))
|
||||
respBody, statusCode, err := c.visual.Client.Json(action, nil, string(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query task failed (status: %d): %w", statusCode, err)
|
||||
}
|
||||
@@ -153,6 +245,11 @@ func (c *Client) QueryTask(req *QueryTaskRequest) (*QueryTaskResponse, error) {
|
||||
return nil, fmt.Errorf("unmarshal response failed: %w", err)
|
||||
}
|
||||
|
||||
// 检查响应错误代码
|
||||
if err := HandleResponseError(result.Code, result.Message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,12 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
return s.handleTaskError(job.Id, fmt.Sprintf("build task request failed: %v", err))
|
||||
}
|
||||
|
||||
logger.Debugf("提交即梦任务: %+v", params)
|
||||
// 数字人任务,先识别主体
|
||||
if req.TaskType == types.JMTaskTypeVirtualHuman {
|
||||
if err := s.client.AvatarRecognition(req.ImageUrls[0], req.RecognizeKey); err != nil {
|
||||
return s.handleTaskError(job.Id, fmt.Sprintf("avatar recognition failed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 同步任务 ,后台执行
|
||||
if req.ReqKey == DoubaoSeedream40ReqKey {
|
||||
@@ -195,6 +200,7 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debugf("提交即梦任务: %+v", params)
|
||||
// 异步任务 ,前台执行
|
||||
resp, err := s.client.SubmitTask(params)
|
||||
if err != nil {
|
||||
@@ -245,6 +251,21 @@ func (s *Service) buildTaskRequest(req *types.JimengTaskRequest) (map[string]any
|
||||
delete(params, "duration")
|
||||
}
|
||||
|
||||
// 单独处理图片特效任务
|
||||
if req.ReqKey == ImageEffectReqKey {
|
||||
params["image_input1"] = req.ImageUrls[0]
|
||||
delete(params, "image_urls")
|
||||
}
|
||||
|
||||
// 动作迁移,数字人任务参数处理
|
||||
if req.TaskType == types.JMTaskTypeVirtualHuman || req.TaskType == types.JMTaskTypeActionTransfer {
|
||||
params["image_url"] = req.ImageUrls[0]
|
||||
delete(params, "image_urls")
|
||||
}
|
||||
if req.RecognizeKey != "" {
|
||||
delete(params, "recognize_key")
|
||||
}
|
||||
|
||||
// 删除多余参数,剩下的就是各个任务自己专有参数了
|
||||
delete(params, "type")
|
||||
delete(params, "power")
|
||||
@@ -280,7 +301,7 @@ func (s *Service) pollTaskStatus() {
|
||||
ReqKey: job.ReqKey,
|
||||
TaskId: job.TaskId,
|
||||
ReqJson: `{"return_url":true}`,
|
||||
})
|
||||
}, ASyncActionGetResult)
|
||||
|
||||
if err != nil {
|
||||
s.handleTaskError(job.Id, fmt.Sprintf("query task failed: %s", err.Error()))
|
||||
|
||||
@@ -51,6 +51,68 @@ type QueryTaskResponse struct {
|
||||
|
||||
const CodeSuccess = 10000
|
||||
|
||||
// 即梦AI错误代码常量
|
||||
const (
|
||||
// 成功
|
||||
ECSuccess = 10000
|
||||
|
||||
// 请求参数错误 (50200-50215)
|
||||
ECReqInvalidArgs = 50200 // 参数错误
|
||||
ECReqMissingArgs = 50201 // 缺少参数
|
||||
ECParseArgs = 50204 // 参数类型错误/参数缺失
|
||||
ECImageSizeLimited = 50205 // 图像尺寸超过限制
|
||||
ECImageEmpty = 50206 // 请求参数中没有获取到图像
|
||||
ECImageDecodeError = 50207 // 图像解码错误
|
||||
ECVideoEmpty = 50209 // 请求参数中没有获取到视频
|
||||
ECVideoDecodeError = 50210 // 视频解码错误
|
||||
ECVideoSizeLimited = 50211 // 视频尺寸超过限制
|
||||
ECReqBodySizeLimited = 50213 // 请求Body过大
|
||||
ECVideoTimeTooLong = 50214 // 输入视频时长过大
|
||||
ECRPCProcess = 50215 // 请求处理失败
|
||||
|
||||
// 算法服务错误 (60102-60208)
|
||||
ECJPFaceDetect = 60102 // 算法服务需要输入人脸图,但未检测到
|
||||
ECFSLeaderRiskError = 60208 // 输入图片中包含敏感信息,未通过审核
|
||||
|
||||
// 权限和系统错误 (50400-50501)
|
||||
ECAuth = 50400 // 权限校验失败
|
||||
ECReqMethod = 50402 // 访问的接口不存在
|
||||
ECReqLimit = 50429 // 超过调用QPS限制
|
||||
ECInternal = 50500 // 服务器内部错误
|
||||
ECRPCInternal = 50501 // 服务器内部RPC错误
|
||||
)
|
||||
|
||||
// 错误代码到错误信息的映射
|
||||
var errorCodeMessages = map[int]string{
|
||||
// 成功
|
||||
ECSuccess: "请求成功",
|
||||
|
||||
// 请求参数错误
|
||||
ECReqInvalidArgs: "参数错误,检查入参及MIME类型",
|
||||
ECReqMissingArgs: "缺少参数,检查入参及MIME类型",
|
||||
ECParseArgs: "参数类型错误/参数缺失,检查入参及MIME类型",
|
||||
ECImageSizeLimited: "图像尺寸超过限制,参考接口文档入参要求部分",
|
||||
ECImageEmpty: "请求参数中没有获取到图像,检查入参",
|
||||
ECImageDecodeError: "图像解码错误:没有获取到图像或者通过image_base64参数传递图像是base64解码错误,检查输出图片或检查base64是否错误携带前缀",
|
||||
ECVideoEmpty: "请求参数中没有获取到视频。输入为视频时可能返回此错误,检查入参",
|
||||
ECVideoDecodeError: "视频解码错误。输入为视频时可能返回此错误,检查输入视频是否不正确",
|
||||
ECVideoSizeLimited: "视频尺寸超过限制。输入为视频时可能返回此错误,检查输入视频大小",
|
||||
ECReqBodySizeLimited: "请求Body过大,超出接口限制,检查请求Body大小",
|
||||
ECVideoTimeTooLong: "输入视频时长过大,检查输入视频时长",
|
||||
ECRPCProcess: "由于输入的图片、视频、参数等不满足要求,导致请求处理失败。若接口文档中有具体说明,优先参考其具体含义,按照具体服务说明进行检查",
|
||||
|
||||
// 算法服务错误
|
||||
ECJPFaceDetect: "算法服务需要输入人脸图,但未检测到,检查输入图片是否包含人脸",
|
||||
ECFSLeaderRiskError: "输入图片中包含敏感信息,未通过审核",
|
||||
|
||||
// 权限和系统错误
|
||||
ECAuth: "权限校验失败,请检查是否已创建应用并开通服务或签名,参考接入指南及快速接入",
|
||||
ECReqMethod: "访问的接口不存在,检查入参",
|
||||
ECReqLimit: "超过调用QPS限制,购买QPS增项包",
|
||||
ECInternal: "服务器内部错误,提工单",
|
||||
ECRPCInternal: "服务器内部RPC错误,提工单",
|
||||
}
|
||||
|
||||
// CreateTaskRequest 创建任务请求
|
||||
type CreateTaskRequest struct {
|
||||
Type types.JMTaskType `json:"type"`
|
||||
@@ -65,3 +127,10 @@ const (
|
||||
ImageEffectReqKey = "i2i_multi_style_zx2x"
|
||||
DoubaoSeedream40ReqKey = "doubao-seedream-4-0-250828"
|
||||
)
|
||||
|
||||
const (
|
||||
ASyncActionSubmit = "CVSync2AsyncSubmitTask" // 异步提交任务
|
||||
SyncActionSubmit = "CVSubmitTask" // 同步提交任务
|
||||
ASyncActionGetResult = "CVSync2AsyncGetResult" // 异步获取结果
|
||||
SyncActionGetResult = "CVGetResult" // 同步获取结果
|
||||
)
|
||||
|
||||
469
web/src/components/FileUpload.vue
Normal file
469
web/src/components/FileUpload.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="file__upload-container">
|
||||
<!-- 单文件模式 -->
|
||||
<template v-if="props.maxCount === 1">
|
||||
<div class="single-upload">
|
||||
<div v-if="fileList.length === 0" class="upload-btn upload-btn-single">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:multiple="false"
|
||||
:accept="accept"
|
||||
class="uploader"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<el-icon :size="20"><UploadFilled /></el-icon>
|
||||
<span>上传文件</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative inline-flex items-center border border-gray-200 rounded-xl bg-white dark:bg-[#2b2b2b] dark:border-gray-700 p-2 w-full"
|
||||
>
|
||||
<img :src="getFileImage(fileList[0].url)" class="w-10 h-10 mr-2" />
|
||||
<div class="min-w-0 flex flex-col items-center gap-1 text-sm">
|
||||
<a
|
||||
:href="fileList[0].url"
|
||||
target="_blank"
|
||||
class="truncate block text-[var(--theme-text-color-primary,#0d0d0d)] max-w-[220px]"
|
||||
>
|
||||
{{ fileList[0].name }}
|
||||
</a>
|
||||
<div class="text-xs flex w-full justify-start text-gray-500">
|
||||
{{ GetFileType(getFileExt(fileList[0].name)) }} ·
|
||||
{{ FormatFileSize(fileList[0].size || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute -right-2 -top-2 w-5 h-5 rounded-full bg-rose-600 text-white flex items-center justify-center text-[10px]"
|
||||
@click="removeFile(0)"
|
||||
aria-label="remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 多文件模式 -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2 px-2 pt-2 !items-start" v-if="fileList.length > 0">
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="file.url || index"
|
||||
class="relative inline-flex items-center border border-gray-200 rounded-xl bg-white dark:bg-[#2b2b2b] dark:border-gray-700 p-2 w-full"
|
||||
>
|
||||
<img :src="getFileImage(file.url)" class="w-10 h-10 mr-2" />
|
||||
<div class="min-w-0 flex flex-col items-center gap-1 text-sm">
|
||||
<a :href="file.url" target="_blank" class="truncate block max-w-[180px]">{{
|
||||
file.name
|
||||
}}</a>
|
||||
<div class="text-xs flex w-full justify-start text-gray-500">
|
||||
{{ GetFileType(getFileExt(file.name)) }} · {{ FormatFileSize(file.size || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute -right-2 -top-2 w-5 h-5 rounded-full bg-rose-600 text-white flex items-center justify-center text-[10px]"
|
||||
@click="removeFile(index)"
|
||||
aria-label="remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<!-- 上传按钮 -->
|
||||
<div v-if="!multiple || fileList.length < maxCount" class="upload-btn">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
class="uploader"
|
||||
:limit="maxCount"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<el-icon :size="20"><UploadFilled /></el-icon>
|
||||
<span>上传文件</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 初始上传区域 -->
|
||||
<div v-else class="upload-area">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
class="uploader"
|
||||
:limit="maxCount"
|
||||
>
|
||||
<el-icon :size="40" class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽文件到此处,或 <em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip text-gray-500 text-sm">
|
||||
支持 {{ accept }} 格式,最多上传 {{ maxCount }} 个,单个最大 {{ maxSize }}MB
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<el-progress
|
||||
v-if="uploading"
|
||||
:percentage="uploadProgress"
|
||||
:stroke-width="4"
|
||||
class="upload-progress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
|
||||
import { httpPost } from '@/utils/http'
|
||||
import { isImage, replaceImg } from '@/utils/libs'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Array],
|
||||
default: '',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.zip,.rar,.7z',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'remove-file'])
|
||||
|
||||
// 上传状态
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const FileInfoList = ref([])
|
||||
|
||||
// 文件列表
|
||||
const fileList = computed({
|
||||
get() {
|
||||
if (props.multiple || props.maxCount > 1) {
|
||||
return FileInfoList.value
|
||||
} else {
|
||||
return FileInfoList.value && FileInfoList.value.length > 0 ? FileInfoList.value : []
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
const isMulti = props.multiple || props.maxCount > 1
|
||||
const normalized = Array.isArray(value) ? value : value ? [value] : []
|
||||
FileInfoList.value = normalized
|
||||
if (isMulti) {
|
||||
const urls = normalized.map((v) => v && v.url).filter((u) => !!u)
|
||||
emit('update:modelValue', urls)
|
||||
} else {
|
||||
const url =
|
||||
normalized.length > 0 && normalized[0] && normalized[0].url ? normalized[0].url : ''
|
||||
emit('update:modelValue', url)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const uploadCount = ref(1)
|
||||
|
||||
// 获取文件扩展名
|
||||
const getFileExt = (filename) => {
|
||||
return '.' + filename.split('.').pop().toLowerCase()
|
||||
}
|
||||
|
||||
const getFileName = (url) => {
|
||||
return url.split('/').pop()
|
||||
}
|
||||
|
||||
// 获取文件
|
||||
const getFileImage = (url) => {
|
||||
return isImage(url) ? url : GetFileIcon(getFileExt(url))
|
||||
}
|
||||
|
||||
// 将外部 modelValue 同步为内部文件对象列表
|
||||
const urlToFileInfo = (url) => ({
|
||||
url,
|
||||
name: getFileName(url),
|
||||
size: 0,
|
||||
ext: getFileExt(url),
|
||||
})
|
||||
|
||||
// 通过 HEAD 请求尝试获取远程资源大小
|
||||
const fetchRemoteFileSize = async (url) => {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD' })
|
||||
const len = res.headers.get('content-length')
|
||||
return len ? parseInt(len, 10) : 0
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 对 size 为空或 0 的项进行补充
|
||||
const updateUnknownSizes = async (items) => {
|
||||
const tasks = items.map(async (it) => {
|
||||
if (!it || !it.url) return it
|
||||
if (!it.size || it.size === 0) {
|
||||
const s = await fetchRemoteFileSize(it.url)
|
||||
if (s > 0) {
|
||||
it.size = s
|
||||
}
|
||||
}
|
||||
return it
|
||||
})
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
const isMulti = props.multiple || props.maxCount > 1
|
||||
if (isMulti) {
|
||||
const urls = Array.isArray(newVal) ? newVal : []
|
||||
FileInfoList.value = urls.map((u) => urlToFileInfo(u))
|
||||
// 异步补齐大小
|
||||
updateUnknownSizes(FileInfoList.value)
|
||||
} else {
|
||||
const url = typeof newVal === 'string' ? newVal : ''
|
||||
FileInfoList.value = url ? [urlToFileInfo(url)] : []
|
||||
// 异步补齐大小
|
||||
updateUnknownSizes(FileInfoList.value)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 处理上传
|
||||
const handleUpload = async (uploadFile) => {
|
||||
const file = uploadFile.file
|
||||
// 检查文件大小
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数量限制
|
||||
if (uploadCount.value > props.maxCount) {
|
||||
ElMessage.error(`最多只能上传 ${props.maxCount} 个文件`)
|
||||
return
|
||||
}
|
||||
uploadCount.value++
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
// 模拟上传进度
|
||||
const progressTimer = setInterval(() => {
|
||||
if (uploadProgress.value < 90) {
|
||||
uploadProgress.value += 10
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const response = await httpPost('/api/upload', formData)
|
||||
|
||||
clearInterval(progressTimer)
|
||||
uploadProgress.value = 100
|
||||
|
||||
const fileUrl = replaceImg(response.data.url)
|
||||
const fileInfo = {
|
||||
name: file.name,
|
||||
url: fileUrl,
|
||||
size: file.size,
|
||||
ext: getFileExt(file.name),
|
||||
}
|
||||
|
||||
// 更新文件列表
|
||||
if (props.multiple || props.maxCount > 1) {
|
||||
const newList = [...fileList.value, fileInfo]
|
||||
fileList.value = newList
|
||||
} else {
|
||||
fileList.value = [fileInfo]
|
||||
}
|
||||
|
||||
emit('upload-success', fileInfo)
|
||||
ElMessage.success('上传成功')
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败: ' + (error.message || '网络错误'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
const removeFile = (index) => {
|
||||
const file = fileList.value[index]
|
||||
const newList = [...fileList.value]
|
||||
newList.splice(index, 1)
|
||||
fileList.value = newList
|
||||
uploadCount.value--
|
||||
emit('remove-file', file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.file__upload-container {
|
||||
width: 100%;
|
||||
|
||||
.single-upload {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.upload-btn-single {
|
||||
.uploader {
|
||||
width: 100%;
|
||||
|
||||
.el-upload-dragger {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .single-file-item {
|
||||
// width: 100%;
|
||||
// position: relative;
|
||||
// border-radius: 6px;
|
||||
// overflow: hidden;
|
||||
// border: 1px solid #dcdfe6;
|
||||
// background-color: var(--chat-content-bg, #f5f5f5);
|
||||
// padding: 8px;
|
||||
// }
|
||||
|
||||
.upload-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
background-color: var(--chat-content-bg, #f5f5f5);
|
||||
padding: 8px;
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
.el-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-left: 8px;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
|
||||
.title {
|
||||
color: #0d0d0d;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #b4b4b4;
|
||||
|
||||
span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-overlay {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
opacity: 1;
|
||||
|
||||
.remove-btn {
|
||||
background: rgba(245, 108, 108, 0.8);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
.uploader {
|
||||
width: 100%;
|
||||
|
||||
.el-upload-dragger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #8c939d;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
.el-upload-dragger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.uploader {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="image-upload">
|
||||
<div class="image__upload-container">
|
||||
<!-- 单图模式 -->
|
||||
<template v-if="props.maxCount === 1">
|
||||
<div class="single-upload">
|
||||
@@ -59,7 +59,7 @@
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:multiple="multiple"
|
||||
accept="image/*"
|
||||
:accept="accept"
|
||||
class="uploader"
|
||||
:limit="maxCount"
|
||||
>
|
||||
@@ -157,7 +157,7 @@ const imageList = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const uploadCount = ref(1)
|
||||
// 使用已选图片数量进行限制,不再使用全局计数
|
||||
// 处理上传
|
||||
const handleUpload = async (uploadFile) => {
|
||||
const file = uploadFile.file
|
||||
@@ -174,12 +174,11 @@ const handleUpload = async (uploadFile) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数量限制
|
||||
if (uploadCount.value > props.maxCount) {
|
||||
// 检查数量限制(单图或多图)
|
||||
if ((props.multiple || props.maxCount > 1) && imageList.value.length >= props.maxCount) {
|
||||
ElMessage.error(`最多只能上传 ${props.maxCount} 张图片`)
|
||||
return
|
||||
}
|
||||
uploadCount.value++
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
@@ -225,111 +224,110 @@ const removeImage = (index) => {
|
||||
const newList = [...imageList.value]
|
||||
newList.splice(index, 1)
|
||||
imageList.value = newList
|
||||
uploadCount.value--
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.image-upload {
|
||||
.image__upload-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.single-upload {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.single-image-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
|
||||
.upload-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.single-upload {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
.single-image-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
|
||||
.remove-btn {
|
||||
background: rgba(245, 108, 108, 0.8);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
.uploader {
|
||||
width: 100%;
|
||||
.upload-item {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
|
||||
.el-upload-dragger {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
.upload-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.upload-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
|
||||
.remove-btn {
|
||||
background: rgba(245, 108, 108, 0.8);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
.upload-btn {
|
||||
.uploader {
|
||||
width: 100%;
|
||||
|
||||
.el-upload-dragger {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #8c939d;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
.el-upload-dragger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.uploader {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #8c939d;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
.el-upload-dragger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.uploader {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
<el-option v-for="item in items" :key="item.name" :label="item.name" :value="item">
|
||||
<div class="flex justify-start">
|
||||
<span
|
||||
class="flex items-center justify-center text-white !text-xl model-version mr-2 w-[40px] h-[40px] rounded-lg"
|
||||
>{{ item.version }}</span
|
||||
class="flex items-center justify-center text-white model-version mr-2 w-[40px] h-[40px] rounded-lg"
|
||||
:class="item.icon.size ? item.icon.size : '!text-xl'"
|
||||
>{{ item.icon.text }}</span
|
||||
>
|
||||
<div class="flex !items-start flex-col py-2 space-y-1">
|
||||
<span class="label text-sm">{{ item.name }}</span>
|
||||
<div class="whitespace-pre-line">
|
||||
<span
|
||||
class="text-xs text-gray-500 break-words line-clamp-1 max-w-[200px]"
|
||||
class="text-xs text-gray-500 break-words line-clamp-1 max-w-[250px]"
|
||||
:title="item.label"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
@@ -47,7 +48,10 @@
|
||||
</div>
|
||||
<p v-if="param.info" class="text-xs text-gray-500 mb-1">{{ param.info }}</p>
|
||||
</div>
|
||||
<div class="w-full flex flex-col !items-start space-y-2" v-else>
|
||||
<div
|
||||
class="w-full flex flex-col !items-start space-y-2"
|
||||
v-else-if="param.type !== 'hidden'"
|
||||
>
|
||||
<label class="label font-bold">
|
||||
{{ param.label }}
|
||||
<span v-if="param.required" class="text-red-500 ml-1">*</span>
|
||||
@@ -139,6 +143,14 @@
|
||||
:max-size="param.maxSize"
|
||||
:accept="param.accept"
|
||||
/>
|
||||
<FileUpload
|
||||
v-if="param.type === 'file'"
|
||||
v-model="modelValue[param.name]"
|
||||
:max-count="param.maxCount"
|
||||
:multiple="param.multiple"
|
||||
:max-size="param.maxSize"
|
||||
:accept="param.accept"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,6 +159,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FileUpload from './FileUpload.vue'
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
import ParamEmpty from './ui/ParamEmpty.vue'
|
||||
|
||||
@@ -225,7 +238,11 @@ const initModelValue = (model) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
// 初始化 req_key 和 action
|
||||
defaultValues.req_key = selectedModel.value.key
|
||||
defaultValues.action = selectedModel.value.action
|
||||
? selectedModel.value.action
|
||||
: 'CVSync2AsyncSubmitTask'
|
||||
return defaultValues
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,179 @@ import Spring_Festival_traditional_Chinese_architecture from '@/assets/img/jimen
|
||||
export const JimengParams = {
|
||||
image: [
|
||||
{
|
||||
name: '图片 4.0 文/图生图',
|
||||
version: '4.0',
|
||||
name: '即梦AI图片-4.0',
|
||||
icon: { text: '4.0' },
|
||||
label: '即梦4.0是即梦同源的图像生成能力,支持4K超高清输出',
|
||||
key: 'jimeng_t2i_v40',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
showWordLimit: true,
|
||||
maxlength: 800,
|
||||
autosize: { minRows: 3, maxRows: 5 },
|
||||
placeholder: '请输入用于编辑图像的提示词,如:把xxx改成xxx,删除xxx,添加xxx等',
|
||||
info: '最长不超过800字符,prompt过长有概率出图异常或不生效',
|
||||
},
|
||||
{
|
||||
name: 'image_urls',
|
||||
label: '参考图片',
|
||||
type: 'image',
|
||||
required: false,
|
||||
placeholder: '请上传图片',
|
||||
maxSize: 10,
|
||||
multiple: true,
|
||||
maxCount: 10,
|
||||
accept: '.png,.jpg,.jpeg',
|
||||
info: '最大 15MB,支持最多输入10张图',
|
||||
},
|
||||
|
||||
// 图片比例
|
||||
{
|
||||
name: 'scale',
|
||||
type: 'slider',
|
||||
required: true,
|
||||
info: '该值越大代表文本描述影响程度越大,且输入图片影响程度越小',
|
||||
label: '文本影响力',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
value: 0.5,
|
||||
},
|
||||
|
||||
// 是否强制生成单图
|
||||
{
|
||||
name: 'force_single',
|
||||
type: 'hidden',
|
||||
required: true,
|
||||
value: true,
|
||||
},
|
||||
|
||||
// 图片尺寸
|
||||
{
|
||||
name: 'size',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: '请选择尺寸',
|
||||
label: '图片尺寸',
|
||||
prefix: 'icon-resize',
|
||||
options: [
|
||||
// 1K 分辨率
|
||||
{
|
||||
label: '1:1 (1024 x 1024)',
|
||||
value: '1024x1024',
|
||||
},
|
||||
{
|
||||
label: '4:3 (1152 x 864)',
|
||||
value: '1152x864',
|
||||
},
|
||||
{
|
||||
label: '3:4 (864 x 1152)',
|
||||
value: '864x1152',
|
||||
},
|
||||
{
|
||||
label: '3:2 (1248 x 832)',
|
||||
value: '1248x832',
|
||||
},
|
||||
{
|
||||
label: '2:3 (832 x 1248)',
|
||||
value: '832x1248',
|
||||
},
|
||||
{
|
||||
label: '16:9 (1280 x 720)',
|
||||
value: '1280x720',
|
||||
},
|
||||
{
|
||||
label: '9:16 (720 x 1280)',
|
||||
value: '720x1280',
|
||||
},
|
||||
{
|
||||
label: '21:9 (1512 x 648)',
|
||||
value: '1512x648',
|
||||
},
|
||||
{
|
||||
label: '9:21 (648 x 1512)',
|
||||
value: '648x1512',
|
||||
},
|
||||
// 2K 分辨率
|
||||
{
|
||||
label: '1:1 (2048 x 2048)',
|
||||
value: '2048x2048',
|
||||
},
|
||||
{
|
||||
label: '4:3 (2304 x 1728)',
|
||||
value: '2304x1728',
|
||||
},
|
||||
{
|
||||
label: '3:4 (1728 x 2304)',
|
||||
value: '1728x2304',
|
||||
},
|
||||
{
|
||||
label: '3:2 (2496 x 1664)',
|
||||
value: '2496x1664',
|
||||
},
|
||||
{
|
||||
label: '2:3 (1664 x 2496)',
|
||||
value: '1664x2496',
|
||||
},
|
||||
{
|
||||
label: '16:9 (2560 x 1440)',
|
||||
value: '2560x1440',
|
||||
},
|
||||
{
|
||||
label: '9:16 (1440 x 2560)',
|
||||
value: '1440x2560',
|
||||
},
|
||||
{
|
||||
label: '21:9 (3024 x 1296)',
|
||||
value: '3024x1296',
|
||||
},
|
||||
{
|
||||
label: '9:21 (1296 x 3024)',
|
||||
value: '1296x3024',
|
||||
},
|
||||
// 4K 分辨率
|
||||
{
|
||||
label: '1:1 (4096 x 4096)',
|
||||
value: '4096x4096',
|
||||
},
|
||||
{
|
||||
label: '4:3 (4736 x 3552)',
|
||||
value: '4736x3552',
|
||||
},
|
||||
{
|
||||
label: '3:4 (3552 x 4736)',
|
||||
value: '3552x4736',
|
||||
},
|
||||
{
|
||||
label: '3:2 (5024 x 3360)',
|
||||
value: '5024x3360',
|
||||
},
|
||||
{
|
||||
label: '2:3 (3360 x 5024)',
|
||||
value: '3360x5024',
|
||||
},
|
||||
{
|
||||
label: '16:9 (5472 x 2072)',
|
||||
value: '5472x2072',
|
||||
},
|
||||
{
|
||||
label: '9:16 (2072 x 5472)',
|
||||
value: '2072x5472',
|
||||
},
|
||||
{
|
||||
label: '21:9 (6272 x 2688)',
|
||||
value: '6272x2688',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '豆包-seedream-4.0',
|
||||
icon: { text: '4.0' },
|
||||
label: '支持文本、单图和多图输入,实现基于主体一致性的多图融合创作、图像编辑等多样玩法',
|
||||
key: 'doubao-seedream-4-0-250828',
|
||||
params: [
|
||||
@@ -105,7 +276,7 @@ export const JimengParams = {
|
||||
|
||||
{
|
||||
name: '图片 3.0 文生图',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '影视质感,文字更准,直出2k高清图',
|
||||
key: 'jimeng_t2i_v30',
|
||||
params: [
|
||||
@@ -182,7 +353,7 @@ export const JimengParams = {
|
||||
},
|
||||
{
|
||||
name: '图片 3.1 文生图',
|
||||
version: '3.1',
|
||||
icon: { text: '3.1' },
|
||||
label: '丰富的美学多样性,画面更鲜明生动',
|
||||
key: 'jimeng_t2i_v31',
|
||||
params: [
|
||||
@@ -260,7 +431,7 @@ export const JimengParams = {
|
||||
|
||||
{
|
||||
name: '图片 3.0 图生图',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '精准执行编辑指令,保持图像内容完整性',
|
||||
key: 'jimeng_i2i_v30',
|
||||
params: [
|
||||
@@ -343,7 +514,7 @@ export const JimengParams = {
|
||||
|
||||
{
|
||||
name: '图片 3.0 图像特效',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '将输入的单人写真图片,进行有创意的特效化处理。',
|
||||
key: 'i2i_multi_style_zx2x',
|
||||
params: [
|
||||
@@ -560,7 +731,7 @@ export const JimengParams = {
|
||||
|
||||
{
|
||||
name: '图片 2.1 文生图',
|
||||
version: '2.1',
|
||||
icon: { text: '2.1' },
|
||||
label: '平面绘感强,可生成文字海报',
|
||||
key: 'jimeng_high_aes_general_v21_L',
|
||||
params: [
|
||||
@@ -633,7 +804,7 @@ export const JimengParams = {
|
||||
// 视频 3.0 720P-文生视频
|
||||
{
|
||||
name: '视频 3.0 720P-文生视频',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '生成效果与速度兼备',
|
||||
key: 'jimeng_t2v_v30',
|
||||
params: [
|
||||
@@ -703,7 +874,7 @@ export const JimengParams = {
|
||||
// 视频 3.0 图生视频-首帧
|
||||
{
|
||||
name: '视频 3.0 720P-图生视频-首帧',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '根据提示词 + 首帧图片生成视频',
|
||||
key: 'jimeng_i2v_first_v30',
|
||||
params: [
|
||||
@@ -749,7 +920,7 @@ export const JimengParams = {
|
||||
// 视频 3.0 图生视频-首尾帧
|
||||
{
|
||||
name: '视频 3.0 720P-图生视频-首尾帧',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '根据提示词 + 首尾帧图片生成视频',
|
||||
key: 'jimeng_i2v_first_tail_v30',
|
||||
params: [
|
||||
@@ -796,7 +967,7 @@ export const JimengParams = {
|
||||
// 视频 3.0 图生视频-运镜
|
||||
{
|
||||
name: '视频 3.0 720P-图生视频-运镜',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '根据提示词 + 运镜图片生成视频',
|
||||
key: 'jimeng_i2v_recamera_v30',
|
||||
params: [
|
||||
@@ -934,7 +1105,7 @@ export const JimengParams = {
|
||||
// 视频 3.0 1080P-文生视频
|
||||
{
|
||||
name: '视频 3.0 1080P-文生视频',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '视觉表达流畅一致,支持1080P高清渲染',
|
||||
key: 'jimeng_t2v_v30_1080p',
|
||||
params: [
|
||||
@@ -1004,7 +1175,7 @@ export const JimengParams = {
|
||||
// 视频 3.0 1080P-图生视频-首帧
|
||||
{
|
||||
name: '视频 3.0 1080P-图生视频-首帧',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '根据提示词 + 首帧图片生成1080P视频',
|
||||
key: 'jimeng_i2v_first_v30_1080',
|
||||
params: [
|
||||
@@ -1050,7 +1221,7 @@ export const JimengParams = {
|
||||
// 视频 3.0 1080P-图生视频-首尾帧
|
||||
{
|
||||
name: '视频 3.0 1080P-图生视频-首尾帧',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '根据提示词 + 首尾帧图片生成1080P视频',
|
||||
key: 'jimeng_i2v_first_tail_v30_1080',
|
||||
params: [
|
||||
@@ -1097,7 +1268,7 @@ export const JimengParams = {
|
||||
// 视频 3.0Pro 1080P-图生视频
|
||||
{
|
||||
name: '视频 3.0Pro 1080P-图生视频',
|
||||
version: '3.0',
|
||||
icon: { text: '3.0' },
|
||||
label: '根据提示词 + 首帧图片生成1080P视频',
|
||||
key: 'jimeng_ti2v_v30_pro',
|
||||
params: [
|
||||
@@ -1180,8 +1351,122 @@ export const JimengParams = {
|
||||
],
|
||||
},
|
||||
],
|
||||
virtualHuman: [],
|
||||
actionTransfer: [],
|
||||
virtual_human: [
|
||||
{
|
||||
name: '即梦AI数字人',
|
||||
icon: { text: '即梦', size: '!text-base' },
|
||||
label: '即梦同源数字人快速模型,单张图片+音频',
|
||||
key: 'jimeng_realman_avatar_picture_omni_v2',
|
||||
action: 'CVSubmitTask',
|
||||
params: [
|
||||
{
|
||||
name: 'image_urls',
|
||||
label: '人物主体图片',
|
||||
required: true,
|
||||
placeholder: '请上传图片',
|
||||
type: 'image',
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxSize: 5,
|
||||
accept: '.png,.jpg,.jpeg',
|
||||
info: '建议JPG格式,输入图中为单人、人脸占比大、正面效果较好,其他类型图片效果不佳',
|
||||
},
|
||||
{
|
||||
name: 'audio_url',
|
||||
label: '驱动音频',
|
||||
required: true,
|
||||
placeholder: '请上传音频',
|
||||
type: 'file',
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxSize: 5,
|
||||
accept: '.mp3,.wav,.m4a',
|
||||
info: '音频建议MP3/WAV格式,时长建议小于15秒以保障生成效果,音频过长可能有效果裂化问题',
|
||||
},
|
||||
{
|
||||
name: 'recognize_key',
|
||||
label: '识别主体请求Key',
|
||||
required: true,
|
||||
value: 'jimeng_realman_avatar_picture_create_role_omni',
|
||||
type: 'hidden',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '火山引擎OmniHuman数字人',
|
||||
icon: { text: '火山', size: '!text-base' },
|
||||
label: '火山引擎OmniHuman数字人模型,单张图片+音频',
|
||||
key: 'realman_avatar_picture_omni_v2',
|
||||
action: 'CVSubmitTask',
|
||||
params: [
|
||||
{
|
||||
name: 'image_urls',
|
||||
label: '人物主体图片',
|
||||
required: true,
|
||||
placeholder: '请上传图片',
|
||||
type: 'image',
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxSize: 5,
|
||||
accept: '.png,.jpg,.jpeg',
|
||||
info: '建议JPG格式,输入图中为单人、人脸占比大、正面效果较好,其他类型图片效果不佳',
|
||||
},
|
||||
{
|
||||
name: 'audio_url',
|
||||
label: '驱动音频',
|
||||
required: true,
|
||||
placeholder: '请上传音频',
|
||||
type: 'file',
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxSize: 5,
|
||||
accept: '.mp3,.wav,.m4a',
|
||||
info: '音频建议MP3/WAV格式,时长建议小于15秒以保障生成效果,音频过长可能有效果裂化问题',
|
||||
},
|
||||
{
|
||||
name: 'recognize_key',
|
||||
label: '识别主体请求Key',
|
||||
required: true,
|
||||
value: 'realman_avatar_picture_create_role_omni',
|
||||
type: 'hidden',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
action_transfer: [
|
||||
{
|
||||
name: '即梦AI-动作模仿-4.0',
|
||||
icon: { text: '4.0', fontSize: '!text-xl' },
|
||||
label: '即梦同源的视频动作模仿(生动模式)',
|
||||
key: 'jimeng_dream_actor_m1_gen_video_cv',
|
||||
params: [
|
||||
{
|
||||
name: 'image_urls',
|
||||
label: '人物主体图片',
|
||||
required: true,
|
||||
placeholder: '请上传图片',
|
||||
type: 'image',
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxSize: 5,
|
||||
accept: '.png,.jpg,.jpeg',
|
||||
info: '图片格式支持 jpeg,jpg,png ,分辨率需在 480x480 以上,1920x1080 以内',
|
||||
},
|
||||
{
|
||||
name: 'video_url',
|
||||
label: '动作视频',
|
||||
required: true,
|
||||
placeholder: '请上传音频',
|
||||
type: 'file',
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxSize: 100,
|
||||
accept: '.mp4,.mov,.webm',
|
||||
info: '输入的视频时长不可超过30s,支持mp4,mov,webm格式,视频分辨率须在480P以上,2K以内',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const JimengFunctions = [
|
||||
@@ -1196,12 +1481,12 @@ export const JimengFunctions = [
|
||||
name: '视频生成',
|
||||
},
|
||||
{
|
||||
key: 'virtualHuman',
|
||||
key: 'virtual_human',
|
||||
icon: 'icon-shuziren',
|
||||
name: '数字人',
|
||||
},
|
||||
{
|
||||
key: 'actionTransfer',
|
||||
key: 'action_transfer',
|
||||
icon: 'icon-action',
|
||||
name: '动作模仿',
|
||||
},
|
||||
@@ -6,7 +6,7 @@
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_data'
|
||||
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_params'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
@@ -59,7 +59,6 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
// 切换功能
|
||||
const switchFunction = (f) => {
|
||||
activeFunction.value = f.key
|
||||
formData.value = {}
|
||||
setFunctionPowers()
|
||||
}
|
||||
|
||||
@@ -200,10 +199,14 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
if (formData.value.duration) {
|
||||
formData.value.duration = parseInt(formData.value.duration)
|
||||
}
|
||||
if (formData.value.image_urls && !Array.isArray(formData.value.image_urls)) {
|
||||
formData.value.image_urls = [formData.value.image_urls]
|
||||
|
||||
const data = { ...formData.value }
|
||||
|
||||
if (data.image_urls && !Array.isArray(data.image_urls)) {
|
||||
data.image_urls = [data.image_urls]
|
||||
}
|
||||
const response = await httpPost('/api/jimeng/task', formData.value)
|
||||
|
||||
const response = await httpPost('/api/jimeng/task', data)
|
||||
showMessageOK('任务提交成功')
|
||||
isOver.value = false
|
||||
await fetchData(1)
|
||||
@@ -279,12 +282,6 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 播放视频
|
||||
const playVideo = (item) => {
|
||||
currentVideoUrl.value = item.video_url
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
const setFunctionPowers = () => {
|
||||
if (activeFunction.value === 'image') {
|
||||
currentPowerCost.value = `${powerConfig.image}积分/张`
|
||||
@@ -333,7 +330,6 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
isLogin,
|
||||
showDialog,
|
||||
currentVideoUrl,
|
||||
|
||||
// 配置
|
||||
functions,
|
||||
activeFunction,
|
||||
@@ -355,7 +351,6 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
downloadFile,
|
||||
retryTask,
|
||||
removeJob,
|
||||
playVideo,
|
||||
cleanup,
|
||||
|
||||
// 工具函数
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<div class="video-mask" @click="store.playVideo(item)">
|
||||
<div class="video-mask" @click="playVideo(item)">
|
||||
<div class="play-btn">
|
||||
<img src="/images/play.svg" alt="播放" />
|
||||
</div>
|
||||
@@ -370,15 +370,15 @@
|
||||
</div>
|
||||
|
||||
<!-- 视频预览对话框 -->
|
||||
<el-dialog v-model="store.showDialog" title="视频预览" center>
|
||||
<el-dialog v-model="store.showDialog" title="视频预览" @close="stopVideoPlay" center>
|
||||
<div class="flex justify-center items-center">
|
||||
<video
|
||||
ref="videoPreviewRef"
|
||||
:src="store.currentVideoUrl"
|
||||
autoplay
|
||||
controls
|
||||
preload="auto"
|
||||
loop
|
||||
muted
|
||||
style="max-height: calc(100vh - 100px); max-width: 100vw; object-fit: cover"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
@@ -414,6 +414,23 @@ const templatePreview = ref('')
|
||||
// 新增:提示词指南折叠面板状态(默认收起)
|
||||
const guideActive = ref([])
|
||||
|
||||
const videoPreviewRef = ref(null)
|
||||
// 播放视频
|
||||
const playVideo = (item) => {
|
||||
store.currentVideoUrl = item.video_url
|
||||
store.showDialog = true
|
||||
if (videoPreviewRef.value) {
|
||||
videoPreviewRef.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
// 停止视频播放
|
||||
const stopVideoPlay = () => {
|
||||
if (videoPreviewRef.value) {
|
||||
videoPreviewRef.value.pause()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.init()
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<script setup>
|
||||
import ParamBuilder from '@/components/ParamBuilder.vue'
|
||||
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_data'
|
||||
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_params'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const functions = JimengFunctions
|
||||
|
||||
Reference in New Issue
Block a user