🚧 Refactor basic services
This commit is contained in:
@@ -1,35 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible && canClose"
|
||||
class="tab-context-menu"
|
||||
:style="{
|
||||
left: position.x + 'px',
|
||||
top: position.y + 'px'
|
||||
}"
|
||||
@click.stop
|
||||
v-if="visible && canClose"
|
||||
v-click-outside="handleClose"
|
||||
class="tab-context-menu"
|
||||
:style="{
|
||||
left: position.x + 'px',
|
||||
top: position.y + 'px'
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="canClose" class="menu-item" @click="handleMenuClick('close')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
<span class="menu-text">{{ t('tabs.contextMenu.closeTab') }}</span>
|
||||
</div>
|
||||
<div v-if="hasOtherTabs" class="menu-item" @click="handleMenuClick('closeOthers')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<path d="M9 9l6 6M15 9l-6 6"/>
|
||||
</svg>
|
||||
<span class="menu-text">{{ t('tabs.contextMenu.closeOthers') }}</span>
|
||||
</div>
|
||||
<div v-if="hasTabsToLeft" class="menu-item" @click="handleMenuClick('closeLeft')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
<path d="M9 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
<span class="menu-text">{{ t('tabs.contextMenu.closeLeft') }}</span>
|
||||
</div>
|
||||
<div v-if="hasTabsToRight" class="menu-item" @click="handleMenuClick('closeRight')">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
<path d="M15 18l6-6-6-6"/>
|
||||
</svg>
|
||||
@@ -39,9 +44,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTabStore } from '@/stores/tabStore';
|
||||
import {computed, onMounted, onUnmounted} from 'vue';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -54,7 +59,7 @@ const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const {t} = useI18n();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
// 计算属性
|
||||
@@ -79,6 +84,9 @@ const hasTabsToLeft = computed(() => {
|
||||
return index > 0;
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
// 处理菜单项点击
|
||||
const handleMenuClick = (action: string) => {
|
||||
if (!props.targetDocumentId) return;
|
||||
@@ -97,34 +105,9 @@ const handleMenuClick = (action: string) => {
|
||||
tabStore.closeTabsToRight(props.targetDocumentId);
|
||||
break;
|
||||
}
|
||||
|
||||
emit('close');
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 处理外部点击
|
||||
const handleClickOutside = (_event: MouseEvent) => {
|
||||
if (props.visible) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理ESC键
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.visible) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -150,12 +133,12 @@ onUnmounted(() => {
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s ease;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
@@ -167,7 +150,7 @@ onUnmounted(() => {
|
||||
height: 12px;
|
||||
color: var(--text-primary);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
|
||||
.menu-item:hover & {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -178,4 +161,4 @@ onUnmounted(() => {
|
||||
font-weight: 400;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<div class="linux-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
|
||||
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
|
||||
<div class="titlebar-content" @dblclick="handleToggleMaximize" @contextmenu.prevent>
|
||||
<div class="titlebar-icon">
|
||||
<img src="/appicon.png" alt="voidraft"/>
|
||||
</div>
|
||||
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
|
||||
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">
|
||||
{{ titleText }}
|
||||
</div>
|
||||
<!-- 标签页容器区域 -->
|
||||
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
|
||||
<TabContainer />
|
||||
<TabContainer/>
|
||||
</div>
|
||||
<!-- 设置页面标题 -->
|
||||
<div v-if="isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
|
||||
@@ -26,7 +28,7 @@
|
||||
|
||||
<button
|
||||
class="titlebar-button maximize-button"
|
||||
@click="toggleMaximize"
|
||||
@click="handleToggleMaximize"
|
||||
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" v-if="!isMaximized">
|
||||
@@ -55,81 +57,43 @@
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {useRoute} from 'vue-router';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import TabContainer from '@/components/tabs/TabContainer.vue';
|
||||
import {useTabStore} from "@/stores/tabStore";
|
||||
import {
|
||||
minimizeWindow,
|
||||
toggleMaximize,
|
||||
closeWindow,
|
||||
getMaximizedState,
|
||||
generateTitleText,
|
||||
generateFullTitleText
|
||||
} from './index';
|
||||
|
||||
const tabStore = useTabStore();
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
const isMaximized = ref(false);
|
||||
const tabStore = useTabStore();
|
||||
const documentStore = useDocumentStore();
|
||||
|
||||
// 判断是否在设置页面
|
||||
const isMaximized = ref(false);
|
||||
const isInSettings = computed(() => route.path.startsWith('/settings'));
|
||||
|
||||
// 计算标题文本
|
||||
const titleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
if (currentDoc) {
|
||||
// 限制文档标题长度,避免标题栏换行
|
||||
const maxTitleLength = 30;
|
||||
const truncatedTitle = currentDoc.title.length > maxTitleLength
|
||||
? currentDoc.title.substring(0, maxTitleLength) + '...'
|
||||
: currentDoc.title;
|
||||
return `voidraft - ${truncatedTitle}`;
|
||||
}
|
||||
return 'voidraft';
|
||||
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
|
||||
return generateTitleText(documentStore.currentDocument?.title);
|
||||
});
|
||||
|
||||
// 计算完整标题文本(用于tooltip)
|
||||
const fullTitleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
|
||||
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
|
||||
return generateFullTitleText(documentStore.currentDocument?.title);
|
||||
});
|
||||
|
||||
const minimizeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Minimise();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaximize = async () => {
|
||||
try {
|
||||
await runtime.Window.ToggleMaximise();
|
||||
await checkMaximizedState();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkMaximizedState = async () => {
|
||||
try {
|
||||
isMaximized.value = await runtime.Window.IsMaximised();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
const handleToggleMaximize = async () => {
|
||||
await toggleMaximize();
|
||||
isMaximized.value = await getMaximizedState();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMaximizedState();
|
||||
isMaximized.value = await getMaximizedState();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -160,7 +124,7 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
min-width: 0; /* 允许内容收缩 */
|
||||
min-width: 0;
|
||||
|
||||
-webkit-context-menu: none;
|
||||
-moz-context-menu: none;
|
||||
@@ -310,4 +274,4 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="macos-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
|
||||
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
|
||||
<button
|
||||
class="titlebar-button close-button"
|
||||
@click="closeWindow"
|
||||
:title="t('titlebar.close')"
|
||||
<button
|
||||
class="titlebar-button close-button"
|
||||
@click="closeWindow"
|
||||
:title="t('titlebar.close')"
|
||||
>
|
||||
<div class="button-icon">
|
||||
<svg width="6" height="6" viewBox="0 0 6 6" v-show="showControlIcons">
|
||||
@@ -12,11 +12,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="titlebar-button minimize-button"
|
||||
@click="minimizeWindow"
|
||||
:title="t('titlebar.minimize')"
|
||||
|
||||
<button
|
||||
class="titlebar-button minimize-button"
|
||||
@click="minimizeWindow"
|
||||
:title="t('titlebar.minimize')"
|
||||
>
|
||||
<div class="button-icon">
|
||||
<svg width="8" height="1" viewBox="0 0 8 1" v-show="showControlIcons">
|
||||
@@ -24,11 +24,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="titlebar-button maximize-button"
|
||||
@click="toggleMaximize"
|
||||
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
|
||||
|
||||
<button
|
||||
class="titlebar-button maximize-button"
|
||||
@click="handleToggleMaximize"
|
||||
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
|
||||
>
|
||||
<div class="button-icon">
|
||||
<svg width="6" height="6" viewBox="0 0 6 6" v-show="showControlIcons && !isMaximized">
|
||||
@@ -42,98 +42,61 @@
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 标签页容器区域 -->
|
||||
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
|
||||
<TabContainer />
|
||||
<TabContainer/>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent v-if="!tabStore.isTabsEnabled || isInSettings">
|
||||
|
||||
<div class="titlebar-content" @dblclick="handleToggleMaximize" @contextmenu.prevent
|
||||
v-if="!tabStore.isTabsEnabled || isInSettings">
|
||||
<div class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
import { useDocumentStore } from '@/stores/documentStore';
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {useRoute} from 'vue-router';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import TabContainer from '@/components/tabs/TabContainer.vue';
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import {
|
||||
minimizeWindow,
|
||||
toggleMaximize,
|
||||
closeWindow,
|
||||
getMaximizedState,
|
||||
generateTitleText,
|
||||
generateFullTitleText
|
||||
} from './index';
|
||||
|
||||
const tabStore = useTabStore();
|
||||
const { t } = useI18n();
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
const isMaximized = ref(false);
|
||||
const showControlIcons = ref(false);
|
||||
const tabStore = useTabStore();
|
||||
const documentStore = useDocumentStore();
|
||||
|
||||
// 判断是否在设置页面
|
||||
const isMaximized = ref(false);
|
||||
const showControlIcons = ref(false);
|
||||
const isInSettings = computed(() => route.path.startsWith('/settings'));
|
||||
|
||||
const minimizeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Minimise();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaximize = async () => {
|
||||
try {
|
||||
await runtime.Window.ToggleMaximise();
|
||||
await checkMaximizedState();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkMaximizedState = async () => {
|
||||
try {
|
||||
isMaximized.value = await runtime.Window.IsMaximised();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算标题文本
|
||||
const titleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
if (currentDoc) {
|
||||
// 限制文档标题长度,避免标题栏换行
|
||||
const maxTitleLength = 30;
|
||||
const truncatedTitle = currentDoc.title.length > maxTitleLength
|
||||
? currentDoc.title.substring(0, maxTitleLength) + '...'
|
||||
: currentDoc.title;
|
||||
return `voidraft - ${truncatedTitle}`;
|
||||
}
|
||||
return 'voidraft';
|
||||
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
|
||||
return generateTitleText(documentStore.currentDocument?.title);
|
||||
});
|
||||
|
||||
// 计算完整标题文本(用于tooltip)
|
||||
const fullTitleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
|
||||
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
|
||||
return generateFullTitleText(documentStore.currentDocument?.title);
|
||||
});
|
||||
|
||||
const handleToggleMaximize = async () => {
|
||||
await toggleMaximize();
|
||||
isMaximized.value = await getMaximizedState();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMaximizedState();
|
||||
isMaximized.value = await getMaximizedState();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -147,11 +110,11 @@ onMounted(async () => {
|
||||
-webkit-user-select: none;
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
|
||||
|
||||
|
||||
-webkit-context-menu: none;
|
||||
-moz-context-menu: none;
|
||||
context-menu: none;
|
||||
|
||||
|
||||
&:hover {
|
||||
.titlebar-button {
|
||||
.button-icon {
|
||||
@@ -168,7 +131,7 @@ onMounted(async () => {
|
||||
padding-left: 8px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
-webkit-context-menu: none;
|
||||
-moz-context-menu: none;
|
||||
context-menu: none;
|
||||
@@ -187,7 +150,7 @@ onMounted(async () => {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
||||
|
||||
.button-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -198,7 +161,7 @@ onMounted(async () => {
|
||||
height: 100%;
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
|
||||
&:hover .button-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -206,11 +169,11 @@ onMounted(async () => {
|
||||
|
||||
.close-button {
|
||||
background: #ff5f57;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #ff453a;
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
background: #d7463f;
|
||||
}
|
||||
@@ -218,11 +181,11 @@ onMounted(async () => {
|
||||
|
||||
.minimize-button {
|
||||
background: #ffbd2e;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #ffb524;
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
background: #e6a220;
|
||||
}
|
||||
@@ -230,11 +193,11 @@ onMounted(async () => {
|
||||
|
||||
.maximize-button {
|
||||
background: #28ca42;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #1ebe36;
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
background: #1ba932;
|
||||
}
|
||||
@@ -247,7 +210,7 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
cursor: default;
|
||||
min-width: 0;
|
||||
|
||||
|
||||
-webkit-context-menu: none;
|
||||
-moz-context-menu: none;
|
||||
context-menu: none;
|
||||
@@ -261,34 +224,32 @@ onMounted(async () => {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
min-width: 0;
|
||||
overflow: visible; /* 允许TabContainer内部处理滚动 */
|
||||
|
||||
/* 确保TabContainer能够正确处理滚动 */
|
||||
overflow: visible;
|
||||
|
||||
:deep(.tab-container) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
:deep(.tab-bar) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
:deep(.tab-scroll-wrapper) {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保底部线条能够正确显示 */
|
||||
|
||||
:deep(.tab-item) {
|
||||
position: relative;
|
||||
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -319,13 +280,13 @@ onMounted(async () => {
|
||||
background: var(--toolbar-bg, #2d2d2d);
|
||||
border-bottom-color: var(--toolbar-border, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
|
||||
.titlebar-title {
|
||||
color: var(--toolbar-text, #fff);
|
||||
}
|
||||
|
||||
|
||||
.titlebar-button .button-icon {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<div class="windows-titlebar" style="--wails-draggable:drag">
|
||||
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
|
||||
<div class="titlebar-content" @dblclick="handleToggleMaximize" @contextmenu.prevent>
|
||||
<div class="titlebar-icon">
|
||||
<img src="/appicon.png" alt="voidraft"/>
|
||||
</div>
|
||||
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
|
||||
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">
|
||||
{{ titleText }}
|
||||
</div>
|
||||
<!-- 标签页容器区域 -->
|
||||
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
|
||||
<TabContainer />
|
||||
<TabContainer/>
|
||||
</div>
|
||||
<!-- 设置页面标题 -->
|
||||
<div v-if="isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
<button
|
||||
class="titlebar-button maximize-button"
|
||||
@click="toggleMaximize"
|
||||
@click="handleToggleMaximize"
|
||||
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
|
||||
>
|
||||
<span class="titlebar-icon" v-html="maximizeIcon"></span>
|
||||
@@ -45,84 +47,44 @@
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {useRoute} from 'vue-router';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import TabContainer from '@/components/tabs/TabContainer.vue';
|
||||
import {useTabStore} from "@/stores/tabStore";
|
||||
import {
|
||||
minimizeWindow,
|
||||
toggleMaximize,
|
||||
closeWindow,
|
||||
getMaximizedState,
|
||||
generateTitleText,
|
||||
generateFullTitleText
|
||||
} from './index';
|
||||
|
||||
const tabStore = useTabStore();
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
const isMaximized = ref(false);
|
||||
const tabStore = useTabStore();
|
||||
const documentStore = useDocumentStore();
|
||||
|
||||
// 计算属性用于图标,减少重复渲染
|
||||
const isMaximized = ref(false);
|
||||
const maximizeIcon = computed(() => isMaximized.value ? '' : '');
|
||||
|
||||
// 判断是否在设置页面
|
||||
const isInSettings = computed(() => route.path.startsWith('/settings'));
|
||||
|
||||
// 计算标题文本
|
||||
const titleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
if (currentDoc) {
|
||||
// 限制文档标题长度,避免标题栏换行
|
||||
const maxTitleLength = 30;
|
||||
const truncatedTitle = currentDoc.title.length > maxTitleLength
|
||||
? currentDoc.title.substring(0, maxTitleLength) + '...'
|
||||
: currentDoc.title;
|
||||
return `voidraft - ${truncatedTitle}`;
|
||||
}
|
||||
return 'voidraft';
|
||||
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
|
||||
return generateTitleText(documentStore.currentDocument?.title);
|
||||
});
|
||||
|
||||
// 计算完整标题文本(用于tooltip)
|
||||
const fullTitleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
|
||||
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
|
||||
return generateFullTitleText(documentStore.currentDocument?.title);
|
||||
});
|
||||
|
||||
const minimizeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Minimise();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaximize = async () => {
|
||||
try {
|
||||
await runtime.Window.ToggleMaximise();
|
||||
await checkMaximizedState();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkMaximizedState = async () => {
|
||||
try {
|
||||
isMaximized.value = await runtime.Window.IsMaximised();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
const handleToggleMaximize = async () => {
|
||||
await toggleMaximize();
|
||||
isMaximized.value = await getMaximizedState();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMaximizedState();
|
||||
isMaximized.value = await getMaximizedState();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -152,7 +114,7 @@ onMounted(async () => {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
cursor: default;
|
||||
min-width: 0; /* 允许内容收缩 */
|
||||
min-width: 0;
|
||||
|
||||
-webkit-context-menu: none;
|
||||
-moz-context-menu: none;
|
||||
@@ -178,7 +140,6 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
min-width: 0;
|
||||
//margin-right: 8px;
|
||||
}
|
||||
|
||||
.titlebar-controls {
|
||||
@@ -254,4 +215,4 @@ onMounted(async () => {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
60
frontend/src/components/titlebar/index.ts
Normal file
60
frontend/src/components/titlebar/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
/**
|
||||
* Titlebar utility functions
|
||||
*/
|
||||
|
||||
// Window control functions
|
||||
export const minimizeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Minimise();
|
||||
} catch (error) {
|
||||
console.error('Failed to minimize window:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleMaximize = async () => {
|
||||
try {
|
||||
await runtime.Window.ToggleMaximise();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle maximize:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const closeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Close();
|
||||
} catch (error) {
|
||||
console.error('Failed to close window:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getMaximizedState = async (): Promise<boolean> => {
|
||||
try {
|
||||
return await runtime.Window.IsMaximised();
|
||||
} catch (error) {
|
||||
console.error('Failed to check maximized state:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate title text with optional truncation
|
||||
*/
|
||||
export const generateTitleText = (
|
||||
title: string | undefined,
|
||||
maxLength: number = 30
|
||||
): string => {
|
||||
if (!title) return 'voidraft';
|
||||
const truncated = title.length > maxLength
|
||||
? title.substring(0, maxLength) + '...'
|
||||
: title;
|
||||
return `voidraft - ${truncated}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate full title text (no truncation)
|
||||
*/
|
||||
export const generateFullTitleText = (title: string | undefined): string => {
|
||||
return title ? `voidraft - ${title}` : 'voidraft';
|
||||
};
|
||||
@@ -1,49 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useDocumentStore } from '@/stores/documentStore';
|
||||
import { useTabStore } from '@/stores/tabStore';
|
||||
import { useWindowStore } from '@/stores/windowStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { Document } from '@/../bindings/voidraft/internal/models/models';
|
||||
import {computed, nextTick, reactive, ref, watch} from 'vue';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import {useWindowStore} from '@/stores/windowStore';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {useConfirm} from '@/composables';
|
||||
import {validateDocumentTitle} from '@/common/utils/validation';
|
||||
import {formatDateTime, truncateString} from '@/common/utils/formatter';
|
||||
import type {Document} from '@/../bindings/voidraft/internal/models/ent/models';
|
||||
|
||||
// 类型定义
|
||||
interface DocumentItem extends Document {
|
||||
isCreateOption?: boolean;
|
||||
}
|
||||
|
||||
const documentStore = useDocumentStore();
|
||||
const tabStore = useTabStore();
|
||||
const windowStore = useWindowStore();
|
||||
const { t } = useI18n();
|
||||
const {t} = useI18n();
|
||||
|
||||
// DOM 引用
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const editInputRef = ref<HTMLInputElement>();
|
||||
|
||||
// 组件状态
|
||||
const inputValue = ref('');
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const editingId = ref<number | null>(null);
|
||||
const editingTitle = ref('');
|
||||
const editInputRef = ref<HTMLInputElement>();
|
||||
const deleteConfirmId = ref<number | null>(null);
|
||||
const state = reactive({
|
||||
isLoaded: false,
|
||||
searchQuery: '',
|
||||
editing: {
|
||||
id: null as number | null,
|
||||
title: ''
|
||||
}
|
||||
});
|
||||
|
||||
// 常量
|
||||
const MAX_TITLE_LENGTH = 50;
|
||||
const DELETE_CONFIRM_TIMEOUT = 3000;
|
||||
|
||||
// 计算属性
|
||||
const currentDocName = computed(() => {
|
||||
if (!documentStore.currentDocument) return t('toolbar.selectDocument');
|
||||
const title = documentStore.currentDocument.title;
|
||||
return title.length > 12 ? title.substring(0, 12) + '...' : title;
|
||||
return truncateString(documentStore.currentDocument.title || '', 12);
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const filteredItems = computed<DocumentItem[]>(() => {
|
||||
const docs = documentStore.documentList;
|
||||
const query = inputValue.value.trim();
|
||||
const query = state.searchQuery.trim();
|
||||
|
||||
if (!query) return docs;
|
||||
|
||||
const filtered = docs.filter(doc =>
|
||||
doc.title.toLowerCase().includes(query.toLowerCase())
|
||||
(doc.title || '').toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
// 如果输入的不是已存在文档的完整标题,添加创建选项
|
||||
const exactMatch = docs.some(doc => doc.title.toLowerCase() === query.toLowerCase());
|
||||
const exactMatch = docs.some(doc => (doc.title || '').toLowerCase() === query.toLowerCase());
|
||||
if (!exactMatch && query.length > 0) {
|
||||
return [
|
||||
{ id: -1, title: t('toolbar.createDocument') + ` "${query}"`, isCreateOption: true } as any,
|
||||
{id: -1, title: t('toolbar.createDocument') + ` "${query}"`, isCreateOption: true} as DocumentItem,
|
||||
...filtered
|
||||
];
|
||||
}
|
||||
@@ -51,53 +65,32 @@ const filteredItems = computed(() => {
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// 工具函数
|
||||
const validateTitle = (title: string): string | null => {
|
||||
if (!title.trim()) return t('toolbar.documentNameRequired');
|
||||
if (title.trim().length > MAX_TITLE_LENGTH) {
|
||||
return t('toolbar.documentNameTooLong', { max: MAX_TITLE_LENGTH });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string | null) => {
|
||||
if (!dateString) return t('toolbar.unknownTime');
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return t('toolbar.invalidDate');
|
||||
|
||||
const locale = t('locale') === 'zh-CN' ? 'zh-CN' : 'en-US';
|
||||
return date.toLocaleString(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
} catch {
|
||||
return t('toolbar.timeError');
|
||||
}
|
||||
};
|
||||
|
||||
// 核心操作
|
||||
const openMenu = async () => {
|
||||
documentStore.openDocumentSelector();
|
||||
await documentStore.getDocumentMetaList();
|
||||
documentStore.openDocumentSelector();
|
||||
state.isLoaded = true;
|
||||
await nextTick();
|
||||
inputRef.value?.focus();
|
||||
};
|
||||
|
||||
// 删除确认
|
||||
const {isConfirming: isDeleting, startConfirm: startDeleteConfirm, reset: resetDeleteConfirm} = useConfirm({
|
||||
timeout: DELETE_CONFIRM_TIMEOUT
|
||||
});
|
||||
|
||||
const closeMenu = () => {
|
||||
state.isLoaded = false;
|
||||
documentStore.closeDocumentSelector();
|
||||
inputValue.value = '';
|
||||
editingId.value = null;
|
||||
editingTitle.value = '';
|
||||
deleteConfirmId.value = null;
|
||||
state.searchQuery = '';
|
||||
state.editing.id = null;
|
||||
state.editing.title = '';
|
||||
resetDeleteConfirm();
|
||||
};
|
||||
|
||||
const selectDoc = async (doc: Document) => {
|
||||
if (doc.id === undefined) return;
|
||||
|
||||
// 如果选择的就是当前文档,直接关闭菜单
|
||||
if (documentStore.currentDocument?.id === doc.id) {
|
||||
closeMenu();
|
||||
@@ -121,7 +114,7 @@ const selectDoc = async (doc: Document) => {
|
||||
|
||||
const createDoc = async (title: string) => {
|
||||
const trimmedTitle = title.trim();
|
||||
const error = validateTitle(trimmedTitle);
|
||||
const error = validateDocumentTitle(trimmedTitle, MAX_TITLE_LENGTH);
|
||||
if (error) return;
|
||||
|
||||
try {
|
||||
@@ -132,20 +125,28 @@ const createDoc = async (title: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectItem = async (item: any) => {
|
||||
const selectDocItem = async (item: any) => {
|
||||
if (item.isCreateOption) {
|
||||
await createDoc(inputValue.value.trim());
|
||||
await createDoc(state.searchQuery.trim());
|
||||
} else {
|
||||
await selectDoc(item);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索框回车处理
|
||||
const handleSearchEnter = () => {
|
||||
const query = state.searchQuery.trim();
|
||||
if (query && filteredItems.value.length > 0) {
|
||||
selectDocItem(filteredItems.value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑操作
|
||||
const startRename = (doc: Document, event: Event) => {
|
||||
const renameDoc = (doc: Document, event: Event) => {
|
||||
event.stopPropagation();
|
||||
editingId.value = doc.id;
|
||||
editingTitle.value = doc.title;
|
||||
deleteConfirmId.value = null;
|
||||
state.editing.id = doc.id ?? null;
|
||||
state.editing.title = doc.title || '';
|
||||
resetDeleteConfirm();
|
||||
nextTick(() => {
|
||||
editInputRef.value?.focus();
|
||||
editInputRef.value?.select();
|
||||
@@ -153,35 +154,41 @@ const startRename = (doc: Document, event: Event) => {
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingId.value || !editingTitle.value.trim()) {
|
||||
editingId.value = null;
|
||||
editingTitle.value = '';
|
||||
if (!state.editing.id || !state.editing.title.trim()) {
|
||||
state.editing.id = null;
|
||||
state.editing.title = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedTitle = editingTitle.value.trim();
|
||||
const error = validateTitle(trimmedTitle);
|
||||
const trimmedTitle = state.editing.title.trim();
|
||||
const error = validateDocumentTitle(trimmedTitle, MAX_TITLE_LENGTH);
|
||||
if (error) return;
|
||||
|
||||
try {
|
||||
await documentStore.updateDocumentMetadata(editingId.value, trimmedTitle);
|
||||
await documentStore.updateDocumentMetadata(state.editing.id, trimmedTitle);
|
||||
await documentStore.getDocumentMetaList();
|
||||
|
||||
|
||||
// 如果tabs功能开启且该文档有标签页,更新标签页标题
|
||||
if (tabStore.isTabsEnabled && tabStore.hasTab(editingId.value)) {
|
||||
tabStore.updateTabTitle(editingId.value, trimmedTitle);
|
||||
if (tabStore.isTabsEnabled && tabStore.hasTab(state.editing.id)) {
|
||||
tabStore.updateTabTitle(state.editing.id, trimmedTitle);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update document:', error);
|
||||
} finally {
|
||||
editingId.value = null;
|
||||
editingTitle.value = '';
|
||||
state.editing.id = null;
|
||||
state.editing.title = '';
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
state.editing.id = null;
|
||||
state.editing.title = '';
|
||||
};
|
||||
|
||||
// 其他操作
|
||||
const openInNewWindow = async (doc: Document, event: Event) => {
|
||||
event.stopPropagation();
|
||||
if (doc.id === undefined) return;
|
||||
try {
|
||||
await documentStore.openDocumentInNewWindow(doc.id);
|
||||
} catch (error) {
|
||||
@@ -191,13 +198,14 @@ const openInNewWindow = async (doc: Document, event: Event) => {
|
||||
|
||||
const handleDelete = async (doc: Document, event: Event) => {
|
||||
event.stopPropagation();
|
||||
if (doc.id === undefined) return;
|
||||
|
||||
if (deleteConfirmId.value === doc.id) {
|
||||
if (isDeleting(doc.id)) {
|
||||
// 确认删除前检查文档是否在其他窗口打开
|
||||
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
||||
if (hasOpen) {
|
||||
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
|
||||
deleteConfirmId.value = null;
|
||||
resetDeleteConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,228 +218,181 @@ const handleDelete = async (doc: Document, event: Event) => {
|
||||
if (firstDoc) await selectDoc(firstDoc);
|
||||
}
|
||||
}
|
||||
deleteConfirmId.value = null;
|
||||
resetDeleteConfirm();
|
||||
} else {
|
||||
// 进入确认状态
|
||||
deleteConfirmId.value = doc.id;
|
||||
editingId.value = null;
|
||||
|
||||
// 3秒后自动取消确认状态
|
||||
setTimeout(() => {
|
||||
if (deleteConfirmId.value === doc.id) {
|
||||
deleteConfirmId.value = null;
|
||||
}
|
||||
}, 3000);
|
||||
startDeleteConfirm(doc.id);
|
||||
state.editing.id = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 键盘事件处理
|
||||
const createKeyHandler = (handlers: Record<string, () => void>) => (event: KeyboardEvent) => {
|
||||
const handler = handlers[event.key];
|
||||
if (handler) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalKeydown = createKeyHandler({
|
||||
Escape: () => {
|
||||
if (editingId.value) {
|
||||
editingId.value = null;
|
||||
editingTitle.value = '';
|
||||
} else if (deleteConfirmId.value) {
|
||||
deleteConfirmId.value = null;
|
||||
} else {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleInputKeydown = createKeyHandler({
|
||||
Enter: () => {
|
||||
const query = inputValue.value.trim();
|
||||
if (query && filteredItems.value.length > 0) {
|
||||
selectItem(filteredItems.value[0]);
|
||||
}
|
||||
},
|
||||
Escape: closeMenu
|
||||
});
|
||||
|
||||
const handleEditKeydown = createKeyHandler({
|
||||
Enter: saveEdit,
|
||||
Escape: () => {
|
||||
editingId.value = null;
|
||||
editingTitle.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 点击外部关闭
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.document-selector')) {
|
||||
// 切换菜单
|
||||
const toggleMenu = () => {
|
||||
if (documentStore.showDocumentSelector) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
});
|
||||
|
||||
// 监听菜单状态变化
|
||||
watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
if (isOpen) {
|
||||
if (isOpen && !state.isLoaded) {
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="document-selector">
|
||||
<div class="document-selector" v-click-outside="closeMenu">
|
||||
<!-- 选择器按钮 -->
|
||||
<button class="doc-btn" @click="documentStore.toggleDocumentSelector">
|
||||
<button class="doc-btn" @click="toggleMenu">
|
||||
<span class="doc-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
|
||||
<polyline points="14,2 14,8 20,8"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="doc-name">{{ currentDocName }}</span>
|
||||
<span class="arrow" :class="{ open: documentStore.showDocumentSelector }">▲</span>
|
||||
<span class="arrow" :class="{ open: state.isLoaded }">▲</span>
|
||||
</button>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<div v-if="documentStore.showDocumentSelector" class="doc-menu">
|
||||
<!-- 输入框 -->
|
||||
<div class="input-box">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
class="main-input"
|
||||
:placeholder="t('toolbar.searchOrCreateDocument')"
|
||||
:maxlength="MAX_TITLE_LENGTH"
|
||||
@keydown="handleInputKeydown"
|
||||
/>
|
||||
<svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<Transition name="slide-up">
|
||||
<div v-if="state.isLoaded" class="doc-menu">
|
||||
<!-- 输入框 -->
|
||||
<div class="input-box">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="state.searchQuery"
|
||||
type="text"
|
||||
class="main-input"
|
||||
:placeholder="t('toolbar.searchOrCreateDocument')"
|
||||
:maxlength="MAX_TITLE_LENGTH"
|
||||
@keydown.enter="handleSearchEnter"
|
||||
@keydown.esc="closeMenu"
|
||||
/>
|
||||
<svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div class="item-list">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
:class="{
|
||||
'active': !item.isCreateOption && documentStore.currentDocument?.id === item.id,
|
||||
'create-item': item.isCreateOption
|
||||
}"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<!-- 创建选项 -->
|
||||
<div v-if="item.isCreateOption" class="create-option">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg>
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 文档项 -->
|
||||
<div v-else class="doc-item-content">
|
||||
<!-- 普通显示 -->
|
||||
<div v-if="editingId !== item.id" class="doc-info">
|
||||
<div class="doc-title">{{ item.title }}</div>
|
||||
<!-- 根据状态显示错误信息或时间 -->
|
||||
<div v-if="documentStore.selectorError?.docId === item.id" class="doc-error">
|
||||
{{ documentStore.selectorError?.message }}
|
||||
</div>
|
||||
<div v-else class="doc-date">{{ formatTime(item.updatedAt) }}</div>
|
||||
<!-- 项目列表 -->
|
||||
<div class="item-list">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
:class="{
|
||||
'active': !item.isCreateOption && documentStore.currentDocument?.id === item.id,
|
||||
'create-item': item.isCreateOption
|
||||
}"
|
||||
@click="selectDocItem(item)"
|
||||
>
|
||||
<!-- 创建选项 -->
|
||||
<div v-if="item.isCreateOption" class="create-option">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg>
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 编辑状态 -->
|
||||
<div v-else class="doc-edit">
|
||||
<input
|
||||
:ref="el => editInputRef = el as HTMLInputElement"
|
||||
v-model="editingTitle"
|
||||
type="text"
|
||||
class="edit-input"
|
||||
:maxlength="MAX_TITLE_LENGTH"
|
||||
@keydown="handleEditKeydown"
|
||||
@blur="saveEdit"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<!-- 文档项 -->
|
||||
<div v-else class="doc-item-content">
|
||||
<!-- 普通显示 -->
|
||||
<div v-if="state.editing.id !== item.id" class="doc-info">
|
||||
<div class="doc-title">{{ item.title }}</div>
|
||||
<!-- 根据状态显示错误信息或时间 -->
|
||||
<div v-if="documentStore.selectorError?.docId === item.id" class="doc-error">
|
||||
{{ documentStore.selectorError?.message }}
|
||||
</div>
|
||||
<div v-else class="doc-date">{{ formatDateTime(item.updated_at) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="editingId !== item.id" class="doc-actions">
|
||||
<!-- 只有非当前文档才显示在新窗口打开按钮 -->
|
||||
<button
|
||||
v-if="documentStore.currentDocument?.id !== item.id"
|
||||
class="action-btn"
|
||||
@click="openInNewWindow(item, $event)"
|
||||
:title="t('toolbar.openInNewWindow')"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M172.8 1017.6c-89.6 0-166.4-70.4-166.4-166.4V441.6c0-89.6 70.4-166.4 166.4-166.4h416c89.6 0 166.4 70.4 166.4 166.4v416c0 89.6-70.4 166.4-166.4 166.4l-416-6.4z m0-659.2c-51.2 0-89.6 38.4-89.6 89.6v416c0 51.2 38.4 89.6 89.6 89.6h416c51.2 0 89.6-38.4 89.6-89.6V441.6c0-51.2-38.4-89.6-89.6-89.6H172.8z"></path>
|
||||
<path
|
||||
d="M851.2 19.2H435.2C339.2 19.2 268.8 96 268.8 185.6v25.6h70.4v-25.6c0-51.2 38.4-89.6 89.6-89.6h409.6c51.2 0 89.6 38.4 89.6 89.6v409.6c0 51.2-38.4 89.6-89.6 89.6h-38.4V768h51.2c96 0 166.4-76.8 166.4-166.4V185.6c0-96-76.8-166.4-166.4-166.4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" @click="startRename(item, $event)" :title="t('toolbar.rename')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="documentStore.documentList.length > 1 && item.id !== 1"
|
||||
class="action-btn delete-btn"
|
||||
:class="{ 'delete-confirm': deleteConfirmId === item.id }"
|
||||
@click="handleDelete(item, $event)"
|
||||
:title="deleteConfirmId === item.id ? t('toolbar.confirmDelete') : t('toolbar.delete')"
|
||||
>
|
||||
<svg v-if="deleteConfirmId !== item.id" xmlns="http://www.w3.org/2000/svg" width="12" height="12"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<polyline points="3,6 5,6 21,6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
<span v-else class="confirm-text">{{ t('toolbar.confirm') }}</span>
|
||||
</button>
|
||||
<!-- 编辑状态 -->
|
||||
<div v-else class="doc-edit">
|
||||
<input
|
||||
:ref="el => editInputRef = el as HTMLInputElement"
|
||||
v-model="state.editing.title"
|
||||
type="text"
|
||||
class="edit-input"
|
||||
:maxlength="MAX_TITLE_LENGTH"
|
||||
@keydown.enter="saveEdit"
|
||||
@keydown.esc="cancelEdit"
|
||||
@blur="saveEdit"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="state.editing.id !== item.id" class="doc-actions">
|
||||
<!-- 只有非当前文档才显示在新窗口打开按钮 -->
|
||||
<button
|
||||
v-if="documentStore.currentDocument?.id !== item.id"
|
||||
class="action-btn"
|
||||
@click="openInNewWindow(item, $event)"
|
||||
:title="t('toolbar.openInNewWindow')"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M172.8 1017.6c-89.6 0-166.4-70.4-166.4-166.4V441.6c0-89.6 70.4-166.4 166.4-166.4h416c89.6 0 166.4 70.4 166.4 166.4v416c0 89.6-70.4 166.4-166.4 166.4l-416-6.4z m0-659.2c-51.2 0-89.6 38.4-89.6 89.6v416c0 51.2 38.4 89.6 89.6 89.6h416c51.2 0 89.6-38.4 89.6-89.6V441.6c0-51.2-38.4-89.6-89.6-89.6H172.8z"></path>
|
||||
<path
|
||||
d="M851.2 19.2H435.2C339.2 19.2 268.8 96 268.8 185.6v25.6h70.4v-25.6c0-51.2 38.4-89.6 89.6-89.6h409.6c51.2 0 89.6 38.4 89.6 89.6v409.6c0 51.2-38.4 89.6-89.6 89.6h-38.4V768h51.2c96 0 166.4-76.8 166.4-166.4V185.6c0-96-76.8-166.4-166.4-166.4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" @click="renameDoc(item, $event)" :title="t('toolbar.rename')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="documentStore.documentList.length > 1 && item.id !== 1"
|
||||
class="action-btn delete-btn"
|
||||
:class="{ 'delete-confirm': isDeleting(item.id!) }"
|
||||
@click="handleDelete(item, $event)"
|
||||
:title="isDeleting(item.id!) ? t('toolbar.confirmDelete') : t('toolbar.delete')"
|
||||
>
|
||||
<svg v-if="!isDeleting(item.id!)" xmlns="http://www.w3.org/2000/svg" width="12" height="12"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<polyline points="3,6 5,6 21,6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
<span v-else class="confirm-text">{{ t('toolbar.confirm') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredItems.length === 0" class="empty">
|
||||
{{ t('toolbar.noDocumentFound') }}
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="documentStore.isLoading" class="loading">
|
||||
{{ t('toolbar.loading') }}
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredItems.length === 0" class="empty">
|
||||
{{ t('toolbar.noDocumentFound') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.document-selector {
|
||||
position: relative;
|
||||
|
||||
@@ -483,8 +444,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
width: 260px;
|
||||
max-height: calc(100vh - 40px); // 限制最大高度,留出titlebar空间(32px)和一些边距
|
||||
width: 300px;
|
||||
max-height: calc(100vh - 40px);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
@@ -527,7 +488,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
}
|
||||
|
||||
.item-list {
|
||||
max-height: calc(100vh - 100px); // 为输入框和边距预留空间
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
@@ -594,7 +555,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
||||
.doc-error {
|
||||
font-size: 10px;
|
||||
color: var(--text-danger);
|
||||
@@ -669,7 +630,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
}
|
||||
}
|
||||
|
||||
.empty, .loading {
|
||||
.empty {
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
@@ -680,9 +641,17 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% { opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user