3121 lines
119 KiB
JavaScript
3121 lines
119 KiB
JavaScript
(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 =
|
||
'<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();
|
||
})();
|