mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 15:05:27 +00:00
feat: custom assets + group rules + perf + docs
This commit is contained in:
@@ -4,6 +4,17 @@
|
||||
当前选择:{{ config.currentItem[config.itemRender.labelField] }}
|
||||
</span>
|
||||
|
||||
<div v-if="config.allowUserAssetUpload" class="user-asset-actions">
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
@change="handleUploadAsset"
|
||||
/>
|
||||
<el-button size="small" type="primary" @click="triggerUpload">上传我的素材</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div v-if="config.searchable !== false" style="display: flex; align-items: center;">
|
||||
<el-input
|
||||
@@ -29,7 +40,7 @@
|
||||
<el-space wrap size="large">
|
||||
<div
|
||||
v-for="item in filteredItems(group)"
|
||||
:key="item[config.itemRender.labelField]"
|
||||
:key="item.id || item[config.itemRender.labelField]"
|
||||
style="display: flex; flex-direction: column; justify-content: center"
|
||||
>
|
||||
<el-button
|
||||
@@ -45,6 +56,15 @@
|
||||
<span style="text-align: center; display: block;">
|
||||
{{ item[config.itemRender.labelField] }}
|
||||
</span>
|
||||
<el-button
|
||||
v-if="item.__userAsset"
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
@click.stop="removeUserAsset(item)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-space>
|
||||
</div>
|
||||
@@ -54,9 +74,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { SelectorConfig, GroupConfig } from '@/types/selector'
|
||||
import { resolveAssetUrl } from '@/utils/assetUrl'
|
||||
import { createCustomAssetFromFile } from '@/utils/customAssets'
|
||||
|
||||
const props = defineProps<{
|
||||
config: SelectorConfig
|
||||
@@ -77,10 +98,20 @@ const searchText = ref('')
|
||||
const activeTab = ref('ALL')
|
||||
const imageSize = computed(() => props.config.itemRender.imageSize || 100)
|
||||
const imageField = computed(() => props.config.itemRender.imageField)
|
||||
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||
const dataSource = ref<any[]>([])
|
||||
|
||||
watch(
|
||||
() => props.config.dataSource,
|
||||
(value) => {
|
||||
dataSource.value = Array.isArray(value) ? [...value] : []
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 过滤逻辑
|
||||
const filteredItems = (group: GroupConfig) => {
|
||||
let items = props.config.dataSource
|
||||
let items = dataSource.value
|
||||
|
||||
// 分组过滤
|
||||
if (group.name !== 'ALL') {
|
||||
@@ -120,9 +151,52 @@ const handleSelect = (item: any) => {
|
||||
}
|
||||
|
||||
const getItemImageUrl = (item: any) => resolveAssetUrl(item?.[imageField.value]) as string
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleUploadAsset = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
const file = target?.files?.[0]
|
||||
if (!file || !props.config.assetLibrary) {
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const createdAsset = await createCustomAssetFromFile(props.config.assetLibrary, file)
|
||||
dataSource.value = [createdAsset, ...dataSource.value]
|
||||
props.config.onUserAssetUploaded?.(createdAsset)
|
||||
} finally {
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeUserAsset = (item: any) => {
|
||||
if (!item?.id) {
|
||||
return
|
||||
}
|
||||
props.config.onDeleteUserAsset?.(item)
|
||||
dataSource.value = dataSource.value.filter((entry) => entry.id !== item.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-asset-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selector-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="groupRuleWarnings.length" class="control-row rule-row">
|
||||
<div class="control-label">规则检查</div>
|
||||
<div class="rule-list">
|
||||
<div v-for="(warning, index) in groupRuleWarnings" :key="`${warning.groupId}-${warning.code}-${index}`" class="rule-item">
|
||||
[{{ warning.groupId }}] {{ warning.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container" ref="containerRef" :style="{ height: '100%' }"></div>
|
||||
</div>
|
||||
@@ -81,6 +89,7 @@ import { useGlobalMessage } from '@/ts/useGlobalMessage';
|
||||
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||
import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle';
|
||||
import { useCanvasSettings } from '@/ts/useCanvasSettings';
|
||||
import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules';
|
||||
|
||||
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
||||
type DistributeType = 'horizontal' | 'vertical';
|
||||
@@ -118,8 +127,10 @@ const { showMessage } = useGlobalMessage();
|
||||
// 当前选中节点
|
||||
const selectedNode = ref<any>(null);
|
||||
const copyBuffer = ref<GraphData | null>(null);
|
||||
const groupRuleWarnings = ref<GroupRuleWarning[]>([]);
|
||||
let nextPasteDistance = COPY_TRANSLATION;
|
||||
let containerResizeObserver: ResizeObserver | null = null;
|
||||
let groupRuleValidationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const resolveResizeHost = () => {
|
||||
const container = containerRef.value;
|
||||
@@ -482,6 +493,7 @@ function groupSelectedNodes(event?: KeyboardEvent) {
|
||||
models.forEach((model) => {
|
||||
updateNodeMeta(model, (meta) => ({ ...meta, groupId }));
|
||||
});
|
||||
scheduleGroupRuleValidation();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -499,6 +511,7 @@ function ungroupSelectedNodes(event?: KeyboardEvent) {
|
||||
return nextMeta;
|
||||
});
|
||||
});
|
||||
scheduleGroupRuleValidation();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -584,6 +597,25 @@ function updateSelectedCount(model?: GraphModel) {
|
||||
selectedCount.value = graphModel?.selectNodes.length ?? 0;
|
||||
}
|
||||
|
||||
function refreshGroupRuleWarnings() {
|
||||
const lfInstance = lf.value;
|
||||
if (!lfInstance) {
|
||||
groupRuleWarnings.value = [];
|
||||
return;
|
||||
}
|
||||
const graphData = lfInstance.getGraphRawData() as GraphData;
|
||||
groupRuleWarnings.value = validateGraphGroupRules(graphData);
|
||||
}
|
||||
|
||||
function scheduleGroupRuleValidation(delay = 120) {
|
||||
if (groupRuleValidationTimer) {
|
||||
clearTimeout(groupRuleValidationTimer);
|
||||
}
|
||||
groupRuleValidationTimer = setTimeout(() => {
|
||||
refreshGroupRuleWarnings();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function applySelectionSelect(enabled: boolean) {
|
||||
const lfInstance = lf.value as any;
|
||||
if (!lfInstance) return;
|
||||
@@ -967,6 +999,7 @@ onMounted(() => {
|
||||
// 标记这个节点是新创建的,避免被 normalizeAllNodes 重置
|
||||
(model as any)._isNewNode = true;
|
||||
}
|
||||
scheduleGroupRuleValidation();
|
||||
});
|
||||
|
||||
// 监听 DND 添加节点事件
|
||||
@@ -978,10 +1011,12 @@ onMounted(() => {
|
||||
// 标记这个节点是新创建的
|
||||
(model as any)._isNewNode = true;
|
||||
}
|
||||
scheduleGroupRuleValidation();
|
||||
});
|
||||
|
||||
lfInstance.on(EventType.GRAPH_RENDERED, () => {
|
||||
normalizeAllNodes();
|
||||
scheduleGroupRuleValidation(0);
|
||||
});
|
||||
|
||||
// 监听节点点击事件,更新选中节点
|
||||
@@ -1008,6 +1043,17 @@ onMounted(() => {
|
||||
}
|
||||
const model = lfInstance.getNodeModelById(nodeId);
|
||||
if (model) normalizeNodeModel(model);
|
||||
scheduleGroupRuleValidation();
|
||||
});
|
||||
|
||||
lfInstance.on(EventType.NODE_DELETE, () => {
|
||||
scheduleGroupRuleValidation();
|
||||
});
|
||||
lfInstance.on(EventType.EDGE_ADD, () => {
|
||||
scheduleGroupRuleValidation();
|
||||
});
|
||||
lfInstance.on(EventType.EDGE_DELETE, () => {
|
||||
scheduleGroupRuleValidation();
|
||||
});
|
||||
|
||||
lfInstance.on('selection:selected', () => updateSelectedCount());
|
||||
@@ -1064,6 +1110,10 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
containerResizeObserver?.disconnect();
|
||||
containerResizeObserver = null;
|
||||
if (groupRuleValidationTimer) {
|
||||
clearTimeout(groupRuleValidationTimer);
|
||||
groupRuleValidationTimer = null;
|
||||
}
|
||||
lf.value?.destroy();
|
||||
lf.value = null;
|
||||
destroyLogicFlowInstance();
|
||||
@@ -1118,6 +1168,23 @@ onBeforeUnmount(() => {
|
||||
.control-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.rule-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 360px;
|
||||
}
|
||||
.rule-item {
|
||||
color: #9a3412;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.control-label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onBeforeUnmount } from 'vue';
|
||||
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
||||
|
||||
const vectorConfig = ref({
|
||||
@@ -14,23 +14,57 @@ const vectorConfig = ref({
|
||||
});
|
||||
|
||||
const nodeSize = ref({ width: 200, height: 200 });
|
||||
let syncRafId: number | null = null;
|
||||
let pendingVectorConfig: Record<string, any> | null = null;
|
||||
let pendingNodeSize: { width: number; height: number } | null = null;
|
||||
|
||||
const flushPendingSync = () => {
|
||||
if (pendingVectorConfig) {
|
||||
Object.assign(vectorConfig.value, pendingVectorConfig);
|
||||
pendingVectorConfig = null;
|
||||
}
|
||||
if (pendingNodeSize) {
|
||||
nodeSize.value = pendingNodeSize;
|
||||
pendingNodeSize = null;
|
||||
}
|
||||
syncRafId = null;
|
||||
};
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (typeof requestAnimationFrame === 'undefined') {
|
||||
flushPendingSync();
|
||||
return;
|
||||
}
|
||||
if (syncRafId !== null) {
|
||||
cancelAnimationFrame(syncRafId);
|
||||
}
|
||||
syncRafId = requestAnimationFrame(flushPendingSync);
|
||||
};
|
||||
|
||||
const { containerStyle } = useNodeAppearance({
|
||||
onPropsChange(props, node) {
|
||||
// 同步矢量配置
|
||||
if (props.vector) {
|
||||
Object.assign(vectorConfig.value, props.vector);
|
||||
pendingVectorConfig = { ...props.vector };
|
||||
}
|
||||
// 同步节点尺寸
|
||||
if (node) {
|
||||
nodeSize.value.width = node.width;
|
||||
nodeSize.value.height = node.height;
|
||||
pendingNodeSize = {
|
||||
width: node.width,
|
||||
height: node.height
|
||||
};
|
||||
}
|
||||
// 使用 requestAnimationFrame 防抖,减少快速缩放时的重复重绘
|
||||
scheduleSync();
|
||||
}
|
||||
});
|
||||
|
||||
// 生成唯一的 pattern ID
|
||||
const patternId = computed(() => `vector-pattern-${Math.random().toString(36).substr(2, 9)}`);
|
||||
onBeforeUnmount(() => {
|
||||
if (syncRafId !== null && typeof cancelAnimationFrame !== 'undefined') {
|
||||
cancelAnimationFrame(syncRafId);
|
||||
syncRafId = null;
|
||||
}
|
||||
});
|
||||
|
||||
const patternId = `vector-pattern-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
// 生成 SVG 内容
|
||||
const svgContent = computed(() => {
|
||||
@@ -64,7 +98,7 @@ const svgContent = computed(() => {
|
||||
<svg width="${nodeSize.value.width}" height="${nodeSize.value.height}"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="${patternId.value}"
|
||||
<pattern id="${patternId}"
|
||||
x="0" y="0"
|
||||
width="${tileWidth}"
|
||||
height="${tileHeight}"
|
||||
@@ -72,7 +106,7 @@ const svgContent = computed(() => {
|
||||
${shapeElement}
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#${patternId.value})" />
|
||||
<rect width="100%" height="100%" fill="url(#${patternId})" />
|
||||
</svg>
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||
import { SELECTOR_PRESETS } from '@/configs/selectorPresets';
|
||||
import type { SelectorConfig } from '@/types/selector';
|
||||
import { resolveAssetUrl, resolveAssetUrlsInDataSource } from '@/utils/assetUrl';
|
||||
import { deleteCustomAsset, listCustomAssets } from '@/utils/customAssets';
|
||||
|
||||
const props = defineProps<{
|
||||
node: any;
|
||||
@@ -40,10 +41,32 @@ const handleOpenSelector = () => {
|
||||
}
|
||||
: selectedAsset;
|
||||
|
||||
const customAssets = listCustomAssets(library);
|
||||
const mergedDataSource = [
|
||||
...(preset.dataSource as any[]),
|
||||
...customAssets
|
||||
];
|
||||
const mergedGroups = [
|
||||
...preset.groups,
|
||||
{ label: '我的素材', name: '__CUSTOM__', filter: (item: any) => !!item?.__userAsset }
|
||||
];
|
||||
|
||||
const config: SelectorConfig = {
|
||||
...preset,
|
||||
dataSource: resolveAssetUrlsInDataSource(preset.dataSource as any[], imageField),
|
||||
currentItem: normalizedSelectedAsset
|
||||
groups: mergedGroups,
|
||||
dataSource: resolveAssetUrlsInDataSource(mergedDataSource, imageField),
|
||||
currentItem: normalizedSelectedAsset,
|
||||
assetLibrary: library,
|
||||
allowUserAssetUpload: true,
|
||||
onDeleteUserAsset: (item: any) => {
|
||||
if (!item?.id) {
|
||||
return;
|
||||
}
|
||||
deleteCustomAsset(library, item.id);
|
||||
},
|
||||
onUserAssetUploaded: () => {
|
||||
// 上传后的数据刷新由选择器内部完成,这里保留扩展钩子。
|
||||
}
|
||||
};
|
||||
|
||||
openGenericSelector(config, (selectedItem) => {
|
||||
|
||||
Reference in New Issue
Block a user