chore: migrate and structure cmbone project
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex items-center justify-center ">
|
||||
<!-- 中央轻卡片 -->
|
||||
<div
|
||||
class="
|
||||
w-[420px]
|
||||
rounded-2xl
|
||||
bg-neutral-50 dark:bg-gray-800
|
||||
px-8 py-10
|
||||
text-center
|
||||
shadow-sm
|
||||
border border-neutral-100 dark:border-gray-700
|
||||
"
|
||||
>
|
||||
<!-- 轻符号 / 视觉锚点 -->
|
||||
<transition name="fade-up">
|
||||
<div
|
||||
v-if="show"
|
||||
class="flex justify-center mb-4"
|
||||
>
|
||||
<img src="/system/about.svg" class="w-28 h-28" />
|
||||
</div>
|
||||
</transition>
|
||||
<!-- 标题 -->
|
||||
<transition name="fade-up">
|
||||
<h1
|
||||
v-if="show"
|
||||
class="text-2xl font-semibold text-neutral-800 mb-1 dark:text-white"
|
||||
style="transition-delay: 80ms"
|
||||
>
|
||||
cmbone
|
||||
</h1>
|
||||
</transition>
|
||||
<!-- 副标题 -->
|
||||
<transition name="fade-up">
|
||||
<p
|
||||
v-if="show"
|
||||
class="text-sm text-neutral-500 mb-6 dark:text-neutral-300"
|
||||
style="transition-delay: 120ms"
|
||||
>
|
||||
Wails3 · Desktop
|
||||
</p>
|
||||
</transition>
|
||||
<!-- 主文案 -->
|
||||
<transition name="fade-up">
|
||||
<div
|
||||
v-if="show"
|
||||
class="text-sm text-neutral-700 leading-relaxed space-y-3 mb-6 dark:text-white"
|
||||
style="transition-delay: 180ms"
|
||||
>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="h-2"></div>
|
||||
<p>{{ $t('components.about.description_p1') }}</p>
|
||||
<p>{{ $t('components.about.description_p2') }}</p>
|
||||
</div>
|
||||
</transition>
|
||||
<!-- 分割线 -->
|
||||
<transition name="fade-up">
|
||||
<div
|
||||
v-if="show"
|
||||
class="flex justify-center mb-6"
|
||||
style="transition-delay: 240ms"
|
||||
>
|
||||
<span class="w-12 h-px bg-neutral-200"></span>
|
||||
</div>
|
||||
</transition>
|
||||
<!-- 底部信息 -->
|
||||
<transition name="fade-up">
|
||||
<div
|
||||
v-if="show"
|
||||
class="text-xs text-neutral-400 space-y-1 dark:text-white"
|
||||
style="transition-delay: 300ms"
|
||||
>
|
||||
<p>Designed & Built by {{ $t('components.about.author') }}</p>
|
||||
<p>© 2026 cmbone</p>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
const show = ref(false)
|
||||
onMounted(() => {
|
||||
// 延迟出现,避免“页面一闪全出”
|
||||
setTimeout(() => {
|
||||
show.value = true
|
||||
}, 80)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.fade-up-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.fade-up-enter-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class=" text-slate-800 font-sans dark:text-white">
|
||||
<div class="max-w-4xl mx-auto pl-6 pr-6">
|
||||
<header class="flex items-center justify-between mb-6">
|
||||
<div class="relative w-2/3">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search reminders..."
|
||||
class="w-full dark:bg-gray-800 pl-10 pr-4 py-2 bg-slate-100 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="showMsgModal" class="relative text-slate-600 w-[40px]">
|
||||
<div class="absolute mr-2 top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-2 border-white dark:border-gray-200"></div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 dark:text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="OpenSecondWindow" class="w-[110px] bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-3 py-2 rounded-xl flex items-center gap-2 font-medium transition-colors shadow-sm">
|
||||
<span class="text-xl pb-1">+</span> {{ $t('components.dashboard.buttons.newtask') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mb-4 dark:text-white ">
|
||||
<span class="text-xl font-bold text-slate-900 text-left dark:text-white">{{ greetingMessage }}, {{ profile.name }}</span>
|
||||
<p class="text-slate-500 mt-1 text-left dark:text-slate-300" v-if="today_pending>0">You have <span class="font-semibold text-slate-600 dark:text-slate-100" v-text="today_pending"></span> tasks to complete today.</p>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
<div v-for="stat in stats" :key="stat.label" class="dark:bg-gray-800 dark:text-white bg-white pt-2 pr-4 pl-4 rounded-3xl shadow-sm 0">
|
||||
<div class="flex justify-between mb-2">
|
||||
<div :class="`p-1 rounded-xl ${stat.iconBg} `">
|
||||
<component :is="stat.icon" class="w-6 h-6 flex items-center justify-center " :class="stat.iconColor" />
|
||||
</div>
|
||||
<span :class="tagStyles(stat.tagType)">{{ stat.tag }}</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-sm font-medium text-left dark:text-white">{{ stat.label }}</p>
|
||||
<h2 class="text-2xl font-bold mt-1 text-slate-800 dark:text-white">{{ stat.value }}</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4 flex items-center justify-between">
|
||||
<span class="text-base font-bold text-slate-800 dark:text-white">{{ $t('components.dashboard.title.upcoming_task') }}</span>
|
||||
<div class="flex bg-slate-200/60 p-1 rounded-lg text-sm dark:bg-gray-800">
|
||||
<button class="px-4 py-1 rounded-md bg-white shadow-sm font-medium dark:text-white dark:bg-gray-900">{{ $t('components.dashboard.buttons.upcoming_all') }}</button>
|
||||
<button class="px-4 py-1 w-[66px] rounded-md text-slate-500 hover:text-slate-700 dark:text-slate-300">{{ $t('components.dashboard.buttons.upcoming_priority') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="space-y-1 ">
|
||||
<div v-if="tasks.length === 0" class="text-center py-10">
|
||||
<img src="/system/empty.svg" class=" mx-auto mt-1 w-32 " />
|
||||
<span class="text-sm">{{ $t('components.dashboard.label.no_tasks') }}</span>
|
||||
</div>
|
||||
<div v-else v-for="task in tasks" :key="task.id"
|
||||
class="dark:bg-gray-800 dark:text-white dark:border-slate-500 group flex items-center bg-white p-2 rounded-3xl border border-slate-100 hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div class="w-6 h-6 rounded-full border-2 border-slate-200 mr-4 flex items-center justify-center group-hover:border-indigo-400 transition-colors">
|
||||
<div v-if="task.isCompleted" class="w-4 h-4 bg-indigo-500 rounded-full"></div>
|
||||
</div>
|
||||
<div class="flex-1 pl-3">
|
||||
<span class="text-slate-800 text-left text-sm dark:text-white line-clamp-1">{{ task.title }}</span>
|
||||
<div class="flex items-center gap-4 ">
|
||||
<span class="flex items-center text-xs text-slate-400 font-medium">
|
||||
<svg v-if="task.time.includes(':')" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
|
||||
{{task.time}}
|
||||
</span>
|
||||
|
||||
<span v-if="task.priority" class="flex items-center text-xs text-orange-500 font-bold uppercase tracking-wider">
|
||||
<span class="mr-1">🚩</span> {{ task.priority }}
|
||||
</span>
|
||||
|
||||
<span :class="categoryStyles(task.category)">
|
||||
{{ task.category }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-modal v-model:open="openmessage" title="" width="400px" :closable="false" :footer="null">
|
||||
<div class="bg-white h-[360px] pl-1 pr-1 pb-4 pt-1 ">
|
||||
<Notifications :handle-cancel="openmessage" @notification-change="handleOk" />
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref ,computed} from 'vue'
|
||||
import { ProfileOutlined,FileDoneOutlined ,ExceptionOutlined } from '@ant-design/icons-vue'
|
||||
import Notifications from '../Setting/Notifications.vue'
|
||||
import { OpenSecondWindow } from '../../../bindings/cmbone/internal/services/appservice'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
const today_pending = ref(0)
|
||||
const profile = ref({
|
||||
name: 'Jin Gong',
|
||||
avatar: 'https://i.pravatar.cc/150?img=3'
|
||||
})
|
||||
const openmessage = ref<boolean>(false);
|
||||
const stats = ref([
|
||||
{ label: t('components.dashboard.label.total_tasks'), value: 520, tag: '+12%', tagType: 'percentage', icon: ProfileOutlined, iconBg: 'bg-indigo-100', iconColor: 'text-indigo-600' },
|
||||
{ label: t('components.dashboard.label.pending_tasks'), value: 13, tag: 'Urgent', tagType: 'status', icon: ExceptionOutlined, iconBg: 'bg-orange-100', iconColor: 'text-orange-600' },
|
||||
{ label: t('components.dashboard.label.completed_tasks'), value: 14, tag: 'Daily', tagType: 'default', icon: FileDoneOutlined, iconBg: 'bg-emerald-100', iconColor: 'text-emerald-600' },
|
||||
])
|
||||
interface Task {
|
||||
id: number
|
||||
title: string
|
||||
time: string
|
||||
category: string
|
||||
priority?: string
|
||||
isCompleted: boolean
|
||||
}
|
||||
const tasks = ref<Task[]>([])
|
||||
tasks.value = [
|
||||
// { id: 1, title: 'Quarterly Budget Review', time: '10:30 AM', category: 'WORK', priority: 'High Priority', isCompleted: true },
|
||||
// { id: 2, title: 'Buy Weekly Groceries', time: '05:00 PM', category: 'PERSONAL', isCompleted: false },
|
||||
// { id: 3, title: 'Client Presentation Design', time: 'Tomorrow', category: 'WORK', isCompleted: false },
|
||||
// { id: 4, title: 'Book Gym Slot', time: '08:00 AM', category: 'PERSONAL', isCompleted: false },
|
||||
]
|
||||
const showMsgModal = () => {
|
||||
openmessage.value = true;
|
||||
};
|
||||
const handleOk = () => {
|
||||
openmessage.value = false;
|
||||
};
|
||||
const tagStyles = (type: string) => {
|
||||
const base = "text-[10px] px-2 my-1 rounded-lg font-bold "
|
||||
if (type === 'percentage') return base + "bg-emerald-50 text-emerald-500"
|
||||
if (type === 'status') return base + "bg-orange-50 text-orange-500"
|
||||
return base + "bg-slate-100 text-slate-400"
|
||||
}
|
||||
|
||||
const categoryStyles = (cat: string) => {
|
||||
const base = "text-[8px] px-2 rounded-md font-bold "
|
||||
return cat === 'WORK'
|
||||
? base + "bg-blue-100 text-blue-500"
|
||||
: base + "bg-emerald-100 text-emerald-500"
|
||||
}
|
||||
onMounted(async () => {
|
||||
})
|
||||
|
||||
const now = ref(new Date())
|
||||
|
||||
// 计算问候语
|
||||
const greetingMessage = computed(() => {
|
||||
const hour = now.value.getHours()
|
||||
if (hour >= 5 && hour < 12) return 'Good Morning'
|
||||
if (hour >= 12 && hour < 18) return 'Good Afternoon'
|
||||
if (hour >= 18 && hour < 22) return 'Good Evening'
|
||||
return 'Good Night'
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以在此添加特定字体,如 Inter 或 Plus Jakarta Sans */
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref,h } from 'vue'
|
||||
import ListItem from '../Setting/ListRow.vue'
|
||||
import LanguageSwitcher from '../Setting/LanguageSwitcher.vue'
|
||||
import ThemeSetting from '../Setting/ThemeSetting.vue'
|
||||
import Shortcut from '../Shortcut/Index.vue'
|
||||
|
||||
const modelValue = ref(false)
|
||||
</script>
|
||||
|
||||
<template >
|
||||
<div >
|
||||
<section >
|
||||
<!-- 外观设置 -->
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white" >{{ $t('components.general.title.exterior') }}</h2>
|
||||
<div class="bg-white rounded-lg shadow divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
|
||||
<ListItem :label="$t('components.general.label.language')" subLabel="">
|
||||
<LanguageSwitcher />
|
||||
</ListItem>
|
||||
<ListItem :label="$t('components.general.label.theme')" subLabel="">
|
||||
<ThemeSetting />
|
||||
</ListItem>
|
||||
</div>
|
||||
<!-- 快捷键设置 -->
|
||||
<Shortcut />
|
||||
|
||||
<!-- 应用设置 -->
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white" >{{ $t('components.general.title.application') }}</h2>
|
||||
<div class="bg-white rounded-lg shadow divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
|
||||
<ListItem :label="$t('components.general.label.startup')" subLabel="">
|
||||
<input type="checkbox" class="sr-only peer" v-model="modelValue" />
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer dark:bg-gray-300 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500 relative"
|
||||
></div>
|
||||
</ListItem>
|
||||
</div>
|
||||
<!-- 应用更新 -->
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white" >{{ $t('components.general.title.update') }}</h2>
|
||||
<div class="mt-4 bg-white rounded-lg shadow divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
|
||||
<ListItem :label="$t('components.general.label.automatic_up')" subLabel="">
|
||||
<input type="checkbox" class="sr-only peer" v-model="modelValue" />
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer dark:bg-gray-300 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500 relative"
|
||||
></div>
|
||||
</ListItem>
|
||||
<ListItem :label="$t('components.general.label.next_up')" subLabel="">
|
||||
<input type="checkbox" class="sr-only peer" v-model="modelValue" />
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer dark:bg-gray-300 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500 relative"
|
||||
></div>
|
||||
</ListItem>
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
h2{
|
||||
text-align: left;
|
||||
margin:15px 15px 4px 15px;
|
||||
font-size:15px;
|
||||
}
|
||||
.rg_desc{
|
||||
text-align: left;
|
||||
margin:4px 0px 4px 8px;
|
||||
font-size:13px;
|
||||
color:#999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="flex h-screen font-sans text-slate-900">
|
||||
<!-- 左侧请求栏 -->
|
||||
<aside
|
||||
class="w-44 border-r border-slate-200 bg-white flex flex-col dark:bg-gray-800 dark:text-white dark:border-slate-600"
|
||||
:class="ismacos ? 'pt-10 ' : 'pt-8'">
|
||||
<nav class="flex-1 px-4 space-y-1 dark:bg-gray-800">
|
||||
<div v-for="(item, index) in requests" :key="index" @click="handleMenu(item)"
|
||||
:class="[' flex items-center gap-3 px-3 py-2 text-indigo-700 cursor-pointer rounded-xl font-medium dark:text-white', selected === item.id ? 'bg-indigo-50 dark:bg-slate-500' : ' hover:bg-slate-50 text-slate-500 dark:hover:bg-slate-700']">
|
||||
<component :is="item.icon" />
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="pl-4 pr-4 pt-2 pb-2 border-t border-slate-100 flex items-center gap-3 dark:border-slate-600 ">
|
||||
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=JinGong8"
|
||||
class="w-10 h-10 rounded-full bg-slate-200" />
|
||||
<div class="text-sm">
|
||||
<p class="font-bold mb-0">Jin Gong</p>
|
||||
<p class="text-slate-500 text-xs mb-0">Pro Plan</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- 右侧整体 -->
|
||||
<div class="flex-1 flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||
<div v-if="iswindows" class="titlebar drag-region pt-4 px-4 mb-2 text-white flex items-center justify-between">
|
||||
<div class="drag-area">
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<button @click="Window.Minimise()" >
|
||||
<LineOutlined class="text-gray-400 hover:text-gray-600 dark:text-gray-300 dark:hover:text-gray-100" />
|
||||
</button>
|
||||
<button @click="toggleMaximize()">
|
||||
<BorderOutlined v-if="!isMaximized" class="text-gray-400 hover:text-gray-600 dark:text-gray-300 dark:hover:text-gray-100" />
|
||||
<SwitcherOutlined v-else class="text-gray-400 hover:text-gray-600 dark:text-gray-300 dark:hover:text-gray-100" />
|
||||
</button>
|
||||
<button @click="Window.Hide()" >
|
||||
<CloseOutlined class="text-gray-400 hover:text-gray-600 dark:text-gray-300 dark:hover:text-gray-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 主内容 -->
|
||||
<main class="flex-1 overflow-y-auto max-h-screen scroll-container scrollbar-thin" :class="iswindows ? 'px-4 pt-px pb-4' : 'p-4'">
|
||||
<component :is="getComponent" />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { SettingOutlined, BulbOutlined, ScheduleOutlined, DashboardOutlined, TagsOutlined } from '@ant-design/icons-vue';
|
||||
import { OpenSecondWindow } from '../../../bindings/cmbone/internal/services/appservice'
|
||||
import { getOS, OS_READY } from '../../utils/osinfo'
|
||||
import { CloseOutlined, LineOutlined, BorderOutlined, SwitcherOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const ismacos = ref(false)
|
||||
const iswindows= ref(false)
|
||||
const { t } = useI18n()
|
||||
const isMaximized = ref(false)
|
||||
//菜单结构
|
||||
type MenuItem =
|
||||
| {
|
||||
id: string
|
||||
label: string
|
||||
icon: any
|
||||
type: 'component'
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
label: string
|
||||
icon: any
|
||||
type: 'window'
|
||||
action: () => void
|
||||
}
|
||||
|
||||
const requests = computed<MenuItem[]>(() => [
|
||||
{ id: 'dashboard', label: t('menus.dashboard'), icon: DashboardOutlined, type: 'component' },
|
||||
{ id: 'light-tip', label: t('menus.second'), icon: ScheduleOutlined, type: 'window', action: OpenSecondWindow },
|
||||
{id:'tags',label:t('menus.tags'),icon:TagsOutlined,type: 'component'},
|
||||
{ id: 'setting', label: t('menus.setting'), icon: SettingOutlined, type: 'component' },
|
||||
{ id: 'about', label: t('menus.about'), icon: BulbOutlined, type: 'component' },
|
||||
])
|
||||
const selected = ref('dashboard')
|
||||
import Dashboard from '../Dashboard/Index.vue'
|
||||
import Tags from '../Tags/Index.vue'
|
||||
import Setting from '../General/Index.vue'
|
||||
import About from '../About/Index.vue'
|
||||
import { Window } from '@wailsio/runtime';
|
||||
|
||||
|
||||
const components = {
|
||||
dashboard: Dashboard,
|
||||
tags: Tags,
|
||||
setting: Setting,
|
||||
about: About
|
||||
}
|
||||
|
||||
const getComponent = computed(() => components[selected.value])
|
||||
function handleMenu(item: MenuItem) {
|
||||
if (item.type === 'component') {
|
||||
selected.value = item.id
|
||||
}
|
||||
if (item.type === 'window') {
|
||||
item.action()
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单变化时滚动到顶部
|
||||
watch(selected, () => {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('.scroll-container')
|
||||
if (el) el.scrollTop = 0
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await OS_READY
|
||||
const osname = getOS() //判断是否为macos
|
||||
if (osname === 'darwin') {
|
||||
ismacos.value = true;
|
||||
}else if (osname === 'windows') {
|
||||
iswindows.value = true;
|
||||
isMaximized.value = await Window.IsMaximised()
|
||||
}else{
|
||||
}
|
||||
})
|
||||
async function toggleMaximize() {
|
||||
isMaximized.value = !isMaximized.value
|
||||
await Window.ToggleMaximise()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
/* 禁止横向滚动 */
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
--wails-draggable: drag;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-area {
|
||||
flex: 1;
|
||||
padding-left: 12px;
|
||||
/* 👇 关键:允许拖动窗口 */
|
||||
-webkit-app-region: drag;
|
||||
--wails-draggable: drag;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
/* 👇 按钮不能拖动 */
|
||||
-webkit-app-region: no-drag;
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
.window-controls button {
|
||||
margin-right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
}
|
||||
|
||||
/* 全局样式(可放在 main.css 或 tailwind.css) */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div >
|
||||
<a-select class="dark:text-white"
|
||||
ref="select"
|
||||
v-model:value="locale"
|
||||
style="width: 100px"
|
||||
>
|
||||
<a-select-option value="zh" >简体中文</a-select-option>
|
||||
<a-select-option value="en">English</a-select-option>
|
||||
<a-select-option value="zh-HK">繁體中文</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { i18n, setupI18n } from '../../utils/i18n'
|
||||
import { Locale } from '../../locales'
|
||||
import { SetLanguage } from '../../../bindings/cmbone/internal/services/appservice'
|
||||
const locale = computed({
|
||||
get: () => i18n.global.locale.value,
|
||||
set: (val) => {
|
||||
switchLang(val as Locale)
|
||||
}
|
||||
})
|
||||
const switchLang =async (lang: Locale) => {
|
||||
await setupI18n(lang) // 切换语言并更新 i18n 实例
|
||||
await SetLanguage(lang)// 更新配置项中的语言设置 and 更新菜单语言
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
subLabel?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-4 py-3 ">
|
||||
<div style="text-align: left;">
|
||||
<div class="text-sm text-gray-900 font-medium dark:text-white">{{ label }}</div>
|
||||
<div v-if="subLabel" class="text-xs text-gray-500 mt-1">{{ subLabel }}</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue';
|
||||
import { CloseOutlined, DeleteOutlined, SyncOutlined } from '@ant-design/icons-vue'
|
||||
const props = defineProps<{
|
||||
handleCancel: boolean,
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'notification-change', value: true | false): void
|
||||
}>()
|
||||
function onCloseChange() {
|
||||
emit('notification-change', false)
|
||||
}
|
||||
interface Activity {
|
||||
id: number
|
||||
title: string
|
||||
content?: string
|
||||
type: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
const activities_data = ref<Activity[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: 'System Update',
|
||||
content: 'Your system was updated successfully.',
|
||||
type: 'system',
|
||||
created_at: Math.floor(Date.now() / 1000) - 300
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Auto-complete Task',
|
||||
content: 'Task "Write report" was auto-completed.',
|
||||
type: 'auto_complete',
|
||||
created_at: Math.floor(Date.now() / 1000) - 7200
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Alert: High CPU Usage',
|
||||
content: 'Your CPU usage has been above 90% for the last hour.',
|
||||
type: 'alert',
|
||||
created_at: Math.floor(Date.now() / 1000) - 90000
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'New Message',
|
||||
content: 'You have received a new message from John.',
|
||||
type: 'message',
|
||||
created_at: Math.floor(Date.now() / 1000) - 120
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Backup Completed',
|
||||
content: 'Your files were backed up successfully.',
|
||||
type: 'backup',
|
||||
created_at: Math.floor(Date.now() / 1000) - 3600
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Friend Request',
|
||||
content: 'Alice has sent you a friend request.',
|
||||
type: 'social',
|
||||
created_at: Math.floor(Date.now() / 1000) - 18000
|
||||
}
|
||||
])
|
||||
const activities = ref<Activity[]>([])
|
||||
// 时间格式
|
||||
function formatTime(ts: number) {
|
||||
const now = Date.now() / 1000
|
||||
const diff = now - ts
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' mins ago'
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + ' hours ago'
|
||||
return 'yesterday'
|
||||
}
|
||||
|
||||
// 类型样式
|
||||
function getTypeStyle(type: string) {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return {
|
||||
icon: 'i-carbon-information',
|
||||
bg: 'bg-indigo-100',
|
||||
iconColor: 'text-indigo-600'
|
||||
}
|
||||
case 'auto_complete':
|
||||
return {
|
||||
icon: 'i-carbon-checkmark-filled',
|
||||
bg: 'bg-green-100',
|
||||
iconColor: 'text-green-600'
|
||||
}
|
||||
case 'complete':
|
||||
return {
|
||||
icon: 'i-carbon-checkmark-filled',
|
||||
bg: 'bg-green-200',
|
||||
iconColor: 'text-green-800'
|
||||
}
|
||||
case 'alert':
|
||||
return {
|
||||
icon: 'i-carbon-warning-filled',
|
||||
bg: 'bg-orange-100',
|
||||
iconColor: 'text-orange-600'
|
||||
}
|
||||
case 'create':
|
||||
return {
|
||||
icon: 'i-carbon-add-filled',
|
||||
bg: 'bg-blue-100',
|
||||
iconColor: 'text-blue-600'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
icon: 'i-carbon-dot-mark',
|
||||
bg: 'bg-gray-100',
|
||||
iconColor: 'text-gray-500'
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
await onloadActivities()
|
||||
})
|
||||
const onloadActivities = async () => {
|
||||
try {
|
||||
activities.value = activities_data.value
|
||||
} catch (error) {
|
||||
console.error('Failed to load activities:', error)
|
||||
}
|
||||
}
|
||||
const clearActivities = async () => {
|
||||
try {
|
||||
activities.value = []
|
||||
} catch (error) {
|
||||
console.error('Failed to clear activities:', error)
|
||||
}
|
||||
}
|
||||
const clearActivity = async (id: number) => {
|
||||
try {
|
||||
activities.value = activities.value.filter(item => item.id !== id)
|
||||
message.success('Deleted successfully.')
|
||||
} catch (error) {
|
||||
console.error('Failed to clear activity:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between mb-4 mt-[-4px]">
|
||||
<div class="text-sm font-semibold flex items-center gap-2 text-slate-800 dark:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 dark:text-gray-200" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
Notifications
|
||||
</div>
|
||||
<div class=" cursor-pointer text-items-center text-sm flex gap-4">
|
||||
<DeleteOutlined @click="clearActivities" class="text-red-400 hover:text-red-600" />
|
||||
<SyncOutlined @click="onloadActivities" class="text-yellow-400 hover:text-yellow-600" />
|
||||
<CloseOutlined @click="onCloseChange" class="text-gray-400 hover:text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white h-[320px] ">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="activities.length === 0" class="text-center text-gray-400 text-sm py-6">
|
||||
<img src="/system/reminders.svg" alt="No activity" class="w-28 h-28 mx-auto mb-2" />
|
||||
No activity yet
|
||||
</div>
|
||||
<div v-else class="space-y-1 overflow-y-auto h-full scrollbar-thin pr-2 ">
|
||||
<div v-for="item in activities" :key="item.id" class="flex gap-2 group">
|
||||
<div class="flex-1 p-1 rounded-xl transition-all duration-200
|
||||
hover:bg-gray-50 ">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-xl flex items-center justify-center" :class="getTypeStyle(item.type).bg">
|
||||
<i :class="[
|
||||
getTypeStyle(item.type).icon,
|
||||
getTypeStyle(item.type).iconColor,
|
||||
'text-lg'
|
||||
]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 sm:w-auto">
|
||||
<div class="flex items-start justify-between gap-2 ">
|
||||
<!-- 标题 -->
|
||||
<div class="text-xs font-medium text-gray-800 break-words line-clamp-1">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<!-- 时间 -->
|
||||
<div class="text-xs text-gray-400 whitespace-nowrap shrink-0 ">
|
||||
{{ formatTime(item.created_at) }}
|
||||
<CloseOutlined @click="clearActivity(item.id)"
|
||||
class="text-red-400 hover:text-red-600 pl-1 group-hover:opacity-100 transition-opacity cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.content" class="text-xs text-gray-500 mt-1">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-[60%] items-center justify-center mx-auto ">
|
||||
<a-divider dashed plain>END</a-divider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 全局样式(可放在 main.css 或 tailwind.css) */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ant-popover {
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps<{ modelValue: string }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const modifierKeys = ['Shift', 'Control', 'Alt', 'Meta'] as const;
|
||||
const modifierSymbols: Record<string, string> = {
|
||||
Shift: '⇧',
|
||||
Control: '⌃',
|
||||
Alt: '⌥',
|
||||
Meta: '⌘',
|
||||
};
|
||||
|
||||
// 用于规范化外部传入的别名,比如 Cmd => Meta
|
||||
const normalizeModifier = (key: string): string => {
|
||||
switch (key.toUpperCase()) {
|
||||
case 'CMD':
|
||||
case 'COMMAND':
|
||||
return 'Meta';
|
||||
case 'CTRL':
|
||||
return 'Control';
|
||||
case 'OPTION':
|
||||
return 'Alt';
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
const pressedModifiers = ref<Set<string>>(new Set());
|
||||
const finalModifiers = ref<Set<string>>(new Set());
|
||||
const mainKey = ref<string | null>(null);
|
||||
|
||||
const isRecording = ref(false);
|
||||
const isFinalized = ref(false);
|
||||
|
||||
const display = computed(() => {
|
||||
const activeSet = isFinalized.value ? finalModifiers.value : pressedModifiers.value;
|
||||
|
||||
const modifiers = modifierKeys
|
||||
.map(key => {
|
||||
const isActive = activeSet.has(key);
|
||||
const color = isActive ? '#1890ff' : '#999';
|
||||
return `<span style=\"color: ${color}; font-weight: bold;\">${modifierSymbols[key]}</span>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const keyColor = isFinalized.value && mainKey.value ? '#1890ff' : '#000';
|
||||
const keyPart = mainKey.value
|
||||
? `<span style=\"margin-left: 6px; color: ${keyColor}; font-weight: bold;\">${mainKey.value.toUpperCase()}</span>`
|
||||
: '';
|
||||
|
||||
return modifiers + keyPart;
|
||||
});
|
||||
|
||||
const shortcutString = computed(() => {
|
||||
if (!isFinalized.value || !mainKey.value || finalModifiers.value.size === 0) {
|
||||
return '';
|
||||
}
|
||||
return [...finalModifiers.value].join('+') + '+' + mainKey.value.toUpperCase();
|
||||
});
|
||||
|
||||
watch(shortcutString, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
const startRecording = () => {
|
||||
isRecording.value = true;
|
||||
isFinalized.value = false;
|
||||
mainKey.value = null;
|
||||
pressedModifiers.value.clear();
|
||||
finalModifiers.value.clear();
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
if (pressedModifiers.value.size === 0 || !mainKey.value) {
|
||||
//message.warning('快捷键无效:必须包含至少一个修饰键和一个主键');
|
||||
startRecording();
|
||||
return;
|
||||
}
|
||||
isFinalized.value = true;
|
||||
finalModifiers.value = new Set(pressedModifiers.value);
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
isRecording.value = false;
|
||||
isFinalized.value = false;
|
||||
pressedModifiers.value.clear();
|
||||
finalModifiers.value.clear();
|
||||
mainKey.value = null;
|
||||
emit('update:modelValue', '');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isRecording.value || isFinalized.value) return;
|
||||
|
||||
if (modifierKeys.includes(e.key as any)) {
|
||||
pressedModifiers.value.add(e.key);
|
||||
} else if (!['Tab', 'Escape'].includes(e.key)) {
|
||||
mainKey.value = e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||
finalize();
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (!isRecording.value || isFinalized.value) return;
|
||||
if (modifierKeys.includes(e.key as any)) {
|
||||
pressedModifiers.value.delete(e.key);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 初始化值支持:从 modelValue 拆解 modifier 和主键
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (!val) {
|
||||
clearAll();
|
||||
return;
|
||||
}
|
||||
|
||||
//const parts = val.split('+');
|
||||
const parts = val.split('+').map(normalizeModifier);
|
||||
const mods = parts.filter(p => modifierKeys.includes(p as any));
|
||||
const key = parts.find(p => !modifierKeys.includes(p as any));
|
||||
|
||||
if (mods.length && key) {
|
||||
finalModifiers.value = new Set(mods);
|
||||
mainKey.value = key;
|
||||
isFinalized.value = true;
|
||||
} else {
|
||||
clearAll(); // 防止非法值
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-input
|
||||
style="width: 100px;"
|
||||
readonly
|
||||
:value="''"
|
||||
@focus="startRecording"
|
||||
@blur="isRecording = false"
|
||||
>
|
||||
<template #suffix>
|
||||
<span v-html="display" />
|
||||
<!-- <template v-if="isFinalized">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
style="margin-left: 8px; color: #999;"
|
||||
@click.stop="clearAll"
|
||||
>✕</a-button>
|
||||
</template> -->
|
||||
</template>
|
||||
</a-input>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { setTheme, getCurrentTheme, ThemeMode } from '../../utils/ThemeManager'
|
||||
|
||||
const themevalue = ref<ThemeMode>('light')
|
||||
|
||||
const themeChange = (val: ThemeMode) => {
|
||||
setTheme(val)
|
||||
// 通知子窗口也可以加上 BroadcastChannel
|
||||
const bc = new BroadcastChannel('theme')
|
||||
bc.postMessage(val)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
themevalue.value = getCurrentTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<a-select
|
||||
v-model:value="themevalue"
|
||||
style="width: 100px"
|
||||
@change="themeChange"
|
||||
>
|
||||
<a-select-option value="light">{{$t('components.themesetting.select.opt_light')}}</a-select-option>
|
||||
<a-select-option value="dark">{{$t('components.themesetting.select.opt_dark')}}</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps<{ modelValue: string }>();
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const modifierKeys = ['Shift', 'Control', 'Alt', 'Meta'] as const;
|
||||
|
||||
const modifierLabels: Record<string, string> = {
|
||||
Shift: 'Shift',
|
||||
Control: 'Ctrl',
|
||||
Alt: 'Alt',
|
||||
Meta: 'Win', // Windows键
|
||||
};
|
||||
|
||||
const normalizeModifier = (key: string): string => {
|
||||
switch (key.toUpperCase()) {
|
||||
case 'CMD':
|
||||
case 'COMMAND':
|
||||
case 'WIN':
|
||||
case 'WINDOWS':
|
||||
return 'Meta';
|
||||
case 'CTRL':
|
||||
return 'Control';
|
||||
case 'OPTION':
|
||||
return 'Alt';
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
const pressedModifiers = ref<Set<string>>(new Set());
|
||||
const finalModifiers = ref<Set<string>>(new Set());
|
||||
const mainKey = ref<string | null>(null);
|
||||
|
||||
const isRecording = ref(false);
|
||||
const isFinalized = ref(false);
|
||||
|
||||
const display = computed(() => {
|
||||
const activeSet = isFinalized.value ? finalModifiers.value : pressedModifiers.value;
|
||||
|
||||
const modifiers = modifierKeys
|
||||
.map(key => {
|
||||
const isActive = activeSet.has(key);
|
||||
const color = isActive ? '#1890ff' : '#999';
|
||||
return `<span style="color: ${color}; font-weight: bold;">${modifierLabels[key]}</span>`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
const keyColor = isFinalized.value && mainKey.value ? '#1890ff' : '#000';
|
||||
const keyPart = mainKey.value
|
||||
? `<span style="margin-left: 6px; color: ${keyColor}; font-weight: bold;">${mainKey.value.toUpperCase()}</span>`
|
||||
: '';
|
||||
|
||||
return modifiers + (mainKey.value ? ' ' : '') + keyPart;
|
||||
});
|
||||
|
||||
const shortcutString = computed(() => {
|
||||
if (!isFinalized.value || !mainKey.value || finalModifiers.value.size === 0) {
|
||||
return '';
|
||||
}
|
||||
return [...finalModifiers.value].join('+') + '+' + mainKey.value.toUpperCase();
|
||||
});
|
||||
|
||||
watch(shortcutString, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
const startRecording = () => {
|
||||
isRecording.value = true;
|
||||
isFinalized.value = false;
|
||||
mainKey.value = null;
|
||||
pressedModifiers.value.clear();
|
||||
finalModifiers.value.clear();
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
if (pressedModifiers.value.size === 0 || !mainKey.value) {
|
||||
startRecording();
|
||||
return;
|
||||
}
|
||||
isFinalized.value = true;
|
||||
finalModifiers.value = new Set(pressedModifiers.value);
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
isRecording.value = false;
|
||||
isFinalized.value = false;
|
||||
pressedModifiers.value.clear();
|
||||
finalModifiers.value.clear();
|
||||
mainKey.value = null;
|
||||
emit('update:modelValue', '');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isRecording.value || isFinalized.value) return;
|
||||
|
||||
if (modifierKeys.includes(e.key as any)) {
|
||||
pressedModifiers.value.add(e.key);
|
||||
} else if (!['Tab', 'Escape'].includes(e.key)) {
|
||||
mainKey.value = e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||
finalize();
|
||||
|
||||
// 主键按下后立即取消焦点(触发 blur 逻辑)
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (!isRecording.value || isFinalized.value) return;
|
||||
if (modifierKeys.includes(e.key as any)) {
|
||||
pressedModifiers.value.delete(e.key);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
});
|
||||
|
||||
// 初始化值支持:从 modelValue 拆解 modifier 和主键
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (!val) {
|
||||
clearAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = val.split('+').map(normalizeModifier);
|
||||
const mods = parts.filter(p => modifierKeys.includes(p as any));
|
||||
const key = parts.find(p => !modifierKeys.includes(p as any));
|
||||
|
||||
if (mods.length && key) {
|
||||
finalModifiers.value = new Set(mods);
|
||||
mainKey.value = key;
|
||||
isFinalized.value = true;
|
||||
} else {
|
||||
clearAll(); // 防止非法值
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-input
|
||||
style="width: 160px;"
|
||||
readonly
|
||||
:value="''"
|
||||
@focus="startRecording"
|
||||
@mousedown.stop
|
||||
@blur="isRecording = false"
|
||||
>
|
||||
<template #suffix>
|
||||
<span v-html="display" />
|
||||
</template>
|
||||
</a-input>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { ref,watch ,onMounted,nextTick} from 'vue'
|
||||
import ListItem from '../Setting/ListRow.vue'
|
||||
import ShortcutInput from '../Setting/ShortcutInput.vue';
|
||||
import WinShortcutInput from '../Setting/WinShortcutInput.vue';
|
||||
import { parseShortcutToHotkeyWin,parseShortcutToHotkey,formatHotkeyStringmac ,formatHotkeyStringWin} from '../../utils/hotkeyUtils'; // 🔁 引入工具函数
|
||||
|
||||
import { UpHotkey, GetHotkeys } from '../../../bindings/cmbone/internal/services/hotkeyservice';
|
||||
import { IsmacOS } from '../../utils/osinfo'
|
||||
import { message } from 'ant-design-vue';
|
||||
const ismacos=ref(false)
|
||||
// const modelValue = ref(false)
|
||||
const OpenShortcut = ref('');
|
||||
const OpenSetting = ref('');
|
||||
|
||||
const isInitialized = ref(false);
|
||||
|
||||
let lastSaved = '';
|
||||
//const action = 'OpenSearch';
|
||||
// 监听变化自动保存
|
||||
watch(OpenShortcut, async (newShortcut) => {
|
||||
if (!isInitialized.value) return;
|
||||
|
||||
if (!newShortcut || newShortcut === lastSaved) return;
|
||||
|
||||
try {
|
||||
if(ismacos.value){
|
||||
await SendHanld(1,newShortcut);
|
||||
}else{
|
||||
await SendHanldWin(1,newShortcut);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.success('快捷键保存失败: ' + e.message);
|
||||
}
|
||||
},{ flush: 'post' }
|
||||
);
|
||||
|
||||
|
||||
watch(OpenSetting, async (newShortcut) => {
|
||||
if (!isInitialized.value) return;
|
||||
|
||||
if (!newShortcut || newShortcut === lastSaved) return;
|
||||
try {
|
||||
if(ismacos.value){
|
||||
await SendHanld(2,newShortcut);
|
||||
}else{
|
||||
await SendHanldWin(2,newShortcut);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.success('快捷键保存失败: ' + e.message);
|
||||
}
|
||||
},{ flush: 'post' }
|
||||
);
|
||||
|
||||
|
||||
const SendHanld = async (item,newShortcut) => {
|
||||
try {
|
||||
const parsed = parseShortcutToHotkey(newShortcut);
|
||||
if (!parsed) {
|
||||
console.error('❌ 快捷键格式错误:', newShortcut);
|
||||
return;
|
||||
}
|
||||
message.success('快捷键已保存');
|
||||
await UpHotkey(item,parsed.key, parsed.modifier);
|
||||
} catch (e: any) {
|
||||
message.success('快捷键保存失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const SendHanldWin = async (item,newShortcut) => {
|
||||
try {
|
||||
const parsed = parseShortcutToHotkeyWin(newShortcut);
|
||||
if (!parsed) {
|
||||
console.error('❌ 快捷键格式错误:', newShortcut);
|
||||
return;
|
||||
}
|
||||
message.success('快捷键已保存');
|
||||
await UpHotkey(item,parsed.key, parsed.modifier);
|
||||
} catch (e: any) {
|
||||
message.success('快捷键保存失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
interface HotkeyItem {
|
||||
id: number;
|
||||
keycode: number;
|
||||
modifiers: number;
|
||||
}
|
||||
const hotkeyentry = ref<HotkeyItem[]>([]);
|
||||
|
||||
const Gethotkey = async () => {
|
||||
hotkeyentry.value = await GetHotkeys();
|
||||
if (hotkeyentry.value && hotkeyentry.value.length > 0) {
|
||||
if(ismacos.value){
|
||||
OpenShortcut.value = formatHotkeyStringmac(hotkeyentry.value[0].keycode, hotkeyentry.value[0].modifiers);
|
||||
OpenSetting.value = formatHotkeyStringmac(hotkeyentry.value[1].keycode, hotkeyentry.value[1].modifiers);
|
||||
}
|
||||
else{
|
||||
OpenShortcut.value = formatHotkeyStringWin(hotkeyentry.value[0].keycode, hotkeyentry.value[0].modifiers);
|
||||
OpenSetting.value = formatHotkeyStringWin(hotkeyentry.value[1].keycode, hotkeyentry.value[1].modifiers);
|
||||
}
|
||||
await nextTick();
|
||||
isInitialized.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
onMounted(async() => {
|
||||
ismacos.value=IsmacOS()
|
||||
Gethotkey();
|
||||
// 等下一轮 DOM 渲染完成后才启用监听,避免初始赋值触发 watch
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white" >{{ $t('components.general.title.shortcut') }}</h2>
|
||||
<div>
|
||||
<div class="bg-white rounded-lg shadow divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
|
||||
<ListItem :label="$t('components.general.label.open_second')" :subLabel="$t('components.general.subLabel.ht_shortcut')">
|
||||
<ShortcutInput v-if="ismacos" v-model:modelValue="OpenShortcut" />
|
||||
<WinShortcutInput v-else v-model:modelValue="OpenShortcut" />
|
||||
</ListItem>
|
||||
<ListItem :label="$t('components.general.label.open_setting')" subLabel="">
|
||||
<ShortcutInput v-if="ismacos" v-model:modelValue="OpenSetting" />
|
||||
<WinShortcutInput v-else v-model:modelValue="OpenSetting" />
|
||||
</ListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
h2{
|
||||
text-align: left;
|
||||
margin:15px 15px 4px 15px;
|
||||
font-size:15px;
|
||||
}
|
||||
.rg_desc{
|
||||
text-align: left;
|
||||
margin:4px 0px 4px 8px;
|
||||
font-size:13px;
|
||||
color:#999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="flex h-full ">
|
||||
<!-- Left List -->
|
||||
<div class=" pl-1 pb-2 pr-1 flex flex-col" :class="currentTag === null ? 'w-3/5 ' : 'w-2/5'">
|
||||
<div class="mr-2 ml-1">
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white float-left">
|
||||
{{ $t('components.tags.title.manage_tags') }}
|
||||
</h2>
|
||||
<button @click="createNew"
|
||||
class="w-[100px] float-right mb-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-2 py-2 rounded-xl flex items-center gap-2 font-medium transition-colors shadow-sm">
|
||||
<span class="text-xl pb-1">+</span> {{ $t('components.tags.buttons.newtag') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="relative w-full pr-2">
|
||||
<FilterOutlined class="absolute pl-1 top-[15px] -translate-y-1/2 text-gray-400 pointer-events-none" />
|
||||
<input v-model="keyword" placeholder="Search Tag..."
|
||||
class="w-full h-[30px] outline-none mb-2 pl-6 pr-3 py-1 border rounded-lg text-sm dark:bg-gray-900 dark:text-white dark:border-slate-500" />
|
||||
</div>
|
||||
<!-- List -->
|
||||
<div class="flex-1 overflow-auto space-y-1 scrollbar-thin">
|
||||
<div v-for="item in filteredTags" :key="item.id" class="item" :class="{ selected: currentTag?.id === item.id }"
|
||||
@click="select(item)">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded mr-1 ml-[-4px] mt-1"
|
||||
:class="item.color">
|
||||
<img :src="item.icon" class="h-8 w-8 object-contain" draggable="false" />
|
||||
</div>
|
||||
<div class="flex-1 ">
|
||||
<div class=" text-sm text-left line-clamp-1 break-words dark:text-white">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="text-sm text-left text-gray-500 line-clamp-2 dark:text-gray-300">
|
||||
{{ item.keywords.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider dashed plain>END</a-divider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Editor -->
|
||||
<div class="flex w-3/5 pt-2 pb-6 bg-white rounded-lg dark:bg-gray-800">
|
||||
<div v-if="currentTag" class="w-full">
|
||||
<div class="w-full mb-4 px-5 border-b gap-2 items-center dark:border-gray-700">
|
||||
<div class="float-right flex gap-2 pt-1 w-[20%] justify-end">
|
||||
<DeleteOutlined v-if="currentTag.is_default != 1 && currentTag.id != 0" @click="deleteTag"
|
||||
class="px-1 cursor-pointer float-left text-red-600" />
|
||||
<CheckCircleOutlined class=" px-1 cursor-pointer float-right text-green-600"
|
||||
@click="editTag(currentTag)" />
|
||||
</div>
|
||||
<div class="pb-3 itmes-left">
|
||||
<input v-model="currentTag.name"
|
||||
class="text-sm w-[80%] font-semibold outline-none dark:bg-gray-800 dark:text-white "
|
||||
placeholder="Tag Title" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 tracking-wider">{{ $t('components.tags.label.tagicon') }}</div>
|
||||
<!-- 当前预览 -->
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{{ $t('components.tags.label.current_preview') }}:</span>
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white" :class="selectedColor">
|
||||
<img v-if="customIcon" :src="customIcon" class="w-6 h-6 object-contain" />
|
||||
<img v-else-if="selectedIconComponent" :src="selectedIconComponent" class="w-6 h-6 object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- icon 列表 -->
|
||||
<div class="flex items-center gap-3 flex-wrap ">
|
||||
<div v-for="item in iconOptions" :key="item.name" @click="selectIcon(item.name)"
|
||||
class="shrink-0 w-10 h-10 rounded-lg flex items-center justify-center cursor-pointer transition" :class="[
|
||||
selectedIcon === item.name && !customIcon
|
||||
? 'bg-indigo-600 text-white shadow'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
]">
|
||||
<img :src="item.component" class="w-8 h-8 object-contain" />
|
||||
</div>
|
||||
<div class="w-px h-6 bg-gray-200 mx-1"></div>
|
||||
<label for="fileInput" @click="handleUpload"
|
||||
class="w-10 h-10 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<UploadOutlined class="text-gray-500 dark:text-gray-200" />
|
||||
</label>
|
||||
</div>
|
||||
<!-- 提示 -->
|
||||
<div class="text-xs text-gray-400 mt-2">
|
||||
SVG, PNG or JPG (max 100x100px recommended)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<div class="mb-2">
|
||||
<span class="text-xs text-left text-gray-500 dark:text-gray-400">
|
||||
{{ $t('components.tags.label.tagcolor') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div v-for="color in colors" :key="color" @click="selectedColor = color"
|
||||
class="w-5 h-5 rounded-full cursor-pointer border-2 shrink-0 relative"
|
||||
:class="[color, selectedColor === color ? 'ring-2 ring-white ring-offset-2 ring-offset-black/20 scale-100 ' : 'border-transparent']">
|
||||
<div v-if="color === 'bg-transparent'"
|
||||
class="absolute inset-0 bg-[linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)] bg-[length:6px_6px] bg-[position:0_0,0_3px,3px_-3px,-3px_0px]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TAG 子标签 -->
|
||||
<div class="px-4">
|
||||
<div class="mb-2">
|
||||
<span class="text-xs text-left text-gray-500 dark:text-gray-400">{{ $t('components.tags.label.subtags') }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<input v-model="newSubTag" @keyup.enter="addSubTag"
|
||||
class="flex-1 h-[30px] bg-gray-100 rounded-lg px-3 py-2 outline-none text-sm dark:bg-gray-900 dark:text-white"
|
||||
placeholder="Add sub tag..." />
|
||||
<button @click="addSubTag" class="px-4 py bg-black text-white rounded-lg text-sm ">
|
||||
{{ $t('components.tags.buttons.add_subtag') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 标签列表 -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="(tag, index) in currentTag.keywords" :key="index"
|
||||
class="flex items-center gap-2 bg-gray-100 px-2 py-1.5 rounded-lg text-sm dark:bg-gray-900 dark:text-white">
|
||||
<span class="px-1">{{ tag }}</span>
|
||||
<button @click="removeSubTag(index)"
|
||||
class="w-4 h-4 flex items-center justify-center text-gray-400 hover:text-red-500 ">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="currentTag.keywords.length === 0" class="text-xs text-gray-400 mt-2">
|
||||
No sub tags yet
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right px-4">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 p-6 text-center mx-auto ">
|
||||
<img src="/system/mychoice.svg" class="mx-auto mb-4" style="width: 120px; height: 120px;" />
|
||||
Select a left tags to view details
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, createVNode } from 'vue'
|
||||
import { i18n } from '../../utils/i18n'
|
||||
import { CheckCircleOutlined, DeleteOutlined, UploadOutlined, ExclamationCircleOutlined, FilterOutlined} from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
const keyword = ref('')
|
||||
//const tags = ref<{id:number, name:string, color:string}[]>([])
|
||||
const currentTag = ref<Tag | null>(null)
|
||||
interface Tag {
|
||||
id: number
|
||||
tag_key: string
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
keywords: string[]
|
||||
is_default: number
|
||||
sort: number
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
const Tags = ref<Tag[]>([
|
||||
{
|
||||
id: 1,
|
||||
tag_key: 'work',
|
||||
name: 'Work',
|
||||
color: 'bg-blue-100',
|
||||
icon: '/visual/work.png',
|
||||
keywords: ['office', 'project', 'meeting'],
|
||||
is_default: 0,
|
||||
sort: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tag_key: 'life',
|
||||
name: 'Life',
|
||||
color: 'bg-green-100',
|
||||
icon: '/visual/eat.png',
|
||||
keywords: ['home', 'family', 'health'],
|
||||
is_default: 0,
|
||||
sort: 2
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tag_key: 'rest',
|
||||
name: 'Rest',
|
||||
color: 'bg-yellow-100',
|
||||
icon: '/visual/coffee.png',
|
||||
keywords: ['break', 'relax', 'leisure'],
|
||||
is_default: 0,
|
||||
sort: 3
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
tag_key: 'read',
|
||||
name: 'Read',
|
||||
color: 'bg-purple-100',
|
||||
icon: '/visual/read.png',
|
||||
keywords: ['book', 'article', 'study'],
|
||||
is_default: 0,
|
||||
sort: 4
|
||||
}
|
||||
])
|
||||
const filteredTags = computed(() => {
|
||||
return Tags.value.filter(t => t.name.toLowerCase().includes(keyword.value.toLowerCase()))
|
||||
})
|
||||
function createNew() {
|
||||
currentTag.value = null
|
||||
selectedColor.value = 'bg-slate-100'
|
||||
selectedIcon.value = 'Default'
|
||||
customIcon.value = null
|
||||
currentTag.value = {
|
||||
id: 0,
|
||||
tag_key: '',
|
||||
name: '',
|
||||
color: 'bg-slate-100',
|
||||
icon: '/visual/default.png',
|
||||
keywords: [],
|
||||
is_default: 0,
|
||||
sort: 0
|
||||
}
|
||||
}
|
||||
|
||||
function editTag(tag) {
|
||||
if (currentTag.value == null) return
|
||||
tag.color = selectedColor.value
|
||||
tag.icon = customIcon.value || selectedIconComponent.value || '/visual/default.png'
|
||||
if (tag.id == 0) {
|
||||
tag.Lang = i18n.global.locale.value
|
||||
tag.tag_key = tag.name.toLowerCase().replace(/\s+/g, '-')
|
||||
tag.id = Date.now() + Math.random()
|
||||
Tags.value.unshift(tag)
|
||||
currentTag.value = null
|
||||
} else {
|
||||
const index = Tags.value.findIndex(t => t.id === tag.id)
|
||||
if (index !== -1) {
|
||||
Tags.value[index] = tag
|
||||
}
|
||||
currentTag.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTag() {
|
||||
if (currentTag.value == null) return
|
||||
Modal.confirm({
|
||||
title: `Do you want to delete the tag "${currentTag.value?.name}"?`,
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: 'When clicked the OK button, this dialog will be closed after 1 second',
|
||||
okText: 'Yes',
|
||||
okType: 'danger',
|
||||
cancelText: 'No',
|
||||
async onOk() {
|
||||
try {
|
||||
Tags.value = Tags.value.filter(t => t.id !== currentTag.value?.id)
|
||||
message.success('Tag deleted successfully.')
|
||||
currentTag.value = null
|
||||
} catch (error) {
|
||||
message.error('Failed to delete the tag.')
|
||||
}
|
||||
},
|
||||
onCancel() { },
|
||||
});
|
||||
}
|
||||
// 选择
|
||||
function select(p: Tag) {
|
||||
selectedColor.value = p.color
|
||||
customIcon.value = p.icon
|
||||
currentTag.value = JSON.parse(JSON.stringify(p))
|
||||
}
|
||||
function fetchTags() {
|
||||
// 调用 API 获取标签列表
|
||||
}
|
||||
onMounted(async () => {
|
||||
await fetchTags()
|
||||
})
|
||||
|
||||
const selectedColor = ref('bg-slate-100') // 默认颜色
|
||||
const colors = [
|
||||
'bg-transparent',
|
||||
'bg-slate-100',
|
||||
'bg-gray-100',
|
||||
'bg-red-100',
|
||||
'bg-yellow-100',
|
||||
'bg-green-100',
|
||||
'bg-blue-100',
|
||||
'bg-indigo-100',
|
||||
'bg-purple-100',
|
||||
'bg-slate-400',
|
||||
'bg-red-400',
|
||||
'bg-yellow-400',
|
||||
'bg-green-400',
|
||||
'bg-blue-400',
|
||||
'bg-indigo-400',
|
||||
'bg-purple-400',
|
||||
'bg-red-700',
|
||||
'bg-yellow-700',
|
||||
'bg-green-700',
|
||||
'bg-zinc-800'
|
||||
]
|
||||
// icon 列表
|
||||
const iconOptions = [
|
||||
{ name: 'Default', component: '/visual/default.png' },
|
||||
{ name: 'Work', component: '/visual/work.png' },
|
||||
{ name: 'Life', component: '/visual/eat.png' },
|
||||
{ name: 'Rest', component: '/visual/coffee.png' },
|
||||
{ name: 'read', component: '/visual/read.png' },
|
||||
]
|
||||
|
||||
const selectedIcon = ref('Default')
|
||||
const customIcon = ref<string | null>(null)
|
||||
const selectedIconComponent = computed(() => {
|
||||
return iconOptions.find(i => i.name === selectedIcon.value)?.component
|
||||
})
|
||||
|
||||
function selectIcon(name: string) {
|
||||
selectedIcon.value = name
|
||||
customIcon.value = null
|
||||
}
|
||||
// 上传处理
|
||||
async function handleUpload(e: Event) {
|
||||
console.log('上传事件:')
|
||||
}
|
||||
|
||||
// 子标签
|
||||
const newSubTag = ref('')
|
||||
// 添加
|
||||
function addSubTag() {
|
||||
const val = newSubTag.value.trim()
|
||||
if (!val) return
|
||||
if (currentTag.value?.keywords.includes(val)) return
|
||||
currentTag.value?.keywords.push(val)
|
||||
newSubTag.value = ''
|
||||
}
|
||||
// 删除
|
||||
function removeSubTag(index: number) {
|
||||
currentTag.value?.keywords.splice(index, 1)
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
/* 全局样式(可放在 main.css 或 tailwind.css) */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.item {
|
||||
@apply flex p-2 hover:bg-gray-100 cursor-pointer rounded dark:hover:bg-slate-700 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.selected {
|
||||
@apply border-l-4 border-indigo-500 bg-white dark:bg-slate-500 dark:border-slate-600;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@apply px-2 py-1 text-xs bg-gray-100 rounded mr-2;
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
@apply bg-purple-100 text-purple-600;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user