fix: 兼容 Chrome 90 前端运行环境

- 为管理端与用户中心补齐旧浏览器运行时 API 和回归测试

- 增加现代 CSS 与选择器降级,保持现有界面效果

- 固定 Chrome 90 构建目标并补充兼容依赖
This commit is contained in:
2026-07-16 14:50:06 +08:00
parent 705e0faab6
commit 27e50a7624
34 changed files with 793 additions and 60 deletions

View File

@@ -91,6 +91,7 @@ const emit = defineEmits<{
max-width: min(88%, 640px);
padding: 12px 14px;
color: var(--el-text-color-primary);
background: var(--el-bg-color);
background: color-mix(in srgb, var(--el-bg-color) 88%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;

View File

@@ -78,6 +78,7 @@ function handleKeydown(event: Event | KeyboardEvent) {
align-items: flex-end;
padding: 10px;
margin: 0 16px 16px;
background: var(--el-bg-color);
background: color-mix(in srgb, var(--el-bg-color) 92%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;

View File

@@ -121,7 +121,11 @@ const sizeMap = {
<template>
<div
class="tag"
:class="[`tag--${size}`, { 'tag--round': round }, `tag--border-${border}`]"
:class="[
`tag--${size}`,
{ 'tag--closable': closable, 'tag--round': round },
`tag--border-${border}`,
]"
:style="[
tagStyle,
{
@@ -212,15 +216,15 @@ const sizeMap = {
}
/* 为可关闭标签调整内边距 */
.tag:has(.tag__close) {
.tag--closable {
padding-right: 8px;
}
.tag:has(.tag__close).tag--small {
.tag--closable.tag--small {
padding-right: 6px;
}
.tag:has(.tag__close).tag--large {
.tag--closable.tag--large {
padding-right: 12px;
}
</style>

View File

@@ -253,6 +253,7 @@ onMounted(() => {
.tree-select {
width: 100%;
overflow: hidden;
background: var(--el-bg-color);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--el-color-primary-light-9) 32%, var(--el-bg-color))
@@ -330,6 +331,7 @@ onMounted(() => {
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background: var(--el-color-primary-light-8);
background: color-mix(
in srgb,
var(--el-color-primary-light-8) 70%,
@@ -358,6 +360,7 @@ onMounted(() => {
}
:deep(.el-scrollbar__thumb) {
background: var(--el-text-color-placeholder);
background: color-mix(
in srgb,
var(--el-text-color-secondary) 22%,

View File

@@ -1,3 +1,4 @@
/* eslint-disable perfectionist/sort-imports -- 兼容层必须先于所有业务依赖执行。 */
import './polyfills';
import {initPreferences, preferences, updatePreferences,} from '@easyflow/preferences';

View File

@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
installArrayAtPolyfill,
installObjectHasOwnPolyfill,
installPromiseWithResolversPolyfill,
installStringAtPolyfill,
installStructuredClonePolyfill,
installUrlCanParsePolyfill,
} from './polyfills';
const descriptors = new Map<
string,
[object, PropertyKey, PropertyDescriptor | undefined]
>();
function remember(key: string, target: object, property: PropertyKey) {
descriptors.set(key, [
target,
property,
Object.getOwnPropertyDescriptor(target, property),
]);
}
function remove(target: object, property: PropertyKey) {
Reflect.deleteProperty(target, property);
}
beforeEach(() => {
remember('arrayAt', Array.prototype, 'at');
remember('stringAt', String.prototype, 'at');
remember('hasOwn', Object, 'hasOwn');
remember('withResolvers', Promise, 'withResolvers');
remember('structuredClone', globalThis, 'structuredClone');
remember('canParse', URL, 'canParse');
});
afterEach(() => {
for (const [target, property, descriptor] of descriptors.values()) {
if (descriptor) Object.defineProperty(target, property, descriptor);
else remove(target, property);
}
descriptors.clear();
});
describe('chrome 90 browser polyfills', () => {
it('installs Array.prototype.at and String.prototype.at', () => {
remove(Array.prototype, 'at');
remove(String.prototype, 'at');
installArrayAtPolyfill();
installStringAtPolyfill();
expect(['first', 'last'].at(-1)).toBe('last');
expect('EasyFlow'.at(-1)).toBe('w');
expect('EasyFlow'.at(99)).toBeUndefined();
});
it('installs Object.hasOwn without accepting inherited properties', () => {
remove(Object, 'hasOwn');
installObjectHasOwnPolyfill();
const value = Object.create({ inherited: true }) as { own?: boolean };
value.own = true;
expect(Object.hasOwn(value, 'own')).toBe(true);
expect(Object.hasOwn(value, 'inherited')).toBe(false);
});
it('installs Promise.withResolvers and exposes working callbacks', async () => {
remove(Promise, 'withResolvers');
installPromiseWithResolversPolyfill();
// eslint-disable-next-line n/no-unsupported-features/es-syntax -- 测试目标就是验证该 API 的兼容实现。
const { promise, resolve } = Promise.withResolvers<string>();
resolve('ready');
await expect(promise).resolves.toBe('ready');
});
it('installs structuredClone with complex value and cycle support', () => {
remove(globalThis, 'structuredClone');
installStructuredClonePolyfill();
const source: {
createdAt: Date;
lookup: Map<string, number>;
self?: unknown;
} = {
createdAt: new Date('2026-07-15T00:00:00.000Z'),
lookup: new Map([['answer', 2026]]),
};
source.self = source;
const clone = structuredClone(source);
expect(clone).not.toBe(source);
expect(clone.self).toBe(clone);
expect(clone.createdAt).toEqual(source.createdAt);
expect(clone.lookup.get('answer')).toBe(2026);
});
it('installs URL.canParse for absolute and base-relative URLs', () => {
remove(URL, 'canParse');
installUrlCanParsePolyfill();
expect(URL.canParse('https://easyflow.example/chat')).toBe(true);
expect(URL.canParse('/chat', 'https://easyflow.example')).toBe(true);
expect(URL.canParse('/chat')).toBe(false);
});
});

View File

@@ -1,62 +1,241 @@
/* eslint-disable no-extend-native -- 该文件用于集中安装旧浏览器缺失的原生 API。 */
import { deserialize, serialize } from '@ungap/structured-clone';
import 'wicg-inert';
type AtFunction = (index: number) => unknown;
type StructuredCloneFunction = <T>(
value: T,
options?: StructuredSerializeOptions,
) => T;
type WithResolversResult<T> = {
promise: Promise<T>;
reject: (reason?: unknown) => void;
resolve: (value: PromiseLike<T> | T) => void;
};
const objectConstructor = Object as ObjectConstructor & {
hasOwn?: (object: unknown, property: PropertyKey) => boolean;
};
if (typeof objectConstructor.hasOwn !== 'function') {
Object.defineProperty(Object, 'hasOwn', {
configurable: true,
value(object: unknown, property: PropertyKey) {
if (object === null || object === undefined) {
throw new TypeError('Cannot convert undefined or null to object');
}
return Object.prototype.hasOwnProperty.call(Object(object), property);
},
writable: true,
});
}
const arrayPrototype = Array.prototype as unknown[] & {
at?: AtFunction;
toReversed?: () => unknown[];
toSorted?: (compareFn?: (left: unknown, right: unknown) => number) => unknown[];
toSorted?: (
compareFn?: (left: unknown, right: unknown) => number,
) => unknown[];
toSpliced?: (
start: number,
deleteCount?: number,
...items: unknown[]
) => unknown[];
};
const stringPrototype = String.prototype as string & {
at?: AtFunction;
};
const promiseConstructor = Promise as PromiseConstructor & {
withResolvers?: <T>() => WithResolversResult<T>;
};
const urlConstructor = URL as typeof URL & {
canParse?: (url: string | URL, base?: string | URL) => boolean;
};
const browserGlobal = globalThis as typeof globalThis & {
structuredClone?: StructuredCloneFunction;
};
if (typeof arrayPrototype.toReversed !== 'function') {
Object.defineProperty(Array.prototype, 'toReversed', {
configurable: true,
value() {
return Array.prototype.slice.call(this).reverse();
},
writable: true,
});
/**
* 将索引转换为 Array.prototype.at 与 String.prototype.at 使用的整数。
*/
function toIntegerOrInfinity(index: number): number {
const numericIndex = Number(index);
if (Number.isNaN(numericIndex) || numericIndex === 0) return 0;
return numericIndex > 0 ? Math.floor(numericIndex) : Math.ceil(numericIndex);
}
if (typeof arrayPrototype.toSorted !== 'function') {
Object.defineProperty(Array.prototype, 'toSorted', {
configurable: true,
value(compareFn?: (left: unknown, right: unknown) => number) {
return Array.prototype.slice.call(this).sort(compareFn);
},
writable: true,
});
}
/**
* 安装 Object.hasOwn 兼容实现。
*/
export function installObjectHasOwnPolyfill() {
if (typeof objectConstructor.hasOwn === 'function') return;
if (typeof arrayPrototype.toSpliced !== 'function') {
Object.defineProperty(Array.prototype, 'toSpliced', {
Object.defineProperty(Object, 'hasOwn', {
configurable: true,
value(start: number, deleteCount?: number, ...items: unknown[]) {
const copy = Array.prototype.slice.call(this);
if (arguments.length === 1) {
copy.splice(start);
} else {
copy.splice(start, deleteCount as number, ...items);
value(object: unknown, property: PropertyKey) {
if (object === null || object === undefined) {
throw new TypeError('Cannot convert undefined or null to object');
}
return copy;
// eslint-disable-next-line unicorn/new-for-builtins -- Object() 保留 ToObject 语义。
return Object.prototype.hasOwnProperty.call(Object(object), property);
},
writable: true,
});
}
/**
* 安装 Array.prototype.at 兼容实现。
*/
export function installArrayAtPolyfill() {
if (typeof arrayPrototype.at === 'function') return;
Object.defineProperty(Array.prototype, 'at', {
configurable: true,
value(this: ArrayLike<unknown> | null | undefined, index: number): unknown {
if (this === null || this === undefined) {
throw new TypeError('Array.prototype.at called on null or undefined');
}
const target = this as ArrayLike<unknown>;
const numericLength = Number(target.length);
const length =
Number.isNaN(numericLength) || numericLength <= 0
? 0
: Math.min(Math.floor(numericLength), Number.MAX_SAFE_INTEGER);
const relativeIndex = toIntegerOrInfinity(index);
const actualIndex =
relativeIndex >= 0 ? relativeIndex : length + relativeIndex;
if (actualIndex < 0 || actualIndex >= length) return undefined;
return target[actualIndex];
},
writable: true,
});
}
/**
* 安装 String.prototype.at 兼容实现。
*/
export function installStringAtPolyfill() {
if (typeof stringPrototype.at === 'function') return;
Object.defineProperty(String.prototype, 'at', {
configurable: true,
value(this: unknown, index: number): string | undefined {
if (this === null || this === undefined) {
throw new TypeError('String.prototype.at called on null or undefined');
}
const target = String(this);
const relativeIndex = toIntegerOrInfinity(index);
const actualIndex =
relativeIndex >= 0 ? relativeIndex : target.length + relativeIndex;
return actualIndex < 0 || actualIndex >= target.length
? undefined
: target[actualIndex];
},
writable: true,
});
}
/**
* 安装不会修改原数组的数组复制方法。
*/
export function installArrayCopyingPolyfills() {
if (typeof arrayPrototype.toReversed !== 'function') {
Object.defineProperty(Array.prototype, 'toReversed', {
configurable: true,
value() {
return Array.prototype.slice.call(this).reverse();
},
writable: true,
});
}
if (typeof arrayPrototype.toSorted !== 'function') {
Object.defineProperty(Array.prototype, 'toSorted', {
configurable: true,
value(compareFn?: (left: unknown, right: unknown) => number) {
return Array.prototype.slice.call(this).sort(compareFn);
},
writable: true,
});
}
if (typeof arrayPrototype.toSpliced !== 'function') {
Object.defineProperty(Array.prototype, 'toSpliced', {
configurable: true,
value(start: number, deleteCount?: number, ...items: unknown[]) {
const copy = Array.prototype.slice.call(this);
if (arguments.length === 1) {
copy.splice(start);
} else {
copy.splice(start, deleteCount as number, ...items);
}
return copy;
},
writable: true,
});
}
}
/**
* 安装 Promise.withResolvers 兼容实现。
*/
export function installPromiseWithResolversPolyfill() {
// eslint-disable-next-line n/no-unsupported-features/es-syntax -- 此处专门检测并补齐该新 API。
if (typeof promiseConstructor.withResolvers === 'function') return;
Object.defineProperty(Promise, 'withResolvers', {
configurable: true,
value<T>(this: PromiseConstructor): WithResolversResult<T> {
let reject!: (reason?: unknown) => void;
let resolve!: (value: PromiseLike<T> | T) => void;
const promise = new this<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
},
writable: true,
});
}
/**
* 安装可处理循环引用、Map、Set 与日期的 structuredClone 兼容实现。
*/
export function installStructuredClonePolyfill() {
if (typeof browserGlobal.structuredClone === 'function') return;
Object.defineProperty(globalThis, 'structuredClone', {
configurable: true,
value: <T>(value: T) => deserialize(serialize(value)) as T,
writable: true,
});
}
/**
* 安装 URL.canParse 兼容实现。
*/
export function installUrlCanParsePolyfill() {
if (typeof urlConstructor.canParse === 'function') return;
Object.defineProperty(URL, 'canParse', {
configurable: true,
value(url: string | URL, base?: string | URL) {
try {
const parsedUrl =
arguments.length > 1
? new URL(String(url), String(base))
: new URL(String(url));
return parsedUrl instanceof URL;
} catch {
return false;
}
},
writable: true,
});
}
/**
* 在业务模块加载前安装 Chrome 90 缺失的运行时能力。
*/
export function installLegacyBrowserPolyfills() {
installObjectHasOwnPolyfill();
installArrayAtPolyfill();
installStringAtPolyfill();
installArrayCopyingPolyfills();
installPromiseWithResolversPolyfill();
installStructuredClonePolyfill();
installUrlCanParsePolyfill();
}
installLegacyBrowserPolyfills();

View File

@@ -0,0 +1 @@
declare module 'wicg-inert';

View File

@@ -181,6 +181,7 @@ const selectedToolOptions = computed(() => {
width: min(420px, calc(100vw - 320px));
min-height: 0;
overflow: hidden;
background: var(--el-bg-color);
background: color-mix(in srgb, var(--el-bg-color) 94%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;

View File

@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue';
import { ArrowRight } from '@element-plus/icons-vue';
import { useResizeObserver } from '@vueuse/core';
import { ElIcon } from 'element-plus';
const props = defineProps<{
@@ -17,8 +18,17 @@ const emit = defineEmits<{
}>();
const avatarFailed = ref(false);
const welcomeRef = ref<HTMLElement>();
const legacyCompact = ref(false);
const headingId = `agent-welcome-${Math.random().toString(36).slice(2, 9)}`;
const initial = computed(() => [...props.agentName.trim()][0] || 'A');
const needsContainerQueryFallback =
typeof CSS === 'undefined' || !CSS.supports('container-type', 'inline-size');
useResizeObserver(welcomeRef, ([entry]) => {
if (!needsContainerQueryFallback || !entry) return;
legacyCompact.value = entry.contentRect.width <= 560;
});
watch(
() => props.avatar,
@@ -29,7 +39,12 @@ watch(
</script>
<template>
<section class="agent-welcome" :aria-labelledby="headingId">
<section
ref="welcomeRef"
class="agent-welcome"
:class="{ 'is-legacy-compact': legacyCompact }"
:aria-labelledby="headingId"
>
<div class="agent-welcome__content">
<div class="agent-welcome__avatar" aria-hidden="true">
<img
@@ -213,6 +228,15 @@ watch(
}
}
.agent-welcome.is-legacy-compact .agent-welcome__suggestions-grid {
grid-template-columns: 1fr;
gap: var(--space-2);
}
.agent-welcome.is-legacy-compact .agent-welcome__message {
font-size: 16px;
}
@media (prefers-reduced-motion: reduce) {
.agent-welcome__question,
.agent-welcome__question .el-icon {

View File

@@ -378,6 +378,7 @@ onBeforeUnmount(() => {
}
.agent-studio-canvas :deep(.tf-flow-line-path) {
stroke: var(--el-border-color);
stroke: color-mix(
in srgb,
var(--el-color-primary) 30%,

View File

@@ -31,6 +31,7 @@ defineProps<{
.agent-studio-edge-layer__path {
fill: none;
stroke: var(--el-border-color);
stroke: color-mix(
in srgb,
var(--el-color-primary) 30%,

View File

@@ -60,6 +60,7 @@ const iconComponent = computed(() => {
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: var(--el-bg-color);
background: color-mix(in srgb, var(--el-bg-color) 96%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 16px;
@@ -73,6 +74,7 @@ const iconComponent = computed(() => {
.agent-studio-node:hover {
background: var(--el-bg-color);
border-color: var(--el-color-primary-light-7);
border-color: color-mix(
in srgb,
var(--el-color-primary) 26%,
@@ -88,11 +90,13 @@ const iconComponent = computed(() => {
}
.agent-studio-node.is-selected {
background: var(--el-color-primary-light-9);
background: color-mix(
in srgb,
var(--el-color-primary-light-9) 54%,
var(--el-bg-color)
);
border-color: var(--el-color-primary-light-5);
border-color: color-mix(
in srgb,
var(--el-color-primary) 42%,
@@ -132,6 +136,7 @@ const iconComponent = computed(() => {
width: 50px;
height: 50px;
font-size: 28px;
background: var(--el-color-primary-light-8);
background: color-mix(
in srgb,
var(--el-color-primary-light-8) 74%,
@@ -174,6 +179,7 @@ const iconComponent = computed(() => {
font-size: 11px;
line-height: 16px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
background: color-mix(
in srgb,
var(--el-color-primary-light-9) 74%,
@@ -193,4 +199,14 @@ const iconComponent = computed(() => {
color: var(--el-text-color-regular);
-webkit-box-orient: vertical;
}
@supports not (color: color-mix(in srgb, red, blue)) {
.agent-studio-node:hover {
border-color: var(--el-color-primary-light-7);
}
.agent-studio-node.is-selected {
border-color: var(--el-color-primary-light-5);
}
}
</style>

View File

@@ -357,11 +357,13 @@ const getChunkHeaderLabel = (row: any) => {
position: relative;
padding: 20px 20px 18px;
overflow: hidden;
background: var(--el-fill-color-blank);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--el-color-primary-light-9) 80%, white) 0%,
var(--el-fill-color-blank) 38%
);
border: 1px solid var(--el-border-color-light);
border: 1px solid color-mix(in srgb, var(--el-border-color-light) 78%, white);
border-radius: 18px;
box-shadow: 0 18px 40px rgb(15 23 42 / 6%);
@@ -372,6 +374,7 @@ const getChunkHeaderLabel = (row: any) => {
inset: 0 auto 0 0;
width: 4px;
content: '';
background: var(--el-color-primary);
background: linear-gradient(
180deg,
var(--el-color-primary),
@@ -393,7 +396,9 @@ const getChunkHeaderLabel = (row: any) => {
width: 34px;
height: 34px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
background: color-mix(in srgb, var(--el-color-primary-light-9) 66%, white);
border: 1px solid var(--el-color-primary-light-8);
border: 1px solid
color-mix(in srgb, var(--el-color-primary-light-8) 72%, white);
box-shadow: 0 8px 18px rgb(37 99 235 / 10%);
@@ -402,6 +407,7 @@ const getChunkHeaderLabel = (row: any) => {
.chunk-card__action--ghost {
color: var(--el-text-color-secondary);
background: rgb(255 255 255 / 86%);
border-color: var(--el-border-color-light);
border-color: color-mix(in srgb, var(--el-border-color-light) 86%, white);
box-shadow: none;
}
@@ -429,6 +435,7 @@ const getChunkHeaderLabel = (row: any) => {
height: 8px;
border-radius: 999px;
background: var(--el-color-primary);
box-shadow: 0 0 0 4px var(--el-color-primary-light-9);
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--el-color-primary-light-8) 50%, transparent);
}
@@ -594,4 +601,18 @@ const getChunkHeaderLabel = (row: any) => {
font-size: 12px;
}
}
@supports not (color: color-mix(in srgb, red, blue)) {
.chunk-card {
border-color: var(--el-border-color-light);
}
.chunk-card__action {
border-color: var(--el-color-primary-light-8);
}
.chunk-card__action--ghost {
border-color: var(--el-border-color-light);
}
}
</style>

View File

@@ -492,7 +492,9 @@ watch(
font-size: 13px;
line-height: 1.7;
color: var(--el-color-danger-dark-2);
background: var(--el-color-danger-light-9);
background: color-mix(in srgb, var(--el-color-danger-light-9) 88%, white);
border: 1px solid var(--el-color-danger-light-8);
border: 1px solid color-mix(in srgb, var(--el-color-danger) 14%, white);
border-radius: 16px;
}
@@ -554,4 +556,10 @@ watch(
min-height: auto;
}
}
@supports not (color: color-mix(in srgb, red, blue)) {
.workbench__error {
border-color: var(--el-color-danger-light-8);
}
}
</style>

View File

@@ -297,6 +297,7 @@ const isActiveChunk = (chunk: ChunkItem) =>
padding: 15px 16px 14px;
cursor: pointer;
user-select: none;
background: var(--el-fill-color-blank);
background: color-mix(in srgb, var(--el-fill-color-blank) 92%, white);
border: 1px solid rgb(15 23 42 / 7%);
border-radius: 14px;