feat(profile): 补全写操作-改资料/改密/API Key 增删状态切换 (#991)

This commit is contained in:
agent committed 2026-08-23 13:21:16 +08:00
1 parent 09440c2577
commit 66f696c1e6
3 files changed
+311 -18

No files matched your search

+31
View File
@@ -265,6 +265,17 @@ export interface ApiKeyItem {
expire_at: string | null; expire_at: string | null;
} }
// 创建 API Key 时后端返回的完整 key(仅创建时返回)
export interface ApiKeyCreated {
id: string;
name: string;
api_key: string;
status: string;
created_at: string;
}
export type ApiKeyStatus = 'enabled' | 'disabled' | 'revoked';
export interface PurchasedAppItem { export interface PurchasedAppItem {
id: string; id: string;
app_id: string; app_id: string;
@@ -299,6 +310,26 @@ export const profileApi = {
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'), getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'), getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'),
// ─── 写操作 ───
updatePassword: (payload: { old_password: string; new_password: string }) =>
request<{ code: number; message: string }>('/profile/password', {
method: 'PUT',
body: JSON.stringify(payload),
}),
createApiKey: (name: string) =>
data<ApiKeyCreated>('/profile/api-keys', {
method: 'POST',
body: JSON.stringify({ name }),
}),
updateApiKey: (keyId: string, status: ApiKeyStatus) =>
data<{ id: string; status: string }>(`/profile/api-keys/${keyId}`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
}; };
// ─── Token 驾驶舱 ──────────────────────────────────────────────────────────────── // ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
+26
View File
@@ -229,6 +229,32 @@ export function useProfilePurchasedApps() {
}); });
} }
// ─── Profile 写操作 ─────────────────────────────────────────────────────────────
export function useUpdateProfilePassword() {
return useMutation({
mutationFn: (data: { old_password: string; new_password: string }) =>
profileApi.updatePassword(data),
});
}
export function useCreateApiKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => profileApi.createApiKey(name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
});
}
export function useUpdateApiKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ keyId, status }: { keyId: string; status: 'enabled' | 'disabled' | 'revoked' }) =>
profileApi.updateApiKey(keyId, status),
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
});
}
// ─── Token 驾驶舱 ───────────────────────────────────────────────────────────────── // ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
// Landing hero still shows a few headline numbers; reuse the real token overview // Landing hero still shows a few headline numbers; reuse the real token overview
+254 -18
View File
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { import {
User as UserIcon, User as UserIcon,
Crown, Crown,
@@ -15,6 +17,10 @@ import {
Mail, Mail,
Phone, Phone,
CalendarDays, CalendarDays,
Pencil,
Plus,
Trash2,
CheckCircle2,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -24,6 +30,10 @@ import {
useProfileLoginLogs, useProfileLoginLogs,
useProfileApiKeys, useProfileApiKeys,
useProfilePurchasedApps, useProfilePurchasedApps,
useUpdateProfile,
useUpdateProfilePassword,
useCreateApiKey,
useUpdateApiKey,
} from '@/hooks/useApi'; } from '@/hooks/useApi';
function StatCard({ function StatCard({
@@ -76,9 +86,146 @@ function InfoRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string
); );
} }
// 改资料:内联表单,提交后 invalidate profile 刷新只读数据
function EditProfileSection({ profile }: { profile?: { username: string; email: string; phone: string } }) {
const updateProfile = useUpdateProfile();
const [username, setUsername] = useState(profile?.username ?? '');
const [email, setEmail] = useState(profile?.email ?? '');
const [phone, setPhone] = useState(profile?.phone ?? '');
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const reset = () => {
setUsername(profile?.username ?? '');
setEmail(profile?.email ?? '');
setPhone(profile?.phone ?? '');
setError(null);
setOk(false);
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setOk(false);
updateProfile.mutate(
{ username, email, phone },
{
onSuccess: () => {
setOk(true);
setEditing(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '更新失败'),
}
);
};
if (!editing) {
return (
<Button variant="outline" size="sm" className="gap-1" onClick={() => { reset(); setEditing(true); }}>
<Pencil size={13} /> 编辑资料
</Button>
);
}
return (
<form onSubmit={onSubmit} className="space-y-3 pt-2">
<div className="space-y-1.5">
<Label htmlFor="pf-username" className="text-xs">用户名</Label>
<Input id="pf-username" value={username} onChange={(e) => setUsername(e.target.value)} disabled={updateProfile.isPending} />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-email" className="text-xs">邮箱</Label>
<Input id="pf-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={updateProfile.isPending} />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-phone" className="text-xs">手机</Label>
<Input id="pf-phone" value={phone} onChange={(e) => setPhone(e.target.value)} disabled={updateProfile.isPending} />
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{ok && (
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle2 size={13} /> 资料已更新
</p>
)}
<div className="flex items-center gap-2">
<Button type="submit" size="sm" className="gap-1" disabled={updateProfile.isPending}>
{updateProfile.isPending && <Loader2 size={13} className="animate-spin" />} 保存
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setEditing(false); reset(); }} disabled={updateProfile.isPending}>
取消
</Button>
</div>
</form>
);
}
// 改密:独立卡片,含原密码/新密码校验
function ChangePasswordSection() {
const changePwd = useUpdateProfilePassword();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setOk(false);
if (newPassword.length < 6) {
setError('新密码至少 6 位');
return;
}
changePwd.mutate(
{ old_password: oldPassword, new_password: newPassword },
{
onSuccess: () => {
setOk(true);
setOldPassword('');
setNewPassword('');
},
onError: (err) => setError(err instanceof Error ? err.message : '修改失败'),
}
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound size={16} className="text-muted-foreground" /> 修改密码
</CardTitle>
<CardDescription>定期更新密码以保障账户安全</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-3 max-w-sm">
<div className="space-y-1.5">
<Label htmlFor="pf-old" className="text-xs">原密码</Label>
<Input id="pf-old" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} disabled={changePwd.isPending} />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-new" className="text-xs">新密码(至少 6 位)</Label>
<Input id="pf-new" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={changePwd.isPending} />
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{ok && (
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle2 size={13} /> 密码修改成功
</p>
)}
<Button type="submit" size="sm" className="gap-1" disabled={changePwd.isPending || !oldPassword || !newPassword}>
{changePwd.isPending && <Loader2 size={13} className="animate-spin" />} 确认修改
</Button>
</form>
</CardContent>
</Card>
);
}
export function ProfilePage() { export function ProfilePage() {
const [logPage, setLogPage] = useState(1); const [logPage, setLogPage] = useState(1);
const logPageSize = 8; const logPageSize = 8;
const [newKeyName, setNewKeyName] = useState('');
const { data: profile, isLoading: pLoading } = useProfile(); const { data: profile, isLoading: pLoading } = useProfile();
const { data: balance, isLoading: bLoading } = useProfileBalance(); const { data: balance, isLoading: bLoading } = useProfileBalance();
@@ -89,6 +236,10 @@ export function ProfilePage() {
); );
const { data: apiKeys, isLoading: keysLoading } = useProfileApiKeys(); const { data: apiKeys, isLoading: keysLoading } = useProfileApiKeys();
const { data: purchasedApps, isLoading: appsLoading } = useProfilePurchasedApps(); const { data: purchasedApps, isLoading: appsLoading } = useProfilePurchasedApps();
const createApiKey = useCreateApiKey();
const updateApiKey = useUpdateApiKey();
const [keyError, setKeyError] = useState<string | null>(null);
const [createdKey, setCreatedKey] = useState<string | null>(null);
const logTotal = loginLogs?.total ?? 0; const logTotal = loginLogs?.total ?? 0;
const logItems = loginLogs?.items ?? []; const logItems = loginLogs?.items ?? [];
@@ -118,9 +269,12 @@ export function ProfilePage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Profile identity */} {/* Profile identity */}
<Card className="lg:col-span-2"> <Card className="lg:col-span-2">
<CardHeader> <CardHeader className="flex flex-row items-start justify-between space-y-0">
<CardTitle>账户资料</CardTitle> <div>
<CardDescription>当前登录账户的基础信息</CardDescription> <CardTitle>账户资料</CardTitle>
<CardDescription>当前登录账户的基础信息</CardDescription>
</div>
<EditProfileSection profile={profile} />
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -222,6 +376,9 @@ export function ProfilePage() {
/> />
</div> </div>
{/* Change password */}
<ChangePasswordSection />
{/* Login logs */} {/* Login logs */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
@@ -312,6 +469,48 @@ export function ProfilePage() {
<CardDescription>用于第三方集成的访问凭证</CardDescription> <CardDescription>用于第三方集成的访问凭证</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* 新建密钥 */}
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label htmlFor="pf-newkey" className="text-xs">新建密钥名称</Label>
<Input
id="pf-newkey"
placeholder="例如:我的应用"
value={newKeyName}
onChange={(e) => { setNewKeyName(e.target.value); setKeyError(null); }}
disabled={createApiKey.isPending}
/>
</div>
<Button
size="sm"
className="gap-1"
disabled={createApiKey.isPending || !newKeyName.trim()}
onClick={() =>
createApiKey.mutate(newKeyName.trim(), {
onSuccess: (res) => {
setCreatedKey(res.api_key);
setNewKeyName('');
},
onError: (err) => setKeyError(err instanceof Error ? err.message : '创建失败'),
})
}
>
{createApiKey.isPending && <Loader2 size={13} className="animate-spin" />}
<Plus size={13} /> 创建
</Button>
</div>
{keyError && <p className="text-xs text-destructive">{keyError}</p>}
{createdKey && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/30 p-3 space-y-1">
<p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1">
<CheckCircle2 size={13} /> 密钥已创建,请立即复制保存(仅显示一次)
</p>
<p className="text-xs font-mono text-foreground break-all select-all">{createdKey}</p>
</div>
)}
{keysLoading ? ( {keysLoading ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Loader2 size={20} className="animate-spin mx-auto text-primary" /> <Loader2 size={20} className="animate-spin mx-auto text-primary" />
@@ -319,22 +518,59 @@ export function ProfilePage() {
) : !apiKeys || apiKeys.length === 0 ? ( ) : !apiKeys || apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">尚未创建密钥</div> <div className="text-center py-8 text-muted-foreground text-sm">尚未创建密钥</div>
) : ( ) : (
apiKeys.map((k) => ( apiKeys.map((k) => {
<div const active = k.status === 'active';
key={k.id} const revoked = k.status === 'revoked';
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5" return (
> <div
<div className="min-w-0"> key={k.id}
<p className="text-sm text-foreground font-medium truncate">{k.name}</p> className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
<p className="text-xs text-muted-foreground tabular-nums font-mono truncate"> >
{k.key_preview} <div className="min-w-0">
</p> <p className="text-sm text-foreground font-medium truncate">{k.name}</p>
<p className="text-xs text-muted-foreground tabular-nums font-mono truncate">
{k.key_preview}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge variant={active ? 'default' : 'secondary'}>
{active ? '启用' : revoked ? '已撤销' : k.status}
</Badge>
{!revoked && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={active ? '禁用' : '启用'}
disabled={updateApiKey.isPending}
onClick={() =>
updateApiKey.mutate({
keyId: k.id,
status: active ? 'disabled' : 'enabled',
})
}
>
{active ? <Trash2 size={13} /> : <CheckCircle2 size={13} />}
</Button>
)}
{!revoked && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
title="撤销"
disabled={updateApiKey.isPending}
onClick={() =>
updateApiKey.mutate({ keyId: k.id, status: 'revoked' })
}
>
<Trash2 size={13} />
</Button>
)}
</div>
</div> </div>
<Badge variant={k.status === 'active' ? 'default' : 'secondary'}> );
{k.status === 'active' ? '启用' : k.status} })
</Badge>
</div>
))
)} )}
</CardContent> </CardContent>
</Card> </Card>