feat(profile): 补全写操作-改资料/改密/API Key 增删状态切换 (#991)
This commit is contained in:
1 parent
09440c2577
commit
66f696c1e6
3 files changed
+311
-18
No files matched your search
@@ -265,6 +265,17 @@ export interface ApiKeyItem {
|
||||
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 {
|
||||
id: string;
|
||||
app_id: string;
|
||||
@@ -299,6 +310,26 @@ export const profileApi = {
|
||||
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
|
||||
|
||||
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 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 驾驶舱 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Landing hero still shows a few headline numbers; reuse the real token overview
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Crown,
|
||||
@@ -15,6 +17,10 @@ import {
|
||||
Mail,
|
||||
Phone,
|
||||
CalendarDays,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -24,6 +30,10 @@ import {
|
||||
useProfileLoginLogs,
|
||||
useProfileApiKeys,
|
||||
useProfilePurchasedApps,
|
||||
useUpdateProfile,
|
||||
useUpdateProfilePassword,
|
||||
useCreateApiKey,
|
||||
useUpdateApiKey,
|
||||
} from '@/hooks/useApi';
|
||||
|
||||
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() {
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const logPageSize = 8;
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
|
||||
const { data: profile, isLoading: pLoading } = useProfile();
|
||||
const { data: balance, isLoading: bLoading } = useProfileBalance();
|
||||
@@ -89,6 +236,10 @@ export function ProfilePage() {
|
||||
);
|
||||
const { data: apiKeys, isLoading: keysLoading } = useProfileApiKeys();
|
||||
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 logItems = loginLogs?.items ?? [];
|
||||
@@ -118,9 +269,12 @@ export function ProfilePage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Profile identity */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>账户资料</CardTitle>
|
||||
<CardDescription>当前登录账户的基础信息</CardDescription>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>账户资料</CardTitle>
|
||||
<CardDescription>当前登录账户的基础信息</CardDescription>
|
||||
</div>
|
||||
<EditProfileSection profile={profile} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -222,6 +376,9 @@ export function ProfilePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change password */}
|
||||
<ChangePasswordSection />
|
||||
|
||||
{/* Login logs */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
@@ -312,6 +469,48 @@ export function ProfilePage() {
|
||||
<CardDescription>用于第三方集成的访问凭证</CardDescription>
|
||||
</CardHeader>
|
||||
<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 ? (
|
||||
<div className="text-center py-8">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||
@@ -319,22 +518,59 @@ export function ProfilePage() {
|
||||
) : !apiKeys || apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">尚未创建密钥</div>
|
||||
) : (
|
||||
apiKeys.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
apiKeys.map((k) => {
|
||||
const active = k.status === 'active';
|
||||
const revoked = k.status === 'revoked';
|
||||
return (
|
||||
<div
|
||||
key={k.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<Badge variant={k.status === 'active' ? 'default' : 'secondary'}>
|
||||
{k.status === 'active' ? '启用' : k.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user