Merge branch 'feat/encrypt_login' into develop
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { baseRequestClient, requestClient } from '#/api/request';
|
||||
import {baseRequestClient, requestClient} from '#/api/request';
|
||||
import {type CredentialKeyMaterial, encryptCredentialPayload,} from '#/utils/credential-encryption';
|
||||
|
||||
export namespace AuthApi {
|
||||
/** 登录接口参数 */
|
||||
@@ -25,11 +26,27 @@ export namespace AuthApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录加密公钥
|
||||
*/
|
||||
export async function getCredentialKeyApi() {
|
||||
return requestClient.get<CredentialKeyMaterial>(
|
||||
'/api/v1/auth/credential-key',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export async function loginApi(data: AuthApi.LoginParams) {
|
||||
return requestClient.post<AuthApi.LoginResult>('/api/v1/auth/login', data);
|
||||
const encryptedPayload = await encryptCredentialPayload(
|
||||
getCredentialKeyApi,
|
||||
data,
|
||||
);
|
||||
return requestClient.post<AuthApi.LoginResult>(
|
||||
'/api/v1/auth/login',
|
||||
encryptedPayload,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
234
easyflow-ui-admin/app/src/utils/credential-encryption.ts
Normal file
234
easyflow-ui-admin/app/src/utils/credential-encryption.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
interface CredentialKeyMaterial {
|
||||
algorithm: string;
|
||||
expiresAt: string;
|
||||
fingerprint: string;
|
||||
keyId: string;
|
||||
nonce: string;
|
||||
publicKey: string;
|
||||
transitions?: CredentialKeyTransition[];
|
||||
}
|
||||
|
||||
interface CredentialKeyPin {
|
||||
fingerprint: string;
|
||||
keyId: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
interface CredentialKeyTransition {
|
||||
fromKeyId: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
toKeyId: string;
|
||||
}
|
||||
|
||||
interface TransitionPayload {
|
||||
newFingerprint: string;
|
||||
newKeyId: string;
|
||||
newPublicKey: string;
|
||||
oldKeyId: string;
|
||||
}
|
||||
|
||||
const PIN_STORAGE_KEY = 'easyflow:credential-key-pin:v1';
|
||||
const SECURITY_ERROR = '登录安全校验失败,请刷新或联系管理员';
|
||||
const UNSUPPORTED_ERROR = '当前浏览器不支持登录安全加密,请升级浏览器';
|
||||
|
||||
/**
|
||||
* 加密密码类请求载荷。
|
||||
* @param loadMaterial 获取后端公钥材料的方法
|
||||
* @param payload 待加密载荷
|
||||
* @returns 加密信封
|
||||
*/
|
||||
export async function encryptCredentialPayload(
|
||||
loadMaterial: () => Promise<CredentialKeyMaterial>,
|
||||
payload: Record<string, any>,
|
||||
) {
|
||||
ensureWebCrypto();
|
||||
const material = await loadMaterial();
|
||||
await verifyAndPersistPin(material);
|
||||
const aesKey = await window.crypto.subtle.generateKey(
|
||||
{ length: 256, name: 'AES-GCM' },
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
...payload,
|
||||
nonce: material.nonce,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ iv, name: 'AES-GCM' },
|
||||
aesKey,
|
||||
plaintext,
|
||||
);
|
||||
const rawAesKey = await window.crypto.subtle.exportKey('raw', aesKey);
|
||||
const publicKey = await importRsaPublicKey(material.publicKey, ['encrypt']);
|
||||
const encryptedKey = await window.crypto.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
publicKey,
|
||||
rawAesKey,
|
||||
);
|
||||
return {
|
||||
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
|
||||
encryptedKey: bytesToBase64(new Uint8Array(encryptedKey)),
|
||||
iv: bytesToBase64(iv),
|
||||
keyId: material.keyId,
|
||||
nonce: material.nonce,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAndPersistPin(material: CredentialKeyMaterial) {
|
||||
const current = readPin();
|
||||
if (!current) {
|
||||
writePin(material);
|
||||
return;
|
||||
}
|
||||
if (current.fingerprint === material.fingerprint) {
|
||||
writePin(material);
|
||||
return;
|
||||
}
|
||||
const nextPin = await verifyTransitionChain(current, material);
|
||||
if (!nextPin || nextPin.fingerprint !== material.fingerprint) {
|
||||
throw new Error(SECURITY_ERROR);
|
||||
}
|
||||
writePin(material);
|
||||
}
|
||||
|
||||
async function verifyTransitionChain(
|
||||
current: CredentialKeyPin,
|
||||
material: CredentialKeyMaterial,
|
||||
) {
|
||||
let cursor = { ...current };
|
||||
const transitions = material.transitions || [];
|
||||
const visited = new Set<string>();
|
||||
while (cursor.fingerprint !== material.fingerprint) {
|
||||
if (visited.has(cursor.keyId)) {
|
||||
return null;
|
||||
}
|
||||
visited.add(cursor.keyId);
|
||||
const transition = transitions.find((item) => item.fromKeyId === cursor.keyId);
|
||||
if (!transition) {
|
||||
return null;
|
||||
}
|
||||
const payload = decodeTransitionPayload(transition.payload);
|
||||
if (
|
||||
payload.oldKeyId !== cursor.keyId ||
|
||||
payload.newKeyId !== transition.toKeyId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const verified = await verifyTransition(cursor.publicKey, transition);
|
||||
if (!verified) {
|
||||
return null;
|
||||
}
|
||||
cursor = {
|
||||
fingerprint: payload.newFingerprint,
|
||||
keyId: payload.newKeyId,
|
||||
publicKey: payload.newPublicKey,
|
||||
};
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
async function verifyTransition(
|
||||
publicKeyPem: string,
|
||||
transition: CredentialKeyTransition,
|
||||
) {
|
||||
const publicKey = await importSignPublicKey(publicKeyPem);
|
||||
return window.crypto.subtle.verify(
|
||||
{ name: 'RSASSA-PKCS1-v1_5' },
|
||||
publicKey,
|
||||
base64ToBytes(transition.signature),
|
||||
new TextEncoder().encode(transition.payload),
|
||||
);
|
||||
}
|
||||
|
||||
function decodeTransitionPayload(payload: string): TransitionPayload {
|
||||
const json = new TextDecoder().decode(base64UrlToBytes(payload));
|
||||
return JSON.parse(json) as TransitionPayload;
|
||||
}
|
||||
|
||||
async function importRsaPublicKey(publicKeyPem: string, usages: KeyUsage[]) {
|
||||
return window.crypto.subtle.importKey(
|
||||
'spki',
|
||||
pemToBytes(publicKeyPem),
|
||||
{ hash: 'SHA-256', name: 'RSA-OAEP' },
|
||||
false,
|
||||
usages,
|
||||
);
|
||||
}
|
||||
|
||||
async function importSignPublicKey(publicKeyPem: string) {
|
||||
return window.crypto.subtle.importKey(
|
||||
'spki',
|
||||
pemToBytes(publicKeyPem),
|
||||
{ hash: 'SHA-256', name: 'RSASSA-PKCS1-v1_5' },
|
||||
false,
|
||||
['verify'],
|
||||
);
|
||||
}
|
||||
|
||||
function ensureWebCrypto() {
|
||||
if (!window.crypto?.subtle || !window.crypto.getRandomValues) {
|
||||
throw new Error(UNSUPPORTED_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function readPin(): CredentialKeyPin | null {
|
||||
const raw = window.localStorage.getItem(PIN_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as CredentialKeyPin;
|
||||
} catch {
|
||||
window.localStorage.removeItem(PIN_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePin(material: CredentialKeyMaterial) {
|
||||
window.localStorage.setItem(
|
||||
PIN_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
fingerprint: material.fingerprint,
|
||||
keyId: material.keyId,
|
||||
publicKey: material.publicKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function pemToBytes(pem: string) {
|
||||
const base64 = pem
|
||||
.replace('-----BEGIN PUBLIC KEY-----', '')
|
||||
.replace('-----END PUBLIC KEY-----', '')
|
||||
.replace(/\s/g, '');
|
||||
return base64ToBytes(base64);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = window.atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
return base64ToBytes(padded);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = '';
|
||||
bytes.forEach((item) => {
|
||||
binary += String.fromCharCode(item);
|
||||
});
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
export type { CredentialKeyMaterial };
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||
import type {EasyFlowFormSchema} from '#/adapter/form';
|
||||
|
||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
||||
import {computed, markRaw, nextTick, onMounted, ref} from 'vue';
|
||||
|
||||
import { ProfileBaseSetting } from '@easyflow/common-ui';
|
||||
import {ProfileBaseSetting} from '@easyflow/common-ui';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {ElMessage} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import {api} from '#/api/request';
|
||||
import Cropper from '#/components/upload/Cropper.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import {$t} from '#/locales';
|
||||
import {useAuthStore} from '#/store';
|
||||
|
||||
const { fetchUserInfo } = useAuthStore();
|
||||
const profileBaseSettingRef = ref();
|
||||
@@ -48,9 +48,16 @@ onMounted(async () => {
|
||||
});
|
||||
async function getInfo() {
|
||||
loading.value = true;
|
||||
const data = await fetchUserInfo();
|
||||
await profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
loading.value = false;
|
||||
try {
|
||||
const data = await fetchUserInfo();
|
||||
await nextTick();
|
||||
const formApi = profileBaseSettingRef.value?.getFormApi?.();
|
||||
if (formApi) {
|
||||
await formApi.setValues(data);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
const loading = ref(false);
|
||||
const updateLoading = ref(false);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||
import type {EasyFlowFormSchema} from '#/adapter/form';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {computed, ref} from 'vue';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
import {ProfilePasswordSetting, z} from '@easyflow/common-ui';
|
||||
import {preferences} from '@easyflow/preferences';
|
||||
import {useUserStore} from '@easyflow/stores';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {ElMessage} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
import {getCredentialKeyApi} from '#/api';
|
||||
import {api} from '#/api/request';
|
||||
import {$t} from '#/locales';
|
||||
import {useAuthStore} from '#/store';
|
||||
import {encryptCredentialPayload} from '#/utils/credential-encryption';
|
||||
import {isStrongPassword} from '#/utils/password-policy';
|
||||
|
||||
const profilePasswordSettingRef = ref();
|
||||
const authStore = useAuthStore();
|
||||
@@ -87,7 +89,14 @@ const updateLoading = ref(false);
|
||||
async function handleSubmit(values: any) {
|
||||
updateLoading.value = true;
|
||||
try {
|
||||
const res = await api.post('/api/v1/sysAccount/updatePassword', values);
|
||||
const encryptedPayload = await encryptCredentialPayload(
|
||||
getCredentialKeyApi,
|
||||
values,
|
||||
);
|
||||
const res = await api.post(
|
||||
'/api/v1/sysAccount/updatePassword',
|
||||
encryptedPayload,
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.success'));
|
||||
const userInfo = await authStore.fetchUserInfo();
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import type {FormInstance} from 'element-plus';
|
||||
import {ElForm, ElFormItem, ElInput, ElMessage} from 'element-plus';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import {onMounted, ref, watch} from 'vue';
|
||||
|
||||
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
|
||||
import {EasyFlowFormModal, EasyFlowInputPassword} from '@easyflow/common-ui';
|
||||
|
||||
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import {getCredentialKeyApi} from '#/api';
|
||||
import {api} from '#/api/request';
|
||||
import DictSelect from '#/components/dict/DictSelect.vue';
|
||||
// import Cropper from '#/components/upload/Cropper.vue';
|
||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
import {$t} from '#/locales';
|
||||
import {encryptCredentialPayload} from '#/utils/credential-encryption';
|
||||
import {isStrongPassword} from '#/utils/password-policy';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
// vue
|
||||
@@ -107,26 +108,33 @@ function openDialog(row: any) {
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function save() {
|
||||
saveForm.value?.validate((valid) => {
|
||||
saveForm.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
btnLoading.value = true;
|
||||
const { confirmPassword: _confirmPassword, ...payload } = entity.value;
|
||||
api
|
||||
.post(
|
||||
try {
|
||||
const {
|
||||
confirmPassword: _confirmPassword,
|
||||
password,
|
||||
...payload
|
||||
} = entity.value;
|
||||
if (isAdd.value) {
|
||||
payload.passwordCredential = await encryptCredentialPayload(
|
||||
getCredentialKeyApi,
|
||||
{ password },
|
||||
);
|
||||
}
|
||||
const res = await api.post(
|
||||
isAdd.value ? 'api/v1/sysAccount/save' : 'api/v1/sysAccount/update',
|
||||
payload,
|
||||
)
|
||||
.then((res) => {
|
||||
btnLoading.value = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
emit('reload');
|
||||
closeDialog();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btnLoading.value = false;
|
||||
});
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
emit('reload');
|
||||
closeDialog();
|
||||
}
|
||||
} finally {
|
||||
btnLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +74,11 @@ export function useTabbar() {
|
||||
|
||||
// 点击tab,跳转路由
|
||||
const handleClick = (key: string) => {
|
||||
const { fullPath, path } = tabbarStore.getTabByKey(key);
|
||||
const tab = tabbarStore.getTabByKey(key);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
const { fullPath, path } = tab;
|
||||
router.push(fullPath || path);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import type {useSvelteFlow} from '@xyflow/svelte';
|
||||
import {componentName} from './consts';
|
||||
import {store} from './store/stores.svelte';
|
||||
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
||||
import {installTinyflowBrowserCompat} from './utils/compat';
|
||||
import {createTinyflowNodeNormalizer} from './utils/nodeInteraction';
|
||||
|
||||
installTinyflowBrowserCompat();
|
||||
|
||||
type FlowInstance = ReturnType<typeof useSvelteFlow>;
|
||||
|
||||
@@ -102,6 +106,7 @@ export class Tinyflow {
|
||||
|
||||
const currentViewport = flow.getViewport();
|
||||
const currentNodes = flow.getNodes();
|
||||
const normalizeNode = createTinyflowNodeNormalizer(this.options);
|
||||
const currentNodePositions = new Map(
|
||||
currentNodes.map((node) => [node.id, node.position]),
|
||||
);
|
||||
@@ -109,11 +114,12 @@ export class Tinyflow {
|
||||
options?.preserveViewport === true
|
||||
? (data.nodes || currentNodes).map((node) => {
|
||||
const currentPosition = currentNodePositions.get(node.id);
|
||||
return currentPosition
|
||||
const nextNode = currentPosition
|
||||
? { ...node, position: { ...currentPosition } }
|
||||
: node;
|
||||
return normalizeNode(nextNode);
|
||||
})
|
||||
: data.nodes || currentNodes;
|
||||
: (data.nodes || currentNodes).map((node) => normalizeNode(node));
|
||||
store.setNodes(nextNodes);
|
||||
store.setEdges(data.edges || flow.getEdges());
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import {store} from '#store/stores.svelte';
|
||||
import type {TinyflowData, TinyflowOptions} from '#types';
|
||||
import {setContext} from 'svelte';
|
||||
import {createTinyflowNodeNormalizer} from '../utils/nodeInteraction';
|
||||
|
||||
const props = $props<{
|
||||
options: TinyflowOptions,
|
||||
@@ -49,6 +50,7 @@
|
||||
}) as TinyflowOptions;
|
||||
const data = parseData(getOptions().data);
|
||||
const initialViewport = data?.viewport || null;
|
||||
store.setNodeNormalizer(createTinyflowNodeNormalizer(getOptions()));
|
||||
store.init(
|
||||
data?.nodes || [],
|
||||
data?.edges || [],
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div style={rest.style} class="tf-collapse {rest.class}">
|
||||
{#each items as item, index}
|
||||
<div class="tf-collapse-item">
|
||||
<div class="tf-collapse-item-title" role="button" tabindex={index}
|
||||
<div class="tf-collapse-item-title tf-node-drag-handle" role="button" tabindex={index}
|
||||
onclick={() => handlerOnChange(item)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
@@ -112,6 +112,14 @@
|
||||
background: var(--tf-bg-hover);
|
||||
}
|
||||
|
||||
.tf-node-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
:global(.svelte-flow__node.dragging) .tf-node-drag-handle {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tf-collapse-item-title-help::after {
|
||||
content: attr(data-help);
|
||||
position: absolute;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { installTinyflowBrowserCompat } from './utils/compat';
|
||||
|
||||
installTinyflowBrowserCompat();
|
||||
|
||||
export * from './types';
|
||||
export * from './Tinyflow';
|
||||
export * from './components/TinyflowComponent.svelte';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
||||
import type { TinyflowNodeNormalizer } from '../utils/nodeInteraction';
|
||||
|
||||
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
||||
|
||||
@@ -6,20 +7,27 @@ const createStore = () => {
|
||||
let nodesInternal = $state.raw([] as Node[]);
|
||||
let edgesInternal = $state.raw([] as Edge[]);
|
||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
|
||||
|
||||
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
|
||||
|
||||
return {
|
||||
// nodes: nodesInternal,
|
||||
// edges: edgesInternal,
|
||||
// viewport,
|
||||
setNodeNormalizer: (normalizer: TinyflowNodeNormalizer) => {
|
||||
normalizeNode = normalizer;
|
||||
nodesInternal = normalizeNodes(nodesInternal);
|
||||
},
|
||||
init: (nodes: Node[], edges: Edge[], nextViewport?: Viewport | null) => {
|
||||
nodesInternal = nodes;
|
||||
nodesInternal = normalizeNodes(nodes);
|
||||
edgesInternal = edges;
|
||||
viewport = nextViewport ? { ...nextViewport } : { ...DEFAULT_VIEWPORT };
|
||||
},
|
||||
|
||||
getNodes: () => nodesInternal,
|
||||
setNodes: (nodes: Node[]) => {
|
||||
nodesInternal = nodes;
|
||||
nodesInternal = normalizeNodes(nodes);
|
||||
},
|
||||
getEdges: () => edgesInternal,
|
||||
setEdges: (edges: Edge[]) => {
|
||||
@@ -32,18 +40,18 @@ const createStore = () => {
|
||||
|
||||
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
|
||||
addNode: (node: Node) => {
|
||||
nodesInternal = [...nodesInternal, node];
|
||||
nodesInternal = [...nodesInternal, normalizeNode(node)];
|
||||
},
|
||||
removeNode: (id: string) => {
|
||||
nodesInternal = nodesInternal.filter((node) => node.id !== id);
|
||||
},
|
||||
updateNode: (id: string, node: Node) => {
|
||||
nodesInternal = nodesInternal.map((n) =>
|
||||
n.id === id ? { ...n, ...node } : n,
|
||||
n.id === id ? normalizeNode({ ...n, ...node }) : n,
|
||||
);
|
||||
},
|
||||
updateNodes: (update: (nodes: Node[]) => Node[]) => {
|
||||
nodesInternal = update(nodesInternal);
|
||||
nodesInternal = normalizeNodes(update(nodesInternal));
|
||||
},
|
||||
updateNodeData: (id: string, data: Node['data']) => {
|
||||
nodesInternal = nodesInternal.map((n) =>
|
||||
|
||||
22
easyflow-ui-admin/packages/tinyflow-ui/src/utils/compat.ts
Normal file
22
easyflow-ui-admin/packages/tinyflow-ui/src/utils/compat.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
type StructuredCloneFallback = <T>(value: T) => T;
|
||||
|
||||
/**
|
||||
* Installs browser compatibility shims required by Tinyflow.
|
||||
*/
|
||||
export function installTinyflowBrowserCompat() {
|
||||
if (
|
||||
typeof globalThis.structuredClone === 'function' ||
|
||||
typeof JSON === 'undefined'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback: StructuredCloneFallback = (value) =>
|
||||
value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
|
||||
Object.defineProperty(globalThis, 'structuredClone', {
|
||||
configurable: true,
|
||||
value: fallback,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Node } from '@xyflow/svelte';
|
||||
import type { CustomNode, TinyflowOptions } from '../types';
|
||||
|
||||
export const DEFAULT_NODE_DRAG_HANDLE = '.tf-node-drag-handle';
|
||||
|
||||
export type TinyflowNodeNormalizer = (node: Node) => Node;
|
||||
|
||||
function shouldUseDefaultDragHandle(
|
||||
node: Node,
|
||||
customNodes?: Record<string, CustomNode>,
|
||||
) {
|
||||
if (Object.prototype.hasOwnProperty.call(node, 'dragHandle')) {
|
||||
return false;
|
||||
}
|
||||
const customNode = node.type ? customNodes?.[node.type] : undefined;
|
||||
return customNode?.presentation !== 'plain';
|
||||
}
|
||||
|
||||
export function createTinyflowNodeNormalizer(
|
||||
options?: TinyflowOptions,
|
||||
): TinyflowNodeNormalizer {
|
||||
const readonly = options?.readonly === true;
|
||||
const nodesDraggable = options?.nodesDraggable ?? !readonly;
|
||||
const nodesConnectable = options?.nodesConnectable ?? !readonly;
|
||||
const elementsSelectable = options?.elementsSelectable ?? !readonly;
|
||||
|
||||
return (node) => {
|
||||
const nextNode = { ...node };
|
||||
|
||||
if (nodesDraggable) {
|
||||
if (nextNode.draggable === false) {
|
||||
delete nextNode.draggable;
|
||||
}
|
||||
if (shouldUseDefaultDragHandle(nextNode, options?.customNodes)) {
|
||||
nextNode.dragHandle = DEFAULT_NODE_DRAG_HANDLE;
|
||||
}
|
||||
} else {
|
||||
nextNode.draggable = false;
|
||||
}
|
||||
|
||||
if (nodesConnectable) {
|
||||
if (nextNode.connectable === false) {
|
||||
delete nextNode.connectable;
|
||||
}
|
||||
} else {
|
||||
nextNode.connectable = false;
|
||||
}
|
||||
|
||||
if (elementsSelectable) {
|
||||
if (nextNode.selectable === false) {
|
||||
delete nextNode.selectable;
|
||||
}
|
||||
} else {
|
||||
nextNode.selectable = false;
|
||||
}
|
||||
|
||||
return nextNode;
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
target: 'chrome90',
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
cssFileName: 'index',
|
||||
|
||||
@@ -5,9 +5,18 @@
|
||||
<script setup lang="ts">
|
||||
import {Tinyflow as TinyflowNative, TinyflowOptions} from '@tinyflow-ai/ui';
|
||||
import '@tinyflow-ai/ui/dist/index.css';
|
||||
import {nextTick, onMounted, onUnmounted, ref, useAttrs, watch} from 'vue';
|
||||
import {
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
useAttrs,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
|
||||
type StructuredCloneFn = <T>(value: T) => T;
|
||||
|
||||
const props = defineProps<
|
||||
{
|
||||
@@ -18,9 +27,26 @@ const props = defineProps<
|
||||
|
||||
const divRef = ref<HTMLDivElement | null>(null);
|
||||
const attrs = useAttrs();
|
||||
const instance = getCurrentInstance();
|
||||
let tinyflow: TinyflowNative | null = null;
|
||||
let mountedDataReady = false;
|
||||
let lastAppliedDataSignature = '';
|
||||
const optionalBooleanOptionKeys = new Set([
|
||||
'readonly',
|
||||
'hideBottomDock',
|
||||
'hideEdgePanel',
|
||||
'hideMiniMap',
|
||||
'hideNodeHandles',
|
||||
'hideNodeToolbar',
|
||||
'hideNodePicker',
|
||||
'hideNodeSetting',
|
||||
'hideEdgeMarkers',
|
||||
'edgeAnimated',
|
||||
'nodesDraggable',
|
||||
'nodesConnectable',
|
||||
'elementsSelectable',
|
||||
'dropEnabled',
|
||||
]);
|
||||
|
||||
function normalizeOptionKey(key: string) {
|
||||
return key.replace(/-([a-z])/g, (_match: string, letter: string) =>
|
||||
@@ -37,21 +63,49 @@ function normalizeOptions(source: Record<string, unknown>) {
|
||||
);
|
||||
}
|
||||
|
||||
function getProvidedPropKeys() {
|
||||
return new Set(
|
||||
Object.keys(instance?.vnode.props || {}).map((key) => normalizeOptionKey(key)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeProps(source: Record<string, unknown>) {
|
||||
const providedKeys = getProvidedPropKeys();
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).filter(([key, value]) => {
|
||||
if (!optionalBooleanOptionKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
return value !== false || providedKeys.has(key);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 安全深拷贝工具函数
|
||||
function safeDeepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
|
||||
try {
|
||||
return structuredClone(obj);
|
||||
const clone = (globalThis as { structuredClone?: StructuredCloneFn })
|
||||
.structuredClone;
|
||||
if (clone) {
|
||||
return clone(obj);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to JSON cloning for browsers without native structuredClone.
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
} catch {
|
||||
console.warn(
|
||||
'Failed to clone object, returning original (may cause issues)',
|
||||
obj,
|
||||
);
|
||||
return obj;
|
||||
} catch {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +138,7 @@ onMounted(() => {
|
||||
// 净化 props.data,避免响应式对象或函数污染
|
||||
const cleanedProps = {
|
||||
...normalizeOptions(attrs),
|
||||
...props,
|
||||
...normalizeProps(props),
|
||||
} as any;
|
||||
if ('data' in cleanedProps && cleanedProps.data != null) {
|
||||
cleanedProps.data = cloneDataIfChanged(cleanedProps.data);
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
target: 'chrome90',
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
cssFileName: 'index',
|
||||
|
||||
Reference in New Issue
Block a user