add ccr statusline command

This commit is contained in:
musistudio
2025-08-15 23:50:57 +08:00
parent a265cbdce6
commit 0e509528c2
21 changed files with 2961 additions and 156 deletions

View File

@@ -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: '',

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View File

@@ -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"
}
}

View File

@@ -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": "模板下载失败"
}
}

View File

@@ -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
View 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;
}
}