修复客户端资源图标显示
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface ImageWithFallbackProps extends Omit<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
"src"
|
||||
> {
|
||||
src?: string;
|
||||
fallback: React.ReactNode;
|
||||
}
|
||||
|
||||
function ImageWithFallback({
|
||||
src,
|
||||
fallback,
|
||||
onError,
|
||||
...imageProps
|
||||
}: ImageWithFallbackProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
|
||||
if (!src || failed) return <>{fallback}</>;
|
||||
|
||||
return (
|
||||
<img
|
||||
{...imageProps}
|
||||
src={src}
|
||||
onError={(event) => {
|
||||
onError?.(event);
|
||||
setFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageWithFallback;
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
fetchAiAgentComponents,
|
||||
resolveAiAgentContext,
|
||||
} from "../../services/core/aiChat";
|
||||
import ImageWithFallback from "../common/ImageWithFallback";
|
||||
import styles from "../../styles/components/WorkflowMarketplacePage.module.css";
|
||||
|
||||
const PAGE_SIZE = 48;
|
||||
@@ -120,11 +121,17 @@ function formatDateTime(value?: string): string {
|
||||
}
|
||||
|
||||
function AgentIcon({ agent }: { agent: PublishedAgent }) {
|
||||
if (agent.icon) {
|
||||
return <img className={styles.workflowIconImage} src={agent.icon} alt="" />;
|
||||
}
|
||||
return (
|
||||
<span className={styles.workflowIconText}>{getInitial(agent.name)}</span>
|
||||
<ImageWithFallback
|
||||
className={styles.workflowIconImage}
|
||||
src={agent.icon}
|
||||
alt=""
|
||||
fallback={
|
||||
<span className={styles.workflowIconText}>
|
||||
{getInitial(agent.name)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import ImageWithFallback from "../common/ImageWithFallback";
|
||||
import {
|
||||
AiAgentContext,
|
||||
AiManualComponent,
|
||||
@@ -1213,22 +1214,18 @@ export default function KnowledgePage() {
|
||||
|
||||
const renderKnowledgeIcon = (
|
||||
knowledge: KnowledgeComponent | KnowledgeInfo | null,
|
||||
) => {
|
||||
if (knowledge?.icon) {
|
||||
return (
|
||||
<img
|
||||
className={styles.knowledgeIconImage}
|
||||
src={knowledge.icon}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles.knowledgeIconText}>
|
||||
{getInitial(knowledge?.name)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
) => (
|
||||
<ImageWithFallback
|
||||
className={styles.knowledgeIconImage}
|
||||
src={knowledge?.icon}
|
||||
alt=""
|
||||
fallback={
|
||||
<span className={styles.knowledgeIconText}>
|
||||
{getInitial(knowledge?.name)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCreateButton = (variant: "header" | "toolbar" | "empty") => (
|
||||
<Button
|
||||
@@ -1809,7 +1806,11 @@ export default function KnowledgePage() {
|
||||
onClick={() => iconFileInputRef.current?.click()}
|
||||
>
|
||||
<span className={styles.iconPreview}>
|
||||
{iconUrl ? <img src={iconUrl} alt="" /> : <PictureOutlined />}
|
||||
<ImageWithFallback
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
fallback={<PictureOutlined />}
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.iconUploadText}>
|
||||
<strong>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
McpRuntimeConfig,
|
||||
SpaceInfo,
|
||||
} from "../../services/core/marketplace";
|
||||
import ImageWithFallback from "../common/ImageWithFallback";
|
||||
import styles from "../../styles/components/McpMarketplacePage.module.css";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
@@ -97,11 +98,16 @@ function getServerIdFromName(item: LocalMcpItem): string {
|
||||
}
|
||||
|
||||
function McpIcon({ item }: { item: LocalMcpItem }) {
|
||||
if (item.icon) {
|
||||
return <img className={styles.mcpIconImage} src={item.icon} alt="" />;
|
||||
}
|
||||
|
||||
return <span className={styles.mcpIconText}>{getInitial(item.name)}</span>;
|
||||
return (
|
||||
<ImageWithFallback
|
||||
className={styles.mcpIconImage}
|
||||
src={item.icon}
|
||||
alt=""
|
||||
fallback={
|
||||
<span className={styles.mcpIconText}>{getInitial(item.name)}</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isServerEntry(value: unknown): value is McpServerEntry {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PublishedSkillDetail,
|
||||
PublishedSkill,
|
||||
} from "../../services/core/marketplace";
|
||||
import ImageWithFallback from "../common/ImageWithFallback";
|
||||
import styles from "../../styles/components/SkillsMarketplacePage.module.css";
|
||||
|
||||
const PAGE_SIZE = 48;
|
||||
@@ -71,11 +72,16 @@ function getRating(skill: PublishedSkill): string {
|
||||
}
|
||||
|
||||
function SkillIcon({ skill }: { skill: PublishedSkill }) {
|
||||
if (skill.icon) {
|
||||
return <img className={styles.skillIconImage} src={skill.icon} alt="" />;
|
||||
}
|
||||
|
||||
return <span className={styles.skillIconText}>{getInitial(skill.name)}</span>;
|
||||
return (
|
||||
<ImageWithFallback
|
||||
className={styles.skillIconImage}
|
||||
src={skill.icon}
|
||||
alt=""
|
||||
fallback={
|
||||
<span className={styles.skillIconText}>{getInitial(skill.name)}</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getPreviewFile(detail: PublishedSkillDetail | null) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isAiWorkflowChatSelectable,
|
||||
resolveAiAgentContext,
|
||||
} from "../../services/core/aiChat";
|
||||
import ImageWithFallback from "../common/ImageWithFallback";
|
||||
import styles from "../../styles/components/WorkflowMarketplacePage.module.css";
|
||||
|
||||
const PAGE_SIZE = 48;
|
||||
@@ -101,14 +102,17 @@ function getIconColor(workflow: PublishedWorkflow): string {
|
||||
}
|
||||
|
||||
function WorkflowIcon({ workflow }: { workflow: PublishedWorkflow }) {
|
||||
if (workflow.icon) {
|
||||
return (
|
||||
<img className={styles.workflowIconImage} src={workflow.icon} alt="" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.workflowIconText}>{getInitial(workflow.name)}</span>
|
||||
<ImageWithFallback
|
||||
className={styles.workflowIconImage}
|
||||
src={workflow.icon}
|
||||
alt=""
|
||||
fallback={
|
||||
<span className={styles.workflowIconText}>
|
||||
{getInitial(workflow.name)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAssetUrl } from "./assetUrl";
|
||||
|
||||
describe("normalizeAssetUrl", () => {
|
||||
const baseUrl = "http://localhost:18081";
|
||||
|
||||
it("将后端默认图标改写到当前登录站点", () => {
|
||||
expect(
|
||||
normalizeAssetUrl(
|
||||
baseUrl,
|
||||
"https://localhost:18000/api/logo/knowledge/%E9%AA%8C%E8%AF%81",
|
||||
),
|
||||
).toBe("http://localhost:18081/api/logo/knowledge/%E9%AA%8C%E8%AF%81");
|
||||
});
|
||||
|
||||
it("补全普通相对资源地址", () => {
|
||||
expect(normalizeAssetUrl(baseUrl, "uploads/icon.png")).toBe(
|
||||
"http://localhost:18081/uploads/icon.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("保留可访问的绝对地址和内联图片", () => {
|
||||
expect(normalizeAssetUrl(baseUrl, "https://cdn.example.com/icon.png")).toBe(
|
||||
"https://cdn.example.com/icon.png",
|
||||
);
|
||||
expect(normalizeAssetUrl(baseUrl, "data:image/png;base64,abc")).toBe(
|
||||
"data:image/png;base64,abc",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export function normalizeAssetUrl(
|
||||
baseUrl: string,
|
||||
value?: string,
|
||||
): string | undefined {
|
||||
const assetUrl = value?.trim();
|
||||
if (!assetUrl) return undefined;
|
||||
if (/^(data:|blob:)/i.test(assetUrl)) return assetUrl;
|
||||
|
||||
try {
|
||||
const normalizedBaseUrl = `${baseUrl.replace(/\/+$/, "")}/`;
|
||||
const resolvedUrl = new URL(assetUrl, normalizedBaseUrl);
|
||||
|
||||
// 默认图标由当前后端动态生成,不应继续使用租户配置中的旧域名或端口。
|
||||
if (resolvedUrl.pathname.startsWith("/api/logo/")) {
|
||||
return new URL(
|
||||
`${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`,
|
||||
normalizedBaseUrl,
|
||||
).toString();
|
||||
}
|
||||
|
||||
return resolvedUrl.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@shared/constants";
|
||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||
import { normalizeServerHost } from "./auth";
|
||||
import { normalizeAssetUrl } from "./assetUrl";
|
||||
import type { AiManualComponent } from "./aiChat";
|
||||
|
||||
const SUCCESS_CODE = "0000";
|
||||
@@ -202,21 +203,6 @@ async function getAuthToken(domain: string): Promise<string | null> {
|
||||
| null;
|
||||
}
|
||||
|
||||
function normalizeAssetUrl(
|
||||
baseUrl: string,
|
||||
value?: string,
|
||||
): string | undefined {
|
||||
if (!value) return value;
|
||||
if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return new URL(value, baseUrl).toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function automationRequest<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@shared/constants";
|
||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||
import { getCurrentAuth, normalizeServerHost } from "./auth";
|
||||
import { normalizeAssetUrl } from "./assetUrl";
|
||||
|
||||
const SUCCESS_CODE = "0000";
|
||||
const MAX_KNOWLEDGE_FILE_SIZE = 100 * 1024 * 1024;
|
||||
@@ -216,21 +217,6 @@ async function getAuthHeaders(domain: string): Promise<Record<string, string>> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
function normalizeAssetUrl(
|
||||
baseUrl: string,
|
||||
value?: string,
|
||||
): string | undefined {
|
||||
if (!value) return value;
|
||||
if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return new URL(value, baseUrl).toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function knowledgeRequest<T>(
|
||||
path: string,
|
||||
options: KnowledgeRequestOptions = {},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@shared/constants";
|
||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||
import { normalizeServerHost } from "./auth";
|
||||
import { normalizeAssetUrl } from "./assetUrl";
|
||||
|
||||
const SUCCESS_CODE = "0000";
|
||||
|
||||
@@ -283,17 +284,6 @@ export interface UpdateAndEnableMcpConfigParams {
|
||||
configJson?: string;
|
||||
}
|
||||
|
||||
function normalizeAssetUrl(
|
||||
baseUrl: string,
|
||||
value?: string,
|
||||
): string | undefined {
|
||||
if (!value) return value;
|
||||
if (/^(https?:|data:|blob:)/i.test(value)) return value;
|
||||
if (value.startsWith("//")) return `${window.location.protocol}${value}`;
|
||||
if (value.startsWith("/")) return `${baseUrl}${value}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
async function getBaseUrl(): Promise<string> {
|
||||
const step1 = (await window.electronAPI?.settings.get("step1_config")) as {
|
||||
serverHost?: string;
|
||||
|
||||
Reference in New Issue
Block a user