diff --git a/core/backend/apps/accounts/serializers.py b/core/backend/apps/accounts/serializers.py index 14ec43a..753b473 100644 --- a/core/backend/apps/accounts/serializers.py +++ b/core/backend/apps/accounts/serializers.py @@ -21,6 +21,15 @@ class UserPreferenceSerializer(serializers.ModelSerializer): fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"] read_only_fields = ["updated_at"] + def update(self, instance, validated_data): + # JSON 字段按 key 合并,避免消息中心写入的 mute-* 被设置页整包覆盖丢掉 + for field in ("notify", "creation_defaults", "display"): + incoming = validated_data.get(field) + current = getattr(instance, field) + if isinstance(incoming, dict) and isinstance(current, dict): + validated_data[field] = {**current, **incoming} + return super().update(instance, validated_data) + class LoginSessionSerializer(serializers.ModelSerializer): is_current = serializers.SerializerMethodField() diff --git a/core/backend/apps/ops/tests.py b/core/backend/apps/ops/tests.py index 92bf300..356ac6a 100644 --- a/core/backend/apps/ops/tests.py +++ b/core/backend/apps/ops/tests.py @@ -1,13 +1,15 @@ from decimal import Decimal +from unittest.mock import patch from django.test import TestCase +from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User from apps.ai.models import AITask, ModelConfig, ModelProvider from apps.assets.models import Asset from apps.billing.models import CreditAccount, CreditLedger from apps.ops.models import Notification -from apps.ops.views import ensure_team_notifications +from apps.ops.views import NotificationViewSet, ensure_team_notifications from apps.products.models import Product from apps.projects.models import Project @@ -84,3 +86,38 @@ class BillingNotificationTests(TestCase): ensure_team_notifications(self.team, self.user) note.refresh_from_db() self.assertEqual(note.cost_label, "¥0.32") + + +class NotificationMuteFilterTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="owner", password="pass") + self.team = Team.objects.create(name="T", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.client = APIClient() + self.client.force_authenticate(self.user) + for kind, title in ( + (Notification.Type.BILLING, "计费 1"), + (Notification.Type.BILLING, "计费 2"), + (Notification.Type.TASK, "任务 1"), + (Notification.Type.SYSTEM, "系统 1"), + ): + Notification.objects.create( + team=self.team, + recipient=self.user, + notification_type=kind, + title=title, + dedupe_key=f"mute-test:{title}", + ) + + @patch.object(NotificationViewSet, "_refresh_notifications") + def test_exclude_type_hides_muted_category(self, _refresh): + res = self.client.get("/api/ops/notifications/", {"exclude_type": "billing", "page_size": 50}) + self.assertEqual(res.status_code, 200) + titles = [row["title"] for row in res.data["results"]] + self.assertNotIn("计费 1", titles) + self.assertNotIn("计费 2", titles) + self.assertIn("任务 1", titles) + self.assertIn("系统 1", titles) + # chip 计数仍是全量,不被静音筛掉 + self.assertEqual(res.data["type_counts"]["billing"], 2) + self.assertEqual(res.data["type_counts"]["task"], 1) diff --git a/core/backend/apps/ops/views.py b/core/backend/apps/ops/views.py index 57fe184..e3193a5 100644 --- a/core/backend/apps/ops/views.py +++ b/core/backend/apps/ops/views.py @@ -257,6 +257,15 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet): user = self.request.user return queryset.filter(Q(recipient=user) | Q(recipient__isnull=True)) + def _excluded_types(self): + """静音同类: ?exclude_type=billing&exclude_type=system 或逗号分隔。非法值丢弃。""" + allowed = {choice[0] for choice in Notification.Type.choices} + raw = self.request.query_params.getlist("exclude_type") + types: list[str] = [] + for item in raw: + types.extend(part.strip() for part in item.split(",") if part.strip()) + return [item for item in types if item in allowed] + def get_queryset(self): queryset = self._recipient_scope() notification_type = self.request.query_params.get("type") @@ -264,6 +273,9 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet): queryset = queryset.filter(notification_type=notification_type) if self.request.query_params.get("unread") in {"1", "true", "yes"}: queryset = queryset.filter(is_read=False) + excluded = self._excluded_types() + if excluded: + queryset = queryset.exclude(notification_type__in=excluded) return queryset def _refresh_notifications(self, request): diff --git a/core/frontend/index.html b/core/frontend/index.html index 7ed3500..660a678 100644 --- a/core/frontend/index.html +++ b/core/frontend/index.html @@ -4,8 +4,8 @@ - - AirShelf + + 影擎
diff --git a/core/frontend/public/assets/logo-dark.png b/core/frontend/public/assets/logo-dark.png index 42f1fdb..e17fe80 100644 Binary files a/core/frontend/public/assets/logo-dark.png and b/core/frontend/public/assets/logo-dark.png differ diff --git a/core/frontend/public/assets/logo.png b/core/frontend/public/assets/logo.png index 94121b1..1d9443a 100644 Binary files a/core/frontend/public/assets/logo.png and b/core/frontend/public/assets/logo.png differ diff --git a/core/frontend/public/assets/yz/login-bg.jpg b/core/frontend/public/assets/yz/login-bg.jpg new file mode 100644 index 0000000..ce216f9 Binary files /dev/null and b/core/frontend/public/assets/yz/login-bg.jpg differ diff --git a/core/frontend/public/assets/yz/logo-horizontal.png b/core/frontend/public/assets/yz/logo-horizontal.png new file mode 100644 index 0000000..1d9443a Binary files /dev/null and b/core/frontend/public/assets/yz/logo-horizontal.png differ diff --git a/core/frontend/public/assets/yz/logo-on-dark.png b/core/frontend/public/assets/yz/logo-on-dark.png new file mode 100644 index 0000000..e17fe80 Binary files /dev/null and b/core/frontend/public/assets/yz/logo-on-dark.png differ diff --git a/core/frontend/public/assets/yz/logo.png b/core/frontend/public/assets/yz/logo.png new file mode 100644 index 0000000..fb86b60 Binary files /dev/null and b/core/frontend/public/assets/yz/logo.png differ diff --git a/core/frontend/public/assets/yz/tool-cover.jpg b/core/frontend/public/assets/yz/tool-cover.jpg new file mode 100644 index 0000000..04077f0 Binary files /dev/null and b/core/frontend/public/assets/yz/tool-cover.jpg differ diff --git a/core/frontend/public/assets/yz/tool-model.jpg b/core/frontend/public/assets/yz/tool-model.jpg new file mode 100644 index 0000000..c691a75 Binary files /dev/null and b/core/frontend/public/assets/yz/tool-model.jpg differ diff --git a/core/frontend/public/assets/yz/tool-studio.jpg b/core/frontend/public/assets/yz/tool-studio.jpg new file mode 100644 index 0000000..ce216f9 Binary files /dev/null and b/core/frontend/public/assets/yz/tool-studio.jpg differ diff --git a/core/frontend/public/assets/yz/video-free.jpg b/core/frontend/public/assets/yz/video-free.jpg new file mode 100644 index 0000000..f0437a6 Binary files /dev/null and b/core/frontend/public/assets/yz/video-free.jpg differ diff --git a/core/frontend/public/assets/yz/video-pro.jpg b/core/frontend/public/assets/yz/video-pro.jpg new file mode 100644 index 0000000..dfcfac1 Binary files /dev/null and b/core/frontend/public/assets/yz/video-pro.jpg differ diff --git a/core/frontend/public/assets/yz/video-quick.jpg b/core/frontend/public/assets/yz/video-quick.jpg new file mode 100644 index 0000000..59fdaac Binary files /dev/null and b/core/frontend/public/assets/yz/video-quick.jpg differ diff --git a/core/frontend/public/assets/yz/video-remix.jpg b/core/frontend/public/assets/yz/video-remix.jpg new file mode 100644 index 0000000..aa0bc95 Binary files /dev/null and b/core/frontend/public/assets/yz/video-remix.jpg differ diff --git a/core/frontend/public/assets/yz/video-replace.jpg b/core/frontend/public/assets/yz/video-replace.jpg new file mode 100644 index 0000000..04077f0 Binary files /dev/null and b/core/frontend/public/assets/yz/video-replace.jpg differ diff --git a/core/frontend/public/assets/yz/welcome-background.png b/core/frontend/public/assets/yz/welcome-background.png new file mode 100644 index 0000000..3fa90c3 Binary files /dev/null and b/core/frontend/public/assets/yz/welcome-background.png differ diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 3c2ea4a..e02c5ce 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -21,7 +21,7 @@ import type { } from "./types"; import { publicModelDisplayName } from "./model-display"; import { generationErrorText } from "./generation-error"; -import { CornerMarks, Decorations, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell"; +import { AccountMenu, CornerMarks, Decorations, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell"; import { SystemLoading } from "./components/loading"; import { AccountPage, @@ -115,6 +115,7 @@ export function App() { const [activeProjectId, setActiveProjectId] = useState(route.projectId || ""); const [notice, setNotice] = useState(null); const [loading, setLoading] = useState(false); + const [accountAnchor, setAccountAnchor] = useState(null); // 右上角 toast 自动消失:成功/信息 3s,错误 5s(此前 notice 不会自己清,导致「提示一直挂着」) useEffect(() => { @@ -899,7 +900,7 @@ export function App() {

暂无项目

- // 先创建一个视频项目 + 先创建一个视频项目
@@ -935,6 +936,7 @@ export function App() { billing={billing} projects={projects} team={currentTeam} + navigate={navigate} onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")} onNotify={(type, text) => setNotice({ type, text })} /> @@ -966,7 +968,7 @@ export function App() { case "assetFactory": return ; case "freeCreate": - return setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} />; + return setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => navigate("projects")} />; case "imageOptimize": return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "modelPhoto": @@ -1016,13 +1018,12 @@ export function App() { /> ); } - if (page === "pipeline" && pipelineProject) { - const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text"); - return ( + const pipelineTextModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text"); + const pipelinePage = page === "pipeline" && pipelineProject ? ( m.capability === "text" && m.status === "active")} loading={loading} navigate={navigate} @@ -1123,12 +1124,11 @@ export function App() { onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")} onSubmitExport={submitExport} /> - ); - } + ) : null; return (
- navigateAdmin("")} /> + navigateAdmin("")} />
@@ -1163,17 +1163,38 @@ export function App() { {unreadCount > 0 && {unreadCount}} -
+
+
{notice && } - {renderPage()} + {pipelinePage || renderPage()}
+ {accountAnchor && ( + setAccountAnchor(null)} + navigate={navigate} + logout={logout} + user={currentUser} + team={currentTeam} + canManageBilling={isOwner} + /> + )}
); } diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css index eb43601..a12e8a6 100644 --- a/core/frontend/src/account-page.css +++ b/core/frontend/src/account-page.css @@ -1,20 +1,60 @@ -/* 账户页 · 从 public/exact/account.html 内联