feat: Enhance hotkeys app with customizable settings and modal support

- Added a modal component for user input in settings.
- Implemented drag-and-drop functionality for customizing hotkeys.
- Integrated toast notifications for user feedback on actions.
- Updated store to manage customizable items and namespaces.
- Enhanced the refresh button to fetch items with a refresh option.
- Improved settings button to open settings in a new tab.
- Added caching mechanisms for hotkeys data.
- Created a settings page to manage hotkeys and namespaces.
- Updated query module to handle configuration retrieval.
This commit is contained in:
2025-12-18 20:56:41 +08:00
parent 42c8b2002e
commit c8f643817c
10 changed files with 2032 additions and 600 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@kevisual/hot-api",
"version": "0.0.3",
"version": "0.0.4",
"description": "",
"main": "index.js",
"basename": "/root/hot-api",
@@ -8,11 +8,11 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"pub": "envision deploy ./dist -k hot-api -v 0.0.3 -u -y y",
"pub:docs": "envision deploy ./dist -k hot-api-docs -v 0.0.3 -u",
"pub": "envision deploy ./dist -k hot-api -v 0.0.4 -u -y y",
"pub:docs": "envision deploy ./dist -k hot-api-docs -v 0.0.4 -u",
"slide:dev": "slidev --open slides/index.md",
"slide:build": "slidev build slides/index.md --base /root/hot-api-slide/",
"slide:pub": "envision deploy ./slides/dist -k hot-api-slide -v 0.0.3 -u",
"slide:pub": "envision deploy ./slides/dist -k hot-api-slide -v 0.0.4 -u",
"ui": "pnpm dlx shadcn@latest add "
},
"keywords": [],
@@ -20,31 +20,39 @@
"license": "MIT",
"type": "module",
"dependencies": {
"@astrojs/mdx": "^4.3.12",
"@astrojs/mdx": "^4.3.13",
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0",
"@astrojs/vue": "^5.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kevisual/app": "^0.0.2",
"@kevisual/cache": "^0.0.3",
"@kevisual/context": "^0.0.4",
"@kevisual/query": "^0.0.31",
"@kevisual/noco": "^0.0.10",
"@kevisual/query": "^0.0.32",
"@kevisual/query-login": "^0.0.7",
"@kevisual/registry": "^0.0.1",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"@uiw/react-md-editor": "^4.0.11",
"antd": "^6.0.1",
"astro": "^5.16.4",
"antd": "^6.1.1",
"astro": "^5.16.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"es-toolkit": "^1.42.0",
"es-toolkit": "^1.43.0",
"github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1",
"lucide-react": "^0.556.0",
"lucide-react": "^0.561.0",
"marked": "^17.0.1",
"marked-highlight": "^2.2.3",
"nanoid": "^5.1.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.2.3",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.25",
@@ -58,10 +66,10 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.2.3",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.24.0",
"packageManager": "pnpm@10.26.0",
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild",

1597
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { wrapBasename } from "@/modules/basename";
import { useStore } from "../store";
export const RefreshButton = () => {
const { isLoading, fetchItems } = useStore();
return (
<button
onClick={() => fetchItems()}
onClick={() => fetchItems(true)}
disabled={isLoading}
className={`
group relative p-3 rounded-full
@@ -32,6 +33,9 @@ export const RefreshButton = () => {
};
export const SettingsButton = () => {
const onClick = () => {
window.open(wrapBasename('/settings/'), '_blank');
}
return (
<button
className="
@@ -43,6 +47,7 @@ export const SettingsButton = () => {
flex items-center justify-center
"
aria-label="Settings"
onClick={onClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -7,7 +7,26 @@
import { useEffect, useState } from 'react';
import { SettingsButton, RefreshButton } from './components/icon';
import { useStore, CardItem } from './store';
import { useStore, CardItem, StoreState } from './store';
import { useShallow } from 'zustand/shallow';
import { toast, ToastContainer } from 'react-toastify';
const CardDetail = ({ item }: { item: CardItem }) => {
return (
<div className="flex w-full items-center space-x-3 p-3 backdrop-blur-md rounded-lg shadow-lg">
{item.iconUrl && (
<img src={item.iconUrl} alt={item.title} className="w-12 h-12 rounded-lg shrink-0" />
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white truncate text-sm">{item.title}</h3>
{item.description && (
<p className="text-xs text-gray-300 truncate mt-1">{item.description}</p>
)}
<p className="text-xs text-gray-400 mt-1">ID: {item.id}</p>
</div>
</div>
);
}
const Card = ({ item }: { item: CardItem }) => {
const [isPressed, setIsPressed] = useState(false);
const { sendEvent } = useStore();
@@ -72,7 +91,21 @@ const Card = ({ item }: { item: CardItem }) => {
{/* Full Title (Truncated if too long) */}
<div className="w-full text-center px-2">
<p className="text-sm font-medium text-white/50 truncate tracking-wide transition-all duration-300 group-hover:text-white group-hover:drop-shadow-md">
<p className="text-sm font-medium text-white/50 truncate tracking-wide transition-all duration-300 group-hover:text-white group-hover:drop-shadow-md"
onClick={(e) => {
e.stopPropagation(); // 阻止点击冒泡,避免触发卡片点击事件
toast.dismiss();
toast.success(<CardDetail item={item} />, {
autoClose: 10000,
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
containerId: 'hotkey-toast',
closeButton: false,
icon: false,
});
}}>
{item.title}
</p>
</div>
@@ -133,55 +166,150 @@ const AdvancedSearch = () => {
};
export const App = () => {
const { items, isLoading, fetchItems } = useStore();
const { items, isLoading, fetchItems } = useStore(
useShallow((state: StoreState) => {
let _items: CardItem[] = []
const customizeItems = state.customizeItems
if (customizeItems && customizeItems.length > 0) {
_items = customizeItems
} else {
_items = state.items
}
return {
items: _items,
isLoading: state.isLoading,
fetchItems: state.fetchItems
}
})
);
const showItems = items.slice(0, 12);
const [isHeaderOpen, setIsHeaderOpen] = useState(false);
const [isHeaderPinned, setIsHeaderPinned] = useState(false);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleToggleClick = () => {
setIsHeaderPinned(!isHeaderPinned);
setIsHeaderOpen(!isHeaderPinned);
};
const handleMouseEnter = () => {
if (!isHeaderPinned) {
setIsHeaderOpen(true);
}
};
const handleMouseLeave = () => {
if (!isHeaderPinned) {
setIsHeaderOpen(false);
}
};
return (
<div
className="min-h-screen w-full bg-cover bg-center bg-no-repeat bg-fixed p-4 sm:p-8 relative"
className="h-screen w-full bg-cover bg-center bg-no-repeat bg-fixed p-4 sm:p-8 relative overflow-auto"
style={{ backgroundImage: "url('https://kevisual.cn/root/resources/upload/1.0.0/2025-12/rRpZIM_IJmc.webp')" }}
>
{/* Overlay for premium look and contrast */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-[4px] z-0 pointer-events-none" />
<div className="max-w-7xl mx-auto relative z-10">
<header className="mb-8 flex items-center justify-between sm:justify-start sm:space-x-6">
<h1 className="hidden sm:block text-5xl font-black relative group cursor-default perspective-1000 italic">
<span className="relative inline-block text-white/70 transition-all duration-700 ease-out group-hover:scale-110 drop-shadow-[0_0_30px_rgba(255,255,255,0.4)] group-hover:drop-shadow-[0_0_50px_rgba(255,255,255,0.6)] tracking-wider transform-gpu" style={{ textShadow: '0 4px 20px rgba(255,255,255,0.3), 0 0 40px rgba(255,255,255,0.2)' }}>
HotKeys
</span>
{/* 3D层次感背景文字 */}
<span className="absolute top-0 left-0 inline-block text-white/20 opacity-20 blur-sm translate-x-1 translate-y-1 transition-all duration-700 group-hover:translate-x-2 group-hover:translate-y-2 tracking-wider" aria-hidden="true">
HotKeys
</span>
{/* 发光边缘效果 */}
<span className="absolute inset-0 bg-white opacity-0 group-hover:opacity-10 blur-xl transition-opacity duration-700" aria-hidden="true" />
</h1>
<div className="flex items-center space-x-3">
<RefreshButton />
<SettingsButton />
<AdvancedSearch />
</div>
</header>
<div className="max-w-7xl mx-auto relative z-10 h-full flex flex-col">
{/* Header Toggle Button */}
<div
className={`absolute top-0 left-1/2 -translate-x-1/2 z-50 transition-all duration-300 ${isHeaderOpen ? 'translate-y-0 opacity-100' : '-translate-y-4 hover:-translate-y-2 opacity-30 hover:opacity-100'}`}
onClick={handleToggleClick}
>
<button
className="w-12 h-6 bg-white/10 backdrop-blur-md border border-white/20 rounded-b-2xl
hover:bg-white/20 hover:border-cyan-400/50 hover:shadow-[0_0_15px_rgba(34,211,238,0.3)]
transition-all duration-300 flex items-center justify-center cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-4 w-4 text-white/70 transition-transform duration-300 ${isHeaderOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Header Dropdown */}
<div
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<header
className={`
flex items-center justify-between sm:justify-start sm:space-x-6
bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-4 mb-2
shadow-lg
transition-all duration-500 ease-in-out origin-top
${isHeaderOpen
? 'opacity-100 scale-y-100 translate-y-0 pointer-events-auto'
: 'opacity-0 scale-y-0 -translate-y-4 pointer-events-none absolute'
}
`}
>
<h1 className="hidden sm:block text-5xl font-black relative group cursor-default perspective-1000 italic">
<span className="relative inline-block text-white/70 transition-all duration-700 ease-out group-hover:scale-110 drop-shadow-[0_0_30px_rgba(255,255,255,0.4)] group-hover:drop-shadow-[0_0_50px_rgba(255,255,255,0.6)] tracking-wider transform-gpu" style={{ textShadow: '0 4px 20px rgba(255,255,255,0.3), 0 0 40px rgba(255,255,255,0.2)' }}>
HotKeys
</span>
{/* 3D层次感背景文字 */}
<span className="absolute top-0 left-0 inline-block text-white/20 opacity-20 blur-sm translate-x-1 translate-y-1 transition-all duration-700 group-hover:translate-x-2 group-hover:translate-y-2 tracking-wider" aria-hidden="true">
HotKeys
</span>
{/* 发光边缘效果 */}
<span className="absolute inset-0 bg-white opacity-0 group-hover:opacity-10 blur-xl transition-opacity duration-700" aria-hidden="true" />
</h1>
<div className="flex items-center space-x-3">
<RefreshButton />
<SettingsButton />
{/* <AdvancedSearch /> */}
</div>
</header>
</div>
{isLoading ? (
<div className="flex justify-center items-center h-64">
<div className="flex justify-center items-center flex-1">
<div className="animate-pulse flex flex-col items-center">
<div className="h-12 w-12 bg-gray-300 rounded-full mb-4"></div>
<div className="h-4 w-32 bg-gray-300 rounded"></div>
</div>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
{items.map((item) => (
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 gap-4 sm:gap-6 content-start">
{showItems.map((item) => (
<Card key={item.id} item={item} />
))}
</div>
)}
</div>
<ToastContainer />
<ToastContainer
position="bottom-center"
autoClose={10000}
hideProgressBar
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
theme='dark'
pauseOnHover
closeButton={false}
containerId="hotkey-toast"
toastClassName="bg-transparent p-0"
style={{
bottom: '40px',
}}
/>
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (value: string) => void;
title: string;
initialValue: string;
placeholder?: string;
}
export const Modal = ({
isOpen,
onClose,
onConfirm,
title,
initialValue,
placeholder = '请输入内容'
}: ModalProps) => {
const [inputValue, setInputValue] = useState(initialValue);
const handleConfirm = () => {
if (inputValue.trim()) {
onConfirm(inputValue.trim());
setInputValue('');
onClose();
}
};
const handleCancel = () => {
setInputValue(initialValue);
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm();
} else if (e.key === 'Escape') {
handleCancel();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-96 max-w-full mx-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
{title}
</h3>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-100"
autoFocus
/>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={handleCancel}
className="px-4 py-2 text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
</button>
<button
onClick={handleConfirm}
disabled={!inputValue.trim()}
className="px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,410 @@
import { useShallow } from "zustand/shallow";
import { StoreState, useStore as useHotApiStore } from "./store.ts";
import { useEffect, useState } from "react";
import { toast, ToastContainer } from 'react-toastify';
import { Modal } from "./modal.tsx";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import clsx from "clsx";
import { wrapBasename } from "@/modules/basename.ts";
const ListItem = ({
position = 'left',
hasItems,
item,
onRemove,
onAdd,
index,
isDraggable = false,
}: {
position?: 'left' | 'right';
hasItems?: any[];
item: any;
onRemove?: (item: any) => void;
onAdd?: (item: any) => void;
index?: number;
isDraggable?: boolean;
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id, disabled: !isDraggable });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const isIn = position === 'right' && hasItems && hasItems.some(i => i.id === item.id);
return (
<div
ref={setNodeRef}
style={style}
className="p-4 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<div className="flex items-center mb-2 space-x-4">
{isDraggable && (
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
)}
{item.iconUrl && (
<img src={item.iconUrl} alt={item.title} className="w-12 h-12 rounded-lg shrink-0" />
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{item.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{item.description}</p>
</div>
<div className="flex items-center space-x-2">
{index !== undefined && <span className="text-xs text-gray-500">: {item.sort || 0}</span>}
{onAdd && (
<button
onClick={() => onAdd(item)}
disabled={isIn}
className={clsx("px-3 py-1 text-sm text-white rounded ",
isIn ? 'bg-green-300 cursor-not-allowed hover:bg-gray-300' : "bg-gray-500 hover:bg-gray-600 cursor-pointer"
)}
>
{isIn ? '已添加' : '添加'}
</button>
)}
{onRemove && (
<button
onClick={() => onRemove(item)}
className=" cursor-pointer p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
};
const SettingsContent = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const store = useHotApiStore(useShallow((state: StoreState) => {
return {
items: state.items,
fetchItems: state.fetchItems,
customizeItems: state.customizeItems || [],
setCustomizeItems: state.setCustomizeItems,
getCustomizeItems: state.getCustomizeItems,
setNamespace: state.setNamespace,
getCacheData: state.getCacheData,
setCacheData: state.setCacheData,
exportCacheData: state.exportCacheData,
importCacheData: state.importCacheData,
namespace: state.namespace,
}
}));
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
useEffect(() => {
store.fetchItems();
// Load customize items from cache
if (store.getCustomizeItems) {
store.getCustomizeItems();
}
}, []);
// Sort customizeItems by sort field
const sortedCustomizeItems = [...store.customizeItems].sort((a: any, b: any) => (a.sort || 0) - (b.sort || 0));
// Handle adding an item to the customize area
const handleAddToCustomize = async (item: any) => {
if (!store.setCustomizeItems) return;
// Check if item already exists
const exists = store.customizeItems.some((existingItem: any) => existingItem.id === item.id);
if (exists) {
toast.warning(`${item.title}」已经添加过了`);
return;
}
// Calculate the next sort value (find max sort + 1)
const currentMaxSort = store.customizeItems.length > 0
? Math.max(...store.customizeItems.map((item: any) => item.sort || 0))
: -1;
const nextSort = currentMaxSort + 1;
// Create a new item with proper sort, keeping the original ID
const newItem = {
...item,
sort: nextSort, // Set sort to next available position
};
// Add the item to customizeItems
const updatedCustomizeItems = [...store.customizeItems, newItem];
await store.setCustomizeItems(updatedCustomizeItems);
toast.success(`${item.title}」已添加`);
};
const handleRemoveFromCustomize = async (itemToRemove: any) => {
if (!store.setCustomizeItems) return;
const updatedCustomizeItems = store.customizeItems
.filter((item: any) => item.id !== itemToRemove.id)
.map((item: any, index: number) => ({
...item,
sort: index // Reorder remaining items
}));
await store.setCustomizeItems(updatedCustomizeItems);
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = sortedCustomizeItems.findIndex((item: any) => item.id === active.id);
const newIndex = sortedCustomizeItems.findIndex((item: any) => item.id === over.id);
const newItems = arrayMove(sortedCustomizeItems, oldIndex, newIndex);
// Update sort values
const updatedItems = newItems.map((item: any, index: number) => ({
...item,
sort: index,
}));
await store.setCustomizeItems!(updatedItems);
};
const handleNamespaceChange = (newNamespace: string) => {
if (store.setNamespace) {
store.setNamespace(newNamespace);
toast.success(`命名空间已修改为: ${newNamespace}`);
}
};
const handleExportData = async () => {
if (!store.exportCacheData) return;
try {
const exportData = await store.exportCacheData();
if (!exportData) {
toast.error('没有数据可以导出');
return;
}
// 创建下载链接
const blob = new Blob([exportData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `hotapi-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('数据导出成功');
} catch (error) {
toast.error('导出失败:' + (error instanceof Error ? error.message : '未知错误'));
}
};
const handleImportData = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file || !store.importCacheData) return;
try {
const content = await file.text();
const result = await store.importCacheData(content);
if (result.success) {
toast.success(`${result.message},导入了 ${result.importedNamespaces?.length || 0} 个命名空间`);
} else {
toast.error(`导入失败:${result.message}`);
}
} catch (error) {
toast.error('导入失败:文件读取错误');
}
};
input.click();
};
return (
<div className="container h-full overflow-hidden mx-auto p-4 grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left side - Customize Items */}
<div className="left h-full">
<div className="border-2 border-gray-300 dark:border-gray-600 rounded-lg p-4">
<div className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-200 flex items-center">
<img src={`https://api.dicebear.com/9.x/bottts/svg?seed=${store.namespace || 'default'}`} alt={store.namespace || 'default'} className="w-12 h-12 rounded-lg shrink-0"
onClick={() => {
window.open(wrapBasename('/'))
}} />
: {store.namespace}
<button
onClick={() => setIsModalOpen(true)}
className="ml-4 p-2 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded cursor-pointer"
aria-label="修改命名空间"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
onClick={handleExportData}
className="ml-2 p-2 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded cursor-pointer"
aria-label="导出数据"
title="导出所有命名空间数据"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 15m0 0l4.5-4.5M12 15V3" />
</svg>
</button>
<button
onClick={handleImportData}
className="ml-2 p-2 text-sm bg-green-500 hover:bg-green-600 text-white rounded cursor-pointer"
aria-label="导入数据"
title="从备份文件导入数据"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v12" />
</svg>
</button>
</div>
{sortedCustomizeItems.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedCustomizeItems.map((item: any) => item.id)}
strategy={verticalListSortingStrategy}
>
{sortedCustomizeItems.map((item: any, index: number) => (
<ListItem
position="left"
key={item.id}
item={item}
index={index}
onRemove={handleRemoveFromCustomize}
isDraggable={true}
/>
))}
</SortableContext>
</DndContext>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>,</p>
</div>
)}
</div>
</div>
{/* Right side - Available Items */}
<div className="right h-full overflow-y-auto">
<div className="border-2 border-gray-300 dark:border-gray-600 rounded-lg p-4">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-200"></h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"></p>
{store.items.length > 0 ? (
store.items.map((item: any) => (
<ListItem
hasItems={store.customizeItems}
position="right"
key={item.id}
item={item}
onAdd={handleAddToCustomize}
/>
))
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p></p>
</div>
)}
</div>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onConfirm={handleNamespaceChange}
title="修改命名空间"
initialValue={store.namespace || 'default'}
placeholder="请输入命名空间namespace"
/>
<ToastContainer />
</div >
);
};
export const App = () => {
return (
<div className="h-screen bg-gray-50 dark:bg-gray-900">
<SettingsContent />
</div>
);
};

