feat(profile): 补全写操作-改资料/改密/API Key 增删状态切换 (#991)
This commit is contained in:
1 parent
09440c2577
commit
66f696c1e6
3 files changed
+298
-5
No files matched your search
@@ -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 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div>
|
||||||
<CardTitle>账户资料</CardTitle>
|
<CardTitle>账户资料</CardTitle>
|
||||||
<CardDescription>当前登录账户的基础信息</CardDescription>
|
<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,7 +518,10 @@ 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) => {
|
||||||
|
const active = k.status === 'active';
|
||||||
|
const revoked = k.status === 'revoked';
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={k.id}
|
key={k.id}
|
||||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
|
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||||
@@ -330,11 +532,45 @@ export function ProfilePage() {
|
|||||||
{k.key_preview}
|
{k.key_preview}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={k.status === 'active' ? 'default' : 'secondary'}>
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{k.status === 'active' ? '启用' : k.status}
|
<Badge variant={active ? 'default' : 'secondary'}>
|
||||||
|
{active ? '启用' : revoked ? '已撤销' : k.status}
|
||||||
</Badge>
|
</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>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in new issue
Block a user