diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index 2bf91678..dba2a220 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -241,6 +241,10 @@ export async function createSystemConfig(key: string, value: string, description return api.post('/admin/system-configs', { key, value, description }); } +export async function resetActivityBanner(): Promise<{ site_banner_version: number }> { + return api.post('/admin/system-configs/banner/reset'); +} + export async function getGlobalResourceCapacity(): Promise { return api.get('/admin/resource-capacity/global'); } @@ -447,6 +451,25 @@ export async function getApiKeyUsage(id: string, days?: number, page?: number, p return api.get(`/admin/api-keys/${id}/usage${qs}`); } +export async function adjustApiKeyQuota( + id: string, + data: { + action: 'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle'; + quotaLimitDelta?: number; + quotaLimit?: number | null; + quotaCycle?: string | null; + reason?: string | null; + }, +): Promise { + return api.post(`/admin/api-keys/${id}/quota-adjust`, { + action: data.action, + quota_limit_delta: data.quotaLimitDelta, + quota_limit: data.quotaLimit, + quota_cycle: data.quotaCycle, + reason: data.reason, + }); +} + export async function getApiKeyUpscaleConfig(id: string): Promise { return api.get(`/admin/api-keys/${id}/upscale`); } @@ -622,8 +645,16 @@ export async function deleteRechargePackage(id: string): Promise { // ── Operation Logs ────────────────────────────────────── -export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> { - const q = page ? `?page=${page}` : ''; +export async function getOperationLogs(params?: { + page?: number; + pageSize?: number; + action?: string; +}): Promise<{ total: number; items: any[] }> { + const sp = new URLSearchParams(); + if (params?.page) sp.set('page', String(params.page)); + if (params?.pageSize) sp.set('page_size', String(params.pageSize)); + if (params?.action) sp.set('action', params.action); + const q = sp.toString() ? `?${sp.toString()}` : ''; return api.get(`/admin/operation-logs${q}`); } diff --git a/video-gen-admin/src/components/QuotaAdjustModal.tsx b/video-gen-admin/src/components/QuotaAdjustModal.tsx new file mode 100644 index 00000000..aefa0da8 --- /dev/null +++ b/video-gen-admin/src/components/QuotaAdjustModal.tsx @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import { + Modal, Radio, InputNumber, Input, Select, Space, Typography, Tag, Divider, message, +} from 'antd'; +import { adjustApiKeyQuota } from '../api'; + +interface QuotaAdjustModalProps { + open: boolean; + keyId: string; + companyName: string; + quotaLimit: number | null; + quotaUsed: number; + quotaCycle: string | null; + onCancel: () => void; + onSuccess: () => void; +} + +const QuotaAdjustModal: React.FC = ({ + open, keyId, companyName, quotaLimit, quotaUsed, quotaCycle, onCancel, onSuccess, +}) => { + const [action, setAction] = useState<'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle'>('adjust'); + const [delta, setDelta] = useState(0); + const [newLimit, setNewLimit] = useState(quotaLimit); + const [newCycle, setNewCycle] = useState(quotaCycle); + const [reason, setReason] = useState(''); + const [loading, setLoading] = useState(false); + + const cycleLabel = (cycle: string | null) => { + const map: Record = { daily: '每日', monthly: '每月', one_time: '一次性' }; + return cycle ? map[cycle] || cycle : '无限'; + }; + + const handleOk = async () => { + setLoading(true); + try { + const payload: any = { action, reason: reason || undefined }; + if (action === 'adjust') payload.quotaLimitDelta = delta; + if (action === 'set_limit') payload.quotaLimit = newLimit; + if (action === 'change_cycle') payload.quotaCycle = newCycle; + + await adjustApiKeyQuota(keyId, payload); + message.success('配额调整成功'); + onSuccess(); + } catch (e: any) { + message.error(e?.response?.data?.detail || '调整失败'); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + setAction('adjust'); + setDelta(0); + setNewLimit(quotaLimit); + setNewCycle(quotaCycle); + setReason(''); + onCancel(); + }; + + // 预览计算 + const previewLimit = action === 'adjust' + ? round((quotaLimit || 0) + delta) + : action === 'set_limit' + ? newLimit + : quotaLimit; + + function round(n: number) { + return Math.round(n * 100) / 100; + } + + return ( + + +
+ 公司: + {companyName} +
+
+ 当前: + 已用 {quotaUsed.toFixed(2)} 元 + 限额 {quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'} + {cycleLabel(quotaCycle)} +
+ + + + setAction(e.target.value)} style={{ width: '100%' }}> + + + + 增加总额 + {action === 'adjust' && ( + setDelta(v || 0)} + addonAfter="元" + style={{ width: 160 }} + /> + )} + + + + + + 重置已用 + {action === 'reset_usage' && ( + + ({quotaUsed.toFixed(2)} → 0.00 元) + + )} + + + + + + 设置限额 + {action === 'set_limit' && ( + <> + + + (当前:{quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'}) + + + )} + + + + + + 修改周期 + {action === 'change_cycle' && ( + patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" /> + patchText({ color: hex || '#ffffff' })} + showText + presets={[{ + label: '推荐', + colors: ['#ffffff', '#000000', '#ff4d4f', '#1677ff', '#52c41a', '#faad14', '#722ed1', '#eb2f96'], + }]} + /> diff --git a/video-gen-admin/tsconfig.tsbuildinfo b/video-gen-admin/tsconfig.tsbuildinfo index 10bbfeab..c47de026 100644 --- a/video-gen-admin/tsconfig.tsbuildinfo +++ b/video-gen-admin/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"} \ No newline at end of file diff --git a/video-gen-api/app/admin_api/api_keys/routes.py b/video-gen-api/app/admin_api/api_keys/routes.py index 894ad260..105092c5 100644 --- a/video-gen-api/app/admin_api/api_keys/routes.py +++ b/video-gen-api/app/admin_api/api_keys/routes.py @@ -17,6 +17,7 @@ from app.schemas.admin_api.api_key import ( ApiKeyCreateResponse, ApiKeyListItem, ApiKeyListOut, + ApiKeyQuotaAdjustRequest, ApiKeyRevealResponse, ApiKeyResponse, ApiKeyUpdateRequest, @@ -388,3 +389,47 @@ async def list_all_usage( "total": total, "items": items, } + + +@router.post("/{key_id}/quota-adjust", response_model=ApiKeyListItem, summary="调整 API Key 配额") +async def quota_adjust( + req: ApiKeyQuotaAdjustRequest, + key_id: str = Path(..., description="API Key ID"), + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +) -> ApiKeyListItem: + """调整 API Key 配额(增加总额/重置已用/设置限额/修改周期)。""" + key = await key_service.get_api_key(db, key_id) + if not key: + raise HTTPException(status_code=404, detail="API Key 不存在") + + key, changes = await key_service.adjust_quota( + db, + key, + action=req.action, + quota_limit_delta=req.quota_limit_delta, + quota_limit=req.quota_limit, + quota_cycle=req.quota_cycle, + ) + + # 审计日志 + try: + from app.services.operation_log import log_operation + await log_operation( + db=db, + user_id=str(admin.id), + username=str(admin.username), + action=f"quota_adjust:{req.action}", + method="POST", + path=f"/admin/api-keys/{key_id}/quota-adjust", + detail=json.dumps( + {**changes, "reason": req.reason}, + ensure_ascii=False, + default=str, + ), + ) + except Exception as log_exc: + logger.warning("配额调整审计日志记录失败: %s", log_exc) + + await db.commit() + return _key_to_list_item(key) diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 8b089f01..8c37a1b9 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -1696,17 +1696,62 @@ async def update_system_config( return config +@router.post("/system-configs/banner/reset", summary="重置活动横幅展示") +async def reset_banner( + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """递增 site_banner_version,使所有用户再次看到横幅。""" + from app.utils.id_gen import generate_id + result = await db.execute(select(SystemConfig).where(SystemConfig.key == "site_banner_version").limit(1)) + config = result.scalar_one_or_none() + new_version = 1 + if config: + try: + new_version = int(config.value or 0) + 1 + except ValueError: + new_version = 1 + config.value = str(new_version) + else: + config = SystemConfig( + id=generate_id(), + key="site_banner_version", + value=str(new_version), + description="活动横幅版本号,递增后所有用户重新看到横幅", + ) + db.add(config) + await db.flush() + await log_operation( + db, + admin.id, + admin.username, + f"重置活动横幅 (版本 → {new_version})", + "POST", + "/admin/system-configs/banner/reset", + detail=json.dumps({"new_version": new_version}), + ) + await db.commit() + await invalidate_system_config_cache(["site_banner_version"]) + return {"site_banner_version": new_version} + + # ── Operation Logs ────────────────────────────────────── @router.get("/operation-logs") async def list_operation_logs( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=500), + action: str | None = Query(None, description="按 action 过滤(前缀匹配)"), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): query = select(OperationLog).order_by(OperationLog.created_at.desc()) count_query = select(func.count(OperationLog.id)) + + if action: + query = query.where(OperationLog.action.like(f"{action}%")) + count_query = count_query.where(OperationLog.action.like(f"{action}%")) + total = (await db.execute(count_query)).scalar() or 0 result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) items = result.scalars().all() diff --git a/video-gen-api/app/api/v1/auth.py b/video-gen-api/app/api/v1/auth.py index b68a7ea3..c3052fa5 100644 --- a/video-gen-api/app/api/v1/auth.py +++ b/video-gen-api/app/api/v1/auth.py @@ -342,7 +342,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)): """Public endpoint returning site name, logo, agreement and copyright info.""" result = await db.execute( select(SystemConfig).where(SystemConfig.key.in_([ - "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits" + "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits", "site_banner", "site_banner_version" ])) ) configs = result.scalars().all() @@ -367,6 +367,8 @@ async def get_site_info(db: AsyncSession = Depends(get_db)): "operation_manual": info.get("operation_manual", ""), "login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "", "optimize_hold_credits": int(info.get("optimize_hold_credits") or 5), + "site_banner": info.get("site_banner", ""), + "site_banner_version": int(info.get("site_banner_version") or 0), } diff --git a/video-gen-api/app/schemas/admin_api/api_key.py b/video-gen-api/app/schemas/admin_api/api_key.py index c90b941a..caf33836 100644 --- a/video-gen-api/app/schemas/admin_api/api_key.py +++ b/video-gen-api/app/schemas/admin_api/api_key.py @@ -133,6 +133,22 @@ class ApiKeyListItem(BaseModel): model_config = ConfigDict(from_attributes=True) +class ApiKeyQuotaAdjustRequest(BaseModel): + """配额调整请求。支持 camelCase 和 snake_case 两种字段名。""" + + model_config = ConfigDict(populate_by_name=True) + + action: str = Field( + ..., + pattern=r"^(adjust|reset_usage|set_limit|change_cycle)$", + description="adjust=增加总额 | reset_usage=重置已用 | set_limit=设置限额 | change_cycle=修改周期", + ) + quota_limit_delta: float | None = Field(None, ge=0, description="增加总额时的增量", alias="quotaLimitDelta") + quota_limit: float | None = Field(None, description="设置新限额时的值(NULL=无限)", alias="quotaLimit") + quota_cycle: str | None = Field(None, description="修改周期时的值", alias="quotaCycle") + reason: str | None = Field(None, max_length=500, description="调整原因/备注") + + class ApiKeyListOut(BaseModel): """API Key 列表响应。""" diff --git a/video-gen-api/app/services/api_v3/key_service.py b/video-gen-api/app/services/api_v3/key_service.py index d17c03b1..7a30c694 100644 --- a/video-gen-api/app/services/api_v3/key_service.py +++ b/video-gen-api/app/services/api_v3/key_service.py @@ -114,6 +114,50 @@ async def update_api_key(db: AsyncSession, key: ApiKey, **kwargs) -> ApiKey: return key +async def adjust_quota( + db: AsyncSession, + key: ApiKey, + action: str, + quota_limit_delta: float | None = None, + quota_limit: float | None = None, + quota_cycle: str | None = None, +) -> tuple[ApiKey, dict]: + """调整 API Key 配额。 + + 返回 (更新后的 key, 变更详情 dict)。 + + action: + - adjust: 增加总额,quota_limit_delta 累加到当前 quota_limit + - reset_usage: 重置 quota_used 为 0 + - set_limit: 直接设置 quota_limit + - change_cycle: 修改 quota_cycle + """ + old_limit = key.quota_limit + old_used = key.quota_used + old_cycle = key.quota_cycle + + if action == "adjust": + delta = quota_limit_delta or 0 + key.quota_limit = round((key.quota_limit or 0) + delta, 2) + elif action == "reset_usage": + key.quota_used = 0.0 + elif action == "set_limit": + key.quota_limit = quota_limit # 允许设为 None(无限) + elif action == "change_cycle": + key.quota_cycle = quota_cycle # 允许设为 None(无限) + else: + raise ValueError(f"未知的调整操作: {action}") + + await db.flush() + + changes = { + "old_limit": old_limit, "new_limit": key.quota_limit, + "old_used": old_used, "new_used": key.quota_used, + "old_cycle": old_cycle, "new_cycle": key.quota_cycle, + } + return key, changes + + async def delete_api_key(db: AsyncSession, key: ApiKey) -> None: """软删除 API Key。""" key.deleted_at = datetime.now(timezone.utc) diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 4f7d81fb..0c0df5ad 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -301,7 +301,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise { +export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string; optimizeHoldCredits?: number; siteBanner?: string; siteBannerVersion?: number }> { if (USE_MOCK) return { siteName: '智创', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2026 智创 版权所有', operationManual: '', loginBgVideo: '' }; return api.get('/auth/site-info', false); } @@ -351,10 +351,42 @@ export async function getAdminStats(): Promise { if (USE_MOCK) return mock.mockGetAdminStats(); return api.get('/admin/stats'); } -export async function getAdminUsers(search?: string): Promise { - if (USE_MOCK) return mock.mockGetAdminUsers(search); - const q = search ? `?search=${encodeURIComponent(search)}` : ''; - return api.get(`/admin/users${q}`); +export async function getAdminUsers(page = 1, pageSize = 1000, search?: string): Promise<{ items: AdminUser[]; total: number }> { + if (USE_MOCK) { + const items = await mock.mockGetAdminUsers(search); + return { items, total: items.length }; + } + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('page_size', String(pageSize)); + if (search) params.set('search', search); + return api.get(`/admin/users?${params.toString()}`); +} + +export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ items: any[]; total: number }> { + if (USE_MOCK) { + const items = await mock.mockGetAdminNotifications(); + return { items, total: items.length }; + } + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('page_size', String(pageSize)); + return api.get(`/admin/notifications?${params.toString()}`); +} + +export async function createAdminNotification(params: { title: string; content: string; type?: string; target_user_id?: string }): Promise { + if (USE_MOCK) return; + await api.post('/admin/notifications', params); +} + +export async function deleteAdminNotification(id: string): Promise { + if (USE_MOCK) return; + await api.delete(`/admin/notifications/${id}`); +} + +export async function getNotificationReadUsers(notificationId: string): Promise<{ items: any[] }> { + if (USE_MOCK) return { items: [] }; + return api.get(`/admin/notifications/${notificationId}/read-users`); } export async function adjustCredits(userId: string, amount: number, description: string): Promise { if (USE_MOCK) return mock.mockAdjustCredits(userId, amount, description); diff --git a/video-gen-app/src/components/Layout/ActivityBanner.tsx b/video-gen-app/src/components/Layout/ActivityBanner.tsx new file mode 100644 index 00000000..d3dc2bed --- /dev/null +++ b/video-gen-app/src/components/Layout/ActivityBanner.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { CloseOutlined, NotificationOutlined } from '@ant-design/icons'; +import { getSiteInfo } from '../../api'; + +const STORAGE_KEY = 'dismissed_activity_banner_version'; + +interface ActivityBannerProps { + onVisibilityChange?: (visible: boolean) => void; +} + +const ActivityBanner: React.FC = ({ onVisibilityChange }) => { + const [bannerContent, setBannerContent] = useState(''); + const [bannerVersion, setBannerVersion] = useState(0); + const [dismissed, setDismissed] = useState(true); + + useEffect(() => { + getSiteInfo().then(info => { + const content = info.siteBanner || ''; + const version = info.siteBannerVersion || 0; + setBannerVersion(version); + if (content) { + // 检查用户关闭的版本号是否与当前一致 + const dismissedVersion = Number(localStorage.getItem(STORAGE_KEY) || 0); + const shouldShow = dismissedVersion < version; + setBannerContent(content); + setDismissed(!shouldShow); + onVisibilityChange?.(shouldShow); + } else { + setDismissed(true); + onVisibilityChange?.(false); + } + }).catch(() => { + setDismissed(true); + onVisibilityChange?.(false); + }); + }, []); + + const handleClose = () => { + localStorage.setItem(STORAGE_KEY, String(bannerVersion)); + setDismissed(true); + onVisibilityChange?.(false); + }; + + if (dismissed || !bannerContent) return null; + + return ( +
+
+
+
+ +
+
+
+ + +
+
+ ); +}; + +export default ActivityBanner; \ No newline at end of file diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index 814a442b..cd02f237 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -67,6 +67,7 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../../store/useAuthStore'; import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api'; import NotificationPopup from '../NotificationPopup'; +import ActivityBanner from './ActivityBanner'; import './AppLayout.css'; import bg1 from '../../assets/bg1.png'; @@ -437,6 +438,7 @@ const AppLayout: React.FC = () => { const countdownTimerRef = useRef | null>(null); const currentOrderNoRef = useRef(null); const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false }); + const [bannerVisible, setBannerVisible] = useState(false); // 资源存储容量(从 getUser().resource_capacity 获取) const [resourceCapacity, setResourceCapacity] = useState<{ @@ -913,15 +915,15 @@ const AppLayout: React.FC = () => { return ( + setBannerVisible(v)} />
@@ -1137,7 +1139,7 @@ const AppLayout: React.FC = () => {