View File

@@ -1,5 +1,14 @@
import { create } from 'zustand';
import { query } from '../../modules/query'
import { queryClient, config } from '../../modules/query'
import { NocoApi } from '@kevisual/noco'
import { toast } from 'react-toastify'
import { MyCache } from '@kevisual/cache'
const cache = new MyCache<CardItem[]>('hot_api')
type CacheCustom = Array<{
namespace: string;
items: CardItem[];
}>
const cacheCustom = new MyCache<CacheCustom>('hot_api_customize');
// --- Types ---
export interface CardItem {
id: string;
@@ -7,68 +16,221 @@ export interface CardItem {
iconUrl?: string;
description?: string;
data?: any;
sort?: number;
namespace?: string;
}
export interface StoreState {
items: CardItem[];
isLoading: boolean;
fetchItems: () => Promise<void>;
machineId?: string;
fetchItems: (refresh?: boolean) => Promise<void>;
sendEvent: (item: CardItem) => Promise<void>;
namespace?: string;
setNamespace?: (namespace: string) => void;
customizeItems?: CardItem[];
setCustomizeItems?: (items: CardItem[]) => void;
getCustomizeItems: (namespace?: string) => Promise<CardItem[]>;
initCustomizeItems?: () => Promise<void>;
getMachineId?: () => Promise<string>;
setCacheData?: (cacheCustom: CacheCustom) => void;
getCacheData?: () => Promise<CacheCustom | null>;
exportCacheData?: () => Promise<string | null>;
importCacheData?: (jsonData: string) => Promise<{
success: boolean;
importedNamespaces?: string[];
message: string;
}>;
nocoApi?: NocoApi;
}
// --- Store ---
export const useStore = create<StoreState>((set) => ({
export const useStore = create<StoreState>((set, get) => ({
items: [],
isLoading: false,
fetchItems: async () => {
getMachineId: async () => {
let machineId = get().machineId;
if (machineId) {
return machineId;
}
const res = await queryClient.get({ path: 'config', key: 'getId' });
if (res.code === 200) {
machineId = res.data;
set({ machineId });
return machineId!;
}
return '';
},
fetchItems: async (refresh?: boolean) => {
set({ isLoading: true });
// TODO: Replace with actual API call
// const response = await fetch('/api/hotkeys');
// const data = await response.json();
get().initCustomizeItems?.();
get().getMachineId?.();
const chacheData = await cache.getData().catch(() => null);
if (!refresh && chacheData && Array.isArray(chacheData) && chacheData.length > 0) {
set({ items: chacheData, isLoading: false });
return;
}
const life = await config.getConfig('life.json').catch(() => null);
if (!life) return;
// Mock data for demonstration
const mockData: CardItem[] = Array.from({ length: 12 }).map((_, i) => ({
id: `item-${i}`,
title: i % 4 === 0 ? `工具 ${i + 1}` : `Application Long Name ${i + 1}`,
iconUrl: i % 3 === 0 ? `https://api.dicebear.com/7.x/icons/svg?seed=${i}` : undefined,
description: `Description for item ${i + 1}`
}));
mockData.unshift({
id: 'item-search',
title: 'win+d 显示桌面',
data: { type: "hotkeys", hotkeys: "win+d" },
iconUrl: 'https://api.dicebear.com/7.x/icons/svg?seed=search',
description: '显示桌面'
});
mockData.unshift({
id: 'item-recor',
title: 'ctrl+alt+h 开始或停止语音转文字',
data: { type: "hotkeys", hotkeys: "ctrl+alt+h" },
iconUrl: 'https://api.dicebear.com/7.x/icons/svg?seed=record',
description: '开始或停止语音转文字'
const baseId = life?.baseId || '';
const nocoToken = life?.token || '';
const baseURL = life?.baseURL || '';
if (!baseId || !nocoToken || !baseURL) {
toast.error('未配置 nocodb 的 baseId 或 token请前往设置页面配置');
return
};
const nocoApi = new NocoApi({ baseURL, token: nocoToken });
const table = await nocoApi.getTableByName('控制中枢', baseId)
if (!table) {
toast.error('未找到 nocodb 中的 控制中枢 表,请检查配置');
return;
}
nocoApi.record.table = table.id
set({ nocoApi });
const res = await nocoApi.record.list({
page: 1,
limit: 10000,
where: "(类型,neq,文档)",
})
mockData.unshift({
id: 'item-newtab',
title: 'ctrl+t 打开浏览器新标签页',
data: { type: "hotkeys", hotkeys: "ctrl+t" },
iconUrl: 'https://api.dicebear.com/7.x/icons/svg?seed=newtab',
description: '打开浏览器新标签页'
})
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 800));
set({ items: mockData, isLoading: false });
console.log('Fetched records:', res);
if (res.code === 200) {
let items: CardItem[] = res.data.list.map((record: any) => ({
id: record.Id,
title: record['标题'] || '未命名',
iconUrl: record['图标'] || `https://api.dicebear.com/9.x/bottts/svg?seed=${record.Id}`,
description: record['总结'] || '',
data: record['数据'] || {},
sort: 0,
}));
cache.setData(items, { expireTime: 30 * 24 * 60 * 60 * 1000 }); // Cache for 10 days
set({ items, isLoading: false });
} else {
toast.error('获取控制中枢数据失败,请检查配置');
set({ isLoading: false });
return;
}
},
sendEvent: async (item: CardItem) => {
// client/router?path=key-sender&keys=win+d
console.log('Sending event for item:', item);
const res = await query.post({
path: 'key-sender',
keys: item?.data?.hotkeys || 'win+d'
});
console.log('Event sent for item:', item, 'Response:', res);
if (res.code !== 200) {
alert('Failed to send event');
if (item.data?.type === 'hotkeys') {
const res = await queryClient.post({
path: 'key-sender',
keys: item?.data?.hotkeys
});
console.log('Event sent for item:', item, 'Response:', res);
if (res.code !== 200) {
toast.error('事件发送失败');
}
return;
}
}
toast.info('暂不支持该类型的控制中枢操作');
},
namespace: localStorage.getItem('hotapi-namespace') || 'default',
customizeItems: [],
setCustomizeItems: (items: CardItem[]) => {
set({ customizeItems: items });
// Save to cache asynchronously without blocking
const ns = useStore.getState().namespace || 'default';
(async () => {
const chacheData = await cacheCustom.getData().catch(() => null);
let allData: CacheCustom = chacheData || [];
// Update or add the namespace data
const existingIndex = allData.findIndex(c => c.namespace === ns);
if (existingIndex >= 0) {
allData[existingIndex].items = items;
} else {
allData.push({ namespace: ns, items });
}
await cacheCustom.setData(allData, { expireTime: 365 * 24 * 60 * 60 * 1000 }); // Cache for 1 year
})();
},
setNamespace: (namespace: string) => {
set({ namespace })
get().getCustomizeItems(namespace);
localStorage.setItem('hotapi-namespace', namespace);
},
getAllNamespaces: async () => {
const chacheData = await cacheCustom.getData().catch(() => null);
if (chacheData) {
return chacheData.map(c => c.namespace);
}
return ['default'];
},
getCustomizeItems: async (namespace?: string) => {
const ns = namespace || useStore.getState().namespace || 'default';
const chacheData = await cacheCustom.getData().catch(() => null);
if (chacheData) {
const nsData = chacheData.find(c => c.namespace === ns);
if (nsData) {
set({ customizeItems: nsData.items });
return nsData.items;
} else {
set({ customizeItems: [] });
return [];
}
}
return [];
},
initCustomizeItems: async () => {
const ns = useStore.getState().namespace || 'default';
await get().getCustomizeItems(ns);
},
getCacheData: async () => {
const chacheData = await cacheCustom.getData().catch(() => null);
return chacheData;
},
setCacheData: (cacheCustomData: CacheCustom) => {
cacheCustom.setData(cacheCustomData, { expireTime: 365 * 24 * 60 * 60 * 1000 }); // Cache for 1 year
},
exportCacheData: async () => {
const cacheData = await get().getCacheData?.();
if (cacheData) {
const exportData = {
version: '1.0.0',
exportTime: new Date().toISOString(),
data: cacheData
};
return JSON.stringify(exportData, null, 2);
}
return null;
},
importCacheData: async (jsonData: string) => {
try {
const importData = JSON.parse(jsonData);
// 验证导入的数据格式
if (!importData.data || !Array.isArray(importData.data)) {
throw new Error('导入数据格式不正确');
}
// 验证每个命名空间的数据格式
for (const nsData of importData.data) {
if (!nsData.namespace || !Array.isArray(nsData.items)) {
throw new Error('命名空间数据格式不正确');
}
}
// 导入数据
get().setCacheData?.(importData.data);
// 刷新当前命名空间的数据
const currentNs = get().namespace || 'default';
get().getCustomizeItems?.(currentNs);
return {
success: true,
importedNamespaces: importData.data.map((ns: any) => ns.namespace),
message: '数据导入成功'
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : '导入失败'
};
}
},
}));

View File

@@ -39,6 +39,8 @@ const { title = 'Light Code', description = 'A lightweight code editor', lang =
margin: 0;
padding: 0;
min-height: 100vh;
height: 100%;
overflow: hidden;
}
* {

View File

@@ -1,4 +1,5 @@
import { QueryClient } from '@kevisual/query'
import { QueryClient, Query } from '@kevisual/query'
import { NocoApi } from '@kevisual/noco'
const getUrl = () => {
const host = window.location.host
@@ -10,6 +11,53 @@ const getUrl = () => {
return '/client/router'
}
export const queryClient = new QueryClient({
url: '/client/router',
});
export const query = new QueryClient({
url: 'http://localhost:51015/client/router',
});
});
export class Config {
query: Query;
token = ''
storage: Storage | null = null;
constructor(opts?: { query?: Query, storage?: Storage, token?: string }) {
this.query = opts?.query || new QueryClient();
this.storage = opts?.storage || null;
this.token = opts?.token || '';
if (!this.token && this.storage) {
const savedToken = this.storage.getItem('token');
if (savedToken) {
this.token = savedToken;
}
}
}
async getConfig(key: string) {
const _config = this.storage?.getItem?.(`config_${key}`)
if (_config) {
return Promise.resolve(JSON.parse(_config))
}
const res = await this.query.post({
path: 'config',
key: 'get',
token: this.token,
data: { key }
})
if (res.code !== 200) {
throw new Error(res.message || '获取配置失败')
}
const data = res.data || {}
const config = data.data || {}
this.storage?.setItem?.(`config_${key}`, JSON.stringify(config))
return config;
}
}
export const config = new Config({
query,
storage: window.sessionStorage,
token: localStorage.getItem('token') || ''
});

10
src/pages/settings.astro Normal file
View File

@@ -0,0 +1,10 @@
---
import Html from '@/components/html.astro';
import { App } from '../apps/hotkeys/settings.tsx';
---
<Html>
<main>
<App client:only />
</main>
</Html>