You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
573 lines
19 KiB
573 lines
19 KiB
<script setup lang="ts">
|
|
import { computed, inject, onMounted, ref, watch } from 'vue';
|
|
|
|
import { name } from '../../package.json';
|
|
import AssetBrowser from './components/AssetBrowser.vue';
|
|
import AssetPreview from './components/AssetPreview.vue';
|
|
import AssetTree from './components/AssetTree.vue';
|
|
import OrganizeTree from './components/OrganizeTree.vue';
|
|
import { keyConfirm, keyMessage } from './provide-inject';
|
|
import {
|
|
applyCatalogResult,
|
|
applyIndexProgress,
|
|
applySelectionResult,
|
|
applyUnusedResult,
|
|
clearSelectionResult,
|
|
panelState,
|
|
} from './store';
|
|
import { countAssetNodes, filterUnusedTreeByExt, parseExtFilter } from '../shared/asset-tree-filter.js';
|
|
|
|
import type { AssetTreeNode } from '../shared/types.js';
|
|
import type { OrganizeMoveItem } from '../shared/organize-move.js';
|
|
|
|
const message = inject(keyMessage)!;
|
|
const confirm = inject(keyConfirm)!;
|
|
const unusedTreeRef = ref<InstanceType<typeof AssetTree>>();
|
|
const organizeLeftRef = ref<InstanceType<typeof OrganizeTree>>();
|
|
const organizeRightRef = ref<InstanceType<typeof OrganizeTree>>();
|
|
|
|
const progressPercent = computed(() => Math.round(panelState.indexProgress * 100));
|
|
const unusedCheckedCount = computed(() => panelState.unusedCheckedUuids.length);
|
|
const filteredUnusedTree = computed(() =>
|
|
filterUnusedTreeByExt(panelState.unusedTree, parseExtFilter(panelState.unusedExtFilter)),
|
|
);
|
|
const filteredUnusedCount = computed(() => countAssetNodes(filteredUnusedTree.value));
|
|
const indexStatusText = computed(() => {
|
|
if (panelState.indexing) {
|
|
return `引用索引构建中 ${progressPercent.value}%`;
|
|
}
|
|
if (!panelState.indexReady) {
|
|
return '引用索引未就绪';
|
|
}
|
|
if (panelState.catalogIndexing) {
|
|
return '引用索引已就绪 · 资源目录构建中';
|
|
}
|
|
if (!panelState.catalogReady) {
|
|
return '引用索引已就绪 · 资源目录未就绪';
|
|
}
|
|
return '索引已就绪';
|
|
});
|
|
|
|
async function loadCatalogTree() {
|
|
const result = await Editor.Message.request(name, 'get-catalog-tree');
|
|
applyCatalogResult(result);
|
|
}
|
|
|
|
async function queryAssetRefs(uuid: string) {
|
|
panelState.queryLoading = true;
|
|
try {
|
|
const result = await Editor.Message.request(name, 'query-selection', uuid);
|
|
applySelectionResult(result);
|
|
} finally {
|
|
panelState.queryLoading = false;
|
|
}
|
|
}
|
|
|
|
async function queryUnused(folderUuid: string | null) {
|
|
panelState.queryLoading = true;
|
|
try {
|
|
const result = await Editor.Message.request(name, 'query-unused', folderUuid);
|
|
applyUnusedResult(result);
|
|
} finally {
|
|
panelState.queryLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadPreview(node: AssetTreeNode) {
|
|
if (node.type !== 'asset' || !node.uuid) {
|
|
panelState.preview = null;
|
|
return;
|
|
}
|
|
panelState.preview = await Editor.Message.request(name, 'get-asset-preview', node.uuid);
|
|
}
|
|
|
|
async function onBrowserSelect(node: AssetTreeNode) {
|
|
panelState.selectedNodeId = node.id;
|
|
|
|
if (node.type === 'folder') {
|
|
let folderUuid = node.uuid ?? null;
|
|
if (!folderUuid && node.url) {
|
|
folderUuid = (await Editor.Message.request('asset-db', 'query-uuid', node.url)) as string | null;
|
|
}
|
|
panelState.selectedFolderUuid = folderUuid;
|
|
panelState.preview = null;
|
|
clearSelectionResult();
|
|
await queryUnused(panelState.selectedFolderUuid);
|
|
return;
|
|
}
|
|
|
|
if (node.type === 'asset' && node.uuid) {
|
|
panelState.selectedFolderUuid = null;
|
|
await loadPreview(node);
|
|
await queryAssetRefs(node.uuid);
|
|
await queryUnused(null);
|
|
}
|
|
}
|
|
|
|
async function refreshAllUI() {
|
|
await loadCatalogTree();
|
|
if (panelState.asset?.uuid) {
|
|
await queryAssetRefs(panelState.asset.uuid);
|
|
}
|
|
await queryUnused(panelState.selectedFolderUuid);
|
|
}
|
|
|
|
async function onOrganizeMove(payload: {
|
|
items: OrganizeMoveItem[];
|
|
targetFolderUrl: string;
|
|
fromSide: 'left' | 'right';
|
|
}) {
|
|
panelState.queryLoading = true;
|
|
try {
|
|
const result = await Editor.Message.request(
|
|
name,
|
|
'move-organize-items',
|
|
payload.items,
|
|
payload.targetFolderUrl,
|
|
);
|
|
organizeLeftRef.value?.clearChecked();
|
|
organizeRightRef.value?.clearChecked();
|
|
await refreshAllUI();
|
|
if (result.moved > 0) {
|
|
message({
|
|
type: 'success',
|
|
message: `已移动 ${result.moved} 项${result.skipped > 0 ? `,跳过 ${result.skipped} 项` : ''}`,
|
|
});
|
|
} else {
|
|
message({ type: 'warning', message: '没有资源被移动' });
|
|
}
|
|
} finally {
|
|
panelState.queryLoading = false;
|
|
}
|
|
}
|
|
|
|
async function refreshIndex() {
|
|
await Editor.Message.request(name, 'rebuild-index');
|
|
await refreshAllUI();
|
|
message({ type: 'success', message: '索引重建完成' });
|
|
}
|
|
|
|
async function onLocateRef(recordId: string, mode: 'incoming' | 'outgoing') {
|
|
if (mode === 'incoming') {
|
|
await Editor.Message.request(name, 'locate-source', recordId);
|
|
} else {
|
|
await Editor.Message.request(name, 'locate-target', recordId);
|
|
}
|
|
}
|
|
|
|
async function onLocateAsset(uuid: string) {
|
|
await Editor.Message.request(name, 'locate-asset-by-uuid', uuid);
|
|
}
|
|
|
|
function onUnusedCheckedChange(uuids: string[]) {
|
|
panelState.unusedCheckedUuids = uuids;
|
|
}
|
|
|
|
function selectAllUnused() {
|
|
unusedTreeRef.value?.checkAll();
|
|
}
|
|
|
|
function clearUnusedSelection() {
|
|
unusedTreeRef.value?.clearChecked();
|
|
}
|
|
|
|
async function batchLocateUnused() {
|
|
if (!panelState.unusedCheckedUuids.length) {
|
|
message({ type: 'warning', message: '请先选择资源' });
|
|
return;
|
|
}
|
|
for (const uuid of panelState.unusedCheckedUuids) {
|
|
await Editor.Message.request(name, 'locate-asset-by-uuid', uuid);
|
|
}
|
|
}
|
|
|
|
async function batchDeleteUnused() {
|
|
if (!panelState.unusedCheckedUuids.length) {
|
|
message({ type: 'warning', message: '请先选择资源' });
|
|
return;
|
|
}
|
|
try {
|
|
await confirm(
|
|
`确定删除选中的 ${panelState.unusedCheckedUuids.length} 个未使用资源?此操作不可撤销。`,
|
|
'批量删除',
|
|
{
|
|
type: 'warning',
|
|
confirmButtonText: '删除',
|
|
cancelButtonText: '取消',
|
|
},
|
|
);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const uuidsToDelete = [...panelState.unusedCheckedUuids];
|
|
const result = await Editor.Message.request(name, 'delete-assets', uuidsToDelete);
|
|
panelState.unusedCheckedUuids = [];
|
|
unusedTreeRef.value?.clearChecked();
|
|
await refreshAllUI();
|
|
if (result.deleted > 0) {
|
|
message({
|
|
type: 'success',
|
|
message: `已删除 ${result.deleted} 个资源${result.failed > 0 ? `,失败 ${result.failed} 个` : ''}`,
|
|
});
|
|
} else {
|
|
message({ type: 'error', message: '删除失败,请查看控制台日志' });
|
|
}
|
|
}
|
|
|
|
async function loadInitialData() {
|
|
const progress = await Editor.Message.request(name, 'get-index-progress');
|
|
applyIndexProgress(progress);
|
|
await loadCatalogTree();
|
|
await queryUnused(null);
|
|
}
|
|
|
|
watch(
|
|
() => panelState.indexReady,
|
|
(ready, prev) => {
|
|
if (ready && !prev) {
|
|
void loadCatalogTree();
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => panelState.catalogReady,
|
|
(ready, prev) => {
|
|
if (ready && !prev) {
|
|
void loadCatalogTree();
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => panelState.unusedExtFilter,
|
|
() => {
|
|
panelState.unusedCheckedUuids = [];
|
|
unusedTreeRef.value?.clearChecked();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => panelState.activeTab,
|
|
(tab) => {
|
|
if (tab !== 'organize') {
|
|
organizeLeftRef.value?.clearChecked();
|
|
organizeRightRef.value?.clearChecked();
|
|
}
|
|
},
|
|
);
|
|
|
|
onMounted(() => {
|
|
void loadInitialData();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="panel-root">
|
|
<header class="header">
|
|
<div class="header-main">
|
|
<h3 class="title">资源管理</h3>
|
|
<span class="status" :class="{ indexing: panelState.indexing || panelState.catalogIndexing }">
|
|
{{ indexStatusText }}
|
|
</span>
|
|
</div>
|
|
<el-button size="small" :loading="panelState.indexing" @click="refreshIndex">重建索引</el-button>
|
|
</header>
|
|
|
|
<el-progress
|
|
v-if="panelState.indexing || panelState.catalogIndexing"
|
|
:percentage="panelState.indexing ? progressPercent : 100"
|
|
:stroke-width="4"
|
|
:show-text="false"
|
|
:indeterminate="!panelState.indexing && panelState.catalogIndexing"
|
|
class="progress"
|
|
/>
|
|
|
|
<div class="workspace">
|
|
<aside class="sidebar">
|
|
<template v-if="panelState.activeTab === 'organize'">
|
|
<OrganizeTree
|
|
ref="organizeLeftRef"
|
|
side="left"
|
|
title="源资源树"
|
|
:data="panelState.catalogTree"
|
|
:loading="panelState.catalogIndexing && !panelState.catalogReady"
|
|
@move="onOrganizeMove"
|
|
@preview-asset="loadPreview"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<div class="sidebar-title">项目资源</div>
|
|
<AssetBrowser
|
|
:data="panelState.catalogTree"
|
|
:selected-node-id="panelState.selectedNodeId"
|
|
:loading="panelState.catalogIndexing && !panelState.catalogReady"
|
|
@select="onBrowserSelect"
|
|
/>
|
|
</template>
|
|
</aside>
|
|
|
|
<main class="content">
|
|
<el-tabs v-model="panelState.activeTab" class="main-tabs">
|
|
<el-tab-pane label="谁用了我" name="incoming">
|
|
<div v-loading="panelState.queryLoading" class="tab-body">
|
|
<section v-if="panelState.asset" class="asset-info">
|
|
<span class="label">当前资源</span>
|
|
<span class="asset-url">{{ panelState.asset.url.replace('db://', '') }}</span>
|
|
<el-tag size="small" type="info">{{ panelState.asset.type }}</el-tag>
|
|
<el-tag size="small" round>{{ panelState.referencedByCount }}</el-tag>
|
|
</section>
|
|
<section v-else class="asset-info empty-asset">
|
|
<span>在左侧资源树中选择一个资源文件</span>
|
|
</section>
|
|
<AssetTree
|
|
fill
|
|
:data="panelState.referencedByTree"
|
|
mode="incoming"
|
|
empty-text="暂无其他资源引用此资源"
|
|
@locate-ref="onLocateRef"
|
|
@locate-asset="onLocateAsset"
|
|
@preview-asset="loadPreview"
|
|
/>
|
|
<AssetPreview compact :preview="panelState.preview" />
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<el-tab-pane label="我使用了哪些资源" name="outgoing">
|
|
<div v-loading="panelState.queryLoading" class="tab-body">
|
|
<section v-if="panelState.asset" class="asset-info">
|
|
<span class="label">当前资源</span>
|
|
<span class="asset-url">{{ panelState.asset.url.replace('db://', '') }}</span>
|
|
<el-tag size="small" type="info">{{ panelState.asset.type }}</el-tag>
|
|
<el-tag size="small" round>{{ panelState.referencesCount }}</el-tag>
|
|
</section>
|
|
<section v-else class="asset-info empty-asset">
|
|
<span>在左侧资源树中选择一个资源文件</span>
|
|
</section>
|
|
<AssetTree
|
|
fill
|
|
:data="panelState.referencesTree"
|
|
mode="outgoing"
|
|
empty-text="此资源未静态引用其他资源"
|
|
@locate-ref="onLocateRef"
|
|
@locate-asset="onLocateAsset"
|
|
@preview-asset="loadPreview"
|
|
/>
|
|
<AssetPreview compact :preview="panelState.preview" />
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<el-tab-pane label="未使用资源" name="unused">
|
|
<div v-loading="panelState.queryLoading" class="tab-body">
|
|
<section class="asset-info">
|
|
<span class="label">检索范围</span>
|
|
<span class="asset-url">{{ panelState.unusedScopeLabel }}</span>
|
|
<el-tag size="small" type="warning">{{ filteredUnusedCount }} / {{ panelState.unusedTotal }} 个</el-tag>
|
|
</section>
|
|
<div class="filter-row">
|
|
<span class="label">排除扩展名</span>
|
|
<el-input
|
|
v-model="panelState.unusedExtFilter"
|
|
size="small"
|
|
clearable
|
|
placeholder=".prefab;.ts;.js;.json"
|
|
/>
|
|
</div>
|
|
<p class="hint">在左侧选择文件夹可限定范围;Shift+点击可连续多选;单击节点在下方预览。</p>
|
|
<div class="batch-bar">
|
|
<el-button size="small" @click="selectAllUnused">全选</el-button>
|
|
<el-button size="small" @click="clearUnusedSelection">取消选择</el-button>
|
|
<el-button size="small" :disabled="!unusedCheckedCount" @click="batchLocateUnused">
|
|
定位选中 ({{ unusedCheckedCount }})
|
|
</el-button>
|
|
<el-button
|
|
size="small"
|
|
type="danger"
|
|
:disabled="!unusedCheckedCount"
|
|
@click="batchDeleteUnused"
|
|
>
|
|
删除选中 ({{ unusedCheckedCount }})
|
|
</el-button>
|
|
</div>
|
|
<AssetTree
|
|
ref="unusedTreeRef"
|
|
fill
|
|
:data="filteredUnusedTree"
|
|
mode="unused"
|
|
empty-text="当前范围内没有未使用的资源"
|
|
@locate-asset="onLocateAsset"
|
|
@checked-change="onUnusedCheckedChange"
|
|
@preview-asset="loadPreview"
|
|
/>
|
|
<AssetPreview compact :preview="panelState.preview" />
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<el-tab-pane label="整理资源" name="organize">
|
|
<div v-loading="panelState.queryLoading" class="tab-body organize-body">
|
|
<p class="hint">
|
|
左侧与右侧均为完整资源树。勾选后拖动到对方树的文件夹上即可移动;支持批量选中、Shift
|
|
连续多选、整文件夹拖动。移动后立即刷新 Cocos 资源管理器与插件索引。
|
|
</p>
|
|
<OrganizeTree
|
|
ref="organizeRightRef"
|
|
side="right"
|
|
title="目标资源树"
|
|
:data="panelState.catalogTree"
|
|
:loading="panelState.catalogIndexing && !panelState.catalogReady"
|
|
@move="onOrganizeMove"
|
|
@preview-asset="loadPreview"
|
|
/>
|
|
<AssetPreview compact :preview="panelState.preview" />
|
|
</div>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.panel-root {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
height: 100%;
|
|
padding: 12px;
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
}
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
.header-main {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
}
|
|
.title {
|
|
margin: 0;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
.status {
|
|
font-size: 12px;
|
|
color: var(--el-color-success);
|
|
}
|
|
.status.indexing {
|
|
color: var(--el-color-warning);
|
|
}
|
|
.progress {
|
|
flex-shrink: 0;
|
|
}
|
|
.workspace {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
gap: 10px;
|
|
overflow: hidden;
|
|
}
|
|
.sidebar {
|
|
width: 280px;
|
|
min-width: 220px;
|
|
max-width: 40%;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
min-height: 0;
|
|
}
|
|
.sidebar-title {
|
|
flex-shrink: 0;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
.content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.main-tabs {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.main-tabs :deep(.el-tabs__header) {
|
|
flex-shrink: 0;
|
|
margin-bottom: 8px;
|
|
}
|
|
.main-tabs :deep(.el-tabs__content) {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
.main-tabs :deep(.el-tab-pane) {
|
|
height: 100%;
|
|
}
|
|
.tab-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
.asset-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
border-radius: 6px;
|
|
background: var(--el-fill-color-light);
|
|
font-size: 12px;
|
|
flex-wrap: wrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.asset-info.empty-asset {
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
.label {
|
|
color: var(--el-text-color-secondary);
|
|
flex-shrink: 0;
|
|
}
|
|
.asset-url {
|
|
font-family: Consolas, monospace;
|
|
word-break: break-all;
|
|
}
|
|
.hint {
|
|
margin: 0;
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
flex-shrink: 0;
|
|
}
|
|
.batch-bar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
.filter-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
.filter-row .el-input {
|
|
flex: 1;
|
|
}
|
|
.organize-body {
|
|
gap: 8px;
|
|
}
|
|
.organize-body .organize-tree {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
</style>
|
|
|