@@ -9,10 +9,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
|
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
|
||||||
Database, Wand2, Copy, Download, Clock, X, Check,
|
Database, Wand2, Copy, Download,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
|
import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
|
||||||
@@ -22,7 +22,6 @@ import { fmtBigNum } from '@/lib/format'
|
|||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||||
import { toast } from '@/components/Toast'
|
import { toast } from '@/components/Toast'
|
||||||
import { usePreferences } from '@/lib/useSharedQueries'
|
|
||||||
import { useReviewState } from '@/lib/useReviewStore'
|
import { useReviewState } from '@/lib/useReviewStore'
|
||||||
import {
|
import {
|
||||||
startReviewGeneration, resetReview, isReviewGenerating,
|
startReviewGeneration, resetReview, isReviewGenerating,
|
||||||
@@ -99,45 +98,8 @@ export function Review() {
|
|||||||
onError: () => { /* request() 已 toast */ },
|
onError: () => { /* request() 已 toast */ },
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===== 定时复盘 =====
|
|
||||||
const [showSchedule, setShowSchedule] = useState(false)
|
|
||||||
const prefs = usePreferences()
|
|
||||||
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
|
|
||||||
const feishuConfigured = !!((prefs.data as any)?.feishu_webhook_url)
|
|
||||||
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
|
|
||||||
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
|
|
||||||
const reviewPushChannels = prefs.data?.review_push_channels ?? []
|
|
||||||
// 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗)
|
|
||||||
const [draft, setDraft] = useState(reviewSched)
|
|
||||||
const openSchedule = useCallback(() => {
|
|
||||||
setDraft(reviewSched) // 每次打开同步最新服务端值
|
|
||||||
setShowSchedule(true)
|
|
||||||
}, [reviewSched])
|
|
||||||
const reviewMut = useMutation({
|
|
||||||
mutationFn: ({ enabled, hour, minute }: { enabled: boolean; hour: number; minute: number }) =>
|
|
||||||
api.updateReviewSchedule(enabled, hour, minute),
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
setShowSchedule(false)
|
|
||||||
toast(vars.enabled ? '已开启定时复盘' : '已关闭定时复盘', 'success')
|
|
||||||
},
|
|
||||||
onError: () => { /* request() 已 toast */ },
|
|
||||||
})
|
|
||||||
// 推送渠道(多选): 独立常驻, 即时生效(勾选渠道即开关, 改了立刻提交)
|
|
||||||
const pushMut = useMutation({
|
|
||||||
mutationFn: (channels: string[]) => api.updateReviewPush(channels),
|
|
||||||
onSuccess: (_data, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
toast(vars.length === 0 ? '已关闭复盘推送' : '已更新复盘推送渠道', 'success')
|
|
||||||
},
|
|
||||||
onError: () => { /* request() 已 toast */ },
|
|
||||||
})
|
|
||||||
const togglePushChannel = useCallback((ch: string) => {
|
|
||||||
const next = reviewPushChannels.includes(ch)
|
|
||||||
? reviewPushChannels.filter(c => c !== ch)
|
|
||||||
: [...reviewPushChannels, ch]
|
|
||||||
pushMut.mutate(next)
|
|
||||||
}, [reviewPushChannels, pushMut])
|
|
||||||
|
|
||||||
// 自动滚动到报告底部(streaming 时)
|
// 自动滚动到报告底部(streaming 时)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,18 +198,6 @@ export function Review() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />刷新
|
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />刷新
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={openSchedule}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 rounded-btn border px-2 py-1 text-[11px] transition-colors',
|
|
||||||
reviewSched.enabled
|
|
||||||
? 'border-accent/40 bg-accent/10 text-accent hover:bg-accent/20'
|
|
||||||
: 'border-border bg-elevated text-secondary hover:text-foreground',
|
|
||||||
)}
|
|
||||||
title={reviewSched.enabled ? `定时复盘已开启 · 每日 ${String(reviewSched.hour).padStart(2,'0')}:${String(reviewSched.minute).padStart(2,'0')}` : '定时复盘'}
|
|
||||||
>
|
|
||||||
<Clock className="h-3 w-3" />定时
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
@@ -344,149 +294,6 @@ export function Review() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 定时复盘设置弹窗 ===== */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{showSchedule && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
||||||
onClick={() => setShowSchedule(false)}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.96, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.96, opacity: 0 }}
|
|
||||||
className="w-full max-w-md rounded-card border border-border bg-surface p-5 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="h-4 w-4 text-accent" />
|
|
||||||
<h3 className="text-sm font-medium text-foreground">定时复盘</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSchedule(false)}
|
|
||||||
className="rounded p-1 text-muted transition-colors hover:bg-elevated hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mb-4 text-[11px] leading-relaxed text-muted">
|
|
||||||
开启后,每个交易日到点自动生成大盘复盘报告并归档,静默执行。
|
|
||||||
下次打开本页即可在历史列表看到新报告;也可选推送到飞书。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 开关(只改本地草稿, 不提交) */}
|
|
||||||
<label className="flex items-center justify-between rounded-btn bg-elevated/40 px-3 py-2.5">
|
|
||||||
<span className="text-xs text-foreground">启用定时复盘</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={draft.enabled}
|
|
||||||
onClick={() => setDraft(d => ({ ...d, enabled: !d.enabled }))}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
|
|
||||||
draft.enabled ? 'bg-accent' : 'bg-border',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn('inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform', draft.enabled ? 'translate-x-[18px]' : 'translate-x-1')} />
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 时间设置(仅开启时可编辑, 本地草稿) */}
|
|
||||||
{draft.enabled && (
|
|
||||||
<div className="mt-3 flex items-center gap-2 rounded-btn bg-elevated/40 px-3 py-2.5">
|
|
||||||
<span className="text-[11px] text-muted">每日</span>
|
|
||||||
<input
|
|
||||||
type="number" min={0} max={23} value={draft.hour}
|
|
||||||
onChange={e => setDraft(d => ({ ...d, hour: Math.max(0, Math.min(23, Number(e.target.value))) }))}
|
|
||||||
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted">:</span>
|
|
||||||
<input
|
|
||||||
type="number" min={0} max={59} value={draft.minute}
|
|
||||||
onChange={e => setDraft(d => ({ ...d, minute: Math.max(0, Math.min(59, Number(e.target.value))) }))}
|
|
||||||
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted/70">不早于 15:00 · 工作日执行</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 推送渠道(多选, 独立常驻, 与定时无关, 即时生效) */}
|
|
||||||
<div className="mt-3 rounded-btn bg-elevated/40 px-3 py-2.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-foreground">生成后推送完整报告</span>
|
|
||||||
<span className="text-[10px] text-muted/70">{reviewPushChannels.length === 0 ? '未开启' : `${reviewPushChannels.length} 个渠道`}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 space-y-1.5">
|
|
||||||
{/* 飞书(可用, 多选) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={pushMut.isPending}
|
|
||||||
onClick={() => togglePushChannel('feishu')}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
|
|
||||||
reviewPushChannels.includes('feishu')
|
|
||||||
? 'border-accent/40 bg-accent/10'
|
|
||||||
: 'border-border/60 bg-base/40 hover:bg-base/60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('feishu') ? 'border-accent bg-accent text-white' : 'border-border')}>
|
|
||||||
{reviewPushChannels.includes('feishu') && <Check className="h-2.5 w-2.5" />}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-foreground">飞书</span>
|
|
||||||
<span className="text-[9px] text-muted">群机器人</span>
|
|
||||||
<span className={cn('ml-auto text-[9px]', feishuConfigured ? 'text-emerald-500' : 'text-warning')}>
|
|
||||||
{feishuConfigured ? '已配置' : '未配置'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/* 微信(开发中, 占位不可选) */}
|
|
||||||
<div className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-1.5 opacity-60">
|
|
||||||
<span className="flex h-3 w-3 shrink-0 items-center justify-center rounded border border-border" />
|
|
||||||
<span className="text-[11px] text-secondary">微信</span>
|
|
||||||
<span className="text-[9px] text-muted">公众号/企业微信</span>
|
|
||||||
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">开发中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
|
|
||||||
手动或定时生成的复盘都会以卡片消息推送完整报告。复用「设置 → 实时监控」的飞书 Webhook。
|
|
||||||
{reviewPushChannels.includes('feishu') && !feishuConfigured && (
|
|
||||||
<Link to="/settings?tab=monitoring" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
|
|
||||||
前往配置 →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!draft.enabled && (
|
|
||||||
<p className="mt-3 text-[10px] text-muted/70">
|
|
||||||
当前: 已关闭。开启后将按设定时间自动复盘。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作区: 取消 + 保存(统一提交开关+时间) */}
|
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSchedule(false)}
|
|
||||||
className="rounded-btn bg-elevated px-4 py-1.5 text-xs text-secondary transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => reviewMut.mutate({ enabled: draft.enabled, hour: draft.hour, minute: draft.minute })}
|
|
||||||
disabled={reviewMut.isPending}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{reviewMut.isPending ? '保存中…' : '保存'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user