feat: add strategy publish settings and reorder navigation

- Add is_public and config_visible fields to Strategy type
- Add PublishSettingsEditor component for strategy studio
- Enable GORM AutoMigrate to add new columns
- Reorder nav: Market → Config → Dashboard → Strategy → Leaderboard → Arena → Backtest → FAQ
- Rename Live to Leaderboard, Debate Arena to Arena
This commit is contained in:
tinkle-community
2026-01-03 00:52:11 +08:00
parent 7df8197542
commit cc726adb57
6 changed files with 263 additions and 15 deletions
+1 -8
View File
@@ -172,14 +172,7 @@ func NewStrategyStore(db *gorm.DB) *StrategyStore {
}
func (s *StrategyStore) initTables() error {
// For PostgreSQL with existing table, skip AutoMigrate
if s.db.Dialector.Name() == "postgres" {
var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'strategies'`).Scan(&tableExists)
if tableExists > 0 {
return nil
}
}
// AutoMigrate will add missing columns without dropping existing data
return s.db.AutoMigrate(&Strategy{})
}
+2 -2
View File
@@ -101,11 +101,11 @@ export default function HeaderBar({
{(() => {
// Define all navigation tabs
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
@@ -449,11 +449,11 @@ export default function HeaderBar({
{/* Mobile Navigation Tabs - Show all tabs */}
{(() => {
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
@@ -0,0 +1,197 @@
import { Globe, Lock, Eye, EyeOff } from 'lucide-react'
interface PublishSettingsEditorProps {
isPublic: boolean
configVisible: boolean
onIsPublicChange: (value: boolean) => void
onConfigVisibleChange: (value: boolean) => void
disabled?: boolean
language: string
}
export function PublishSettingsEditor({
isPublic,
configVisible,
onIsPublicChange,
onConfigVisibleChange,
disabled = false,
language,
}: PublishSettingsEditorProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
publishToMarket: { zh: '发布到策略市场', en: 'Publish to Market' },
publishDesc: { zh: '策略将在市场公开展示,其他用户可发现并使用', en: 'Strategy will be publicly visible in the marketplace' },
showConfig: { zh: '公开配置参数', en: 'Show Config' },
showConfigDesc: { zh: '允许他人查看和复制详细配置', en: 'Allow others to view and clone config details' },
private: { zh: '私有', en: 'PRIVATE' },
public: { zh: '公开', en: 'PUBLIC' },
hidden: { zh: '隐藏', en: 'HIDDEN' },
visible: { zh: '可见', en: 'VISIBLE' },
}
return translations[key]?.[language] || key
}
return (
<div className="space-y-3">
{/* 发布开关 */}
<div
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
style={{
background: isPublic
? 'linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.05) 100%)'
: 'linear-gradient(135deg, #1E2329 0%, #0B0E11 100%)',
border: isPublic ? '1px solid rgba(14, 203, 129, 0.4)' : '1px solid #2B3139',
boxShadow: isPublic ? '0 0 20px rgba(14, 203, 129, 0.1)' : 'none',
}}
onClick={() => !disabled && onIsPublicChange(!isPublic)}
>
{/* Top glow line */}
<div
className="absolute top-0 left-0 w-full h-[1px] transition-opacity duration-300"
style={{
background: isPublic
? 'linear-gradient(90deg, transparent, #0ECB81, transparent)'
: 'linear-gradient(90deg, transparent, #2B3139, transparent)',
opacity: isPublic ? 1 : 0.5
}}
/>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="p-2.5 rounded-lg transition-all duration-300"
style={{
background: isPublic ? 'rgba(14, 203, 129, 0.2)' : '#0B0E11',
border: isPublic ? '1px solid rgba(14, 203, 129, 0.3)' : '1px solid #2B3139'
}}
>
{isPublic ? (
<Globe className="w-5 h-5" style={{ color: '#0ECB81' }} />
) : (
<Lock className="w-5 h-5" style={{ color: '#848E9C' }} />
)}
</div>
<div>
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{t('publishToMarket')}
</div>
<div className="text-xs mt-0.5" style={{ color: '#848E9C' }}>
{t('publishDesc')}
</div>
</div>
</div>
{/* Toggle with status */}
<div className="flex items-center gap-3">
<span
className="text-[10px] font-mono font-bold tracking-wider"
style={{ color: isPublic ? '#0ECB81' : '#848E9C' }}
>
{isPublic ? t('public') : t('private')}
</span>
<div
className="relative w-12 h-6 rounded-full transition-all duration-300"
style={{
background: isPublic
? 'linear-gradient(90deg, #0ECB81, #4ade80)'
: '#2B3139',
boxShadow: isPublic ? '0 0 10px rgba(14, 203, 129, 0.4)' : 'none'
}}
>
<div
className="absolute top-1 w-4 h-4 rounded-full transition-all duration-300"
style={{
background: '#EAECEF',
left: isPublic ? '28px' : '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
}}
/>
</div>
</div>
</div>
</div>
{/* 配置可见性开关 - 仅在公开时显示 */}
{isPublic && (
<div
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
style={{
background: configVisible
? 'linear-gradient(135deg, rgba(168, 85, 247, 0.15) 0%, rgba(168, 85, 247, 0.05) 100%)'
: 'linear-gradient(135deg, #1E2329 0%, #0B0E11 100%)',
border: configVisible ? '1px solid rgba(168, 85, 247, 0.4)' : '1px solid #2B3139',
boxShadow: configVisible ? '0 0 20px rgba(168, 85, 247, 0.1)' : 'none',
}}
onClick={() => !disabled && onConfigVisibleChange(!configVisible)}
>
{/* Top glow line */}
<div
className="absolute top-0 left-0 w-full h-[1px] transition-opacity duration-300"
style={{
background: configVisible
? 'linear-gradient(90deg, transparent, #a855f7, transparent)'
: 'linear-gradient(90deg, transparent, #2B3139, transparent)',
opacity: configVisible ? 1 : 0.5
}}
/>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="p-2.5 rounded-lg transition-all duration-300"
style={{
background: configVisible ? 'rgba(168, 85, 247, 0.2)' : '#0B0E11',
border: configVisible ? '1px solid rgba(168, 85, 247, 0.3)' : '1px solid #2B3139'
}}
>
{configVisible ? (
<Eye className="w-5 h-5" style={{ color: '#a855f7' }} />
) : (
<EyeOff className="w-5 h-5" style={{ color: '#848E9C' }} />
)}
</div>
<div>
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{t('showConfig')}
</div>
<div className="text-xs mt-0.5" style={{ color: '#848E9C' }}>
{t('showConfigDesc')}
</div>
</div>
</div>
{/* Toggle with status */}
<div className="flex items-center gap-3">
<span
className="text-[10px] font-mono font-bold tracking-wider"
style={{ color: configVisible ? '#a855f7' : '#848E9C' }}
>
{configVisible ? t('visible') : t('hidden')}
</span>
<div
className="relative w-12 h-6 rounded-full transition-all duration-300"
style={{
background: configVisible
? 'linear-gradient(90deg, #a855f7, #c084fc)'
: '#2B3139',
boxShadow: configVisible ? '0 0 10px rgba(168, 85, 247, 0.4)' : 'none'
}}
>
<div
className="absolute top-1 w-4 h-4 rounded-full transition-all duration-300"
style={{
background: '#EAECEF',
left: configVisible ? '28px' : '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
}}
/>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default PublishSettingsEditor
+4 -4
View File
@@ -18,11 +18,11 @@ export const translations = {
view: 'View',
// Navigation
realtimeNav: 'Live',
realtimeNav: 'Leaderboard',
configNav: 'Config',
dashboardNav: 'Dashboard',
strategyNav: 'Strategy',
debateNav: 'Debate Arena',
debateNav: 'Arena',
faqNav: 'FAQ',
// Footer
@@ -1226,11 +1226,11 @@ export const translations = {
view: '查看',
// Navigation
realtimeNav: '实时',
realtimeNav: '排行榜',
configNav: '配置',
dashboardNav: '看板',
strategyNav: '策略',
debateNav: '行情辩论',
debateNav: '竞技场',
faqNav: '常见问题',
// Footer
+38 -1
View File
@@ -28,6 +28,7 @@ import {
Send,
Download,
Upload,
Globe,
} from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel } from '../types'
import { confirmToast, notify } from '../lib/notify'
@@ -35,6 +36,7 @@ import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
const API_BASE = import.meta.env.VITE_API_BASE || ''
@@ -61,6 +63,7 @@ export function StrategyStudioPage() {
riskControl: false,
promptSections: false,
customPrompt: false,
publishSettings: false,
})
// Right panel states
@@ -181,6 +184,8 @@ export function StrategyStudioPage() {
description: '',
is_active: false,
is_default: false,
is_public: false,
config_visible: true,
config: defaultConfig,
created_at: now,
updated_at: now,
@@ -343,11 +348,14 @@ export function StrategyStudioPage() {
name: selectedStrategy.name,
description: selectedStrategy.description,
config: editingConfig,
is_public: selectedStrategy.is_public,
config_visible: selectedStrategy.config_visible,
}),
}
)
if (!response.ok) throw new Error('Failed to save strategy')
setHasChanges(false)
notify.success(language === 'zh' ? '策略已保存' : 'Strategy saved')
await fetchStrategies()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
@@ -462,6 +470,7 @@ export function StrategyStudioPage() {
duration: { zh: '耗时', en: 'Duration' },
noModel: { zh: '请先配置 AI 模型', en: 'Please configure AI model first' },
testNote: { zh: '使用真实 AI 模型测试,不执行交易', en: 'Test with real AI, no trading' },
publishSettings: { zh: '发布设置', en: 'Publish' },
}
return translations[key]?.[language] || key
}
@@ -557,6 +566,28 @@ export function StrategyStudioPage() {
</div>
),
},
{
key: 'publishSettings' as const,
icon: Globe,
color: '#0ECB81',
title: t('publishSettings'),
content: selectedStrategy && (
<PublishSettingsEditor
isPublic={selectedStrategy.is_public ?? false}
configVisible={selectedStrategy.config_visible ?? true}
onIsPublicChange={(value) => {
setSelectedStrategy({ ...selectedStrategy, is_public: value })
setHasChanges(true)
}}
onConfigVisibleChange={(value) => {
setSelectedStrategy({ ...selectedStrategy, config_visible: value })
setHasChanges(true)
}}
disabled={selectedStrategy?.is_default}
language={language}
/>
),
},
]
return (
@@ -658,7 +689,7 @@ export function StrategyStudioPage() {
)}
</div>
</div>
<div className="flex items-center gap-1 mt-1">
<div className="flex items-center gap-1 mt-1 flex-wrap">
{strategy.is_active && (
<span className="px-1.5 py-0.5 text-[10px] rounded" style={{ background: 'rgba(14, 203, 129, 0.15)', color: '#0ECB81' }}>
{t('active')}
@@ -669,6 +700,12 @@ export function StrategyStudioPage() {
{t('default')}
</span>
)}
{strategy.is_public && (
<span className="px-1.5 py-0.5 text-[10px] rounded flex items-center gap-0.5" style={{ background: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}>
<Globe className="w-2.5 h-2.5" />
{language === 'zh' ? '公开' : 'Public'}
</span>
)}
</div>
</div>
))}
+21
View File
@@ -428,11 +428,32 @@ export interface Strategy {
description: string;
is_active: boolean;
is_default: boolean;
is_public: boolean; // 是否在策略市场公开
config_visible: boolean; // 配置参数是否公开可见
config: StrategyConfig;
created_at: string;
updated_at: string;
}
// 策略使用统计
export interface StrategyStats {
clone_count: number; // 被克隆次数
active_users: number; // 当前使用人数
top_performers?: StrategyPerformer[]; // 收益排行
}
// 策略使用者收益排行
export interface StrategyPerformer {
user_id: string;
user_name: string; // 脱敏后的用户名
total_pnl_pct: number; // 总收益率
total_pnl: number; // 总收益金额
win_rate: number; // 胜率
trade_count: number; // 交易次数
using_since: string; // 使用开始时间
rank: number; // 排名
}
export interface PromptSectionsConfig {
role_definition?: string;
trading_frequency?: string;