Add Invalid Avatar Detection Feature (#1525)

* feat: 完善失效模型检查功能的进度显示

* localization

* feat: improve invalid avatar detection and deletion
This commit is contained in:
laomo
2025-12-09 13:56:06 +08:00
committed by GitHub
parent 25d17198df
commit c5709f8ce5
7 changed files with 361 additions and 29 deletions

View File

@@ -172,7 +172,21 @@
"local_favorites": "Local Favorites (Requires VRC+)",
"new_group": "New Group",
"refresh": "Refresh",
"cancel_refresh": "Cancel Refresh"
"cancel_refresh": "Cancel Refresh",
"check_invalid": "Check Invalid Avatars in This Group",
"check_description": "Detect and remove invalid avatars in this group",
"checking": "Checking invalid avatars...",
"check_progress": "Progress: {current}/{total}",
"check_complete": "Check complete!",
"check_summary": "Checked {total} avatars, found {invalid} invalid, removed {removed}",
"removed_list_header": "Removed avatar list:",
"copy_removed_ids": "Copy Removed Avatar IDs",
"checking_progress": "Checking avatar ({current}/{total})...",
"confirm_delete_invalid": "Delete Invalid Avatars?",
"confirm_delete_description": "Found {count} invalid avatars, delete them?",
"delete_summary": "Successfully deleted {removed} invalid avatars",
"no_invalid_found": "No invalid avatars found",
"delete_cancelled": "Delete operation cancelled"
},
"edit_mode": "Edit Mode",
"copy": "Copy",

View File

@@ -142,7 +142,21 @@
"local_favorites": "ローカルのお気に入り (VRC+が必要)",
"new_group": "グループ作成",
"refresh": "更新",
"cancel_refresh": "キャッシュ削除"
"cancel_refresh": "キャッシュ削除",
"check_invalid": "このグループの無効なアバターをチェック",
"check_description": "このグループ内の無効なアバターを検出して削除",
"checking": "無効なアバターをチェック中...",
"check_progress": "進行状況:{current}/{total}",
"check_complete": "チェック完了!",
"check_summary": "{total}個のアバターをチェックし、{invalid}個の無効なアバターを発見、{removed}個を削除しました",
"removed_list_header": "削除されたアバターリスト:",
"copy_removed_ids": "削除されたアバターIDをコピー",
"checking_progress": "アバター確認中 ({current}/{total})...",
"confirm_delete_invalid": "無効なアバターを削除しますか?",
"confirm_delete_description": "{count}個の無効なアバターが見つかりました。削除しますか?",
"delete_summary": "{removed}個の無効なアバターを正常に削除しました",
"no_invalid_found": "無効なアバターは見つかりませんでした",
"delete_cancelled": "削除操作がキャンセルされました"
},
"edit_mode": "編集モード",
"copy": "コピー",

View File

@@ -97,7 +97,21 @@
"search": "검색",
"vrchat_favorites": "VRChat 즐겨찾기",
"local_favorites": "Local Favorites (Requires VRC+)",
"new_group": "새 그룹"
"new_group": "새 그룹",
"check_invalid": "이 그룹의 유효하지 않은 모델 확인",
"check_description": "이 그룹에서 유효하지 않은 모델을 감지하고 삭제",
"checking": "유효하지 않은 모델 확인 중...",
"check_progress": "확인 진행: {current}/{total}",
"check_complete": "확인 완료!",
"check_summary": "{total}개의 모델을 확인했고, {invalid}개의 유효하지 않은 모델을 발견했으며, {removed}개를 제거했습니다",
"removed_list_header": "제거된 모델 목록:",
"copy_removed_ids": "제거된 모델 ID 복사",
"checking_progress": "모델 확인 중 ({current}/{total})...",
"confirm_delete_invalid": "유효하지 않은 모델을 삭제하시겠습니까?",
"confirm_delete_description": "{count}개의 유효하지 않은 모델을 발견했습니다. 삭제하시겠습니까?",
"delete_summary": "{removed}개의 유효하지 않은 모델을 성공적으로 삭제했습니다",
"no_invalid_found": "유효하지 않은 모델을 찾을 수 없습니다",
"delete_cancelled": "삭제 작업이 취소되었습니다"
},
"bulk_unfavorite_mode": "즐겨찾기 해제 모드",
"bulk_unfavorite_selection": "선택한 즐겨찾기 해제",

View File

