码桶
发现社区成员的开源项目
Profile.tsx15 KB
import { useEffect, useState, useCallback, type ReactNode } from 'react'
import { useAuth } from '@/lib/auth'
import { api } from '@/lib/api'
import { DndPanel } from '@/components/DndPanel'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Loader2, BellOff, Cpu, Link2, Pencil, KeyRound } from 'lucide-react'
import { toast } from 'sonner'
interface Account {
username: string
email: string
profile_url: string
ai_persona: string
}
export default function Profile() {
const { me, bots, loading: authLoading } = useAuth()
const [acc, setAcc] = useState<Account | null>(null)
const [loading, setLoading] = useState(true)
const [selBotId, setSelBotId] = useState<number>(0)
// 勿扰设置:默认选中第一个机器人
useEffect(() => {
if (!authLoading && bots.length > 0 && !selBotId) {
setSelBotId(Number(bots[0].id))
}
}, [authLoading, bots, selBotId])
const [profileUrl, setProfileUrl] = useState('')
const [profileUrlEditing, setProfileUrlEditing] = useState(false)
const [profileUrlSaving, setProfileUrlSaving] = useState(false)
const [heat, setHeat] = useState<{ day: string; tokens: number }[]>([])
const [heatLoading, setHeatLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const d = await api.post('/api/account')
if (d.ok) { setAcc(d); setProfileUrl(d.profile_url || '') }
} catch (e: any) { toast.error(e.message) } finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
api.post('/api/token_heatmap', { days: 182 })
.then(d => { if (d.ok) setHeat(d.data || []) })
.catch(() => {})
.finally(() => setHeatLoading(false))
}, [])
async function doSaveProfileUrl() {
setProfileUrlSaving(true)
try {
const d = await api.post('/api/update_profile', { profile_url: profileUrl })
if (!d.ok) throw new Error(d.msg)
toast.success(d.msg)
setProfileUrlEditing(false)
setAcc(prev => prev ? { ...prev, profile_url: d.profile_url } : prev)
} catch (e: any) { toast.error(e.message) } finally { setProfileUrlSaving(false) }
}
if (loading || !acc) {
return <div className="flex justify-center py-20"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
}
return (
<div className="mx-auto w-full max-w-6xl space-y-5 p-3 md:p-6">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_360px] items-start">
{/* 左主栏:个人信息 */}
<div className="space-y-5">
{/* 头部:渐变背景 + 头像 */}
<div className="relative overflow-hidden rounded-2xl border p-6 shadow-sm bg-gradient-to-br from-zinc-800 to-zinc-900 text-white">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16 shrink-0 ring-4 ring-black/5">
<AvatarFallback className="text-2xl font-bold text-white bg-white/25">
{(acc.username || '?').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-xl font-bold">{acc.username}</h1>
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-sm text-primary-foreground/80">
<Link2 className="h-3.5 w-3.5 shrink-0" />
{profileUrlEditing ? (
<div className="flex flex-1 items-center gap-1.5">
<Input
className="h-7 min-w-0 flex-1 bg-white/15 text-primary-foreground placeholder:text-primary-foreground/50 border-white/20"
value={profileUrl}
onChange={e => setProfileUrl(e.target.value)}
placeholder="https://你的网站"
autoFocus
onKeyDown={e => { if (e.key === 'Enter') doSaveProfileUrl(); if (e.key === 'Escape') { setProfileUrlEditing(false); setProfileUrl(acc.profile_url || ''); } }}
/>
<Button size="sm" variant="ghost" className="h-7 px-2 text-primary-foreground hover:bg-white/20" disabled={profileUrlSaving} onClick={doSaveProfileUrl}>
{profileUrlSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存'}
</Button>
</div>
) : (
<>
{acc.profile_url ? (
<a href={acc.profile_url} target="_blank" rel="noopener noreferrer" className="truncate underline underline-offset-2 hover:text-white">
{acc.profile_url}
</a>
) : (
<span className="opacity-60">未设置个人网站</span>
)}
<button onClick={() => setProfileUrlEditing(true)} className="ml-1 rounded p-0.5 opacity-60 hover:opacity-100 hover:bg-white/20">
<Pencil className="h-3 w-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* 勿扰设置(原 /push-settings,已整合进个人中心) */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<BellOff className="h-4 w-4" /> 勿扰设置
</CardTitle>
<CardDescription>
为每个微信会话单独设置勿扰时段:该时段内机器人主动发出的指定类别消息会被延后到时段结束再发。
也可在微信中发送「勿扰 22:00-08:00」快速设置。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{authLoading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
{!authLoading && bots.length === 0 && (
<p className="text-sm text-muted-foreground">暂无机器人,请先绑定。</p>
)}
{!authLoading && bots.length > 0 && selBotId > 0 && (
<DndPanel botId={selBotId} embedded={false} />
)}
</CardContent>
</Card>
{/* 修改管理员密码(开源单用户版:由前台个人中心管理) */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<KeyRound className="h-4 w-4" /> 修改密码
</CardTitle>
<CardDescription>
固定管理员账号 admin,默认密码 admin123。修改后请牢记新密码。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ChangePassword />
</CardContent>
</Card>
</div>
{/* 右栏:Token 热力图 */}
<div className="space-y-5 lg:sticky lg:top-6">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Cpu className="h-4 w-4" /> Token 使用热力图
</CardTitle>
<CardDescription>按天展示你的 AI Token 消耗,颜色越深当天用得越多</CardDescription>
</CardHeader>
<CardContent>
{heatLoading ? (
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
) : (
<TokenHeatmap data={heat} />
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}
/** 修改密码(调用 /api/change_password) */
function ChangePassword() {
const [oldPw, setOldPw] = useState('')
const [newPw, setNewPw] = useState('')
const [cfmPw, setCfmPw] = useState('')
const [loading, setLoading] = useState(false)
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
async function submit() {
if (newPw.length < 6) { setMsg({ ok: false, text: '新密码至少 6 位' }); return }
if (newPw !== cfmPw) { setMsg({ ok: false, text: '两次输入的新密码不一致' }); return }
setLoading(true); setMsg(null)
const d: any = await api.post('/api/change_password', { old_password: oldPw, new_password: newPw })
setLoading(false)
if (d && d.ok) {
setMsg({ ok: true, text: '密码修改成功' })
setOldPw(''); setNewPw(''); setCfmPw('')
} else {
setMsg({ ok: false, text: (d && d.msg) || '修改失败' })
}
}
return (
<div className="space-y-4">
{msg && (
<div className={'rounded-md border p-3 text-sm ' + (msg.ok ? 'border-green-500/30 bg-green-500/10 text-green-600' : 'border-red-500/30 bg-red-500/10 text-red-600')}>
{msg.text}
</div>
)}
<div className="space-y-1.5">
<Label>原密码</Label>
<Input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} placeholder="请输入当前密码" autoComplete="current-password" />
</div>
<div className="space-y-1.5">
<Label>新密码</Label>
<Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="至少 6 位" autoComplete="new-password" />
</div>
<div className="space-y-1.5">
<Label>确认新密码</Label>
<Input type="password" value={cfmPw} onChange={(e) => setCfmPw(e.target.value)} placeholder="再次输入新密码" autoComplete="new-password" />
</div>
<Button onClick={submit} disabled={loading}>{loading ? '提交中…' : '修改密码'}</Button>
</div>
)
}
/** Token 使用热力图(GitHub 风格:按周分列,颜色深浅代表当天用量) */
function TokenHeatmap({ data }: { data: { day: string; tokens: number }[] }) {
if (!data || data.length === 0) {
return <p className="text-sm text-muted-foreground">暂无用量数据</p>
}
// 仅展示当月(从当月 1 号到今天)
const today = new Date();
today.setHours(0, 0, 0, 0);
const cutoff = new Date(today.getFullYear(), today.getMonth(), 1);
const days = data.filter(d => {
const t = new Date(d.day + 'T00:00:00').getTime();
return t >= cutoff.getTime() && t <= today.getTime();
});
if (days.length === 0) {
return <p className="text-sm text-muted-foreground">本月暂无用量数据</p>
}
const map: Record<string, number> = {};
for (const d of days) map[d.day] = (map[d.day] || 0) + d.tokens;
const max = Math.max(1, ...days.map(d => d.tokens));
const levelClass = ['bg-muted', 'bg-emerald-200', 'bg-emerald-400', 'bg-emerald-500', 'bg-emerald-600'];
const levelOf = (t: number) => {
if (!t) return 0;
const r = t / max;
return r > 0.8 ? 4 : r > 0.55 ? 3 : r > 0.3 ? 2 : 1;
};
const start = new Date(days[0].day + 'T00:00:00');
const end = new Date(days[days.length - 1].day + 'T00:00:00');
const total = days.reduce((s, c) => s + c.tokens, 0);
const withinRange = (key: string) => {
const t = new Date(key + 'T00:00:00').getTime();
return t >= start.getTime() && t <= end.getTime();
};
// 构建覆盖窗口的月份列表(含起止月之间的所有月份,通常为当前月 + 前后相邻月)
const months: { y: number; m: number }[] = [];
const cur = new Date(start.getFullYear(), start.getMonth(), 1);
const last = new Date(end.getFullYear(), end.getMonth(), 1);
while (cur <= last) {
months.push({ y: cur.getFullYear(), m: cur.getMonth() });
cur.setMonth(cur.getMonth() + 1);
}
const WEEK = ['一', '二', '三', '四', '五', '六', '日']; // 周一开头
// 渲染单月日历(函数调用,非组件,避免每次重挂载)
const renderMonth = (mo: { y: number; m: number }) => {
const { y, m } = mo;
const dim = new Date(y, m + 1, 0).getDate();
const first = new Date(y, m, 1);
const lead = (first.getDay() + 6) % 7; // 周一开头偏移
const cells: ({ d: number; key: string } | null)[] = [
...Array(lead).fill(null),
...Array.from({ length: dim }, (_, i) => {
const d = i + 1;
const key = `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
return { d, key };
}),
];
let monthTotal = 0;
for (let d = 1; d <= dim; d++) {
const key = `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
monthTotal += map[key] || 0;
}
return (
<div key={`${y}-${m}`} className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">{y}年{m + 1}月</span>
<span className="text-xs text-muted-foreground">{monthTotal.toLocaleString()} Token</span>
</div>
<div className="mb-1 grid grid-cols-7 gap-1.5 text-center text-xs text-muted-foreground">
{WEEK.map(w => <div key={w}>{w}</div>)}
</div>
<div className="grid grid-cols-7 gap-1.5">
{cells.map((c, i) => {
if (!c) return <div key={i} />;
const tokens = map[c.key] || 0;
const within = withinRange(c.key);
const lvl = within ? levelOf(tokens) : 0;
return (
<div
key={i}
title={`${c.key}${within ? ':' + tokens.toLocaleString() + ' Token' : '(无数据)'}`}
className={`flex min-h-[2.25rem] items-center justify-center rounded-md text-sm ${within ? levelClass[lvl] : 'bg-transparent'} ${within && tokens > 0 ? 'text-emerald-950' : 'text-muted-foreground'}`}
>
{c.d}
</div>
);
})}
</div>
</div>
);
};
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>
本月累计 <strong className="text-foreground">{total.toLocaleString()}</strong> Token
</span>
<div className="flex items-center gap-1">
<span>少</span>
{levelClass.map((c, i) => (
<span key={i} className={`h-3 w-3 rounded-sm ${c}`} />
))}
<span>多</span>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{months.map(mo => renderMonth(mo))}
</div>
</div>
);
}
// 保留类型给其他组件引用(无实际使用)
export type { ReactNode }