generated from template/astro-simple-template
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:
38
package.json
38
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevisual/hot-api",
|
"name": "@kevisual/hot-api",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"basename": "/root/hot-api",
|
"basename": "/root/hot-api",
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"pub": "envision deploy ./dist -k hot-api -v 0.0.3 -u -y y",
|
"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.3 -u",
|
"pub:docs": "envision deploy ./dist -k hot-api-docs -v 0.0.4 -u",
|
||||||
"slide:dev": "slidev --open slides/index.md",
|
"slide:dev": "slidev --open slides/index.md",
|
||||||
"slide:build": "slidev build slides/index.md --base /root/hot-api-slide/",
|
"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 "
|
"ui": "pnpm dlx shadcn@latest add "
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -20,31 +20,39 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.12",
|
"@astrojs/mdx": "^4.3.13",
|
||||||
"@astrojs/react": "^4.4.2",
|
"@astrojs/react": "^4.4.2",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
"@astrojs/vue": "^5.1.3",
|
"@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/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/query-login": "^0.0.7",
|
||||||
"@kevisual/registry": "^0.0.1",
|
"@kevisual/registry": "^0.0.1",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@uiw/react-md-editor": "^4.0.11",
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
"antd": "^6.0.1",
|
"antd": "^6.1.1",
|
||||||
"astro": "^5.16.4",
|
"astro": "^5.16.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"es-toolkit": "^1.42.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.561.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.1",
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
@@ -58,10 +66,10 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.26.0",
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
|||||||
1597
pnpm-lock.yaml
generated
1597
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
|||||||
|
import { wrapBasename } from "@/modules/basename";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
export const RefreshButton = () => {
|
export const RefreshButton = () => {
|
||||||
const { isLoading, fetchItems } = useStore();
|
const { isLoading, fetchItems } = useStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchItems()}
|
onClick={() => fetchItems(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`
|
className={`
|
||||||
group relative p-3 rounded-full
|
group relative p-3 rounded-full
|
||||||
@@ -32,6 +33,9 @@ export const RefreshButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsButton = () => {
|
export const SettingsButton = () => {
|
||||||
|
const onClick = () => {
|
||||||
|
window.open(wrapBasename('/settings/'), '_blank');
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="
|
className="
|
||||||
@@ -43,6 +47,7 @@ export const SettingsButton = () => {
|
|||||||
flex items-center justify-center
|
flex items-center justify-center
|
||||||
"
|
"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -7,7 +7,26 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { SettingsButton, RefreshButton } from './components/icon';
|
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 Card = ({ item }: { item: CardItem }) => {
|
||||||
const [isPressed, setIsPressed] = useState(false);
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
const { sendEvent } = useStore();
|
const { sendEvent } = useStore();
|
||||||
@@ -72,7 +91,21 @@ const Card = ({ item }: { item: CardItem }) => {
|
|||||||
|
|
||||||
{/* Full Title (Truncated if too long) */}
|
{/* Full Title (Truncated if too long) */}
|
||||||
<div className="w-full text-center px-2">
|
<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}
|
{item.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,55 +166,150 @@ const AdvancedSearch = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const App = () => {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
}, [fetchItems]);
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
const handleToggleClick = () => {
|
||||||
|
setIsHeaderPinned(!isHeaderPinned);
|
||||||
|
setIsHeaderOpen(!isHeaderPinned);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!isHeaderPinned) {
|
||||||
|
setIsHeaderOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!isHeaderPinned) {
|
||||||
|
setIsHeaderOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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')" }}
|
style={{ backgroundImage: "url('https://kevisual.cn/root/resources/upload/1.0.0/2025-12/rRpZIM_IJmc.webp')" }}
|
||||||
>
|
>
|
||||||
{/* Overlay for premium look and contrast */}
|
{/* Overlay for premium look and contrast */}
|
||||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-[4px] z-0 pointer-events-none" />
|
<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">
|
<div className="max-w-7xl mx-auto relative z-10 h-full flex flex-col">
|
||||||
<header className="mb-8 flex items-center justify-between sm:justify-start sm:space-x-6">
|
{/* Header Toggle Button */}
|
||||||
<h1 className="hidden sm:block text-5xl font-black relative group cursor-default perspective-1000 italic">
|
<div
|
||||||
<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)' }}>
|
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'}`}
|
||||||
HotKeys
|
onClick={handleToggleClick}
|
||||||
</span>
|
>
|
||||||
{/* 3D层次感背景文字 */}
|
<button
|
||||||
<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">
|
className="w-12 h-6 bg-white/10 backdrop-blur-md border border-white/20 rounded-b-2xl
|
||||||
HotKeys
|
hover:bg-white/20 hover:border-cyan-400/50 hover:shadow-[0_0_15px_rgba(34,211,238,0.3)]
|
||||||
</span>
|
transition-all duration-300 flex items-center justify-center cursor-pointer"
|
||||||
{/* 发光边缘效果 */}
|
>
|
||||||
<span className="absolute inset-0 bg-white opacity-0 group-hover:opacity-10 blur-xl transition-opacity duration-700" aria-hidden="true" />
|
<svg
|
||||||
</h1>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<div className="flex items-center space-x-3">
|
className={`h-4 w-4 text-white/70 transition-transform duration-300 ${isHeaderOpen ? 'rotate-180' : ''}`}
|
||||||
<RefreshButton />
|
fill="none"
|
||||||
<SettingsButton />
|
viewBox="0 0 24 24"
|
||||||
<AdvancedSearch />
|
stroke="currentColor"
|
||||||
</div>
|
strokeWidth={2}
|
||||||
</header>
|
>
|
||||||
|
<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 ? (
|
{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="animate-pulse flex flex-col items-center">
|
||||||
<div className="h-12 w-12 bg-gray-300 rounded-full mb-4"></div>
|
<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 className="h-4 w-32 bg-gray-300 rounded"></div>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
{items.map((item) => (
|
{showItems.map((item) => (
|
||||||
<Card key={item.id} item={item} />
|
<Card key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
78
src/apps/hotkeys/modal.tsx
Normal file
78
src/apps/hotkeys/modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
410
src/apps/hotkeys/settings.tsx
Normal file
410
src/apps/hotkeys/settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { create } from 'zustand';
|
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 ---
|
// --- Types ---
|
||||||
export interface CardItem {
|
export interface CardItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,68 +16,221 @@ export interface CardItem {
|
|||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
data?: any;
|
data?: any;
|
||||||
|
sort?: number;
|
||||||
|
namespace?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreState {
|
export interface StoreState {
|
||||||
items: CardItem[];
|
items: CardItem[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
fetchItems: () => Promise<void>;
|
machineId?: string;
|
||||||
|
fetchItems: (refresh?: boolean) => Promise<void>;
|
||||||
sendEvent: (item: CardItem) => 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 ---
|
// --- Store ---
|
||||||
export const useStore = create<StoreState>((set) => ({
|
export const useStore = create<StoreState>((set, get) => ({
|
||||||
items: [],
|
items: [],
|
||||||
isLoading: false,
|
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 });
|
set({ isLoading: true });
|
||||||
// TODO: Replace with actual API call
|
get().initCustomizeItems?.();
|
||||||
// const response = await fetch('/api/hotkeys');
|
get().getMachineId?.();
|
||||||
// const data = await response.json();
|
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 baseId = life?.baseId || '';
|
||||||
const mockData: CardItem[] = Array.from({ length: 12 }).map((_, i) => ({
|
const nocoToken = life?.token || '';
|
||||||
id: `item-${i}`,
|
const baseURL = life?.baseURL || '';
|
||||||
title: i % 4 === 0 ? `工具 ${i + 1}` : `Application Long Name ${i + 1}`,
|
if (!baseId || !nocoToken || !baseURL) {
|
||||||
iconUrl: i % 3 === 0 ? `https://api.dicebear.com/7.x/icons/svg?seed=${i}` : undefined,
|
toast.error('未配置 nocodb 的 baseId 或 token,请前往设置页面配置');
|
||||||
description: `Description for item ${i + 1}`
|
return
|
||||||
}));
|
};
|
||||||
mockData.unshift({
|
const nocoApi = new NocoApi({ baseURL, token: nocoToken });
|
||||||
id: 'item-search',
|
const table = await nocoApi.getTableByName('控制中枢', baseId)
|
||||||
title: 'win+d 显示桌面',
|
if (!table) {
|
||||||
data: { type: "hotkeys", hotkeys: "win+d" },
|
toast.error('未找到 nocodb 中的 控制中枢 表,请检查配置');
|
||||||
iconUrl: 'https://api.dicebear.com/7.x/icons/svg?seed=search',
|
return;
|
||||||
description: '显示桌面'
|
}
|
||||||
});
|
nocoApi.record.table = table.id
|
||||||
mockData.unshift({
|
set({ nocoApi });
|
||||||
id: 'item-recor',
|
const res = await nocoApi.record.list({
|
||||||
title: 'ctrl+alt+h 开始或停止语音转文字',
|
page: 1,
|
||||||
data: { type: "hotkeys", hotkeys: "ctrl+alt+h" },
|
limit: 10000,
|
||||||
iconUrl: 'https://api.dicebear.com/7.x/icons/svg?seed=record',
|
where: "(类型,neq,文档)",
|
||||||
description: '开始或停止语音转文字'
|
|
||||||
})
|
})
|
||||||
mockData.unshift({
|
console.log('Fetched records:', res);
|
||||||
id: 'item-newtab',
|
if (res.code === 200) {
|
||||||
title: 'ctrl+t 打开浏览器新标签页',
|
let items: CardItem[] = res.data.list.map((record: any) => ({
|
||||||
data: { type: "hotkeys", hotkeys: "ctrl+t" },
|
id: record.Id,
|
||||||
iconUrl: 'https://api.dicebear.com/7.x/icons/svg?seed=newtab',
|
title: record['标题'] || '未命名',
|
||||||
description: '打开浏览器新标签页'
|
iconUrl: record['图标'] || `https://api.dicebear.com/9.x/bottts/svg?seed=${record.Id}`,
|
||||||
})
|
description: record['总结'] || '',
|
||||||
// Simulate network delay
|
data: record['数据'] || {},
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
sort: 0,
|
||||||
|
}));
|
||||||
set({ items: mockData, isLoading: false });
|
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) => {
|
sendEvent: async (item: CardItem) => {
|
||||||
// client/router?path=key-sender&keys=win+d
|
|
||||||
console.log('Sending event for item:', item);
|
console.log('Sending event for item:', item);
|
||||||
const res = await query.post({
|
if (item.data?.type === 'hotkeys') {
|
||||||
path: 'key-sender',
|
const res = await queryClient.post({
|
||||||
keys: item?.data?.hotkeys || 'win+d'
|
path: 'key-sender',
|
||||||
});
|
keys: item?.data?.hotkeys
|
||||||
console.log('Event sent for item:', item, 'Response:', res);
|
});
|
||||||
if (res.code !== 200) {
|
console.log('Event sent for item:', item, 'Response:', res);
|
||||||
alert('Failed to send event');
|
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 : '导入失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const { title = 'Light Code', description = 'A lightweight code editor', lang =
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { QueryClient } from '@kevisual/query'
|
import { QueryClient, Query } from '@kevisual/query'
|
||||||
|
import { NocoApi } from '@kevisual/noco'
|
||||||
|
|
||||||
const getUrl = () => {
|
const getUrl = () => {
|
||||||
const host = window.location.host
|
const host = window.location.host
|
||||||
@@ -10,6 +11,53 @@ const getUrl = () => {
|
|||||||
return '/client/router'
|
return '/client/router'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const query = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
url: 'http://localhost:51015/client/router',
|
url: '/client/router',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const query = new QueryClient({
|
||||||
|
});
|
||||||
|
|
||||||
|
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
10
src/pages/settings.astro
Normal 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>
|
||||||
Reference in New Issue
Block a user