(function () { "use strict"; const TOKEN_KEY = "airshelf_token"; const LIVE_KEY = "airshelf_live"; const USER_KEY = "airshelf_user"; const TEAM_KEY = "airshelf_team"; const BRIDGE_VERSION = "20260601-live-api-bridge-notifications"; const CACHE_VERSION = "20260601"; const CACHE_PREFIX = "airshelf:live-cache:"; const requestMemo = {}; const context = window.__AIR_SHELF_EXACT_CONTEXT__ || {}; const sourceMeta = document.querySelector('meta[name="x-airshelf-exact-source"]'); const page = (context.page || (sourceMeta && sourceMeta.content) || location.pathname.split("/").pop() || "index.html").toLowerCase(); const query = new URLSearchParams(context.search || location.search); const liveDataPages = { "index.html": true, "products.html": true, "product-detail.html": true, "projects.html": true, "projects-new.html": true, "pipeline.html": true, "library.html": true, "account.html": true, "settings.html": true, "team.html": true, "messages.html": true, }; const needsLiveHydration = Boolean(context.liveHydrate || liveDataPages[page]); function token() { return localStorage.getItem(TOKEN_KEY) || ""; } function isLive() { return query.get("live") === "1" || localStorage.getItem(LIVE_KEY) === "1"; } function canUseApi() { return !!token(); } function cacheScope() { try { const team = JSON.parse(localStorage.getItem(TEAM_KEY) || "null"); if (team?.id) return "team:" + team.id; const user = JSON.parse(localStorage.getItem(USER_KEY) || "null"); if (user?.id) return "user:" + user.id; } catch (error) { // Fall through to anonymous scope. } return "anonymous"; } function cacheKey(name) { return CACHE_PREFIX + CACHE_VERSION + ":" + cacheScope() + ":" + name; } function readCache(name) { try { const raw = localStorage.getItem(cacheKey(name)); if (!raw) return null; const parsed = JSON.parse(raw); return parsed && parsed.data !== undefined ? parsed.data : null; } catch (error) { return null; } } function writeCache(name, data) { try { localStorage.setItem(cacheKey(name), JSON.stringify({ ts: Date.now(), data: data })); renderShellFromCache(); } catch (error) { // localStorage may be full or unavailable; live API rendering still works. } } function forgetCache(name) { try { localStorage.removeItem(cacheKey(name)); delete requestMemo[name]; renderShellFromCache(); } catch (error) { // Best effort only. } } function apiGet(cacheName, path) { if (!requestMemo[cacheName]) { requestMemo[cacheName] = api(path) .then(function (data) { writeCache(cacheName, data); return data; }) .catch(function (error) { delete requestMemo[cacheName]; throw error; }); } return requestMemo[cacheName]; } function rememberAuthShape(meData) { if (!meData) return; try { if (meData.user) localStorage.setItem(USER_KEY, JSON.stringify(meData.user)); if (meData.team) localStorage.setItem(TEAM_KEY, JSON.stringify(meData.team)); } catch (error) { // Best effort only. } } function cachedAuthShape() { const cached = readCache("auth:me"); if (cached) return cached; try { const user = JSON.parse(localStorage.getItem(USER_KEY) || "null"); const team = JSON.parse(localStorage.getItem(TEAM_KEY) || "null"); return user || team ? { user: user || {}, team: team || {} } : null; } catch (error) { return null; } } async function loadWithCache(cacheName, fetcher, render, failTitle) { const cached = readCache(cacheName); if (cached) { render(cached, true); markHydrationDone(); } try { const fresh = await fetcher(); writeCache(cacheName, fresh); render(fresh, false); return fresh; } catch (error) { if (cached) { toast(failTitle || "数据刷新失败", (error && error.message ? error.message : "接口暂不可用") + " · 已显示本地缓存"); return cached; } toast(failTitle || "数据加载失败", error.message); throw error; } } function markHydrationLoading() { if (!needsLiveHydration || !canUseApi()) return; document.documentElement.removeAttribute("data-live-error"); document.documentElement.setAttribute("data-live-hydrating", "1"); } function markHydrationDone() { if (!needsLiveHydration) return; document.documentElement.removeAttribute("data-live-hydrating"); document.documentElement.removeAttribute("data-live-error"); document.documentElement.setAttribute("data-live-ready", "1"); } function markHydrationError(message) { if (!needsLiveHydration) return; document.documentElement.removeAttribute("data-live-hydrating"); document.documentElement.setAttribute("data-live-error", "1"); document.documentElement.dataset.liveErrorMessage = message || "真实数据加载失败"; } function applyContextHash() { const cleanHash = String(context.hash || location.hash || "").replace(/^#/, ""); if (!cleanHash) return; if (page === "settings.html" && cleanHash.indexOf("sec-") === 0 && typeof window.showSection === "function") { window.showSection(cleanHash); return; } const stageMatch = cleanHash.match(/^stage-(\d+)$/); if (page === "pipeline.html" && stageMatch && typeof window.activateStage === "function") { window.activateStage(Number(stageMatch[1])); } } function go(href) { if (typeof window.__AIR_SHELF_HOST_NAVIGATE__ === "function") { window.__AIR_SHELF_HOST_NAVIGATE__(href); return; } location.href = href; } function ready(fn) { if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); else fn(); } function esc(value) { return String(value == null ? "" : value).replace(/[&<>"']/g, function (char) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]; }); } function dateOnly(value) { if (!value) return new Date().toISOString().slice(0, 10); return String(value).slice(0, 10); } function shortLabel(value) { const text = String(value || "商品").replace(/[·||]/g, " ").trim(); return text.length > 9 ? text.slice(0, 9) : text; } function parseError(text) { try { const data = JSON.parse(text); if (typeof data.detail === "string") return data.detail; if (Array.isArray(data.non_field_errors)) return data.non_field_errors.join(" / "); const parts = []; Object.keys(data).forEach(function (key) { const value = data[key]; if (Array.isArray(value)) parts.push(key + ": " + value.join(" / ")); else if (typeof value === "string") parts.push(key + ": " + value); }); return parts.join(";") || text; } catch (error) { return text || "请求失败"; } } async function api(path, options) { const opts = options || {}; const headers = new Headers(opts.headers || {}); if (!(opts.body instanceof FormData)) headers.set("Content-Type", "application/json"); if (token()) headers.set("Authorization", "Token " + token()); const response = await fetch(path, Object.assign({}, opts, { headers: headers })); if (!response.ok) { const text = await response.text(); throw new Error(parseError(text)); } if (response.status === 204) return null; return response.json(); } function toast(title, sub) { if (window.Shell && typeof window.Shell.toast === "function") { window.Shell.toast(title, sub || ""); return; } if (typeof window._loginToast === "function") { window._loginToast(title, sub || ""); return; } if (typeof window._regToast === "function") { window._regToast(title, sub || ""); return; } let el = document.getElementById("__api_bridge_toast"); if (!el) { el = document.createElement("div"); el.id = "__api_bridge_toast"; el.style.cssText = "position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:380px;"; document.body.appendChild(el); } el.innerHTML = '
' + esc(title) + "
" + (sub ? '
// ' + esc(sub) + "
" : ""); requestAnimationFrame(function () { el.style.opacity = "1"; el.style.transform = "translateX(-50%) translateY(0)"; }); clearTimeout(el._timer); el._timer = setTimeout(function () { el.style.opacity = "0"; el.style.transform = "translateX(-50%) translateY(20px)"; }, 3000); } function setAuthPayload(payload) { localStorage.setItem(TOKEN_KEY, payload.token); localStorage.setItem(LIVE_KEY, "1"); localStorage.setItem(USER_KEY, JSON.stringify(payload.user || {})); localStorage.setItem(TEAM_KEY, JSON.stringify(payload.team || {})); } function renderShellIdentity(meData, billingData) { if (meData) rememberAuthShape(meData); const teamName = meData?.team?.name || "团队"; const userName = meData?.user ? displayName(meData.user) : "成员"; const email = meData?.user?.email || ""; const avatar = initial(userName); document.querySelectorAll(".aside-foot .user .em, .shell-account-head .nm").forEach(function (el) { el.textContent = teamName; }); document.querySelectorAll(".aside-foot .user .av, .topbar-avatar span, .shell-account-head .av").forEach(function (el) { el.textContent = avatar; }); document.querySelectorAll(".shell-account-head .mail").forEach(function (el) { el.textContent = email; }); syncShellBalance(billingData); } function syncShellBalance(billingData) { const balance = billingData?.account?.balance; document.querySelectorAll(".balance-chip strong").forEach(function (el) { el.textContent = balance === undefined || balance === null ? "--" : money(balance); }); } function firstPresent(values) { for (let i = 0; i < values.length; i += 1) { if (values[i] !== null && values[i] !== undefined) return values[i]; } return null; } function collectionCount(data) { if (!data) return null; if (typeof data.count === "number") return data.count; if (typeof data.count === "string" && data.count.trim() !== "" && !Number.isNaN(Number(data.count))) return Number(data.count); if (Array.isArray(data.results)) return data.results.length; if (Array.isArray(data)) return data.length; return null; } function listResults(data) { if (Array.isArray(data)) return data; if (data && Array.isArray(data.results)) return data.results; return []; } function unreadNotificationCount(data) { if (!data) return null; if (typeof data.unread_count === "number") return data.unread_count; if (typeof data.unread_count === "string" && data.unread_count.trim() !== "" && !Number.isNaN(Number(data.unread_count))) { return Number(data.unread_count); } const rows = listResults(data); if (!rows.length && collectionCount(data) === 0) return 0; return rows.filter(function (item) { if (item.unread !== undefined) return Boolean(item.unread); return item.is_read === false; }).length; } function emptyList() { return { count: 0, next: null, previous: null, results: [] }; } function listCacheOrEmpty(name) { return readCache(name) || emptyList(); } function cacheNested(name, key) { const data = readCache(name); return data && data[key] ? data[key] : null; } function setShellBadge(href, count) { document.querySelectorAll('aside.sidebar a[href="' + href + '"] .pill-mini').forEach(function (badge) { if (count === null || count === undefined) { badge.textContent = ""; badge.hidden = true; return; } badge.hidden = false; badge.textContent = String(count); }); } function syncNotificationBadge() { document.querySelectorAll(".count-noti").forEach(function (badge) { const count = firstPresent([ readCache("notifications:summary")?.unread_count, unreadNotificationCount(readCache("notifications:list")), ]); if (count === null || count === undefined || Number(count) <= 0) { badge.textContent = ""; badge.hidden = true; return; } badge.hidden = false; badge.textContent = String(count); }); } function shellSummaryFromCache() { return firstPresent([ readCache("billing:summary"), cacheNested("dashboard", "summary"), cacheNested("account:bundle", "summary"), cacheNested("team:bundle", "summary"), cacheNested("settings:profile", "summary"), ]); } function shellProductDataFromCache() { return firstPresent([ readCache("products:list"), cacheNested("dashboard", "products"), cacheNested("account:bundle", "products"), cacheNested("projects:bundle", "products"), cacheNested("pipeline:latest", "products"), ]); } function shellProjectDataFromCache() { return firstPresent([ readCache("projects:list"), cacheNested("dashboard", "projects"), cacheNested("account:bundle", "projects"), cacheNested("projects:bundle", "projects"), ]); } function renderShellFromCache() { if (!document.body) return; const meData = cachedAuthShape(); const summaryData = shellSummaryFromCache(); if (canUseApi() || meData || summaryData) renderShellIdentity(meData, summaryData); setShellBadge("products.html", collectionCount(shellProductDataFromCache())); setShellBadge("projects.html", collectionCount(shellProjectDataFromCache())); syncNotificationBadge(); } function seedProjectsBundleFromCache() { if (readCache("projects:bundle")) return; const products = shellProductDataFromCache(); const projects = shellProjectDataFromCache(); if (!products && !projects) return; writeCache("projects:bundle", { products: products || emptyList(), projects: projects || emptyList(), }); } function seedAccountBundleFromCache() { if (readCache("account:bundle")) return; const summary = shellSummaryFromCache(); const ledgers = readCache("billing:ledgers"); const members = readCache("team:members"); const products = shellProductDataFromCache(); const projects = shellProjectDataFromCache(); if (!summary && !ledgers && !members && !products && !projects) return; writeCache("account:bundle", { summary: summary || { account: { balance: 0 }, charged_total: 0 }, ledgers: ledgers || [], members: members || [], products: products || emptyList(), projects: projects || emptyList(), }); } function seedTeamBundleFromCache() { if (readCache("team:bundle")) return; const me = cachedAuthShape(); const summary = readCache("billing:summary"); const members = readCache("team:members"); if (!me && !summary && !members) return; writeCache("team:bundle", { me: me || {}, summary: summary || { account: { balance: 0 }, charged_total: 0 }, members: members || [], }); } async function hydrateShellIdentity() { if (!canUseApi()) return; const cachedMe = cachedAuthShape(); const cachedBilling = readCache("billing:summary"); renderShellFromCache(); if (cachedMe) renderShellIdentity(cachedMe, cachedBilling); try { const meData = await apiGet("auth:me", "/api/auth/me/"); writeCache("auth:me", meData); const billingData = await apiGet("billing:summary", "/api/billing/summary/").catch(function () { return null; }); if (billingData) writeCache("billing:summary", billingData); renderShellIdentity(meData, billingData || cachedBilling); renderShellFromCache(); } catch (error) { // Shell identity is a convenience layer; page-specific API errors are handled below. } } function setButtonBusy(button, text) { if (!button) return function () {}; const html = button.innerHTML; button.disabled = true; button.innerHTML = '// ' + esc(text) + ""; return function () { button.disabled = false; button.innerHTML = html; }; } function wireAuth() { if (page === "login.html") { window.doLogin = async function () { const email = document.getElementById("auth-email").value.trim(); const password = document.getElementById("auth-pwd").value; const restore = setButtonBusy(document.querySelector(".btn-cta"), "验证中..."); try { const payload = await api("/api/auth/login/", { method: "POST", body: JSON.stringify({ username: email, password: password }), }); setAuthPayload(payload); toast("登录成功", "已接入 Django 真实会话"); go("index.html"); } catch (error) { restore(); toast("登录失败", error.message || "请检查账号密码"); } }; } if (page === "register.html") { window.doRegister = async function () { const team = document.getElementById("reg-team").value.trim(); const email = document.getElementById("reg-email").value.trim(); const password = document.getElementById("reg-pwd").value; const confirm = document.getElementById("reg-pwd2").value; const agree = document.getElementById("reg-agree").checked; if (!team || !email) return alert("请补全团队名 + 邮箱"); if (password.length < 8) return alert("密码至少 8 位"); if (password !== confirm) return alert("两次密码不一致"); if (!agree) return alert("请同意用户协议"); const restore = setButtonBusy(document.getElementById("reg-submit"), "创建团队中..."); try { const payload = await api("/api/auth/register/", { method: "POST", body: JSON.stringify({ username: email, email: email, password: password, team_name: team, }), }); setAuthPayload(payload); toast("注册成功", "团队与试用额度已创建"); go("index.html"); } catch (error) { restore(); toast("注册失败", error.message || "请稍后重试"); } }; } } function sellingPointsFromDrawer() { const points = Array.from(document.querySelectorAll("#pf-bullets .bl-item .bl-text")) .map(function (el) { return el.textContent.trim(); }) .filter(Boolean); const pending = document.querySelector("#pf-bullets .bl-add .bl-input"); if (pending && pending.value.trim()) points.push(pending.value.trim()); return points; } let productCreateInFlight = false; async function submitLiveProduct(saveBtn) { if (!canUseApi()) return false; if (productCreateInFlight) return true; const nameEl = document.getElementById("pf-name"); const catEl = document.getElementById("pf-cat"); const targetEl = document.getElementById("pf-target"); const name = (nameEl && nameEl.value ? nameEl.value : "").trim(); const category = catEl ? catEl.value : ""; const target = targetEl ? targetEl.value.trim() : ""; const points = sellingPointsFromDrawer(); if (!name) { toast("请填写商品名称", "必填项"); if (nameEl) nameEl.focus(); return true; } if (points.length === 0) { toast("请填写核心卖点", "至少 1 条"); return true; } productCreateInFlight = true; const restore = setButtonBusy(saveBtn, "保存商品中..."); try { const product = await api("/api/products/", { method: "POST", body: JSON.stringify({ title: name, category: category, target_audience: target, selling_points: points.map(function (title, index) { return { title: title, detail: "", sort_order: index }; }), }), }); rememberPrototypeProduct(product, { title: name, category: category, target: target, points: points, }); forgetCache("products:list"); forgetCache("projects:bundle"); forgetCache("dashboard"); localStorage.setItem("airshelf_current_product_id", product.id); toast("商品已创建", "已写入 Django 数据库"); go("product-detail.html?product_id=" + encodeURIComponent(product.id) + "&product=" + encodeURIComponent(product.title || name)); } catch (error) { productCreateInFlight = false; restore(); toast("创建失败", error.message); } return true; } function rememberPrototypeProduct(product, draft) { try { const key = "fs-extra-products"; const list = JSON.parse(sessionStorage.getItem(key) || "[]"); list.push({ id: product.id, name: product.title || draft.title, cat: product.category || draft.category || "未分类", target: product.target_audience || draft.target || "", assets: 0, videos: 0, bullets: draft.points || [], date: dateOnly(product.created_at), createdAt: Date.now(), }); sessionStorage.setItem(key, JSON.stringify(list)); } catch (error) { // Prototype compatibility only; API persistence is already complete. } } function productCardHTML(product, index) { const title = product.title || "未命名商品"; const cat = product.category || "未分类"; const date = dateOnly(product.created_at); const assets = (product.images && product.images.length) || 0; const videos = product.metadata && product.metadata.videos_count ? product.metadata.videos_count : 0; return ( '
' + '' + '' + '
' + esc(shortLabel(title)) + " · 1200×800
" + '
' + esc(title) + '
' + esc(cat) + '
' + esc(date) + " 创建
" + '
" ); } function syncProductCount() { const cards = Array.from(document.querySelectorAll("#product-grid .product-card")); const visible = cards.filter(function (card) { return card.style.display !== "none"; }).length; const total = cards.length; const sku = document.getElementById("sku-count"); if (sku) sku.textContent = String(total); const meta = document.getElementById("result-meta"); if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + total + " 个商品"; setShellBadge("products.html", total); const empty = document.getElementById("empty"); if (empty) empty.hidden = visible !== 0; } function applyLiveProductFilter() { const q = (document.getElementById("search-input") || {}).value || ""; const needle = q.trim().toLowerCase(); document.querySelectorAll("#product-grid .product-card").forEach(function (card) { const hay = ((card.dataset.name || "") + " " + (card.dataset.cat || "") + " " + (card.dataset.tags || "")).toLowerCase(); card.style.display = !needle || hay.indexOf(needle) >= 0 ? "" : "none"; }); syncProductCount(); } function bindLiveProductCards() { const grid = document.getElementById("product-grid"); if (!grid) return; grid.querySelectorAll(".product-card").forEach(function (card) { card.addEventListener("click", function (event) { if (document.body.classList.contains("edit-mode")) { event.preventDefault(); event.stopImmediatePropagation(); card.classList.toggle("selected"); return; } const id = card.dataset.productId; const name = card.dataset.name || ""; if (id) go("product-detail.html?product_id=" + encodeURIComponent(id) + "&product=" + encodeURIComponent(name)); }); }); grid.querySelectorAll('[data-action="delete-product"][data-product-id]').forEach(function (button) { button.addEventListener("click", async function (event) { event.preventDefault(); event.stopImmediatePropagation(); const card = button.closest(".product-card"); const id = button.dataset.productId; if (!id || !confirm("确认删除商品「" + (card && card.dataset.name ? card.dataset.name : "") + "」?")) return; try { await api("/api/products/" + encodeURIComponent(id) + "/", { method: "DELETE" }); if (card) card.remove(); forgetCache("products:list"); forgetCache("projects:bundle"); forgetCache("dashboard"); syncProductCount(); toast("已删除", "商品已从 Django 数据库移除"); } catch (error) { toast("删除失败", error.message); } }); }); } function renderLiveProductsPayload(data) { const grid = document.getElementById("product-grid"); if (!grid) return; const products = data.results || []; grid.innerHTML = products.length ? products.map(productCardHTML).join("") : '
// 当前团队还没有真实商品
'; bindLiveProductCards(); applyLiveProductFilter(); } async function loadLiveProducts() { if (!canUseApi()) return; const grid = document.getElementById("product-grid"); if (!grid) return; return loadWithCache( "products:list", function () { return apiGet("products:list", "/api/products/"); }, renderLiveProductsPayload, "商品加载失败" ); } function wireProductCreate() { const saveBtn = document.getElementById("pc-save-btn"); if (!saveBtn) return; const submitHandler = function (event) { const button = event.target && event.target.closest ? event.target.closest("#pc-save-btn") : null; if (!button || !canUseApi()) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); submitLiveProduct(button); }; document.addEventListener("click", submitHandler, true); saveBtn.addEventListener("click", submitHandler, true); const search = document.getElementById("search-input"); if (search) { search.addEventListener( "input", function (event) { if (!canUseApi()) return; event.stopImmediatePropagation(); applyLiveProductFilter(); }, true ); } } function renderProductDetail(product) { if (!product) return; const title = product.title || ""; const category = product.category || ""; const target = product.target_audience || ""; const h1 = document.getElementById("pd-name"); if (h1 && title) h1.textContent = title; setField("name", title); setField("cat", category); setField("target", target); const bulletBox = document.querySelector('[data-field="bullets"] .v-static'); if (bulletBox && product.selling_points && product.selling_points.length) { bulletBox.innerHTML = product.selling_points .map(function (point) { return '' + esc(point.title) + ""; }) .join(""); } } async function hydrateProductDetail() { if (!canUseApi()) return; if (page !== "product-detail.html") return; const id = query.get("product_id") || localStorage.getItem("airshelf_current_product_id"); if (!id) return; return loadWithCache( "product:" + id, function () { return apiGet("product:" + id, "/api/products/" + encodeURIComponent(id) + "/"); }, renderProductDetail, "商品详情加载失败" ); } function setField(field, value) { const row = document.querySelector('[data-field="' + field + '"]'); if (!row) return; const stat = row.querySelector(".v-static"); const input = row.querySelector(".v-input, .v-select"); if (stat && value) stat.textContent = value; if (input && value) { if (input.tagName === "SELECT" && !Array.from(input.options).some(function (option) { return option.value === value; })) { const option = document.createElement("option"); option.textContent = value; option.value = value; input.insertBefore(option, input.firstChild); } input.value = value; } } function stageNo(project) { const map = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 }; if (project.status === "completed") return 5; return map[project.current_stage] || 1; } function statusBucket(project) { if (project.status === "completed") return "done"; if (project.status === "failed") return "fail"; return "wip"; } function projectStatusLabel(project) { return { draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败", }[project.status] || "进行中"; } function pillClass(project) { if (project.status === "completed") return "ok"; if (project.status === "failed") return "fail"; return "info"; } function progressHTML(project) { const no = stageNo(project); let html = ""; for (let i = 1; i <= 5; i += 1) { let cls = ""; if (project.status === "completed" || i < no) cls = "done"; else if (i === no) cls = "cur"; html += ''; } return html; } function projectHref(project) { return "pipeline.html?project_id=" + encodeURIComponent(project.id) + "#stage-" + stageNo(project); } function liveProjectRowHTML(project, productName) { const href = projectHref(project); const status = statusBucket(project); const label = projectStatusLabel(project); const shots = (project.video_segments && project.video_segments.length) || 4; const no = stageNo(project); return ( '' + '
9:16
' + esc(project.name) + '
' + esc(shots) + " 镜 · 0-60s
" + "" + esc(productName || "未命名商品") + 'AI 全生
' + progressHTML(project) + '
' + esc(no) + '/5
' + esc(label) + '' + esc(dateOnly(project.updated_at)) + '
' ); } function liveProjectCardHTML(project, productName) { const href = projectHref(project); const status = statusBucket(project); const label = projectStatusLabel(project); const shots = (project.video_segments && project.video_segments.length) || 4; const no = stageNo(project); return ( '
' + '' + '' + '
9:16 · 阶段 ' + esc(no) + '/5
' + esc(project.name) + '
' + esc(productName || "未命名商品") + " · " + esc(shots) + ' 镜
' + progressHTML(project) + '
' + esc(no) + '/5
' + esc(label) + '' + esc(dateOnly(project.updated_at)) + "
" ); } function syncProjectCount() { const rows = Array.from(document.querySelectorAll("#list-tbody tr")); const visible = rows.filter(function (row) { return row.style.display !== "none"; }).length; const counts = { total: rows.length, wip: 0, done: 0, fail: 0 }; rows.forEach(function (row) { const s = row.dataset.status; if (counts[s] !== undefined) counts[s] += 1; }); const total = document.getElementById("sub-total"); const wip = document.getElementById("sub-wip"); const done = document.getElementById("sub-done"); const fail = document.getElementById("sub-fail"); if (total) total.textContent = counts.total; if (wip) wip.textContent = counts.wip; if (done) done.textContent = counts.done; if (fail) fail.textContent = counts.fail; document.querySelectorAll("#status-tabs .tab").forEach(function (tab) { const filter = tab.dataset.filter; const n = filter === "all" ? counts.total : counts[filter] || 0; const el = tab.querySelector(".count"); if (el) el.textContent = n; }); const meta = document.getElementById("result-meta"); if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + counts.total + " 个项目"; setShellBadge("projects.html", counts.total); const empty = document.getElementById("empty"); if (empty) empty.hidden = visible !== 0; } function applyLiveProjectFilter() { const active = document.querySelector("#status-tabs .tab.active"); const filter = active ? active.dataset.filter : "all"; const needle = ((document.getElementById("search-input") || {}).value || "").trim().toLowerCase(); const test = function (el) { const statusOk = filter === "all" || el.dataset.status === filter; const textOk = !needle || (el.dataset.name || "").toLowerCase().indexOf(needle) >= 0; return statusOk && textOk; }; document.querySelectorAll("#list-tbody tr, #grid-body .proj-card").forEach(function (el) { el.style.display = test(el) ? "" : "none"; }); syncProjectCount(); } function bindLiveProjects() { document.querySelectorAll("#list-tbody tr[data-project-id], #grid-body .proj-card[data-project-id]").forEach(function (el) { el.addEventListener("click", function (event) { if (event.target.closest("button, a, .row-more")) return; if (document.body.classList.contains("edit-mode")) { event.preventDefault(); event.stopImmediatePropagation(); el.classList.toggle("selected"); return; } go(el.dataset.href || projectHref({ id: el.dataset.projectId, current_stage: "script", status: "" })); }); }); document.querySelectorAll('[data-action="delete-project"][data-project-id]').forEach(function (button) { button.addEventListener("click", async function (event) { event.preventDefault(); event.stopImmediatePropagation(); const id = button.dataset.projectId; const el = button.closest(".proj-card, tr"); const name = el ? el.dataset.name : ""; if (!id || !confirm("确认删除项目「" + name + "」?")) return; try { await api("/api/projects/" + encodeURIComponent(id) + "/", { method: "DELETE" }); document.querySelectorAll('[data-project-id="' + CSS.escape(id) + '"]').forEach(function (node) { node.remove(); }); forgetCache("projects:bundle"); forgetCache("projects:list"); forgetCache("dashboard"); applyLiveProjectFilter(); toast("已删除", "项目已从 Django 数据库移除"); } catch (error) { toast("删除失败", error.message); } }); }); const search = document.getElementById("search-input"); if (search) { search.addEventListener( "input", function (event) { if (!canUseApi()) return; event.stopImmediatePropagation(); applyLiveProjectFilter(); }, true ); } document.querySelectorAll("#status-tabs .tab").forEach(function (tab) { tab.addEventListener( "click", function (event) { if (!canUseApi()) return; event.preventDefault(); event.stopImmediatePropagation(); document.querySelectorAll("#status-tabs .tab").forEach(function (x) { x.classList.remove("active"); }); tab.classList.add("active"); applyLiveProjectFilter(); }, true ); }); } function renderLiveProjectsPayload(payload) { const tbody = document.getElementById("list-tbody"); const grid = document.getElementById("grid-body"); if (!tbody || !grid) return; const productData = payload.products || {}; const projectData = payload.projects || {}; const productMap = {}; (productData.results || []).forEach(function (product) { productMap[product.id] = product.title; }); const projects = projectData.results || []; tbody.innerHTML = projects.length ? projects .map(function (project) { return liveProjectRowHTML(project, productMap[project.product]); }) .join("") : '// 当前团队还没有真实项目'; grid.innerHTML = projects.length ? projects .map(function (project) { return liveProjectCardHTML(project, productMap[project.product]); }) .join("") : '
// 当前团队还没有真实项目
'; bindLiveProjects(); applyLiveProjectFilter(); } async function loadLiveProjects() { if (!canUseApi()) return; const tbody = document.getElementById("list-tbody"); const grid = document.getElementById("grid-body"); if (!tbody || !grid) return; seedProjectsBundleFromCache(); return loadWithCache( "projects:bundle", async function () { const productData = await apiGet("products:list", "/api/products/"); writeCache("products:list", productData); const projectData = await apiGet("projects:list", "/api/projects/"); return { products: productData, projects: projectData }; }, renderLiveProjectsPayload, "项目加载失败" ); } async function ensureProduct(title, category, id) { if (id) return api("/api/products/" + encodeURIComponent(id) + "/"); const data = await api("/api/products/?search=" + encodeURIComponent(title)); const found = (data.results || []).find(function (product) { return product.title === title; }); if (found) return found; return api("/api/products/", { method: "POST", body: JSON.stringify({ title: title, category: category || "" }), }); } function selectedWizardProduct() { const card = document.querySelector("#step-pane-1 .product-card.selected, #step-pane-1 .product-pick.selected"); if (!card) return null; return { id: card.dataset.productId || card.dataset.id || "", title: (card.querySelector(".product-name, .name") || {}).textContent || "", category: (card.querySelector(".product-cat, .meta") || {}).textContent || "", }; } function projectNameFromWizard(productTitle) { const inputs = Array.from(document.querySelectorAll("#step-pane-2 input.input")); const named = inputs.find(function (input) { return input.value && input.value.trim().length >= 2; }); const value = named ? named.value.trim() : ""; return value || (productTitle ? productTitle + " · AI 视频" : "未命名项目"); } function liveWizardProductHTML(product, index) { const title = product.title || "未命名商品"; const category = product.category || "未分类"; const date = dateOnly(product.created_at); return ( '
' + '
' + esc(shortLabel(title)) + " · 1200×800
" + '
' + esc(title) + '
' + esc(category) + '
' + esc(date) + " 创建
" ); } function renderLiveWizardProductsPayload(data) { const grid = document.querySelector("#step-pane-1 .pp-grid"); if (!grid) return; const products = data.results || []; const createCard = '
新建商品
// 进入商品库创建
'; grid.innerHTML = createCard + (products.length ? products.map(liveWizardProductHTML).join("") : '
// 当前团队还没有商品 · 请先新建商品
'); grid.querySelector('[data-live-create="1"]')?.addEventListener("click", function () { go("products.html"); }); grid.querySelectorAll(".product-card[data-product-id]").forEach(function (card) { card.addEventListener("click", function () { grid.querySelectorAll(".product-card").forEach(function (node) { node.classList.remove("selected"); }); card.classList.add("selected"); const title = (card.querySelector(".product-name") || {}).textContent || ""; const projectInputs = Array.from(document.querySelectorAll("#step-pane-2 input.input")); const target = projectInputs.find(function (input) { return input.placeholder && input.placeholder.indexOf("项目") >= 0; }); if (target && !target.value.trim()) target.value = title + " · 痛点种草 · v1"; }); }); const meta = document.querySelector("#step-pane-1 .pp-result-meta"); if (meta) meta.textContent = "// 显示 " + products.length + " / " + products.length + " 个真实商品"; document.querySelectorAll(".btn-start.disabled").forEach(function (button) { button.classList.remove("disabled"); }); } async function loadLiveWizardProducts() { if (page !== "projects-new.html" || !canUseApi()) return; const grid = document.querySelector("#step-pane-1 .pp-grid"); if (!grid) return; return loadWithCache( "products:list", function () { return apiGet("products:list", "/api/products/"); }, renderLiveWizardProductsPayload, "商品加载失败" ); } function wireProjectWizard() { if (page !== "projects-new.html") return; if (!window._wiz || typeof window._wiz.startGenerate !== "function") return; const original = window._wiz.startGenerate; window._wiz.startGenerate = async function () { if (!canUseApi()) return original(); const picked = selectedWizardProduct(); if (!picked || !picked.title.trim()) { toast("请选择商品", "项目必须绑定一个商品"); return; } const startBtn = document.querySelector(".btn-start"); const restore = setButtonBusy(startBtn, "创建项目中..."); try { const product = await ensureProduct(picked.title.trim(), picked.category.trim(), picked.id); const project = await api("/api/projects/", { method: "POST", body: JSON.stringify({ name: projectNameFromWizard(product.title), product: product.id, }), }); forgetCache("projects:bundle"); forgetCache("projects:list"); forgetCache("dashboard"); localStorage.setItem("airshelf_current_project_id", project.id); toast("项目已创建", "已写入 Django,进入生产管线"); go( "pipeline.html?project_id=" + encodeURIComponent(project.id) + "&product=" + encodeURIComponent(product.title || picked.title) + "#stage-1" ); } catch (error) { restore(); toast("创建项目失败", error.message); } }; } function money(value) { const n = Number(value || 0); return "¥" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } function plainMoney(value) { const n = Number(value || 0); return "¥" + n.toFixed(2); } function roleUi(role) { if (role === "owner" || role === "super") return { key: "super", label: "超管" }; if (role === "admin") return { key: "admin", label: "团管" }; if (role === "viewer") return { key: "member", label: "访客" }; return { key: "member", label: "成员" }; } function displayName(user) { const raw = String(user?.username || user?.email || "成员").trim(); if (raw.indexOf("@") > 0) return raw.split("@")[0]; return raw || "成员"; } function userPublicId(user) { const id = String(user?.id || "").replace(/-/g, "").toUpperCase(); return id ? "USR-" + id.slice(0, 12) : "USR-UNKNOWN"; } function initial(name) { return String(name || "U").trim().slice(0, 1).toUpperCase() || "U"; } function stageLabel(project) { return { script: "Stage 1 脚本", base_assets: "Stage 2 基础资产", storyboard: "Stage 3 故事板", video: "Stage 4 视频", export: "Stage 5 导出", }[project.current_stage] || "Stage 1 脚本"; } function assetTab(asset) { const category = asset.category || ""; if (category === "person") return "people"; if (category === "scene") return "scenes"; if (category === "product_image") return "products"; if (category === "final_video" || category === "video_clip") return "finals"; if (category === "upload") return "uploads"; return "unclassified"; } function assetMeta(asset, tab) { const source = asset.source === "ai_generated" ? "AI 生成" : asset.source === "exported" ? "导出" : "手动上传"; const type = asset.asset_type === "video" ? "视频" : "图片"; if (tab === "people") return "人物 · " + source + " · 用过 0 次"; if (tab === "scenes") return "场景 · " + source + " · 用过 0 次"; if (tab === "products") return "商品图 · " + source + " · 用过 0 次"; if (tab === "finals") return "成片 · " + type + " · " + dateOnly(asset.created_at); return type + " · " + source + " · " + dateOnly(asset.created_at); } function liveAssetCardHTML(asset, tab) { const file = asset.files && asset.files[0]; const isVideo = asset.asset_type === "video"; const preview = file && file.preview_url; const thumb = preview ? isVideo ? '' : '' : '' + esc(shortLabel(asset.name)) + ""; return ( '
' + '' + '
' + thumb + '
' + esc(asset.name || "未命名资产") + '
' + esc(assetMeta(asset, tab)) + "
" ); } function applyLiveAssetTab(tab) { const assets = window.__airshelfLiveAssets || []; document.querySelectorAll("#asset-tabs .tab").forEach(function (node) { node.classList.toggle("active", node.dataset.tab === tab); }); document.querySelectorAll(".asset-grid[data-tab]").forEach(function (grid) { grid.hidden = grid.dataset.tab !== tab; }); const grid = document.getElementById("grid-" + tab); const queryText = ((document.getElementById("search-input") || {}).value || "").trim().toLowerCase(); let visible = 0; if (grid) { grid.querySelectorAll(".asset-card").forEach(function (card) { const ok = !queryText || (card.dataset.name || "").toLowerCase().indexOf(queryText) >= 0; card.style.display = ok ? "" : "none"; if (ok) visible += 1; }); } const total = assets.filter(function (asset) { return assetTab(asset) === tab; }).length; const meta = document.getElementById("result-meta"); if (meta) meta.innerHTML = '// 显示 ' + visible + " / " + total + " 个资产"; } function renderLiveAssetsPayload(data) { const assets = data.results || []; window.__airshelfLiveAssets = assets; const tabs = ["people", "scenes", "products", "finals", "uploads", "unclassified"]; tabs.forEach(function (tab) { const grid = document.getElementById("grid-" + tab); if (!grid) return; const list = assets.filter(function (asset) { return assetTab(asset) === tab; }); grid.innerHTML = list.length ? list .map(function (asset) { return liveAssetCardHTML(asset, tab); }) .join("") : '
// 当前分类暂无真实资产
'; const count = document.querySelector('#asset-tabs .tab[data-tab="' + tab + '"] .count'); if (count) count.textContent = String(list.length); }); const counts = tabs.reduce(function (acc, tab) { acc[tab] = assets.filter(function (asset) { return assetTab(asset) === tab; }).length; return acc; }, {}); const people = document.getElementById("sub-people"); const scenes = document.getElementById("sub-scenes"); const products = document.getElementById("sub-products"); const finals = document.getElementById("sub-finals"); if (people) people.textContent = String(counts.people || 0); if (scenes) scenes.textContent = String(counts.scenes || 0); if (products) products.textContent = String(counts.products || 0); if (finals) finals.textContent = String(counts.finals || 0); const active = document.querySelector("#asset-tabs .tab.active"); applyLiveAssetTab(active ? active.dataset.tab : "people"); } async function loadLiveAssets() { if (page !== "library.html" || !canUseApi()) return; return loadWithCache( "assets:list", function () { return apiGet("assets:list", "/api/assets/"); }, renderLiveAssetsPayload, "资产加载失败" ); } let assetUploadInFlight = false; async function submitLiveAsset(submit) { if (!canUseApi()) return false; if (assetUploadInFlight) return true; const file = document.getElementById("upload-file")?.files?.[0]; const name = (document.getElementById("upload-name")?.value || "").trim(); const kind = document.getElementById("upload-kind")?.value || "uploads"; if (!file) { toast("请选择文件", "资产上传需要真实文件"); return true; } if (!name) { toast("请填写资产名称", "必填项"); return true; } const categoryMap = { people: "person", scenes: "scene", products: "product_image", finals: "final_video", uploads: "upload", unclassified: "uncategorized", }; const form = new FormData(); form.append("file", file); form.append("name", name); form.append("asset_type", kind === "finals" ? "video" : "image"); form.append("category", categoryMap[kind] || "upload"); assetUploadInFlight = true; const restore = setButtonBusy(submit, "上传中..."); try { await api("/api/assets/upload/", { method: "POST", body: form }); forgetCache("assets:list"); forgetCache("dashboard"); if (window.Shell) Shell.closeModal("upload-modal-bg"); toast("资产已上传", "已写入 TOS 与资产表"); await loadLiveAssets(); } catch (error) { toast("上传失败", error.message); } finally { assetUploadInFlight = false; restore(); } return true; } function wireLiveAssets() { if (page !== "library.html") return; document.querySelectorAll("#asset-tabs .tab").forEach(function (tab) { tab.addEventListener( "click", function (event) { if (!canUseApi()) return; event.preventDefault(); event.stopImmediatePropagation(); applyLiveAssetTab(tab.dataset.tab); }, true ); }); const search = document.getElementById("search-input"); if (search) { search.addEventListener( "input", function (event) { if (!canUseApi()) return; event.stopImmediatePropagation(); const active = document.querySelector("#asset-tabs .tab.active"); applyLiveAssetTab(active ? active.dataset.tab : "people"); }, true ); } const submit = document.getElementById("upload-submit"); if (!submit) return; const submitHandler = function (event) { const button = event.target && event.target.closest ? event.target.closest("#upload-submit") : null; if (!button || !canUseApi()) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); submitLiveAsset(button); }; document.addEventListener("click", submitHandler, true); submit.addEventListener("click", submitHandler, true); } function ledgerStageKey(ledger) { const text = String(ledger.reason || ledger.ledger_type || ledger.metadata?.stage || ledger.metadata?.task_type || "").toLowerCase(); if (text.indexOf("video") >= 0 || text.indexOf("seedance") >= 0 || text.indexOf("视频") >= 0) return "video"; if (text.indexOf("story") >= 0 || text.indexOf("image-2") >= 0 || text.indexOf("故事板") >= 0) return "storyboard"; if (text.indexOf("asset") >= 0 || text.indexOf("image") >= 0 || text.indexOf("基础资产") >= 0) return "asset"; if (text.indexOf("script") >= 0 || text.indexOf("llm") >= 0 || text.indexOf("脚本") >= 0) return "script"; return "other"; } function renderLiveAccountOverview(ledgers, used) { const spendByStage = { video: 0, storyboard: 0, asset: 0, script: 0 }; const daily = {}; ledgers.forEach(function (ledger) { const amount = Math.abs(Math.min(0, Number(ledger.amount || 0))); if (!amount) return; const key = ledgerStageKey(ledger); if (spendByStage[key] !== undefined) spendByStage[key] += amount; const day = dateOnly(ledger.created_at); daily[day] = (daily[day] || 0) + amount; }); const stageRows = [ ["video", "视频片段(Seedance)"], ["storyboard", "故事板(image-2)"], ["asset", "基础资产"], ["script", "脚本 LLM"], ]; const stageTotal = stageRows.reduce(function (sum, row) { return sum + spendByStage[row[0]]; }, 0); const denominator = Math.max(stageTotal, used, 1); const lines = document.querySelectorAll(".stage-pane .usage-line"); const bars = document.querySelectorAll(".stage-pane .usage-bar > span"); stageRows.forEach(function (row, index) { const value = spendByStage[row[0]]; const valueEl = lines[index]?.querySelector(".v"); if (valueEl) valueEl.textContent = money(value); if (bars[index]) bars[index].style.width = Math.min(100, (value / denominator) * 100).toFixed(1) + "%"; }); const totalEl = document.querySelector(".stage-pane .total .v"); if (totalEl) totalEl.textContent = money(used || stageTotal); const days = Object.keys(daily).sort().slice(-14); const values = days.map(function (day) { return daily[day] || 0; }); const sum = values.reduce(function (acc, value) { return acc + value; }, 0); const peak = values.reduce(function (max, value) { return Math.max(max, value); }, 0); const trendSum = document.getElementById("trend-sum"); const trendAvg = document.getElementById("trend-avg"); const trendPeak = document.getElementById("trend-peak"); if (trendSum) trendSum.textContent = money(sum); if (trendAvg) trendAvg.textContent = money(values.length ? sum / values.length : 0); if (trendPeak) trendPeak.textContent = money(peak); } function renderLiveAccountPayload(payload) { const summaryData = payload.summary || {}; const ledgers = payload.ledgers || []; const members = payload.members || []; const projectsData = payload.projects || {}; const productsData = payload.products || {}; const projects = projectsData.results || []; const productMap = {}; (productsData.results || []).forEach(function (product) { productMap[product.id] = product.title; }); const account = summaryData.account || {}; const used = Math.abs(Number(summaryData.charged_total || 0)); const memberLimit = members.reduce(function (sum, member) { return sum + Math.max(0, Number(member.monthly_credit_limit || 0)); }, 0); const limit = memberLimit || Number(account.balance || 0); const left = Math.max(0, limit - used); const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; const hero = document.querySelector(".balance-hero .v"); if (hero) hero.textContent = money(account.balance || 0); const subValues = document.querySelectorAll(".balance-sub .col .v"); if (subValues[0]) subValues[0].textContent = money(limit); if (subValues[1]) subValues[1].textContent = money(used); const subMeta = document.querySelectorAll(".balance-sub .col .meta"); if (subMeta[1]) subMeta[1].textContent = "// 占比 " + pct.toFixed(1) + "% · " + (pct >= 80 ? "注意" : "健康"); const meter = document.querySelector(".balance-meter > span"); if (meter) meter.style.width = pct.toFixed(1) + "%"; const foot = document.querySelectorAll(".balance-foot-meta span"); if (foot[0]) foot[0].textContent = "团队月剩余 " + money(left); if (foot[1]) foot[1].textContent = "使用率 " + pct.toFixed(1) + "%"; renderLiveAccountOverview(ledgers, used); const billsBody = document.getElementById("bills-body"); if (billsBody) { billsBody.innerHTML = ledgers.length ? ledgers .map(function (ledger) { const n = Number(ledger.amount || 0); const cls = n > 0 ? "pos" : n < 0 ? "neg" : "zero"; return ( "" + esc(dateOnly(ledger.created_at)) + '
' + esc(ledger.reason || ledger.ledger_type) + '
' + esc(ledger.ledger_type) + '
' + esc(ledger.id) + 'U成员真实' + plainMoney(n) + "" ); }) .join("") : '// 暂无真实账单流水'; } const billsCount = document.getElementById("bills-count"); if (billsCount) billsCount.textContent = String(ledgers.length); const projectBody = document.getElementById("proj-body"); if (projectBody) { projectBody.innerHTML = projects.length ? projects .map(function (project) { const status = statusBucket(project); return ( '' + esc(project.name) + '' + esc(productMap[project.product] || "未命名商品") + '' + initial(members[0]?.user?.username) + "" + esc(members[0]?.user?.username || "成员") + '' + esc(stageLabel(project)) + '' + esc(projectStatusLabel(project)) + '¥0.00' ); }) .join("") : '// 暂无真实项目'; } const projCount = document.getElementById("proj-count"); if (projCount) projCount.innerHTML = '共 ' + projects.length + " 个项目 · 消耗 " + money(used); const memberBody = document.getElementById("member-body"); if (memberBody) { memberBody.innerHTML = members.length ? members .map(function (member) { const role = roleUi(member.role); const name = member.user?.username || member.user?.email || "成员"; const limitText = Number(member.monthly_credit_limit || 0) > 0 ? money(member.monthly_credit_limit) : "不限"; return ( '' + initial(name) + "" + esc(name) + '' + role.label + "0" + money(0) + " / " + limitText + '真实成员表' ); }) .join("") : '// 暂无成员'; } const memCount = document.getElementById("mem-count"); if (memCount) memCount.innerHTML = '共 ' + members.length + " 人 · 合计 " + money(used); document.querySelectorAll(".billing-tabs .tab .count").forEach(function (count) { const tab = count.closest(".tab")?.dataset.tab; if (tab === "by-project") count.textContent = String(projects.length); if (tab === "by-member") count.textContent = String(members.length); if (tab === "bills") count.textContent = String(ledgers.length); }); } async function loadLiveAccount() { if (page !== "account.html" || !canUseApi()) return; seedAccountBundleFromCache(); return loadWithCache( "account:bundle", async function () { const summaryData = await apiGet("billing:summary", "/api/billing/summary/"); writeCache("billing:summary", summaryData); const ledgers = await apiGet("billing:ledgers", "/api/billing/ledgers/"); const members = await apiGet("team:members", "/api/auth/team/members/"); writeCache("team:members", members); const projectsData = await apiGet("projects:list", "/api/projects/"); const productsData = await apiGet("products:list", "/api/products/"); writeCache("products:list", productsData); return { summary: summaryData, ledgers: ledgers, members: members, projects: projectsData, products: productsData }; }, renderLiveAccountPayload, "账户数据加载失败" ); } function parseMoneyText(text) { const n = Number(String(text || "").replace(/[^\d.-]/g, "")); return Number.isFinite(n) ? n : 0; } function liveTopupPayload() { const amount = parseMoneyText(document.getElementById("topup-amt")?.textContent || ""); const bonusText = document.getElementById("topup-bonus")?.textContent || ""; const bonusMatch = bonusText.match(/含\s*¥?([\d,.]+)/); const channelText = document.getElementById("topup-channel-label")?.textContent || ""; return { amount: amount, bonus: bonusMatch ? parseMoneyText(bonusMatch[1]) : 0, channel: channelText.indexOf("支付宝") >= 0 ? "alipay" : "wechat", }; } async function submitLiveTopup() { const payload = liveTopupPayload(); if (payload.amount <= 0) { toast("充值金额不正确", "请重新选择金额"); return; } try { await api("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload), }); if (window.Shell) Shell.closeModal("topup-bg"); forgetCache("billing:summary"); forgetCache("billing:ledgers"); forgetCache("account:bundle"); forgetCache("dashboard"); toast("充值成功", "已写入 Django 账本"); await loadLiveAccount(); } catch (error) { toast("充值确认失败", error.message); } } function wireLiveAccount() { if (page !== "account.html" || window.__airshelfLiveAccountWired) return; window.__airshelfLiveAccountWired = true; const fallbackTopupDone = window.topupDone; window.topupDone = function () { if (!canUseApi()) { if (typeof fallbackTopupDone === "function") fallbackTopupDone(); return; } submitLiveTopup(); }; } function liveTeamMemberRowHTML(member) { const name = member.user?.first_name || member.user?.username || member.user?.email || "成员"; const role = roleUi(member.role); const monthly = Number(member.monthly_credit_limit || 0); const usedPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0; const isOwner = member.role === "owner"; const actions = isOwner ? '不可编辑' : ''; return ( '' + initial(name) + '' + esc(name) + '' + esc(member.user?.email || "") + '' + role.label + '不限' + (monthly > 0 ? money(monthly) : "不限") + '
¥0.00 / ' + usedPct.toFixed(0) + '%
' + actions + "
" ); } function renderLiveTeamMembers(filterText) { const tbody = document.getElementById("members-tbody"); if (!tbody) return; const members = window.__airshelfLiveTeamMembers || []; const needle = String(filterText || "").trim().toLowerCase(); const list = members.filter(function (member) { const name = member.user?.first_name || member.user?.username || ""; const email = member.user?.email || ""; return !needle || (name + " " + email).toLowerCase().indexOf(needle) >= 0; }); tbody.innerHTML = list.length ? list.map(liveTeamMemberRowHTML).join("") : '// 没有匹配的真实成员'; const headingCount = document.querySelector(".members-table")?.closest(".pane")?.querySelector("h3 .ct"); if (headingCount) headingCount.textContent = "// " + list.length + " / " + members.length + " 人 · 真实团队表"; } function liveMemberById(id) { return (window.__airshelfLiveTeamMembers || []).find(function (member) { return String(member.id) === String(id); }); } function selectedRoleValue(selector, fallback) { const selected = document.querySelector(selector + " .role-choice.selected"); return selected?.dataset.role || selected?.dataset.editRole || fallback || "member"; } async function refreshLiveTeamAfterMutation() { forgetCache("team:members"); forgetCache("team:bundle"); forgetCache("account:bundle"); await loadLiveTeam(); } async function submitLiveTeamMember(button) { const username = (document.getElementById("inv-username")?.value || "").trim(); const password = (document.getElementById("inv-password")?.value || "").trim(); const name = (document.getElementById("inv-name")?.value || "").trim() || username; const monthly = Number(document.getElementById("inv-monthly")?.value || 0); if (!username || !password) { toast("请填写用户名和密码", "成员未创建"); return; } const restore = setButtonBusy(button, "创建中..."); try { const member = await api("/api/auth/team/members/", { method: "POST", body: JSON.stringify({ username: username, password: password, name: name, role: selectedRoleValue("#invite-bg", "member"), monthly_credit_limit: Number.isFinite(monthly) ? monthly : 0, }), }); if (window.Shell) Shell.closeModal("invite-bg"); const shareUser = document.getElementById("share-username"); const sharePassword = document.getElementById("share-password"); if (shareUser) shareUser.textContent = username; if (sharePassword) sharePassword.textContent = password; if (window.Shell) Shell.openModal("invite-share-bg"); toast("账户已创建", (member.user?.username || username) + " · 已写入团队表"); await refreshLiveTeamAfterMutation(); } catch (error) { toast("创建成员失败", error.message); } finally { restore(); } } function openLiveTeamEdit(memberId) { const member = liveMemberById(memberId); if (!member) return; window.__airshelfEditingMemberId = memberId; const name = member.user?.first_name || member.user?.username || ""; const title = document.getElementById("edit-username"); const input = document.getElementById("edit-name-readonly"); if (title) title.textContent = name; if (input) input.value = name; document.querySelectorAll("#edit-role-choices .role-choice").forEach(function (choice) { choice.classList.toggle("selected", choice.dataset.editRole === member.role || (member.role === "owner" && choice.dataset.editRole === "super")); }); const daily = document.getElementById("edit-daily"); const monthly = document.getElementById("edit-monthly"); const total = document.getElementById("edit-total"); if (daily) daily.value = "-1"; if (monthly) monthly.value = Number(member.monthly_credit_limit || 0); if (total) total.value = "-1"; if (window.Shell) Shell.openModal("edit-member-bg"); } async function submitLiveTeamMemberEdit(button) { const memberId = window.__airshelfEditingMemberId; const monthly = Number(document.getElementById("edit-monthly")?.value || 0); const restore = setButtonBusy(button, "保存中..."); try { await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/", { method: "PATCH", body: JSON.stringify({ name: (document.getElementById("edit-name-readonly")?.value || "").trim(), role: selectedRoleValue("#edit-role-choices", "member"), monthly_credit_limit: Number.isFinite(monthly) ? monthly : 0, }), }); window.__airshelfEditingMemberId = null; if (window.Shell) Shell.closeModal("edit-member-bg"); toast("成员已保存", "已写入 Django 团队表"); await refreshLiveTeamAfterMutation(); } catch (error) { toast("保存成员失败", error.message); } finally { restore(); } } function openLiveTeamPassword(memberId) { const member = liveMemberById(memberId); if (!member) return; window.__airshelfPasswordMemberId = memberId; const name = member.user?.first_name || member.user?.username || "成员"; const title = document.getElementById("reset-pwd-name"); if (title) title.textContent = name; const input = document.getElementById("reset-pwd-input"); if (input && !input.value.trim()) input.value = "Airshelf" + Math.floor(100000 + Math.random() * 900000); if (window.Shell) Shell.openModal("reset-pwd-bg"); } async function submitLiveTeamPassword(button) { const memberId = window.__airshelfPasswordMemberId; const password = (document.getElementById("reset-pwd-input")?.value || "").trim(); const restore = setButtonBusy(button, "重置中..."); try { await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/password/", { method: "POST", body: JSON.stringify({ password: password }), }); window.__airshelfPasswordMemberId = null; if (window.Shell) Shell.closeModal("reset-pwd-bg"); toast("密码已重置", "成员旧会话已失效"); } catch (error) { toast("重置密码失败", error.message); } finally { restore(); } } async function removeLiveTeamMember(memberId) { const member = liveMemberById(memberId); const name = member?.user?.first_name || member?.user?.username || "成员"; if (!confirm("确定将「" + name + "」移出团队?")) return; try { await api("/api/auth/team/members/" + encodeURIComponent(memberId) + "/", { method: "DELETE" }); toast("成员已移出", name); await refreshLiveTeamAfterMutation(); } catch (error) { toast("移出失败", error.message); } } function runLiveTeamAction(action, memberId) { if (action === "edit") openLiveTeamEdit(memberId); if (action === "password") openLiveTeamPassword(memberId); if (action === "remove") removeLiveTeamMember(memberId); } function wireLiveTeam() { if (page !== "team.html" || window.__airshelfLiveTeamWired) return; window.__airshelfLiveTeamWired = true; const search = document.getElementById("member-search"); if (search) { search.addEventListener( "input", function (event) { if (!canUseApi()) return; event.stopImmediatePropagation(); renderLiveTeamMembers(search.value); }, true ); } document.addEventListener( "click", function (event) { if (!canUseApi()) return; const actionButton = event.target && event.target.closest ? event.target.closest("[data-live-team-action]") : null; if (actionButton) { event.preventDefault(); event.stopImmediatePropagation(); runLiveTeamAction(actionButton.dataset.liveTeamAction, actionButton.dataset.memberId); return; } const createButton = event.target && event.target.closest ? event.target.closest("#inv-send") : null; if (createButton) { event.preventDefault(); event.stopImmediatePropagation(); submitLiveTeamMember(createButton); return; } const editButton = event.target && event.target.closest ? event.target.closest("#edit-save") : null; if (editButton && window.__airshelfEditingMemberId) { event.preventDefault(); event.stopImmediatePropagation(); submitLiveTeamMemberEdit(editButton); return; } const pwdButton = event.target && event.target.closest ? event.target.closest("#reset-pwd-confirm") : null; if (pwdButton && window.__airshelfPasswordMemberId) { event.preventDefault(); event.stopImmediatePropagation(); submitLiveTeamPassword(pwdButton); } }, true ); } function renderLiveTeamActivity(members) { const feed = document.querySelector(".team-feed .feed-list"); if (feed) { const count = members.length; feed.innerHTML = '
Q
真实团队表已同步' + esc(count) + ' 名成员
local cache
'; } const feedCount = document.querySelector(".team-feed .h .ct"); if (feedCount) feedCount.textContent = "// 真实动态接口待接入"; const more = document.getElementById("open-feed-all"); if (more) more.hidden = true; const allList = document.getElementById("feed-all-list"); if (allList) allList.innerHTML = '
// 暂无真实团队动态
'; const allCount = document.getElementById("feed-all-count"); if (allCount) allCount.textContent = "// 共 0 条"; const allMeta = document.getElementById("feed-all-meta"); if (allMeta) allMeta.textContent = "// 共 0 条"; } function renderLiveTeamPayload(payload) { const meData = payload.me || {}; const summaryData = payload.summary || {}; const members = payload.members || []; rememberAuthShape(meData); window.__airshelfLiveTeamMembers = members; const account = summaryData.account || {}; const used = Number(summaryData.charged_total || 0); const limit = members.reduce(function (sum, member) { return sum + Math.max(0, Number(member.monthly_credit_limit || 0)); }, 0) || Number(account.balance || 0); const left = Math.max(0, limit - used); const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; const nameEl = document.querySelector(".banner-id .nm"); if (nameEl) nameEl.innerHTML = esc(meData.team?.name || "团队") + ' 企业'; const metaEl = document.querySelector(".banner-id .meta"); if (metaEl) metaEl.textContent = "// 团队 ID: " + (meData.team?.id || "-") + " · " + members.length + " 名成员"; const statValues = document.querySelectorAll(".banner-stats .stat .v"); if (statValues[0]) statValues[0].textContent = money(account.balance || 0); if (statValues[1]) statValues[1].textContent = money(limit); if (statValues[2]) statValues[2].textContent = money(used); if (statValues[3]) statValues[3].textContent = money(left); const usedSub = document.getElementById("stat-used-sub"); const leftSub = document.getElementById("stat-left-sub"); if (usedSub) usedSub.textContent = "// 占月限 " + pct.toFixed(1) + "%"; if (leftSub) leftSub.textContent = "// 还可生成约 " + Math.max(0, Math.round(left / 10)) + " 个项目"; renderLiveTeamActivity(members); renderLiveTeamMembers((document.getElementById("member-search") || {}).value || ""); } async function loadLiveTeam() { if (page !== "team.html" || !canUseApi()) return; seedTeamBundleFromCache(); return loadWithCache( "team:bundle", async function () { const meData = await apiGet("auth:me", "/api/auth/me/"); writeCache("auth:me", meData); const summaryData = await apiGet("billing:summary", "/api/billing/summary/"); writeCache("billing:summary", summaryData); const members = await apiGet("team:members", "/api/auth/team/members/"); writeCache("team:members", members); return { me: meData, summary: summaryData, members: members }; }, renderLiveTeamPayload, "团队数据加载失败" ); } function renderLivePipelinePayload(payload) { const project = payload.project; const productsData = payload.products || {}; if (!project) return; const productMap = {}; (productsData.results || []).forEach(function (product) { productMap[product.id] = product; }); localStorage.setItem("airshelf_current_project_id", project.id); const product = productMap[project.product] || {}; const productName = product.title || "未命名商品"; const title = document.querySelector(".pipeline-topbar-title"); if (title) { title.innerHTML = '
' + esc(project.name) + '
// ' + esc(productName) + " · " + esc(projectStatusLabel(project)) + "
"; } ["asset-prod-name", "asset-prod-card-name"].forEach(function (id) { const el = document.getElementById(id); if (el) el.textContent = productName; }); const thumb = document.getElementById("asset-prod-thumb-label"); if (thumb) thumb.textContent = productName + " · 主图"; const cat = document.querySelector("#asset-prod-card .prod-cat"); if (cat) cat.textContent = product.category || "未分类"; const date = document.querySelector("#asset-prod-card .prod-date"); if (date) date.textContent = dateOnly(product.created_at) + " 创建"; const activeNo = stageNo(project); document.querySelectorAll("#stage-pill .sp-dot").forEach(function (dot) { const no = Number(dot.dataset.stage); dot.classList.toggle("done", project.status === "completed" || no < activeNo); dot.classList.toggle("active", no === activeNo && project.status !== "completed"); dot.classList.toggle("fail", project.status === "failed" && no === activeNo); }); document.querySelectorAll("#stage-pill .sp-line").forEach(function (line, index) { line.classList.toggle("done", index + 1 < activeNo || project.status === "completed"); }); } async function loadLivePipeline() { if (page !== "pipeline.html" || !canUseApi()) return; const projectId = query.get("project_id") || localStorage.getItem("airshelf_current_project_id"); return loadWithCache( "pipeline:" + (projectId || "latest"), async function () { const productsData = await apiGet("products:list", "/api/products/"); writeCache("products:list", productsData); let project = null; if (projectId) project = await apiGet("project:" + projectId, "/api/projects/" + encodeURIComponent(projectId) + "/"); if (!project) { const data = await apiGet("projects:list", "/api/projects/"); project = (data.results || [])[0]; } return new Promise(function (resolve) { setTimeout(function () { resolve({ products: productsData, project: project }); }, 120); }); }, renderLivePipelinePayload, "Pipeline 数据加载失败" ); } function monthStart() { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), 1); } function isThisMonth(value) { if (!value) return false; const date = new Date(value); return !Number.isNaN(date.getTime()) && date >= monthStart(); } function moneyHTML(value) { const text = money(value).replace("¥", ""); const parts = text.split("."); return "¥" + esc(parts[0]) + (parts[1] ? "." + esc(parts[1]) + "" : ""); } function dashboardRecentRowHTML(project, productMap) { const product = productMap[project.product] || {}; const productName = product.title || "未命名商品"; const href = projectHref(project); const shots = (project.video_segments && project.video_segments.length) || 4; return ( '
9:16
' + esc(project.name || "未命名项目") + '
' + esc(productName) + " / AI 全生 / " + esc(shots) + ' 镜
' + progressHTML(project) + '
' + esc(projectStatusLabel(project)) + '' + (project.status === "completed" ? "打开" : "继续") + "
" ); } function renderLiveDashboardPayload(payload) { const meData = payload.me || {}; const summaryData = payload.summary || {}; const productsData = payload.products || {}; const projectsData = payload.projects || {}; const assetsData = payload.assets || {}; rememberAuthShape(meData); const projects = projectsData.results || []; const products = productsData.results || []; const assets = assetsData.results || []; const productMap = {}; products.forEach(function (product) { productMap[product.id] = product; }); const wip = projects.filter(function (project) { return project.status !== "completed" && project.status !== "failed"; }).length; const done = projects.filter(function (project) { return project.status === "completed"; }).length; const monthDone = projects.filter(function (project) { return project.status === "completed" && isThisMonth(project.updated_at || project.created_at); }).length; const failed = projects.filter(function (project) { return project.status === "failed"; }).length; const balance = Number(summaryData.account?.balance || 0); const charged = Number(summaryData.charged_total || 0); const budget = Math.max(balance + charged, 1); const budgetPct = Math.min(100, (charged / budget) * 100); const h1 = document.querySelector(".page-head h1"); if (h1) h1.textContent = "欢迎回来," + displayName(meData.user); const sub = document.querySelector(".page-head .sub"); if (sub) { const now = new Date(); const day = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" }).format(now); sub.innerHTML = '// ' + esc(day.replace(/\//g, ".")) + '·你有 ' + esc(wip) + " 个项目 正在进行中"; } const stats = document.querySelectorAll(".stats .stat"); if (stats[0]) { const v = stats[0].querySelector(".v"); const d = stats[0].querySelector(".delta"); if (v) v.textContent = String(projects.length); if (d) d.textContent = "本月完成 " + monthDone; } if (stats[1]) { const v = stats[1].querySelector(".v"); const d = stats[1].querySelector(".delta"); if (v) v.textContent = String(wip); if (d) d.textContent = failed ? failed + " 个失败需处理" : "全部正常推进"; } if (stats[2]) { const v = stats[2].querySelector(".v"); const d = stats[2].querySelector(".delta"); if (v) v.textContent = String(monthDone || done); if (d) d.textContent = "累计完成 " + done; } if (stats[3]) { const v = stats[3].querySelector(".v"); const bar = stats[3].querySelector(".bar span"); const subText = stats[3].querySelector(".sub"); if (v) v.innerHTML = moneyHTML(balance); if (bar) bar.style.width = budgetPct.toFixed(0) + "%"; if (subText) subText.textContent = "已用 " + money(charged) + " / " + money(budget); } const more = document.querySelector(".dash-grid .section-h .more[href='projects.html']"); if (more) more.textContent = "[ ALL · " + projects.length + " ]"; const recentBox = document.querySelector(".card-hard"); if (recentBox) { const recent = projects.slice(0, 5); recentBox.innerHTML = recent.length ? recent .map(function (project) { return dashboardRecentRowHTML(project, productMap); }) .join("") : '
// 当前团队还没有真实项目
'; } const shortcutData = [ products.length + " SKU", "资产 " + assets.length + " 个", money(balance), projects.length + " 个", ]; document.querySelectorAll(".shortcut .d").forEach(function (el, index) { if (shortcutData[index]) el.textContent = shortcutData[index]; }); } async function loadLiveDashboard() { if (page !== "index.html" || !canUseApi()) return; return loadWithCache( "dashboard", async function () { const meData = await apiGet("auth:me", "/api/auth/me/"); writeCache("auth:me", meData); const summaryData = await apiGet("billing:summary", "/api/billing/summary/"); writeCache("billing:summary", summaryData); const productsData = await apiGet("products:list", "/api/products/"); writeCache("products:list", productsData); const projectsData = await apiGet("projects:list", "/api/projects/"); const assetsData = await apiGet("assets:list", "/api/assets/"); writeCache("assets:list", assetsData); return { me: meData, summary: summaryData, products: productsData, projects: projectsData, assets: assetsData }; }, renderLiveDashboardPayload, "工作台数据加载失败" ); } function settingsRowByLabel(label) { return Array.from(document.querySelectorAll("#sec-profile .form-row")).find(function (row) { const lbl = row.querySelector(".lbl"); return lbl && lbl.textContent.indexOf(label) >= 0; }); } function collectSettingsPrefs() { const fields = {}; document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(function (el) { if (!el.id) return; fields[el.id] = el.type === "checkbox" ? Boolean(el.checked) : el.value; }); const choices = {}; ["pref-template", "pref-duration", "pref-subtitle"].forEach(function (id) { const selected = document.querySelector("#" + id + " .selected"); if (selected) choices[id] = selected.dataset.v || selected.textContent.trim(); }); const avatar = document.getElementById("prof-avatar-preview"); return { fields: fields, choices: choices, avatarText: avatar ? avatar.textContent.trim() : "", }; } function applySettingsPrefs(prefs) { if (!prefs) return; Object.keys(prefs.fields || {}).forEach(function (id) { const el = document.getElementById(id); if (!el) return; if (el.type === "checkbox") el.checked = Boolean(prefs.fields[id]); else el.value = prefs.fields[id]; }); Object.keys(prefs.choices || {}).forEach(function (id) { const value = prefs.choices[id]; document.querySelectorAll("#" + id + " .pref-choice, #" + id + " .dur-chip").forEach(function (node) { const nodeValue = node.dataset.v || node.textContent.trim(); node.classList.toggle("selected", nodeValue === value); }); }); if (prefs.avatarText) { ["prof-avatar-preview", "av-up-preview"].forEach(function (id) { const el = document.getElementById(id); if (el) el.textContent = prefs.avatarText; }); } } function renderLiveSettingsPayload(payload) { const meData = payload.me || {}; const members = payload.members || []; rememberAuthShape(meData); const user = meData.user || {}; const team = meData.team || {}; const name = displayName(user); const email = user.email || ""; const phone = user.phone || ""; const avatar = initial(name); const member = members.find(function (item) { return item.user?.id === user.id || item.user?.email === email; }) || members[0] || {}; const role = roleUi(member.role); const avatarEl = document.getElementById("prof-avatar-preview"); if (avatarEl) avatarEl.textContent = avatar; const avatarUp = document.getElementById("av-up-preview"); if (avatarUp) avatarUp.textContent = avatar; const avatarName = document.getElementById("av-up-preview-name"); if (avatarName) avatarName.textContent = "当前头像 · 默认"; const avatarInfo = document.getElementById("av-up-preview-info"); if (avatarInfo) avatarInfo.textContent = "// 系统生成 · 取显示名称首字"; const nameInput = document.getElementById("prof-name"); const emailInput = document.getElementById("prof-email"); const phoneInput = document.getElementById("prof-phone"); if (nameInput) nameInput.value = name; if (emailInput) emailInput.value = email; if (phoneInput) phoneInput.value = phone; const emailBtn = emailInput?.closest(".val")?.querySelector("button"); if (emailBtn) emailBtn.onclick = function () { Shell.toast("已发送验证邮件", email || "当前邮箱"); }; const phoneBtn = phoneInput?.closest(".val")?.querySelector("button"); if (phoneBtn) phoneBtn.onclick = function () { Shell.toast("已发送短信验证码", phone || "未绑定手机号"); }; const teamRow = settingsRowByLabel("所属团队"); if (teamRow) { const staticEl = teamRow.querySelector(".static"); const roleEl = teamRow.querySelector(".role-tag"); if (staticEl) staticEl.textContent = team.name || "团队"; if (roleEl) roleEl.innerHTML = '' + esc(role.label) + (member.role === "owner" ? " · 创建者" : ""); } const idRow = settingsRowByLabel("用户 ID"); if (idRow) { const idEl = idRow.querySelector(".static"); if (idEl) idEl.textContent = userPublicId(user); } applySettingsPrefs(readCache("settings:prefs")); } function wireLiveSettings() { if (page !== "settings.html") return; const save = document.getElementById("save-btn"); if (save) { save.addEventListener("click", function () { setTimeout(function () { const invalid = document.querySelector("#sec-profile .input.invalid"); if (!invalid) { writeCache("settings:prefs", collectSettingsPrefs()); toast("本地设置已更新", "已写入 localStorage, 下次访问会先显示本地数据"); } }, 0); }); } const reset = document.getElementById("prof-avatar-reset"); if (reset) { reset.addEventListener("click", function () { setTimeout(function () { const current = readCache("settings:profile"); if (current) renderLiveSettingsPayload(current); }, 0); }); } } async function loadLiveSettings() { if (page !== "settings.html" || !canUseApi()) return; if (!readCache("settings:profile")) { const me = cachedAuthShape(); if (me) { writeCache("settings:profile", { me: me, summary: readCache("billing:summary"), members: readCache("team:members") || [], }); } } return loadWithCache( "settings:profile", async function () { const meData = await apiGet("auth:me", "/api/auth/me/"); writeCache("auth:me", meData); const summaryData = await apiGet("billing:summary", "/api/billing/summary/").catch(function () { return null; }); if (summaryData) writeCache("billing:summary", summaryData); const members = await apiGet("team:members", "/api/auth/team/members/").catch(function () { return []; }); writeCache("team:members", members); return { me: meData, summary: summaryData, members: members }; }, renderLiveSettingsPayload, "设置数据加载失败" ); } const messageTypeLabel = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" }; const messageTypeIcon = { task: "clapperboard", team: "users", billing: "creditCard", system: "info" }; const messagePriorityLabel = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" }; function messageIcon(name) { return window.IconKit && typeof window.IconKit.svg === "function" ? window.IconKit.svg(name) : ""; } function messageDate(value) { const date = value ? new Date(value) : new Date(); return Number.isNaN(date.getTime()) ? new Date() : date; } function messageTimeAgo(value) { const diff = Math.max(0, Date.now() - messageDate(value).getTime()); const minutes = Math.floor(diff / 60000); if (minutes < 1) return "刚刚"; if (minutes < 60) return minutes + "m"; if (minutes < 1440) return Math.floor(minutes / 60) + "h"; return Math.floor(minutes / 1440) + "d"; } function messageFullTime(value) { const date = messageDate(value); const z = function (n) { return String(n).padStart(2, "0"); }; return date.getFullYear() + "-" + z(date.getMonth() + 1) + "-" + z(date.getDate()) + " " + z(date.getHours()) + ":" + z(date.getMinutes()); } function notificationType(item) { return item.type || item.notification_type || "system"; } function notificationUnread(item) { if (item.unread !== undefined) return Boolean(item.unread); return item.is_read !== true; } function normalizeNotification(item) { const metadata = item.metadata || {}; const type = notificationType(item); return { id: String(item.id || item.dedupe_key || item.title || ""), type: type, priority: item.priority || metadata.priority || "info", unread: notificationUnread(item), title: item.title || "系统消息", brief: item.brief || metadata.brief || "", body: item.body || item.brief || metadata.body || "暂无详情。", source: item.source || metadata.source || "Airshelf", project: item.project_name || metadata.project_name || metadata.project || "系统", stage: item.stage || metadata.stage || "通知", owner: item.owner_label || metadata.owner || metadata.owner_label || "系统", cost: item.cost_label || metadata.cost || metadata.cost_label || "-", href: item.related_url || metadata.href || metadata.related_url || "", time: item.created_at || item.updated_at || item.time || new Date().toISOString(), metadata: metadata, raw: item, }; } function normalizeNotificationsPayload(data) { const rows = listResults(data).map(normalizeNotification).filter(function (item) { return item.id; }); const unread = unreadNotificationCount(data); return { count: collectionCount(data) ?? rows.length, unread_count: unread === null || unread === undefined ? rows.filter(function (item) { return item.unread; }).length : unread, results: rows, }; } function notificationCachePayload(messages) { const rows = (messages || []).map(function (message) { return { id: message.id, type: message.type, notification_type: message.type, priority: message.priority, title: message.title, brief: message.brief, body: message.body, source: message.source, project_name: message.project, stage: message.stage, owner_label: message.owner, cost_label: message.cost, related_url: message.href, is_read: !message.unread, unread: message.unread, created_at: message.time, metadata: message.metadata || {}, }; }); return { count: rows.length, next: null, previous: null, unread_count: rows.filter(function (item) { return item.unread; }).length, results: rows, }; } function persistLiveMessages(messages) { const payload = notificationCachePayload(messages || []); delete requestMemo["notifications:list"]; writeCache("notifications:list", payload); writeCache("notifications:summary", { count: payload.count, unread_count: payload.unread_count }); return payload; } function liveMessageState() { if (!window.__airshelfLiveMessageState) { window.__airshelfLiveMessageState = { tab: "all", q: "", selectedId: null, showLogId: null }; } return window.__airshelfLiveMessageState; } function liveMessages() { return Array.isArray(window.__airshelfLiveMessages) ? window.__airshelfLiveMessages : []; } function liveVisibleMessages() { const state = liveMessageState(); const q = String(state.q || "").trim().toLowerCase(); return liveMessages().filter(function (message) { if (state.tab === "unread" && !message.unread) return false; if (!["all", "unread"].includes(state.tab) && message.type !== state.tab) return false; if (!q) return true; return [message.title, message.brief, message.body, message.source, message.project, message.stage] .join(" ") .toLowerCase() .indexOf(q) >= 0; }); } function liveMessageCounts() { const rows = liveMessages(); return { all: rows.length, unread: rows.filter(function (message) { return message.unread; }).length, task: rows.filter(function (message) { return message.type === "task"; }).length, team: rows.filter(function (message) { return message.type === "team"; }).length, billing: rows.filter(function (message) { return message.type === "billing"; }).length, system: rows.filter(function (message) { return message.type === "system"; }).length, }; } function liveMessageTimeline(message) { if (Array.isArray(message.metadata?.timeline)) return message.metadata.timeline; const created = messageFullTime(message.time); const rows = [[created, "通知写入团队消息中心"]]; if (!message.unread) rows.push([messageFullTime(message.raw?.read_at || message.time), "当前用户已读"]); return rows; } function renderLiveMessageFilters() { const box = document.getElementById("msg-filters"); if (!box) return; const state = liveMessageState(); const c = liveMessageCounts(); const filters = [ ["all", "全部", c.all], ["unread", "未读", c.unread], ["task", "任务", c.task], ["team", "团队", c.team], ["billing", "计费", c.billing], ["system", "系统", c.system], ]; box.innerHTML = filters .map(function (filter) { return ( '" ); }) .join(""); box.querySelectorAll("[data-live-tab]").forEach(function (button) { button.addEventListener("click", function (event) { event.preventDefault(); event.stopImmediatePropagation(); state.tab = button.dataset.liveTab || "all"; renderLiveMessages(); }); }); } function renderLiveMessageList() { const listEl = document.getElementById("msg-list"); const countEl = document.getElementById("msg-list-count"); if (!listEl) return; const state = liveMessageState(); const list = liveVisibleMessages(); if (countEl) countEl.textContent = "// 显示 " + list.length + " 条"; if (!list.length) { listEl.innerHTML = '
' + messageIcon("search") + "没有符合条件的消息
"; return; } listEl.innerHTML = list .map(function (message) { return ( '" ); }) .join(""); listEl.querySelectorAll("[data-live-message-id]").forEach(function (button) { button.addEventListener("click", function (event) { event.preventDefault(); event.stopImmediatePropagation(); selectLiveMessage(button.dataset.liveMessageId); }); }); } function renderLiveMessageDetail() { const detail = document.getElementById("msg-detail"); if (!detail) return; const state = liveMessageState(); const list = liveMessages(); let message = list.find(function (item) { return item.id === state.selectedId; }); if (!message) message = liveVisibleMessages()[0] || list[0]; if (!message) { state.selectedId = null; detail.innerHTML = '
' + messageIcon("bell") + "
暂无消息
"; return; } state.selectedId = message.id; const props = [ ["来源", message.source], ["类别", messageTypeLabel[message.type] || "系统"], ["项目", message.project], ["阶段", message.stage], ["负责人", message.owner], ["费用", message.cost], ["时间", messageFullTime(message.time)], ["关联资源", message.href ? '' + esc(message.project) + " →" : "无"], ] .map(function (row) { return '' + esc(row[0]) + '' + row[1] + ""; }) .join(""); const timeline = liveMessageTimeline(message) .map(function (row) { return '
' + esc(row[0]) + '' + esc(row[1]) + "
"; }) .join(""); const primaryAction = message.href ? '' : ''; const readAction = message.unread ? '' : ''; detail.innerHTML = '
' + messageIcon(messageTypeIcon[message.type] || "info") + '

' + esc(message.title) + '

' + esc(message.source) + "// " + esc(messageTypeLabel[message.type] || "系统") + "" + esc(messageFullTime(message.time)) + '
' + esc(messagePriorityLabel[message.priority] || "更新") + '

' + esc(message.body) + '

' + props + '
处理记录
' + timeline + '
' + readAction + primaryAction + "
"; detail.querySelectorAll("[data-live-action]").forEach(function (button) { button.addEventListener("click", function (event) { event.preventDefault(); event.stopImmediatePropagation(); runLiveMessageAction(message.id, button.dataset.liveAction, button); }); }); } function renderLiveMessages() { const head = document.getElementById("msg-head-sub"); const counts = liveMessageCounts(); if (head) head.textContent = "// " + counts.unread + " 条未读 · " + counts.all + " 条总计"; renderLiveMessageFilters(); renderLiveMessageList(); renderLiveMessageDetail(); persistLiveMessages(liveMessages()); } function setLiveMessageReadState(id, unread, response) { const rows = liveMessages(); const next = response ? normalizeNotification(response) : null; const index = rows.findIndex(function (item) { return item.id === id; }); if (index < 0) return; if (next) rows[index] = next; else rows[index].unread = unread; window.__airshelfLiveMessages = rows; renderLiveMessages(); } async function markLiveMessageRead(id) { const message = liveMessages().find(function (item) { return item.id === id; }); if (!message || !message.unread) return; setLiveMessageReadState(id, false); try { const response = await api("/api/ops/notifications/" + encodeURIComponent(id) + "/mark-read/", { method: "POST", body: JSON.stringify({}), }); setLiveMessageReadState(id, false, response); } catch (error) { setLiveMessageReadState(id, true); toast("已读状态保存失败", error.message); } } async function markLiveMessageUnread(id) { setLiveMessageReadState(id, true); try { const response = await api("/api/ops/notifications/" + encodeURIComponent(id) + "/mark-unread/", { method: "POST", body: JSON.stringify({}), }); setLiveMessageReadState(id, true, response); } catch (error) { setLiveMessageReadState(id, false); toast("未读状态保存失败", error.message); } } function selectLiveMessage(id) { const state = liveMessageState(); state.selectedId = id; renderLiveMessages(); markLiveMessageRead(id); } async function archiveLiveMessage(id, button) { const message = liveMessages().find(function (item) { return item.id === id; }); if (!message) return; const restore = setButtonBusy(button, "归档中..."); try { await api("/api/ops/notifications/" + encodeURIComponent(id) + "/archive/", { method: "POST", body: JSON.stringify({}), }); window.__airshelfLiveMessages = liveMessages().filter(function (item) { return item.id !== id; }); const state = liveMessageState(); state.selectedId = liveMessages()[0]?.id || null; renderLiveMessages(); toast("已归档", message.title); } catch (error) { toast("归档失败", error.message); } finally { restore(); } } async function markAllLiveMessagesRead(button) { const rows = liveMessages(); if (!rows.length) return; const restore = setButtonBusy(button, "保存中..."); try { await api("/api/ops/notifications/mark-all-read/", { method: "POST", body: JSON.stringify({}), }); rows.forEach(function (message) { message.unread = false; }); window.__airshelfLiveMessages = rows; renderLiveMessages(); toast("已全部标为已读", rows.length + " 条消息已写入数据库"); } catch (error) { toast("批量更新失败", error.message); } finally { restore(); } } function runLiveMessageAction(id, action, button) { const message = liveMessages().find(function (item) { return item.id === id; }); if (!message) return; if (action === "goto") { go(message.href || "settings.html#sec-notify"); return; } if (action === "settings") { go("settings.html#sec-notify"); return; } if (action === "ack") { markLiveMessageRead(id); return; } if (action === "unread") { markLiveMessageUnread(id); return; } if (action === "archive") { archiveLiveMessage(id, button); return; } if (action === "mute") { toast("已记录静音偏好", (messageTypeLabel[message.type] || "系统") + " 类提醒设置入口在通知设置"); } } function renderLiveMessagesPayload(data) { const payload = normalizeNotificationsPayload(data); window.__airshelfLiveMessages = payload.results; const state = liveMessageState(); if (!payload.results.some(function (message) { return message.id === state.selectedId; })) { state.selectedId = payload.results[0]?.id || null; } renderLiveMessages(); } function wireLiveMessages() { if (page !== "messages.html" || window.__airshelfLiveMessagesWired) return; window.__airshelfLiveMessagesWired = true; const search = document.getElementById("msg-search"); if (search) { search.addEventListener( "input", function (event) { if (!canUseApi()) return; event.preventDefault(); event.stopImmediatePropagation(); liveMessageState().q = search.value.trim(); renderLiveMessages(); }, true ); } const markAll = document.getElementById("msg-mark-all"); if (markAll) { markAll.addEventListener( "click", function (event) { if (!canUseApi()) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); markAllLiveMessagesRead(markAll); }, true ); } const settings = document.getElementById("msg-settings"); if (settings) { settings.addEventListener( "click", function (event) { if (!canUseApi()) return; event.preventDefault(); event.stopImmediatePropagation(); go("settings.html#sec-notify"); }, true ); } } async function loadLiveMessages() { if (page !== "messages.html" || !canUseApi()) return; return loadWithCache( "notifications:list", function () { return apiGet("notifications:list", "/api/ops/notifications/"); }, renderLiveMessagesPayload, "消息加载失败" ); } function init() { wireAuth(); ready(function () { applyContextHash(); markHydrationLoading(); const work = (async function () { hydrateShellIdentity(); if (page === "index.html") await loadLiveDashboard(); if (page === "products.html") { wireProductCreate(); await loadLiveProducts(); } if (page === "product-detail.html") await hydrateProductDetail(); if (page === "projects.html") await loadLiveProjects(); if (page === "projects-new.html") { wireProjectWizard(); await loadLiveWizardProducts(); } if (page === "library.html") { wireLiveAssets(); await loadLiveAssets(); } if (page === "account.html") { wireLiveAccount(); await loadLiveAccount(); } if (page === "settings.html") { wireLiveSettings(); await loadLiveSettings(); } if (page === "team.html") { wireLiveTeam(); await loadLiveTeam(); } if (page === "messages.html") { wireLiveMessages(); await loadLiveMessages(); } if (page === "pipeline.html") await loadLivePipeline(); })(); work .then(function () { applyContextHash(); markHydrationDone(); }) .catch(function (error) { markHydrationError(error && error.message); }); }); } window.AirShelfBridge = { version: BRIDGE_VERSION, api: api, isLive: isLive, canUseApi: canUseApi, loadLiveProducts: loadLiveProducts, loadLiveProjects: loadLiveProjects, loadLiveDashboard: loadLiveDashboard, loadLiveSettings: loadLiveSettings, loadLiveMessages: loadLiveMessages, }; init(); })();