Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion MaiChartManager/Front/src/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ assetDir:
music:
list:
title: Chart List
searchPlaceholder: Search title / composer / charter / alias / ID
searchPlaceholder: Search title / composer / charter / ID
tableTitle: Title
tableCover: Cover
tableVersion: Version
Expand All @@ -71,6 +71,8 @@ music:
filterStandard: Standard
filterDX: DX
filterUtage: Utage
noMatch: No matching songs
clearSearch: Clear search
unknown: Unknown
filterBasic: Basic
filterMaster: Master
Expand All @@ -81,6 +83,7 @@ music:
sortByName: Name
sortByVersion: Release
edit:
selectHint: Select a chart to edit
name: Song Title
artist: Artist
bpm: BPM
Expand Down
5 changes: 4 additions & 1 deletion MaiChartManager/Front/src/locales/zh-TW.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ assetDir:
music:
list:
title: 譜面列表
searchPlaceholder: 搜尋名稱 / 作曲 / 譜師 / 別名 / ID
searchPlaceholder: 搜尋名稱 / 作曲 / 譜師 / ID
tableTitle: 標題
tableCover: 封面
tableVersion: 版本
Expand All @@ -69,6 +69,8 @@ music:
filterStandard: 標準
filterDX: DX
filterUtage: 宴會場
noMatch: 沒有符合的曲目
clearSearch: 清除搜尋
unknown: 未知
filterBasic: 綠
filterMaster: 紫
Expand All @@ -79,6 +81,7 @@ music:
sortByName: 名稱
sortByVersion: 更新時間
edit:
selectHint: 請選擇譜面以編輯
name: 歌曲名稱
artist: 作者
bpm: BPM
Expand Down
5 changes: 4 additions & 1 deletion MaiChartManager/Front/src/locales/zh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ assetDir:
music:
list:
title: 谱面列表
searchPlaceholder: 搜索名称 / 作曲 / 谱师 / 别名 / ID
searchPlaceholder: 搜索名称 / 作曲 / 谱师 / ID
tableTitle: 标题
tableCover: 封面
tableVersion: 版本
Expand All @@ -69,6 +69,8 @@ music:
filterStandard: 标准
filterDX: DX
filterUtage: 宴会场
noMatch: 没有匹配的曲目
clearSearch: 清空搜索
unknown: 未知
filterBasic: 绿
filterMaster: 紫
Expand All @@ -79,6 +81,7 @@ music:
sortByName: 名称
sortByVersion: 更新时间
edit:
selectHint: 请选择谱面以编辑
name: 名称
artist: 作者
bpm: BPM
Expand Down
102 changes: 90 additions & 12 deletions MaiChartManager/Front/src/views/Charts/MusicList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,69 @@
import { computed, defineComponent, ref } from "vue";
import { computed, defineComponent, nextTick, ref } from "vue";
import { MusicXmlWithABJacket } from "@/client/apiGen";
import { Button, Select, theme } from "@munet/ui";
import { Button, Select, TextInput, theme } from "@munet/ui";
import { VList } from 'virtua/vue';
import MusicEntry from "@/views/Charts/MusicList/MusicEntry";
import { assetDirs, musicList, musicSortMode, MusicSortMode, selectedADir, selectMusicId } from "@/store/refs";
import { leftPanel } from "@/views/Charts/refs";
import { matchMusicKeyword } from "@/utils/musicSearch";
import { useI18n } from 'vue-i18n';


export const musicListRef = ref<InstanceType<typeof VList> | null>(null);

