feat: optimize ui

This commit is contained in:
musistudio
2025-08-17 18:02:09 +08:00
parent d6b11e1b60
commit 95b2dadd40
16 changed files with 704 additions and 286 deletions

View File

@@ -331,7 +331,7 @@ function App() {
</Button>
</div>
</header>
<main className="flex h-[calc(100vh-4rem)] gap-4 p-4">
<main className="flex h-[calc(100vh-4rem)] gap-4 p-4 overflow-hidden">
<div className="w-3/5">
<Providers />
</div>
@@ -339,7 +339,7 @@ function App() {
<div className="h-3/5">
<Router />
</div>
<div className="flex-1">
<div className="flex-1 overflow-hidden">
<Transformers />
</div>
</div>

View File

@@ -69,7 +69,7 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
// Validate the received data to ensure it has the expected structure
const validConfig = {
LOG: typeof data.LOG === 'boolean' ? data.LOG : false,
LOG_LEVEL: typeof data.LOG_LEVEL === 'string' ? data.LOG_LEVEL : 'info',
LOG_LEVEL: typeof data.LOG_LEVEL === 'string' ? data.LOG_LEVEL : 'debug',
CLAUDE_PATH: typeof data.CLAUDE_PATH === 'string' ? data.CLAUDE_PATH : '',
HOST: typeof data.HOST === 'string' ? data.HOST : '127.0.0.1',
PORT: typeof data.PORT === 'number' ? data.PORT : 3456,
@@ -115,7 +115,7 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
// Set default empty config when fetch fails
setConfig({
LOG: false,
LOG_LEVEL: 'info',
LOG_LEVEL: 'debug',
CLAUDE_PATH: '',
HOST: '127.0.0.1',
PORT: 3456,

View File

@@ -14,7 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { X, Trash2, Plus, Eye, EyeOff } from "lucide-react";
import { X, Trash2, Plus, Eye, EyeOff, Search, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { ComboInput } from "@/components/ui/combo-input";
@@ -38,6 +38,7 @@ export function Providers() {
const [showApiKey, setShowApiKey] = useState<Record<number, boolean>>({});
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState<string>("");
const comboInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -487,15 +488,57 @@ export function Providers() {
const editingProvider = editingProviderData || (editingProviderIndex !== null ? validProviders[editingProviderIndex] : null);
// Filter providers based on search term
const filteredProviders = validProviders.filter(provider => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
// Check provider name and URL
if (
(provider.name && provider.name.toLowerCase().includes(term)) ||
(provider.api_base_url && provider.api_base_url.toLowerCase().includes(term))
) {
return true;
}
// Check models
if (provider.models && Array.isArray(provider.models)) {
return provider.models.some(model =>
model && model.toLowerCase().includes(term)
);
}
return false;
});
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({validProviders.length})</span></CardTitle>
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
<CardHeader className="flex flex-col border-b p-4 gap-3">
<div className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({filteredProviders.length}/{validProviders.length})</span></CardTitle>
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<Input
placeholder={t("providers.search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
{searchTerm && (
<Button
variant="ghost"
size="icon"
onClick={() => setSearchTerm("")}
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
<ProviderList
providers={validProviders}
providers={filteredProviders}
onEdit={handleEditProvider}
onRemove={setDeletingProviderIndex}
/>

View File

@@ -58,12 +58,12 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent data-testid="settings-dialog">
<DialogHeader>
<Dialog open={isOpen} onOpenChange={onOpenChange} >
<DialogContent data-testid="settings-dialog" className="max-h-[80vh] flex flex-col p-0">
<DialogHeader className="p-4 pb-0">
<DialogTitle>{t("toplevel.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 p-4 px-8 overflow-y-auto flex-1">
<div className="flex items-center space-x-2">
<Switch
id="log"
@@ -213,7 +213,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
/>
</div>
</div>
<DialogFooter>
<DialogFooter className="p-4 pt-0">
<Button
onClick={() => onOpenChange(false)}
className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]"

View File

@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import {
Dialog,
DialogContent,
@@ -96,6 +97,207 @@ const ANSI_COLORS: Record<string, string> = {
bg_bright_purple: "bg-purple-400",
};
// 图标搜索输入组件
interface IconData {
className: string;
unicode: string;
char: string;
}
interface IconSearchInputProps {
value: string;
onChange: (value: string) => void;
fontFamily: string;
t: (key: string) => string;
}
const IconSearchInput = React.memo(({ value, onChange, fontFamily, t }: IconSearchInputProps) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(value);
const [icons, setIcons] = useState<IconData[]>([]);
const [filteredIcons, setFilteredIcons] = useState<IconData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
// 加载Nerdfonts图标数据
const loadIcons = useCallback(async () => {
if (icons.length > 0) return; // 已经加载过了
setIsLoading(true);
try {
const response = await fetch('https://www.nerdfonts.com/assets/css/combo.css');
const cssText = await response.text();
// 解析CSS中的图标类名和Unicode
const iconRegex = /\.nf-([a-zA-Z0-9_-]+):before\s*\{\s*content:\s*"\\([0-9a-fA-F]+)";?\s*\}/g;
const iconData: IconData[] = [];
let match;
while ((match = iconRegex.exec(cssText)) !== null) {
const className = `nf-${match[1]}`;
const unicode = match[2];
const char = String.fromCharCode(parseInt(unicode, 16));
iconData.push({ className, unicode, char });
}
setIcons(iconData);
setFilteredIcons(iconData.slice(0, 200));
} catch (error) {
console.error('Failed to load icons:', error);
setIcons([]);
setFilteredIcons([]);
} finally {
setIsLoading(false);
}
}, [icons.length]);
// 模糊搜索图标
useEffect(() => {
if (searchTerm.trim() === '') {
setFilteredIcons(icons.slice(0, 100)); // 显示前100个图标
return;
}
const term = searchTerm.toLowerCase();
let filtered = icons;
// 如果输入的是特殊字符(可能是粘贴的图标),则搜索对应图标
if (term.length === 1 || /[\u{2000}-\u{2FFFF}]/u.test(searchTerm)) {
const pastedIcon = icons.find(icon => icon.char === searchTerm);
if (pastedIcon) {
filtered = [pastedIcon];
} else {
// 搜索包含该字符的图标
filtered = icons.filter(icon => icon.char === searchTerm);
}
} else {
// 模糊搜索:类名、简化后的名称匹配
filtered = icons.filter(icon => {
const className = icon.className.toLowerCase();
const simpleClassName = className.replace(/[_-]/g, '');
const simpleTerm = term.replace(/[_-]/g, '');
return (
className.includes(term) ||
simpleClassName.includes(simpleTerm) ||
// 关键词匹配
term.split(' ').every(keyword =>
className.includes(keyword) || simpleClassName.includes(keyword)
)
);
});
}
setFilteredIcons(filtered.slice(0, 120)); // 显示更多结果
}, [searchTerm, icons]);
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setSearchTerm(newValue);
onChange(newValue);
// 始终打开下拉框,让用户搜索或确认粘贴的内容
setIsOpen(true);
if (icons.length === 0) {
loadIcons();
}
};
// 处理粘贴事件
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pastedText = e.clipboardData.getData('text');
// 如果是单个字符(可能是图标),直接接受并打开下拉框显示相应图标
if (pastedText && pastedText.length === 1) {
setTimeout(() => {
setIsOpen(true);
}, 10);
}
};
// 选择图标
const handleIconSelect = (iconChar: string) => {
setSearchTerm(iconChar);
onChange(iconChar);
setIsOpen(false);
inputRef.current?.focus();
};
// 处理焦点事件
const handleFocus = () => {
setIsOpen(true);
if (icons.length === 0) {
loadIcons();
}
};
// 处理失去焦点(延迟关闭以便点击图标)
const handleBlur = () => {
setTimeout(() => setIsOpen(false), 200);
};
return (
<div className="relative">
<div className="relative">
<Input
ref={inputRef}
value={searchTerm}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
placeholder={t("statusline.icon_placeholder")}
style={{ fontFamily: fontFamily + ', monospace' }}
className="text-lg pr-2"
/>
</div>
{isOpen && (
<div className="absolute z-50 mt-1 w-full max-h-72 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<svg className="animate-spin h-6 w-6 text-primary" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" opacity="0.1"/>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
</div>
) : (
<>
<div className="grid grid-cols-5 gap-2 p-2 max-h-72 overflow-y-auto">
{filteredIcons.map((icon) => (
<div
key={icon.className}
className="flex items-center justify-center p-3 text-2xl cursor-pointer hover:bg-secondary rounded transition-colors"
onClick={() => handleIconSelect(icon.char)}
onMouseDown={(e) => e.preventDefault()} // 防止失去焦点
title={`${icon.char} - ${icon.className}`}
style={{ fontFamily: fontFamily + ', monospace' }}
>
{icon.char}
</div>
))}
{filteredIcons.length === 0 && (
<div className="col-span-5 flex flex-col items-center justify-center p-8 text-muted-foreground">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="mb-2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<div className="text-sm">
{searchTerm ? `${t("statusline.no_icons_found")} "${searchTerm}"` : t("statusline.no_icons_available")}
</div>
</div>
)}
</div>
</>
)}
</div>
)}
</div>
);
});
// 变量替换函数
function replaceVariables(
text: string,
@@ -500,9 +702,57 @@ export function StatusLineConfigDialog({
? currentModules[selectedModuleIndex]
: null;
// 删除选中模块的函数
const deleteSelectedModule = useCallback(() => {
if (selectedModuleIndex === null) return;
const currentTheme =
statusLineConfig.currentStyle as keyof StatusLineConfig;
const themeConfig = statusLineConfig[currentTheme];
const modules =
themeConfig &&
typeof themeConfig === "object" &&
"modules" in themeConfig
? [...((themeConfig as StatusLineThemeConfig).modules || [])]
: [];
if (selectedModuleIndex >= 0 && selectedModuleIndex < modules.length) {
modules.splice(selectedModuleIndex, 1);
setStatusLineConfig((prev) => ({
...prev,
[currentTheme]: { modules },
}));
setSelectedModuleIndex(null);
}
}, [selectedModuleIndex, statusLineConfig]);
// 字体样式
const fontStyle = fontFamily ? { fontFamily } : {};
// 键盘事件监听器,支持删除选中的模块
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 检查是否选中了模块
if (selectedModuleIndex === null) return;
// 检查是否按下了删除键 (Delete 或 Backspace)
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
deleteSelectedModule();
}
};
// 添加事件监听器
document.addEventListener('keydown', handleKeyDown);
// 清理函数
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [selectedModuleIndex, deleteSelectedModule]);
// 当字体或主题变化时强制重新渲染
const fontKey = `${fontFamily}-${statusLineConfig.currentStyle}`;
@@ -558,30 +808,30 @@ export function StatusLineConfigDialog({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="theme-style" className="text-sm font-medium">
{t("statusline.theme")}
</Label>
<Combobox
options={[
{ label: "默认", value: "default" },
{ label: "Powerline", value: "powerline" },
{ label: t("statusline.theme_default"), value: "default" },
{ label: t("statusline.theme_powerline"), value: "powerline" },
]}
value={statusLineConfig.currentStyle}
onChange={handleThemeChange}
data-testid="theme-selector"
placeholder="选择主题样式"
placeholder={t("statusline.theme_placeholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="font-family" className="text-sm font-medium">
{t("statusline.module_icon")}
</Label>
<Combobox
options={NERD_FONTS}
value={fontFamily}
onChange={(value) => setFontFamily(value)}
data-testid="font-family-selector"
placeholder="选择字体"
placeholder={t("statusline.font_placeholder")}
/>
</div>
</div>
@@ -590,9 +840,9 @@ export function StatusLineConfigDialog({
{/* 三栏布局:组件列表 | 预览区域 | 属性配置 */}
<div className="grid grid-cols-5 gap-6 overflow-hidden flex-1">
{/* 左侧:支持的组件 */}
<div className="border rounded-lg p-4 flex flex-col overflow-hidden col-span-1">
<h3 className="text-sm font-medium mb-3"></h3>
<div className="space-y-2 overflow-y-auto flex-1">
<div className="border rounded-lg flex flex-col overflow-hidden col-span-1">
<h3 className="text-sm font-medium p-4 pb-0 mb-3">{t("statusline.components")}</h3>
<div className="space-y-2 overflow-y-auto px-4 pb-4 flex-1">
{MODULE_TYPES_OPTIONS.map((moduleType) => (
<div
key={moduleType.value}
@@ -610,7 +860,7 @@ export function StatusLineConfigDialog({
{/* 中间:预览区域 */}
<div className="border rounded-lg p-4 flex flex-col col-span-3">
<h3 className="text-sm font-medium mb-3"></h3>
<h3 className="text-sm font-medium mb-3">{t("statusline.preview")}</h3>
<div
key={fontKey}
className={`rounded bg-black/90 text-white font-mono text-sm overflow-x-auto flex items-center border border-border p-3 py-5 shadow-inner overflow-hidden ${
@@ -793,7 +1043,7 @@ export function StatusLineConfigDialog({
<path d="M8 12h8" />
</svg>
<span className="text-gray-500 text-sm">
{t("statusline.drag_hint")}
</span>
</div>
)}
@@ -801,45 +1051,31 @@ export function StatusLineConfigDialog({
</div>
{/* 右侧:属性配置 */}
<div className="border rounded-lg p-4 flex flex-col overflow-hidden col-span-1">
<h3 className="text-sm font-medium mb-3"></h3>
<div className="overflow-y-auto flex-1">
<div className="border rounded-lg flex flex-col overflow-hidden col-span-1">
<h3 className="text-sm font-medium p-4 pb-0 mb-3">{t("statusline.properties")}</h3>
<div className="overflow-y-auto px-4 pb-4 flex-1">
{selectedModule && selectedModuleIndex !== null ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("statusline.module_type")}</Label>
<Combobox
options={MODULE_TYPES_OPTIONS}
value={selectedModule.type}
onChange={(value) =>
handleModuleChange(selectedModuleIndex, "type", value)
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="module-icon">
{t("statusline.module_icon")}
</Label>
<Input
<IconSearchInput
key={fontKey}
id="module-icon"
value={selectedModule.icon || ""}
onChange={(e) =>
onChange={(value) =>
handleModuleChange(
selectedModuleIndex,
"icon",
e.target.value
value
)
}
placeholder="例如: 󰉋"
style={fontStyle}
fontFamily={fontFamily}
t={t}
/>
<p className="text-xs text-muted-foreground">
{t("statusline.icon_description")}
</p>
</div>
@@ -857,10 +1093,10 @@ export function StatusLineConfigDialog({
e.target.value
)
}
placeholder="例如: {{workDirName}}"
placeholder={t("statusline.text_placeholder")}
/>
<div className="text-xs text-muted-foreground">
<p>使:</p>
<p>{t("statusline.module_text_description")}</p>
<div className="flex flex-wrap gap-1 mt-1">
<Badge
variant="secondary"
@@ -909,7 +1145,7 @@ export function StatusLineConfigDialog({
}
/>
<p className="text-xs text-muted-foreground">
{t("statusline.module_color_description")}
</p>
</div>
@@ -926,7 +1162,7 @@ export function StatusLineConfigDialog({
}
/>
<p className="text-xs text-muted-foreground">
{t("statusline.module_background_description")}
</p>
</div>
@@ -934,7 +1170,7 @@ export function StatusLineConfigDialog({
{selectedModule.type === "script" && (
<div className="space-y-2">
<Label htmlFor="module-script-path">
{t("statusline.module_script_path")}
</Label>
<Input
id="module-script-path"
@@ -946,10 +1182,10 @@ export function StatusLineConfigDialog({
e.target.value
)
}
placeholder="例如: /path/to/your/script.js"
placeholder={t("statusline.script_placeholder")}
/>
<p className="text-xs text-muted-foreground">
Node.js脚本文件的绝对路径
{t("statusline.module_script_path_description")}
</p>
</div>
)}
@@ -958,36 +1194,15 @@ export function StatusLineConfigDialog({
<Button
variant="destructive"
size="sm"
onClick={() => {
const currentTheme =
statusLineConfig.currentStyle as keyof StatusLineConfig;
const themeConfig = statusLineConfig[currentTheme];
const modules =
themeConfig &&
typeof themeConfig === "object" &&
"modules" in themeConfig
? [
...((themeConfig as StatusLineThemeConfig)
.modules || []),
]
: [];
modules.splice(selectedModuleIndex, 1);
setStatusLineConfig((prev) => ({
...prev,
[currentTheme]: { modules },
}));
setSelectedModuleIndex(null);
}}
onClick={deleteSelectedModule}
>
{t("statusline.delete_module")}
</Button>
</div>
) : (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground text-sm">
{t("statusline.select_hint")}
</p>
</div>
)}

View File

@@ -119,4 +119,38 @@
body {
@apply bg-background text-foreground;
}
/* 美化滚动条 - WebKit浏览器 (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30;
border-radius: 4px;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
::-webkit-scrollbar-corner {
@apply bg-transparent;
}
* {
scrollbar-width: thin;
scrollbar-color: oklch(0.556 0 0) oklch(0.97 0 0);
}
.dark * {
scrollbar-color: oklch(0.708 0 0) oklch(0.269 0 0);
}
}

View File

@@ -93,7 +93,8 @@
"select_template": "Select a template...",
"api_key_required": "API Key is required",
"name_required": "Name is required",
"name_duplicate": "A provider with this name already exists"
"name_duplicate": "A provider with this name already exists",
"search": "Search providers..."
},
"router": {
@@ -128,9 +129,17 @@
"module_text": "Text",
"module_color": "Color",
"module_background": "Background",
"add_module": "Add Module",
"module_text_description": "Enter display text, variables can be used:",
"module_color_description": "Select text color",
"module_background_description": "Select background color (optional)",
"module_script_path": "Script Path",
"module_script_path_description": "Enter the absolute path of the Node.js script file",
"add_module": "Add Module",
"remove_module": "Remove Module",
"delete_module": "Delete Module",
"preview": "Preview",
"components": "Components",
"properties": "Properties",
"workDir": "Working Directory",
"gitBranch": "Git Branch",
"model": "Model",
@@ -153,6 +162,16 @@
"color_bright_magenta": "Bright Magenta",
"color_bright_cyan": "Bright Cyan",
"color_bright_white": "Bright White",
"font_placeholder": "Select Font",
"theme_placeholder": "Select Theme Style",
"icon_placeholder": "Paste icon or search by name...",
"icon_description": "Enter icon character, paste icon, or search icons (optional)",
"text_placeholder": "e.g.: {{workDirName}}",
"script_placeholder": "e.g.: /path/to/your/script.js",
"drag_hint": "Drag components here to configure",
"select_hint": "Select a component to configure",
"no_icons_found": "No icons found",
"no_icons_available": "No icons available",
"import_export": "Import/Export",
"import": "Import Config",
"export": "Export Config",

View File

@@ -93,7 +93,8 @@
"select_template": "选择一个模板...",
"api_key_required": "API 密钥为必填项",
"name_required": "名称为必填项",
"name_duplicate": "已存在同名供应商"
"name_duplicate": "已存在同名供应商",
"search": "搜索供应商..."
},
"router": {
@@ -128,9 +129,17 @@
"module_text": "文本",
"module_color": "颜色",
"module_background": "背景",
"add_module": "添加模块",
"module_text_description": "输入显示文本,可使用变量:",
"module_color_description": "选择文字颜色",
"module_background_description": "选择背景颜色(可选)",
"module_script_path": "脚本路径",
"module_script_path_description": "输入Node.js脚本文件的绝对路径",
"add_module": "添加模块",
"remove_module": "移除模块",
"delete_module": "删除组件",
"preview": "预览",
"components": "组件",
"properties": "属性",
"workDir": "工作目录",
"gitBranch": "Git分支",
"model": "模型",
@@ -153,6 +162,16 @@
"color_bright_magenta": "亮品红",
"color_bright_cyan": "亮青色",
"color_bright_white": "亮白色",
"font_placeholder": "选择字体",
"theme_placeholder": "选择主题样式",
"icon_placeholder": "粘贴图标或输入名称搜索...",
"icon_description": "输入图标字符、粘贴图标或搜索图标(可选)",
"text_placeholder": "例如: {{workDirName}}",
"script_placeholder": "例如: /path/to/your/script.js",
"drag_hint": "拖拽组件到此处进行配置",
"select_hint": "选择一个组件进行配置",
"no_icons_found": "未找到图标",
"no_icons_available": "暂无可用图标",
"import_export": "导入/导出",
"import": "导入配置",
"export": "导出配置",