mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-11 19:56:37 +08:00
style: 美化用户隧道权限分配表单UI并统一代码格式
- 使用HeroUI Checkbox组件替换原生checkbox - 重构隧道列表为card-based tile风格 - 添加选中/未选中/已分配状态视觉区分 - 应用eslint --fix代码格式统一 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+227
-214
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
@@ -7,23 +7,27 @@ import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateConfigs } from '@/api';
|
||||
import { SettingsIcon } from '@/components/icons';
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { isAdmin } from '@/utils/auth';
|
||||
import { getCachedConfigs, clearConfigCache, updateSiteConfig } from '@/config/site';
|
||||
import { updateConfigs } from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
getCachedConfigs,
|
||||
clearConfigCache,
|
||||
updateSiteConfig,
|
||||
} from "@/config/site";
|
||||
|
||||
// 简单的保存图标组件
|
||||
const SaveIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17,21 17,13 7,13 7,21" />
|
||||
@@ -36,7 +40,7 @@ interface ConfigItem {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
type: 'input' | 'switch' | 'select';
|
||||
type: "input" | "switch" | "select";
|
||||
options?: { label: string; value: string; description?: string }[];
|
||||
dependsOn?: string; // 依赖的配置项key
|
||||
dependsValue?: string; // 依赖的配置项值
|
||||
@@ -45,96 +49,102 @@ interface ConfigItem {
|
||||
// 网站配置项定义
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
label: '面板后端地址',
|
||||
placeholder: '请输入面板后端IP:PORT',
|
||||
description: '格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密',
|
||||
type: 'input'
|
||||
key: "ip",
|
||||
label: "面板后端地址",
|
||||
placeholder: "请输入面板后端IP:PORT",
|
||||
description:
|
||||
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: 'app_name',
|
||||
label: '应用名称',
|
||||
placeholder: '请输入应用名称',
|
||||
description: '在浏览器标签页和导航栏显示的应用名称',
|
||||
type: 'input'
|
||||
key: "app_name",
|
||||
label: "应用名称",
|
||||
placeholder: "请输入应用名称",
|
||||
description: "在浏览器标签页和导航栏显示的应用名称",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: 'captcha_enabled',
|
||||
label: '启用验证码',
|
||||
description: '开启后,用户登录时需要完成验证码验证',
|
||||
type: 'switch'
|
||||
key: "captcha_enabled",
|
||||
label: "启用验证码",
|
||||
description: "开启后,用户登录时需要完成验证码验证",
|
||||
type: "switch",
|
||||
},
|
||||
{
|
||||
key: 'captcha_type',
|
||||
label: '验证码类型',
|
||||
description: '选择验证码的显示类型,不同类型有不同的安全级别',
|
||||
type: 'select',
|
||||
dependsOn: 'captcha_enabled',
|
||||
dependsValue: 'true',
|
||||
key: "captcha_type",
|
||||
label: "验证码类型",
|
||||
description: "选择验证码的显示类型,不同类型有不同的安全级别",
|
||||
type: "select",
|
||||
dependsOn: "captcha_enabled",
|
||||
dependsValue: "true",
|
||||
options: [
|
||||
{
|
||||
label: '随机类型',
|
||||
value: 'RANDOM',
|
||||
description: '系统随机选择验证码类型'
|
||||
{
|
||||
label: "随机类型",
|
||||
value: "RANDOM",
|
||||
description: "系统随机选择验证码类型",
|
||||
},
|
||||
{
|
||||
label: '滑块验证码',
|
||||
value: 'SLIDER',
|
||||
description: '拖动滑块完成拼图验证'
|
||||
{
|
||||
label: "滑块验证码",
|
||||
value: "SLIDER",
|
||||
description: "拖动滑块完成拼图验证",
|
||||
},
|
||||
{
|
||||
label: '文字点选验证码',
|
||||
value: 'WORD_IMAGE_CLICK',
|
||||
description: '按顺序点击指定文字'
|
||||
{
|
||||
label: "文字点选验证码",
|
||||
value: "WORD_IMAGE_CLICK",
|
||||
description: "按顺序点击指定文字",
|
||||
},
|
||||
{
|
||||
label: '旋转验证码',
|
||||
value: 'ROTATE',
|
||||
description: '旋转图片到正确角度'
|
||||
{
|
||||
label: "旋转验证码",
|
||||
value: "ROTATE",
|
||||
description: "旋转图片到正确角度",
|
||||
},
|
||||
{
|
||||
label: '拼图验证码',
|
||||
value: 'CONCAT',
|
||||
description: '拖动滑块完成图片拼接'
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
label: "拼图验证码",
|
||||
value: "CONCAT",
|
||||
description: "拖动滑块完成图片拼接",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 初始化时从缓存读取配置,避免闪烁
|
||||
const getInitialConfigs = (): Record<string, string> => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
const configKeys = ['app_name', 'captcha_enabled', 'captcha_type', 'ip'];
|
||||
if (typeof window === "undefined") return {};
|
||||
|
||||
const configKeys = ["app_name", "captcha_enabled", "captcha_type", "ip"];
|
||||
const initialConfigs: Record<string, string> = {};
|
||||
|
||||
|
||||
try {
|
||||
configKeys.forEach(key => {
|
||||
const cachedValue = localStorage.getItem('vite_config_' + key);
|
||||
configKeys.forEach((key) => {
|
||||
const cachedValue = localStorage.getItem("vite_config_" + key);
|
||||
|
||||
if (cachedValue) {
|
||||
initialConfigs[key] = cachedValue;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
} catch (error) {}
|
||||
|
||||
return initialConfigs;
|
||||
};
|
||||
|
||||
export default function ConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const initialConfigs = getInitialConfigs();
|
||||
const [configs, setConfigs] = useState<Record<string, string>>(initialConfigs);
|
||||
const [loading, setLoading] = useState(Object.keys(initialConfigs).length === 0); // 如果有缓存数据,不显示loading
|
||||
const [configs, setConfigs] =
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
const [loading, setLoading] = useState(
|
||||
Object.keys(initialConfigs).length === 0,
|
||||
); // 如果有缓存数据,不显示loading
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [originalConfigs, setOriginalConfigs] = useState<Record<string, string>>(initialConfigs);
|
||||
const [originalConfigs, setOriginalConfigs] =
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (!isAdmin()) {
|
||||
toast.error('权限不足,只有管理员可以访问此页面');
|
||||
navigate('/dashboard', { replace: true });
|
||||
toast.error("权限不足,只有管理员可以访问此页面");
|
||||
navigate("/dashboard", { replace: true });
|
||||
|
||||
return;
|
||||
}
|
||||
}, [navigate]);
|
||||
@@ -143,17 +153,19 @@ export default function ConfigPage() {
|
||||
const loadConfigs = async (currentConfigs?: Record<string, string>) => {
|
||||
const configsToCompare = currentConfigs || configs;
|
||||
const hasInitialData = Object.keys(configsToCompare).length > 0;
|
||||
|
||||
|
||||
// 如果已有缓存数据,不显示loading,静默更新
|
||||
if (!hasInitialData) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const configData = await getCachedConfigs();
|
||||
|
||||
|
||||
// 只有在数据有变化时才更新
|
||||
const hasDataChanged = JSON.stringify(configData) !== JSON.stringify(configsToCompare);
|
||||
const hasDataChanged =
|
||||
JSON.stringify(configData) !== JSON.stringify(configsToCompare);
|
||||
|
||||
if (hasDataChanged) {
|
||||
setConfigs(configData);
|
||||
setOriginalConfigs({ ...configData });
|
||||
@@ -163,7 +175,7 @@ export default function ConfigPage() {
|
||||
} catch (error) {
|
||||
// 只有在没有缓存数据时才显示错误
|
||||
if (!hasInitialData) {
|
||||
toast.error('加载配置出错,请重试');
|
||||
toast.error("加载配置出错,请重试");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -182,22 +194,25 @@ export default function ConfigPage() {
|
||||
// 处理配置项变更
|
||||
const handleConfigChange = (key: string, value: string) => {
|
||||
let newConfigs = { ...configs, [key]: value };
|
||||
|
||||
|
||||
// 特殊处理:启用验证码时,如果验证码类型未设置,默认为随机
|
||||
if (key === 'captcha_enabled' && value === 'true') {
|
||||
if (key === "captcha_enabled" && value === "true") {
|
||||
if (!newConfigs.captcha_type) {
|
||||
newConfigs.captcha_type = 'RANDOM';
|
||||
newConfigs.captcha_type = "RANDOM";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setConfigs(newConfigs);
|
||||
|
||||
|
||||
// 检查是否有变更
|
||||
const hasChangesNow = Object.keys(newConfigs).some(
|
||||
k => newConfigs[k] !== originalConfigs[k]
|
||||
) || Object.keys(originalConfigs).some(
|
||||
k => originalConfigs[k] !== newConfigs[k]
|
||||
);
|
||||
const hasChangesNow =
|
||||
Object.keys(newConfigs).some(
|
||||
(k) => newConfigs[k] !== originalConfigs[k],
|
||||
) ||
|
||||
Object.keys(originalConfigs).some(
|
||||
(k) => originalConfigs[k] !== newConfigs[k],
|
||||
);
|
||||
|
||||
setHasChanges(hasChangesNow);
|
||||
};
|
||||
|
||||
@@ -206,112 +221,115 @@ export default function ConfigPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await updateConfigs(configs);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success('配置保存成功');
|
||||
|
||||
toast.success("配置保存成功");
|
||||
|
||||
// 清除所有配置缓存,强制下次重新获取
|
||||
clearConfigCache();
|
||||
|
||||
|
||||
// 获取变更的配置项
|
||||
const changedKeys = Object.keys(configs).filter(
|
||||
key => configs[key] !== originalConfigs[key]
|
||||
(key) => configs[key] !== originalConfigs[key],
|
||||
);
|
||||
|
||||
|
||||
setOriginalConfigs({ ...configs });
|
||||
setHasChanges(false);
|
||||
|
||||
|
||||
// 如果应用名称发生变化,立即更新网站配置
|
||||
if (changedKeys.includes('app_name')) {
|
||||
if (changedKeys.includes("app_name")) {
|
||||
await updateSiteConfig();
|
||||
}
|
||||
|
||||
|
||||
// 触发配置更新事件,通知其他组件
|
||||
window.dispatchEvent(new CustomEvent('configUpdated', {
|
||||
detail: { changedKeys }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("configUpdated", {
|
||||
detail: { changedKeys },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error('保存配置失败: ' + response.msg);
|
||||
toast.error("保存配置失败: " + response.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('保存配置出错,请重试');
|
||||
toast.error("保存配置出错,请重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 检查配置项是否应该显示(依赖检查)
|
||||
const shouldShowItem = (item: ConfigItem): boolean => {
|
||||
if (!item.dependsOn || !item.dependsValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return configs[item.dependsOn] === item.dependsValue;
|
||||
};
|
||||
|
||||
// 渲染不同类型的配置项
|
||||
const renderConfigItem = (item: ConfigItem) => {
|
||||
const isChanged = hasChanges && configs[item.key] !== originalConfigs[item.key];
|
||||
|
||||
const isChanged =
|
||||
hasChanges && configs[item.key] !== originalConfigs[item.key];
|
||||
|
||||
switch (item.type) {
|
||||
case 'input':
|
||||
case "input":
|
||||
return (
|
||||
<Input
|
||||
value={configs[item.key] || ''}
|
||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||
placeholder={item.placeholder}
|
||||
variant="bordered"
|
||||
size="md"
|
||||
classNames={{
|
||||
input: "text-sm",
|
||||
inputWrapper: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: ""
|
||||
inputWrapper: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: "",
|
||||
}}
|
||||
placeholder={item.placeholder}
|
||||
size="md"
|
||||
value={configs[item.key] || ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'switch':
|
||||
case "switch":
|
||||
return (
|
||||
<Switch
|
||||
isSelected={configs[item.key] === 'true'}
|
||||
onValueChange={(checked) => handleConfigChange(item.key, checked ? 'true' : 'false')}
|
||||
color="primary"
|
||||
size="md"
|
||||
classNames={{
|
||||
wrapper: isChanged ? "border-warning-300" : ""
|
||||
wrapper: isChanged ? "border-warning-300" : "",
|
||||
}}
|
||||
color="primary"
|
||||
isSelected={configs[item.key] === "true"}
|
||||
size="md"
|
||||
onValueChange={(checked) =>
|
||||
handleConfigChange(item.key, checked ? "true" : "false")
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{configs[item.key] === 'true' ? '已启用' : '已禁用'}
|
||||
{configs[item.key] === "true" ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
classNames={{
|
||||
trigger: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: "",
|
||||
}}
|
||||
placeholder="请选择验证码类型"
|
||||
selectedKeys={configs[item.key] ? [configs[item.key]] : []}
|
||||
size="md"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
handleConfigChange(item.key, selectedKey);
|
||||
}
|
||||
}}
|
||||
placeholder="请选择验证码类型"
|
||||
variant="bordered"
|
||||
size="md"
|
||||
classNames={{
|
||||
trigger: isChanged
|
||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||
: ""
|
||||
}}
|
||||
>
|
||||
{item.options?.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
description={option.description}
|
||||
>
|
||||
<SelectItem key={option.value} description={option.description}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)) || []}
|
||||
@@ -325,105 +343,100 @@ export default function ConfigPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner size="lg" label="加载配置中..." />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner label="加载配置中..." size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<SettingsIcon className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">网站配置</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
管理网站的基本信息和显示设置
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<SettingsIcon className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">网站配置</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
管理网站的基本信息和显示设置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">基本设置</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
配置网站的基本信息,这些设置会影响网站的显示效果
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
isLoading={saving}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
{saving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">基本设置</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
配置网站的基本信息,这些设置会影响网站的显示效果
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={!hasChanges}
|
||||
isLoading={saving}
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
{CONFIG_ITEMS.map((item, index) => {
|
||||
// 检查配置项是否应该显示
|
||||
if (!shouldShowItem(item)) {
|
||||
return null;
|
||||
}
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
{CONFIG_ITEMS.map((item, index) => {
|
||||
// 检查配置项是否应该显示
|
||||
if (!shouldShowItem(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算是否是最后一个显示的项目(用于决定是否显示分隔线)
|
||||
const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(shouldShowItem);
|
||||
const isLastItem = remainingItems.length === 0;
|
||||
// 计算是否是最后一个显示的项目(用于决定是否显示分隔线)
|
||||
const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(
|
||||
shouldShowItem,
|
||||
);
|
||||
const isLastItem = remainingItems.length === 0;
|
||||
|
||||
return (
|
||||
<div key={item.key} className="space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{item.label}
|
||||
</label>
|
||||
{item.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 渲染配置项 */}
|
||||
{renderConfigItem(item)}
|
||||
|
||||
{/* 分隔线 */}
|
||||
{!isLastItem && (
|
||||
<Divider className="mt-6" />
|
||||
return (
|
||||
<div key={item.key} className="space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{item.label}
|
||||
</label>
|
||||
{item.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 渲染配置项 */}
|
||||
{renderConfigItem(item)}
|
||||
|
||||
{/* 分隔线 */}
|
||||
{!isLastItem && <Divider className="mt-6" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 操作提示 */}
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm">
|
||||
检测到配置变更,请记得保存您的修改
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 操作提示 */}
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm">
|
||||
检测到配置变更,请记得保存您的修改
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user