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