feat: 新增日志中心、功能优化与代码重构
- 新增 log_datasource.go / log_proxy.go / user_api_token.go 后端控制器 - 新增 log_datasource.go model - 新增 monitor/log-center.ts API 模块 - 新增 LogCenter 前端组件及页面 - 重构 deployment 相关组件 (HPDA/UpdateImageModal/bundle-yaml 等) - 新增 ResetPasswordDialog / code-snippet-block / copy-button 等 UI 组件 - 重构多个 k8s 相关组件 (TopologyView/ResourceVisualEditor/PodFileBrowser 等) - 更新多个 API 和工具函数 (auth.ts/monitor/index.ts/request.ts 等) - 更新静态资源 (index.html + 新 JS/CSS bundle) - 删除废弃静态资源文件
This commit is contained in:
1 parent
5ca0e6d83a
commit
8d46e8f352
197 files changed
+5014
-815
No files matched your search
@@ -0,0 +1,443 @@
|
||||
// ApiAccessKeySection — 单条 CI/CD 访问凭证(凭证卡片布局)// Logan.Li
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { CodeSnippetBlock } from '@/components/ui/code-snippet-block';
|
||||
import {
|
||||
KeyRound, Plus, Settings2, RotateCw, Ban, Loader2, Rocket, Eye, EyeOff, Clock, Activity,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { userApi } from '@/api';
|
||||
import { getPublicApiBase } from '@/utils/request';
|
||||
import { getSessionApiAccessKey, setSessionApiAccessKey } from '@/utils/storage';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type ApiKeyMeta = {
|
||||
has_token: boolean;
|
||||
key_id?: string;
|
||||
prefix?: string;
|
||||
access_key_mask?: string;
|
||||
description?: string;
|
||||
created_at?: string;
|
||||
expires_at?: string;
|
||||
last_used_at?: string;
|
||||
status?: 'active' | 'expired';
|
||||
expired?: boolean;
|
||||
days_left?: number;
|
||||
};
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString();
|
||||
}
|
||||
|
||||
function defaultExpiresDate(months = 12) {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() + months);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toDateInputValue(value?: string) {
|
||||
if (!value) return defaultExpiresDate();
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? defaultExpiresDate() : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 根据创建/到期时间计算有效期进度(0-100)// Logan.Li */
|
||||
function expiryProgress(createdAt?: string, expiresAt?: string) {
|
||||
if (!createdAt || !expiresAt) return { pct: 0, daysLeft: null as number | null };
|
||||
const start = new Date(createdAt).getTime();
|
||||
const end = new Date(expiresAt).getTime();
|
||||
const now = Date.now();
|
||||
if (end <= start) return { pct: 0, daysLeft: 0 };
|
||||
const daysLeft = Math.max(0, Math.ceil((end - now) / 86400000));
|
||||
const pct = Math.min(100, Math.max(0, ((now - start) / (end - start)) * 100));
|
||||
return { pct, daysLeft };
|
||||
}
|
||||
|
||||
function shortKeyId(id?: string) {
|
||||
if (!id) return '—';
|
||||
if (id.length <= 10) return id;
|
||||
return `${id.slice(0, 6)}…${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function ApiAccessKeySection() {
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const apiBase = getPublicApiBase();
|
||||
|
||||
const [keyMeta, setKeyMeta] = useState<ApiKeyMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [plainToken, setPlainToken] = useState('');
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'create' | 'edit'>('create');
|
||||
const [formDescription, setFormDescription] = useState('');
|
||||
const [formExpiresAt, setFormExpiresAt] = useState(defaultExpiresDate());
|
||||
|
||||
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||
const [revealedToken, setRevealedToken] = useState('');
|
||||
|
||||
const loadKey = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cached = getSessionApiAccessKey();
|
||||
if (cached) setPlainToken(cached);
|
||||
const meta = await userApi.getApiAccessKey().catch(() => ({ has_token: false }));
|
||||
setKeyMeta(meta);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadKey(); }, [loadKey]);
|
||||
|
||||
const copyableToken = plainToken || getSessionApiAccessKey();
|
||||
const mask = keyMeta?.access_key_mask || (keyMeta?.prefix ? `${keyMeta.prefix}.•••••••••••••••••••••••••••••••••` : '');
|
||||
|
||||
const { pct: expiryPct, daysLeft: calcDaysLeft } = useMemo(
|
||||
() => expiryProgress(keyMeta?.created_at, keyMeta?.expires_at),
|
||||
[keyMeta?.created_at, keyMeta?.expires_at],
|
||||
);
|
||||
const daysLeft = keyMeta?.days_left ?? calcDaysLeft;
|
||||
const isExpired = !!keyMeta?.expired || keyMeta?.status === 'expired' || (daysLeft != null && daysLeft <= 0);
|
||||
|
||||
const openCreate = () => {
|
||||
setFormMode('create');
|
||||
setFormDescription('');
|
||||
setFormExpiresAt(defaultExpiresDate());
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
setFormMode('edit');
|
||||
setFormDescription(keyMeta?.description || '');
|
||||
setFormExpiresAt(toDateInputValue(keyMeta?.expires_at));
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async () => {
|
||||
if (formMode === 'edit') {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await userApi.updateApiAccessKey({
|
||||
description: formDescription,
|
||||
expires_at: formExpiresAt,
|
||||
});
|
||||
setKeyMeta(res);
|
||||
setFormOpen(false);
|
||||
toast({ title: '凭证信息已更新' });
|
||||
} catch (e: any) {
|
||||
toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasKey = !!keyMeta?.has_token;
|
||||
const ok = await confirm({
|
||||
title: hasKey ? '轮换凭证' : '签发凭证',
|
||||
description: hasKey ? '旧凭证将立即作废,是否继续?' : '凭证明文仅显示一次,请妥善保存。',
|
||||
confirmText: hasKey ? '轮换' : '签发',
|
||||
variant: hasKey ? 'destructive' : 'default',
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await userApi.regenerateApiAccessKey({
|
||||
description: formDescription,
|
||||
expires_at: formExpiresAt,
|
||||
});
|
||||
setPlainToken(res.token);
|
||||
setSessionApiAccessKey(res.token);
|
||||
setRevealedToken(res.token);
|
||||
setTokenDialogOpen(true);
|
||||
setFormOpen(false);
|
||||
setKeyMeta({
|
||||
has_token: true,
|
||||
key_id: res.key_id,
|
||||
prefix: res.prefix,
|
||||
access_key_mask: `${res.prefix}.•••••••••••••••••••••••••••••••••`,
|
||||
description: res.description,
|
||||
created_at: res.created_at,
|
||||
expires_at: res.expires_at,
|
||||
status: 'active',
|
||||
expired: false,
|
||||
days_left: Math.ceil((new Date(res.expires_at).getTime() - Date.now()) / 86400000),
|
||||
});
|
||||
toast({ title: '凭证已签发', description: '已保存至本浏览器,请立即复制' });
|
||||
} catch (e: any) {
|
||||
toast({ title: '签发失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
const ok = await confirm({
|
||||
title: '吊销凭证',
|
||||
description: '吊销后 Pipeline / curl 调用将全部失败,是否继续?',
|
||||
confirmText: '吊销',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await userApi.revokeApiAccessKey();
|
||||
setPlainToken('');
|
||||
setSessionApiAccessKey('');
|
||||
setKeyMeta({ has_token: false });
|
||||
toast({ title: '凭证已吊销' });
|
||||
} catch (e: any) {
|
||||
toast({ title: '吊销失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const authHeader = copyableToken ? `X-AIOps-Access-Key: ${copyableToken}` : 'X-AIOps-Access-Key: <你的访问凭证>';
|
||||
const sampleCluster = 'test-k8s';
|
||||
const sampleNs = 'default';
|
||||
const sampleDep = 'demo-jar';
|
||||
const updateCurl = `curl -X POST '${apiBase}/kubernetes/${sampleCluster}/namespace/${sampleNs}/deployments/${sampleDep}/update-image' \\
|
||||
-H '${authHeader}' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{"image":"harbor.example.com/app:v1.0","container_name":"main"}'`;
|
||||
const restartCurl = `curl -X POST '${apiBase}/kubernetes/${sampleCluster}/namespace/${sampleNs}/deployments/${sampleDep}/restart' \\
|
||||
-H '${authHeader}'`;
|
||||
|
||||
const progressColor = isExpired
|
||||
? 'bg-red-500'
|
||||
: daysLeft != null && daysLeft <= 30
|
||||
? 'bg-amber-400'
|
||||
: 'bg-[var(--dg-blue)]';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="dg-card border-[var(--dg-blue)]/20">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<KeyRound className="h-4 w-4 text-[var(--dg-blue)]" />
|
||||
CI/CD 访问凭证
|
||||
<Badge className="text-[10px] bg-[var(--dg-blue)]/15 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">长期有效</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
每人唯一凭证,Deployment 发布脚本自动引用,不随登录会话过期。
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!loading && !keyMeta?.has_token && (
|
||||
<Button size="sm" onClick={openCreate} disabled={actionLoading}>
|
||||
<Plus className="h-4 w-4 mr-1" />签发凭证
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-[var(--dg-text-muted)] justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />加载中…
|
||||
</div>
|
||||
) : keyMeta?.has_token ? (
|
||||
<div className="rounded-xl border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 overflow-hidden">
|
||||
{/* 凭证主体:左侧色条 + 内容区 // Logan.Li */}
|
||||
<div className="flex">
|
||||
<div className={cn(
|
||||
'w-1 shrink-0',
|
||||
isExpired ? 'bg-red-500' : daysLeft != null && daysLeft <= 30 ? 'bg-amber-400' : 'bg-gradient-to-b from-[var(--dg-blue)] to-[var(--dg-purple)]',
|
||||
)} />
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--dg-text-muted)]">凭证字符串</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md px-3 py-2 truncate block">
|
||||
{showToken && copyableToken ? copyableToken : mask}
|
||||
</code>
|
||||
{copyableToken && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0 border-[var(--dg-border)]"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
title={showToken ? '隐藏' : '显示'}
|
||||
>
|
||||
{showToken ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 shrink-0">
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs border-[var(--dg-border)]" onClick={openEdit} disabled={actionLoading}>
|
||||
<Settings2 className="h-3 w-3 mr-1" />调整
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs border-[var(--dg-border)]" onClick={openCreate} disabled={actionLoading}>
|
||||
<RotateCw className="h-3 w-3 mr-1" />轮换
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs border-red-500/30 text-red-400" onClick={handleRevoke} disabled={actionLoading}>
|
||||
<Ban className="h-3 w-3 mr-1" />吊销
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 text-xs">
|
||||
<div>
|
||||
<p className="text-[var(--dg-text-muted)] mb-0.5">用途说明</p>
|
||||
<p className="text-[var(--dg-text-secondary)]">{keyMeta.description || '未填写'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--dg-text-muted)] mb-0.5">凭证标识</p>
|
||||
<p className="font-mono text-[var(--dg-text-secondary)]" title={keyMeta.key_id}>{shortKeyId(keyMeta.key_id)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--dg-text-muted)] mb-0.5">签发时间</p>
|
||||
<p className="text-[var(--dg-text-secondary)]">{formatDateTime(keyMeta.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 有效期进度条 // Logan.Li */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="flex items-center gap-1 text-[var(--dg-text-muted)]">
|
||||
<Clock className="h-3 w-3" />
|
||||
{isExpired ? '已过期' : daysLeft != null ? `剩余 ${daysLeft} 天` : '有效期'}
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)]">
|
||||
有效至 {formatDateTime(keyMeta.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--dg-bg-secondary)] overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', progressColor)}
|
||||
style={{ width: `${isExpired ? 100 : 100 - expiryPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-[var(--dg-text-muted)] pt-1 border-t border-[var(--dg-border)]/60">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
最近调用 {formatDateTime(keyMeta.last_used_at)}
|
||||
</span>
|
||||
{keyMeta.prefix && (
|
||||
<span>前缀 <code className="text-[var(--dg-text-secondary)]">{keyMeta.prefix}</code></span>
|
||||
)}
|
||||
{isExpired && (
|
||||
<Badge variant="outline" className="text-[10px] border-red-500/40 text-red-400">需续期或轮换</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30 px-6 py-12 text-center">
|
||||
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-[var(--dg-blue)]/10 flex items-center justify-center">
|
||||
<KeyRound className="h-6 w-6 text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">尚未签发 CI/CD 访问凭证</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1 mb-4">签发后可用于 Pipeline 触发镜像更新、滚动重启等操作</p>
|
||||
<Button size="sm" onClick={openCreate} disabled={actionLoading}>
|
||||
<Plus className="h-4 w-4 mr-1" />立即签发
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keyMeta?.has_token && !copyableToken && (
|
||||
<p className="text-[11px] text-amber-400/90">本机未缓存凭证明文,请点击「轮换」重新签发一次;签发后将自动填入 Deployment 发布 curl。</p>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/60 p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||
<Rocket className="h-3.5 w-3.5 text-purple-400" />调用示例
|
||||
</p>
|
||||
<CodeSnippetBlock
|
||||
label="更新镜像"
|
||||
code={updateCurl}
|
||||
copyLabel="curl 已复制"
|
||||
copyDisabled={!copyableToken}
|
||||
copyDisabledTitle="请先签发访问凭证"
|
||||
/>
|
||||
<CodeSnippetBlock
|
||||
label="滚动重启"
|
||||
code={restartCurl}
|
||||
copyLabel="curl 已复制"
|
||||
copyDisabled={!copyableToken}
|
||||
copyDisabledTitle="请先签发访问凭证"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{formMode === 'edit' ? '调整凭证' : keyMeta?.has_token ? '轮换凭证' : '签发凭证'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-[var(--dg-text-muted)]">
|
||||
{formMode === 'edit' ? '修改用途说明与有效期限,凭证字符串不变。' : '填写用途并设定有效期,签发后请立即保存明文。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">用途说明</Label>
|
||||
<Input
|
||||
value={formDescription}
|
||||
onChange={e => setFormDescription(e.target.value)}
|
||||
placeholder="如 Jenkins 流水线、GitLab CI"
|
||||
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">有效至</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formExpiresAt}
|
||||
onChange={e => setFormExpiresAt(e.target.value)}
|
||||
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} className="border-[var(--dg-border)]">取消</Button>
|
||||
<Button onClick={handleFormSubmit} disabled={actionLoading || !formExpiresAt}>
|
||||
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
|
||||
{formMode === 'edit' ? '保存' : keyMeta?.has_token ? '轮换' : '签发'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">请保存凭证明文</DialogTitle>
|
||||
<DialogDescription className="text-xs text-[var(--dg-text-muted)]">
|
||||
仅此一次机会查看完整字符串,关闭后无法再次获取。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CodeSnippetBlock
|
||||
label="访问凭证"
|
||||
code={revealedToken}
|
||||
copyLabel="凭证已复制"
|
||||
className="mt-2"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setTokenDialogOpen(false)}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// ProfilePage — 个人中心(基本信息 / 安全 / API 访问密钥)// Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { User, Shield, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { userApi } from '@/api';
|
||||
import { useUserStore } from '@/store';
|
||||
import { ApiAccessKeySection } from './ApiAccessKeySection';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const storeUser = useUserStore(s => s.user);
|
||||
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [oldPwd, setOldPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [pwdSaving, setPwdSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await userApi.getProfile();
|
||||
setProfile(p);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const displayName = profile?.real_name || profile?.nickname || storeUser?.nickname || storeUser?.username || '用户';
|
||||
const username = profile?.username || storeUser?.username || '';
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!oldPwd || newPwd.length < 6) return;
|
||||
setPwdSaving(true);
|
||||
try {
|
||||
await userApi.changePassword({ old_password: oldPwd, new_password: newPwd });
|
||||
toast({ title: '密码已修改' });
|
||||
setOldPwd('');
|
||||
setNewPwd('');
|
||||
} catch (e: any) {
|
||||
toast({ title: '修改失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setPwdSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24 text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6 pb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-[var(--dg-blue)] to-[var(--dg-purple)] flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{displayName}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">@{username} · {profile?.email || '—'}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{(profile?.roles || []).map((r: any) => (
|
||||
<Badge key={r.id || r.name} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.name || r}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<User className="h-4 w-4 text-[var(--dg-blue)]" />基本信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">用户名</span><span className="font-mono">{username}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">邮箱</span><span>{profile?.email || '—'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">手机</span><span>{profile?.phone || '—'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">账号来源</span>
|
||||
<Badge variant="outline" className="text-xs">{profile?.auth_source === 'ldap' ? 'LDAP' : '本地'}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<Shield className="h-4 w-4 text-emerald-400" />安全设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">修改登录密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">当前密码</Label>
|
||||
<Input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">新密码</Label>
|
||||
<Input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<Button size="sm" onClick={handleChangePassword} disabled={pwdSaving || !oldPwd || newPwd.length < 6}>
|
||||
{pwdSaving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
|
||||
保存密码
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ApiAccessKeySection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user