export const scrollToMusic = (musicId: number) => {
const idx = musicList.value.findIndex(m => m.id === musicId);
if (idx === -1 || !musicListRef.value) return;
musicListRef.value.scrollToIndex(idx, { align: 'center', smooth: true });
// 左侧列表的搜索词。放在模块作用域,因为跳转逻辑(scrollToMusic)要在组件之外读它。
// 切 A-dir 时刻意不清空:列表上方的搜索框会把当前词显示出来,保留它可以比较不同目录下的同一首。
export const musicSearchKeyword = ref('');
// 过滤、空态、清空按钮都用 trim 后的值,免得只输入空格时三处口径不一致
const keyword = computed(() => musicSearchKeyword.value.trim());

const clearMusicSearch = () => (musicSearchKeyword.value = '');

// 实际渲染的曲目。搜索词为空时就是当前 A-dir 的全量。
// 不能直接过滤 musicList:selectedMusic 由它派生,把列表过滤掉会让右侧编辑面板跟着变空。
export const displayedMusic = computed(() =>
keyword.value ? musicList.value.filter(m => matchMusicKeyword(m, keyword.value)) : musicList.value
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
);

// 连续跳转时只让最后一次生效,避免前一次的等待把列表滚到旧位置
let lastJump = 0;
// 用户手动切到目录管理时,之前那次跳转已经不作数了
const cancelPendingJump = () => { lastJump++; };

export const scrollToMusic = async (musicId: number) => {
const jump = ++lastJump;

// 目标必须在当前 A-dir 里。跨目录不是这里处理的:调用方(批量页跳转)会先把 selectedADir 切到目标所在目录
if (!musicList.value.some(m => m.id === musicId)) return;

// 跳转的含义是「回到曲目列表并看到这首歌」:左侧停在目录管理时也要切回列表
if (leftPanel.value !== 'musicList') leftPanel.value = 'musicList';
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved

// 搜索词把目标挡住了就先清掉,否则 scrollToIndex 找不到它
if (keyword.value && !displayedMusic.value.some(m => m.id === musicId)) {
clearMusicSearch();
}
await nextTick();
if (jump !== lastJump) return;

const list = await waitForMusicList();
if (!list || jump !== lastJump) return;

// 等 VList 挂载的这段时间里列表可能已经变了,下标要按最新数据重算
const idx = displayedMusic.value.findIndex(m => m.id === musicId);
if (idx === -1) return;
list.scrollToIndex(idx, { align: 'center', smooth: true });
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
};

// 从批量操作跳过来时这个页面还在做 0.3s 的过渡,VList 尚未挂载,而调用方只等 100ms。
// 用 setTimeout 轮询而不是 requestAnimationFrame:页面在后台时 rAF 不触发,循环会一直卡住。
// 注意:改 Index.module.sass 的 tab 过渡时长、或 Charts/index.tsx 的左侧面板切换方式时,要回来确认这个超时还够。
const waitForMusicList = async (timeout = 2000) => {
const start = Date.now();
while (!musicListRef.value && Date.now() - start < timeout) {
await new Promise(resolve => setTimeout(resolve, 30));
}
return musicListRef.value;
};

export default defineComponent({
Expand All @@ -33,6 +83,8 @@ export default defineComponent({
return <>{dir.dirName}<span class="op-70">{dir.version ? ` (Ver.${dir.version})` : ''}</span></>;
};

const clearSearch = clearMusicSearch;

return () => (
<div class="flex flex-col gap-3 h-full p-xy">
<div class="flex items-center gap-2">
Expand All @@ -41,7 +93,7 @@ export default defineComponent({
</Button>
<div
class={["grow w-0 flex items-center gap-1 px-3 py-1.5 rounded-12px transition-colors text-left truncate cursor-pointer border-none h-48px", theme.value.listItem, theme.value.listItemHover]}
onClick={() => leftPanel.value = 'assetDirs'}
onClick={() => { cancelPendingJump(); leftPanel.value = 'assetDirs'; }}
>
<span class="truncate">{selectedDirLabel()}</span>
</div>
Expand All @@ -51,11 +103,37 @@ export default defineComponent({
class="w-40! shrink-0"
/>
</div>
<VList ref={musicListRef} class="flex-1 cst" data={musicList.value}>
{({item}: {item: MusicXmlWithABJacket}) => (
<MusicEntry music={item} selected={selectMusicId.value === item.id} onClick={() => selectMusicId.value = item.id!} key={item.id} />
)}
</VList>
<div
class="relative"
onKeydown={(e: KeyboardEvent) => {
// 输入法组词时按 Esc 是取消候选,不该顺手清空搜索框
if (e.isComposing) return;
if (e.key === 'Escape') clearSearch();
}}
>
<TextInput
v-model:value={musicSearchKeyword.value}
placeholder={t('music.list.searchPlaceholder')}
innerClass="min-h-12 cst pr-10"
/>
{keyword.value &&
<button
class="absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 p-0 flex items-center justify-center rounded-full border-none bg-transparent c-gray-4 hover:c-gray-6 cursor-pointer"
title={t('music.list.clearSearch')}
onClick={clearSearch}
>
<span class="i-mdi:close-circle text-lg" />
</button>}
</div>
<div class="relative flex-1 min-h-0">
<VList ref={musicListRef} class="h-full cst" data={displayedMusic.value}>
{({item}: {item: MusicXmlWithABJacket}) => (
<MusicEntry music={item} selected={selectMusicId.value === item.id} onClick={() => selectMusicId.value = item.id!} key={item.id} />
)}
</VList>
{keyword.value && !displayedMusic.value.length &&
<div class="absolute inset-0 flex items-center justify-center c-gray-5 pointer-events-none">{t('music.list.noMatch')}</div>}
</div>
</div>
)
}
Expand Down
33 changes: 20 additions & 13 deletions MaiChartManager/Front/src/views/Charts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,22 @@ export default defineComponent({
const { t } = useI18n();
const mobileShowMenu = ref(false);
const isForward = ref(true);
// A000 是游戏本体目录,只读:按钮保留但禁用,下面用灰字说明原因
const isA000 = computed(() => selectedADir.value === 'A000');
watch(leftPanel, (val) => {
isForward.value = val === 'assetDirs';
});

return () => <div class="grid cols-[40em_1fr] max-[1440px]:cols-1">
// 抽屉布局用 max-[1439px]:抽屉与下面的 min-[1440px]:hidden(汉堡按钮)必须以 1440 为界、
// 不能互相重叠。之前两边都写 1440,窗口宽度正好 1440(例如 1440 宽的屏幕最大化)时
// 左侧既被推出屏幕、汉堡按钮又被隐藏,谱面管理页直接没法用。
return () => <div class="grid cols-[40em_1fr] max-[1439px]:cols-1">
<div class={[
'h-100dvh',
'max-[1440px]:absolute max-[1440px]:left-16 max-[1440px]:w-40em max-[1440px]:max-w-[calc(100dvw-64px)] max-[1440px]:z-10 transition-transform duration-300',
'max-[1440px]:bg-white max-[1440px]:border-r-solid max-[1440px]:border-r-1 max-[1440px]:border-r-gray-200',
'max-[1439px]:absolute max-[1439px]:left-16 max-[1439px]:w-40em max-[1439px]:max-w-[calc(100dvw-64px)] max-[1439px]:z-10 transition-transform duration-300',
'max-[1439px]:bg-white max-[1439px]:border-r-solid max-[1439px]:border-r-1 max-[1439px]:border-r-gray-200',
'max-[767px]:left-0 max-[767px]:max-w-100dvw',
mobileShowMenu.value ? 'max-[1440px]:translate-x-0' : 'max-[1440px]:translate-x-[-100%]',
mobileShowMenu.value ? 'max-[1439px]:translate-x-0' : 'max-[1439px]:translate-x-[-100%]',
]}>
<div class="relative h-full of-hidden">
<Transition
Expand Down Expand Up @@ -64,20 +69,22 @@ export default defineComponent({
<span class="i-ic-baseline-menu text-lg" />
</button>
<div class="grow-1" />
<div class="flex flex-wrap items-center justify-end gap-2 max-[639px]:w-full">
{!!selectedMusic.value && <div class="shrink-0"><CopyToButton /></div>}
{selectedADir.value === 'A000' ? (
t('assetDir.selectNonA000')
) : (
<>
<div class="flex flex-col items-end gap-1 max-[639px]:w-full">
<div class="flex flex-wrap items-center justify-end gap-2 max-[639px]:w-full">
{!!selectedMusic.value && <div class="shrink-0"><CopyToButton /></div>}
{/* A000 是游戏本体目录,不允许编辑:按钮保留但禁用,下面用灰字说明原因 */}
<div class={['flex flex-wrap items-center justify-end gap-2', isA000.value && 'pointer-events-none op-50']}>
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
<div class="flex gap-2 shrink-0"><MusicSelectedTopRightToolbar /></div>
<div class="shrink-0"><ImportCreateChartButton /></div>
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
</>
)}
</div>
</div>
{isA000.value && <div class="text-xs c-gray-5">{t('assetDir.selectNonA000')}</div>}
</div>
</div>
<div class="of-y-auto cst grow-1 px">
<MusicEdit />
{selectedMusic.value
? <MusicEdit />
: <div class="h-full flex items-center justify-center text-2xl op-50 select-none">{t('music.edit.selectHint')}</div>}
</div>
</div>
)}
Expand Down