feat: 密码管理 + 错误提示体系统一 (v0.9.2 & v0.9.3)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
密码管理:用户自助修改密码(个人中心弹窗)、管理员重置用户密码(审计日志记录) 错误提示:补全火山 ARK 错误码映射(+7 个)、修复创建失败时前端不显示真实错误、 轮询失败走 ERROR_MESSAGES 映射、前端 catch 统一取后端 message Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
10e5bd57df
commit
b520b429c5
@ -71,6 +71,7 @@ class AdminAuditLog(models.Model):
|
||||
('member_create', '创建团队成员'),
|
||||
('member_quota_update', '更新成员额度'),
|
||||
('member_status_toggle', '切换成员状态'),
|
||||
('user_password_reset', '重置用户密码'),
|
||||
]
|
||||
|
||||
operator = models.ForeignKey(
|
||||
|
||||
@ -8,4 +8,5 @@ urlpatterns = [
|
||||
path('login', views.login_view, name='login'),
|
||||
path('token/refresh', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('me', views.me_view, name='me'),
|
||||
path('change-password', views.change_password_view, name='change_password'),
|
||||
]
|
||||
|
||||
@ -9,6 +9,7 @@ from django.utils import timezone
|
||||
from django.db.models import Sum
|
||||
|
||||
from .serializers import UserSerializer
|
||||
from django.contrib.auth.hashers import check_password
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -112,3 +113,33 @@ def me_view(request):
|
||||
data['team_disabled'] = False
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def change_password_view(request):
|
||||
"""POST /api/v1/auth/change-password — user changes own password."""
|
||||
old_password = request.data.get('old_password', '')
|
||||
new_password = request.data.get('new_password', '')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return Response(
|
||||
{'error': 'missing_fields', 'message': '请填写旧密码和新密码'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if len(new_password) < 8:
|
||||
return Response(
|
||||
{'error': 'password_too_short', 'message': '新密码至少8位'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not check_password(old_password, request.user.password):
|
||||
return Response(
|
||||
{'error': 'wrong_password', 'message': '旧密码错误'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
request.user.set_password(new_password)
|
||||
request.user.save()
|
||||
return Response({'message': '密码修改成功'})
|
||||
|
||||
@ -28,6 +28,7 @@ urlpatterns = [
|
||||
path('admin/users/<int:user_id>', views.admin_user_detail_view, name='admin_user_detail'),
|
||||
path('admin/users/<int:user_id>/quota', views.admin_user_quota_view, name='admin_user_quota'),
|
||||
path('admin/users/<int:user_id>/status', views.admin_user_status_view, name='admin_user_status'),
|
||||
path('admin/users/<int:user_id>/reset-password', views.admin_reset_password_view, name='admin_reset_password'),
|
||||
|
||||
# ── Super Admin: Records, Settings & Audit Logs ──
|
||||
path('admin/records', views.admin_records_view, name='admin_records'),
|
||||
|
||||
@ -291,6 +291,7 @@ def video_generate_view(request):
|
||||
'status': record.status,
|
||||
'estimated_time': 120,
|
||||
'seconds_consumed': duration,
|
||||
'error_message': getattr(record, 'error_message', '') or '',
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
@ -377,9 +378,10 @@ def video_task_detail_view(request, task_id):
|
||||
record.result_url = video_url
|
||||
elif new_status == 'failed':
|
||||
error = ark_resp.get('error', {})
|
||||
record.error_message = (
|
||||
error.get('message', '') if isinstance(error, dict) else str(error)
|
||||
)
|
||||
code = error.get('code', '') if isinstance(error, dict) else ''
|
||||
raw_msg = error.get('message', '') if isinstance(error, dict) else str(error)
|
||||
from utils.airdrama_client import ERROR_MESSAGES
|
||||
record.error_message = ERROR_MESSAGES.get(code, raw_msg)
|
||||
# Phase 5: Refund if Seedance didn't charge
|
||||
usage = ark_resp.get('usage', {})
|
||||
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
|
||||
@ -948,6 +950,26 @@ def admin_user_status_view(request, user_id):
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
def admin_reset_password_view(request, user_id):
|
||||
"""POST /api/v1/admin/users/:id/reset-password"""
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
new_password = request.data.get('new_password', '')
|
||||
if len(new_password) < 8:
|
||||
return Response({'error': '密码至少8位'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
log_admin_action(request, 'user_password_reset', 'user', target_id=user.id, target_name=user.username)
|
||||
|
||||
return Response({'message': f'已重置 {user.username} 的密码'})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
def admin_create_user_view(request):
|
||||
|
||||
@ -6,12 +6,24 @@ from django.conf import settings
|
||||
|
||||
# API error code → user-friendly Chinese message
|
||||
ERROR_MESSAGES = {
|
||||
# Input content moderation
|
||||
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,系统不允许处理包含真人面部的图片',
|
||||
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
|
||||
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
|
||||
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
|
||||
# Output content moderation
|
||||
'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截',
|
||||
'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截',
|
||||
# Parameter & rate limit errors
|
||||
'InvalidParameter': '请求参数无效,请检查输入',
|
||||
'RateLimitExceeded': 'API 调用频率超限,请稍后重试',
|
||||
'ConcurrencyLimitExceeded': '并发数超限,请稍后重试',
|
||||
# Account & billing
|
||||
'InsufficientBalance': '账户余额不足,请联系管理员充值',
|
||||
# Server errors
|
||||
'ServerOverloaded': '服务器繁忙,请稍后重试',
|
||||
'InternalError': '服务内部错误,请稍后重试',
|
||||
'Timeout': '生成超时,请重试',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -72,6 +72,9 @@ export const authApi = {
|
||||
|
||||
getMe: () =>
|
||||
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
|
||||
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
api.post('/auth/change-password', { old_password: oldPassword, new_password: newPassword }),
|
||||
};
|
||||
|
||||
// Media upload API
|
||||
@ -106,6 +109,7 @@ export const videoApi = {
|
||||
status: string;
|
||||
estimated_time: number;
|
||||
seconds_consumed: number;
|
||||
error_message: string;
|
||||
}>('/video/generate', data),
|
||||
|
||||
getTasks: (params?: { page_size?: number; offset?: number }) =>
|
||||
@ -180,6 +184,9 @@ export const adminApi = {
|
||||
updateUserStatus: (userId: number, isActive: boolean) =>
|
||||
api.patch(`/admin/users/${userId}/status`, { is_active: isActive }),
|
||||
|
||||
resetUserPassword: (userId: number, newPassword: string) =>
|
||||
api.post(`/admin/users/${userId}/reset-password`, { new_password: newPassword }),
|
||||
|
||||
getRecords: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
|
||||
@ -20,6 +20,7 @@ const ACTION_OPTIONS = [
|
||||
{ label: '创建团队成员', value: 'member_create' },
|
||||
{ label: '更新成员额度', value: 'member_quota_update' },
|
||||
{ label: '切换成员状态', value: 'member_status_toggle' },
|
||||
{ label: '重置用户密码', value: 'user_password_reset' },
|
||||
];
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
|
||||
@ -49,6 +49,22 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.changePwBtn {
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.changePwBtn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
@ -375,6 +391,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Password Modal */
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.formInput:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.formError {
|
||||
color: var(--color-danger);
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
padding: 6px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-card);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
padding: 6px 16px;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.overviewGrid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@ -6,10 +6,11 @@ import { LineChart } from 'echarts/charts';
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { profileApi } from '../lib/api';
|
||||
import { profileApi, authApi } from '../lib/api';
|
||||
import type { ProfileOverview, AdminRecord } from '../types';
|
||||
import { showToast } from '../components/Toast';
|
||||
import styles from './ProfilePage.module.css';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
@ -24,6 +25,12 @@ export function ProfilePage() {
|
||||
const [recordsPage, setRecordsPage] = useState(1);
|
||||
const [trendPeriod, setTrendPeriod] = useState<'7d' | '30d'>('7d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pwModalOpen, setPwModalOpen] = useState(false);
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwError, setPwError] = useState('');
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
try {
|
||||
@ -60,6 +67,26 @@ export function ProfilePage() {
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
setPwError('');
|
||||
if (!oldPw) { setPwError('请输入旧密码'); return; }
|
||||
if (newPw.length < 8) { setPwError('新密码至少8位'); return; }
|
||||
if (newPw !== confirmPw) { setPwError('两次输入的新密码不一致'); return; }
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await authApi.changePassword(oldPw, newPw);
|
||||
showToast('密码修改成功,请重新登录');
|
||||
setPwModalOpen(false);
|
||||
setOldPw(''); setNewPw(''); setConfirmPw('');
|
||||
setTimeout(() => { logout(); navigate('/login', { replace: true }); }, 1500);
|
||||
} catch (err) {
|
||||
const msg = (err as AxiosError<{ message?: string }>)?.response?.data?.message || '修改失败';
|
||||
setPwError(msg);
|
||||
} finally {
|
||||
setPwSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !overview) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@ -117,6 +144,7 @@ export function ProfilePage() {
|
||||
<h1 className={styles.pageTitle}>个人中心</h1>
|
||||
<div className={styles.headerRight}>
|
||||
<span className={styles.username}>{user?.username}</span>
|
||||
<button className={styles.changePwBtn} onClick={() => setPwModalOpen(true)}>修改密码</button>
|
||||
<button className={styles.logoutBtn} onClick={handleLogout}>退出</button>
|
||||
</div>
|
||||
</header>
|
||||
@ -211,6 +239,37 @@ export function ProfilePage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pwModalOpen && (
|
||||
<div className={styles.modalOverlay} onClick={() => setPwModalOpen(false)}>
|
||||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className={styles.modalTitle}>修改密码</h3>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>旧密码</label>
|
||||
<input type="password" className={styles.formInput} value={oldPw}
|
||||
onChange={(e) => setOldPw(e.target.value)} placeholder="请输入当前密码" />
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>新密码</label>
|
||||
<input type="password" className={styles.formInput} value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)} placeholder="至少8位" />
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>确认新密码</label>
|
||||
<input type="password" className={styles.formInput} value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)} placeholder="再次输入新密码"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleChangePassword()} />
|
||||
</div>
|
||||
{pwError && <div className={styles.formError}>{pwError}</div>}
|
||||
<div className={styles.modalActions}>
|
||||
<button className={styles.cancelBtn} onClick={() => setPwModalOpen(false)}>取消</button>
|
||||
<button className={styles.saveBtn} onClick={handleChangePassword} disabled={pwSaving}>
|
||||
{pwSaving ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -29,6 +29,11 @@ export function UsersPage() {
|
||||
// Confirm toggle
|
||||
const [confirmUser, setConfirmUser] = useState<AdminUser | null>(null);
|
||||
|
||||
// Reset password modal
|
||||
const [resetPwUser, setResetPwUser] = useState<AdminUser | null>(null);
|
||||
const [resetPwValue, setResetPwValue] = useState('');
|
||||
const [resetPwError, setResetPwError] = useState('');
|
||||
|
||||
// Create user modal
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
@ -135,6 +140,20 @@ export function UsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!resetPwUser) return;
|
||||
setResetPwError('');
|
||||
if (resetPwValue.length < 8) { setResetPwError('密码至少8位'); return; }
|
||||
try {
|
||||
await adminApi.resetUserPassword(resetPwUser.id, resetPwValue);
|
||||
showToast(`已重置 ${resetPwUser.username} 的密码`);
|
||||
setResetPwUser(null);
|
||||
setResetPwValue('');
|
||||
} catch {
|
||||
setResetPwError('重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
@ -225,6 +244,7 @@ export function UsersPage() {
|
||||
<td>
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.editBtn} onClick={() => openEditModal(u)}>编辑</button>
|
||||
<button className={styles.editBtn} onClick={() => { setResetPwUser(u); setResetPwValue(''); setResetPwError(''); }}>重置密码</button>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${u.is_active ? styles.disableBtn : styles.enableBtn}`}
|
||||
onClick={() => setConfirmUser(u)}
|
||||
@ -337,6 +357,25 @@ export function UsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{resetPwUser && (
|
||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setResetPwUser(null); }}>
|
||||
<div className={styles.modal}>
|
||||
<h3 className={styles.modalTitle}>重置密码 — {resetPwUser.username}</h3>
|
||||
<div className={styles.formGroup}>
|
||||
<label>新密码</label>
|
||||
<input type="password" value={resetPwValue} onChange={(e) => setResetPwValue(e.target.value)}
|
||||
placeholder="至少8位" onKeyDown={(e) => e.key === 'Enter' && handleResetPassword()} />
|
||||
</div>
|
||||
{resetPwError && <div className={styles.formError}>{resetPwError}</div>}
|
||||
<div className={styles.modalActions}>
|
||||
<button className={styles.cancelBtn} onClick={() => setResetPwUser(null)}>取消</button>
|
||||
<button className={styles.saveBtn} onClick={handleResetPassword}>确认重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Detail Drawer */}
|
||||
{drawerOpen && detailUser && (
|
||||
<div className={styles.drawerOverlay} onClick={() => setDrawerOpen(false)}>
|
||||
|
||||
@ -363,7 +363,8 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
id: frontendId,
|
||||
taskId: genResult.task_id,
|
||||
status: taskStatus as GenerationTask['status'],
|
||||
progress: taskStatus === 'completed' ? 100 : t.progress,
|
||||
progress: taskStatus === 'completed' ? 100 : taskStatus === 'failed' ? 0 : t.progress,
|
||||
errorMessage: mapErrorMessage(genResult.error_message) || t.errorMessage,
|
||||
}
|
||||
: t
|
||||
),
|
||||
@ -391,11 +392,8 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
return frontendId;
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
if (error.response?.status === 429) {
|
||||
showToast(error.response.data?.message || '今日额度已用完');
|
||||
} else {
|
||||
showToast('生成失败,请重试');
|
||||
}
|
||||
const msg = error.response?.data?.message;
|
||||
showToast(msg || '生成失败,请重试');
|
||||
|
||||
// Mark task as failed
|
||||
set((s) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user