feat: 密码管理 + 错误提示体系统一 (v0.9.2 & v0.9.3)
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:
seaislee1209 2026-03-16 17:12:40 +08:00
parent 10e5bd57df
commit b520b429c5
12 changed files with 292 additions and 10 deletions

View File

@ -71,6 +71,7 @@ class AdminAuditLog(models.Model):
('member_create', '创建团队成员'),
('member_quota_update', '更新成员额度'),
('member_status_toggle', '切换成员状态'),
('user_password_reset', '重置用户密码'),
]
operator = models.ForeignKey(

View File

@ -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'),
]

View File

@ -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': '密码修改成功'})

View File

@ -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'),

View File

@ -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):

View File

@ -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': '生成超时,请重试',
}

View File

@ -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;

View File

@ -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> = {

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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)}>

View File

@ -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) => ({