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",
|
||||
"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
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";
|
||||
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"
|
||||
|
||||
@@ -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,22 +166,97 @@ 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">
|
||||
<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
|
||||
@@ -163,25 +271,45 @@ export const App = () => {
|
||||
<div className="flex items-center space-x-3">
|
||||
<RefreshButton />
|
||||
<SettingsButton />
|
||||
<AdvancedSearch />
|
||||
{/* <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>
|
||||
);
|
||||
};
|
||||
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 { 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}`
|
||||
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,文档)",
|
||||
})
|
||||
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,
|
||||
}));
|
||||
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: '开始或停止语音转文字'
|
||||
})
|
||||
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 });
|
||||
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({
|
||||
if (item.data?.type === 'hotkeys') {
|
||||
const res = await queryClient.post({
|
||||
path: 'key-sender',
|
||||
keys: item?.data?.hotkeys || 'win+d'
|
||||
keys: item?.data?.hotkeys
|
||||
});
|
||||
console.log('Event sent for item:', item, 'Response:', res);
|
||||
if (res.code !== 200) {
|
||||
alert('Failed to send event');
|
||||
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;
|
||||
padding: 0;
|
||||
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 host = window.location.host
|
||||
@@ -10,6 +11,53 @@ const getUrl = () => {
|
||||
return '/client/router'
|
||||
}
|
||||
|
||||
export const query = new QueryClient({
|
||||
url: 'http://localhost:51015/client/router',
|
||||
export const queryClient = new QueryClient({
|
||||
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