@@ -172,7 +172,21 @@
"local_favorites": "本地收藏(需要 VRC+",
"new_group": "创建新的收藏夹",
"refresh": "刷新",
"cancel_refresh": "清除缓存"
"cancel_refresh": "清除缓存",
"check_invalid": "检查这个分组的失效模型",
"check_description": "检测并删除这个分组中的失效模型",
"checking": "正在检查失效模型...",
"check_progress": "检查进度:{current}/{total}",
"checking_progress": "正在检查模型 ({current}/{total})...",
"confirm_delete_invalid": "删除失效模型?",
"confirm_delete_description": "发现 {count} 个失效模型,是否删除它们?",
"check_complete": "检查完成!",
"check_summary": "共检查 {total} 个模型,发现 {invalid} 个失效模型",
"delete_summary": "已成功删除 {removed} 个失效模型",
"removed_list_header": "已删除的模型列表:",
"copy_removed_ids": "复制已删除的模型ID",
"no_invalid_found": "未发现失效模型",
"delete_cancelled": "已取消删除操作"
},
"edit_mode": "编辑模式",
"copy": "复制",

View File

@@ -9,7 +9,7 @@ import {
replaceReactiveObject
} from '../shared/utils';
import { database } from '../service/database';
import { favoriteRequest } from '../api';
import { avatarRequest, favoriteRequest } from '../api';
import { processBulk } from '../service/request';
import { useAppearanceSettingsStore } from './settings/appearance';
import { useAvatarStore } from './avatar';
@@ -1252,6 +1252,97 @@ export const useFavoriteStore = defineStore('Favorite', () => {
}
}
/**
* Check invalid local avatar favorites
* @param {string | null} targetGroup - Target group to check, null for all groups
* @param {Function | null} onProgress - Progress callback function, receives (current, total) parameters
* @returns {Promise<{total: number, invalid: number, invalidIds: string[]}>}
*/
async function checkInvalidLocalAvatars(targetGroup = null, onProgress = null) {
const result = {
total: 0,
invalid: 0,
invalidIds: []
};
const groupsToCheck = targetGroup
? [targetGroup]
: localAvatarFavoriteGroups.value;
for (const group of groupsToCheck) {
const favoriteGroup = localAvatarFavorites[group];
if (favoriteGroup && favoriteGroup.length > 0) {
result.total += favoriteGroup.length;
}
}
let currentIndex = 0;
for (const group of groupsToCheck) {
const favoriteGroup = localAvatarFavorites[group];
if (!favoriteGroup || favoriteGroup.length === 0) {
continue;
}
for (const favorite of favoriteGroup) {
currentIndex++;
if (typeof onProgress === 'function') {
onProgress(currentIndex, result.total);
}
try {
await avatarRequest.getAvatar({
avatarId: favorite.id
});
await new Promise(resolve => setTimeout(resolve, 500));
} catch (err) {
result.invalid++;
result.invalidIds.push(favorite.id);
}
}
}
return result;
}
/**
* Remove invalid avatars from local favorites
* @param {string[]} avatarIds - Array of avatar IDs to remove
* @param {string | null} targetGroup - Target group, null for all groups
* @returns {Promise<{removed: number, removedIds: string[]}>}
*/
async function removeInvalidLocalAvatars(avatarIds, targetGroup = null) {
const result = {
removed: 0,
removedIds: []
};
const groupsToCheck = targetGroup
? [targetGroup]
: localAvatarFavoriteGroups.value;
for (const group of groupsToCheck) {
const favoriteGroup = localAvatarFavorites[group];
if (!favoriteGroup) {
continue;
}
for (const avatarId of avatarIds) {
const index = favoriteGroup.findIndex(fav => fav.id === avatarId);
if (index !== -1) {
removeLocalAvatarFavorite(avatarId, group);
result.removed++;
if (!result.removedIds.includes(avatarId)) {
result.removedIds.push(avatarId);
}
}
}
}
return result;
}
/**
*
* @param {string} newName
@@ -1507,6 +1598,8 @@ export const useFavoriteStore = defineStore('Favorite', () => {
handleFavoriteGroup,
handleFavoriteDelete,
handleFavoriteAdd,
getCachedFavoritesByObjectId
getCachedFavoritesByObjectId,
checkInvalidLocalAvatars,
removeInvalidLocalAvatars
};
});

View File

@@ -240,6 +240,12 @@
@click="handleLocalRename(group)">
<span>{{ t('view.favorite.rename_tooltip') }}</span>
</button>
<button
type="button"
class="favorites-group-menu__item"
@click="handleCheckInvalidAvatars(group)">
<span>{{ t('view.favorite.avatars.check_invalid') }}</span>
</button>
<button
type="button"
class="favorites-group-menu__item favorites-group-menu__item--danger"
@@ -493,9 +499,9 @@
</template>
<script setup>
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, h, nextTick, onBeforeMount, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { Loading, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox, ElNotification, ElProgress } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -544,7 +550,9 @@
localAvatarFavoritesList,
refreshFavorites,
getLocalWorldFavorites,
handleFavoriteGroup
handleFavoriteGroup,
checkInvalidLocalAvatars,
removeInvalidLocalAvatars
} = favoriteStore;
const { avatarHistory } = storeToRefs(useAvatarStore());
const { promptClearAvatarHistory, showAvatarDialog, applyAvatar } = useAvatarStore();
@@ -1074,6 +1082,158 @@
promptLocalAvatarFavoriteGroupDelete(groupName);
}
async function handleCheckInvalidAvatars(groupName) {
handleGroupMenuVisible(localGroupMenuKey(groupName), false);
try {
await ElMessageBox.confirm(
t('view.favorite.avatars.check_description'),
t('view.favorite.avatars.check_invalid'),
{
confirmButtonText: t('confirm.confirm_button'),
cancelButtonText: t('confirm.cancel_button'),
type: 'info'
}
);
} catch {
return;
}
const progressState = reactive({
current: 0,
total: 0,
percentage: 0
});
const ProgressContent = {
setup() {
return () => h('div', { style: 'padding: 4px 0;' }, [
h('p', {
style: 'margin: 0 0 12px 0; font-size: 14px; color: var(--el-text-color-primary);'
}, t('view.favorite.avatars.checking_progress', {
current: progressState.current,
total: progressState.total
})),
h(ElProgress, {
percentage: progressState.percentage,
style: 'margin-top: 8px;'
})
]);
}
};
let progressNotification = null;
try {
progressNotification = ElNotification({
title: t('view.favorite.avatars.checking'),
message: h(ProgressContent),
duration: 0,
type: 'info',
position: 'bottom-right'
});
const result = await checkInvalidLocalAvatars(groupName, (current, total) => {
progressState.current = current;
progressState.total = total;
progressState.percentage = Math.floor((current / total) * 100);
});
if (progressNotification) {
progressNotification.close();
progressNotification = null;
}
if (result.invalid === 0) {
ElNotification({
title: t('view.favorite.avatars.check_complete'),
message: t('view.favorite.avatars.no_invalid_found'),
type: 'success',
duration: 5000,
position: 'bottom-right'
});
return;
}
const confirmDelete = await ElMessageBox.confirm(
h('div', [
h('p', { style: 'margin-bottom: 12px;' },
t('view.favorite.avatars.confirm_delete_description', { count: result.invalid })
),
h('div', { style: 'margin-top: 12px; margin-bottom: 8px; font-weight: 600;' },
t('view.favorite.avatars.removed_list_header')
),
h('div', {
style: 'max-height: 200px; overflow-y: auto; background: var(--el-fill-color-lighter); padding: 8px; border-radius: 4px;'
}, result.invalidIds.map(id =>
h('div', { style: 'font-family: monospace; font-size: 12px; padding: 2px 0;' }, id)
))
]),
t('view.favorite.avatars.confirm_delete_invalid'),
{
confirmButtonText: t('confirm.confirm_button'),
cancelButtonText: t('view.favorite.avatars.copy_removed_ids'),
distinguishCancelAndClose: true,
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'cancel') {
navigator.clipboard.writeText(result.invalidIds.join('\n'))
.then(() => {
ElMessage({
message: t('dialog.user.info.copy_id'),
type: 'success'
});
})
.catch(() => {
ElMessage({
message: 'Failed to copy',
type: 'error'
});
});
return;
}
done();
}
}
).then(() => true).catch(() => false);
if (!confirmDelete) {
ElNotification({
title: t('view.favorite.avatars.check_complete'),
message: t('view.favorite.avatars.delete_cancelled'),
type: 'info',
duration: 5000,
position: 'bottom-right'
});
return;
}
const removeResult = await removeInvalidLocalAvatars(result.invalidIds, groupName);
ElNotification({
title: t('view.favorite.avatars.check_complete'),
message: t('view.favorite.avatars.delete_summary', {
removed: removeResult.removed
}),
type: 'success',
duration: 5000,
position: 'bottom-right'
});
} catch (err) {
if (progressNotification) {
progressNotification.close();
}
console.error(err);
ElNotification({
title: t('message.api_handler.avatar_private_or_deleted'),
message: String(err.message || err),
type: 'error',
duration: 5000,
position: 'bottom-right'
});
}
}
function handleHistoryClear() {
handleGroupMenuVisible(historyGroupMenuKey, false);
promptClearAvatarHistory();