diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 03a3b35..fede85a 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -71,6 +71,7 @@ class AdminAuditLog(models.Model): ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), + ('user_password_reset', '重置用户密码'), ] operator = models.ForeignKey( diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index ecb2080..1de2e2a 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -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'), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 0f63510..bae344a 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -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': '密码修改成功'}) diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 050a4dd..dc80f00 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -28,6 +28,7 @@ urlpatterns = [ path('admin/users/', views.admin_user_detail_view, name='admin_user_detail'), path('admin/users//quota', views.admin_user_quota_view, name='admin_user_quota'), path('admin/users//status', views.admin_user_status_view, name='admin_user_status'), + path('admin/users//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'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 15ab86f..03d871d 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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): diff --git a/backend/utils/airdrama_client.py b/backend/utils/airdrama_client.py index 241cd7d..9f58b32 100644 --- a/backend/utils/airdrama_client.py +++ b/backend/utils/airdrama_client.py @@ -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': '生成超时,请重试', } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d13f76c..f6f825e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -72,6 +72,9 @@ export const authApi = { getMe: () => api.get('/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; diff --git a/web/src/pages/AuditLogsPage.tsx b/web/src/pages/AuditLogsPage.tsx index 13f2c6e..c71c1e6 100644 --- a/web/src/pages/AuditLogsPage.tsx +++ b/web/src/pages/AuditLogsPage.tsx @@ -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 = { diff --git a/web/src/pages/ProfilePage.module.css b/web/src/pages/ProfilePage.module.css index b8474c8..2c30a56 100644 --- a/web/src/pages/ProfilePage.module.css +++ b/web/src/pages/ProfilePage.module.css @@ -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; diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx index a8e11e0..2c8ea69 100644 --- a/web/src/pages/ProfilePage.tsx +++ b/web/src/pages/ProfilePage.tsx @@ -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 (
@@ -117,6 +144,7 @@ export function ProfilePage() {

个人中心

{user?.username} +
@@ -211,6 +239,37 @@ export function ProfilePage() { )}
+ + {pwModalOpen && ( +
setPwModalOpen(false)}> +
e.stopPropagation()}> +

修改密码

+
+ + setOldPw(e.target.value)} placeholder="请输入当前密码" /> +
+
+ + setNewPw(e.target.value)} placeholder="至少8位" /> +
+
+ + setConfirmPw(e.target.value)} placeholder="再次输入新密码" + onKeyDown={(e) => e.key === 'Enter' && handleChangePassword()} /> +
+ {pwError &&
{pwError}
} +
+ + +
+
+
+ )} ); } diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index e97810d..7d47bb2 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -29,6 +29,11 @@ export function UsersPage() { // Confirm toggle const [confirmUser, setConfirmUser] = useState(null); + // Reset password modal + const [resetPwUser, setResetPwUser] = useState(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() {
+
)} + {/* Reset Password Modal */} + {resetPwUser && ( +
{ if (e.target === e.currentTarget) setResetPwUser(null); }}> +
+

重置密码 — {resetPwUser.username}

+
+ + setResetPwValue(e.target.value)} + placeholder="至少8位" onKeyDown={(e) => e.key === 'Enter' && handleResetPassword()} /> +
+ {resetPwError &&
{resetPwError}
} +
+ + +
+
+
+ )} + {/* User Detail Drawer */} {drawerOpen && detailUser && (
setDrawerOpen(false)}> diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index 16f6997..77db9ad 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -363,7 +363,8 @@ export const useGenerationStore = create((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((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) => ({