feat: improve model/exchange deletion and selection logic

- Add soft delete support for AI models
  * Add deleted field to ai_models table
  * Implement soft delete with sensitive data cleanup
  * Filter deleted records in queries
- Replace browser confirm dialogs with custom styled modals
  * Create DeleteConfirmModal component with Binance theme
  * Add proper warning messages and icons
  * Improve user experience with consistent styling
- Fix duplicate model/exchange display in selection dropdowns
  * Use supportedModels/supportedExchanges for modal selectors
  * Use configuredModels/configuredExchanges for panel display
  * Remove redundant selectableModels/selectableExchanges logic
- Enhance data management
  * Refresh data after deletion operations
  * Proper state cleanup after modal operations
  * Clear sensitive data during soft delete
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
icy
2025-11-06 22:05:21 +08:00
parent 08f57fe5c9
commit 7ce7c64fa8
4 changed files with 216 additions and 57 deletions

View File

@@ -140,8 +140,9 @@ func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error
SELECT id, user_id, name, provider, enabled, api_key,
COALESCE(custom_api_url, '') as custom_api_url,
COALESCE(custom_model_name, '') as custom_model_name,
COALESCE(deleted, FALSE) as deleted,
created_at, updated_at
FROM ai_models WHERE user_id = $1 ORDER BY id
FROM ai_models WHERE user_id = $1 AND COALESCE(deleted, FALSE) = FALSE ORDER BY id
`, userID)
if err != nil {
return nil, err
@@ -152,10 +153,11 @@ func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error
models := make([]*AIModelConfig, 0)
for rows.Next() {
var model AIModelConfig
var deleted bool // 临时变量,用于读取 deleted 字段但不保存到结构体
err := rows.Scan(
&model.ID, &model.UserID, &model.Name, &model.Provider,
&model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName,
&model.CreatedAt, &model.UpdatedAt,
&deleted, &model.CreatedAt, &model.UpdatedAt,
)
if err != nil {
return nil, err
@@ -168,7 +170,59 @@ func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error
// UpdateAIModel 更新AI模型配置如果不存在则创建用户特定配置
func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
// 先尝试精确匹配 ID新版逻辑支持多个相同 provider 的模型)
log.Printf("🔧 UpdateAIModel: userID=%s, id=%s, enabled=%v", userID, id, enabled)
// 检查是否为删除操作API Key 为空且 enabled 为 false 表示删除)
isDelete := !enabled && apiKey == "" && customAPIURL == "" && customModelName == ""
if isDelete {
// 执行软删除:标记为已删除并清空敏感数据
// 先尝试精确匹配 ID
var existingID string
err := d.db.QueryRow(`
SELECT id FROM ai_models WHERE user_id = $1 AND id = $2 LIMIT 1
`, userID, id).Scan(&existingID)
if err == nil {
// 找到了现有配置(精确匹配 ID标记为删除并清空敏感数据
_, err = d.db.Exec(`
UPDATE ai_models SET enabled = FALSE, deleted = TRUE, api_key = '', custom_api_url = '', custom_model_name = '', updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $2
`, existingID, userID)
if err != nil {
log.Printf("❌ UpdateAIModel: 标记删除失败: %v", err)
return err
}
log.Printf("🗑️ UpdateAIModel: 已标记删除用户 %s 的模型配置 %s", userID, existingID)
return nil
}
// ID 不存在,尝试兼容旧逻辑:将 id 作为 provider 查找
provider := id
err = d.db.QueryRow(`
SELECT id FROM ai_models WHERE user_id = $1 AND provider = $2 LIMIT 1
`, userID, provider).Scan(&existingID)
if err == nil {
// 找到了现有配置(通过 provider 匹配),标记为删除并清空敏感数据
_, err = d.db.Exec(`
UPDATE ai_models SET enabled = FALSE, deleted = TRUE, api_key = '', custom_api_url = '', custom_model_name = '', updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $2
`, existingID, userID)
if err != nil {
log.Printf("❌ UpdateAIModel: 标记删除失败: %v", err)
return err
}
log.Printf("🗑️ UpdateAIModel: 已标记删除用户 %s 的模型配置 %s (通过provider匹配)", userID, existingID)
return nil
}
// 没有找到配置,返回成功(幂等性)
log.Printf(" UpdateAIModel: 模型配置不存在,跳过删除: %s", id)
return nil
}
// 启用模型的情况:先尝试精确匹配 ID新版逻辑支持多个相同 provider 的模型)
var existingID string
err := d.db.QueryRow(`
SELECT id FROM ai_models WHERE user_id = $1 AND id = $2 LIMIT 1
@@ -177,7 +231,7 @@ func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiK
if err == nil {
// 找到了现有配置(精确匹配 ID更新它
_, err = d.db.Exec(`
UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, updated_at = CURRENT_TIMESTAMP
UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = $5 AND user_id = $6
`, enabled, apiKey, customAPIURL, customModelName, existingID, userID)
return err
@@ -193,7 +247,7 @@ func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiK
// 找到了现有配置(通过 provider 匹配,兼容旧版),更新它
log.Printf("⚠️ 使用旧版 provider 匹配更新模型: %s -> %s", provider, existingID)
_, err = d.db.Exec(`
UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, updated_at = CURRENT_TIMESTAMP
UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = $5 AND user_id = $6
`, enabled, apiKey, customAPIURL, customModelName, existingID, userID)
return err

View File

@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS ai_models (
api_key TEXT DEFAULT '',
custom_api_url TEXT DEFAULT '',
custom_model_name TEXT DEFAULT '',
deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -167,6 +168,9 @@ INSERT INTO system_config (key, value) VALUES
('jwt_secret', '')
ON CONFLICT (key) DO NOTHING;
-- 数据库迁移:添加 deleted 字段到现有 ai_models 表
ALTER TABLE ai_models ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_ai_models_user_id ON ai_models(user_id);
CREATE INDEX IF NOT EXISTS idx_exchanges_user_id ON exchanges(user_id);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect } from 'react'
import useSWR from 'swr'
import { api } from '../lib/api'
import type {
@@ -58,6 +58,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [showModelModal, setShowModelModal] = useState(false)
const [showExchangeModal, setShowExchangeModal] = useState(false)
const [showSignalSourceModal, setShowSignalSourceModal] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<{type: 'model' | 'exchange', id: string} | null>(null)
const [editingModel, setEditingModel] = useState<string | null>(null)
const [editingExchange, setEditingExchange] = useState<string | null>(null)
const [editingTrader, setEditingTrader] = useState<any>(null)
@@ -135,20 +137,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const configuredModels = allModels || []
const configuredExchanges = allExchanges || []
const selectableModels = useMemo(() => {
return (supportedModels || []).map((model) => {
const configured = allModels?.find((m) => m.id === model.id)
return configured ? { ...model, ...configured } : model
})
}, [supportedModels, allModels])
const selectableExchanges = useMemo(() => {
return (supportedExchanges || []).map((exchange) => {
const configured = allExchanges?.find((e) => e.id === exchange.id)
return configured ? { ...exchange, ...configured } : exchange
})
}, [supportedExchanges, allExchanges])
// 只在创建交易员时使用已启用且配置完整的
const enabledModels = allModels?.filter((m) => m.enabled && m.apiKey) || []
const enabledExchanges =
@@ -313,8 +301,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
const handleDeleteModelConfig = async (modelId: string) => {
if (!confirm(t('confirmDeleteModel', language))) return
try {
const updatedModels =
allModels?.map((m) =>
@@ -344,15 +330,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
await api.updateModelConfigs(request)
setAllModels(updatedModels)
// 重新获取用户配置以确保数据同步
const refreshedModels = await api.getModelConfigs()
setAllModels(refreshedModels)
setShowModelModal(false)
setEditingModel(null)
setShowDeleteConfirm(false)
setDeleteTarget(null)
} catch (error) {
console.error('Failed to delete model config:', error)
alert(t('deleteConfigFailed', language))
}
}
const handleConfirmDelete = () => {
if (!deleteTarget) return
if (deleteTarget.type === 'model') {
handleDeleteModelConfig(deleteTarget.id)
} else if (deleteTarget.type === 'exchange') {
handleDeleteExchangeConfig(deleteTarget.id)
}
}
const handleSaveModelConfig = async (
modelId: string,
apiKey: string,
@@ -427,8 +429,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
const handleDeleteExchangeConfig = async (exchangeId: string) => {
if (!confirm(t('confirmDeleteExchange', language))) return
try {
const request = {
exchanges: {
@@ -451,6 +451,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setAllExchanges(refreshed)
setShowExchangeModal(false)
setEditingExchange(null)
setShowDeleteConfirm(false)
setDeleteTarget(null)
} catch (error) {
console.error('Failed to delete exchange config:', error)
alert(t('deleteExchangeConfigFailed', language))
@@ -1043,15 +1045,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
{/* Model Configuration Modal */}
{showModelModal && (
<ModelConfigModal
allModels={selectableModels}
supportedModels={supportedModels}
configuredModels={allModels}
editingModelId={editingModel}
onSave={handleSaveModelConfig}
onDelete={handleDeleteModelConfig}
onClose={() => {
setShowModelModal(false)
setEditingModel(null)
}}
onDelete={(modelId) => {
setDeleteTarget({ type: 'model', id: modelId })
setShowDeleteConfirm(true)
}}
language={language}
/>
)}
@@ -1059,15 +1064,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
{/* Exchange Configuration Modal */}
{showExchangeModal && (
<ExchangeConfigModal
allExchanges={selectableExchanges}
supportedExchanges={supportedExchanges}
configuredExchanges={allExchanges}
editingExchangeId={editingExchange}
onSave={handleSaveExchangeConfig}
onDelete={handleDeleteExchangeConfig}
onClose={() => {
setShowExchangeModal(false)
setEditingExchange(null)
}}
onDelete={(exchangeId) => {
setDeleteTarget({ type: 'exchange', id: exchangeId })
setShowDeleteConfirm(true)
}}
language={language}
/>
)}
@@ -1082,6 +1090,27 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
language={language}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && deleteTarget && (
<DeleteConfirmModal
isOpen={showDeleteConfirm}
title={deleteTarget.type === 'model'
? t('confirmDeleteModel', language)
: t('confirmDeleteExchange', language)
}
message={deleteTarget.type === 'model'
? t('deleteModelWarning', language)
: t('deleteExchangeWarning', language)
}
onConfirm={handleConfirmDelete}
onCancel={() => {
setShowDeleteConfirm(false)
setDeleteTarget(null)
}}
language={language}
/>
)}
</div>
)
}
@@ -1255,17 +1284,80 @@ function SignalSourceModal({
)
}
// Delete Confirmation Modal Component
function DeleteConfirmModal({
isOpen,
title,
message,
onConfirm,
onCancel,
language,
}: {
isOpen: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
language: Language
}) {
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="bg-gray-800 rounded-lg p-6 w-full max-w-md relative"
style={{ background: '#1E2329' }}
>
<div className="flex items-center gap-3 mb-4">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ background: 'rgba(246, 70, 93, 0.1)' }}
>
<AlertTriangle className="w-5 h-5" style={{ color: '#F6465D' }} />
</div>
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{title}
</h3>
</div>
<p className="text-sm mb-6" style={{ color: '#848E9C' }}>
{message}
</p>
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{t('cancel', language)}
</button>
<button
type="button"
onClick={onConfirm}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F6465D', color: '#fff' }}
>
{t('delete', language)}
</button>
</div>
</div>
</div>
)
}
// Model Configuration Modal Component
function ModelConfigModal({
allModels,
supportedModels,
configuredModels,
editingModelId,
onSave,
onDelete,
onClose,
onDelete,
language,
}: {
allModels: AIModel[]
supportedModels: AIModel[]
configuredModels: AIModel[]
editingModelId: string | null
onSave: (
@@ -1274,8 +1366,8 @@ function ModelConfigModal({
baseUrl?: string,
modelName?: string
) => void
onDelete: (modelId: string) => void
onClose: () => void
onDelete: (modelId: string) => void
language: Language
}) {
const [selectedModelId, setSelectedModelId] = useState(editingModelId || '')
@@ -1283,10 +1375,10 @@ function ModelConfigModal({
const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('')
// 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找
// 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从支持的模型中查找
const selectedModel = editingModelId
? configuredModels?.find((m) => m.id === selectedModelId)
: allModels?.find((m) => m.id === selectedModelId)
? configuredModels?.find((m) => m.id === selectedModelId) // 编辑:从已配置中获取完整信息
: supportedModels?.find((m) => m.id === selectedModelId) // 新建:从支持列表获取基本信息
// 如果是编辑现有模型初始化API Key、Base URL和Model Name
useEffect(() => {
@@ -1309,8 +1401,8 @@ function ModelConfigModal({
)
}
// 可选择的模型列表(所有支持的模型
const availableModels = allModels || []
// 可选择的模型列表:直接使用系统支持的模型
const availableModels = supportedModels || []
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
@@ -1327,14 +1419,10 @@ function ModelConfigModal({
{editingModelId && (
<button
type="button"
onClick={() => {
if (confirm(t('confirmDeleteModel', language))) {
onDelete(editingModelId)
}
}}
onClick={() => onDelete(editingModelId)}
className="p-2 rounded hover:bg-red-100 transition-colors"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
title={t('deleteConfigFailed', language)}
title={t('deleteModel', language)}
>
<Trash2 className="w-4 h-4" />
</button>
@@ -1528,15 +1616,15 @@ function ModelConfigModal({
// Exchange Configuration Modal Component
function ExchangeConfigModal({
allExchanges,
supportedExchanges,
configuredExchanges,
editingExchangeId,
onSave,
onDelete,
onClose,
onDelete,
language,
}: {
allExchanges: Exchange[]
supportedExchanges: Exchange[]
configuredExchanges: Exchange[]
editingExchangeId: string | null
onSave: (
@@ -1549,8 +1637,8 @@ function ExchangeConfigModal({
asterSigner?: string,
asterPrivateKey?: string
) => Promise<void>
onDelete: (exchangeId: string) => void
onClose: () => void
onDelete: (exchangeId: string) => void
language: Language
}) {
const [selectedExchangeId, setSelectedExchangeId] = useState(
@@ -1581,10 +1669,10 @@ function ExchangeConfigModal({
// 获取当前选择的交易所信息
// 编辑模式:从 configuredExchanges 查找(包含用户配置的 apiKey、secretKey 等)
// 新增模式:从 allExchanges 查找(系统支持的交易所列表)
// 新增模式:从 supportedExchanges 查找(系统支持的交易所列表)
const selectedExchange = editingExchangeId
? configuredExchanges?.find(e => e.id === selectedExchangeId)
: allExchanges?.find(e => e.id === selectedExchangeId);
: supportedExchanges?.find(e => e.id === selectedExchangeId);
// 如果是编辑现有交易所,初始化表单数据
useEffect(() => {
@@ -1622,6 +1710,9 @@ function ExchangeConfigModal({
}
}, [selectedExchangeId])
// 可选择的交易所列表:直接使用系统支持的交易所
const availableExchanges = supportedExchanges || []
const handleCopyIP = (ip: string) => {
navigator.clipboard.writeText(ip).then(() => {
setCopiedIP(true)
@@ -1693,17 +1784,13 @@ function ExchangeConfigModal({
{editingExchangeId && (
<button
type="button"
onClick={() => {
if (confirm(t('confirmDeleteExchange', language))) {
onDelete(editingExchangeId)
}
}}
onClick={() => onDelete(editingExchangeId)}
className="p-2 rounded hover:bg-red-100 transition-colors"
style={{
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}}
title={t('deleteConfigFailed', language)}
title={t('deleteExchange', language)}
>
<Trash2 className="w-4 h-4" />
</button>
@@ -1732,7 +1819,7 @@ function ExchangeConfigModal({
required
>
<option value="">{t('pleaseSelectExchange', language)}</option>
{(allExchanges || []).map((exchange) => (
{availableExchanges.map((exchange) => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name)} ({exchange.type.toUpperCase()}
)

View File

@@ -313,6 +313,13 @@ export const translations = {
deleteExchangeConfigFailed: 'Failed to delete exchange configuration',
saveSignalSourceFailed: 'Failed to save signal source configuration',
// Delete Confirmation
delete: 'Delete',
deleteModel: 'Delete Model',
deleteExchange: 'Delete Exchange',
deleteModelWarning: 'This will remove the AI model configuration. Traders using this model will not work properly.',
deleteExchangeWarning: 'This will remove the exchange configuration. Traders using this exchange will not be able to trade.',
// Login & Register
login: 'Sign In',
register: 'Sign Up',
@@ -784,6 +791,13 @@ export const translations = {
deleteExchangeConfigFailed: '删除交易所配置失败',
saveSignalSourceFailed: '保存信号源配置失败',
// Delete Confirmation
delete: '删除',
deleteModel: '删除模型',
deleteExchange: '删除交易所',
deleteModelWarning: '这将删除AI模型配置。使用此模型的交易员将无法正常工作。',
deleteExchangeWarning: '这将删除交易所配置。使用此交易所的交易员将无法进行交易。',
// Login & Register
login: '登录',
register: '注册',