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
+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>