diff --git a/core/backend/.env b/core/backend/.env index c85164c..462aea6 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -15,10 +15,10 @@ REDIS_CACHE_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/0 CELERY_BROKER_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/1 CELERY_RESULT_BACKEND=redis://:redis_WFSzWJ@yingqing-redis:6379/2 REDIS_LOCK_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/3 -TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com -TOS_BUCKET=airshelf -TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY -TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ== +TOS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com +TOS_BUCKET=yingqing-media +TOS_ACCESS_KEY_ID=LTAI5t62RF5Swgs2uwopvuZz +TOS_SECRET_ACCESS_KEY=CMmQNGiG2ieESRrhD1ORwuB6FYBmxE VOLCANO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26 VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # 2026-07-09 视频(Seedance)ARK key 换成新账号,与新账号素材库审核同账号 → 真人 asset:// 可解析。EP=ep-20260707204223-wkcn9(备用,代码按模型名调不用它)。 diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 49e63b7..73c1615 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -238,11 +238,26 @@ CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled") REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3") +def _tos_region() -> str: + """阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。""" + explicit = (env("TOS_REGION") or "").strip() + if explicit: + return explicit + host = (env("TOS_ENDPOINT") or "").split("://", 1)[-1].split("/", 1)[0] + if host.startswith("oss-") and host.endswith(".aliyuncs.com"): + return host.split(".", 1)[0] + return "cn-shanghai" + + TOS = { "endpoint": env("TOS_ENDPOINT"), "bucket": env("TOS_BUCKET"), + "region": _tos_region(), "access_key_id": env("TOS_ACCESS_KEY_ID"), "secret_access_key": env("TOS_SECRET_ACCESS_KEY"), + # 换到阿里云之后,历史文件仍在火山引擎旧桶。按桶名把旧链接指回原地址。 + "legacy_endpoint": env("TOS_LEGACY_ENDPOINT", "https://tos-s3-cn-shanghai.volces.com"), + "legacy_bucket": env("TOS_LEGACY_BUCKET", "airshelf"), } VOLCANO = { diff --git a/core/backend/airshelf/settings/development.py b/core/backend/airshelf/settings/development.py index b3cc813..3273c43 100644 --- a/core/backend/airshelf/settings/development.py +++ b/core/backend/airshelf/settings/development.py @@ -3,5 +3,16 @@ from .base import * # noqa: F403 DEBUG = True +# 本机 runserver 不在 K8s 的 service DNS 中;若直接沿用集群 Redis 地址,任何依赖 +# cache.get/cache.add 的普通读取接口都会变成 500。开发环境默认使用进程内缓存,确需 +# 联调分布式锁或 Celery 时再显式设 AIRSHELF_DEV_USE_REDIS_CACHE=true。 +if not env_bool("AIRSHELF_DEV_USE_REDIS_CACHE", False): # noqa: F405 + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "airshelf-development-cache", + } + } + # MySQL 连接复用(CONN_MAX_AGE / 健康检查 / connect_timeout)已上移到 base.py, # development 与 production 共用,此处不再重复设置。 diff --git a/core/backend/apps/accounts/tests.py b/core/backend/apps/accounts/tests.py index 98c3682..75db1ce 100644 --- a/core/backend/apps/accounts/tests.py +++ b/core/backend/apps/accounts/tests.py @@ -1,6 +1,8 @@ from decimal import Decimal +from unittest.mock import patch from django.conf import settings +from django.db import IntegrityError from django.test import TestCase from rest_framework.test import APIClient @@ -103,6 +105,21 @@ class SingleDeviceLoginTests(TestCase): self.assertEqual(active.count(), 1) self.assertEqual(active.get().user_agent, "Device B") + def test_login_succeeds_when_device_audit_write_fails(self): + """设备审计表异常不能回滚已经签发的登录 Token。""" + with patch( + "apps.accounts.views.LoginSession.objects.create", + side_effect=IntegrityError("device audit unavailable"), + ): + response = APIClient().post( + "/api/auth/login/", + {"username": self.user.username, "password": "strong-password"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("token", response.data) + class InvitationFlowTests(TestCase): def _register(self, client, username, **extra): diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index d53018b..51a2354 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -99,7 +99,10 @@ def issue_single_device_token(request, user): LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now()) Token.objects.filter(user=locked_user).delete() token = Token.objects.create(user=locked_user) - record_login_session(request, locked_user) + # 设备记录只用于设置页展示和审计,不属于 Token 签发的关键路径。此前它在 + # 同一事务里即使被 record_login_session 捕获,数据库事务仍可能已被标记为 + # rollback,离开 atomic 后登录照样变成 500。 + record_login_session(request, locked_user) return token diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 3833dd3..e9a8c66 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -2991,7 +2991,7 @@ def notify_generation_failure( def _asset_preview_url(asset) -> str: - """资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。""" + """资产主文件的公读直链。写进会话的地址不能带签名,否则 1 小时后图片会消失。""" if asset is None: return "" primary = asset.files.filter(is_primary=True).first() or asset.files.first() @@ -2999,16 +2999,16 @@ def _asset_preview_url(asset) -> str: return "" if primary.preview_url: return primary.preview_url + if not primary.object_key: + return "" try: - return TosStorage().presigned_get_url(object_key=primary.object_key) + return TosStorage().public_url(object_key=primary.object_key, bucket=primary.bucket or None) except Exception: return "" def asset_stable_url(asset) -> tuple[str, str]: - """资产的「长期可打开」直链 (主文件, 封面图)。历史记录这类隔天还要点开的场景必须用这个: - 预签名链只活 1 小时,存进 payload 快照的话第二天就播不了 —— 公读直链不过期还能被缓存。 - 顺序:落库 preview_url → TOS 公读直链 → 兜底预签名。""" + """资产的长期直链 (主文件, 封面图)。公读地址不过期,结果卡隔天还能打开。""" if asset is None: return "", "" files = list(asset.files.all()) @@ -3031,10 +3031,6 @@ def asset_stable_url(asset) -> tuple[str, str]: return "" try: return TosStorage().public_url(object_key=f.object_key, bucket=f.bucket or None) - except Exception: # noqa: BLE001 - pass - try: - return TosStorage().presigned_get_url(object_key=f.object_key) except Exception: # noqa: BLE001 return "" diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 3df4d89..913d3e7 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1780,12 +1780,20 @@ class ModelConfigViewSet(ReadOnlyModelViewSet): ) cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}" if is_plain: - cached = cache.get(cache_key) + # 模型目录缓存只是加速层;开发机或 Redis 短暂不可用时仍应正常返回目录。 + try: + cached = cache.get(cache_key) + except Exception: + logger.warning("模型目录缓存不可用,改为直接读取", exc_info=True) + cached = None if cached is not None: return Response(cached) response = super().list(request, *args, **kwargs) if is_plain and response.status_code == 200: - cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL) + try: + cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL) + except Exception: + logger.warning("模型目录缓存写入失败,已返回直读结果", exc_info=True) return response diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index 01ca5ab..7d2af48 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -45,9 +45,8 @@ class AssetFileSerializer(serializers.ModelSerializer): ] def get_preview_url(self, obj): - # 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼 TOS 公读直链。 - # TOS 桶公读,直链(虚拟主机式)免签名、且稳定可缓存——不再逐图签发预签名 URL - # (签名是纯本地计算但每次都变、1h 过期会打不到浏览器/CDN 缓存;直链一劳永逸)。 + # 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼公读直链。 + # 桶公读,直链不过期、可缓存。不再签发 1 小时就失效的预签名 URL。 if obj.preview_url: return obj.preview_url if not obj.object_key or not settings.TOS.get("endpoint"): diff --git a/core/backend/apps/assets/storage.py b/core/backend/apps/assets/storage.py index 6dcee36..c201ab3 100644 --- a/core/backend/apps/assets/storage.py +++ b/core/backend/apps/assets/storage.py @@ -19,13 +19,25 @@ class TosStorage: def __init__(self) -> None: tos = settings.TOS self.bucket = tos["bucket"] + config_kwargs = { + "signature_version": "s3v4", + "s3": {"addressing_style": "virtual"}, + } + try: + client_config = Config( + **config_kwargs, + request_checksum_calculation="when_required", + response_checksum_validation="when_required", + ) + except TypeError: + client_config = Config(**config_kwargs) self.client = boto3.client( "s3", endpoint_url=tos["endpoint"], aws_access_key_id=tos["access_key_id"], aws_secret_access_key=tos["secret_access_key"], - region_name="cn-shanghai", - config=Config(s3={"addressing_style": "virtual"}), + region_name=tos.get("region") or "cn-shanghai", + config=client_config, ) def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject: @@ -57,6 +69,13 @@ class TosStorage: def public_url(self, *, object_key: str, bucket: str | None = None) -> str: """桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}。 相比预签名 URL:不签名(省 boto3 客户端/签名开销)、且 URL 稳定可被浏览器/CDN 缓存 - (签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。""" - host = urlparse(settings.TOS["endpoint"]).netloc - return f"https://{bucket or self.bucket}.{host}/{quote(object_key, safe='/')}" + (签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。 + 历史文件的 bucket 仍是火山引擎旧桶,必须用旧域名,不能拼到当前阿里云地址上。""" + bucket_name = bucket or self.bucket + endpoint = settings.TOS.get("endpoint") or "" + legacy_bucket = settings.TOS.get("legacy_bucket") or "" + legacy_endpoint = settings.TOS.get("legacy_endpoint") or "" + if legacy_bucket and legacy_endpoint and bucket_name == legacy_bucket: + endpoint = legacy_endpoint + host = urlparse(endpoint).netloc + return f"https://{bucket_name}.{host}/{quote(object_key, safe='/')}" diff --git a/core/backend/apps/ops/views.py b/core/backend/apps/ops/views.py index b838d11..fedc806 100644 --- a/core/backend/apps/ops/views.py +++ b/core/backend/apps/ops/views.py @@ -1,3 +1,4 @@ +import logging from datetime import timedelta from django.core.cache import cache @@ -25,6 +26,9 @@ from .models import Notification from .serializers import NotificationSerializer +logger = logging.getLogger(__name__) + + def project_stage_label(project): return { "script": "Stage 1 · 脚本", @@ -288,7 +292,13 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet): if not Notification.objects.filter(team=team).exists(): ensure_team_notifications(team, request.user) return - if cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60): + # 通知列表是普通读接口;Redis 不可用时跳过后台刷新,不能因此返回 500。 + try: + should_refresh = cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60) + except Exception: + logger.warning("通知刷新缓存不可用,跳过本次后台刷新", exc_info=True) + should_refresh = False + if should_refresh: from .tasks import ensure_team_notifications_task user_id = str(request.user.id) if getattr(request.user, "id", None) else None diff --git a/core/frontend/index.html b/core/frontend/index.html index 660a678..f752d45 100644 --- a/core/frontend/index.html +++ b/core/frontend/index.html @@ -6,6 +6,20 @@ 影擎 +
diff --git a/core/frontend/package.json b/core/frontend/package.json index 238ba9c..9f03648 100644 --- a/core/frontend/package.json +++ b/core/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", + "be": "npm --prefix ../.. run be", "build": "tsc -b && vite build", "preview": "vite preview --host 0.0.0.0" }, diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 2456979..64d0e4a 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -21,6 +21,7 @@ import type { UserPreference } from "./types"; import { publicModelDisplayName } from "./model-display"; +import { applyTheme, normalizeAppearance, readStoredAppearance } from "./theme"; import { generationErrorText } from "./generation-error"; import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock"; import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell"; @@ -143,6 +144,22 @@ export function App() { // 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询 const exportingRef = useRef(false); const [preferences, setPreferences] = useState(null); + + // 首屏先用本地外观,后端 preferences 到达后再对齐;跟随系统时监听系统切换 + useEffect(() => { + applyTheme(readStoredAppearance()); + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => { + if (readStoredAppearance() === "system") applyTheme("system"); + }; + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + useEffect(() => { + const appearance = preferences?.display?.appearance; + if (appearance) applyTheme(normalizeAppearance(appearance)); + }, [preferences?.display?.appearance]); + const [sessions, setSessions] = useState([]); const [activeProductId, setActiveProductId] = useState(route.productId || ""); @@ -211,19 +228,25 @@ export function App() { } }, [loadData]); - // 设置页数据:偏好 + 登录会话(进入设置页时按需加载) + // 设置页数据:偏好 + 登录会话。settingsLoadSeq 挡住过期 GET 覆盖更新的 PUT 结果。 + const settingsLoadSeq = useRef(0); const loadSettingsData = useCallback(async () => { + const seq = ++settingsLoadSeq.current; const [pref, sess] = await Promise.all([ api.preferences().catch(() => null), api.loginSessions().catch(() => []) ]); + if (seq !== settingsLoadSeq.current) return; if (pref) setPreferences(pref); setSessions(sess); }, []); async function savePreferences(payload: Partial) { - const next = await api.updatePreferences(payload).catch(() => null); - if (next) setPreferences(next); + // 失败必须抛出,不能吞成 null —— 否则 settings 会误以为已落盘并推进 baseline + const next = await api.updatePreferences(payload); + // 使在途的 preferences GET 失效,避免刚保存的外观被旧响应打回 system + settingsLoadSeq.current += 1; + setPreferences(next); return next; } @@ -399,16 +422,30 @@ export function App() { // Load preferences + sessions when entering settings. // 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存 useEffect(() => { - if (!authed || !dataLoaded) return; + // 模型目录是创作页的必要数据,不能被商品/项目等全局数据的加载状态卡住。 + // 否则任一非关键列表短暂失败时,模型按钮仍显示默认名却展开空菜单。 + if (!authed) return; if (!["freeCreate", "omniCreate", "omniSession", "omniHistory", "quickCreate", "videoReplace", "pipeline"].includes(page)) return; refreshModelConfigs(); - }, [authed, page, dataLoaded, refreshModelConfigs]); + }, [authed, page, refreshModelConfigs]); useEffect(() => { if (!authed || (page !== "settings" && page !== "settingsNotify")) return; loadSettingsData(); }, [authed, page, loadSettingsData]); + // 登录后拉一次偏好(外观等),不必等进设置页才生效;仍走 settingsLoadSeq,与设置页重载互斥 + useEffect(() => { + if (!authed || !user) return; + const seq = ++settingsLoadSeq.current; + void api.preferences() + .then((pref) => { + if (seq !== settingsLoadSeq.current) return; + if (pref) setPreferences(pref); + }) + .catch(() => undefined); + }, [authed, user]); + // YYX#row22:登录后拉一次未读生成任务,并每 30s 静默轮询(生成是慢任务,出图后角标自动亮) useEffect(() => { if (!authed || !user) return; @@ -901,16 +938,15 @@ export function App() { return; } navigate("omniCreate", { replace: true }); - // 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载), - // 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。 - try { - await loadDataWithRetry(); - } catch (error) { + // 登录凭证已验证即可进入工作台。此前等待商品/项目等整套数据(包含重试)才 + // setAuthed,任一慢接口都会让登录页长时间卡在「正在进入工作台」;而刷新后的 + // boot 路径本来就是先展示外壳、后台补数据。首次登录也保持同一体验。 + setDataLoaded(false); + setAuthed(true); + void loadDataWithRetry().catch((error) => { console.error("[login] data hydrate failed:", error); - setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" }); - } finally { - setAuthed(true); - } + setNotice({ type: "error", text: "部分数据加载失败,页面会自动重试" }); + }); } async function logout() { @@ -1216,9 +1252,9 @@ export function App() { case "modelPhotoDemoB": return goBack("modelPhoto")} navigate={navigate} />; case "settings": - return setNotice({ type: "success", text })} onLogout={logout} />; + return setNotice({ type, text })} onLogout={logout} />; case "settingsNotify": - return setNotice({ type: "success", text })} onLogout={logout} />; + return setNotice({ type, text })} onLogout={logout} />; default: return setNotice({ type, text })} />; } @@ -1379,7 +1415,8 @@ export function App() { )} navigate("account")}> - 余额 {money(billing?.account.balance)} + 余额 + {money(billing?.account.balance)} @@ -483,6 +491,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: { const onCardClick = () => { if (editMode) { if (selectable) toggleSelect(m.id); + else onNotify?.("info", "官方模特不能删除"); return; } setDetail(m); diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index ff3ddd4..3adfc60 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -941,7 +941,7 @@ export function OmniHistoryPage({
{loading && products.length === 0 ? ( - + ) : pageItems.length === 0 ? (
@@ -381,6 +381,26 @@ export function ProductsPage({ products, projects = [], loading = false, navigat ); } +function ProductGridSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); +} + export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, onOpenVideos, editMode = false, selected = false, onDelete }: { product: Product; coverUrl?: string; videoCount?: number; onOpen: () => void; onOpenVideos?: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) { const mock = productCover(product.title); const assetCount = product.images?.length || 0; diff --git a/core/frontend/src/routes/settings.tsx b/core/frontend/src/routes/settings.tsx index 70d751a..a86ca9c 100644 --- a/core/frontend/src/routes/settings.tsx +++ b/core/frontend/src/routes/settings.tsx @@ -4,6 +4,7 @@ import { Bell, KeyRound, LogOut, + Monitor, Shield, Upload, User as UserIcon, @@ -12,16 +13,18 @@ import type { LoginSession, Team, User, UserPreference } from "../types"; import { TeamModal } from "../components/overlays"; import { CustomSelect } from "../components/custom-select"; import { useFileDrop } from "../components/use-file-drop"; +import { applyTheme, normalizeAppearance, readStoredAppearance } from "../theme"; type SectionKey = "profile" | "security" | "notify" | "pref" | "display"; -// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。 -const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"]; +// 可导航分区。pref(创作默认)仍隐藏;display(显示)开放外观切换。 +const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify", "display"]; const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [ { key: "profile", label: "个人信息", icon: }, { key: "security", label: "安全", icon: }, { key: "notify", label: "通知", icon: }, + { key: "display", label: "显示", icon: }, ]; function SettingRow({ title, hint, stack, children }: { title: string; hint?: string; stack?: boolean; children: ReactNode }) { @@ -174,7 +177,7 @@ export function SettingsPage({ onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise; onUploadAvatar: (formData: FormData) => void | Promise; onResetAvatar?: () => void | Promise; - onNotify?: (text: string) => void; + onNotify?: (type: "success" | "error" | "info", text: string) => void; onLogout?: () => void | Promise; }) { const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection) @@ -199,7 +202,10 @@ export function SettingsPage({ transition: cd?.transition ?? DEFAULT_PREFS.transition, twoFactor: !!preferences?.two_factor_enabled, notify: { ...DEFAULT_PREFS.notify, ...(preferences?.notify || {}) }, - appearance: dp?.appearance ?? DEFAULT_PREFS.appearance, + // preferences 尚未加载时用本地已选外观,避免一进设置就被默认 system 盖掉 + appearance: !preferences + ? readStoredAppearance() + : (dp?.appearance ?? DEFAULT_PREFS.appearance), language: dp?.language ?? DEFAULT_PREFS.language, density: dp?.density ?? DEFAULT_PREFS.density, }; @@ -210,18 +216,19 @@ export function SettingsPage({ const [draft, setDraft] = useState(baselineFromProps); const [saving, setSaving] = useState(false); - // 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端) + // 外观即时生效(含后端 preferences 到达 / 放弃修改回滚) useEffect(() => { - setBaseline(baselineFromProps); - setDraft(baselineFromProps); - }, [baselineFromProps]); + applyTheme(normalizeAppearance(draft.appearance)); + }, [draft.appearance]); - // 单字段 draft 更新器 - const patchDraft = useCallback((key: K, value: TrackedState[K]) => { - setDraft((prev) => ({ ...prev, [key]: value })); - }, []); + // 已保存基线的 ref,自动保存时避免连点用到过期 baseline + const baselineRef = useRef(baseline); + baselineRef.current = baseline; + const draftRef = useRef(draft); + draftRef.current = draft; + const saveSeq = useRef(0); - // ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / nav dirty-dot / beforeunload 全由此驱动) ─── + // ─── 聚合脏字段集(文本框编辑中仍可能短暂脏;选项类会立即落盘) ─── const dirtyFields = useMemo>(() => { const keys = Object.keys(FIELD_SECTION) as Array; return keys.filter((key) => { @@ -239,6 +246,97 @@ export function SettingsPage({ const dirtyCount = dirtyFields.length; const isDirty = dirtyCount > 0; + // 后端 preferences / user 到达或刷新时同步;保存中或本地有未落盘编辑时不覆盖 draft + useEffect(() => { + if (saving || isDirty) return; + setBaseline(baselineFromProps); + setDraft(baselineFromProps); + }, [baselineFromProps, saving, isDirty]); + + // 把 next 相对 baseline 的变更拆成 profile / preferences 提交;成功后升基线 + const persist = useCallback(async (next: TrackedState) => { + const base = baselineRef.current; + const profilePayload: { name?: string; email?: string; phone?: string } = {}; + if (next.name !== base.name) profilePayload.name = next.name.trim(); + if (next.email !== base.email) profilePayload.email = next.email.trim(); + if (next.phone !== base.phone) profilePayload.phone = next.phone.trim(); + + const prefPayload: Partial = {}; + const creationKeys: Array = ["template", "duration", "subtitle", "bgm", "transition"]; + if (creationKeys.some((key) => next[key] !== base[key])) { + prefPayload.creation_defaults = { + template: next.template, + duration: next.duration, + subtitle: next.subtitle, + bgm: next.bgm, + transition: next.transition, + }; + } + const displayKeys: Array = ["appearance", "language", "density"]; + if (displayKeys.some((key) => next[key] !== base[key])) { + prefPayload.display = { appearance: next.appearance, language: next.language, density: next.density }; + } + if (!notifyEqual(next.notify, base.notify)) prefPayload.notify = { ...next.notify }; + if (next.twoFactor !== base.twoFactor) prefPayload.two_factor_enabled = next.twoFactor; + + const hasProfile = Object.keys(profilePayload).length > 0; + const hasPref = Object.keys(prefPayload).length > 0; + if (!hasProfile && !hasPref) { + setBaseline(next); + return; + } + + const seq = ++saveSeq.current; + setSaving(true); + try { + const tasks: Array> = []; + if (hasProfile) tasks.push(Promise.resolve(onSaveProfile(profilePayload))); + if (hasPref && onSavePreferences) { + tasks.push( + Promise.resolve(onSavePreferences(prefPayload)).then((result) => { + if (result == null) throw new Error("偏好设置保存失败,请重试"); + return result; + }), + ); + } + await Promise.all(tasks); + if (seq !== saveSeq.current) return; + baselineRef.current = next; + setBaseline(next); + } catch (error) { + if (seq !== saveSeq.current) return; + // 失败不推进 baseline,回滚 draft 并恢复主题 + const restored = baselineRef.current; + draftRef.current = restored; + setDraft(restored); + applyTheme(normalizeAppearance(restored.appearance)); + onNotify?.( + "error", + error instanceof Error ? error.message : "设置保存失败,请重试", + ); + } finally { + if (seq === saveSeq.current) setSaving(false); + } + }, [onSaveProfile, onSavePreferences, onNotify]); + + // 选项类:改完立即存 + const commitField = useCallback((key: K, value: TrackedState[K]) => { + const next = { ...draftRef.current, [key]: value }; + draftRef.current = next; + setDraft(next); + if (key === "appearance") applyTheme(normalizeAppearance(value as string)); + void persist(next); + }, [persist]); + + // 文本框:只改 draft,失焦再存 + const patchDraft = useCallback((key: K, value: TrackedState[K]) => { + setDraft((prev) => ({ ...prev, [key]: value })); + }, []); + + const saveDraftNow = useCallback(() => { + void persist(draftRef.current); + }, [persist]); + // 个人信息 · 受控输入(draft 派生) const name = draft.name; const email = draft.email; @@ -271,55 +369,6 @@ export function SettingsPage({ [notifyOnCount, sessions.length], ); - // ─── 取消:draft 回退到 baseline(放弃所有未保存改动) ─── - function discardChanges() { - if (!isDirty) return; - setDraft(baseline); - onNotify?.("已放弃未保存的改动"); - } - - // ─── 统一保存:把所有脏字段拆成 profile / preferences 两个 payload 提交,成功后把 draft 升为新基线 ─── - async function handleSaveAll() { - if (!isDirty || saving) return; - setSaving(true); - try { - const profilePayload: { name?: string; email?: string; phone?: string } = {}; - if (draft.name !== baseline.name) profilePayload.name = draft.name.trim(); - if (draft.email !== baseline.email) profilePayload.email = draft.email.trim(); - if (draft.phone !== baseline.phone) profilePayload.phone = draft.phone.trim(); - - const prefPayload: Partial = {}; - const creationKeys: Array = ["template", "duration", "subtitle", "bgm", "transition"]; - if (creationKeys.some((key) => draft[key] !== baseline[key])) { - prefPayload.creation_defaults = { - template: draft.template, - duration: draft.duration, - subtitle: draft.subtitle, - bgm: draft.bgm, - transition: draft.transition, - }; - } - const displayKeys: Array = ["appearance", "language", "density"]; - if (displayKeys.some((key) => draft[key] !== baseline[key])) { - prefPayload.display = { appearance: draft.appearance, language: draft.language, density: draft.density }; - } - if (!notifyEqual(draft.notify, baseline.notify)) prefPayload.notify = { ...draft.notify }; - if (draft.twoFactor !== baseline.twoFactor) prefPayload.two_factor_enabled = draft.twoFactor; - - const tasks: Array> = []; - if (Object.keys(profilePayload).length > 0) tasks.push(Promise.resolve(onSaveProfile(profilePayload))); - if (Object.keys(prefPayload).length > 0 && onSavePreferences) tasks.push(Promise.resolve(onSavePreferences(prefPayload))); - await Promise.all(tasks); - - // 提交成功 → draft 升为新基线,脏集清空 - const savedCount = dirtyCount; - setBaseline(draft); - onNotify?.(`${savedCount} 项已保存`); - } finally { - setSaving(false); - } - } - function openPasswordModal() { setOldPassword(""); setNewPassword(""); @@ -381,32 +430,13 @@ export function SettingsPage({ return () => URL.revokeObjectURL(avatarPreview); }, [avatarPreview]); - // ─── 离开页面前提醒:有未保存改动时弹浏览器原生确认 ─── - useEffect(() => { - if (!isDirty) return; - const handler = (event: BeforeUnloadEvent) => { - event.preventDefault(); - event.returnValue = ""; - }; - window.addEventListener("beforeunload", handler); - return () => window.removeEventListener("beforeunload", handler); - }, [isDirty]); - return (

设置

-
个人信息、偏好、通知与安全
-
-
- - +
个人信息、偏好、通知与安全 · 修改后自动保存
@@ -449,7 +479,7 @@ export function SettingsPage({
- patchDraft("name", event.target.value)} /> + patchDraft("name", event.target.value)} onBlur={saveDraftNow} /> patchDraft("email", event.target.value)} + onBlur={saveDraftNow} /> - + patchDraft("phone", event.target.value)} + onBlur={saveDraftNow} placeholder="138****8000" /> @@ -499,7 +531,7 @@ export function SettingsPage({
- patchDraft("twoFactor", v)} /> + commitField("twoFactor", v)} />
@@ -541,7 +573,7 @@ export function SettingsPage({
{NOTIFY_ROWS.map((row) => ( - patchDraft("notify", { ...draft.notify, [row.key]: next })} /> + commitField("notify", { ...draft.notify, [row.key]: next })} /> ))}
消息中心分类
@@ -549,7 +581,7 @@ export function SettingsPage({ patchDraft("notify", { ...draft.notify, [`mute-${row.key}`]: !show })} + onChange={(show) => commitField("notify", { ...draft.notify, [`mute-${row.key}`]: !show })} /> ))} @@ -571,7 +603,7 @@ export function SettingsPage({ className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`} role="button" tabIndex={0} - onClick={() => patchDraft("template", choice.v)} + onClick={() => commitField("template", choice.v)} >
{choice.t}
{choice.d}
@@ -590,7 +622,7 @@ export function SettingsPage({ className={`dur-chip ${draft.duration === d ? "selected" : ""}`} role="button" tabIndex={0} - onClick={() => patchDraft("duration", d)} + onClick={() => commitField("duration", d)} > {d}s @@ -609,7 +641,7 @@ export function SettingsPage({ className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`} role="button" tabIndex={0} - onClick={() => patchDraft("subtitle", choice.v)} + onClick={() => commitField("subtitle", choice.v)} >
{choice.t}
{choice.d}
@@ -621,7 +653,7 @@ export function SettingsPage({
默认 BGM 库
- patchDraft("bgm", next)} options={[ + commitField("bgm", next)} options={[ { value: "kapian", label: "抖音 Top10 卡点曲库" }, { value: "emotion", label: "情绪向 · 治愈/悬念" }, { value: "urban", label: "都市电子 · 通勤场景" }, @@ -632,7 +664,7 @@ export function SettingsPage({
默认转场
- patchDraft("transition", next)} options={[ + commitField("transition", next)} options={[ { value: "none", label: "无转场" }, { value: "fade", label: "淡入淡出 · 0.3s" }, { value: "slide", label: "滑动 · 0.3s" }, @@ -651,6 +683,8 @@ export function SettingsPage({
)} + + {section === "display" && (

显示

@@ -659,17 +693,17 @@ export function SettingsPage({
外观
- patchDraft("appearance", next)} options={[ + commitField("appearance", next)} options={[ { value: "system", label: "跟随系统" }, { value: "light", label: "浅色" }, - { value: "dark", label: "深色(V2)", disabled: true }, + { value: "dark", label: "深色" }, ]} />
语言
- patchDraft("language", next)} options={[ + commitField("language", next)} options={[ { value: "zh", label: "简体中文" }, { value: "en", label: "English(V2)", disabled: true }, ]} /> @@ -678,7 +712,7 @@ export function SettingsPage({
表格密度
- patchDraft("density", next)} options={[ + commitField("density", next)} options={[ { value: "compact", label: "紧凑" }, { value: "standard", label: "标准" }, { value: "loose", label: "宽松" }, @@ -813,7 +847,7 @@ export function SettingsPage({
项目、资产、团队成员与余额数据都会保留
仅影响当前浏览器会话,不会下线其他设备
- {isDirty ?
当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。
: null} + {isDirty ?
还有 {dirtyCount} 项未落盘的修改(通常是正在编辑的文本),退出后不会保存。
: null}
); diff --git a/core/frontend/src/settings-page.css b/core/frontend/src/settings-page.css index 3c0751d..38de7ce 100644 --- a/core/frontend/src/settings-page.css +++ b/core/frontend/src/settings-page.css @@ -28,7 +28,7 @@ .settings-grid { display: grid; grid-template-columns: 210px minmax(0, 1fr); - gap: 22px; + gap: 18px; align-items: start; background: transparent; border: 0; @@ -42,9 +42,9 @@ display: grid; gap: 7px; align-content: start; - padding: 10px; + padding: 12px; border: 1px solid var(--st-line); - border-radius: 13px; + border-radius: 20px; background: var(--st-wash); } .settings-nav .nav-h { @@ -104,7 +104,7 @@ flex: 0 0 auto; } .st-tab.has-changes .nav-dot { display: block; } - .st-tab.active .nav-dot { background: #fff; } + .st-tab.active .nav-dot { background: var(--surface); } .st-tab:not(:has(.nav-badge)) .nav-dot { margin-left: auto; } .logout-pill { @@ -132,24 +132,34 @@ } .settings-content { - padding: 8px 22px; + padding: 22px 28px 24px; border: 1px solid var(--st-line); - border-radius: 13px; - background: rgba(34, 42, 54, 0.04); + border-radius: 20px; + background: rgba(34, 42, 54, 0.035); box-shadow: var(--st-shadow); min-width: 0; + overflow: hidden; } - .pane { background: transparent; border: 0; border-radius: 0; padding: 0; margin: 0; } + /* 内容区本身是卡,pane 不再叠第二层底/阴影 */ + .pane { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + margin: 0; + box-shadow: none; + } .pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; } .pane .pane-desc { font-size: 12px; color: var(--st-muted); margin-bottom: 18px; } .st-row { - min-height: 82px; + min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 24px; + padding: 4px 0; border-bottom: 1px solid rgba(34, 42, 54, 0.075); } .st-row:last-child { border-bottom: 0; } @@ -196,7 +206,7 @@ border: 1px solid rgba(34, 42, 54, 0.11); border-radius: 10px; outline: none; - background: #fff; + background: var(--surface); color: var(--st-text); font: inherit; font-size: 14px; @@ -283,7 +293,7 @@ .switch { position: relative; width: 46px; height: 26px; flex: 0 0 46px; display: inline-block; } .switch input { opacity: 0; width: 0; height: 0; } .switch .slider { position: absolute; inset: 0; background: #cfd3da; border-radius: 999px; cursor: pointer; transition: background 170ms ease; } - .switch .slider::before { content: ''; position: absolute; left: 4px; top: 4px; width: 18px; height: 18px; background: #fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); transition: transform 170ms ease; } + .switch .slider::before { content: ''; position: absolute; left: 4px; top: 4px; width: 18px; height: 18px; background: var(--surface); border-radius: 50%; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); transition: transform 170ms ease; } .switch input:checked + .slider { background: var(--klein); } .switch input:checked + .slider::before { transform: translateX(20px); } .switch input:disabled + .slider { cursor: not-allowed; opacity: .55; } diff --git a/core/frontend/src/styles.css b/core/frontend/src/styles.css index d446840..946ce94 100644 --- a/core/frontend/src/styles.css +++ b/core/frontend/src/styles.css @@ -68,6 +68,31 @@ --red-bd: #F2D6CE; } + +html[data-theme="dark"] { + color-scheme: dark; + --bg: #0f1115; + --bg-soft: #14171c; + --card: #181b21; + --border: #2a2f38; + --border-soft: #323843; + --ink: #e8eaed; + --ink-2: rgba(232, 234, 237, .72); + --ink-3: rgba(232, 234, 237, .52); + --ink-4: rgba(232, 234, 237, .32); + --orange: #3d6bff; + --orange-soft: rgba(61, 107, 255, .28); + --orange-tint: rgba(61, 107, 255, .10); + --orange-hover: #5a82ff; + --green: #6dd88a; + --green-bg: rgba(66, 195, 102, .14); + --green-bd: rgba(66, 195, 102, .32); + --red: #ff6b5c; + --red-bg: rgba(235, 52, 36, .14); + --red-bd: rgba(235, 52, 36, .32); +} + + html, body { background: var(--bg); color: var(--ink); @@ -177,7 +202,7 @@ nav a.disabled:hover { background: transparent; color: var(--ink-4); } .user .em { font-size: 13px; } /* ─── Main + grid background ─── */ -main { position: relative; background: #fff; } +main { position: relative; background: var(--surface); } .grid-bg { position: absolute; inset: 0; @@ -1529,7 +1554,7 @@ table.t tbody tr:hover { background: var(--bg-soft); } .topup-modal .topup-amt { text-align: center; font-size: 34px; font-weight: 600; color: var(--ink); margin: 4px 0; } .topup-note { text-align: center; color: var(--orange); font-family: 'JetBrains Mono', monospace; font-size: 12px; } .topup-qr { width: 180px; height: 180px; margin: 18px auto; background: repeating-linear-gradient(45deg, var(--ink) 0 4px, #fff 4px 8px); border: 8px solid #fff; display: grid; place-items: center; } -.topup-qr .center { background: #fff; padding: 12px; text-align: center; font-weight: 600; } +.topup-qr .center { background: var(--surface); padding: 12px; text-align: center; font-weight: 600; } .topup-qr .center span { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; } /* 团队页旧全局块已移除 · 详见 src/team-page.css 的 .team-page 作用域。保留通用 .av(app-shell/account/dashboard 在用)。 */ @@ -1597,7 +1622,7 @@ table.t tbody tr:hover { background: var(--bg-soft); } .switch { position: relative; display: inline-flex; width: 42px; height: 24px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; inset: 0; border-radius: 999px; background: var(--border); transition: .15s; } -.slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: .15s; } +.slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; border-radius: 50%; background: var(--surface); transition: .15s; } .switch input:checked + .slider { background: var(--orange); } .switch input:checked + .slider::before { transform: translateX(18px); } diff --git a/core/frontend/src/team-page.css b/core/frontend/src/team-page.css index db59443..aa103c7 100644 --- a/core/frontend/src/team-page.css +++ b/core/frontend/src/team-page.css @@ -162,7 +162,7 @@ padding: 0 13px; border: 1px solid rgba(34, 42, 54, 0.10); border-radius: 10px; - background: #fff; + background: var(--surface); color: var(--st-muted); } .tc-search input { @@ -239,7 +239,7 @@ line-height: 0; border: 1px solid var(--st-line); border-radius: 8px; - background: #fff; + background: var(--surface); cursor: pointer; color: var(--st-muted); padding: 0; diff --git a/core/frontend/src/theme.ts b/core/frontend/src/theme.ts new file mode 100644 index 0000000..b441b8a --- /dev/null +++ b/core/frontend/src/theme.ts @@ -0,0 +1,40 @@ +/** 外观主题:浅色 / 深色 / 跟随系统。写到 html[data-theme] + localStorage,避免首屏闪白。 */ + +export type Appearance = "light" | "dark" | "system"; + +export const THEME_STORAGE_KEY = "yingqing-appearance"; + +export function normalizeAppearance(value: unknown): Appearance { + if (value === "light" || value === "dark" || value === "system") return value; + return "system"; +} + +export function resolveTheme(appearance: Appearance): "light" | "dark" { + if (appearance === "light" || appearance === "dark") return appearance; + if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + return "light"; +} + +export function applyTheme(appearance: Appearance | string | null | undefined) { + if (typeof document === "undefined") return; + const normalized = normalizeAppearance(appearance); + const theme = resolveTheme(normalized); + const root = document.documentElement; + root.setAttribute("data-theme", theme); + root.style.colorScheme = theme; + try { + localStorage.setItem(THEME_STORAGE_KEY, normalized); + } catch { + /* ignore quota / private mode */ + } +} + +export function readStoredAppearance(): Appearance { + try { + return normalizeAppearance(localStorage.getItem(THEME_STORAGE_KEY)); + } catch { + return "system"; + } +} diff --git a/core/frontend/src/video-remix-page.css b/core/frontend/src/video-remix-page.css index 2ab9fc9..04ad722 100644 --- a/core/frontend/src/video-remix-page.css +++ b/core/frontend/src/video-remix-page.css @@ -810,7 +810,7 @@ border: 1px solid rgba(0, 47, 167, 0.16); border-radius: 50%; color: #78849a; - background: #fff; + background: var(--surface); font-size: 10px; line-height: 1; transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease; @@ -903,7 +903,7 @@ box-sizing: content-box; border: 1px solid rgba(0, 47, 167, 0.12); border-radius: 14px; - background: #fff; + background: var(--surface); box-shadow: 0 8px 20px rgba(0, 47, 167, 0.08); } @@ -1015,7 +1015,7 @@ .vr-page .remix-page .remix-prompt-panel .analysis-prompt { border-left: 3px solid var(--klein); - background: #fff; + background: var(--surface); box-shadow: inset 0 1px 3px rgba(27, 45, 83, 0.03), 0 8px 22px rgba(22, 45, 92, 0.045); } @@ -1245,7 +1245,7 @@ border: 1px solid rgba(0, 47, 167, 0.24); border-radius: 9px; color: var(--klein); - background: #fff; + background: var(--surface); font: inherit; font-size: 11px; font-weight: 600; @@ -1312,7 +1312,7 @@ border-radius: 9px; outline: none; color: #27334a; - background: #fff; + background: var(--surface); font: inherit; font-size: 12px; line-height: 1.7; @@ -1327,7 +1327,7 @@ border: 1px solid rgba(0, 47, 167, 0.2); border-radius: 8px; color: var(--klein); - background: #fff; + background: var(--surface); font: inherit; font-size: 11px; font-weight: 600; diff --git a/core/frontend/src/video-replace-page.css b/core/frontend/src/video-replace-page.css index 7f7ba28..119a441 100644 --- a/core/frontend/src/video-replace-page.css +++ b/core/frontend/src/video-replace-page.css @@ -168,7 +168,7 @@ .vrep-page .replace-mode-button.active { color: var(--klein); - background: #fff; + background: var(--surface); box-shadow: 0 7px 18px rgba(22, 45, 92, 0.09), inset 0 0 0 1px rgba(0, 47, 167, 0.11); } @@ -553,7 +553,7 @@ border: 1px solid rgba(0, 47, 167, 0.12); border-radius: 13px; color: var(--klein); - background: #fff; + background: var(--surface); box-shadow: 0 8px 18px rgba(0, 47, 167, 0.07); } @@ -623,7 +623,7 @@ padding: 6px; border: 1px solid rgba(34, 42, 54, 0.09); border-radius: 11px; - background: #fff; + background: var(--surface); } .vrep-page .replace-temporary-cell { @@ -686,7 +686,7 @@ place-items: center; border-radius: 11px; color: var(--klein); - background: #fff; + background: var(--surface); } .vrep-page .replace-temporary-more svg { @@ -1130,7 +1130,7 @@ border: 1px solid rgba(0, 47, 167, 0.18); border-radius: 8px; color: var(--klein); - background: #fff; + background: var(--surface); font-size: 12px; font-weight: 500; white-space: nowrap; @@ -1232,7 +1232,7 @@ display: flex; flex-direction: column; overflow: hidden; - background: #fff; + background: var(--surface); box-shadow: -22px 0 48px rgba(7, 12, 22, 0.2); transform: translateX(100%); transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1); @@ -1363,7 +1363,7 @@ gap: 10px; padding: 0 24px; border-top: 1px solid rgba(34, 42, 54, 0.09); - background: #fff; + background: var(--surface); box-shadow: 0 -10px 24px rgba(20, 27, 38, 0.045); } diff --git a/package.json b/package.json new file mode 100644 index 0000000..abadfbc --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "yingqing", + "private": true, + "scripts": { + "be": "bash scripts/dev.sh be", + "fe": "bash scripts/dev.sh fe", + "dev": "bash scripts/dev.sh dev" + } +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..393700b --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# 在仓库任意目录记这三条就够: +# npm run be 只开后端 http://127.0.0.1:8010 +# npm run fe 只开前端 http://127.0.0.1:5173 +# npm run dev 两个一起开(在项目根目录) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODE="${1:-all}" +PY="$ROOT/core/backend/.venv/bin/python" + +listening() { + lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 +} + +need_python() { + if [[ -x "$PY" ]]; then + return 0 + fi + echo "后端环境还没装好。在项目里执行一次:" + echo " cd core/backend && python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt" + exit 1 +} + +start_backend() { + need_python + if listening 8010; then + echo "后端已经在跑: http://127.0.0.1:8010" + return 0 + fi + echo "后端 → http://127.0.0.1:8010" + cd "$ROOT/core/backend" + exec "$PY" manage.py runserver 0.0.0.0:8010 +} + +start_frontend() { + if listening 5173; then + echo "前端已经在跑: http://127.0.0.1:5173" + return 0 + fi + if [[ ! -d "$ROOT/core/frontend/node_modules" ]]; then + echo "第一次开前端,先装依赖…" + npm --prefix "$ROOT/core/frontend" install + fi + echo "前端 → http://127.0.0.1:5173" + cd "$ROOT/core/frontend" + exec npm run dev +} + +case "$MODE" in + be|backend) + start_backend + ;; + fe|frontend) + start_frontend + ;; + all|dev) + need_python + trap 'kill 0' INT TERM + if listening 8010; then + echo "后端已经在跑: http://127.0.0.1:8010" + else + echo "后端 → http://127.0.0.1:8010" + (cd "$ROOT/core/backend" && "$PY" manage.py runserver 0.0.0.0:8010) & + fi + if listening 5173; then + echo "前端已经在跑: http://127.0.0.1:5173" + else + echo "前端 → http://127.0.0.1:5173" + (cd "$ROOT/core/frontend" && npm run dev) & + fi + wait + ;; + *) + echo "用法: npm run be | npm run fe | npm run dev" + exit 1 + ;; +esac