Эх сурвалжийг харах

添加了主动查询的按钮

ChenYu 7 сар өмнө
parent
commit
f6eaffdc28

+ 111 - 0
doc/AI_ORDER_V2_API.md

@@ -196,3 +196,114 @@ GET /v2/ai/order/v2/get/ORD_202603090001
   "timestamp": 1773033000000
 }
 ```
+
+---
+
+## 4. 手动查询任务结果
+
+*   **URL**: `/v2/ai/order/v2/queryResult`
+*   **Method**: `GET`
+*   **描述**: 根据 taskId 主动向外部 Workflow 服务查询任务结果,并自动处理(更新订单、相册、推送通知)。用于 Redis 通知遗漏时的手动补偿。
+
+### 请求参数
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `taskId` | string | 是 | 外部任务ID,例如 `2b80cfe9-eb90-44c2-9201-55ecff33a85d` |
+
+### 响应参数(`data` 字段)
+
+返回外部 Workflow 服务原始响应 `WorkflowTaskMessage` 对象:
+
+| 字段名 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `taskId` | string | 任务ID |
+| `status` | string | 任务状态(`successes`=成功, `failed`=失败, `running`=执行中) |
+| `message` | string | 消息/错误信息 |
+| `type` | string | 任务类型 |
+| `data` | object | 任务结果数据 |
+| `data.images` | string[] | 生成的图片URL列表 |
+| `data.videos` | string[] | 生成的视频URL列表 |
+| `request` | object | 任务请求参数 |
+| `request.template` | string | 模版名称 |
+| `request.inputs` | object | 输入参数 |
+| `createTime` | long | 创建时间(时间戳) |
+| `updateTime` | long | 更新时间(时间戳) |
+
+### 处理逻辑
+
+1. 调用外部接口 `GET {workflow_host}/result?taskId=xxx` 获取任务结果
+2. 如果 `status` 为 `successes`:更新订单状态为完成、更新相册、推送用户通知
+3. 如果 `status` 为失败状态:执行退款并标记订单/相册失败
+4. 如果 `status` 为 `running`/`pending`/`processing` 等执行中状态:跳过处理,仅返回当前状态
+
+### 示例请求
+
+```http
+GET /v2/ai/order/v2/queryResult?taskId=2b80cfe9-eb90-44c2-9201-55ecff33a85d
+```
+
+### 示例响应(任务已完成)
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "success": true,
+  "data": {
+    "taskId": "2b80cfe9-eb90-44c2-9201-55ecff33a85d",
+    "status": "successes",
+    "message": null,
+    "type": "workflow",
+    "data": {
+      "images": [
+        "https://cdn.example.com/result1.jpg",
+        "https://cdn.example.com/result2.jpg"
+      ],
+      "videos": null
+    },
+    "request": {
+      "template": "spring_photo",
+      "inputs": {
+        "style": "natural"
+      }
+    },
+    "createTime": 1773033000,
+    "updateTime": 1773033300
+  },
+  "timestamp": 1773033600000
+}
+```
+
+### 示例响应(任务仍在执行中)
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "success": true,
+  "data": {
+    "taskId": "2b80cfe9-eb90-44c2-9201-55ecff33a85d",
+    "status": "running",
+    "message": null,
+    "type": "workflow",
+    "data": null,
+    "request": null,
+    "createTime": 1773033000,
+    "updateTime": null
+  },
+  "timestamp": 1773033600000
+}
+```
+
+### 示例响应(查询异常)
+
+```json
+{
+  "code": 500,
+  "message": "查询异常: Connection refused",
+  "success": false,
+  "data": null,
+  "timestamp": 1773033600000
+}
+```

+ 21 - 0
src/api/aiOrderV2.ts

@@ -58,3 +58,24 @@ export function getAiOrderV2List(params: AiOrderV2Params) {
 export function getAiOrderV2Detail(id: string) {
   return get<AiOrderV2Item>(`/v2/ai/order/v2/get/${id}`);
 }
+
+export interface WorkflowTaskMessage {
+  taskId: string;
+  status: string;
+  message?: string;
+  type?: string;
+  data?: {
+    images?: string[];
+    videos?: string[];
+  };
+  request?: {
+    template?: string;
+    inputs?: Record<string, unknown>;
+  };
+  createTime?: number;
+  updateTime?: number;
+}
+
+export function queryAiOrderV2Result(taskId: string) {
+  return get<WorkflowTaskMessage>("/v2/ai/order/v2/queryResult", { taskId });
+}

+ 22 - 1
src/i18n/locales/en.json

@@ -743,7 +743,28 @@
     "fieldLanguage": "Language",
     "fieldAppVersion": "App Version",
     "fieldPhoneModel": "Phone Model",
-    "fieldPhoneOsVersion": "OS Version"
+    "fieldPhoneOsVersion": "OS Version",
+    "queryResult": "Query Result",
+    "queryResultTitle": "Task Query Result - {{taskId}}",
+    "queryResultLoading": "Querying task result...",
+    "queryResultSuccess": "Query successful",
+    "queryResultFailed": "Query failed",
+    "queryResultNoTaskId": "No task ID for this order",
+    "queryFieldTaskId": "Task ID",
+    "queryFieldStatus": "Status",
+    "queryFieldMessage": "Message",
+    "queryFieldType": "Task Type",
+    "queryFieldTemplate": "Template",
+    "queryFieldInputs": "Inputs",
+    "queryFieldCreateTime": "Created At",
+    "queryFieldUpdateTime": "Updated At",
+    "queryFieldImages": "Generated Images",
+    "queryFieldVideos": "Generated Videos",
+    "queryStatusSuccesses": "Success",
+    "queryStatusFailed": "Failed",
+    "queryStatusRunning": "Running",
+    "queryStatusPending": "Pending",
+    "queryStatusProcessing": "Processing"
   },
   "aiAlbum": {
     "title": "AI Albums",

+ 22 - 1
src/i18n/locales/zh.json

@@ -743,7 +743,28 @@
     "fieldLanguage": "语言",
     "fieldAppVersion": "App版本",
     "fieldPhoneModel": "手机型号",
-    "fieldPhoneOsVersion": "系统版本"
+    "fieldPhoneOsVersion": "系统版本",
+    "queryResult": "主动查询",
+    "queryResultTitle": "任务查询结果 - {{taskId}}",
+    "queryResultLoading": "正在查询任务结果...",
+    "queryResultSuccess": "查询成功",
+    "queryResultFailed": "查询失败",
+    "queryResultNoTaskId": "该订单没有任务ID",
+    "queryFieldTaskId": "任务ID",
+    "queryFieldStatus": "任务状态",
+    "queryFieldMessage": "消息",
+    "queryFieldType": "任务类型",
+    "queryFieldTemplate": "模版",
+    "queryFieldInputs": "输入参数",
+    "queryFieldCreateTime": "创建时间",
+    "queryFieldUpdateTime": "更新时间",
+    "queryFieldImages": "生成图片",
+    "queryFieldVideos": "生成视频",
+    "queryStatusSuccesses": "成功",
+    "queryStatusFailed": "失败",
+    "queryStatusRunning": "执行中",
+    "queryStatusPending": "等待中",
+    "queryStatusProcessing": "处理中"
   },
   "aiAlbum": {
     "title": "相册管理",

+ 179 - 11
src/pages/ai-order-v2/AiOrderV2Page.tsx

@@ -27,9 +27,10 @@ import {
   PictureOutlined,
   PlayCircleOutlined,
   MobileOutlined,
+  CloudSyncOutlined,
 } from "@ant-design/icons";
-import type { AiOrderV2Item, AiOrderV2Params } from "@/api/aiOrderV2";
-import { getAiOrderV2List, getAiOrderV2Detail } from "@/api/aiOrderV2";
+import type { AiOrderV2Item, AiOrderV2Params, WorkflowTaskMessage } from "@/api/aiOrderV2";
+import { getAiOrderV2List, getAiOrderV2Detail, queryAiOrderV2Result } from "@/api/aiOrderV2";
 
 const { Title } = Typography;
 
@@ -208,6 +209,11 @@ export function AiOrderV2Page() {
   const [detailData, setDetailData] = useState<AiOrderV2Item | null>(null);
   const [detailId, setDetailId] = useState<string>("");
 
+  const [queryResultOpen, setQueryResultOpen] = useState(false);
+  const [queryResultLoading, setQueryResultLoading] = useState(false);
+  const [queryResultData, setQueryResultData] = useState<WorkflowTaskMessage | null>(null);
+  const [queryResultTaskId, setQueryResultTaskId] = useState<string>("");
+
   const fetchData = useCallback(async () => {
     setLoading(true);
     try {
@@ -267,6 +273,41 @@ export function AiOrderV2Page() {
     }
   }
 
+  async function handleQueryResult(taskId?: string) {
+    if (!taskId) {
+      message.warning(t("aiOrderV2.queryResultNoTaskId"));
+      return;
+    }
+    setQueryResultOpen(true);
+    setQueryResultTaskId(taskId);
+    setQueryResultData(null);
+    setQueryResultLoading(true);
+    try {
+      const res = await queryAiOrderV2Result(taskId);
+      setQueryResultData(res);
+      message.success(t("aiOrderV2.queryResultSuccess"));
+    } catch {
+      message.error(t("aiOrderV2.queryResultFailed"));
+      setQueryResultData(null);
+    } finally {
+      setQueryResultLoading(false);
+    }
+  }
+
+  const QUERY_STATUS_MAP: Record<string, { i18nKey: string; color: string }> = {
+    successes: { i18nKey: "aiOrderV2.queryStatusSuccesses", color: "green" },
+    failed: { i18nKey: "aiOrderV2.queryStatusFailed", color: "red" },
+    running: { i18nKey: "aiOrderV2.queryStatusRunning", color: "blue" },
+    pending: { i18nKey: "aiOrderV2.queryStatusPending", color: "gold" },
+    processing: { i18nKey: "aiOrderV2.queryStatusProcessing", color: "cyan" },
+  };
+
+  function formatTimestamp(ts?: number) {
+    if (!ts) return "—";
+    const d = new Date(ts * 1000);
+    return d.toLocaleString();
+  }
+
   const columns: TableProps<AiOrderV2Item>["columns"] = [
     {
       title: t("aiOrderV2.colOrder"),
@@ -542,18 +583,29 @@ export function AiOrderV2Page() {
     {
       title: t("aiOrderV2.colActions"),
       key: "actions",
-      width: 90,
+      width: 150,
       fixed: "right",
       align: "center",
       render: (_, r) => (
-        <Button
-          type="link"
-          size="small"
-          icon={<EyeOutlined />}
-          onClick={() => handleOpenDetail(r.id)}
-        >
-          {t("aiOrderV2.viewDetail")}
-        </Button>
+        <div className="flex flex-col items-center gap-1">
+          <Button
+            type="link"
+            size="small"
+            icon={<EyeOutlined />}
+            onClick={() => handleOpenDetail(r.id)}
+          >
+            {t("aiOrderV2.viewDetail")}
+          </Button>
+          <Button
+            type="link"
+            size="small"
+            icon={<CloudSyncOutlined />}
+            onClick={() => handleQueryResult(r.taskId)}
+            disabled={!r.taskId}
+          >
+            {t("aiOrderV2.queryResult")}
+          </Button>
+        </div>
       ),
     },
   ];
@@ -802,6 +854,122 @@ export function AiOrderV2Page() {
           <span className="text-gray-300">—</span>
         )}
       </Modal>
+
+      <Modal
+        title={t("aiOrderV2.queryResultTitle", { taskId: queryResultTaskId })}
+        open={queryResultOpen}
+        onCancel={() => setQueryResultOpen(false)}
+        footer={null}
+        width={720}
+      >
+        {queryResultLoading ? (
+          <div className="py-8 text-center text-gray-500">
+            {t("aiOrderV2.queryResultLoading")}
+          </div>
+        ) : queryResultData ? (
+          <div className="space-y-3">
+            <Descriptions bordered size="small" column={2}>
+              <Descriptions.Item label={t("aiOrderV2.queryFieldTaskId")}>
+                {queryResultData.taskId || "—"}
+              </Descriptions.Item>
+              <Descriptions.Item label={t("aiOrderV2.queryFieldStatus")}>
+                {queryResultData.status ? (() => {
+                  const cfg = QUERY_STATUS_MAP[queryResultData.status];
+                  return cfg ? (
+                    <Tag color={cfg.color} style={{ margin: 0 }}>
+                      {t(cfg.i18nKey)}
+                    </Tag>
+                  ) : (
+                    <Tag style={{ margin: 0 }}>{queryResultData.status}</Tag>
+                  );
+                })() : "—"}
+              </Descriptions.Item>
+              <Descriptions.Item label={t("aiOrderV2.queryFieldType")}>
+                {queryResultData.type || "—"}
+              </Descriptions.Item>
+              <Descriptions.Item label={t("aiOrderV2.queryFieldMessage")}>
+                {queryResultData.message || "—"}
+              </Descriptions.Item>
+              <Descriptions.Item label={t("aiOrderV2.queryFieldCreateTime")}>
+                {formatTimestamp(queryResultData.createTime)}
+              </Descriptions.Item>
+              <Descriptions.Item label={t("aiOrderV2.queryFieldUpdateTime")}>
+                {formatTimestamp(queryResultData.updateTime)}
+              </Descriptions.Item>
+            </Descriptions>
+
+            {queryResultData.request && (
+              <Card size="small" title={t("aiOrderV2.queryFieldTemplate")}>
+                <div className="space-y-2">
+                  <div className="text-sm">
+                    <span className="text-gray-500">{t("aiOrderV2.queryFieldTemplate")}:</span>{" "}
+                    <span className="font-medium">{queryResultData.request.template || "—"}</span>
+                  </div>
+                  {queryResultData.request.inputs && (
+                    <div>
+                      <div className="text-sm text-gray-500 mb-1">{t("aiOrderV2.queryFieldInputs")}:</div>
+                      <pre className="m-0 text-xs bg-gray-50 rounded p-2 whitespace-pre-wrap">
+                        {JSON.stringify(queryResultData.request.inputs, null, 2)}
+                      </pre>
+                    </div>
+                  )}
+                </div>
+              </Card>
+            )}
+
+            {queryResultData.data && (
+              <Card size="small" title={t("aiOrderV2.fieldResult")}>
+                {queryResultData.data.images && queryResultData.data.images.length > 0 && (
+                  <div className="mb-3">
+                    <div className="text-sm text-gray-500 mb-2">{t("aiOrderV2.queryFieldImages")}</div>
+                    <Image.PreviewGroup>
+                      <div className="flex flex-wrap gap-2">
+                        {queryResultData.data.images.map((url, idx) => (
+                          <Image
+                            key={`qimg-${idx}`}
+                            src={url}
+                            width={100}
+                            height={100}
+                            style={{ objectFit: "cover", borderRadius: 8 }}
+                          />
+                        ))}
+                      </div>
+                    </Image.PreviewGroup>
+                  </div>
+                )}
+                {queryResultData.data.videos && queryResultData.data.videos.length > 0 && (
+                  <div>
+                    <div className="text-sm text-gray-500 mb-2">{t("aiOrderV2.queryFieldVideos")}</div>
+                    <div className="flex flex-wrap gap-2">
+                      {queryResultData.data.videos.map((url, idx) => (
+                        <a
+                          key={`qvid-${idx}`}
+                          href={url}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="group relative block shrink-0 overflow-hidden rounded-lg bg-black"
+                          style={{ width: 100, height: 100 }}
+                        >
+                          <video src={url} muted preload="metadata" className="h-full w-full object-cover" />
+                          <div className="absolute inset-0 flex items-center justify-center bg-black/30 transition-colors group-hover:bg-black/40">
+                            <PlayCircleOutlined className="text-white" style={{ fontSize: 28 }} />
+                          </div>
+                        </a>
+                      ))}
+                    </div>
+                  </div>
+                )}
+                {(!queryResultData.data.images || queryResultData.data.images.length === 0) &&
+                  (!queryResultData.data.videos || queryResultData.data.videos.length === 0) && (
+                    <span className="text-gray-300">—</span>
+                  )}
+              </Card>
+            )}
+          </div>
+        ) : (
+          <span className="text-gray-300">—</span>
+        )}
+      </Modal>
     </div>
   );
 }