add ccr statusline command
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode, Dispatch, SetStateAction } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Config } from '@/types';
|
||||
import type { Config, StatusLineConfig } from '@/types';
|
||||
|
||||
interface ConfigContextType {
|
||||
config: Config | null;
|
||||
@@ -78,6 +78,17 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
PROXY_URL: typeof data.PROXY_URL === 'string' ? data.PROXY_URL : '',
|
||||
transformers: Array.isArray(data.transformers) ? data.transformers : [],
|
||||
Providers: Array.isArray(data.Providers) ? data.Providers : [],
|
||||
StatusLine: data.StatusLine && typeof data.StatusLine === 'object' ? {
|
||||
enabled: typeof data.StatusLine.enabled === 'boolean' ? data.StatusLine.enabled : false,
|
||||
currentStyle: typeof data.StatusLine.currentStyle === 'string' ? data.StatusLine.currentStyle : 'default',
|
||||
default: data.StatusLine.default && typeof data.StatusLine.default === 'object' && Array.isArray(data.StatusLine.default.modules) ? data.StatusLine.default : { modules: [] },
|
||||
powerline: data.StatusLine.powerline && typeof data.StatusLine.powerline === 'object' && Array.isArray(data.StatusLine.powerline.modules) ? data.StatusLine.powerline : { modules: [] }
|
||||
} : {
|
||||
enabled: false,
|
||||
currentStyle: 'default',
|
||||
default: { modules: [] },
|
||||
powerline: { modules: [] }
|
||||
},
|
||||
Router: data.Router && typeof data.Router === 'object' ? {
|
||||
default: typeof data.Router.default === 'string' ? data.Router.default : '',
|
||||
background: typeof data.Router.background === 'string' ? data.Router.background : '',
|
||||
@@ -113,6 +124,7 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
PROXY_URL: '',
|
||||
transformers: [],
|
||||
Providers: [],
|
||||
StatusLine: undefined,
|
||||
Router: {
|
||||
default: '',
|
||||
background: '',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,6 +12,9 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useConfig } from "./ConfigProvider";
|
||||
import { StatusLineConfigDialog } from "./StatusLineConfigDialog";
|
||||
import { useState } from "react";
|
||||
import type { StatusLineConfig } from "@/types";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -22,6 +24,7 @@ interface SettingsDialogProps {
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config, setConfig } = useConfig();
|
||||
const [isStatusLineConfigOpen, setIsStatusLineConfigOpen] = useState(false);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
@@ -35,16 +38,71 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
|
||||
setConfig({ ...config, CLAUDE_PATH: e.target.value });
|
||||
};
|
||||
|
||||
const handleStatusLineEnabledChange = (checked: boolean) => {
|
||||
// Ensure we have a complete StatusLineConfig object
|
||||
const newStatusLineConfig: StatusLineConfig = {
|
||||
enabled: checked,
|
||||
currentStyle: config.StatusLine?.currentStyle || "default",
|
||||
default: config.StatusLine?.default || { modules: [] },
|
||||
powerline: config.StatusLine?.powerline || { modules: [] },
|
||||
};
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
StatusLine: newStatusLineConfig,
|
||||
});
|
||||
};
|
||||
|
||||
const openStatusLineConfig = () => {
|
||||
setIsStatusLineConfigOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent data-testid="settings-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("toplevel.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="log" checked={config.LOG} onCheckedChange={handleLogChange} />
|
||||
<Label htmlFor="log" className="transition-all-ease hover:scale-[1.02] cursor-pointer">{t("toplevel.log")}</Label>
|
||||
<Switch
|
||||
id="log"
|
||||
checked={config.LOG}
|
||||
onCheckedChange={handleLogChange}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="log"
|
||||
className="transition-all-ease hover:scale-[1.02] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.log")}
|
||||
</Label>
|
||||
</div>
|
||||
{/* StatusLine Configuration */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="statusline"
|
||||
checked={config.StatusLine?.enabled || false}
|
||||
onCheckedChange={handleStatusLineEnabledChange}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="statusline"
|
||||
className="transition-all-ease hover:scale-[1.02] cursor-pointer"
|
||||
>
|
||||
{t("statusline.title")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openStatusLineConfig}
|
||||
className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
|
||||
data-testid="statusline-config-button"
|
||||
>
|
||||
{t("app.settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="log-level" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.log_level")}</Label>
|
||||
@@ -62,34 +120,114 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="claude-path" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.claude_path")}</Label>
|
||||
<Input id="claude-path" value={config.CLAUDE_PATH} onChange={handlePathChange} className="transition-all-ease focus:scale-[1.01]" />
|
||||
<Label
|
||||
htmlFor="claude-path"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.claude_path")}
|
||||
</Label>
|
||||
<Input
|
||||
id="claude-path"
|
||||
value={config.CLAUDE_PATH}
|
||||
onChange={handlePathChange}
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.host")}</Label>
|
||||
<Input id="host" value={config.HOST} onChange={(e) => setConfig({ ...config, HOST: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
|
||||
<Label
|
||||
htmlFor="host"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.host")}
|
||||
</Label>
|
||||
<Input
|
||||
id="host"
|
||||
value={config.HOST}
|
||||
onChange={(e) => setConfig({ ...config, HOST: e.target.value })}
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.port")}</Label>
|
||||
<Input id="port" type="number" value={config.PORT} onChange={(e) => setConfig({ ...config, PORT: parseInt(e.target.value, 10) })} className="transition-all-ease focus:scale-[1.01]" />
|
||||
<Label
|
||||
htmlFor="port"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.port")}
|
||||
</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={config.PORT}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, PORT: parseInt(e.target.value, 10) })
|
||||
}
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.timeout")}</Label>
|
||||
<Input id="timeout" value={config.API_TIMEOUT_MS} onChange={(e) => setConfig({ ...config, API_TIMEOUT_MS: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
|
||||
<Label
|
||||
htmlFor="timeout"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.timeout")}
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
value={config.API_TIMEOUT_MS}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, API_TIMEOUT_MS: e.target.value })
|
||||
}
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-url" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.proxy_url")}</Label>
|
||||
<Input id="proxy-url" value={config.PROXY_URL} onChange={(e) => setConfig({ ...config, PROXY_URL: e.target.value })} placeholder="http://127.0.0.1:7890" className="transition-all-ease focus:scale-[1.01]" />
|
||||
<Label
|
||||
htmlFor="proxy-url"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.proxy_url")}
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-url"
|
||||
value={config.PROXY_URL}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, PROXY_URL: e.target.value })
|
||||
}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apikey" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.apikey")}</Label>
|
||||
<Input id="apikey" type="password" value={config.APIKEY} onChange={(e) => setConfig({ ...config, APIKEY: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
|
||||
<Label
|
||||
htmlFor="apikey"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.apikey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="apikey"
|
||||
type="password"
|
||||
value={config.APIKEY}
|
||||
onChange={(e) => setConfig({ ...config, APIKEY: e.target.value })}
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">{t("app.save")}</Button>
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{t("app.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<StatusLineConfigDialog
|
||||
isOpen={isStatusLineConfigOpen}
|
||||
onOpenChange={setIsStatusLineConfigOpen}
|
||||
data-testid="statusline-config-dialog"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
647
ui/src/components/StatusLineConfigDialog.tsx
Normal file
647
ui/src/components/StatusLineConfigDialog.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useConfig } from "./ConfigProvider";
|
||||
import { validateStatusLineConfig, formatValidationError, createDefaultStatusLineConfig } from "@/utils/statusline";
|
||||
import type { StatusLineConfig, StatusLineModuleConfig, StatusLineThemeConfig } from "@/types";
|
||||
|
||||
|
||||
const DEFAULT_MODULE: StatusLineModuleConfig = {
|
||||
type: "workDir",
|
||||
icon: "",
|
||||
text: "{{workDirName}}",
|
||||
color: "bright_blue"
|
||||
};
|
||||
|
||||
// 模块类型选项
|
||||
const MODULE_TYPES = [
|
||||
{ label: "workDir", value: "workDir" },
|
||||
{ label: "gitBranch", value: "gitBranch" },
|
||||
{ label: "model", value: "model" },
|
||||
{ label: "usage", value: "usage" }
|
||||
];
|
||||
|
||||
// ANSI颜色代码映射
|
||||
const ANSI_COLORS: Record<string, string> = {
|
||||
// 标准颜色
|
||||
black: "text-black",
|
||||
red: "text-red-600",
|
||||
green: "text-green-600",
|
||||
yellow: "text-yellow-500",
|
||||
blue: "text-blue-500",
|
||||
magenta: "text-purple-500",
|
||||
cyan: "text-cyan-500",
|
||||
white: "text-white",
|
||||
// 亮色
|
||||
bright_black: "text-gray-500",
|
||||
bright_red: "text-red-400",
|
||||
bright_green: "text-green-400",
|
||||
bright_yellow: "text-yellow-300",
|
||||
bright_blue: "text-blue-300",
|
||||
bright_magenta: "text-purple-300",
|
||||
bright_cyan: "text-cyan-300",
|
||||
bright_white: "text-white",
|
||||
// 背景颜色
|
||||
bg_black: "bg-black",
|
||||
bg_red: "bg-red-600",
|
||||
bg_green: "bg-green-600",
|
||||
bg_yellow: "bg-yellow-500",
|
||||
bg_blue: "bg-blue-500",
|
||||
bg_magenta: "bg-purple-500",
|
||||
bg_cyan: "bg-cyan-500",
|
||||
bg_white: "bg-white",
|
||||
// 亮背景色
|
||||
bg_bright_black: "bg-gray-800",
|
||||
bg_bright_red: "bg-red-400",
|
||||
bg_bright_green: "bg-green-400",
|
||||
bg_bright_yellow: "bg-yellow-300",
|
||||
bg_bright_blue: "bg-blue-300",
|
||||
bg_bright_magenta: "bg-purple-300",
|
||||
bg_bright_cyan: "bg-cyan-300",
|
||||
bg_bright_white: "bg-gray-100",
|
||||
// Powerline样式需要的额外背景色
|
||||
bg_bright_orange: "bg-orange-400",
|
||||
bg_bright_purple: "bg-purple-400",
|
||||
};
|
||||
|
||||
// 变量替换函数
|
||||
function replaceVariables(text: string, variables: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
||||
return variables[varName] || match;
|
||||
});
|
||||
}
|
||||
|
||||
// 渲染单个模块预览
|
||||
function renderModulePreview(module: StatusLineModuleConfig, isPowerline: boolean = false): React.ReactNode {
|
||||
// 模拟变量数据
|
||||
const variables = {
|
||||
workDirName: "project",
|
||||
gitBranch: "main",
|
||||
model: "Claude Sonnet 4",
|
||||
inputTokens: "1.2k",
|
||||
outputTokens: "2.5k"
|
||||
};
|
||||
|
||||
const text = replaceVariables(module.text, variables);
|
||||
const icon = module.icon || "";
|
||||
|
||||
// 如果text为空且不是usage类型,则跳过该模块
|
||||
if (!text && module.type !== "usage") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果是Powerline样式,添加背景色和分隔符
|
||||
if (isPowerline) {
|
||||
const bgColorClass = module.background ? ANSI_COLORS[module.background] || "" : "";
|
||||
const textColorClass = module.color ? ANSI_COLORS[module.color] || "text-white" : "text-white";
|
||||
|
||||
return (
|
||||
<div className={`powerline-module ${bgColorClass} ${textColorClass}`}>
|
||||
<div className="powerline-module-content">
|
||||
{icon && <span>{icon}</span>}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
<div
|
||||
className="powerline-separator"
|
||||
data-current-bg={module.background || ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{icon && <span>{icon}</span>}
|
||||
<span>{text}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface StatusLineConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export function StatusLineConfigDialog({ isOpen, onOpenChange }: StatusLineConfigDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config, setConfig } = useConfig();
|
||||
|
||||
// 添加Powerline分隔符样式
|
||||
useEffect(() => {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.innerHTML = `
|
||||
.powerline-module {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
padding: 0 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.powerline-module-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.powerline-separator {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 14px solid transparent;
|
||||
border-bottom: 14px solid transparent;
|
||||
border-left: 8px solid;
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 使用层级确保每个模块的三角形覆盖在下一个模块上方 */
|
||||
.cursor-pointer:nth-child(1) .powerline-separator { z-index: 10; }
|
||||
.cursor-pointer:nth-child(2) .powerline-separator { z-index: 9; }
|
||||
.cursor-pointer:nth-child(3) .powerline-separator { z-index: 8; }
|
||||
.cursor-pointer:nth-child(4) .powerline-separator { z-index: 7; }
|
||||
.cursor-pointer:nth-child(5) .powerline-separator { z-index: 6; }
|
||||
.cursor-pointer:nth-child(6) .powerline-separator { z-index: 5; }
|
||||
.cursor-pointer:nth-child(7) .powerline-separator { z-index: 4; }
|
||||
.cursor-pointer:nth-child(8) .powerline-separator { z-index: 3; }
|
||||
.cursor-pointer:nth-child(9) .powerline-separator { z-index: 2; }
|
||||
.cursor-pointer:nth-child(10) .powerline-separator { z-index: 1; }
|
||||
|
||||
.cursor-pointer:last-child .powerline-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 根据data属性动态设置颜色,确保与模块背景色一致 */
|
||||
.powerline-separator[data-current-bg="bg_black"] { border-left-color: #000000; }
|
||||
.powerline-separator[data-current-bg="bg_red"] { border-left-color: #dc2626; }
|
||||
.powerline-separator[data-current-bg="bg_green"] { border-left-color: #16a34a; }
|
||||
.powerline-separator[data-current-bg="bg_yellow"] { border-left-color: #eab308; }
|
||||
.powerline-separator[data-current-bg="bg_blue"] { border-left-color: #3b82f6; }
|
||||
.powerline-separator[data-current-bg="bg_magenta"] { border-left-color: #a855f7; }
|
||||
.powerline-separator[data-current-bg="bg_cyan"] { border-left-color: #06b6d4; }
|
||||
.powerline-separator[data-current-bg="bg_white"] { border-left-color: #ffffff; }
|
||||
.powerline-separator[data-current-bg="bg_bright_black"] { border-left-color: #1f2937; }
|
||||
.powerline-separator[data-current-bg="bg_bright_red"] { border-left-color: #f87171; }
|
||||
.powerline-separator[data-current-bg="bg_bright_green"] { border-left-color: #4ade80; }
|
||||
.powerline-separator[data-current-bg="bg_bright_yellow"] { border-left-color: #fde047; }
|
||||
.powerline-separator[data-current-bg="bg_bright_blue"] { border-left-color: #93c5fd; }
|
||||
.powerline-separator[data-current-bg="bg_bright_magenta"] { border-left-color: #c084fc; }
|
||||
.powerline-separator[data-current-bg="bg_bright_cyan"] { border-left-color: #22d3ee; }
|
||||
.powerline-separator[data-current-bg="bg_bright_white"] { border-left-color: #f3f4f6; }
|
||||
.powerline-separator[data-current-bg="bg_bright_orange"] { border-left-color: #fb923c; }
|
||||
.powerline-separator[data-current-bg="bg_bright_purple"] { border-left-color: #c084fc; }
|
||||
`;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
document.head.removeChild(styleElement);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [statusLineConfig, setStatusLineConfig] = useState<StatusLineConfig>(
|
||||
config?.StatusLine || createDefaultStatusLineConfig()
|
||||
);
|
||||
|
||||
const [selectedModuleIndex, setSelectedModuleIndex] = useState<number | null>(null);
|
||||
|
||||
// 模块类型选项
|
||||
const MODULE_TYPES_OPTIONS = MODULE_TYPES.map(item => ({
|
||||
...item,
|
||||
label: t(`statusline.${item.label}`)
|
||||
}));
|
||||
|
||||
|
||||
|
||||
const handleThemeChange = (value: string) => {
|
||||
setStatusLineConfig(prev => ({ ...prev, currentStyle: value }));
|
||||
};
|
||||
|
||||
const handleModuleChange = (index: number, field: keyof StatusLineModuleConfig, value: string) => {
|
||||
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 (modules[index]) {
|
||||
modules[index] = { ...modules[index], [field]: value };
|
||||
}
|
||||
|
||||
setStatusLineConfig(prev => ({
|
||||
...prev,
|
||||
[currentTheme]: { modules }
|
||||
}));
|
||||
};
|
||||
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
const handleSave = () => {
|
||||
// 验证配置
|
||||
const validationResult = validateStatusLineConfig(statusLineConfig);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
// 格式化错误信息
|
||||
const errorMessages = validationResult.errors.map(error =>
|
||||
formatValidationError(error, t)
|
||||
);
|
||||
setValidationErrors(errorMessages);
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的错误
|
||||
setValidationErrors([]);
|
||||
|
||||
if (config) {
|
||||
setConfig({
|
||||
...config,
|
||||
StatusLine: statusLineConfig
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建自定义Alert组件
|
||||
const CustomAlert = ({
|
||||
title,
|
||||
description,
|
||||
variant = "default"
|
||||
}: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
variant?: "default" | "destructive";
|
||||
}) => {
|
||||
const isError = variant === "destructive";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${
|
||||
isError
|
||||
? "bg-red-50 border-red-200 text-red-800"
|
||||
: "bg-blue-50 border-blue-200 text-blue-800"
|
||||
}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
{isError ? (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className={`text-sm font-medium ${
|
||||
isError ? "text-red-800" : "text-blue-800"
|
||||
}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<div className={`mt-2 text-sm ${
|
||||
isError ? "text-red-700" : "text-blue-700"
|
||||
}`}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const currentThemeKey = statusLineConfig.currentStyle as keyof StatusLineConfig;
|
||||
const currentThemeConfig = statusLineConfig[currentThemeKey];
|
||||
const currentModules = currentThemeConfig && typeof currentThemeConfig === 'object' && 'modules' in currentThemeConfig
|
||||
? ((currentThemeConfig as StatusLineThemeConfig).modules || [])
|
||||
: [];
|
||||
const selectedModule = selectedModuleIndex !== null && currentModules.length > selectedModuleIndex ? currentModules[selectedModuleIndex] : null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl h-[90vh] overflow-hidden sm:max-w-5xl md:max-w-6xl lg:max-w-7xl animate-in fade-in-90 slide-in-from-bottom-10 duration-300 flex flex-col">
|
||||
<DialogHeader data-testid="statusline-config-dialog-header" className="border-b pb-4">
|
||||
<DialogTitle className="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M14 3v4a2 2 0 0 0 2 2h4"/>
|
||||
<path d="M3 12h18"/>
|
||||
</svg>
|
||||
{t("statusline.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 错误显示区域 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="px-6">
|
||||
<CustomAlert
|
||||
variant="destructive"
|
||||
title="配置验证失败"
|
||||
description={
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-6 flex-1 overflow-hidden">
|
||||
{/* 配置面板 */}
|
||||
<div className="space-y-6">
|
||||
{/* 主题样式选择 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="theme-style" className="text-sm font-medium">
|
||||
主题样式
|
||||
</Label>
|
||||
<div className="w-1/2">
|
||||
<Combobox
|
||||
options={[
|
||||
{ label: "默认", value: "default" },
|
||||
{ label: "Powerline", value: "powerline" }
|
||||
]}
|
||||
value={statusLineConfig.currentStyle}
|
||||
onChange={handleThemeChange}
|
||||
data-testid="theme-selector"
|
||||
placeholder="选择主题样式"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* 三栏布局:组件列表 | 预览区域 | 属性配置 */}
|
||||
<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">
|
||||
{MODULE_TYPES_OPTIONS.map((moduleType) => (
|
||||
<div
|
||||
key={moduleType.value}
|
||||
className="flex items-center gap-2 p-2 border rounded cursor-move hover:bg-secondary"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("moduleType", moduleType.value);
|
||||
}}
|
||||
>
|
||||
<span className="text-sm">{moduleType.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:预览区域 */}
|
||||
<div className="border rounded-lg p-4 flex flex-col col-span-3">
|
||||
<h3 className="text-sm font-medium mb-3">预览</h3>
|
||||
<div
|
||||
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 ${statusLineConfig.currentStyle === 'powerline' ? 'gap-0 h-8 p-0 items-center overflow-visible relative' : 'h-5 overflow-hidden'}`}
|
||||
data-testid="statusline-preview"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const moduleType = e.dataTransfer.getData("moduleType");
|
||||
if (moduleType) {
|
||||
// 添加新模块
|
||||
const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig;
|
||||
const themeConfig = statusLineConfig[currentTheme];
|
||||
const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig
|
||||
? [...((themeConfig as StatusLineThemeConfig).modules || [])]
|
||||
: [];
|
||||
|
||||
// 根据模块类型设置默认值
|
||||
let newModule: StatusLineModuleConfig;
|
||||
switch (moduleType) {
|
||||
case "workDir":
|
||||
newModule = { type: "workDir", icon: "", text: "{{workDirName}}", color: "bright_blue" };
|
||||
break;
|
||||
case "gitBranch":
|
||||
newModule = { type: "gitBranch", icon: "🌿", text: "{{gitBranch}}", color: "bright_green" };
|
||||
break;
|
||||
case "model":
|
||||
newModule = { type: "model", icon: "🤖", text: "{{model}}", color: "bright_yellow" };
|
||||
break;
|
||||
case "usage":
|
||||
newModule = { type: "usage", icon: "📊", text: "{{inputTokens}} → {{outputTokens}}", color: "bright_magenta" };
|
||||
break;
|
||||
default:
|
||||
newModule = { ...DEFAULT_MODULE, type: moduleType };
|
||||
}
|
||||
|
||||
modules.push(newModule);
|
||||
|
||||
setStatusLineConfig(prev => ({
|
||||
...prev,
|
||||
[currentTheme]: { modules }
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentModules.length > 0 ? (
|
||||
<div className="flex items-center flex-wrap gap-0">
|
||||
{currentModules.map((module, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`cursor-pointer ${
|
||||
selectedModuleIndex === index ? "bg-white/20" : "hover:bg-white/10"
|
||||
} ${statusLineConfig.currentStyle === 'powerline' ? 'p-0 rounded-none inline-flex overflow-visible relative' : 'flex items-center gap-1 px-2 py-1 rounded'}`}
|
||||
onClick={() => setSelectedModuleIndex(index)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("dragIndex", index.toString());
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = parseInt(e.dataTransfer.getData("dragIndex"));
|
||||
if (!isNaN(dragIndex) && dragIndex !== index) {
|
||||
// 重新排序模块
|
||||
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 (dragIndex >= 0 && dragIndex < modules.length && index >= 0 && index <= modules.length) {
|
||||
const [movedModule] = modules.splice(dragIndex, 1);
|
||||
modules.splice(index, 0, movedModule);
|
||||
|
||||
setStatusLineConfig(prev => ({
|
||||
...prev,
|
||||
[currentTheme]: { modules }
|
||||
}));
|
||||
|
||||
// 更新选中项的索引
|
||||
if (selectedModuleIndex === dragIndex) {
|
||||
setSelectedModuleIndex(index);
|
||||
} else if (selectedModuleIndex === index) {
|
||||
setSelectedModuleIndex(dragIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderModulePreview(module, statusLineConfig.currentStyle === 'powerline')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full py-4 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500 mb-2">
|
||||
<path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"/>
|
||||
<path d="M12 8v8"/>
|
||||
<path d="M8 12h8"/>
|
||||
</svg>
|
||||
<span className="text-gray-500 text-sm">
|
||||
拖拽组件到此处进行配置
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{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
|
||||
id="module-icon"
|
||||
value={selectedModule.icon || ""}
|
||||
onChange={(e) => handleModuleChange(selectedModuleIndex, "icon", e.target.value)}
|
||||
placeholder="例如: "
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
输入图标字符或表情符号(可选)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="module-text">{t("statusline.module_text")}</Label>
|
||||
<Input
|
||||
id="module-text"
|
||||
value={selectedModule.text}
|
||||
onChange={(e) => handleModuleChange(selectedModuleIndex, "text", e.target.value)}
|
||||
placeholder="例如: {{workDirName}}"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>输入显示文本,可使用变量:</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{workDirName}}"}</Badge>
|
||||
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{gitBranch}}"}</Badge>
|
||||
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{model}}"}</Badge>
|
||||
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{inputTokens}}"}</Badge>
|
||||
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{outputTokens}}"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("statusline.module_color")}</Label>
|
||||
<ColorPicker
|
||||
value={selectedModule.color || ""}
|
||||
onChange={(value) => handleModuleChange(selectedModuleIndex, "color", value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择文字颜色
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("statusline.module_background")}</Label>
|
||||
<ColorPicker
|
||||
value={selectedModule.background || ""}
|
||||
onChange={(value) => handleModuleChange(selectedModuleIndex, "background", value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择背景颜色(可选)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
删除组件
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||||
<p className="text-muted-foreground text-sm">选择一个组件进行配置</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
{t("app.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
data-testid="save-statusline-config"
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
{t("app.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
309
ui/src/components/StatusLineImportExport.tsx
Normal file
309
ui/src/components/StatusLineImportExport.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { validateStatusLineConfig, backupConfig, restoreConfig, createDefaultStatusLineConfig } from "@/utils/statusline";
|
||||
import type { StatusLineConfig } from "@/types";
|
||||
|
||||
interface StatusLineImportExportProps {
|
||||
config: StatusLineConfig;
|
||||
onImport: (config: StatusLineConfig) => void;
|
||||
onShowToast: (message: string, type: 'success' | 'error' | 'warning') => void;
|
||||
}
|
||||
|
||||
export function StatusLineImportExport({ config, onImport, onShowToast }: StatusLineImportExportProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
// 导出配置为JSON文件
|
||||
const handleExport = () => {
|
||||
try {
|
||||
// 在导出前验证配置
|
||||
const validationResult = validateStatusLineConfig(config);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
onShowToast(t("statusline.export_validation_failed"), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataStr = JSON.stringify(config, null, 2);
|
||||
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
|
||||
|
||||
const exportFileDefaultName = `statusline-config-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
|
||||
onShowToast(t("statusline.export_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
onShowToast(t("statusline.export_failed"), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 导入配置从JSON文件
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsImporting(true);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const importedConfig = JSON.parse(content) as StatusLineConfig;
|
||||
|
||||
// 验证导入的配置
|
||||
const validationResult = validateStatusLineConfig(importedConfig);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
// 格式化错误信息
|
||||
const errorMessages = validationResult.errors.map(error =>
|
||||
error.message
|
||||
).join('; ');
|
||||
throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`);
|
||||
}
|
||||
|
||||
onImport(importedConfig);
|
||||
onShowToast(t("statusline.import_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Import failed:", error);
|
||||
onShowToast(t("statusline.import_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
// 重置文件输入,以便可以再次选择同一个文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
onShowToast(t("statusline.import_failed"), 'error');
|
||||
setIsImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
// 下载配置模板
|
||||
const handleDownloadTemplate = () => {
|
||||
try {
|
||||
// 使用新的默认配置函数
|
||||
const templateConfig = createDefaultStatusLineConfig();
|
||||
|
||||
const dataStr = JSON.stringify(templateConfig, null, 2);
|
||||
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
|
||||
|
||||
const templateFileName = "statusline-config-template.json";
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', templateFileName);
|
||||
linkElement.click();
|
||||
|
||||
onShowToast(t("statusline.template_download_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Template download failed:", error);
|
||||
onShowToast(t("statusline.template_download_failed"), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 配置备份功能
|
||||
const handleBackup = () => {
|
||||
try {
|
||||
const backupStr = backupConfig(config);
|
||||
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(backupStr)}`;
|
||||
|
||||
const backupFileName = `statusline-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', backupFileName);
|
||||
linkElement.click();
|
||||
|
||||
onShowToast(t("statusline.backup_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Backup failed:", error);
|
||||
onShowToast(t("statusline.backup_failed"), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 配置恢复功能
|
||||
const handleRestore = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const restoredConfig = restoreConfig(content);
|
||||
|
||||
if (!restoredConfig) {
|
||||
throw new Error(t("statusline.invalid_backup_file"));
|
||||
}
|
||||
|
||||
// 验证恢复的配置
|
||||
const validationResult = validateStatusLineConfig(restoredConfig);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
// 格式化错误信息
|
||||
const errorMessages = validationResult.errors.map(error =>
|
||||
error.message
|
||||
).join('; ');
|
||||
throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`);
|
||||
}
|
||||
|
||||
onImport(restoredConfig);
|
||||
onShowToast(t("statusline.restore_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Restore failed:", error);
|
||||
onShowToast(t("statusline.restore_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error');
|
||||
} finally {
|
||||
// 重置文件输入
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
onShowToast(t("statusline.restore_failed"), 'error');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
// 移除本地验证函数,因为我们现在使用utils中的验证函数
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
{t("statusline.import_export")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{t("statusline.export")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
disabled={isImporting}
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
{t("statusline.import")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleBackup}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
{t("statusline.backup")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
// 创建一个隐藏的文件输入用于恢复
|
||||
const restoreInput = document.createElement('input');
|
||||
restoreInput.type = 'file';
|
||||
restoreInput.accept = '.json';
|
||||
restoreInput.onchange = (e) => handleRestore(e as any);
|
||||
restoreInput.click();
|
||||
}}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M3 15v4c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-4M17 9l-5 5-5-5M12 12.8V2.5"/>
|
||||
</svg>
|
||||
{t("statusline.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDownloadTemplate}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105 sm:col-span-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
{t("statusline.download_template")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImport}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-secondary/50 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground mt-0.5 flex-shrink-0">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("statusline.import_export_help")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
262
ui/src/components/ui/color-picker.tsx
Normal file
262
ui/src/components/ui/color-picker.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HexColorPicker } from "react-colorful"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
showPreview?: boolean;
|
||||
}
|
||||
|
||||
// 预定义的ANSI颜色映射
|
||||
const ANSI_COLOR_MAP: Record<string, string> = {
|
||||
"black": "#000000",
|
||||
"red": "#ff0000",
|
||||
"green": "#00ff00",
|
||||
"yellow": "#ffff00",
|
||||
"blue": "#0000ff",
|
||||
"magenta": "#ff00ff",
|
||||
"cyan": "#00ffff",
|
||||
"white": "#ffffff",
|
||||
"bright_black": "#808080",
|
||||
"bright_red": "#ff8080",
|
||||
"bright_green": "#80ff80",
|
||||
"bright_yellow": "#ffff80",
|
||||
"bright_blue": "#8080ff",
|
||||
"bright_magenta": "#ff80ff",
|
||||
"bright_cyan": "#80ffff",
|
||||
"bright_white": "#ffffff"
|
||||
}
|
||||
|
||||
// 背景颜色映射(添加bg_前缀)
|
||||
const ANSI_BG_COLOR_MAP: Record<string, string> = Object.keys(ANSI_COLOR_MAP).reduce((acc, key) => {
|
||||
acc[`bg_${key}`] = ANSI_COLOR_MAP[key]
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
// 合并所有颜色映射
|
||||
const ALL_COLOR_MAP = { ...ANSI_COLOR_MAP, ...ANSI_BG_COLOR_MAP }
|
||||
|
||||
// 获取颜色值的函数
|
||||
const getColorValue = (color: string): string => {
|
||||
// 如果是预定义的ANSI颜色
|
||||
if (ALL_COLOR_MAP[color]) {
|
||||
return ALL_COLOR_MAP[color]
|
||||
}
|
||||
|
||||
// 如果是十六进制颜色
|
||||
if (color.startsWith("#")) {
|
||||
return color
|
||||
}
|
||||
|
||||
// 默认返回黑色
|
||||
return "#000000"
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = "选择颜色...",
|
||||
showPreview = true
|
||||
}: ColorPickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [customColor, setCustomColor] = React.useState("")
|
||||
|
||||
// 当value变化时更新customColor
|
||||
React.useEffect(() => {
|
||||
if (value.startsWith("#")) {
|
||||
setCustomColor(value)
|
||||
} else {
|
||||
setCustomColor("")
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
onChange(color)
|
||||
}
|
||||
|
||||
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const color = e.target.value
|
||||
setCustomColor(color)
|
||||
// 验证十六进制颜色格式
|
||||
if (/^#[0-9A-F]{6}$/i.test(color)) {
|
||||
handleColorChange(color)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePresetColorClick = (colorName: string) => {
|
||||
handleColorChange(colorName)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectedColorValue = getColorValue(value)
|
||||
|
||||
// 获取ANSI颜色名称(如果适用)
|
||||
const ansiColorName = Object.keys(ALL_COLOR_MAP).find(key => ALL_COLOR_MAP[key] === selectedColorValue) || value
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal h-10 transition-all hover:scale-[1.02] active:scale-[0.98]",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{showPreview && (
|
||||
<div
|
||||
className="h-5 w-5 rounded border shadow-sm"
|
||||
style={{ backgroundColor: selectedColorValue }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate flex-1">
|
||||
{value ? ansiColorName : placeholder}
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m7 15 5 5 5-5"/>
|
||||
<path d="m7 9 5-5 5 5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-3" align="start">
|
||||
<div className="space-y-4">
|
||||
{/* 颜色选择器标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">颜色选择器</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => handleColorChange("")}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 颜色预览 */}
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary">
|
||||
<div
|
||||
className="h-8 w-8 rounded border shadow-sm"
|
||||
style={{ backgroundColor: selectedColorValue }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{value ? ansiColorName : "未选择颜色"}
|
||||
</div>
|
||||
{value && value.startsWith("#") && (
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{value.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 颜色选择器 */}
|
||||
<div className="rounded-md overflow-hidden border">
|
||||
<HexColorPicker
|
||||
color={selectedColorValue}
|
||||
onChange={handleColorChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自定义颜色输入 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">自定义颜色</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={customColor}
|
||||
onChange={handleCustomColorChange}
|
||||
placeholder="#RRGGBB"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => customColor && handleColorChange(customColor)}
|
||||
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
输入十六进制颜色值 (例如: #FF0000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 预定义颜色选项 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">ANSI 颜色</label>
|
||||
<span className="text-xs text-muted-foreground">文字颜色</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{Object.entries(ANSI_COLOR_MAP).map(([name, color]) => (
|
||||
<Button
|
||||
key={name}
|
||||
variant={value === name ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 rounded-full transition-all hover:scale-110",
|
||||
value === name && "ring-2 ring-offset-2 ring-ring ring-offset-background"
|
||||
)}
|
||||
style={{ backgroundColor: value === name ? color : undefined }}
|
||||
onClick={() => handlePresetColorClick(name)}
|
||||
title={name}
|
||||
>
|
||||
{value === name && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 背景颜色选项 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">背景颜色</label>
|
||||
<span className="text-xs text-muted-foreground">背景色</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{Object.entries(ANSI_BG_COLOR_MAP).map(([name, color]) => (
|
||||
<Button
|
||||
key={name}
|
||||
variant={value === name ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 rounded-full transition-all hover:scale-110",
|
||||
value === name && "ring-2 ring-offset-2 ring-ring ring-offset-background"
|
||||
)}
|
||||
style={{ backgroundColor: value === name ? color : undefined }}
|
||||
onClick={() => handlePresetColorClick(name)}
|
||||
title={name}
|
||||
>
|
||||
{value === name && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -115,5 +115,55 @@
|
||||
"cancel": "Cancel",
|
||||
"save_failed": "Failed to save config",
|
||||
"save_and_restart": "Save & Restart"
|
||||
},
|
||||
"statusline": {
|
||||
"title": "Status Line Configuration",
|
||||
"enable": "Enable Status Line",
|
||||
"theme": "Theme Style",
|
||||
"theme_default": "Default",
|
||||
"theme_powerline": "Powerline",
|
||||
"modules": "Modules",
|
||||
"module_type": "Type",
|
||||
"module_icon": "Icon",
|
||||
"module_text": "Text",
|
||||
"module_color": "Color",
|
||||
"module_background": "Background",
|
||||
"add_module": "Add Module",
|
||||
"remove_module": "Remove Module",
|
||||
"preview": "Preview",
|
||||
"workDir": "Working Directory",
|
||||
"gitBranch": "Git Branch",
|
||||
"model": "Model",
|
||||
"usage": "Usage",
|
||||
"background_none": "None",
|
||||
"color_black": "Black",
|
||||
"color_red": "Red",
|
||||
"color_green": "Green",
|
||||
"color_yellow": "Yellow",
|
||||
"color_blue": "Blue",
|
||||
"color_magenta": "Magenta",
|
||||
"color_cyan": "Cyan",
|
||||
"color_white": "White",
|
||||
"color_bright_black": "Bright Black",
|
||||
"color_bright_red": "Bright Red",
|
||||
"color_bright_green": "Bright Green",
|
||||
"color_bright_yellow": "Bright Yellow",
|
||||
"color_bright_blue": "Bright Blue",
|
||||
"color_bright_magenta": "Bright Magenta",
|
||||
"color_bright_cyan": "Bright Cyan",
|
||||
"color_bright_white": "Bright White",
|
||||
"import_export": "Import/Export",
|
||||
"import": "Import Config",
|
||||
"export": "Export Config",
|
||||
"download_template": "Download Template",
|
||||
"import_export_help": "Export current configuration as a JSON file, or import configuration from a JSON file. You can also download a configuration template for reference.",
|
||||
"export_success": "Configuration exported successfully",
|
||||
"export_failed": "Failed to export configuration",
|
||||
"import_success": "Configuration imported successfully",
|
||||
"import_failed": "Failed to import configuration",
|
||||
"invalid_config": "Invalid configuration file",
|
||||
"template_download_success": "Template downloaded successfully",
|
||||
"template_download_success_desc": "Configuration template has been downloaded to your device",
|
||||
"template_download_failed": "Failed to download template"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,5 +115,55 @@
|
||||
"cancel": "取消",
|
||||
"save_failed": "配置保存失败",
|
||||
"save_and_restart": "保存并重启"
|
||||
},
|
||||
"statusline": {
|
||||
"title": "状态栏配置",
|
||||
"enable": "启用状态栏",
|
||||
"theme": "主题样式",
|
||||
"theme_default": "默认",
|
||||
"theme_powerline": "Powerline",
|
||||
"modules": "模块",
|
||||
"module_type": "类型",
|
||||
"module_icon": "图标",
|
||||
"module_text": "文本",
|
||||
"module_color": "颜色",
|
||||
"module_background": "背景",
|
||||
"add_module": "添加模块",
|
||||
"remove_module": "移除模块",
|
||||
"preview": "预览",
|
||||
"workDir": "工作目录",
|
||||
"gitBranch": "Git分支",
|
||||
"model": "模型",
|
||||
"usage": "使用情况",
|
||||
"background_none": "无",
|
||||
"color_black": "黑色",
|
||||
"color_red": "红色",
|
||||
"color_green": "绿色",
|
||||
"color_yellow": "黄色",
|
||||
"color_blue": "蓝色",
|
||||
"color_magenta": "品红",
|
||||
"color_cyan": "青色",
|
||||
"color_white": "白色",
|
||||
"color_bright_black": "亮黑色",
|
||||
"color_bright_red": "亮红色",
|
||||
"color_bright_green": "亮绿色",
|
||||
"color_bright_yellow": "亮黄色",
|
||||
"color_bright_blue": "亮蓝色",
|
||||
"color_bright_magenta": "亮品红",
|
||||
"color_bright_cyan": "亮青色",
|
||||
"color_bright_white": "亮白色",
|
||||
"import_export": "导入/导出",
|
||||
"import": "导入配置",
|
||||
"export": "导出配置",
|
||||
"download_template": "下载模板",
|
||||
"import_export_help": "导出当前配置为JSON文件,或从JSON文件导入配置。您也可以下载配置模板作为参考。",
|
||||
"export_success": "配置导出成功",
|
||||
"export_failed": "配置导出失败",
|
||||
"import_success": "配置导入成功",
|
||||
"import_failed": "配置导入失败",
|
||||
"invalid_config": "无效的配置文件",
|
||||
"template_download_success": "模板下载成功",
|
||||
"template_download_success_desc": "配置模板已下载到您的设备",
|
||||
"template_download_failed": "模板下载失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,30 @@ export interface Transformer {
|
||||
options?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface StatusLineModuleConfig {
|
||||
type: string;
|
||||
icon?: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export interface StatusLineThemeConfig {
|
||||
modules: StatusLineModuleConfig[];
|
||||
}
|
||||
|
||||
export interface StatusLineConfig {
|
||||
enabled: boolean;
|
||||
currentStyle: string;
|
||||
default: StatusLineThemeConfig;
|
||||
powerline: StatusLineThemeConfig;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
Providers: Provider[];
|
||||
Router: RouterConfig;
|
||||
transformers: Transformer[];
|
||||
StatusLine?: StatusLineConfig;
|
||||
// Top-level settings
|
||||
LOG: boolean;
|
||||
LOG_LEVEL: string;
|
||||
|
||||
146
ui/src/utils/statusline.ts
Normal file
146
ui/src/utils/statusline.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { StatusLineConfig, StatusLineModuleConfig } from "@/types";
|
||||
|
||||
// 验证结果(保留接口但不使用)
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证StatusLine配置 - 已移除所有验证
|
||||
* @param config 要验证的配置对象
|
||||
* @returns 始终返回验证通过
|
||||
*/
|
||||
export function validateStatusLineConfig(config: unknown): ValidationResult {
|
||||
// 不再执行任何验证
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化错误信息(支持国际化)- 不再使用
|
||||
*/
|
||||
export function formatValidationError(error: unknown, t: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
return t("statusline.validation.unknown_error");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析颜色值,支持十六进制和内置颜色名称
|
||||
* @param color 颜色值(可以是颜色名称或十六进制值)
|
||||
* @param defaultColor 默认颜色(十六进制)
|
||||
* @returns 十六进制颜色值
|
||||
*/
|
||||
export function parseColorValue(color: string | undefined, defaultColor: string = "#ffffff"): string {
|
||||
if (!color) {
|
||||
return defaultColor;
|
||||
}
|
||||
|
||||
// 如果是十六进制颜色值(以#开头)
|
||||
if (color.startsWith('#')) {
|
||||
return color;
|
||||
}
|
||||
|
||||
// 如果是已知的颜色名称,返回对应的十六进制值
|
||||
return COLOR_HEX_MAP[color] || defaultColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为有效的十六进制颜色值
|
||||
* @param color 要检查的颜色值
|
||||
* @returns 是否为有效的十六进制颜色值
|
||||
*/
|
||||
export function isHexColor(color: string): boolean {
|
||||
return /^#([0-9A-F]{3}){1,2}$/i.test(color);
|
||||
}
|
||||
|
||||
// 颜色枚举到十六进制的映射
|
||||
export const COLOR_HEX_MAP: Record<string, string> = {
|
||||
black: "#000000",
|
||||
red: "#cd0000",
|
||||
green: "#00cd00",
|
||||
yellow: "#cdcd00",
|
||||
blue: "#0000ee",
|
||||
magenta: "#cd00cd",
|
||||
cyan: "#00cdcd",
|
||||
white: "#e5e5e5",
|
||||
bright_black: "#7f7f7f",
|
||||
bright_red: "#ff0000",
|
||||
bright_green: "#00ff00",
|
||||
bright_yellow: "#ffff00",
|
||||
bright_blue: "#5c5cff",
|
||||
bright_magenta: "#ff00ff",
|
||||
bright_cyan: "#00ffff",
|
||||
bright_white: "#ffffff",
|
||||
bg_black: "#000000",
|
||||
bg_red: "#cd0000",
|
||||
bg_green: "#00cd00",
|
||||
bg_yellow: "#cdcd00",
|
||||
bg_blue: "#0000ee",
|
||||
bg_magenta: "#cd00cd",
|
||||
bg_cyan: "#00cdcd",
|
||||
bg_white: "#e5e5e5",
|
||||
bg_bright_black: "#7f7f7f",
|
||||
bg_bright_red: "#ff0000",
|
||||
bg_bright_green: "#00ff00",
|
||||
bg_bright_yellow: "#ffff00",
|
||||
bg_bright_blue: "#5c5cff",
|
||||
bg_bright_magenta: "#ff00ff",
|
||||
bg_bright_cyan: "#00ffff",
|
||||
bg_bright_white: "#ffffff"
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建默认的StatusLine配置
|
||||
*/
|
||||
export function createDefaultStatusLineConfig(): StatusLineConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
currentStyle: "default",
|
||||
default: {
|
||||
modules: [
|
||||
{ type: "workDir", icon: "", text: "{{workDirName}}", color: "bright_blue" },
|
||||
{ type: "gitBranch", icon: "", text: "{{gitBranch}}", color: "bright_magenta" },
|
||||
{ type: "model", icon: "", text: "{{model}}", color: "bright_cyan" },
|
||||
{ type: "usage", icon: "↑", text: "{{inputTokens}}", color: "bright_green" },
|
||||
{ type: "usage", icon: "↓", text: "{{outputTokens}}", color: "bright_yellow" }
|
||||
]
|
||||
},
|
||||
powerline: {
|
||||
modules: [
|
||||
{ type: "workDir", icon: "", text: "{{workDirName}}", color: "white", background: "bg_bright_blue" },
|
||||
{ type: "gitBranch", icon: "", text: "{{gitBranch}}", color: "white", background: "bg_bright_magenta" },
|
||||
{ type: "model", icon: "", text: "{{model}}", color: "white", background: "bg_bright_cyan" },
|
||||
{ type: "usage", icon: "↑", text: "{{inputTokens}}", color: "white", background: "bg_bright_green" },
|
||||
{ type: "usage", icon: "↓", text: "{{outputTokens}}", color: "white", background: "bg_bright_yellow" }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建配置备份
|
||||
*/
|
||||
export function backupConfig(config: StatusLineConfig): string {
|
||||
const backup = {
|
||||
config,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "1.0"
|
||||
};
|
||||
return JSON.stringify(backup, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从备份恢复配置
|
||||
*/
|
||||
export function restoreConfig(backupStr: string): StatusLineConfig | null {
|
||||
try {
|
||||
const backup = JSON.parse(backupStr);
|
||||
if (backup && backup.config && backup.timestamp) {
|
||||
return backup.config as StatusLineConfig;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Failed to restore config from backup:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user