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

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