码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/Profile.tsx
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 }