3121 lines
119 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 =
'<div style="font-size:13.5px;font-weight:600;color:#262626;">' +
esc(title) +
"</div>" +
(sub ? '<div style="font-size:11.5px;color:rgba(0,0,0,.56);">// ' + esc(sub) + "</div>" : "");
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 =
'<span style="font-family:var(--font-mono);font-size:12px;letter-spacing:.04em;">// ' +
esc(text) +
"</span>";
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 (
'<div class="product-card" data-live="1" data-product-id="' +
esc(product.id) +
'" data-cat="' +
esc(cat) +
'" data-name="' +
esc(title) +
'" data-tags="" data-added="' +
esc(index + 1) +
'" data-assets="' +
esc(assets) +
'" data-videos="' +
esc(videos) +
'" data-date="' +
esc(date) +
'">' +
'<span class="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg></span>' +
'<button class="card-del-btn" type="button" title="删除商品" data-action="delete-product" data-product-id="' +
esc(product.id) +
'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg></button>' +
'<div class="placeholder product-thumb"><span class="ph-frame">' +
esc(shortLabel(title)) +
" · 1200×800</span></div>" +
'<div class="product-body"><div class="product-name">' +
esc(title) +
'</div><div class="product-cat">' +
esc(cat) +
'</div><div class="product-date">' +
esc(date) +
" 创建</div></div>" +
'<div class="product-footer"><span class="stat"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 17l-5-5-9 9"/></svg>素材 <b>' +
esc(assets) +
'</b></span><span class="sep">·</span><span class="stat"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/></svg>视频 <b>' +
esc(videos) +
"</b></span></div></div>"
);
}
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 = '// 显示 <span class="count">' + visible + "</span> / " + 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("")
: '<div class="empty-filter">// 当前团队还没有真实商品</div>';
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 '<span class="bullet">' + esc(point.title) + "</span>";
})
.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 += '<span class="' + cls + '"></span>';
}
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 (
'<tr data-live="1" data-project-id="' +
esc(project.id) +
'" data-href="' +
esc(href) +
'" data-status="' +
esc(status) +
'" data-name="' +
esc(project.name) +
'">' +
'<td><div class="proj-name-cell"><div class="placeholder proj-thumb"><span class="ph-frame">9:16</span></div><div><div class="proj-name">' +
esc(project.name) +
'</div><div class="proj-sub">' +
esc(shots) +
" 镜 · 0-60s</div></div></div></td>" +
"<td>" +
esc(productName || "未命名商品") +
'</td><td><span class="muted">AI 全生</span></td><td><div class="hstack"><div class="prog">' +
progressHTML(project) +
'</div><span class="muted-2 mono" style="font-size:11px;">' +
esc(no) +
'/5</span></div></td><td><span class="pill ' +
pillClass(project) +
'"><span class="dot"></span>' +
esc(label) +
'</span></td><td class="muted-2">' +
esc(dateOnly(project.updated_at)) +
'</td><td><div class="row-action"><a href="' +
esc(href) +
'" title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor"/></svg></a><span class="row-more"><svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="13" cy="8" r="1.2" fill="currentColor"/></svg></span></div></td></tr>'
);
}
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 (
'<div class="proj-card" data-live="1" data-project-id="' +
esc(project.id) +
'" data-href="' +
esc(href) +
'" data-status="' +
esc(status) +
'" data-name="' +
esc(project.name) +
'">' +
'<span class="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg></span>' +
'<button class="card-del-btn" type="button" title="删除项目" data-action="delete-project" data-project-id="' +
esc(project.id) +
'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6"/></svg></button>' +
'<div class="placeholder card-thumb"><span class="ph-frame">9:16 · 阶段 ' +
esc(no) +
'/5</span></div><div class="card-body"><div><div class="card-name">' +
esc(project.name) +
'</div><div class="card-sub" style="margin-top:4px;">' +
esc(productName || "未命名商品") +
" · " +
esc(shots) +
' 镜</div></div><div class="hstack"><div class="prog">' +
progressHTML(project) +
'</div><span class="muted-2 mono" style="font-size:10.5px;">' +
esc(no) +
'/5</span></div><div class="card-foot"><span class="pill ' +
pillClass(project) +
'"><span class="dot"></span>' +
esc(label) +
'</span><span class="card-time">' +
esc(dateOnly(project.updated_at)) +
"</span></div></div></div>"
);
}
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 = '// 显示 <span class="count">' + visible + "</span> / " + 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("")
: '<tr data-live="1"><td colspan="6" class="muted">// 当前团队还没有真实项目</td></tr>';
grid.innerHTML = projects.length
? projects
.map(function (project) {
return liveProjectCardHTML(project, productMap[project.product]);
})
.join("")
: '<div class="empty-filter">// 当前团队还没有真实项目</div>';
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 (
'<div class="product-card' +
(index === 0 ? " selected" : "") +
'" data-live="1" data-product-id="' +
esc(product.id) +
'">' +
'<div class="placeholder product-thumb"><span class="ph-frame">' +
esc(shortLabel(title)) +
" · 1200×800</span></div>" +
'<div class="product-body"><div class="product-name">' +
esc(title) +
'</div><div class="product-cat">' +
esc(category) +
'</div><div class="product-date">' +
esc(date) +
" 创建</div></div></div>"
);
}
function renderLiveWizardProductsPayload(data) {
const grid = document.querySelector("#step-pane-1 .pp-grid");
if (!grid) return;
const products = data.results || [];
const createCard =
'<div class="pp-create-card" data-live-create="1"><div class="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></div><div class="pc-t">新建商品</div><div class="pc-d">// 进入商品库创建</div></div>';
grid.innerHTML =
createCard +
(products.length
? products.map(liveWizardProductHTML).join("")
: '<div class="pp-empty">// 当前团队还没有商品 · 请先新建商品</div>');
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
? '<video src="' + esc(preview) + '" muted playsinline style="width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;"></video>'
: '<img src="' + esc(preview) + '" alt="" style="width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;">'
: '<span class="ph-frame">' + esc(shortLabel(asset.name)) + "</span>";
return (
'<div class="asset-card' +
(isVideo ? " video" : "") +
'" data-live="1" data-asset-id="' +
esc(asset.id) +
'" data-name="' +
esc(asset.name) +
'" data-source="' +
esc(asset.source || "") +
'">' +
'<span class="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg></span>' +
'<div class="placeholder asset-thumb">' +
thumb +
'</div><div class="asset-body"><div class="asset-name">' +
esc(asset.name || "未命名资产") +
'</div><div class="asset-meta">' +
esc(assetMeta(asset, tab)) +
"</div></div></div>"
);
}
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 = '// 显示 <span class="count">' + visible + "</span> / " + 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("")
: '<div class="empty-filter">// 当前分类暂无真实资产</div>';
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 (
"<tr><td><span class=\"ts\">" +
esc(dateOnly(ledger.created_at)) +
'</span></td><td><div>' +
esc(ledger.reason || ledger.ledger_type) +
'<div class="ref">' +
esc(ledger.ledger_type) +
'</div></div></td><td><span class="muted">' +
esc(ledger.id) +
'</span></td><td><span class="who"><span class="av">U</span>成员</span></td><td><span class="status-tag ok">真实</span></td><td class="' +
cls +
'">' +
plainMoney(n) +
"</td></tr>"
);
})
.join("")
: '<tr><td colspan="6" class="muted">// 暂无真实账单流水</td></tr>';
}
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 (
'<tr><td><strong>' +
esc(project.name) +
'</strong></td><td><span class="muted">' +
esc(productMap[project.product] || "未命名商品") +
'</span></td><td><span class="who"><span class="av">' +
initial(members[0]?.user?.username) +
"</span>" +
esc(members[0]?.user?.username || "成员") +
'</span></td><td><span class="muted">' +
esc(stageLabel(project)) +
'</span></td><td><span class="status-tag ' +
(status === "fail" ? "fail" : status === "done" ? "ok" : "wip") +
'">' +
esc(projectStatusLabel(project)) +
'</span></td><td class="neg">¥0.00</td></tr>'
);
})
.join("")
: '<tr><td colspan="6" class="muted">// 暂无真实项目</td></tr>';
}
const projCount = document.getElementById("proj-count");
if (projCount) projCount.innerHTML = '共 <b style="color:var(--accent-black);">' + projects.length + "</b> 个项目 · 消耗 " + 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 (
'<tr><td><span class="who"><span class="av">' +
initial(name) +
"</span>" +
esc(name) +
'</span></td><td><span class="role-pill role-' +
role.key +
'"><span class="dot"></span>' +
role.label +
"</span></td><td>0</td><td>" +
money(0) +
" / " +
limitText +
'</td><td><span class="muted">真实成员表</span></td></tr>'
);
})
.join("")
: '<tr><td colspan="5" class="muted">// 暂无成员</td></tr>';
}
const memCount = document.getElementById("mem-count");
if (memCount) memCount.innerHTML = '共 <b style="color:var(--accent-black);">' + members.length + "</b> 人 · 合计 " + 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
? '<span style="font-family:var(--font-mono);font-size:10.5px;color:var(--black-alpha-32);align-self:center;">不可编辑</span>'
: '<button class="icon-btn-sm" type="button" data-live-team-action="edit" data-member-id="' +
esc(member.id) +
'" title="编辑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"/></svg></button><button class="icon-btn-sm" type="button" data-live-team-action="password" data-member-id="' +
esc(member.id) +
'" title="重置密码"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></button><button class="icon-btn-sm danger" type="button" data-live-team-action="remove" data-member-id="' +
esc(member.id) +
'" title="移出"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>';
return (
'<tr data-live="1" data-id="' +
esc(member.id) +
'"><td><span class="member-cell"><span class="av">' +
initial(name) +
'</span><span><span class="nm">' +
esc(name) +
'</span><span class="em">' +
esc(member.user?.email || "") +
'</span></span></span></td><td><span class="role-pill role-' +
role.key +
'"><span class="dot"></span>' +
role.label +
'</span></td><td><span class="quota-cell"><span class="v">不限</span></span></td><td><span class="quota-cell"><span class="v">' +
(monthly > 0 ? money(monthly) : "不限") +
'</span></span></td><td><div class="quota-cell"><span class="v">¥0.00</span> <span class="lbl">/ ' +
usedPct.toFixed(0) +
'%</span></div><div class="used-bar"><span class="ok" style="width:' +
usedPct.toFixed(0) +
'%"></span></div></td><td><div class="acts">' +
actions +
"</div></td></tr>"
);
}
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("")
: '<tr data-live="1"><td colspan="6" class="muted">// 没有匹配的真实成员</td></tr>';
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 =
'<div class="feed-item"><div class="av">Q</div><div><div class="txt"><span class="who">真实团队表</span><span class="act">已同步</span><span class="obj">' +
esc(count) +
' 名成员</span></div><div class="ts">local cache</div></div></div>';
}
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 = '<div class="feed-empty">// 暂无真实团队动态</div>';
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 || "团队") + ' <span class="tag">企业</span>';
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 =
'<div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
esc(project.name) +
'</div><div class="mono">// ' +
esc(productName) +
" · " +
esc(projectStatusLabel(project)) +
"</div>";
}
["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] ? "<small>." + esc(parts[1]) + "</small>" : "");
}
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 (
'<a class="recent-row" data-live="1" href="' +
esc(href) +
'"><div class="placeholder thumb"><span class="ph-frame">9:16</span></div><div class="recent-meta"><div class="name">' +
esc(project.name || "未命名项目") +
'</div><div class="sub">' +
esc(productName) +
" / AI 全生 / " +
esc(shots) +
' 镜</div></div><div class="prog">' +
progressHTML(project) +
'</div><span class="pill ' +
pillClass(project) +
'"><span class="dot"></span>' +
esc(projectStatusLabel(project)) +
'</span><span class="btn btn-sm">' +
(project.status === "completed" ? "打开" : "继续") +
"</span></a>"
);
}
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 =
'<span class="mono">// ' +
esc(day.replace(/\//g, ".")) +
'</span><span>·</span><span>你有 <b style="color:var(--accent-black)">' +
esc(wip) +
" 个项目</b> 正在进行中</span>";
}
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("")
: '<div class="recent-row" style="grid-template-columns:1fr;cursor:default;">// 当前团队还没有真实项目</div>';
}
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 = '<span class="dot"></span>' + 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 (
'<button class="msg-filter' +
(state.tab === filter[0] ? " active" : "") +
'" type="button" data-live-tab="' +
esc(filter[0]) +
'">' +
esc(filter[1]) +
'<span class="ct">' +
esc(filter[2]) +
"</span></button>"
);
})
.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 = '<div class="msg-empty">' + messageIcon("search") + "<span>没有符合条件的消息</span></div>";
return;
}
listEl.innerHTML = list
.map(function (message) {
return (
'<button class="msg-item' +
(state.selectedId === message.id ? " active" : "") +
(message.unread ? "" : " read") +
'" type="button" data-live-message-id="' +
esc(message.id) +
'"><span class="msg-type-ic ' +
esc(message.type) +
'">' +
messageIcon(messageTypeIcon[message.type] || "info") +
'</span><span class="msg-item-main"><span class="msg-item-row"><span class="msg-dot"></span><span class="msg-item-title">' +
esc(message.title) +
'</span><span class="msg-time">' +
esc(messageTimeAgo(message.time)) +
'</span></span><span class="msg-brief">' +
esc(message.brief) +
'</span><span class="msg-item-foot"><span class="msg-priority ' +
esc(message.priority) +
'">' +
esc(messagePriorityLabel[message.priority] || "更新") +
'</span><span class="msg-priority">' +
esc(message.project) +
"</span></span></span></button>"
);
})
.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 = '<div class="msg-detail-empty"><div class="ic">' + messageIcon("bell") + "</div><div>暂无消息</div></div>";
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 ? '<a href="' + esc(message.href) + '">' + esc(message.project) + " →</a>" : "无"],
]
.map(function (row) {
return '<span class="k">' + esc(row[0]) + '</span><span class="v">' + row[1] + "</span>";
})
.join("");
const timeline = liveMessageTimeline(message)
.map(function (row) {
return '<div class="msg-step"><span class="t">' + esc(row[0]) + '</span><span class="d">' + esc(row[1]) + "</span></div>";
})
.join("");
const primaryAction = message.href
? '<button class="btn btn-primary" type="button" data-live-action="goto">进入关联页面</button>'
: '<button class="btn btn-primary" type="button" data-live-action="settings">通知设置</button>';
const readAction = message.unread
? '<button class="btn" type="button" data-live-action="ack">标为已读</button>'
: '<button class="btn" type="button" data-live-action="unread">标为未读</button>';
detail.innerHTML =
'<div class="msg-detail-body"><div class="msg-detail-top"><span class="msg-type-ic ' +
esc(message.type) +
'">' +
messageIcon(messageTypeIcon[message.type] || "info") +
'</span><div class="msg-detail-title"><h2>' +
esc(message.title) +
'</h2><div class="meta"><span>' +
esc(message.source) +
"</span><span>// " +
esc(messageTypeLabel[message.type] || "系统") +
"</span><span>" +
esc(messageFullTime(message.time)) +
'</span></div></div><span class="msg-priority ' +
esc(message.priority) +
'">' +
esc(messagePriorityLabel[message.priority] || "更新") +
'</span></div><p class="msg-body-text">' +
esc(message.body) +
'</p><div class="msg-props">' +
props +
'</div><div class="msg-timeline"><div class="msg-timeline-h">处理记录</div>' +
timeline +
'</div></div><div class="msg-detail-f"><button class="btn btn-ghost" type="button" data-live-action="archive">归档</button><button class="btn btn-ghost" type="button" data-live-action="mute">静音同类</button><span class="spacer"></span>' +
readAction +
primaryAction +
"</div>";
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();
})();