修复客户端资源图标显示

This commit is contained in:
baiyanyun
2026-07-13 17:30:51 +08:00
parent d59bd66dcc
commit c3d6c6dc8e
11 changed files with 157 additions and 79 deletions

View File

@@ -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;

View File

@@ -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>
}
/>
);
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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>
}
/>
);
}

View File

@@ -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",
);
});
});

View File

@@ -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;
}
}

View File

@@ -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 = {},

View File

@@ -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 = {},

View File

@@ -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;