chore: migrate and structure cmbone project

This commit is contained in:
QiuSW
2026-07-08 15:13:26 +08:00
parent 519a532038
commit e5d10577c0
123 changed files with 9076 additions and 25 deletions
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onMounted} from 'vue'
import { themeConfig } from './utils/ThemeManager'
const applyTheme = () => {
const userTheme = localStorage.getItem('theme')
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const isDark = userTheme === 'dark' || (!userTheme && systemPrefersDark)
document.documentElement.classList.toggle('dark', isDark)
}
onMounted(() => {
applyTheme()
// 可监听系统主题变化自动更新(可选)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
applyTheme()
})
})
</script>
<template>
<ConfigProvider :theme="themeConfig" >
<router-view />
</ConfigProvider>
</template>
<style scoped>
html, body {
margin: 0;
overflow-x: hidden; /* 禁止横向滚动 */
}
/* 可选:全局背景适配 */
body {
@apply bg-white dark:bg-gray-900 text-black dark:text-white;
}
.drag-region {
-webkit-app-region: drag;
--wails-draggable: drag;
}
</style>
+108
View File
@@ -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>
+171
View File
@@ -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>
+70
View File
@@ -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>
+205
View File
@@ -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>
+144
View File
@@ -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>
+384
View File
@@ -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>
+96
View File
@@ -0,0 +1,96 @@
{
"app": {
"title": "My App",
"login": "Login"
},
"appmenus": {
"second": "Second win",
"version": "Version: 0.3.0",
"quit": "Quit",
"preferences": "Settings"
},
"menus": {
"about": "About",
"general": "General",
"shortcut": "Shortcut key",
"second": "Second win",
"setting": "Settings",
"dashboard": "Dashboard",
"tags": "Tags"
},
"components": {
"general": {
"title": {
"application": "Apply settings",
"exterior": "Appearance settings",
"power": "Permission settings",
"update": "Application update",
"shortcut": "Shortcut key settings"
},
"label": {
"assistant_access": "Accessibility permissions",
"automatic_up": "Automatically check for updates",
"disk_access": "Full disk access",
"language": "Interface language",
"next_up": "Automatically check for updates next time",
"startup": "Started when logged in",
"theme": "Theme mode",
"open_second": "Open second window",
"open_setting": "Open manages window"
},
"subLabel": {
"sb_assistant_access": "Accessibility permission is required to operate clipboard contents",
"sb_disk_access": "Full disk access is required to implement file preview",
"ht_shortcut": "Press the combination key, which must contain at least one modifier key and one main key"
},
"buttons": {
"authorized": "Auth",
"go_authorized": "Auth now"
}
},
"themesetting": {
"select": {
"opt_dark": "Dark",
"opt_light": "Bright"
}
},
"about": {
"version": "Version",
"author": "Jin Gong",
"description_p1": "Just do the right thing,",
"description_p2": "at the right time."
},
"dashboard":{
"title":{
"upcoming_task":"Upcoming Tasks"
},
"label":{
"total_tasks":"Total Tasks",
"completed_tasks":"Completed Tasks",
"pending_tasks":"Pending Tasks",
"no_tasks":"No Tasks"
},
"buttons":{
"newtask":"New Task",
"upcoming_all":"All",
"upcoming_priority":"Priority"
}
},
"tags":{
"title":{
"manage_tags":"Tags"
},
"label":{
"name":"Name",
"tagcolor":"TAG COLOR",
"tagicon":"TAG ICON",
"current_preview":"CURRENT PREVIEW",
"subtags":"SUB TAGS"
},
"buttons":{
"newtag":"New Tag",
"add_subtag":"Add"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import en from './en.json'
import zh from './zh.json'
import zhHK from './zh-HK.json'
export const availableLocales = ['en', 'zh','zh-HK'] as const
export type Locale = typeof availableLocales[number]
export async function loadLocaleMessages(locale: Locale) {
const messagesMap = {
en,
zh,
'zh-HK':zhHK
}
return messagesMap[locale]
}
+97
View File
@@ -0,0 +1,97 @@
{
"appmenus":{
"second": "第二窗口",
"version": "版本: 0.3.0",
"quit": "退出應用",
"preferences": "偏好設定"
},
"menus": {
"about": "關於",
"backup": "數據備份",
"general": "通用設置",
"history": "歷史記錄",
"remote": "遠程共享",
"shortcut": "快速鍵",
"second": "第二窗口",
"setting": "設置",
"dashboard": "儀表盤",
"tags": "標籤"
},
"app": {
"login": "登入",
"title": "我的應用"
},
"components": {
"general": {
"buttons": {
"authorized": "已授權",
"go_authorized": "去授權"
},
"label": {
"assistant_access": "輔助功能訪問權限",
"automatic_up": "自動檢查更新",
"disk_access": "完全磁盤訪問權限",
"language": "界面語言",
"next_up": "下次啟動自動檢查更新",
"startup": "登錄時啟動",
"theme": "主題模式",
"open_second": "打開第二窗口",
"open_setting": "打開管理窗口"
},
"subLabel": {
"sb_assistant_access": "需要無障礙訪問權限來操作剪切板內容",
"sb_disk_access": "需要完全磁盤訪問權限來實現文件預覽",
"ht_shortcut": "按下組合鍵,必須包含至少一個修飾鍵和一個主鍵"
},
"title": {
"application": "應用設置",
"exterior": "外觀設置",
"power": "權限設置",
"update": "應用更新"
}
},
"themesetting": {
"select": {
"opt_dark": "暗色模式",
"opt_light": "亮色模式"
}
},
"about": {
"version": "版本",
"author": "Jin Gong",
"description_p1": "只是在合適的時候,",
"description_p2": "做一些合適的事。"
},
"dashboard":{
"title":{
"upcoming_task": "即將到來的任務"
},
"label":{
"total_tasks":"總任務數",
"completed_tasks":"已完成任務",
"pending_tasks":"待辦任務",
"no_tasks":"暫無任務"
},
"buttons":{
"newtask":"新建任務",
"upcoming_all":"全部",
"upcoming_priority":"優先"
}
},
"tags":{
"title":{
"manage_tags":"標籤"
},
"label":{
"icon":"標籤圖示",
"current_preview":"當前預覽",
"tagcolor":"標籤顏色",
"subtags":"子標籤"
},
"buttons":{
"newtag":"新建標籤",
"add_subtag":"添加"
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
{
"app": {
"title": "我的应用",
"login": "登录"
},
"appmenus": {
"second": "第二窗口",
"preferences": "偏好设置",
"version": "版本:0.3.0",
"quit": "退出应用"
},
"menus":{
"dashboard":"仪表盘",
"general":"通用设置",
"shortcut":"快捷键",
"about":"关于",
"second":"第二窗口",
"setting":"设置",
"tags":"标签"
},
"components":{
"home":{},
"general":{
"title":{
"power":"权限设置",
"application":"应用设置",
"exterior":"外观设置",
"update":"应用更新",
"shortcut":"快捷键设置"
},
"label":{
"assistant_access":"辅助功能访问权限",
"disk_access":"完全磁盘访问权限",
"startup" :"登录时启动",
"language":"界面语言",
"theme":"主题模式",
"automatic_up":"自动检查更新",
"next_up":"下次启动自动检查更新",
"open_second":"打开第二窗口",
"open_setting":"打开管理窗口"
},
"subLabel":{
"sb_assistant_access":"需要无障碍访问权限来操作剪切板内容",
"sb_disk_access":"需要完全磁盘访问权限来实现文件预览",
"ht_shortcut":"按下组合键,必须包含至少一个修饰键和一个主键"
},
"buttons":{
"authorized":"已授权",
"go_authorized":"去授权"
}
},
"themesetting":{
"select":{
"opt_light":"亮色模式",
"opt_dark":"暗色模式"
}
},
"about": {
"version": "版本",
"author": "Jin Gong",
"description_p1": "只是在合适的时候,",
"description_p2": "做一些合适的事。"
},
"dashboard":{
"title":{
"upcoming_task":"即将进行的任务"
},
"label":{
"total_tasks":"总任务数",
"completed_tasks":"已完成任务",
"pending_tasks":"待办任务",
"no_tasks":"暂无任务"
},
"buttons":{
"newtask":"新建任务",
"upcoming_all":"全部",
"upcoming_priority":"优先"
}
},
"tags":{
"title":{
"manage_tags":"标签"
},
"label":{
"name":"名称",
"tagcolor":"标签颜色",
"tagicon":"标签图标",
"current_preview":"当前预览",
"subtags":"子标签"
},
"buttons":{
"newtag":"新增标签",
"add_subtag":"添加",
"delete_tag":"删除标签",
"delete_subtag":"删除子标签"
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import Antd from 'ant-design-vue';//add
import App from './App.vue'
import './style.css'
import 'ant-design-vue/dist/reset.css';
import {i18n,setupI18n } from './utils/i18n'
import { initTheme } from './utils/ThemeManager'//add
import router from './router/index'
import { GetLanguage } from '../bindings/cmbone/internal/services/appconfigservice'
initTheme()
if (window.location.hash === '' || window.location.hash === '#/') {
router.replace('/second')
}
async function bootstrap(){
await setupI18n('zh')//默认语言或从后端读取
createApp(App).use(Antd).use(router).use(i18n).mount('#app')
router.isReady().then(async () => {
const lang = await GetLanguage() //从后端读取语言设置 lang as any|| 'zh'
await setupI18n(lang as any|| 'zh')
})
}
bootstrap()
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref } from 'vue'
import { applyTheme } from '../../utils/ThemeManager'
import { RecognizeImageBase64 } from '../../../bindings/cmbone/internal/services/ocrservice'
const bc = new BroadcastChannel('theme')
bc.onmessage = (e) => {
applyTheme(e.data)
}
//-- OCR功能 ---
declare global {
interface Window {
go: any
}
}
const imageUrl = ref<string>('')
const imageBase64 = ref<string>('')
const result = ref<string>('')
const loading = ref<boolean>(false)
// 文件处理
function handleFile(file: File) {
const reader = new FileReader()
reader.onload = () => {
const base64 = reader.result as string
imageUrl.value = base64
imageBase64.value = base64.split(',')[1]
result.value = '' // 清空旧结果
}
reader.readAsDataURL(file)
}
// 点击选择
function selectImage() {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = (e: any) => {
const file = e.target.files[0]
if (file) handleFile(file)
}
input.click()
}
// 拖拽
function onDrop(e: DragEvent) {
const file = e.dataTransfer?.files[0]
if (file) handleFile(file)
}
// OCR 调用
async function handleOCR() {
if (!imageBase64.value) return
loading.value = true
result.value = ''
try {
const res = await RecognizeImageBase64(imageBase64.value)
result.value = res || '未识别到内容'
} catch (err) {
console.error(err)
result.value = '识别失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div
class="flex items-start justify-center pt-8 h-screen w-screen h-full w-full font-sans select-none bg-gray-100 drag-region dark:bg-gray-900">
<TransitionGroup name="card" tag="div">
<div
class="flex-1 w-[300px] rounded-2xl bg-white/80 dark:bg-gray-700 dark:text-white backdrop-blur-md px-4 py-2 shadow-[0_8px_24px_rgba(0,0,0,0.12)] transition">
<!-- 图片区域 -->
<div
class="h-20 w-full border-2 border-dashed border-gray-300 rounded-xl flex items-center justify-center cursor-pointer overflow-hidden hover:border-blue-400 transition"
@click="selectImage" @drop.prevent="onDrop" @dragover.prevent>
<img v-if="imageUrl" :src="imageUrl" class="w-full h-full object-cover" />
<span v-else class="text-gray-400 text-sm">
点击或者拖拽图片
</span>
</div>
<button @click="handleOCR" :disabled="!imageBase64 || loading" class="w-full my-2 py-1 rounded-lg text-white transition
bg-blue-500 hover:bg-blue-600
disabled:bg-gray-300 disabled:cursor-not-allowed">
<span v-if="loading">识别中...</span>
<span v-else>OCR识别</span>
</button>
<div v-if="result" class="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 max-h-40 overflow-auto dark:bg-gray-800 dark:text-gray-300">
<div class="font-medium mb-1">识别结果:</div>
<pre class="whitespace-pre-wrap">{{ result }}</pre>
</div>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
</style>
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHashHistory} from 'vue-router'
import MainPage from '../components/Main/Index.vue'
import SecondPage from '../pages/Home/Index.vue'
const routes = [
{ path: '/', component: MainPage },
{ path: '/second', component: SecondPage }
]
export default createRouter({
history: createWebHashHistory(), // Use createWebHashHistory for hash-based routing
routes
})
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+55
View File
@@ -0,0 +1,55 @@
// src/utils/ThemeManager.ts
import { theme } from 'ant-design-vue'
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
import { ref } from 'vue'
export const lightTheme = {
algorithm: theme.defaultAlgorithm,
}
export const darkTheme = {
algorithm: theme.darkAlgorithm,
}
const THEME_KEY = 'theme'
export type ThemeMode = 'light' | 'dark' | 'systemdefault'
export function applyTheme(mode: ThemeMode) {
let resolvedTheme = mode
if (mode === 'systemdefault') {
resolvedTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('dark', 'light')
document.documentElement.classList.add(resolvedTheme)
themeConfig.value = getAntdCurrentTheme(resolvedTheme)
}
export function initTheme() {
const savedTheme = (localStorage.getItem(THEME_KEY) || 'light') as ThemeMode
applyTheme(savedTheme)
// 监听系统变化,仅在 systemdefault 时响应
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const current = getCurrentTheme()
if (current === 'systemdefault') {
applyTheme('systemdefault')
}
})
}
export function setTheme(mode: ThemeMode) {
localStorage.setItem(THEME_KEY, mode)
applyTheme(mode)
}
export function getCurrentTheme(): ThemeMode {
return (localStorage.getItem(THEME_KEY) || 'light') as ThemeMode
}
// 全局响应式主题状态
//export const themeMode = ref<ThemeMode>(getCurrentTheme())
export const currentMode = ref<'light' | 'dark'>(localStorage.getItem('theme') as 'light' | 'dark' || 'light')
export const themeConfig = ref<ThemeConfig>(getAntdCurrentTheme(currentMode.value))
export function getAntdCurrentTheme(mode: ThemeMode): ThemeConfig {
return mode === 'dark' ? darkTheme : lightTheme
}
+199
View File
@@ -0,0 +1,199 @@
// hotkeyUtils.ts
const modifierMap: Record<string, number> = {
Control: 256,
Alt: 512,
Shift: 1024,
Meta: 2048,
};
// 修饰键位定义(macOS 通常使用)
export const MODIFIERS = {
CMD: 1 << 8, // 256
SHIFT: 1 << 9, // 512
ALT: 1 << 10, // 1024
CTRL: 1 << 11, // 2048
};
// 快捷键字符串 => 键码映射(mac keycode 映射)
const keyMap: Record<string, number> = {
A: 0, B: 11, C: 8, D: 2, E: 14, F: 3, G: 5,
H: 4, I: 34, J: 38, K: 40, L: 37, M: 46,
N: 45, O: 31, P: 35, Q: 12, R: 15, S: 1,
T: 17, U: 32, V: 9, W: 13, X: 7, Y: 16, Z: 6,
};
export function parseHotkeyString(hotkey: string): { key: number, modifier: number } | null {
if (!hotkey.includes('+')) return null;
const parts = hotkey.split('+');
const mods = parts.slice(0, -1); // 修饰键
const keyStr = parts[parts.length - 1];
let modifier = 0;
for (const mod of mods) {
if (modifierMap[mod]) modifier |= modifierMap[mod];
}
const key = keyStr.toUpperCase().charCodeAt(0); // A → 65, B → 66, S → 83 ...
if (!key || isNaN(key)) return null;
return { key, modifier };
}
export function parseShortcutToHotkey(shortcut: string): { key: number, modifier: number } {
const parts = shortcut.toUpperCase().split('+');
let keys = '';
let modifier = 0;
for (const part of parts) {
switch (part) {
case 'CMD':
case 'COMMAND':
case 'META':
modifier |= 1 << 8;
break;
case 'SHIFT':
modifier |= 1 << 9;
break;
case 'ALT':
case 'OPTION':
modifier |= 1 << 10;
break;
case 'CTRL':
case 'CONTROL':
modifier |= 1 << 11;
break;
default:
keys = part;
}
}
// const keyMap: Record<string, number> = {
// A: 0, B: 11, C: 8, D: 2, E: 14, F: 3, G: 5,
// H: 4, I: 34, J: 38, K: 40, L: 37, M: 46,
// N: 45, O: 31, P: 35, Q: 12, R: 15, S: 1,
// T: 17, U: 32, V: 9, W: 13, X: 7, Y: 16, Z: 6,
// };
const key = keyMap[keys];
return {
key,
modifier,
};
}
// 反向映射:keycode => 字符
const reverseKeyMap = Object.fromEntries(
Object.entries(keyMap).map(([k, v]) => [v, k])
);
// 将 keycode 和 modifier 转换为字符串(如 40, 768 => "Cmd+Shift+K")
export function formatHotkeyString(key: number, modifier: number): string {
const parts: string[] = [];
if (modifier & MODIFIERS.CMD) parts.push('Cmd');
if (modifier & MODIFIERS.SHIFT) parts.push('Shift');
if (modifier & MODIFIERS.ALT) parts.push('Alt');
if (modifier & MODIFIERS.CTRL) parts.push('Ctrl');
const keyStr = reverseKeyMap[key];
if (!keyStr) throw new Error(`Unknown keycode: ${key}`);
parts.push(keyStr.toUpperCase());
return parts.join('+');
}
export function formatHotkeyStringmac(keycode: number, modifiers: number): string {
const keyMap: Record<number, string> = {
0: 'A', 11: 'B', 8: 'C', 2: 'D', 14: 'E', 3: 'F', 5: 'G',
4: 'H', 34: 'I', 38: 'J', 40: 'K', 37: 'L', 46: 'M',
45: 'N', 31: 'O', 35: 'P', 12: 'Q', 15: 'R', 1: 'S',
17: 'T', 32: 'U', 9: 'V', 13: 'W', 7: 'X', 16: 'Y', 6: 'Z'
};
const mods: string[] = [];
if (modifiers & (1 << 8)) mods.push('Cmd'); // mac
if (modifiers & (1 << 9)) mods.push('Shift');
if (modifiers & (1 << 10)) mods.push('Alt');
if (modifiers & (1 << 11)) mods.push('Control');
const key = keyMap[keycode] ?? 'Unknown';
return [...mods, key].join('+');
}
//win
export function formatHotkeyStringWin(keycode: number, modifiers: number): string {
const keyMap: Record<number, string> = {
65: 'A', 66: 'B', 67: 'C', 68: 'D', 69: 'E', 70: 'F', 71: 'G',
72: 'H', 73: 'I', 74: 'J', 75: 'K', 76: 'L', 77: 'M', 78: 'N',
79: 'O', 80: 'P', 81: 'Q', 82: 'R', 83: 'S', 84: 'T', 85: 'U',
86: 'V', 87: 'W', 88: 'X', 89: 'Y', 90: 'Z',
13: 'Enter', 27: 'Esc', 32: 'Space',
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4', 116: 'F5', 117: 'F6',
118: 'F7', 119: 'F8', 120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
37: 'Left', 38: 'Up', 39: 'Right', 40: 'Down',
};
const mods: string[] = [];
if (modifiers & 1) mods.push('Alt');
if (modifiers & 2) mods.push('Ctrl');
if (modifiers & 4) mods.push('Shift');
if (modifiers & 8) mods.push('Win');
const key = keyMap[keycode] ?? `KeyCode(${keycode})`;
return [...mods, key].join('+');
}
export function parseShortcutToHotkeyWin(shortcut: string): { key: number, modifier: number } {
const parts = shortcut.toUpperCase().split('+');
let keyPart = '';
let modifier = 0;
for (const part of parts) {
switch (part) {
case 'ALT':
modifier |= 1;
break;
case 'CTRL':
case 'CONTROL':
modifier |= 2;
break;
case 'SHIFT':
modifier |= 4;
break;
case 'WIN':
case 'CMD':
case 'META':
modifier |= 8;
break;
default:
keyPart = part;
}
}
const key = keyWinMap[keyPart] ?? parseInt(keyPart);
return {
key,
modifier,
};
}
// 快捷键字符串 => 键码映射(win keycode 映射)
const keyWinMap: Record<string, number> = {
A: 65, B: 66, C: 67, D: 68, E: 69, F: 70, G: 71,
H: 72, I: 73, J: 74, K: 75, L: 76, M: 77, N: 78,
O: 79, P: 80, Q: 81, R: 82, S: 83, T: 84, U: 85,
V: 86, W: 87, X: 88, Y: 89, Z: 90,
ENTER: 13, ESC: 27, SPACE: 32,
F1: 112, F2: 113, F3: 114, F4: 115,
F5: 116, F6: 117, F7: 118, F8: 119,
F9: 120, F10: 121, F11: 122, F12: 123,
UP: 38, DOWN: 40, LEFT: 37, RIGHT: 39,
};
+19
View File
@@ -0,0 +1,19 @@
// src/i18n.ts
import { createI18n } from 'vue-i18n'
import { Locale, loadLocaleMessages } from '../locales'
const defaultLocale: Locale = 'zh'
export const i18n = createI18n({
legacy: false, // 使用 Composition API
locale: defaultLocale, // 默认语言
fallbackLocale: 'en',
messages: {},
})
//export default i18n
// 初始化语言(只加载一次)
export async function setupI18n(locale: Locale) {
const messages = await loadLocaleMessages(locale)
i18n.global.setLocaleMessage(locale, messages)
i18n.global.locale.value = locale
}
+30
View File
@@ -0,0 +1,30 @@
import { GetOS } from "../../bindings/cmbone/internal/services/systeminfo";
let cachedOS: string | null = null;
let osInitPromise: Promise<void>
/**
* 初始化 OS,只调用一次
*/
export function initOS(): Promise<void> {
if (!osInitPromise) {
osInitPromise = GetOS().then(os => {
cachedOS = os
})
}
return osInitPromise;
}
export function getOS(): string | null {
if (!cachedOS) {
throw new Error("OS not initialized yet, await OS_READY first")
}
return cachedOS;
}
export function IsmacOS(): boolean {
return cachedOS==='darwin';
}
// ✅ 导出一个初始化状态 Promise
export const OS_READY = initOS()
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />