feat(task):完善wan3.0模型支持

This commit is contained in:
qian committed 2026-09-23 00:49:25 +08:00
1 parent e4f31eae5c
commit 46b9e6cdf3
13 files changed
+1165 -144

No files matched your search

+19 -9
View File
@@ -19,13 +19,15 @@ type TaskListReq struct {
}
type ModelTaskItem struct {
ID int64 `json:"id"`
PublicTaskID string `json:"public_task_id"`
Platform string `json:"platform"`
Action string `json:"action"`
Status string `json:"status"`
Progress string `json:"progress"`
ModelName string `json:"model_name"`
ID int64 `json:"id"`
PublicTaskID string `json:"public_task_id"`
Platform string `json:"platform"`
Action string `json:"action"`
Status string `json:"status"`
Progress string `json:"progress"`
ModelName string `json:"model_name"`
// UpstreamModel 上游供应商实际使用的模型名(仅任务详情返回,排障时用于和请求模型对照)
UpstreamModel string `json:"upstream_model,omitempty"`
FailReason string `json:"fail_reason,omitempty"`
PreDeductAmount float64 `json:"pre_deduct_amount"`
ActualCost float64 `json:"actual_cost"`
@@ -35,8 +37,16 @@ type ModelTaskItem struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
SubmitTime string `json:"submit_time,omitempty"`
FinishTime string `json:"finish_time,omitempty"`
CreatedAt string `json:"created_at"`
// StartTime 上游开始执行任务的时间(仅任务详情返回;详情时间线用它区分「排队耗时」与「执行耗时」)
StartTime string `json:"start_time,omitempty"`
FinishTime string `json:"finish_time,omitempty"`
CreatedAt string `json:"created_at"`
// RequestID 任务提交时的原始请求 ID(req_xxxxx,仅任务详情返回),关联 aud_request_logs.request_id
RequestID string `json:"request_id,omitempty"`
// 上游排障标识(仅任务详情返回;从 private_data 提取,其余内部字段不外露):
// UpstreamTaskID 任务句柄(上游轮询查询用);UpstreamRequestID 提交调用追踪 ID(部分上游返回)
UpstreamTaskID string `json:"upstream_task_id,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
}
type TaskListRes struct {
+1 -1
View File
@@ -598,7 +598,7 @@ func registerRelayRoutes(server *ghttp.Server) {
// 阿里 DashScope 官方视频协议端点(wan2.x / wan3.0 通用,阿里 SDK 换 base_url 直连;
// 查询回放官方响应形态,轮询计费由网关后台完成)
server.Group("/api/v1", func(group *ghttp.RouterGroup) {
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/services/aigc/video-generation/video-synthesis", relay.HandleAliVideoSubmit)
group.GET("/tasks/{task_id}", relay.HandleAliVideoRetrieve)
+21
View File
@@ -2,6 +2,7 @@ package admin
import (
"context"
"encoding/json"
"time"
"github.com/gogf/gf/v2/errors/gcode"
@@ -132,6 +133,8 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
ResultUrl string `json:"result_url"`
TenantId int64 `json:"tenant_id"`
UserId int64 `json:"user_id"`
PrivateData string `json:"private_data"`
RequestId string `json:"request_id"`
SubmitTime *gtime.Time `json:"submit_time"`
StartTime *gtime.Time `json:"start_time"`
FinishTime *gtime.Time `json:"finish_time"`
@@ -153,6 +156,7 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
Status: task.Status,
Progress: task.Progress,
ModelName: task.ModelName,
UpstreamModel: task.UpstreamModel,
FailReason: task.FailReason,
PreDeductAmount: task.PreDeductAmount,
ActualCost: task.ActualCost,
@@ -160,15 +164,32 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
ResultURL: task.ResultUrl,
TenantID: task.TenantId,
UserID: task.UserId,
RequestID: task.RequestId,
CreatedAt: task.CreatedAt.Format("Y-m-d H:i:s"),
}
if task.SubmitTime != nil {
item.SubmitTime = task.SubmitTime.Format("Y-m-d H:i:s")
}
if task.StartTime != nil {
item.StartTime = task.StartTime.Format("Y-m-d H:i:s")
}
if task.FinishTime != nil {
item.FinishTime = task.FinishTime.Format("Y-m-d H:i:s")
}
// 从 private_data 提取上游排障标识:仅取 upstream_task_id / upstream_request_id 两个键,
// 计费上下文等其余内部数据不外露(同步图片任务无上游任务 ID,两字段为空)
if task.PrivateData != "" {
var pd struct {
UpstreamTaskID string `json:"upstream_task_id"`
UpstreamRequestID string `json:"upstream_request_id"`
}
if json.Unmarshal([]byte(task.PrivateData), &pd) == nil {
item.UpstreamTaskID = pd.UpstreamTaskID
item.UpstreamRequestID = pd.UpstreamRequestID
}
}
// 对 re-host 到对象存储的结果图,生成新鲜的缩略图 URL 供详情弹窗内联预览,并刷新原图 URL
// (规避 result_url 24h 预签名过期)。非图片/上游直链任务查不到文件记录,保持原 result_url。
if thumb, orig := common.TaskResultImageURLs(ctx, task.TenantId, task.PublicTaskId, 600); thumb != "" {
+10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
@@ -111,6 +112,15 @@ func LookupPerSecondPrice(prices map[string]float64, spec string) float64 {
if p, ok := prices[spec]; ok && p > 0 {
return p
}
// 大小写不敏感兜底:配置键形态因供应商词汇而异("720p" vs "720P"),
// 语义同档应优先于 "*" 通配回退,防止大写上报查不中而按兜底价计费
if lower := strings.ToLower(spec); lower != "" {
for k, p := range prices {
if strings.ToLower(k) == lower && p > 0 {
return p
}
}
}
if p, ok := prices[perSecondWildcard]; ok && p > 0 {
return p
}
@@ -352,4 +352,15 @@ func TestLookupPerSecondPrice(t *testing.T) {
if p := LookupPerSecondPrice(map[string]float64{"720p": 0}, "720p"); p != 0 {
t.Errorf("zero price entry = %v", p)
}
// 大小写不敏感兜底:同档不同形态(配置小写/上报大写,或反之)优先于 "*" 通配回退
if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "*": 0.9}, "720P"); p != 0.5 {
t.Errorf("case-insensitive hit = %v", p)
}
if p := LookupPerSecondPrice(map[string]float64{"768P": 0.35}, "768p"); p != 0.35 {
t.Errorf("case-insensitive hit (uppercase key) = %v", p)
}
// 大小写不敏感未命中("2k")仍回退 "*"
if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "1080p": 1.0, "*": 0.9}, "2K"); p != 0.9 {
t.Errorf("wildcard after case-insensitive miss = %v", p)
}
}
+15 -8
View File
@@ -326,9 +326,10 @@ func processChannelTasks(ctx context.Context, channelID int64, tasks []*common.A
// privateData PrivateData 反序列化结构
type privateData struct {
UpstreamTaskID string `json:"upstream_task_id"`
TaskType string `json:"task_type"`
BillingContext struct {
UpstreamTaskID string `json:"upstream_task_id"`
UpstreamRequestID string `json:"upstream_request_id"` // 上游提交调用追踪 ID(部分上游返回,可空)
TaskType string `json:"task_type"`
BillingContext struct {
Ratios map[string]any `json:"ratios"`
ModelName string `json:"model_name"`
PreDeduct float64 `json:"pre_deduct"`
@@ -541,18 +542,24 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
// 按秒计费任务的视频时长:取提交时计费上下文里的 spec.duration(真实秒数)。
// 只认 spec.* 事实键——旧 duration 键在部分 adaptor 是乘数语义(如 gemini 1.3),不可作为时长
// 同时提取 upstream_task_id 作为上游请求 ID 落用量日志(排障时凭它在上游定位任务)。
// 提取上游请求 ID 落用量日志(排障时凭它在上游定位任务):
// 优先 upstream_request_id(上游调用追踪 ID,如 DashScope 顶层 request_id),
// 未记录时回退 upstream_task_id(任务句柄,轮询同款标识)
durationSeconds := 0
upstreamRequestID := ""
if len(task.PrivateData) > 0 {
var pdDur struct {
UpstreamTaskID string `json:"upstream_task_id"`
BillingContext struct {
UpstreamTaskID string `json:"upstream_task_id"`
UpstreamRequestID string `json:"upstream_request_id"`
BillingContext struct {
Ratios map[string]any `json:"ratios"`
} `json:"billing_context"`
}
if json.Unmarshal(task.PrivateData, &pdDur) == nil {
upstreamRequestID = pdDur.UpstreamTaskID
upstreamRequestID = pdDur.UpstreamRequestID
if upstreamRequestID == "" {
upstreamRequestID = pdDur.UpstreamTaskID
}
if v, ok := pdDur.BillingContext.Ratios["spec.duration"].(float64); ok && v > 0 {
durationSeconds = int(v)
}
@@ -587,7 +594,7 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
DurationSeconds: durationSeconds,
// 任务的上游请求 ID = 上游提交响应体中的任务 ID(轮询同款标识)
// 任务的上游请求 ID = 调用追踪 ID(上游返回时),否则为任务句柄(轮询同款标识)
UpstreamRequestID: upstreamRequestID,
}
+10
View File
@@ -55,6 +55,16 @@ type TaskAdaptor interface {
GetChannelName() string
}
// UpstreamRequestIDExtractor 供应商私有的「上游调用追踪 ID 提取」可选能力。
// 为什么需要:部分异步上游(如阿里 DashScope)不在响应头返回请求 ID,而是放在
// 提交响应体顶层(request_id 字段),通用管线无法用统一键提取。实现方从提交
// 响应体解析自家私有的追踪 ID,与 output.task_id(任务句柄)互补——提工单或
// 在上游日志定位一次提交调用时用它。未实现的上游走响应头提取或为空。
type UpstreamRequestIDExtractor interface {
// ExtractUpstreamRequestID 从上游提交响应体提取调用追踪 ID,未返回时为空串
ExtractUpstreamRequestID(body []byte) string
}
// TaskInfo 异步任务查询结果
type TaskInfo struct {
Status TaskStatusEnum
+13
View File
@@ -317,6 +317,16 @@ func HandleTaskSubmit(
}
now := time.Now()
// 上游调用追踪 ID:优先响应头(与同步对话链路 helper.ExtractUpstreamRequestID 同源,
// 覆盖 OpenAI/Anthropic 等返回标准头的上游);无候选头时由供应商可选能力从提交
// 响应体提取(如 DashScope 顶层 request_id)。与 upstream_task_id(任务句柄)互补
upstreamRequestID := helper.ExtractUpstreamRequestID(resp.Header)
if upstreamRequestID == "" {
if ex, ok := adaptor.(common.UpstreamRequestIDExtractor); ok {
upstreamRequestID = ex.ExtractUpstreamRequestID(taskData)
}
}
privateDataMap := map[string]any{
"upstream_task_id": upstreamTaskID,
"task_type": platform,
@@ -329,6 +339,9 @@ func HandleTaskSubmit(
"pre_deduct": preDeductAmount.InexactFloat64(),
},
}
if upstreamRequestID != "" {
privateDataMap["upstream_request_id"] = upstreamRequestID
}
if rc.RequestEcho != nil {
privateDataMap["request_echo"] = rc.RequestEcho
}
+38 -2
View File
@@ -173,9 +173,15 @@ func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bod
metadata := taskchannel.ExtractMetadata(req)
var meta aliMetadata
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil {
// 分辨率事实值:per_second 矩阵查价键(480P/720P/1080P 原值)
// 分辨率事实值:per_second 矩阵查价键(480P/720P/1080P 原值)。
// OpenAI Videos 路径无 resolution 字段,wan3.0 按 size 归一档位上报,
// 与 buildVideoRequest 的折算口径同源
if meta.Resolution != "" {
ratios["spec.resolution"] = meta.Resolution
} else if strings.HasPrefix(strings.ToLower(modelName), "wan3.") && meta.Size != "" {
if r := normalizeWan3Resolution(meta.Size); r != "" {
ratios["spec.resolution"] = r
}
}
// 时长事实值:duration>0 用请求值;duration=-1(wan3.0 智能时长)按输出上限冻结,
// 结算以 usage.output_video_duration 多退少补
@@ -293,8 +299,13 @@ func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]an
params.Audio = &audio
}
if params.Resolution == "" && meta.Size != "" {
params.Resolution = resolutionFromSize(meta.Size)
if r := normalizeWan3Resolution(meta.Size); r != "" {
params.Resolution = r
}
}
// wan3.0 协议无 size 参数:size 只作档位折算源,任何情况不透传上游
//(wan2.x 的 size 是原生尺寸语义,不进本分支、不受影响)
params.Size = ""
// ratio 兜底:通用词汇 aspect_ratio(OpenAI Videos 双写键,部分链路只写该键)
if params.Ratio == "" {
if ar, ok := metadata["aspect_ratio"].(string); ok {
@@ -355,6 +366,18 @@ func buildWan3Media(req map[string]any, metadata map[string]any) []dashScopeMedi
return nil
}
// normalizeWan3Resolution 把 size 值归一为 wan3.0 分辨率档位:
// 裸档位词汇("720P"/"480p")直接归一为大写档位;WxH 尺寸("1280x720")按短边归档;
// 其余返回空串(上游取默认 1080P)。wan2.x 的 size 是原生尺寸语义,不得走此函数。
func normalizeWan3Resolution(size string) string {
upper := strings.ToUpper(strings.TrimSpace(size))
switch upper {
case "480P", "720P", "1080P":
return upper
}
return resolutionFromSize(size)
}
// resolutionFromSize 从尺寸字符串(如 "1280x720")按短边映射 wan3.0 分辨率档位。
// 竖版尺寸(720x1280)同样按短边归档;解析失败返回空串(上游取默认 1080P)
func resolutionFromSize(size string) string {
@@ -484,6 +507,19 @@ func (a *AliAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *commo
return result.Output.TaskID, body, nil
}
// ExtractUpstreamRequestID 从 DashScope 提交响应体顶层提取 request_id(调用追踪 ID)。
// 与 output.task_id(任务句柄,轮询查询用)互补:在阿里云日志/工单中定位一次提交调用时用它
func (a *AliAdaptor) ExtractUpstreamRequestID(body []byte) string {
var resp dashScopeSubmitResponse
if json.Unmarshal(body, &resp) != nil {
return ""
}
return resp.RequestID
}
// 确保实现上游调用追踪 ID 提取可选能力
var _ common.UpstreamRequestIDExtractor = (*AliAdaptor)(nil)
func (a *AliAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) {
var data struct {
TaskID string `json:"task_id"`
+108
View File
@@ -3,6 +3,7 @@ package ali
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/qianfree/team-api/relay/common"
@@ -67,6 +68,25 @@ func TestEstimateBilling_VideoSpecSignals(t *testing.T) {
t.Fatalf("seconds should override metadata duration: %v", ratios)
}
// OpenAI Videos 路径无 resolution 字段:wan3.0 按 size 归一档位上报(裸档位/WxH 均可)
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"720P","duration":5}}`))
if ratios["spec.resolution"] != "720P" {
t.Fatalf("size tier vocabulary should report spec.resolution: %v", ratios)
}
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"1280x720","duration":5}}`))
if ratios["spec.resolution"] != "720P" {
t.Fatalf("size WxH should report spec.resolution: %v", ratios)
}
// wan2.x 的 size 是原生尺寸语义,不得折算档位(预扣按矩阵兜底档走)
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan2.2-t2v-plus","prompt":"p","metadata":{"size":"1024x576","duration":5}}`))
if _, has := ratios["spec.resolution"]; has {
t.Fatalf("wan2 size must not derive spec.resolution: %v", ratios)
}
// 未传时长:不产出 spec.duration(引擎按默认 5s 估)
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p"}`))
@@ -142,6 +162,9 @@ func TestBuildVideoRequest_OpenAIVocabulary(t *testing.T) {
if req.Parameters.Resolution != "720P" {
t.Fatalf("size short-edge → resolution lost: %+v", req.Parameters)
}
if req.Parameters.Size != "" {
t.Fatalf("wan3.0 must not carry size after resolution derived: %+v", req.Parameters)
}
if req.Parameters.Ratio != "9:16" {
t.Fatalf("aspect_ratio → ratio lost: %+v", req.Parameters)
}
@@ -235,6 +258,27 @@ func TestParseTaskResult_Wan3MaterialUsage(t *testing.T) {
}
}
func TestExtractUpstreamRequestID(t *testing.T) {
a := &AliAdaptor{}
// DashScope 提交成功响应:顶层 request_id 提取,与 output.task_id 互不干扰
if got := a.ExtractUpstreamRequestID([]byte(
`{"output":{"task_id":"tsk-abc","task_status":"PENDING"},"request_id":"req-123","code":null,"message":""}`)); got != "req-123" {
t.Fatalf("request_id lost: %q", got)
}
// 错误响应同样带顶层 request_id(DashScope 错误时 code/message 非空)
if got := a.ExtractUpstreamRequestID([]byte(
`{"request_id":"req-err","code":"InvalidApiKey","message":"Invalid API-key"}`)); got != "req-err" {
t.Fatalf("error response request_id lost: %q", got)
}
// 缺失/非法 body:返回空串不报错
if got := a.ExtractUpstreamRequestID([]byte(`{"output":{"task_id":"t"}}`)); got != "" {
t.Fatalf("missing request_id should yield empty: %q", got)
}
if got := a.ExtractUpstreamRequestID([]byte(`not json`)); got != "" {
t.Fatalf("invalid body should yield empty: %q", got)
}
}
func TestResolutionFromSize(t *testing.T) {
cases := map[string]string{
"1280x720": "720P",
@@ -252,3 +296,67 @@ func TestResolutionFromSize(t *testing.T) {
}
}
}
// 裸档位词汇(OpenAI 端点 size 直传 wan3.0 原生词汇)归一;
// WxH 尺寸走短边归档,无效值返回空串
func TestNormalizeWan3Resolution(t *testing.T) {
cases := map[string]string{
"720P": "720P",
"480p": "480P", // 小写归一为大写档位
" 1080p ": "1080P",
"1280x720": "720P",
"1024x576": "720P",
"4K": "", // 非法键必须返回空串,防止把 "4K" 之类键透传上游
"768P": "", // MiniMax 档位非 wan3.0 词汇,不归一
"not-a-tie": "",
}
for size, want := range cases {
if got := normalizeWan3Resolution(size); got != want {
t.Errorf("normalizeWan3Resolution(%q) = %q, want %q", size, got, want)
}
}
}
// OpenAI 端点 size 直传 wan3.0 原生档位词汇("720P"/"480p"):
// 直接作为 resolution 下发上游,且不再透传 wan3.0 不认识的 size 参数
func TestBuildVideoRequest_SizeTierVocabulary(t *testing.T) {
for _, size := range []string{"720P", "480p", "1080p"} {
a := &AliAdaptor{}
body := []byte(`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"` + size + `"}}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build (%s): %v", size, err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode (%s): %v", size, err)
}
want := strings.ToUpper(size)
if req.Parameters == nil || req.Parameters.Resolution != want {
t.Fatalf("size=%s should derive resolution=%s: %+v", size, want, req.Parameters)
}
if req.Parameters.Size != "" {
t.Fatalf("wan3.0 request must not carry size param (input %s): %+v", size, req.Parameters)
}
}
}
// 回归:metadata.size 无法归一为档位(如 MiniMax 词汇误传给 wan3.0)时,
// resolution 留空走上游默认 1080P,原 size 值不再透传
func TestBuildVideoRequest_SizeUnrecognized(t *testing.T) {
a := &AliAdaptor{}
body := []byte(`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"768P"}}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build: %v", err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
// 归一失败且无其他参数时 Parameters 整体为 nil(不下发 parameters 节点),
// 非 nil 时 resolution/size 必须为空(上游默认 1080P)
if req.Parameters != nil && (req.Parameters.Resolution != "" || req.Parameters.Size != "") {
t.Fatalf("unrecognized size should leave resolution/size empty: %+v", req.Parameters)
}
}
+615 -38
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { ref, reactive, computed, watch, onMounted, onUnmounted, h } from 'vue'
import { useRoute } from 'vue-router'
import {
Tag, Button, Space, Popconfirm, Message,
@@ -226,12 +226,34 @@ const showDetail = ref(false)
const detailLoading = ref(false)
const detailData = ref<any>(null)
// 进行中任务在抽屉内按秒刷新「已运行」时长
const nowTs = ref(Date.now())
let heroTicker: ReturnType<typeof setInterval> | undefined
function startHeroTicker() {
stopHeroTicker()
heroTicker = setInterval(() => { nowTs.value = Date.now() }, 1000)
}
function stopHeroTicker() {
if (heroTicker) {
clearInterval(heroTicker)
heroTicker = undefined
}
}
async function openDetail(row: any) {
detailData.value = row
showDetail.value = true
await reloadDetail(row.id)
}
async function reloadDetail(id?: number) {
const taskId = id ?? detailData.value?.id
if (!taskId) return
detailLoading.value = true
try {
const res: any = await request.get(`/admin/tasks/${row.id}`)
const res: any = await request.get(`/admin/tasks/${taskId}`)
const raw = res.data?.data
if (raw?.task) {
detailData.value = raw.task
@@ -243,6 +265,142 @@ async function openDetail(row: any) {
}
}
// 状态语义色:抬头区状态点、进度条与时间线节点共用(与列表 Tag 的色彩语义一致)
const statusVisual: Record<string, { label: string; dot: string; bar: string }> = {
NOT_START: { label: '未开始', dot: 'var(--color-text-4)', bar: 'var(--color-text-4)' },
SUBMITTED: { label: '已提交', dot: 'rgb(var(--arcoblue-6))', bar: 'rgb(var(--arcoblue-6))' },
IN_PROGRESS: { label: '进行中', dot: 'rgb(var(--arcoblue-6))', bar: 'rgb(var(--arcoblue-6))' },
SUCCESS: { label: '成功', dot: 'var(--ta-success)', bar: 'var(--ta-success)' },
FAILURE: { label: '失败', dot: 'var(--ta-danger)', bar: 'var(--ta-danger)' },
}
// 平台 → 任务类型,用于抬头区类型标签(覆盖范围与 platformOptions 一致)
const platformKind: Record<string, string> = {
sora: '视频',
kling: '视频',
volcengine: '视频',
ali: '视频',
minimax: '视频',
midjourney: '图片',
suno: '音乐',
}
const detailVisual = computed(() => {
const status = String(detailData.value?.status || '')
return statusVisual[status] || { label: status || '-', dot: 'var(--color-text-4)', bar: 'var(--color-text-4)' }
})
// 进度条比值:Arco ProgressLine 的 percent 是 0~1 小数(内部 `width: percent * 100%`),
// 本仓库其余用法(AdminLayout/RealtimeMonitorPage/MonitorPage)也都传比值;传 0~100 会被
// .arco-progress-line-bar 的 max-width:100% 夹成满格,出现「失败任务进度条画满整条」的假象
const progressRatio = computed(() => {
if (detailData.value?.status === 'SUCCESS') return 1
const n = parseInt(String(detailData.value?.progress || '').replace('%', ''), 10)
if (Number.isNaN(n)) return 0
return Math.min(100, Math.max(0, n)) / 100
})
// 后端时间为 Asia/Shanghai 墙钟(YYYY-MM-DD HH:mm:ss),此处只用来算时间差,不做时区换算;
// 把 - 换成 / 以兼容 Safari 的 Date 解析
function parseTime(v?: string): number | null {
if (!v) return null
const t = new Date(String(v).replace(/-/g, '/')).getTime()
return Number.isNaN(t) ? null : t
}
function diffMs(start?: string, end?: string): number | null {
const a = parseTime(start)
const b = parseTime(end)
if (a === null || b === null) return null
return b >= a ? b - a : null
}
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s} 秒`
const m = Math.floor(s / 60)
if (m < 60) return `${m} 分 ${s % 60} 秒`
const h = Math.floor(m / 60)
if (h < 24) return `${h} 小时 ${m % 60} 分`
return `${Math.floor(h / 24)} 天 ${h % 24} 小时`
}
// 终态显示总耗时,进行中显示至今已运行时长
const runningOrTotalText = computed(() => {
const d = detailData.value || {}
if (d.status === 'SUCCESS' || d.status === 'FAILURE') {
const total = diffMs(d.created_at, d.finish_time)
return total === null ? '' : `总耗时 ${formatDuration(total)}`
}
const since = parseTime(d.start_time) ?? parseTime(d.submit_time) ?? parseTime(d.created_at)
if (since === null) return ''
return `已运行 ${formatDuration(Math.max(0, nowTs.value - since))}`
})
const timelineNodes = computed(() => {
const d = detailData.value || {}
const status = String(d.status || '')
const active = status === 'FAILURE'
? 'var(--ta-danger)'
: status === 'SUCCESS' ? 'var(--ta-success)' : 'rgb(var(--arcoblue-6))'
const mk = (label: string, time?: string) => ({
label,
time: time || '',
short: time ? String(time).slice(11, 19) : '',
color: time ? active : 'var(--color-text-4)',
})
return [
mk('创建', d.created_at),
mk('提交上游', d.submit_time),
mk('开始执行', d.start_time),
mk('完成', d.finish_time),
]
})
const durationList = computed(() => {
const d = detailData.value || {}
const items: { label: string; ms: number | null }[] = [
{ label: '排队耗时', ms: diffMs(d.created_at, d.submit_time) },
{ label: '执行耗时', ms: diffMs(d.start_time || d.submit_time, d.finish_time) },
{ label: '总耗时', ms: diffMs(d.created_at, d.finish_time) },
]
return items
.filter((i): i is { label: string; ms: number } => i.ms !== null)
.map(i => ({ label: i.label, value: formatDuration(i.ms) }))
})
// 结果资源类型:按扩展名判定,视频/音频内联播放,图片走缩略图,其余回退为链接
const resultKind = computed<'image' | 'video' | 'audio' | 'file'>(() => {
const url = String(detailData.value?.result_url || '')
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)([?#]|$)/i.test(url)) return 'image'
if (/\.(mp4|mov|webm|m4v|mkv)([?#]|$)/i.test(url)) return 'video'
if (/\.(mp3|wav|m4a|aac|ogg|flac)([?#]|$)/i.test(url)) return 'audio'
return 'file'
})
// 复制上游排障标识到剪贴板(与上游沟通时直接粘贴)
async function copyText(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
Message.success('已复制')
} catch {
Message.error('复制失败,请手动选择复制')
}
}
watch(
[showDetail, () => detailData.value?.status],
([visible, status]) => {
const running = visible && ['NOT_START', 'SUBMITTED', 'IN_PROGRESS'].includes(String(status || ''))
if (running) startHeroTicker()
else stopHeroTicker()
},
{ immediate: true },
)
onUnmounted(stopHeroTicker)
onMounted(() => {
const route = useRoute()
if (route.query.public_task_id) {
@@ -350,46 +508,147 @@ onMounted(() => {
</ACard>
<!-- Detail Drawer -->
<ADrawer v-model:visible="showDetail" :width="620" title="任务详情">
<ASpin :loading="detailLoading">
<ADrawer v-model:visible="showDetail" :width="760" title="任务详情" unmount-on-close>
<ASpin class="detail-spin" :loading="detailLoading">
<template v-if="detailData">
<h3 class="detail-section-title">基本信息</h3>
<ADescriptions :column="2" bordered size="small">
<ADescriptionsItem label="任务ID">{{ detailData.public_task_id }}</ADescriptionsItem>
<ADescriptionsItem label="状态">
<Tag :color="statusTagColor[detailData.status]" size="small">
{{ statusTagLabel[detailData.status] || detailData.status }}
</Tag>
</ADescriptionsItem>
<ADescriptionsItem label="平台">{{ platformLabel[detailData.platform] || detailData.platform }}</ADescriptionsItem>
<ADescriptionsItem label="动作">{{ detailData.action }}</ADescriptionsItem>
<ADescriptionsItem label="模型">{{ detailData.model_name }}</ADescriptionsItem>
<ADescriptionsItem label="进度">{{ detailData.progress }}</ADescriptionsItem>
<ADescriptionsItem label="预扣金额">
<span v-if="detailData.pre_deduct_amount > 0">{{ formatBilling(detailData.pre_deduct_amount, 4) }}</span>
<span v-else>-</span>
</ADescriptionsItem>
<ADescriptionsItem label="实际费用">
<span v-if="detailData.billing_settled">{{ formatBilling(detailData.actual_cost, 4) }}</span>
<span v-else>未结算</span>
</ADescriptionsItem>
<ADescriptionsItem label="提交时间">{{ detailData.submit_time || '-' }}</ADescriptionsItem>
<ADescriptionsItem label="完成时间">{{ detailData.finish_time || '-' }}</ADescriptionsItem>
<ADescriptionsItem label="创建时间">{{ detailData.created_at }}</ADescriptionsItem>
<ADescriptionsItem label="租户ID">{{ detailData.tenant_id }}</ADescriptionsItem>
<ADescriptionsItem label="用户ID">{{ detailData.user_id }}</ADescriptionsItem>
<ADescriptionsItem v-if="detailData.fail_reason" label="失败原因" :span="2">
<span style="color: var(--color-danger-6)">{{ detailData.fail_reason }}</span>
</ADescriptionsItem>
</ADescriptions>
<!-- 状态抬头区:打开抽屉第一眼就能判断任务处境 -->
<div class="detail-hero">
<div class="detail-hero__top">
<div class="detail-hero__status">
<span class="detail-hero__dot" :style="{ background: detailVisual.dot }"></span>
<span class="detail-hero__text">{{ detailVisual.label }}</span>
<span class="detail-hero__sub">{{ detailData.model_name || '-' }} · {{ detailData.action || '-' }}</span>
</div>
<ASpace :size="6">
<ATag v-if="platformKind[detailData.platform]" size="small">{{ platformKind[detailData.platform] }}</ATag>
<ATag size="small" color="arcoblue">{{ platformLabel[detailData.platform] || detailData.platform }}</ATag>
</ASpace>
</div>
<AProgress
class="detail-hero__progress"
:percent="progressRatio"
:show-text="false"
size="small"
:color="detailVisual.bar"
/>
<div class="detail-hero__foot">
<span>进度 {{ detailData.progress || '0%' }}</span>
<span v-if="runningOrTotalText">{{ runningOrTotalText }}</span>
</div>
</div>
<!-- Result -->
<!-- 失败原因:紧跟抬头区,避免埋在字段表末尾被忽略 -->
<div v-if="detailData.fail_reason" class="detail-alert">
<span class="detail-alert__title">失败原因</span>
<span class="detail-alert__body">{{ detailData.fail_reason }}</span>
</div>
<!-- 任务ID:等宽 + 单行省略,长 ID 不再折行破坏整体对齐 -->
<div class="detail-idbar">
<div class="detail-idbar__main">
<span class="detail-idbar__label">任务ID</span>
<ATooltip :content="detailData.public_task_id" position="top" :disabled="!detailData.public_task_id">
<span class="detail-idbar__value">{{ detailData.public_task_id || '-' }}</span>
</ATooltip>
<AButton
v-if="detailData.public_task_id"
size="mini"
type="text"
@click="copyText(detailData.public_task_id)"
>复制</AButton>
</div>
<AButton size="mini" type="text" :loading="detailLoading" @click="reloadDetail()">刷新</AButton>
</div>
<h3 class="detail-section-title">任务实例</h3>
<div class="detail-grid">
<span class="detail-grid__k">平台</span>
<span class="detail-grid__v">{{ platformLabel[detailData.platform] || detailData.platform || '-' }}</span>
<span class="detail-grid__k">动作</span>
<span class="detail-grid__v">{{ detailData.action || '-' }}</span>
<span class="detail-grid__k">模型</span>
<span class="detail-grid__v detail-mono">{{ detailData.model_name || '-' }}</span>
<span class="detail-grid__k">上游模型</span>
<span class="detail-grid__v detail-mono">{{ detailData.upstream_model || '-' }}</span>
<span class="detail-grid__k">租户ID</span>
<span class="detail-grid__v">#{{ detailData.tenant_id }}</span>
<span class="detail-grid__k">用户ID</span>
<span class="detail-grid__v">#{{ detailData.user_id }}</span>
</div>
<h3 class="detail-section-title">计费</h3>
<div class="detail-metrics">
<div class="detail-metric">
<div class="detail-metric__label">预扣金额</div>
<div class="detail-metric__value">
{{ detailData.pre_deduct_amount > 0 ? formatBilling(detailData.pre_deduct_amount, 4) : '-' }}
</div>
</div>
<div class="detail-metric">
<div class="detail-metric__label">实际费用</div>
<div class="detail-metric__value">
{{ detailData.billing_settled ? formatBilling(detailData.actual_cost, 4) : '未结算' }}
</div>
</div>
<div class="detail-metric">
<div class="detail-metric__label">结算状态</div>
<div class="detail-metric__value">{{ detailData.billing_settled ? '已结算' : '待结算' }}</div>
</div>
</div>
<h3 class="detail-section-title">时间线</h3>
<div class="detail-timeline">
<span class="detail-timeline__track"></span>
<div v-for="n in timelineNodes" :key="n.label" class="detail-timeline__node">
<div class="detail-timeline__label">{{ n.label }}</div>
<span class="detail-timeline__dot" :style="{ background: n.color }"></span>
<div class="detail-timeline__time" :class="{ 'is-empty': !n.time }">{{ n.short || '—' }}</div>
</div>
</div>
<div v-if="durationList.length" class="detail-durations">
<span v-for="i in durationList" :key="i.label">{{ i.label }} {{ i.value }}</span>
</div>
<h3 class="detail-section-title">排障标识</h3>
<div class="detail-grid detail-grid--wide">
<span class="detail-grid__k">请求ID</span>
<span class="detail-grid__v">
<template v-if="detailData.request_id">
<ATooltip :content="detailData.request_id" position="top">
<span class="detail-mono detail-ellipsis">{{ detailData.request_id }}</span>
</ATooltip>
<AButton size="mini" type="text" @click="copyText(detailData.request_id)">复制</AButton>
</template>
<span v-else>-</span>
</span>
<span class="detail-grid__k">上游任务ID</span>
<span class="detail-grid__v">
<template v-if="detailData.upstream_task_id">
<ATooltip :content="detailData.upstream_task_id" position="top">
<span class="detail-mono detail-ellipsis">{{ detailData.upstream_task_id }}</span>
</ATooltip>
<AButton size="mini" type="text" @click="copyText(detailData.upstream_task_id)">复制</AButton>
</template>
<span v-else>-</span>
</span>
<span class="detail-grid__k">上游请求ID</span>
<span class="detail-grid__v">
<template v-if="detailData.upstream_request_id">
<ATooltip :content="detailData.upstream_request_id" position="top">
<span class="detail-mono detail-ellipsis">{{ detailData.upstream_request_id }}</span>
</ATooltip>
<AButton size="mini" type="text" @click="copyText(detailData.upstream_request_id)">复制</AButton>
</template>
<span v-else>-</span>
</span>
</div>
<!-- 结果:图片缩略图 / 视频音频内联播放 / 其余回退为链接 -->
<template v-if="detailData.result_url">
<h3 class="detail-section-title">结果</h3>
<div class="result-preview">
<!-- 图片:内联展示缩略图(省流量),点击在新标签打开原图 -->
<a
v-if="detailData.result_url && /\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(detailData.result_url)"
v-if="resultKind === 'image'"
:href="detailData.result_url"
target="_blank"
rel="noopener"
@@ -398,13 +657,31 @@ onMounted(() => {
<img
:src="detailData.result_thumb_url || detailData.result_url"
alt="任务结果"
style="max-width: 100%; border-radius: 6px; cursor: zoom-in;"
class="result-image"
/>
</a>
<a v-else :href="detailData.result_url" target="_blank" class="result-link">
<video
v-else-if="resultKind === 'video'"
class="result-video"
:src="detailData.result_url"
controls
preload="metadata"
></video>
<audio
v-else-if="resultKind === 'audio'"
class="result-audio"
:src="detailData.result_url"
controls
preload="metadata"
></audio>
<a v-else :href="detailData.result_url" target="_blank" rel="noopener" class="result-link">
{{ detailData.result_url }}
</a>
</div>
<div class="result-actions">
<a :href="detailData.result_url" target="_blank" rel="noopener" class="result-action">打开原文件</a>
<a class="result-action" @click="copyText(detailData.result_url)">复制链接</a>
</div>
</template>
</template>
</ASpin>
@@ -425,6 +702,137 @@ onMounted(() => {
margin-bottom: 0;
}
/* ===== 任务详情抽屉 ===== */
/* Arco Spin 根节点是 display:inline-block(arco.css: .arco-spin{display:inline-block}),
会把内容压成「最长不可换行内容」决定的收缩宽度(本页长任务ID 等 nowrap 文本 → 约 418px),
于是内部所有块级元素(hero / 告警 / idbar / 指标卡 / grid)在 720px 的抽屉里只占一半,
右侧留大片空白。这里强制撑满抽屉内容宽度。 */
.detail-spin {
display: block;
width: 100%;
}
/* 状态抬头区:状态 + 进度 + 耗时,打开抽屉第一屏即给出结论 */
.detail-hero {
background: var(--ta-bg-secondary);
border-radius: var(--ta-radius);
padding: 12px 14px;
}
.detail-hero__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detail-hero__status {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.detail-hero__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.detail-hero__text {
font-size: 15px;
font-weight: 600;
color: var(--ta-text-primary);
flex-shrink: 0;
}
.detail-hero__sub {
font-size: 12px;
color: var(--ta-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-hero__progress {
margin-top: 10px;
}
.detail-hero__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
font-size: 12px;
color: var(--ta-text-secondary);
}
/* 失败原因告警块 */
.detail-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 10px 12px;
border-radius: var(--ta-radius);
background: var(--color-danger-light-1);
border: 1px solid var(--color-danger-light-2);
}
.detail-alert__title {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--ta-danger);
line-height: 1.6;
}
.detail-alert__body {
font-size: 12px;
line-height: 1.6;
color: var(--ta-text-primary);
word-break: break-all;
}
/* 任务ID:等宽 + 单行省略,避免长 ID 折行把整行行高撑成两倍 */
.detail-idbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 5px 10px;
border: 1px solid var(--ta-border);
border-radius: var(--ta-radius);
}
.detail-idbar__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.detail-idbar__label {
flex-shrink: 0;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.detail-idbar__value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 12px;
color: var(--ta-text-primary);
}
.detail-section-title {
font-size: 14px;
font-weight: 600;
@@ -436,14 +844,183 @@ onMounted(() => {
margin-top: 0;
}
/* 字段表:两列 label/value,比 bordered 表格更紧凑,且不产生空单元格 */
.detail-grid {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
gap: 9px 16px;
align-items: baseline;
}
.detail-grid--wide {
grid-template-columns: 76px minmax(0, 1fr);
}
.detail-grid__k {
font-size: 13px;
color: var(--ta-text-tertiary);
}
.detail-grid__v {
font-size: 13px;
color: var(--ta-text-primary);
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
word-break: break-all;
}
.detail-mono {
font-family: monospace;
}
.detail-ellipsis {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
/* 计费指标卡 */
.detail-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.detail-metric {
background: var(--ta-bg-secondary);
border-radius: var(--ta-radius);
padding: 10px 12px;
}
.detail-metric__label {
font-size: 12px;
color: var(--ta-text-tertiary);
}
.detail-metric__value {
margin-top: 4px;
font-size: 15px;
font-weight: 600;
color: var(--ta-text-primary);
}
/* 时间线:四节点等分,节点连线作为底轨 */
.detail-timeline {
position: relative;
display: flex;
}
.detail-timeline__track {
position: absolute;
top: 34px;
left: 12.5%;
right: 12.5%;
height: 1px;
background: var(--ta-border);
}
.detail-timeline__node {
position: relative;
flex: 1;
min-width: 0;
padding-top: 46px;
text-align: center;
}
.detail-timeline__label {
position: absolute;
top: 0;
left: 0;
right: 0;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.detail-timeline__dot {
position: absolute;
top: 30px;
left: 50%;
width: 8px;
height: 8px;
margin-left: -4px;
border-radius: 50%;
}
.detail-timeline__time {
font-size: 12px;
color: var(--ta-text-primary);
font-family: monospace;
}
.detail-timeline__time.is-empty {
color: var(--ta-text-tertiary);
}
.detail-durations {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-top: 10px;
font-size: 12px;
color: var(--ta-text-secondary);
}
.result-preview {
background: var(--color-fill-1);
border-radius: 6px;
padding: 12px;
}
.result-image {
display: block;
max-width: 100%;
border-radius: 6px;
cursor: zoom-in;
}
.result-video {
display: block;
width: 100%;
max-height: 400px;
border-radius: 6px;
background: #000;
}
.result-audio {
display: block;
width: 100%;
}
.result-actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 8px;
font-size: 12px;
}
.result-action {
color: rgb(var(--arcoblue-6));
cursor: pointer;
}
.result-link {
color: rgb(var(--arcoblue-6));
word-break: break-all;
}
@media (max-width: 767px) {
.detail-grid,
.detail-grid--wide {
grid-template-columns: 68px minmax(0, 1fr);
}
.detail-metrics {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+302 -84
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
import { ref, onMounted, computed, watch, onUnmounted, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NTag, NInput, NDropdown } from 'naive-ui'
import { useRoute } from 'vue-router'
@@ -84,13 +84,14 @@ const { exporting, exportFile } = useExport({
}),
})
const statusBadge: Record<string, string> = {
NOT_START: 'bg-gray-100 text-gray-800',
SUBMITTED: 'bg-blue-100 text-blue-800',
IN_PROGRESS: 'bg-amber-100 text-amber-800',
SUCCESS: 'bg-emerald-100 text-emerald-800',
FAILURE: 'bg-red-100 text-red-800',
TIMEOUT: 'bg-orange-100 text-orange-800',
// 状态语义色:详情抬头区的状态点与进度条共用(列表徽章另走 taskStatusType 的 NTag)
const statusColor: Record<string, string> = {
NOT_START: '#9ca3af',
SUBMITTED: '#3b82f6',
IN_PROGRESS: '#f59e0b',
SUCCESS: '#10b981',
FAILURE: '#ef4444',
TIMEOUT: '#f97316',
}
const statusLabel: Record<string, string> = {
@@ -112,6 +113,17 @@ const platformLabel: Record<string, string> = {
minimax: 'MiniMax',
}
// 平台 → 任务类型,用于详情抬头区的类型标签(覆盖范围与筛选项一致)
const platformKind: Record<string, string> = {
sora: '视频',
kling: '视频',
volcengine: '视频',
ali: '视频',
minimax: '视频',
midjourney: '图片',
suno: '音乐',
}
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
function formatCost(n: number | undefined): string {
return formatBilling(n, 6)
@@ -122,6 +134,31 @@ function formatTime(s: string | undefined): string {
return s.replace('T', ' ').substring(0, 19)
}
// 后端时间为 Asia/Shanghai 墙钟(YYYY-MM-DD HH:mm:ss),此处只用来算时间差,不做时区换算;
// 把 - 换成 / 以兼容 Safari 的 Date 解析
function parseTs(s: string | undefined): number | null {
if (!s) return null
const t = new Date(s.replace('T', ' ').substring(0, 19).replace(/-/g, '/')).getTime()
return Number.isNaN(t) ? null : t
}
function diffMs(start?: string, end?: string): number | null {
const a = parseTs(start)
const b = parseTs(end)
if (a === null || b === null) return null
return b >= a ? b - a : null
}
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s} 秒`
const m = Math.floor(s / 60)
if (m < 60) return `${m} 分 ${s % 60} 秒`
const h = Math.floor(m / 60)
if (h < 24) return `${h} 小时 ${m % 60} 分`
return `${Math.floor(h / 24)} 天 ${h % 24} 小时`
}
async function fetchTasks() {
loading.value = true
try {
@@ -155,7 +192,8 @@ async function openDetail(task: TaskItem) {
const res: any = await request.get(`/tenant/tasks/${task.id}`)
const raw = res.data?.data
if (raw?.task) {
detailTask.value = raw.task
// 详情接口不返回 username(只有列表接口 join 得到),整体替换会把它抹掉,故显式回落
detailTask.value = { ...task, ...raw.task, username: raw.task.username || task.username }
}
} catch {
// keep the list-level data
@@ -164,6 +202,130 @@ async function openDetail(task: TaskItem) {
}
}
// === 详情弹窗展示 ===
const detailStatusColor = computed(() => statusColor[detailTask.value?.status || ''] || statusColor.NOT_START)
// 进度百分比:解析后端 "50%" 字符串;成功一律补满,保证终态视觉一致
const progressPercent = computed(() => {
if (detailTask.value?.status === 'SUCCESS') return 100
const n = parseInt(String(detailTask.value?.progress || '').replace('%', ''), 10)
return Number.isNaN(n) ? 0 : Math.min(100, Math.max(0, n))
})
// 结果资源类型:图片走缩略图、视频/音频内联播放,其余才回退成链接
const resultKind = computed<'image' | 'video' | 'audio' | 'file'>(() => {
const url = detailTask.value?.result_url || ''
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)([?#]|$)/i.test(url)) return 'image'
if (/\.(mp4|mov|webm|m4v|mkv)([?#]|$)/i.test(url)) return 'video'
if (/\.(mp3|wav|m4a|aac|ogg|flac)([?#]|$)/i.test(url)) return 'audio'
return 'file'
})
const resultKindLabel = computed(() => ({ image: '图片', video: '视频', audio: '音频', file: '文件' }[resultKind.value]))
// Icon 组件按名取 path,取不到会渲染空图标并留下空白占位,故需按类型给出真实存在的图标名
const resultIconName = computed(
() => ({ image: 'photo', video: 'film', audio: 'musicalNote', file: 'link' }[resultKind.value]),
)
// 结果文件名:取 URL 路径末段(去掉查询串),供下载按钮落盘用
const resultFileName = computed(() => {
const path = (detailTask.value?.result_url || '').split('?')[0]
const base = path.substring(path.lastIndexOf('/') + 1)
try {
return base ? decodeURIComponent(base) : ''
} catch {
return base
}
})
// 进行中任务抬头区的「已运行」按秒刷新
const nowTs = ref(Date.now())
let heroTicker: ReturnType<typeof setInterval> | undefined
function startHeroTicker() {
stopHeroTicker()
heroTicker = setInterval(() => { nowTs.value = Date.now() }, 1000)
}
function stopHeroTicker() {
if (heroTicker) {
clearInterval(heroTicker)
heroTicker = undefined
}
}
watch(
[showDetail, () => detailTask.value?.status],
([visible, status]) => {
const running = visible && ['NOT_START', 'SUBMITTED', 'IN_PROGRESS'].includes(String(status || ''))
if (running) startHeroTicker()
else stopHeroTicker()
},
{ immediate: true },
)
onUnmounted(stopHeroTicker)
// 抬头区右侧文案:终态给总耗时与完成时刻,进行中给已运行时长
const detailTimingText = computed(() => {
const t = detailTask.value
if (!t) return ''
if (t.status === 'SUCCESS' || t.status === 'FAILURE' || t.status === 'TIMEOUT') {
const total = diffMs(t.created_at, t.finish_time)
if (total === null) return ''
return `总耗时 ${formatDuration(total)}${t.finish_time ? ' · ' + formatTime(t.finish_time) + ' 完成' : ''}`
}
const since = parseTs(t.submit_time) ?? parseTs(t.created_at)
if (since === null) return ''
return `已运行 ${formatDuration(Math.max(0, nowTs.value - since))}`
})
// 执行耗时 = 提交上游 → 完成,无完成时间(进行中)时显示「-」
const detailDurationText = computed(() => {
const ms = diffMs(detailTask.value?.submit_time, detailTask.value?.finish_time)
return ms === null ? '-' : formatDuration(ms)
})
// 复制反馈:按 key 区分触发源,短暂显示「已复制」后复位(可编辑文本行内提示,不额外引 toast)
const copiedKey = ref('')
async function copyText(text: string, key = 'default') {
if (!text) return
try {
await navigator.clipboard.writeText(text)
copiedKey.value = key
setTimeout(() => {
if (copiedKey.value === key) copiedKey.value = ''
}, 1500)
} catch (e) {
console.error(e)
}
}
// 下载结果:跨域直链的 download 属性会被浏览器忽略,故先取 blob 落盘;
// 对象存储未开 CORS 时 fetch 会失败,降级为新标签打开(与「打开原文件」一致)
async function downloadResult() {
const url = detailTask.value?.result_url
if (!url) return
try {
const res = await fetch(url)
if (!res.ok) throw new Error(String(res.status))
const blob = await res.blob()
const href = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = href
a.download = resultFileName.value || 'task-result'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(href)
} catch (e) {
console.error('[task-result] 下载失败,改为新标签打开', e)
window.open(url, '_blank', 'noopener')
}
}
function applyFilters() {
page.value = 1
fetchTasks()
@@ -179,12 +341,7 @@ function resetFilters() {
fetchTasks()
}
function isImageResult(url: string | undefined): boolean {
if (!url) return false
return /\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(url)
}
// 任务状态 → NTag type 映射(statusBadge 为 bg-* 类,无法映射到 renderBadge,改为直接渲染 NTag)
// 任务状态 → NTag type 映射(statusColor 为语义色,无法映射到 renderBadge,改为直接渲染 NTag)
const taskStatusType: Record<string, 'default' | 'success' | 'info' | 'warning' | 'error' | 'primary'> = {
NOT_START: 'default',
SUBMITTED: 'info',
@@ -199,19 +356,19 @@ const columns = computed<DataTableColumns<TaskItem>>(() => [
{
title: '任务 ID',
key: 'public_task_id',
width: 160,
width: 200,
render: (row) => h('span', { class: 'font-mono text-xs text-gray-600' }, row.public_task_id),
},
{
title: '平台',
key: 'platform',
width: 110,
width: 100,
render: (row) => h('span', { class: 'text-sm font-medium text-gray-700' }, platformLabel[row.platform] || row.platform),
},
{
title: '状态',
key: 'status',
width: 110,
width: 60,
render: (row) =>
h(NTag, { type: taskStatusType[row.status] || 'default', size: 'small' }, { default: () => statusLabel[row.status] || row.status }),
},
@@ -235,13 +392,13 @@ const columns = computed<DataTableColumns<TaskItem>>(() => [
{
title: '提交时间',
key: 'submit_time',
width: 170,
width: 150,
render: (row) => h('span', { class: 'text-xs text-gray-500' }, formatTime(row.submit_time)),
},
{
title: '完成时间',
key: 'finish_time',
width: 170,
width: 150,
render: (row) => h('span', { class: 'text-xs text-gray-500' }, formatTime(row.finish_time)),
},
{
@@ -354,54 +511,99 @@ onMounted(() => {
</div>
<div v-else-if="detailTask" class="space-y-5">
<!-- 状态抬头区:打开弹窗第一眼即可判断结果与耗时 -->
<div class="rounded-xl bg-gray-50 px-4 py-3">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ background: detailStatusColor }"></span>
<span class="shrink-0 text-base font-semibold text-gray-800">{{ statusLabel[detailTask.status] || detailTask.status }}</span>
<span class="truncate text-xs text-gray-500">{{ detailTask.model_name || '-' }} · {{ detailTask.action || '-' }}</span>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<span v-if="platformKind[detailTask.platform]" class="rounded-md bg-white px-2 py-0.5 text-xs font-medium text-gray-600">
{{ platformKind[detailTask.platform] }}
</span>
<span class="rounded-md bg-white px-2 py-0.5 text-xs font-medium text-primary-700">
{{ platformLabel[detailTask.platform] || detailTask.platform }}
</span>
</div>
</div>
<div class="mt-2.5 h-1.5 overflow-hidden rounded-full bg-gray-200">
<div
class="h-full rounded-full transition-all duration-500"
:style="{ width: progressPercent + '%', background: detailStatusColor }"
></div>
</div>
<div class="mt-1.5 flex items-center justify-between gap-3 text-xs text-gray-500">
<span>进度 {{ detailTask.progress || '0%' }}</span>
<span v-if="detailTimingText">{{ detailTimingText }}</span>
</div>
</div>
<!-- 失败原因:紧跟抬头区,避免埋在字段末尾被忽略 -->
<div v-if="detailTask.fail_reason" class="flex items-start gap-2 rounded-xl border border-red-100 bg-red-50 px-4 py-3">
<Icon name="xCircle" size="sm" class="mt-0.5 shrink-0 text-red-500" />
<div class="min-w-0 text-sm text-red-700">
<p class="mb-1 font-semibold">任务失败</p>
<p class="break-all leading-relaxed">{{ detailTask.fail_reason }}</p>
</div>
</div>
<!-- Basic Info -->
<div>
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Icon name="document" size="sm" class="text-primary-500" />
基本信息
</h4>
<div class="grid grid-cols-2 gap-x-6 gap-y-2.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">任务 ID</span>
<span class="font-mono text-xs">{{ detailTask.public_task_id }}</span>
<!-- 任务 ID 独占一行:等宽 + 单行省略,长 ID 不再折行挤乱右侧字段的对齐 -->
<div class="flex items-center gap-2 text-sm">
<span class="w-[60px] shrink-0 text-gray-500">任务 ID</span>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-gray-700" :title="detailTask.public_task_id">
{{ detailTask.public_task_id }}
</span>
<button
class="shrink-0 text-gray-400 transition-colors hover:text-primary-500"
title="复制任务 ID"
@click="copyText(detailTask.public_task_id, 'task')"
>
<Icon :name="copiedKey === 'task' ? 'check' : 'copy'" size="xs" />
</button>
</div>
<!-- label 固定宽 + 值左对齐:两端对齐留下的中间空洞会让 label 与值难以对照 -->
<div class="mt-2.5 grid grid-cols-2 gap-x-6 gap-y-2.5 text-sm">
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">平台</span>
<span class="min-w-0 truncate text-gray-700">{{ platformLabel[detailTask.platform] || detailTask.platform || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">状态</span>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" :class="statusBadge[detailTask.status] || 'bg-gray-100 text-gray-800'">
{{ statusLabel[detailTask.status] || detailTask.status }}
</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">动作</span>
<span class="min-w-0 truncate text-gray-700">{{ detailTask.action || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">平台</span>
<span class="text-sm">{{ platformLabel[detailTask.platform] || detailTask.platform }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">模型</span>
<span class="min-w-0 truncate font-mono text-xs text-gray-700">{{ detailTask.model_name || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">动作</span>
<span class="text-sm">{{ detailTask.action || '-' }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">成员</span>
<span class="min-w-0 truncate text-gray-700">{{ detailTask.username || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">模型</span>
<span class="text-sm font-mono">{{ detailTask.model_name }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">提交时间</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ formatTime(detailTask.submit_time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">进度</span>
<span class="text-sm">{{ detailTask.progress || '-' }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">完成时间</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ formatTime(detailTask.finish_time) }}</span>
</div>
<div v-if="detailTask.username" class="flex justify-between">
<span class="text-gray-500">用户</span>
<span class="text-sm">{{ detailTask.username }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">创建时间</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ formatTime(detailTask.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">提交时间</span>
<span class="text-xs">{{ formatTime(detailTask.submit_time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">完成时间</span>
<span class="text-xs">{{ formatTime(detailTask.finish_time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">创建时间</span>
<span class="text-xs">{{ formatTime(detailTask.created_at) }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">执行耗时</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ detailDurationText }}</span>
</div>
</div>
</div>
@@ -412,29 +614,44 @@ onMounted(() => {
<Icon name="creditCard" size="sm" class="text-primary-500" />
费用信息
</h4>
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-500">预扣金额</span>
<span>{{ formatCost(detailTask.pre_deduct_amount) }}</span>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-xl bg-gray-50 px-4 py-3">
<p class="text-xs text-gray-500">预扣金额</p>
<p class="mt-1 text-base font-semibold text-gray-800">{{ formatCost(detailTask.pre_deduct_amount) }}</p>
</div>
<div class="flex items-center justify-between border-t border-gray-200 pt-2 font-semibold">
<span class="text-gray-700">实际费用</span>
<span v-if="detailTask.billing_settled" class="text-emerald-600">{{ formatCost(detailTask.actual_cost) }}</span>
<span v-else class="text-gray-400">未结算</span>
<div class="rounded-xl bg-gray-50 px-4 py-3">
<div class="flex items-center gap-1.5">
<p class="text-xs text-gray-500">实际费用</p>
<span v-if="detailTask.billing_settled" class="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium leading-tight text-emerald-600">已结算</span>
</div>
<p v-if="detailTask.billing_settled" class="mt-1 text-base font-semibold text-emerald-600">
{{ formatCost(detailTask.actual_cost) }}
</p>
<p v-else class="mt-1 text-base font-semibold text-gray-400">未结算</p>
</div>
</div>
</div>
<!-- Result -->
<div v-if="detailTask.result_url">
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Icon name="image" size="sm" class="text-primary-500" />
生成结果
</h4>
<div class="bg-gray-50 rounded-xl p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
<Icon :name="resultIconName" size="sm" class="text-primary-500" />
生成结果
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium leading-tight text-gray-500">{{ resultKindLabel }}</span>
</h4>
<div class="flex shrink-0 items-center gap-3 text-xs">
<a :href="detailTask.result_url" target="_blank" rel="noopener" class="text-primary-600 hover:text-primary-700">打开原文件</a>
<button class="text-primary-600 hover:text-primary-700" @click="downloadResult">下载</button>
<button class="text-primary-600 hover:text-primary-700" @click="copyText(detailTask.result_url || '', 'result')">
{{ copiedKey === 'result' ? '已复制' : '复制链接' }}
</button>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-3">
<!-- 图片:内联展示缩略图(省流量),点击在新标签打开原图 -->
<a
v-if="isImageResult(detailTask.result_url)"
v-if="resultKind === 'image'"
:href="detailTask.result_url"
target="_blank"
rel="noopener"
@@ -446,27 +663,28 @@ onMounted(() => {
class="max-w-full rounded-lg cursor-zoom-in"
/>
</a>
<a v-else :href="detailTask.result_url" target="_blank" class="text-primary-600 hover:text-primary-700 text-sm break-all">
<!-- 视频/音频:内联播放,客户不必再另开签名链接 -->
<video
v-else-if="resultKind === 'video'"
:src="detailTask.result_url"
class="w-full max-h-[420px] rounded-lg bg-black"
controls
preload="metadata"
></video>
<audio
v-else-if="resultKind === 'audio'"
:src="detailTask.result_url"
class="w-full"
controls
preload="metadata"
></audio>
<a v-else :href="detailTask.result_url" target="_blank" rel="noopener" class="text-primary-600 hover:text-primary-700 text-sm break-all">
{{ detailTask.result_url }}
</a>
</div>
</div>
<!-- Fail Reason -->
<div v-if="detailTask.fail_reason">
<h4 class="text-sm font-semibold text-red-600 mb-3 flex items-center gap-2">
<Icon name="xCircle" size="sm" />
失败原因
</h4>
<div class="rounded-lg bg-red-50 border border-red-100 px-4 py-3 text-sm text-red-700">
{{ detailTask.fail_reason }}
</div>
</div>
</div>
<template #footer>
<button class="btn btn-secondary" @click="showDetail = false">关闭</button>
</template>
</BaseModal>
</div>
</template>
@@ -388,8 +388,8 @@ function downloadVideo() {
{{ duration }} 秒
</span>
</div>
<!-- min=4 覆盖 MiniMax-H3 的 4 秒档;其他模型档位由上游校验 -->
<n-input-number v-model:value="duration" :min="4" :max="15" :step="1" class="w-full" />
<!-- min=2 覆盖短时长档位模型;其他模型档位由上游校验 -->
<n-input-number v-model:value="duration" :min="2" :max="30" :step="1" class="w-full" />
</div>
<div class="flex items-start justify-between gap-3">