fix(proxy): resolve UI/UX issues and database constraint error

Simplify proxy control interface and fix database persistence issues:

Backend Fixes:
- Fix NOT NULL constraint error in proxy_config.created_at field
  * Use COALESCE to preserve created_at on updates
  * Ensure proper INSERT OR REPLACE behavior
- Remove redundant enabled field validation on startup
  * Auto-enable when user clicks start button
  * Persist enabled state after successful start
- Preserve enabled state during config updates
  * Prevent accidental service shutdown on config save

Frontend Improvements:
- Remove duplicate proxy enable switch from settings dialog
  * Keep only runtime toggle in ProxyPanel
  * Simplify user experience with single control point
- Hide proxy target button when proxy service is stopped
  * Add isProxyRunning prop to ProviderCard
  * Conditionally render proxy controls based on service status
- Update form schema to omit enabled field
  * Managed automatically by backend

Files: 5 changed, 81 insertions(+), 94 deletions(-)
This commit is contained in:
YoVinchen
2025-12-01 09:17:12 +08:00
parent 4c94e70f97
commit c7ef4365b8
5 changed files with 81 additions and 94 deletions
+5 -3
View File
@@ -50,9 +50,11 @@ impl Database {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, updated_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))",
"INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))",
rusqlite::params![
if config.enabled { 1 } else { 0 },
config.listen_address,
+20 -24
View File
@@ -25,16 +25,14 @@ impl ProxyService {
/// 启动代理服务器
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
// 1. 获取配置
let config = self
let mut config = self
.db
.get_proxy_config()
.await
.map_err(|e| format!("获取代理配置失败: {e}"))?;
// 2. 检查是否启用
if !config.enabled {
return Err("代理服务未启用,请先在设置中启用".to_string());
}
// 2. 确保配置启用(用户通过UI启动即表示希望启用
config.enabled = true;
// 3. 检查是否已在运行
if self.server.read().await.is_some() {
@@ -42,7 +40,7 @@ impl ProxyService {
}
// 4. 创建并启动服务器
let server = ProxyServer::new(config, self.db.clone());
let server = ProxyServer::new(config.clone(), self.db.clone());
let info = server
.start()
.await
@@ -51,6 +49,12 @@ impl ProxyService {
// 5. 保存服务器实例
*self.server.write().await = Some(server);
// 6. 持久化 enabled 状态
self.db
.update_proxy_config(config)
.await
.map_err(|e| format!("保存代理配置失败: {e}"))?;
log::info!("代理服务器已启动: {}:{}", info.address, info.port);
Ok(info)
}
@@ -99,9 +103,12 @@ impl ProxyService {
.await
.map_err(|e| format!("获取代理配置失败: {e}"))?;
// 保存到数据库
// 保存到数据库(保持 enabled 状态不变)
let mut new_config = config.clone();
new_config.enabled = previous.enabled;
self.db
.update_proxy_config(config.clone())
.update_proxy_config(new_config.clone())
.await
.map_err(|e| format!("保存代理配置失败: {e}"))?;
@@ -111,20 +118,9 @@ impl ProxyService {
return Ok(());
}
// 如果关闭代理,直接停止
if !config.enabled {
if let Some(server) = server_guard.take() {
server
.stop()
.await
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
log::info!("代理配置禁用了服务,已自动停止代理服务器");
}
return Ok(());
}
let require_restart = config.listen_address != previous.listen_address
|| config.listen_port != previous.listen_port;
// 判断是否需要重启(地址或端口变更)
let require_restart = new_config.listen_address != previous.listen_address
|| new_config.listen_port != previous.listen_port;
if require_restart {
if let Some(server) = server_guard.take() {
@@ -134,7 +130,7 @@ impl ProxyService {
.map_err(|e| format!("重启前停止代理服务器失败: {e}"))?;
}
let new_server = ProxyServer::new(config.clone(), self.db.clone());
let new_server = ProxyServer::new(new_config, self.db.clone());
new_server
.start()
.await
@@ -143,7 +139,7 @@ impl ProxyService {
*server_guard = Some(new_server);
log::info!("代理配置已更新,服务器已自动重启应用最新配置");
} else if let Some(server) = server_guard.as_ref() {
server.apply_runtime_config(config).await;
server.apply_runtime_config(&new_config).await;
log::info!("代理配置已实时应用,无需重启代理服务器");
}
+39 -35
View File
@@ -31,6 +31,7 @@ interface ProviderCardProps {
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
dragHandleProps?: DragHandleProps;
}
@@ -80,6 +81,7 @@ export function ProviderCard({
onOpenWebsite,
onDuplicate,
onSetProxyTarget,
isProxyRunning,
dragHandleProps,
}: ProviderCardProps) {
const { t } = useTranslation();
@@ -169,41 +171,43 @@ export function ProviderCard({
</span>
)}
{/* 代理目标开关 */}
<div
className="flex items-center gap-2 ml-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => {
if (checked && !provider.isProxyTarget) {
onSetProxyTarget(provider);
}
}}
disabled={provider.isProxyTarget}
className="scale-75 data-[state=checked]:bg-purple-500"
/>
{provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs font-medium text-purple-500 dark:text-purple-400 cursor-pointer"
>
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
</Label>
)}
{!provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
{t("provider.setAsProxyTarget", {
defaultValue: "设为代理",
})}
</Label>
)}
</div>
{/* 代理目标开关 - 仅在代理服务运行时显示 */}
{isProxyRunning && (
<div
className="flex items-center gap-2 ml-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => {
if (checked && !provider.isProxyTarget) {
onSetProxyTarget(provider);
}
}}
disabled={provider.isProxyTarget}
className="scale-75 data-[state=checked]:bg-purple-500"
/>
{provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs font-medium text-purple-500 dark:text-purple-400 cursor-pointer"
>
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
</Label>
)}
{!provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
{t("provider.setAsProxyTarget", {
defaultValue: "设为代理",
})}
</Label>
)}
</div>
)}
</div>
{displayUrl && (
@@ -9,6 +9,7 @@ import type { CSSProperties } from "react";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
@@ -46,6 +47,9 @@ export function ProviderList({
appId,
);
// 获取代理服务运行状态
const { isRunning: isProxyRunning } = useProxyStatus();
if (isLoading) {
return (
<div className="space-y-3">
@@ -90,6 +94,7 @@ export function ProviderList({
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
/>
))}
</div>
@@ -109,6 +114,7 @@ interface SortableProviderCardProps {
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
}
function SortableProviderCard({
@@ -122,6 +128,7 @@ function SortableProviderCard({
onConfigureUsage,
onOpenWebsite,
onSetProxyTarget,
isProxyRunning,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -152,6 +159,7 @@ function SortableProviderCard({
}
onOpenWebsite={onOpenWebsite}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
dragHandleProps={{
attributes,
listeners,
+9 -32
View File
@@ -26,7 +26,8 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy";
type ProxyConfigForm = ProxyConfig;
// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理)
type ProxyConfigForm = Omit<ProxyConfig, "enabled">;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
@@ -50,7 +51,6 @@ const createProxyConfigSchema = (t: TFunction) => {
});
return z.object({
enabled: z.boolean(),
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
t("proxy.settings.validation.addressInvalid", {
@@ -108,7 +108,6 @@ export function ProxySettingsDialog({
const form = useForm<ProxyConfigForm>({
resolver: zodResolver(schema),
defaultValues: {
enabled: false,
listen_address: "127.0.0.1",
listen_port: 5000,
max_retries: 3,
@@ -128,7 +127,12 @@ export function ProxySettingsDialog({
const onSubmit = async (data: ProxyConfigForm) => {
try {
await updateConfig(data);
// 添加 enabled 字段(从当前配置中获取,保持不变)
const configToSave: ProxyConfig = {
...data,
enabled: config?.enabled ?? true,
};
await updateConfig(configToSave);
closePanel();
} catch (error) {
console.error("Save config failed:", error);
@@ -196,38 +200,11 @@ export function ProxySettingsDialog({
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "控制代理服务是否启用以及监听的地址与端口。",
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enabled.label", {
defaultValue: "启用代理服务",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enabled.description", {
defaultValue: "开启后可以从命令或 UI 启动代理服务器",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}