Superseded by standalone /home/StationBuilder-vue (Element Plus port).
Live stationbuilder-vue service now serves the standalone repo's dist-vue.
+++ /dev/null
-<script setup>
-import { defineComponent, onMounted } from "vue";
-import { ConfigProvider, theme as antdTheme } from "ant-design-vue";
-import zhCN from "ant-design-vue/es/locale/zh_CN";
-import brandTheme from "./styles/theme.json";
-import Home from "./Home.vue";
-
-/** Serialize official antd tokens without turning unitless values into lengths. */
-function tokenCssValue(name, value) {
- if (typeof value !== "number") return value;
- if (/^(lineHeight|fontWeight|opacity|zIndex|sizeStep|sizeUnit)/i.test(name)) return String(value);
- const length = /^(fontSize|borderRadius|controlHeight|size|padding|margin)|(?:Size|Height|Width|Radius|Padding|Margin)/;
- return length.test(name) ? `${value}px` : String(value);
-}
-
-const companyTheme = { ...brandTheme, cssVar: { key: "stationbuilder" } };
-const kebab = (name) =>
- name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
-
-// Vue port of React company-theme.tsx: ant-design-vue has no `theme.getDesignToken`
-// export, so resolve the merged design token via the useToken() composable from a
-// null-rendering child inside the ConfigProvider, then inject the `--ant-*`
-// variables that the app styles depend on (React renders an equivalent
-// <style data-station-brand-tokens> inside its ConfigProvider).
-const BrandTokenStyle = defineComponent({
- name: "BrandTokenStyle",
- setup() {
- const { token } = antdTheme.useToken();
- onMounted(() => {
- const resolved = token.value || {};
- const declarations = Object.entries(resolved)
- .filter(([, value]) => typeof value === "string" || typeof value === "number")
- .map(([name, value]) => "--ant-" + kebab(name) + ":" + tokenCssValue(name, value));
- for (const [component, tokens] of Object.entries(brandTheme.components)) {
- for (const [name, value] of Object.entries(tokens)) {
- declarations.push("--ant-" + kebab(component) + "-" + kebab(name) + ":" + tokenCssValue(name, value));
- }
- }
- const style = document.createElement("style");
- style.setAttribute("data-station-brand-tokens", "");
- style.textContent = ":root{" + declarations.join(";") + "}";
- document.head.appendChild(style);
- });
- return () => null;
- },
-});
-</script>
-
-<template>
- <ConfigProvider :theme="companyTheme" :locale="zhCN" :button="{ autoInsertSpace: false }">
- <BrandTokenStyle />
- <Home />
- </ConfigProvider>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted, nextTick, h } from "vue";
-import { message, Drawer, Dropdown, Tag } from "ant-design-vue";
-import {
- AimOutlined, ApartmentOutlined, BarsOutlined, BlockOutlined, CaretDownOutlined,
- CaretRightOutlined, ControlOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined,
- EditOutlined, EllipsisOutlined, EyeOutlined, LinkOutlined, PlusOutlined,
- PlusSquareOutlined, RobotOutlined, SearchOutlined, SnippetsOutlined, UploadOutlined,
- ZoomInOutlined, ZoomOutOutlined,
-} from "@ant-design/icons-vue";
-
-import UiButton from "./ui/UiButton.vue";
-import UiInput from "./ui/UiInput.vue";
-import UiSelect from "./ui/UiSelect.vue";
-import UiTable from "./ui/UiTable.vue";
-import UiTabbedRegion from "./ui/UiTabbedRegion.vue";
-import UiActions from "./ui/UiActions.vue";
-import UiOverlay from "./ui/UiOverlay.vue";
-import SvgIcon from "./components/SvgIcon.vue";
-import SideMenu from "./components/SideMenu.vue";
-import SearchInput from "./components/SearchInput.vue";
-import Pagination from "./components/Pagination.vue";
-import StatusTag from "./components/StatusTag.vue";
-import Field from "./components/Field.vue";
-import Panel from "./components/Panel.vue";
-import PrimaryButton from "./components/PrimaryButton.vue";
-import Steps from "./components/Steps.vue";
-import SiteEditTabs from "./components/SiteEditTabs.vue";
-import Login from "./components/Login.vue";
-import ModalComp from "./components/Modal.vue";
-import Dashboard from "./pages/Dashboard.vue";
-import ImagePage from "./components/ImagePage.vue";
-import ResourcePlaceholder from "./components/ResourcePlaceholder.vue";
-import DomainPageThumb from "./components/DomainPageThumb.vue";
-import DashboardThumb from "./components/DashboardThumb.vue";
-import Projects from "./pages/Projects.vue";
-import Basic from "./pages/Basic.vue";
-import CompanyDomainLibrary from "./pages/domain/CompanyDomainLibrary.vue";
-import DomainDetail from "./pages/domain/DomainDetail.vue";
-import StyleLibrary from "./pages/StyleLibrary.vue";
-import DeviceTemplates from "./pages/DeviceTemplates.vue";
-import ToolSettings from "./pages/ToolSettings.vue";
-import DomainSettings from "./pages/DomainSettings.vue";
-import SiteConfigurationContext from "./pages/SiteConfigurationContext.vue";
-import PageConfig from "./pages/PageConfig.vue";
-import Functions from "./pages/Functions.vue";
-import Assets from "./pages/Assets.vue";
-import ManualAssetConfiguration from "./pages/ManualAssetConfiguration.vue";
-import Generate from "./pages/Generate.vue";
-import Result from "./pages/Result.vue";
-import Preview from "./pages/Preview.vue";
-import Placeholder from "./pages/Placeholder.vue";
-
-import { fetchDb, buildMuLeaves } from "./lib/api.js";
-import { meta, SITE_EDIT_STEPS, MODELS } from "./lib/meta.js";
-
-// `window` is not in Vue's template global allow-list, so referencing it inside a
-// template expression resolves to the (undefined) component scope. Expose it as a
-// setup binding instead.
-const win = window;
-
-const notify = (m) => { message.info(m, 2.2); };
-const confirm = (msg) => Promise.resolve(window.confirm(msg));
-
-// ---- state ----
-const page = ref("projects");
-const projectNavOpen = ref(true);
-const extensionNavOpen = ref(true);
-const resourceNavOpen = ref(true);
-const navCollapsed = ref(false);
-const systemNavOpen = ref(true);
-
-const modal = ref("");
-const promptData = ref(null);
-const model = ref("Hy3");
-const modelOpen = ref(false);
-const aiOpen = ref(false);
-const chatSessionId = (() => {
- try {
- const k = "sb-ai-sid";
- let v = sessionStorage.getItem(k);
- if (!v) { v = "web-" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); sessionStorage.setItem(k, v); }
- return v;
- } catch { return "web-" + Math.random().toString(36).slice(2, 10); }
-})();
-const fabPos = ref((() => { try { const s = localStorage.getItem("ai-fab-pos"); return s ? JSON.parse(s) : null; } catch { return null; } })());
-const fabRef = ref(null);
-const routeAppliedRef = ref(false);
-const dragState = ref(null);
-const lastPosRef = ref(null);
-
-const aiMessages = ref((() => { try { const s = sessionStorage.getItem("sb-ai-msgs"); return s ? JSON.parse(s) : []; } catch { return []; } })());
-watch(aiMessages, (val) => { try { sessionStorage.setItem("sb-ai-msgs", JSON.stringify(val.filter((m) => m.content && m.content.trim()))); } catch {} }, { deep: true });
-const aiDraft = ref("");
-const aiBusy = ref(false);
-const aiEndRef = ref(null);
-
-const sites = ref([]);
-const siteId = ref(null);
-const site = ref(null);
-const loading = ref(true);
-const setLoading = (v) => (loading.value = v);
-const saving = ref(false);
-const dirty = ref(false);
-const domains = ref([]);
-const muTree = ref([]);
-const pageStyles = ref([]);
-const openDomainTemplateId = ref(null);
-const domainFilter = ref(null);
-const deviceTemplateDomain = ref((() => { try { return new URLSearchParams(window.location.search).get("domain") || ""; } catch { return ""; } })());
-const domainOptions = ref([]);
-const addingDomain = ref(false);
-const newDomainName = ref("");
-
-const view = ref("table");
-const siteEditSection = ref("basic");
-const assetSaveRef = ref(null);
-const pageSaveRef = ref(null);
-const menuSaveRef = ref(null);
-const configurationSaving = ref(false);
-const assetConfigMode = ref("quick");
-const pageConfigTab = ref("ops");
-const functionTab = ref("mu");
-const previewTab = ref("info");
-const feedbackCube = ref(null);
-const refining = ref(false);
-const refineLog = ref("");
-
-// ---- prompt viewers ----
-const viewPrompt = async (cubeId, name, content) => {
- const sid = site.value?.id ?? siteId.value;
- if (sid == null) { notify("请先创建或打开一个站点"); return; }
- try {
- const data = cubeId != null
- ? await fetchDb(`/sites/${sid}/cubes/${cubeId}/prompt`)
- : await fetchDb(`/sites/${sid}/cubes/prompt?${new URLSearchParams({ name: name || "", content: content || "" }).toString()}`);
- promptData.value = { name, text: data.prompt };
- modal.value = "prompt";
- } catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewSitePrompt = async () => {
- const sid = site.value?.id ?? siteId.value;
- if (sid == null) { notify("请先创建或打开一个站点"); return; }
- try { const data = await fetchDb(`/sites/${sid}/prompt`); promptData.value = { name: "站点级提示词(通用上下文)", text: data.prompt }; modal.value = "prompt"; }
- catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewCompletePrompt = async () => {
- if (!site.value?.id) { notify("请先创建或打开一个站点"); return; }
- try {
- const qs = new URLSearchParams({ name: site.value.name || "", domain: site.value.domain || "", description: site.value.description || "" }).toString();
- const data = await fetchDb(`/sites/${site.value.id}/complete-prompt?${qs}`);
- promptData.value = { name: "AI 智能补全", text: data.prompt }; modal.value = "completePrompt";
- } catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewCompleteAssetsPrompt = async () => {
- if (!site.value?.id) { notify("请先创建或打开一个站点"); return; }
- try {
- const qs = new URLSearchParams({ name: site.value.name || "", domain: site.value.domain || "", description: site.value.description || "", supplement: site.value.supplement || "" }).toString();
- const data = await fetchDb(`/sites/${site.value.id}/complete-assets-prompt?${qs}`);
- promptData.value = { name: "AI 资产补全", text: data.prompt }; modal.value = "completePrompt";
- } catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewCompleteCubesPrompt = async () => {
- if (!site.value?.id) { notify("请先创建或打开一个站点"); return; }
- try {
- const qs = new URLSearchParams({ name: site.value.name || "", domain: site.value.domain || "", description: site.value.description || "", supplement: site.value.supplement || "" }).toString();
- const data = await fetchDb(`/sites/${site.value.id}/complete-cubes-prompt?${qs}`);
- promptData.value = { name: "AI Cube 页面补全", text: data.prompt }; modal.value = "completePrompt";
- } catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewDtCompletePrompt = async (id, name, domain, description) => {
- try { const qs = new URLSearchParams({ name, domain, description }).toString(); const data = await fetchDb(`/domain-templates/${id}/complete-prompt?${qs}`); promptData.value = { name: "AI 智能补全", text: data.prompt }; modal.value = "completePrompt"; }
- catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewDtCompleteAssetsPrompt = async (id, name, domain, description, supplement) => {
- try { const qs = new URLSearchParams({ name, domain, description, supplement }).toString(); const data = await fetchDb(`/domain-templates/${id}/complete-assets-prompt?${qs}`); promptData.value = { name: "AI 资产补全", text: data.prompt }; modal.value = "completePrompt"; }
- catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewDtCompleteCubesPrompt = async (id, name, domain, description, supplement) => {
- try { const qs = new URLSearchParams({ name, domain, description, supplement }).toString(); const data = await fetchDb(`/domain-templates/${id}/complete-cubes-prompt?${qs}`); promptData.value = { name: "AI Cube 页面补全", text: data.prompt }; modal.value = "completePrompt"; }
- catch (e) { notify("读取提示词失败: " + e.message); }
-};
-const viewDeviceCpCompletePrompt = async (id) => {
- try { const data = await fetchDb(`/device-templates/${id}/complete-collection-points-prompt`); promptData.value = { name: "AI 采集点补全", text: data.prompt }; modal.value = "completePrompt"; }
- catch (e) { notify("读取提示词失败: " + e.message); }
-};
-
-const openFeedback = (cube) => {
- if (!cube.id) { notify("请先生成该页面再提出修改意见"); return; }
- feedbackCube.value = cube; refineLog.value = ""; modal.value = "feedback";
-};
-const refineCube = async (cube, feedback) => {
- if (!site.value?.id || !cube.id) return;
- if (!feedback.trim()) { notify("请填写修改意见"); return; }
- refining.value = true; refineLog.value = "";
- const watchdog = setTimeout(() => {
- refining.value = false; modal.value = ""; feedbackCube.value = null;
- notify("重新生成超过预期时长(>10 分钟),已自动结束并关闭窗口,可重新打开再试");
- }, 600000);
- try {
- const res = await fetch(`/api/db/sites/${site.value.id}/cubes/${cube.id}/refine`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feedback }) });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = "";
- for (;;) {
- const { done, value } = await reader.read(); if (done) break;
- buf += dec.decode(value, { stream: true });
- const lines = buf.split("\n"); buf = lines.pop() || "";
- for (const line of lines) {
- if (!line.trim()) continue;
- try { const ev = JSON.parse(line); if (ev.log) refineLog.value += ev.log; } catch {}
- }
- }
- notify(`已根据修改意见重新生成「${cube.name}」`);
- refining.value = false; modal.value = ""; feedbackCube.value = null;
- try { const refreshed = await fetchDb(`/sites/${site.value.id}`); site.value = refreshed; siteId.value = site.value.id; } catch {}
- } catch (e) { notify("重新生成失败:" + (e?.message || e)); }
- finally { clearTimeout(watchdog); refining.value = false; }
-};
-
-watch([aiMessages, aiOpen], async () => { await nextTick(); if (aiEndRef.value) aiEndRef.value.scrollIntoView({ block: "end" }); });
-
-const loadPageStyles = async () => {
- try { const d = await fetchDb("/page-styles"); pageStyles.value = d || []; } catch (e) { notify("加载页面样式失败: " + e.message); }
-};
-
-onMounted(() => {
- setLoading(true);
- Promise.all([
- fetchDb("/sites"),
- fetchDb("/domains"),
- fetchDb("/mu-tree"),
- fetchDb("/page-styles"),
- ]).then(([s, d, m, ps]) => {
- sites.value = s || [];
- domains.value = (d || []).map((x) => x.name);
- domainOptions.value = (d || []).map((x) => ({ id: x.id, name: x.name }));
- muTree.value = m || [];
- pageStyles.value = ps || [];
- }).catch((e) => notify("加载失败: " + e.message)).finally(() => setLoading(false));
-});
-
-const reloadDomains = async () => {
- try {
- const d = await fetchDb("/domains"); const arr = d || [];
- domainOptions.value = arr.map((x) => ({ id: x.id, name: x.name }));
- domains.value = arr.map((x) => x.name);
- } catch (e) { notify("加载领域失败: " + e.message); }
-};
-const handleDomainDeleted = async (id) => { await reloadDomains(); if (domainFilter.value === id) domainFilter.value = null; };
-
-const loadSite = async (id) => { const data = await fetchDb(`/sites/${id}`); site.value = data; siteId.value = id; };
-const createSite = async () => {
- const data = await fetchDb("/sites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "", domain: "", description: "", supplement: "" }) });
- sites.value = [data, ...sites.value]; site.value = data; siteId.value = data.id; return data.id;
-};
-const createSubSite = async (parentId) => {
- const parent = sites.value.find((s) => s.id === parentId);
- const data = await fetchDb("/sites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "", domain: parent?.domain || "", description: "", supplement: "", parent_id: parentId }) });
- sites.value = [data, ...sites.value]; site.value = data; siteId.value = data.id;
- page.value = "basic"; window.history.replaceState({}, "", "?page=basic&id=" + data.id);
-};
-const deleteSites = async (ids) => {
- if (!ids.length) return;
- if (!(await confirm(`确认删除选中的 ${ids.length} 个站点?该操作不可恢复,相关资产、页面与生成文件将一并删除。`))) return;
- try {
- await fetchDb("/sites", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }) });
- sites.value = sites.value.filter((s) => !ids.includes(s.id));
- if (siteId.value != null && ids.includes(siteId.value)) { site.value = null; siteId.value = null; }
- notify(`已删除 ${ids.length} 个站点`);
- } catch (e) { notify("删除失败: " + e.message); }
-};
-const confirmUnsaved = async () => {
- if (!dirty.value) return true;
- const ok = window.confirm("当前站点有未保存的修改,切换站点将丢失这些修改。是否继续?");
- if (ok) { dirty.value = false; return true; }
- return false;
-};
-const go = async (target, sid) => {
- try {
- if (page.value === "siteEdit" && (target !== page.value || (typeof sid === "number" && sid !== siteId.value))) {
- if (siteEditSection.value === "assets" && assetSaveRef.value) await assetSaveRef.value();
- if (siteEditSection.value === "functions" && menuSaveRef.value) await menuSaveRef.value();
- }
- if (target === "siteEdit") siteEditSection.value = "basic";
- if (sid === "new") { await createSite(); page.value = target; window.history.replaceState({}, "", "?page=" + target); return; }
- let curId = sid ?? siteId.value;
- if (target === "siteEdit" && !curId) {
- const first = sites.value[0];
- if (!first) { page.value = "projects"; window.history.replaceState({}, "", "?page=projects"); notify("暂无可编辑站点,请先新建站点"); return; }
- curId = first.id;
- }
- const needsSite = ["siteEdit", "basic", "assets", "opsflow", "pageConfig", "functions", "generate", "result", "preview"].includes(target);
- if (needsSite && !curId) { page.value = "projects"; window.history.replaceState({}, "", "?page=projects"); notify("请先选择或新建站点"); return; }
- if (curId && (!site.value || site.value.id !== curId)) {
- if (!(await confirmUnsaved())) return;
- await loadSite(curId);
- }
- page.value = target;
- window.history.replaceState({}, "", "?page=" + target + (curId ? "&id=" + curId : ""));
- } catch (e) { notify("操作失败: " + (e?.message || e)); }
-};
-const createSiteFromDomainTemplate = async (templateId) => {
- if (!(await confirmUnsaved())) return;
- const created = await fetchDb(`/domain-templates/${templateId}/create-site`, { method: "POST", headers: { "Content-Type": "application/json" } });
- if (!created?.id) throw new Error("未返回有效的站点编号");
- sites.value = [created, ...sites.value]; site.value = created; siteId.value = created.id; dirty.value = false;
- siteEditSection.value = "basic"; page.value = "siteEdit";
- window.history.replaceState({}, "", `?page=siteEdit&id=${created.id}`);
- notify("已根据领域模板创建草稿站点,可继续调整配置");
- fetchDb("/sites").then((data) => (sites.value = data)).catch(() => {});
-};
-const saveHierarchySite = async (values, id) => {
- const saved = await fetchDb(id ? `/sites/${id}` : "/sites", { method: id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values) });
- sites.value = await fetchDb("/sites"); if (site.value?.id === id) site.value = saved; return saved;
-};
-const saveCurrentConfiguration = async () => {
- if (saving.value) throw new Error("基本信息正在保存,请稍后重试");
- const section = page.value === "siteEdit" ? siteEditSection.value : page.value;
- if (section === "assets" && assetSaveRef.value) await assetSaveRef.value();
- if (section === "pageConfig" && pageSaveRef.value) await pageSaveRef.value();
- if (section === "functions" && menuSaveRef.value) await menuSaveRef.value();
- if (dirty.value && site.value) await fetchDb(`/sites/${site.value.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(site.value) });
- dirty.value = false; sites.value = await fetchDb("/sites");
-};
-const saveConfigurationDraft = async () => { if (configurationSaving.value) return; configurationSaving.value = true; try { await saveCurrentConfiguration(); notify("草稿已保存"); } catch (error) { notify("保存失败:" + error.message); } finally { configurationSaving.value = false; } };
-const changeConfigurationStep = async (section) => {
- if (configurationSaving.value) return; configurationSaving.value = true;
- try { await saveCurrentConfiguration(); if (page.value === "siteEdit") siteEditSection.value = section; else await go(section, site.value?.id); }
- catch (error) { notify("请先完成保存:" + error.message); } finally { configurationSaving.value = false; }
-};
-const backToHierarchy = async () => { if (configurationSaving.value) return; configurationSaving.value = true; try { await saveCurrentConfiguration(); await go("projects"); } catch (error) { notify("请先完成保存:" + error.message); } finally { configurationSaving.value = false; } };
-const nextConfigurationStep = async () => {
- const section = page.value === "siteEdit" ? siteEditSection.value : page.value;
- const index = SITE_EDIT_STEPS.indexOf(section);
- if (index < 4) return changeConfigurationStep(SITE_EDIT_STEPS[index + 1]);
- if (configurationSaving.value) return; configurationSaving.value = true;
- try { await saveCurrentConfiguration(); await go("generate", site.value?.id); } catch (error) { notify("请先完成保存:" + error.message); } finally { configurationSaving.value = false; }
-};
-const applyTemplateToSite = async (templateId, replace) => {
- if (!site.value) return;
- const applied = await fetchDb(`/sites/${site.value.id}/apply-template`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template_id: templateId, replace }) });
- site.value = applied; dirty.value = false; sites.value = await fetchDb("/sites");
- if (page.value === "siteEdit") siteEditSection.value = "assets"; else await go("assets", site.value.id);
- notify("模板已应用,可在当前工作区调整资产");
-};
-const savePageConfiguration = async (config) => { if (!site.value) return; const saved = await fetchDb(`/sites/${site.value.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ page_config: config }) }); site.value = saved; };
-const openDomainDeviceTemplates = (domainName) => { deviceTemplateDomain.value = domainName; page.value = "deviceTemplates"; window.history.replaceState({}, "", `?page=deviceTemplates&domain=${encodeURIComponent(domainName)}`); };
-const openDeviceTemplateEditor = (templateId) => { page.value = "deviceTemplates"; window.history.replaceState({}, "", `?page=deviceTemplates&openTemplate=${Number(templateId)}`); };
-
-watch(loading, () => {
- if (loading.value || routeAppliedRef.value) return;
- routeAppliedRef.value = true;
- const params = new URLSearchParams(window.location.search);
- const target = params.get("page");
- if (!target || target === "projects" || !Object.prototype.hasOwnProperty.call(meta, target)) return;
- const rawId = Number(params.get("id"));
- go(target, Number.isInteger(rawId) && rawId > 0 ? rawId : undefined);
-});
-
-const saveSiteBasic = async (patch) => {
- if (!site.value) return;
- const next = { ...site.value, ...patch };
- site.value = next; dirty.value = true; saving.value = true;
- try {
- await fetchDb(`/sites/${site.value.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next) });
- sites.value = sites.value.map((s) => (s.id === site.value.id ? { ...s, name: next.name, domain: next.domain, status: next.status } : s));
- dirty.value = false;
- } catch (e) { notify("保存失败: " + e.message); }
- finally { saving.value = false; }
-};
-const saveAssets = async (assets) => {
- if (!site.value) return;
- site.value = { ...site.value, assets }; saving.value = true;
- try { await fetchDb(`/sites/${site.value.id}/assets`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ assets }) }); }
- catch (e) { notify("资产保存失败: " + e.message); } finally { saving.value = false; }
-};
-const saveMu = async (selected) => {
- if (!site.value) return;
- try { const saved = await fetchDb(`/sites/${site.value.id}/mu`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selected }) }); site.value = saved; }
- catch (e) { notify("MU 选择保存失败: " + e.message); throw e; }
-};
-const addDomain = async (name) => {
- const n = (name || "").trim();
- if (!n) return;
- if (n.length > 6) { notify("领域名称不超过 6 个字"); return; }
- try { await fetchDb("/domains", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: n }) }); await reloadDomains(); }
- catch (e) { notify("添加领域失败: " + e.message); }
-};
-const doAddDomain = async () => { if (!newDomainName.value.trim()) return; await addDomain(newDomainName.value); addingDomain.value = false; newDomainName.value = ""; };
-const uploadFile = async (file) => {
- if (!site.value) return;
- const reader = new FileReader(); reader.readAsDataURL(file);
- const data = await new Promise((res) => (reader.onload = () => res(String(reader.result))));
- const base64 = data.split(",")[1];
- try {
- const result = await fetchDb(`/sites/${site.value.id}/files`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, mime: file.type, data: base64 }) });
- site.value = { ...site.value, files: [...site.value.files, result] };
- notify("已上传 " + file.name);
- } catch (e) { notify("上传失败: " + e.message); }
-};
-const sendAi = async () => {
- const text = aiDraft.value.trim();
- if (!text || aiBusy.value) return;
- aiMessages.value = [...aiMessages.value, { role: "user", content: text }, { role: "assistant", content: "" }];
- aiDraft.value = ""; aiBusy.value = true;
- try {
- const siteInfo = site.value ? { name: site.value.name, domain: site.value.domain, description: site.value.description, status: site.value.status, assets: (site.value.assets || []).filter((a) => a.type).map((a) => ({ type: a.type, count: a.count })), cubes: (site.value.cubes || []).map((c) => ({ name: c.name, gen_filename: c.gen_filename || null })) } : null;
- const OVERVIEW_MAX = 40;
- const overview = sites.value && sites.value.length
- ? { total: sites.value.length, truncated: sites.value.length > OVERVIEW_MAX, sites: sites.value.slice(0, OVERVIEW_MAX).map((s) => ({ name: s.name, domain: s.domain, status: s.status, device_count: s.device_count, cube_count: s.cube_count, gen_count: s.gen_count, mu_count: s.mu_count })) }
- : { total: 0, truncated: false, sites: [] };
- const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text, model: model.value, sessionId: chatSessionId, history: aiMessages.value.map((m) => ({ role: m.role, content: m.content })).slice(-20), site: siteInfo, overview }) });
- if (!res.ok || !res.body) { const t = await res.text().catch(() => ""); throw new Error(t || "HTTP " + res.status); }
- const reader = res.body.getReader(); const dec = new TextDecoder(); let acc = "";
- for (;;) { const { done, value } = await reader.read(); if (done) break; acc += dec.decode(value, { stream: true }); aiMessages.value = (() => { const c = aiMessages.value.slice(); c[c.length - 1] = { role: "assistant", content: acc }; return c; })(); }
- if (!acc.trim()) aiMessages.value = (() => { const c = aiMessages.value.slice(); c[c.length - 1] = { role: "assistant", content: "(模型未返回内容)" }; return c; })();
- } catch (e) {
- aiMessages.value = (() => { const c = aiMessages.value.slice(); const last = c[c.length - 1]; if (last && last.role === "assistant") { c[c.length - 1] = { role: "assistant", content: last.content + "\n[连接出错] " + (e?.message || e) }; } else c.push({ role: "assistant", content: "[连接出错] " + (e?.message || e) }); return c; })();
- } finally { aiBusy.value = false; }
-};
-
-// FAB drag
-const onFabPointerDown = (e) => {
- const btn = fabRef.value; if (!btn) return;
- const r = btn.getBoundingClientRect();
- dragState.value = { active: true, startX: e.clientX, startY: e.clientY, origX: r.left, origY: r.top, moved: false };
- try { btn.setPointerCapture(e.pointerId); } catch {}
-};
-const onFabPointerMove = (e) => {
- const ds = dragState.value; if (!ds || !ds.active) return;
- const dx = e.clientX - ds.startX, dy = e.clientY - ds.startY;
- if (Math.abs(dx) > 4 || Math.abs(dy) > 4) ds.moved = true;
- if (!ds.moved) return;
- const btn = fabRef.value; const w = btn.offsetWidth, h = btn.offsetHeight;
- let x = Math.max(0, Math.min(ds.origX + dx, window.innerWidth - w));
- let y = Math.max(0, Math.min(ds.origY + dy, window.innerHeight - h));
- const p = { x, y }; lastPosRef.value = p; fabPos.value = p;
-};
-const onFabPointerUp = (e) => {
- const ds = dragState.value; dragState.value = null;
- try { fabRef.value?.releasePointerCapture(e.pointerId); } catch {}
- if (!ds) return;
- if (ds.moved) { if (lastPosRef.value) localStorage.setItem("ai-fab-pos", JSON.stringify(lastPosRef.value)); }
- else { aiOpen.value = true; }
-};
-const resetFab = () => { fabPos.value = null; lastPosRef.value = null; try { localStorage.removeItem("ai-fab-pos"); } catch {} };
-
-// ---- derived ----
-const muLeaves = computed(() => buildMuLeaves(muTree.value));
-const renderDashboard = (cube, large) =>
- h(DashboardThumb, { siteId: site.value?.id, cube, fixedHeight: large ? Math.min(680, window.innerHeight - 180) : 145 });
-const renderDomainDashboard = (domainPage, large) =>
- h(DomainPageThumb, { jsonText: domainPage?.json_text, fixedHeight: large ? Math.min(680, window.innerHeight - 180) : 145 });
-
-const siteEditMeta = computed(() => meta[siteEditSection.value]);
-const projectArea = computed(() => ["projects", "siteEdit", "basic", "assets", "pageConfig", "functions", "generate", "result", "preview"].includes(page.value));
-const domainArea = computed(() => ["domains", "domainDetail"].includes(page.value));
-const resourceArea = computed(() => ["deviceTemplates", "monitor-dashboard", "twin-model", "ops-template", "knowledge-template"].includes(page.value));
-const systemArea = computed(() => ["toolSettings", "domainSettings"].includes(page.value));
-const extensionArea = computed(() => ["dashboard", "report-dashboard", "report-doc", "basic-components", "func-components", "filters", "datasource"].includes(page.value));
-const manualAssetActive = computed(() => page.value === "assets" || (page.value === "siteEdit" && siteEditSection.value === "assets"));
-
-const siteHierarchy = computed(() => {
- if (!siteId.value) return null;
- const byId = new Map(sites.value.map((s) => [s.id, s]));
- const current = byId.get(siteId.value);
- if (!current) return null;
- const ancestors = []; let p = current;
- while (p) { ancestors.unshift(p); p = p.parent_id ? byId.get(p.parent_id) : undefined; }
- const children = sites.value.filter((s) => s.parent_id === siteId.value);
- return { ancestors, children, current };
-});
-
-const menuItems = computed(() => [
- { key: "domains", label: "领域方案", icon: h(SvgIcon, { name: "domain-solutions", size: 16 }) },
- { key: "projects", label: "站点管理", icon: h(SvgIcon, { name: "site-management", size: 16 }) },
- { key: "resources", label: "建站资源", icon: h(SvgIcon, { name: "building-resources", size: 16 }), children: [
- { key: "deviceTemplates", label: "设备模板", icon: h(SvgIcon, { name: "device-templates", size: 16 }) },
- { key: "monitor-dashboard", label: "监控仪表板", icon: h(SvgIcon, { name: "monitoring-dashboards", size: 16 }) },
- { key: "twin-model", label: "数字孪生模型", icon: h(SvgIcon, { name: "digital-twin-models", size: 16 }) },
- { key: "ops-template", label: "运维模板", icon: h(SvgIcon, { name: "operations-templates", size: 16 }) },
- { key: "knowledge-template", label: "知识模板", icon: h(SvgIcon, { name: "knowledge-templates", size: 16 }) },
- ] },
- { key: "system", label: "系统设定", icon: h(SvgIcon, { name: "system-settings", size: 16 }), children: [
- { key: "toolSettings", label: "内部工具配置", icon: h(SvgIcon, { name: "internal-tools", size: 16 }) },
- { key: "domainSettings", label: "领域配置", icon: h(SvgIcon, { name: "domain-settings", size: 16 }) },
- ] },
-]);
-
-function onMenuClick({ key }) { if (key === "domains") domainFilter.value = null; go(key); }
-function onMenuOpenChange(keys) { resourceNavOpen.value = keys.includes("resources"); systemNavOpen.value = keys.includes("system"); }
-</script>
-
-<template>
- <div class="app-shell page sb-brand-shell">
- <header class="topbar wise-header">
- <UiButton className="wise-menu-button" type="button" aria-label="展开或收起菜单" :aria-expanded="!navCollapsed" @click="navCollapsed = !navCollapsed"><img src="/t/stationbuilder/wise-header/menu.svg" alt="" /></UiButton>
- <a class="wise-logo-link" href="#" aria-label="WISE-IoT 首页" @click.prevent><img src="/t/stationbuilder/wise-header/wise-iot-logo.svg" alt="WISE-IoT" /></a>
- <UiButton className="wise-product-switch" type="button" aria-label="切换产品">
- <img src="/t/stationbuilder/wise-header/folder.svg" alt="" />
- <span>StationBuilder</span><strong>| 建站工具 v0.1</strong>
- <img src="/t/stationbuilder/wise-header/caret-down.svg" alt="" />
- </UiButton>
- <nav class="wise-header-actions" aria-label="顶部工具栏">
- <UiButton type="button" aria-label="搜索" title="搜索"><img src="/t/stationbuilder/wise-header/search.svg" alt="" /></UiButton>
- <UiButton type="button" aria-label="通知" title="通知"><img src="/t/stationbuilder/wise-header/bell.svg" alt="" /></UiButton>
- <UiButton type="button" className="wise-header-secondary" aria-label="主题设置" title="主题设置"><img src="/t/stationbuilder/wise-header/theme.svg" alt="" /></UiButton>
- <UiButton type="button" className="wise-header-secondary" aria-label="收藏" title="收藏"><img src="/t/stationbuilder/wise-header/favorite.svg" alt="" /></UiButton>
- <UiButton type="button" aria-label="系统设置" title="系统设置"><img src="/t/stationbuilder/wise-header/settings.svg" alt="" /></UiButton>
- <UiButton type="button" className="wise-avatar" aria-label="管理员账户" title="管理员账户" @click="go('login')">管</UiButton>
- <span class="wise-powered-divider" aria-hidden="true" />
- <img class="wise-powered-logo" src="/t/stationbuilder/wise-header/powered-by-wise-iot.svg" alt="Powered by WISE-IoT" />
- </nav>
- </header>
- <div class="body">
- <aside class="left-menu sb-brand-nav">
- <nav aria-label="StationBuilder 主菜单">
- <SideMenu
- :collapsed="navCollapsed"
- :selected-keys="[domainArea ? 'domains' : projectArea ? 'projects' : page]"
- :open-keys="navCollapsed ? undefined : [...(resourceNavOpen ? ['resources'] : []), ...(systemNavOpen ? ['system'] : [])]"
- @open-change="onMenuOpenChange"
- @click="onMenuClick"
- :items="menuItems"
- />
- </nav>
- <a v-if="!navCollapsed" class="design-doc-link" href="https://aidemo.istation.cloud/t/stationbuilder/design-doc.html" target="_blank" rel="noopener">RDP文档</a>
- </aside>
- <aside class="side-nav">
- <div class="module-switch">
- <UiButton :class="['projects','basic','assets','pageConfig','functions','generate','result','preview'].includes(page) ? 'active' : ''" @click="go('projects')"><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></UiButton>
- <UiButton :class="['domains','domainDetail','deviceTemplates'].includes(page) ? 'active' : ''" @click="() => { domainFilter = null; go('domains'); }"><b>◆</b><span>领域方案库<small>行业标准方案</small></span></UiButton>
- <UiButton :class="page === 'styleLibrary' ? 'active' : ''" @click="go('styleLibrary')"><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></UiButton>
- </div>
- <template v-if="page === 'styleLibrary'">
- <small>样式库导航</small>
- <UiButton :class="page === 'styleLibrary' ? 'active' : ''" @click="go('styleLibrary')"><b>❖</b>全部页面样式</UiButton>
- </template>
- <template v-else-if="['domains','domainDetail','deviceTemplates'].includes(page)">
- <small>方案库导航</small>
- <UiButton :class="page === 'domains' ? 'active' : ''" @click="() => { domainFilter = null; go('domains'); }"><b>▦</b>领域方案</UiButton>
- <UiButton :class="page === 'deviceTemplates' ? 'active' : ''" @click="go('deviceTemplates')"><b>⬡</b>设备模版</UiButton>
- <small>领域分类</small>
- <div v-for="d in domainOptions" :key="d.id" class="domain-nav-row" :class="page === 'domains' && domainFilter === d.id ? ' active' : ''">
- <UiButton :class="page === 'domains' && domainFilter === d.id ? 'active' : ''" @click="() => { domainFilter = d.id; go('domains'); }"><b>◆</b><span class="domain-nav-name">{{ d.name }}</span></UiButton>
- <UiButton v-if="d.name !== '其他'" className="domain-nav-del" title="删除领域" @click="async () => { if (!win.confirm(`确定删除领域「${d.name}」?其下的方案将移入「其他」领域。`)) return; try { await fetchDb(`/domains/${d.id}`, { method: 'DELETE' }); notify('已删除领域,相关方案已归入「其他」'); await handleDomainDeleted(d.id); } catch (e) { notify('删除失败:' + e.message); } }">×</UiButton>
- </div>
- <div v-if="addingDomain" class="domain-nav-row add">
- <UiInput className="domain-nav-input" autoFocus :value="newDomainName" placeholder="领域名称" @change="(e) => (newDomainName = e.target.value)" @keydown="async (e) => { if (e.key === 'Enter') { await doAddDomain(); } else if (e.key === 'Escape') { addingDomain = false; newDomainName = ''; } }" />
- <UiButton className="domain-nav-ok" title="确认" @click="doAddDomain">✓</UiButton>
- <UiButton className="domain-nav-del" title="取消" @click="() => { addingDomain = false; newDomainName = ''; }">×</UiButton>
- </div>
- <div v-else class="domain-nav-add-cta">
- <UiButton className="domain-nav-add" @click="() => { addingDomain = true; newDomainName = ''; }"><b>+</b>新增领域</UiButton>
- </div>
- </template>
- <template v-else>
- <small>站点列表</small>
- <UiButton :class="page === 'projects' ? 'active' : ''" @click="go('projects')"><b>▦</b>站点列表</UiButton>
- <small>站点详情</small>
- <UiSelect className="site-switcher" :value="siteId ?? ''" @change="(e) => { const v = e.target.value; if (v) { const t = (['projects','domains','domainDetail','styleLibrary','deviceTemplates'].includes(page)) ? 'basic' : page; go(t, Number(v)); } }">
- <option value="">选择站点…</option>
- <option v-for="s in sites" :key="s.id" :value="s.id">{{ s.name || '未命名站点' }}</option>
- </UiSelect>
- <small>生成向导</small>
- <UiButton v-for="item in SITE_EDIT_STEPS" :key="item" :class="page === item ? 'active' : ''" @click="go(item)"><em>{{ SITE_EDIT_STEPS.indexOf(item) + 1 }}</em>{{ meta[item].label }}</UiButton>
- <small>方案交付</small>
- <UiButton :class="page === 'result' ? 'active' : ''" @click="go('result')"><b>◫</b>站点结果</UiButton>
- <UiButton :class="page === 'preview' ? 'active' : ''" @click="go('preview')"><b>▣</b>页面预览</UiButton>
- </template>
- <div class="side-help">
- <b>内部工具 · v0.3</b>
- <span class="model-trigger" @click="modelOpen = !modelOpen">Wordbuddy AI · {{ model }}<span class="caret">▾</span></span>
- <template v-if="modelOpen">
- <div class="model-backdrop" @click="modelOpen = false" />
- <div class="model-popover">
- <div class="model-popover-head"><span>Wordbuddy AI 模型</span><small>仅免费模型可选</small></div>
- <label v-for="m in MODELS" :key="m.name" class="model-row" :class="m.rate === '0.00x' ? '' : ' disabled'">
- <UiInput type="radio" name="sideModel" :disabled="m.rate !== '0.00x'" :checked="model === m.name" @change="() => { model = m.name; modelOpen = false; }" />
- <span class="model-name">{{ m.name }}</span>
- <span class="model-tag">{{ m.tag || (m.rate === '0.00x' ? '免费' : '') }}</span>
- <span class="model-rate">{{ m.rate }}</span>
- </label>
- </div>
- </template>
- </div>
- </aside>
- <main class="workspace main">
- <div class="crumb">iStation StationBuilder / <b>{{ ['domains','domainDetail','styleLibrary','deviceTemplates','toolSettings','domainSettings','monitor-dashboard','twin-model','ops-template','knowledge-template'].includes(page) ? meta[page].label : page === 'projects' ? '站点列表' : page === 'siteEdit' ? `站点管理 / 站点编辑 / ${siteEditMeta.label}` : `站点详情 / ${meta[page].label}` }}</b></div>
- <div class="page-heading">
- <div>
- <h1>{{ page === 'siteEdit' ? siteEditMeta.label : meta[page].label }}</h1>
- <p>{{ page === 'siteEdit' ? siteEditMeta.subtitle : meta[page].subtitle }}</p>
- </div>
- <div>
- <label v-if="page === 'siteEdit'" class="site-edit-switcher"><span>当前站点</span>
- <UiSelect :value="siteId ?? ''" @change="async (e) => { const id = Number(e.target.value); if (id === siteId) return; try { await saveCurrentConfiguration(); await loadSite(id); siteEditSection = 'basic'; } catch (error) { notify('切换失败:' + error.message); return; } win.history.replaceState({}, '', `?page=siteEdit&id=${id}`); }">
- <option v-for="item in sites" :key="item.id" :value="item.id">{{ item.name || '未命名站点' }}</option>
- </UiSelect>
- </label>
- <PrimaryButton v-if="page === 'domains' && openDomainTemplateId == null" @click="openDomainTemplateId = 'create'"><UploadOutlined /> 导入领域方案</PrimaryButton>
- <PrimaryButton v-if="page === 'domains' && openDomainTemplateId != null" secondary @click="openDomainTemplateId = null">← 返回方案列表</PrimaryButton>
- <PrimaryButton v-if="domainFilter != null && page === 'domains' && openDomainTemplateId == null" secondary @click="domainFilter = null">清除领域筛选:{{ domainOptions.find((d) => d.id === domainFilter)?.name || '' }}</PrimaryButton>
- <PrimaryButton v-if="page === 'domainSettings'" @click="() => { newDomainName = ''; addingDomain = true; }">+ 新增领域</PrimaryButton>
- </div>
- </div>
- <SiteEditTabs v-if="page === 'siteEdit'" :section="siteEditSection" :onChange="(s) => changeConfigurationStep(s)" />
- <Steps v-if="SITE_EDIT_STEPS.includes(page)" :page="page" :go="go" />
- <div class="content">
- <Login v-if="page === 'login'" :on-login="() => go('projects')" />
- <Projects v-if="page === 'projects'" :sites="sites" :domains="domainOptions" :loading="loading" :on-save="saveHierarchySite" :on-configure="(id) => go('siteEdit', id)" :on-delete="deleteSites" :notify="notify" />
- <Dashboard v-if="page === 'dashboard'" />
- <ImagePage v-if="page === 'report-dashboard'" src="/t/stationbuilder/ext-pages/report-dashboard.jpg" />
- <ImagePage v-if="page === 'report-doc'" src="/t/stationbuilder/ext-pages/report-doc.jpg" />
- <ImagePage v-if="page === 'basic-components'" src="/t/stationbuilder/ext-pages/basic-components.jpg" />
- <ImagePage v-if="page === 'func-components'" src="/t/stationbuilder/ext-pages/func-components.jpg" />
- <ImagePage v-if="page === 'filters'" src="/t/stationbuilder/ext-pages/filters.jpg" />
- <ImagePage v-if="page === 'datasource'" src="/t/stationbuilder/ext-pages/datasource.jpg" />
- <CompanyDomainLibrary v-if="(page === 'domains' || page === 'domainDetail') && openDomainTemplateId == null" :go="go" :notify="notify" :on-open="(id) => (openDomainTemplateId = id)" :domain-filter="domainFilter" :domain-options="domainOptions" />
- <DomainDetail v-if="(page === 'domains' || page === 'domainDetail') && openDomainTemplateId != null" :id="openDomainTemplateId === 'create' ? null : openDomainTemplateId" :go="go" :notify="notify" :on-back="() => (openDomainTemplateId = null)" :on-created="(nid) => (openDomainTemplateId = nid)" :view-complete-prompt="viewDtCompletePrompt" :view-complete-assets-prompt="viewDtCompleteAssetsPrompt" :view-complete-cubes-prompt="viewDtCompleteCubesPrompt" :page-styles="pageStyles" :on-edit-assets="openDomainDeviceTemplates" :on-open-device-template="openDeviceTemplateEditor" :on-create-site="createSiteFromDomainTemplate" />
- <StyleLibrary v-if="page === 'styleLibrary'" :page-styles="pageStyles" :load-page-styles="loadPageStyles" :notify="notify" />
- <DeviceTemplates v-if="page === 'deviceTemplates'" :notify="notify" :on-view-cp-prompt="viewDeviceCpCompletePrompt" :go="go" :site-id="siteId" :domain-options="domainOptions" :initial-domain="deviceTemplateDomain" />
- <ResourcePlaceholder v-if="page === 'monitor-dashboard'" title="监控仪表板" />
- <ResourcePlaceholder v-if="page === 'twin-model'" title="数字孪生模型" />
- <ResourcePlaceholder v-if="page === 'ops-template'" title="运维模板" />
- <ResourcePlaceholder v-if="page === 'knowledge-template'" title="知识模板" />
- <ToolSettings v-if="page === 'toolSettings'" :model="model" :on-model-change="(v) => (model = v)" :notify="notify" />
- <DomainSettings v-if="page === 'domainSettings'" :domains="domainOptions" :notify="notify" :on-deleted="handleDomainDeleted" />
- <SiteConfigurationContext v-if="page === 'siteEdit' && site" :site="site" :on-back="backToHierarchy" />
- <div v-if="page === 'basic' || (page === 'siteEdit' && siteEditSection === 'basic')" class="station-overview-layout"><Basic :site="site" :domains="domains" :on-change="saveSiteBasic" :on-add-domain="addDomain" :on-upload="uploadFile" :saving="saving" :notify="notify" :on-view-complete-prompt="viewCompletePrompt" :go="go" :site-id="siteId" :site-hierarchy="siteHierarchy" /></div>
- <div v-if="(page === 'assets' || (page === 'siteEdit' && siteEditSection === 'assets')) && site" class="station-unified-assets"><ManualAssetConfiguration :key="site.id" :site="site" :notify="notify" :on-site-change="(s) => (site = s)" :register-save="(save) => (assetSaveRef = save)" /></div>
- <div v-if="page === 'opsflow' || (page === 'siteEdit' && siteEditSection === 'opsflow')" class="station-opsflow-blank" aria-label="运维流程配置预留区域" />
- <PageConfig v-if="(page === 'pageConfig' || (page === 'siteEdit' && siteEditSection === 'pageConfig')) && site" :site="site" :leaves="muLeaves" :on-mu-change="saveMu" :on-page-config-change="savePageConfiguration" :on-site-change="(s) => (site = s)" :render-domain-dashboard="renderDomainDashboard" :render-dashboard="renderDashboard" :notify="notify" :register-save="(save) => (pageSaveRef = save)" />
- <Functions v-if="(page === 'functions' || (page === 'siteEdit' && siteEditSection === 'functions')) && site" :site="site" :leaves="muLeaves" :on-site-change="(s) => (site = s)" :notify="notify" :register-save="(save) => (menuSaveRef = save)" />
- <Generate v-if="page === 'generate'" :site="site" :on-site-change="(s) => (site = s)" :notify="notify" :view-prompt="viewPrompt" :view-site-prompt="viewSitePrompt" />
- <Result v-if="page === 'result'" :site="site" :go="go" :set-modal="(m) => (modal = m)" :view-prompt="viewPrompt" :notify="notify" :open-feedback="openFeedback" :view-site-prompt="viewSitePrompt" />
- <Preview v-if="page === 'preview'" :site="site" :tab="previewTab" :set-tab="(t) => (previewTab = t)" :set-modal="(m) => (modal = m)" :notify="notify" />
- </div>
- <div v-if="page === 'siteEdit' || SITE_EDIT_STEPS.includes(page)" class="sticky-actions station-config-footer">
- <PrimaryButton secondary :disabled="configurationSaving || saving" @click="() => { const section = page === 'siteEdit' ? siteEditSection : page; const i = SITE_EDIT_STEPS.indexOf(section); if (i > 0) changeConfigurationStep(SITE_EDIT_STEPS[i - 1]); else backToHierarchy(); }">上一步</PrimaryButton>
- <div style="display: flex; gap: 12px">
- <PrimaryButton secondary :disabled="configurationSaving || saving" @click="saveConfigurationDraft">保存草稿</PrimaryButton>
- <PrimaryButton :disabled="configurationSaving || saving" @click="nextConfigurationStep">{{ (page === 'siteEdit' ? siteEditSection : page) === 'functions' ? '保存并进入生成确认' : '下一步' }}</PrimaryButton>
- </div>
- </div>
- <div v-if="page === 'generate'" class="sticky-actions">
- <PrimaryButton secondary @click="go('siteEdit', site?.id)">返回站点配置</PrimaryButton>
- </div>
- </main>
- </div>
- <UiOverlay v-if="addingDomain && page === 'domainSettings'" className="dlg-backdrop" :onMouseDown="() => { addingDomain = false; newDomainName = ''; }">
- <div class="dlg-box domain-create-dialog" @mousedown.stop>
- <h2>新增领域</h2>
- <p>创建后可在领域方案和站点基本信息中直接选择。</p>
- <UiInput autoFocus :value="newDomainName" maxLength="6" placeholder="请输入领域名称(最多 6 个字)" @change="(e) => (newDomainName = e.target.value)" @keydown="async (e) => { if (e.key === 'Enter') await doAddDomain(); if (e.key === 'Escape') { addingDomain = false; newDomainName = ''; } }" />
- <UiActions className="dlg-actions">
- <PrimaryButton secondary @click="() => { addingDomain = false; newDomainName = ''; }">取消</PrimaryButton>
- <PrimaryButton :disabled="!newDomainName.trim()" @click="doAddDomain">创建</PrimaryButton>
- </UiActions>
- </div>
- </UiOverlay>
- <ModalComp v-if="modal" :type="modal" :close="() => (modal = '')" :notify="notify" :prompt-data="promptData" :feedback-cube="feedbackCube" :refine-cube="refineCube" :refining="refining" :refine-log="refineLog" />
- <UiButton v-if="!aiOpen && !manualAssetActive && !domainArea" ref="fabRef" className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
- :style="fabPos ? { left: fabPos.x + 'px', top: page === 'siteEdit' ? Math.min(fabPos.y, win.innerHeight - 132) + 'px' : fabPos.y + 'px', right: 'auto', bottom: 'auto' } : page === 'siteEdit' ? { bottom: '76px' } : undefined"
- @pointerdown="onFabPointerDown" @pointermove="onFabPointerMove" @pointerup="onFabPointerUp" @dblclick="resetFab">
- <img src="/t/stationbuilder/ai-icon.png" alt="AI 助手" className="ai-fab-icon" draggable="false" />
- </UiButton>
- <div v-if="aiOpen && !manualAssetActive && !domainArea" class="ai-dialog">
- <header><span class="ai-dialog-logo">✦</span><div class="ai-dialog-title"><b>StationBuilder AI</b><small>模型 · {{ model }}</small></div><UiButton className="ai-close" @click="aiOpen = false">×</UiButton></header>
- <div class="ai-messages">
- <div v-if="aiMessages.length === 0" class="ai-welcome">
- <div class="ai-welcome-intro"><span>✦</span><div><b>我可以帮你建设场站</b><p>进入对应页面使用 AI 功能,或直接在下方提问。</p></div></div>
- <div class="ai-capability-list">
- <div><i>1</i><span><b>完善站点需求</b><small>站点编辑 → 站点基本属性</small></span></div>
- <div><i>2</i><span><b>推荐设备、数量与命名规则</b><small>站点编辑 → 站点资产设置</small></span></div>
- <div><i>3</i><span><b>规划并生成 Cube 页面</b><small>站点编辑 → 功能与菜单</small></span></div>
- <div><i>4</i><span><b>检查结果并按意见修改页面</b><small>站点编辑 → 生成确认/页面预览</small></span></div>
- <div><i>5</i><span><b>补全领域方案与设备采集点</b><small>建站资源 → 领域方案/设备模板</small></span></div>
- </div>
- <div class="ai-welcome-tip"><b>你可以问:</b>当前站点还缺少哪些设备或页面?</div>
- </div>
- <div v-for="(m, i) in aiMessages" :key="i" class="ai-bubble" :class="m.role === 'user' ? 'me' : 'bot'"><span class="ai-avatar">{{ m.role === 'user' ? 'W' : '✦' }}</span><div class="ai-text">{{ m.content || (m.role === 'assistant' && aiBusy ? '…' : '') }}</div></div>
- <div ref="aiEndRef" />
- </div>
- <footer>
- <UiInput className="ai-input" :value="aiDraft" placeholder="输入消息,Enter 发送,Shift+Enter 换行" @change="(e) => (aiDraft = e.target.value)" @keydown="(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendAi(); } }" />
- <UiButton className="ai-send" :disabled="aiBusy || !aiDraft.trim()" @click="sendAi">{{ aiBusy ? '生成中' : '发送' }}</UiButton>
- </footer>
- </div>
- </div>
-</template>
+++ /dev/null
-<svg width="20" height="20" viewBox="6 14 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6 14H12V20H6V14Z" fill="currentColor"/>
-<path d="M6 21H12V27H6V21Z" fill="currentColor"/>
-<path d="M19 14H13V20H19V14Z" fill="currentColor"/>
-<path d="M13 21H19V27H13V21Z" fill="currentColor"/>
-<path d="M26 14H20V20H26V14Z" fill="currentColor"/>
-<path d="M20 21H26V27H20V21Z" fill="currentColor"/>
-<path d="M12 28H6V34H12V28Z" fill="currentColor"/>
-<path d="M13 28H19V34H13V28Z" fill="currentColor"/>
-<path d="M26 28H20V34H26V28Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M11 9V23H9V9H11ZM23 10.707L17.707 16L23 21.293L21.5859 22.707L14.8789 16L21.5859 9.29297L23 10.707Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M23 23H21V9H23V23ZM17.1211 16L10.4141 22.707L9 21.293L14.293 16L9 10.707L10.4141 9.29297L17.1211 16Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13.7068 9.29303L20.4138 16.0004L13.7068 22.7069L12.2928 21.2929L17.586 16.0001L12.2928 10.707L13.7068 9.29303Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M18.9999 9.29303L12.2928 16.0004L18.9999 22.7069L20.4138 21.2929L15.1207 16.0001L20.4138 10.707L18.9999 9.29303Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_211_32681)">
-<path d="M23.855 10.3101L35.715 17.1551V30.8451L23.855 37.6901L12 30.8451V17.1551L23.855 10.3101Z" fill="url(#paint0_linear_211_32681)"/>
-<path d="M23.855 10.31L35.71 17.155V30.845L23.855 37.69L12 30.845V17.155L23.855 10.31ZM23.855 8L10 16V32L23.855 40L37.71 32V16L23.855 8Z" fill="url(#paint1_linear_211_32681)"/>
-<path d="M24.855 12.04V17.73L29.785 20.575V26.27L34.715 29.115V17.73L24.855 12.04Z" fill="url(#paint2_linear_211_32681)"/>
-<path d="M18.9299 15.465V21.155L23.8549 24V29.69L28.7849 32.535V21.155L18.9299 15.465Z" fill="url(#paint3_linear_211_32681)"/>
-<path d="M13 18.885L22.855 24.575V35.96L13 30.27V18.885Z" fill="url(#paint4_linear_211_32681)"/>
-</g>
-<defs>
-<linearGradient id="paint0_linear_211_32681" x1="12" y1="10.3101" x2="39.0997" y2="33.7823" gradientUnits="userSpaceOnUse">
-<stop offset="0.1" stop-color="white"/>
-<stop offset="0.8" stop-color="#BEDFFF"/>
-</linearGradient>
-<linearGradient id="paint1_linear_211_32681" x1="23.855" y1="8" x2="23.855" y2="40" gradientUnits="userSpaceOnUse">
-<stop offset="0.2" stop-color="#1E90FF"/>
-<stop offset="1" stop-color="#003CA6"/>
-</linearGradient>
-<linearGradient id="paint2_linear_211_32681" x1="29.785" y1="12.04" x2="29.785" y2="29.115" gradientUnits="userSpaceOnUse">
-<stop offset="0.2" stop-color="#1E90FF"/>
-<stop offset="1" stop-color="#003CA6"/>
-</linearGradient>
-<linearGradient id="paint3_linear_211_32681" x1="23.8574" y1="15.465" x2="23.8574" y2="32.535" gradientUnits="userSpaceOnUse">
-<stop offset="0.2" stop-color="#1E90FF"/>
-<stop offset="1" stop-color="#003CA6"/>
-</linearGradient>
-<linearGradient id="paint4_linear_211_32681" x1="17.9275" y1="18.885" x2="17.9275" y2="35.96" gradientUnits="userSpaceOnUse">
-<stop offset="0.2" stop-color="#1E90FF"/>
-<stop offset="1" stop-color="#003CA6"/>
-</linearGradient>
-<clipPath id="clip0_211_32681">
-<rect width="40" height="40" fill="white" transform="translate(4 4)"/>
-</clipPath>
-</defs>
-</svg>
+++ /dev/null
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M8 9L12.0001 15L16 9H8Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M9 16L15 11.9999L9 8L9 16Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="建站资源"><path d="m12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5"/></svg>
+++ /dev/null
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3254 15.2654L21.5741 15.0103C21.8091 13.9692 22.0001 13.0862 22.0001 12.0007C22.0001 10.9143 21.8096 10.03 21.5729 8.98855L19.3254 8.73284C19.2052 8.45275 19.0691 8.17876 18.9186 7.91261C18.7683 7.64732 18.6028 7.38958 18.4228 7.14142L19.3268 5.0293C18.5662 4.30021 17.9035 3.68575 16.9856 3.13806C16.0683 2.59066 15.2386 2.3123 14.2448 2L12.9043 3.86483C12.6024 3.83204 12.3042 3.80823 12.0001 3.80823C11.697 3.80823 11.3991 3.83174 11.0984 3.86454L9.75728 2.00029C8.76289 2.31202 7.93404 2.59066 7.01591 3.13777C6.09464 3.68692 5.43502 4.29469 4.67166 5.02524L5.57873 7.14056C5.39873 7.38872 5.23291 7.64645 5.08267 7.91202C4.93216 8.1779 4.79581 8.45159 4.67534 8.73227L2.42718 8.98885C2.18992 10.0308 2 10.9138 2 12.0007C2 13.0871 2.1902 13.9689 2.42746 15.0103L4.67421 15.2654C4.79467 15.5464 4.93102 15.8209 5.08182 16.088C5.23235 16.3536 5.3976 16.611 5.5776 16.8586L4.67194 18.9748C5.43473 19.7044 6.09408 20.3116 7.01448 20.8608C7.93178 21.4079 8.76176 21.6871 9.75501 22L11.0964 20.134C11.3977 20.168 11.6965 20.1912 12.0001 20.1912C12.3042 20.1912 12.6024 20.1668 12.9043 20.134L14.2448 22C15.2386 21.6868 16.0692 21.4073 16.9868 20.8602C17.9077 20.3108 18.5671 19.7039 19.3305 18.9739L18.4239 16.8571C18.6039 16.609 18.7698 16.3515 18.92 16.0857C19.0694 15.8212 19.2049 15.5475 19.3254 15.2654ZM20 12.0007C20 12.3899 19.9682 12.7577 19.9181 13.1417L17.8757 13.3736L17.8353 13.4948C17.6521 14.0446 17.4861 14.5425 17.1931 15.0617C16.8956 15.5882 16.5545 15.987 16.1752 16.4306L16.1752 16.4306L16.1007 16.5178L16.9236 18.439C16.6262 18.6787 16.3133 18.8973 15.9867 19.092C15.6681 19.2821 15.3362 19.4502 14.9934 19.5936L13.7779 17.9014L13.6271 17.9319C13.0708 18.0446 12.5843 18.1432 12.0001 18.1432C11.4129 18.1432 10.9499 18.0489 10.3916 17.9352L10.3916 17.9352L10.2239 17.9011L9.00667 19.5939C8.66425 19.4502 8.33259 19.2824 8.01454 19.0926C7.68828 18.8981 7.37534 18.6796 7.07855 18.4398L7.90144 16.5181L7.82349 16.427C7.44547 15.9856 7.10535 15.5884 6.80869 15.064C6.51751 14.549 6.35033 14.0483 6.16785 13.5018L6.12497 13.3736L4.08204 13.1417C4.03188 12.7574 4.00012 12.3902 4.00012 12.0007C4.00012 11.6101 4.03188 11.242 4.08233 10.8566L6.12441 10.6238L6.16413 10.5053C6.34838 9.95514 6.5161 9.4543 6.80954 8.93602C7.10617 8.41143 7.44674 8.0127 7.82454 7.57039L7.82455 7.57039L7.90258 7.47898L7.07911 5.55929C7.37647 5.31955 7.68913 5.1007 8.01597 4.90595C8.3343 4.71641 8.66567 4.54837 9.00866 4.40498L10.2259 6.09741L10.3744 6.06722C10.9318 5.95362 11.4097 5.85622 12.0001 5.85622C12.5922 5.85622 13.0714 5.95363 13.6311 6.0674L13.7777 6.09712L14.9934 4.40527C15.3359 4.54866 15.6672 4.71641 15.9856 4.90624C16.3147 5.10274 16.6279 5.321 16.9227 5.55813L16.1004 7.48072L16.1753 7.5686C16.5537 8.01204 16.8943 8.41117 17.1917 8.9366C17.4823 9.45045 17.6494 9.94985 17.8319 10.4951L17.8754 10.625L19.9178 10.8569C19.9677 11.242 20 11.6103 20 12.0007ZM14.0002 11.9993C14.0002 13.1257 13.1005 14.047 12.0003 14.047C10.8988 14.047 10.0002 13.1286 10.0002 11.9993C10.0002 10.8714 10.8974 9.95159 12.0003 9.95159C13.105 9.95159 14.0002 10.8682 14.0002 11.9993ZM12.0003 16.0949C9.80009 16.0949 8.00009 14.2542 8.00009 11.9993C8.00009 9.74696 9.79896 7.90361 12.0003 7.90361C14.198 7.90361 16.0003 9.74871 16.0003 11.9993C16.0003 14.2557 14.204 16.0949 12.0003 16.0949Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="设备模板"><rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 3v3m6-3v3M9 18v3m6-3v3M3 9h3m-3 6h3m12-6h3m-3 6h3"/></svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="数字孪生模型"><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3ZM4 7.5l8 4.5 8-4.5M12 12v9"/><path d="m8 5.3 8 4.5v4.5" stroke-dasharray="2 2"/></svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="领域配置"><path d="M4 7h6m4 0h6M4 17h10m4 0h2"/><circle cx="12" cy="7" r="2"/><circle cx="16" cy="17" r="2"/><path d="M4 12h2m4 0h10"/><circle cx="8" cy="12" r="2"/></svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="领域方案"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M14 17.5h7m-3.5-3.5v7"/></svg>
+++ /dev/null
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M9.6139 1.99976H1.99976V21.9999H21.9999V3.99944H11.6135L9.6139 1.99976ZM3.99989 5.99957H19.9998V19.9998H3.99989V5.99957Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="内部工具配置"><path d="M14 4a6 6 0 0 0-7.4 7.4L3.5 14.5a3 3 0 0 0 4.2 4.2l3.1-3.1A6 6 0 0 0 18.2 8L15 11l-3-3 2-4Z"/><circle cx="5.8" cy="16.5" r=".5"/></svg>
+++ /dev/null
-<svg width="123" height="40" viewBox="0 0 123 40" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_48899_475337)">
-<path d="M44.9346 7.54688C47.4309 7.54689 50.0724 8.61211 51.9639 10.708L49.3818 14.3203C47.9591 13.1276 46.6216 12.4796 44.9434 12.4795C43.3501 12.4795 42.3613 13.1874 42.3613 14.4824C42.3615 15.7772 43.7248 16.3224 45.6416 17.1572L48.3428 18.3506C51.0093 19.5434 52.6113 21.5454 52.6113 24.9277C52.6113 28.9236 49.5945 32.4424 44.3633 32.4424C41.5518 32.4423 38.5271 31.2839 36.2949 28.9922H36.2871L39.2178 25.0215C40.7599 26.4698 42.7621 27.4756 44.4746 27.4756C46.3915 27.4755 47.2861 26.6654 47.2861 25.3789C47.286 23.9905 46.0933 23.5388 44.2363 22.6699L41.4756 21.3838C39.2093 20.3869 37.0957 18.2903 37.0957 14.8057C37.0958 10.7758 40.3509 7.54688 44.9346 7.54688ZM99.1133 13.1191C103.339 13.1192 107.216 16.6805 107.216 22.7979C107.216 28.9151 103.314 32.4423 99.1133 32.4424C94.913 32.4424 90.9854 28.881 90.9854 22.7979C90.9854 16.6805 94.8874 13.1191 99.1133 13.1191ZM6.6709 18.9297L7.54004 26.5381H7.65918C8.06813 23.9906 8.50299 21.409 8.9375 18.9297L11.1445 7.99805H15.498L17.7646 18.9297C18.1992 21.3834 18.5733 23.9651 19.0078 26.5381H19.1533C19.4174 23.9566 19.7063 21.409 19.9619 18.9297L21.3594 7.99805H26.293L22.5439 31.999H16.0088L14.0664 21.5449C13.7171 19.7048 13.4271 17.7709 13.2227 15.9648H13.0781C12.814 17.7709 12.5581 19.7047 12.2344 21.5449L10.3516 31.999H3.94434L0 7.99805H5.30762L6.6709 18.9297ZM33.5518 31.999H28.3545V7.99805H33.5518V31.999ZM69.0293 12.7695H60.5264V17.2168H67.751V22.0566H60.5264V27.1514H69.3525V31.9912H55.3291V7.99805H69.0293V12.7695ZM88.207 31.9912H82.9932V7.90527H88.207V31.9912ZM122.458 12.7012H116.57V31.9912H111.356V12.7012H105.503V7.91309H122.458V12.7012ZM99.1133 17.7793C97.1963 17.7793 96.2842 19.7221 96.2842 22.7979C96.2842 25.8735 97.1622 27.7822 99.1133 27.7822C101.064 27.7822 101.908 25.8393 101.908 22.7979C101.908 19.7222 101.03 17.7794 99.1133 17.7793ZM79.9092 24.6719H71.6953V20.6416H79.9092V24.6719Z" fill="#004280"/>
-</g>
-<defs>
-<clipPath id="clip0_48899_475337">
-<rect width="123" height="40" fill="white"/>
-</clipPath>
-</defs>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="知识模板"><path d="M12 5v15M12 5c-3-2-6-2-9-1v15c3-1 6-1 9 1 3-2 6-2 9-1V4c-3-1-6-1-9 1ZM6 8h3m-3 4h3m6-4h3m-3 4h3"/></svg>
+++ /dev/null
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M2 2H8V8H2V2Z" fill="currentColor"/>
-<path d="M2 9H8V15H2V9Z" fill="currentColor"/>
-<path d="M15 2H9V8H15V2Z" fill="currentColor"/>
-<path d="M9 9H15V15H9V9Z" fill="currentColor"/>
-<path d="M22 2H16V8H22V2Z" fill="currentColor"/>
-<path d="M16 9H22V15H16V9Z" fill="currentColor"/>
-<path d="M8 16H2V22H8V16Z" fill="currentColor"/>
-<path d="M9 16H15V22H9V16Z" fill="currentColor"/>
-<path d="M22 16H16V22H22V16Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M4 5H20.0002V7H4V5ZM4 17H20.0002V19H4V17ZM20.0002 11.0001H4V13.0001H20.0002V11.0001Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="监控仪表板"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4M6 13l3-3 3 2 5-5m-3 0h3v3"/></svg>
+++ /dev/null
-<svg width="20" height="20" viewBox="6 14 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M17.001 15.084C19.875 15.5689 21.9999 18.0774 22 21V23.7217C23.2804 25.1778 24 27.0598 24 29.001L23.9971 31.001H19C18.9994 32.6537 17.6536 33.9997 16.001 34C14.3478 34 13.0006 32.6541 13 31.001H8.00488L8 29.001C8 27.0601 8.71905 25.1775 10 23.7217V21C10.0001 18.082 12.1299 15.5683 15 15.084V14H17.001V15.084ZM15 31.001C15.0006 31.5506 15.4513 32 16.001 32C16.5501 31.9997 16.9994 31.5502 17 31.001H15ZM16.001 17C13.7953 17 12 18.7944 12 21V24.5127C10.7229 25.8185 10 27.1169 10 29C10 29 21.952 29.0003 22 29C22 27.1173 21.2767 25.8183 20 24.5127V21C20 18.7973 18.2036 17.0002 16.001 17Z" fill="currentColor"/>
-<path d="M25 17.5C25 19.433 23.433 21 21.5 21C19.567 21 18 19.433 18 17.5C18 15.567 19.567 14 21.5 14C23.433 14 25 15.567 25 17.5Z" fill="#F55D3E"/>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="运维模板"><rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M9 6h7a2 2 0 0 1 2 2v3m-2-2 2 2 2-2M15 18H8a2 2 0 0 1-2-2v-3m-2 2 2-2 2 2"/></svg>
+++ /dev/null
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M14.473 15.887L18.293 19.707L19.708 18.293L15.888 14.473C16.6122 13.4595 17.001 12.2447 17 10.999C17 7.69402 14.305 4.99902 11 4.99902C7.696 4.99902 5 7.69602 5 10.999C5 14.302 7.698 16.999 11 16.999C12.2454 16.9999 13.4598 16.6111 14.473 15.887ZM15 11C15 13.2 13.202 15 11 15C8.795 15 7 13.203 7 11C7 8.79602 8.795 7.00002 11 7.00002C13.201 7.00002 15 8.79702 15 11Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="站点管理"><path d="M3 21h18M5 21V9l7-5 7 5v12M9 21v-5h6v5M9 10h.01M15 10h.01M9 13h.01M15 13h.01"/></svg>
+++ /dev/null
-<svg width="20" height="20" viewBox="6 14 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M6 24.0003C6 29.5092 10.4926 34.0001 16.0012 34.0001C21.5098 34.0001 26.0001 29.5086 26.0001 24.0003C26.0001 18.4921 21.51 14 16.0012 14C10.4926 14 6 18.4915 6 24.0003ZM24 24.0003C24 28.4051 20.4062 32 16.0012 32C11.5964 32 8.00012 28.4057 8.00012 24.0003C8.00012 19.595 11.5962 16.0001 16.0012 16.0001C20.4065 16.0001 24 19.5956 24 24.0003ZM15.1417 26.4355H16.8918C16.8825 25.906 16.9267 25.5397 17.0248 25.3342C17.1228 25.129 17.3748 24.856 17.7807 24.5198C18.5671 23.8667 19.0782 23.3508 19.3186 22.973C19.5586 22.5948 19.6777 22.1912 19.6777 21.7688C19.6777 21.0012 19.3511 20.3316 18.6978 19.7553C18.0444 19.1788 17.1648 18.8896 16.0587 18.8896C15.0088 18.8896 14.1595 19.1742 13.5132 19.746C12.8666 20.3175 12.5191 21.0105 12.4678 21.8295L14.2318 22.0537C14.3554 21.4819 14.5818 21.0573 14.9107 20.7772C15.242 20.4994 15.6505 20.3597 16.1403 20.3597C16.6492 20.3597 17.0528 20.4926 17.3513 20.7585C17.6523 21.0222 17.8017 21.3419 17.8017 21.713C17.8017 21.9811 17.7178 22.2286 17.5475 22.4525C17.4378 22.5948 17.1019 22.8936 16.5395 23.3485C15.9771 23.8061 15.6037 24.2165 15.4169 24.5805C15.2282 24.947 15.1346 25.4113 15.1346 25.976C15.1346 26.0318 15.1372 26.1835 15.1417 26.4355ZM15.1418 29.0117V27.0938H16.892V29.0117H15.1418Z" fill="currentColor"/>
-</svg>
+++ /dev/null
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="系统设定"><path d="m9 3-.6 2.2-1.8 1L4.4 6 2.9 8.6 4.5 10v2L3 13.6l1.5 2.6 2.2-.3 1.7 1L9 19h3l.6-2.2 1.8-1 2.2.3 1.5-2.6-1.6-1.5v-2l1.5-1.5L16.5 6l-2.2.3-1.7-1L12 3H9Z" transform="translate(1.5 1)"/><circle cx="12" cy="12" r="3"/></svg>
+++ /dev/null
-<template>
- <div class="content dashboard-page">
- <img src="/t/stationbuilder/dashboard-preview.jpg" alt="仪表盘示意" loading="lazy" />
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, watch, onMounted } from "vue";
-import { loadRenderer, fetchDb } from "../lib/api.js";
-
-const props = defineProps({
- siteId: { type: Number, required: true },
- cube: { type: Object, required: true },
- fixedHeight: { type: Number, default: undefined },
-});
-
-const outer = ref(null);
-const inner = ref(null);
-
-async function render() {
- const o = outer.value;
- const i = inner.value;
- if (!o || !i) return;
- if (!props.cube.id) {
- i.innerHTML = '<div class="thumb-empty">未生成</div>';
- return;
- }
- try {
- const json = await fetchDb(`/sites/${props.siteId}/cubes/${props.cube.id}/file`);
- const renderDashboard = await loadRenderer();
- renderDashboard(json, i, {});
- const w = o.clientWidth || 300;
- const pw = json.grid?.pageWidth || 1920;
- const rootEl = i.firstElementChild;
- const rh = rootEl ? rootEl.offsetHeight : json.grid?.pageHeight || 1080;
- const s = Math.max(0.02, props.fixedHeight ? Math.min(w / pw, props.fixedHeight / rh) : w / pw);
- i.style.height = `${rh}px`;
- i.style.transform = `scale(${s})`;
- i.style.transformOrigin = "top left";
- if (o) o.style.height = `${props.fixedHeight || rh * s}px`;
- } catch (e) {
- i.innerHTML = '<div style="padding:8px;color:var(--ant-color-text-tertiary)">预览不可用</div>';
- }
-}
-
-onMounted(render);
-watch(() => [props.cube.id, props.cube.generated_at, props.fixedHeight], render);
-</script>
-
-<template>
- <div ref="outer" class="dashboard-thumb-live" style="overflow: hidden">
- <div ref="inner" style="width: 1920px; height: 1080px; position: relative"></div>
- </div>
-</template>
+++ /dev/null
-// Shared device-template card + helpers (ported from app/page.tsx).
-// Extracted so both the DomainSettings page and the DeviceTemplates page can reuse it.
-import { ref, computed, h } from "vue";
-import { Drawer, Button as AntButton } from "ant-design-vue";
-import {
- EditOutlined, DeleteOutlined, CopyOutlined, PlusOutlined, RobotOutlined,
- SearchOutlined, ControlOutlined,
-} from "@ant-design/icons-vue";
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiTable from "../ui/UiTable.vue";
-import UiActions from "../ui/UiActions.vue";
-import PrimaryButton from "../components/PrimaryButton.vue";
-import { fetchDb } from "../lib/api.js";
-import { useConfirm } from "../pages/domain/useConfirm.js";
-import { useStreamComplete, streamInto } from "../pages/domain/domainCommon.js";
-
-// React local `Button` (PrimaryButton primary/default + "button" class).
-const Button = PrimaryButton;
-
-const CP_RANGE_PRESETS = ["0~100", "0~1000", "-20~80", "-40~85", "0~50", "0~5", "0~1", "0~100%"];
-
-function fileToBase64(file) {
- return new Promise((resolve, reject) => {
- const r = new FileReader();
- r.onload = () => {
- const buf = new Uint8Array(r.result);
- let bin = "";
- for (const b of buf) bin += String.fromCharCode(b);
- resolve({ filename: file.name, mime: file.type || "application/octet-stream", data: btoa(bin) });
- };
- r.onerror = reject;
- r.readAsArrayBuffer(file);
- });
-}
-
-const DeviceImageField = {
- props: { imageUrl: { type: String, default: null }, onPick: { type: Function, default: () => {} } },
- setup(p) {
- const inputRef = ref(null);
- return () =>
- h("div", { class: "device-img-field", onClick: () => inputRef.value && inputRef.value.click() }, [
- p.imageUrl
- ? h("img", { src: p.imageUrl, alt: "设备图片" })
- : h("span", { class: "device-img-empty" }, "+ 点击上传设备图片"),
- h("input", {
- ref: inputRef,
- type: "file",
- accept: "image/*",
- hidden: true,
- onChange: (e) => {
- const f = e.target.files && e.target.files[0];
- if (f) p.onPick(f);
- e.target.value = "";
- },
- }),
- ]);
- },
-};
-
-const DeviceTemplateCard = {
- props: {
- dev: { type: Object, default: () => ({}) },
- notify: { type: Function, default: () => {} },
- onChange: { type: Function, default: () => {} },
- onBack: { type: Function, default: () => {} },
- onViewCpPrompt: { type: Function, default: () => {} },
- },
- setup(p) {
- const name = ref(p.dev.name);
- const description = ref(p.dev.description || "");
- const imgPreview = ref(p.dev.image_path ? `/api/db/device-templates/${p.dev.id}/image` : null);
- const imgB64 = ref(null);
- const cps = ref((p.dev.collection_points || []).map((c) => ({ ...c, key: c.id, checked: false })));
- const aiOpen = ref(false);
- const abortRef = ref(null);
- const { confirm, el: confirmEl } = useConfirm();
-
- const onPick = async (f) => {
- imgPreview.value = URL.createObjectURL(f);
- const b = await fileToBase64(f);
- imgB64.value = { filename: b.filename, mime: b.mime, data: b.data };
- };
- const save = async () => {
- const body = { name: name.value, description: description.value };
- if (imgB64.value) body.image = imgB64.value;
- await fetchDb(`/device-templates/${p.dev.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- p.notify("设备已保存");
- p.onChange();
- };
- const delDev = async () => {
- if (!(await confirm("删除该设备模板?"))) return;
- await fetchDb(`/device-templates/${p.dev.id}`, { method: "DELETE" });
- p.onChange();
- p.onBack();
- };
- const addCpRow = () => cps.value.push({ key: -(Date.now()), name: "", collect_note: "", unit: "", value_range: "", description: "", checked: false });
- const updCp = (k, field, val) => (cps.value = cps.value.map((c) => (c.key === k ? { ...c, [field]: val } : c)));
- const toggleCp = (k) => (cps.value = cps.value.map((c) => (c.key === k ? { ...c, checked: !c.checked } : c)));
- const allChecked = computed(() => cps.value.length > 0 && cps.value.every((c) => c.checked));
- const toggleAll = () => (cps.value = cps.value.map((c) => ({ ...c, checked: !allChecked.value })));
- const delCpRows = async () => {
- const sel = cps.value.filter((c) => c.checked);
- if (sel.length === 0) { p.notify("请先勾选要删除的采集点"); return; }
- if (!(await confirm(`确定删除选中的 ${sel.length} 个采集点?`))) return;
- for (const c of sel) if (c.id) await fetchDb(`/collection-points/${c.id}`, { method: "DELETE" });
- cps.value = cps.value.filter((c) => !c.checked);
- p.onChange();
- };
- const saveCpRows = async () => {
- try {
- for (const c of cps.value) {
- const payload = { name: c.name, collect_note: c.collect_note, unit: c.unit, value_range: c.value_range, description: c.description };
- if (c.id) await fetchDb(`/collection-points/${c.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
- else await fetchDb(`/device-templates/${p.dev.id}/collection-points`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
- }
- p.notify("采集点已保存");
- p.onChange();
- } catch (e) {
- p.notify("保存失败:" + (e && e.message ? e.message : e));
- }
- };
-
- const attrs = ref((p.dev.attributes || []).map((a) => ({ ...a, key: a.id, checked: false })));
- const addAttrRow = () => attrs.value.push({ key: -(Date.now()), keyword: "", name: "", value: "", unit: "", checked: false });
- const updAttr = (k, field, val) => (attrs.value = attrs.value.map((a) => (a.key === k ? { ...a, [field]: val } : a)));
- const toggleAttr = (k) => (attrs.value = attrs.value.map((a) => (a.key === k ? { ...a, checked: !a.checked } : a)));
- const allAttrChecked = computed(() => attrs.value.length > 0 && attrs.value.every((a) => a.checked));
- const toggleAllAttr = () => (attrs.value = attrs.value.map((a) => ({ ...a, checked: !allAttrChecked.value })));
- const delAttrRows = async () => {
- const sel = attrs.value.filter((a) => a.checked);
- if (sel.length === 0) { p.notify("请先勾选要删除的属性"); return; }
- if (!(await confirm(`确定删除选中的 ${sel.length} 个属性?`))) return;
- for (const a of sel) if (a.id) await fetchDb(`/attributes/${a.id}`, { method: "DELETE" });
- attrs.value = attrs.value.filter((a) => !a.checked);
- p.onChange();
- };
- const saveAttrRows = async () => {
- try {
- for (const a of attrs.value) {
- const payload = { keyword: a.keyword, name: a.name, value: a.value, unit: a.unit };
- if (a.id) await fetchDb(`/attributes/${a.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
- else await fetchDb(`/device-templates/${p.dev.id}/attributes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
- }
- p.notify("属性已保存");
- p.onChange();
- } catch (e) {
- p.notify("保存失败:" + (e && e.message ? e.message : e));
- }
- };
-
- const alarmRules = ref(
- Array.isArray(p.dev.alarm_rules) && p.dev.alarm_rules.length
- ? p.dev.alarm_rules
- : [
- { id: 1, name: "设备离线", level: "高", category: "故障", method: "自动", description: "设备连续 5 分钟无数据上报", enabled: true, sensitivityType: "duration", sensitivityValue: "5", typeName: "离线报警", triggers: [{ id: 11, name: "离线判断", source: "当前设备", compareType: "状态", point: "在线状态", operator: "=", threshold: "0" }] },
- { id: 2, name: "电压过高", level: "高", category: "报警", method: "自动", description: "供电电压超过额定上限", enabled: true, sensitivityType: "consecutive", sensitivityValue: "1", typeName: "阈值报警", triggers: [{ id: 21, name: "电压上限", source: "当前设备", compareType: "数值", point: "供电电压", operator: ">", threshold: "240" }] },
- ]
- );
- const alarmQuery = ref("");
- const selectedAlarmIds = ref(new Set());
- const alarmDraft = ref(null);
- const alarmFiltered = computed(() =>
- alarmRules.value.filter((rule) => !alarmQuery.value.trim() || `${rule.name} ${rule.description || ""}`.toLowerCase().includes(alarmQuery.value.trim().toLowerCase()))
- );
- const newAlarmDraft = () => ({
- id: null, name: "", level: "高", category: "报警", method: "自动", description: "", enabled: true,
- typeName: "阈值报警", sensitivityType: "consecutive", sensitivityValue: "1",
- triggers: [{ id: -Date.now(), name: "规则 1", source: "当前设备", compareType: "数值", point: cps.value[0] ? cps.value[0].name : "", operator: ">", threshold: "" }],
- });
- const editAlarm = (rule) => (alarmDraft.value = { ...rule, triggers: (rule.triggers || []).map((item) => ({ ...item })) });
- const saveAlarm = () => {
- if (!alarmDraft.value || !alarmDraft.value.name || !alarmDraft.value.name.trim()) { p.notify("请填写报警名称"); return; }
- if (!alarmDraft.value.triggers || !alarmDraft.value.triggers.length) { p.notify("请至少添加一条触发规则"); return; }
- const isCreate = alarmDraft.value.id == null;
- const saved = { ...alarmDraft.value, id: alarmDraft.value.id != null ? alarmDraft.value.id : Date.now() };
- alarmRules.value = isCreate ? [...alarmRules.value, saved] : alarmRules.value.map((rule) => (rule.id === saved.id ? saved : rule));
- alarmDraft.value = null;
- p.notify(isCreate ? "报警规则已新增" : "报警规则已更新");
- };
- const copyAlarm = (rule) => {
- const copied = { ...rule, id: Date.now(), name: `${rule.name} - 副本`, triggers: (rule.triggers || []).map((item, index) => ({ ...item, id: -(Date.now() + index) })) };
- alarmRules.value = [...alarmRules.value, copied];
- p.notify("已复制报警规则");
- };
- const removeAlarm = async (rule) => {
- if (!(await confirm(`确定删除报警规则「${rule.name}」?`))) return;
- alarmRules.value = alarmRules.value.filter((item) => item.id !== rule.id);
- const next = new Set(selectedAlarmIds.value); next.delete(rule.id); selectedAlarmIds.value = next;
- p.notify("报警规则已删除");
- };
- const toggleAlarm = (id) => (alarmRules.value = alarmRules.value.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)));
- const toggleAlarmSelect = (id) => {
- const next = new Set(selectedAlarmIds.value);
- if (next.has(id)) next.delete(id); else next.add(id);
- selectedAlarmIds.value = next;
- };
- const allAlarmsChecked = computed(() => alarmFiltered.value.length > 0 && alarmFiltered.value.every((rule) => selectedAlarmIds.value.has(rule.id)));
- const toggleAllAlarms = () => {
- const next = new Set(selectedAlarmIds.value);
- if (allAlarmsChecked.value) alarmFiltered.value.forEach((rule) => next.delete(rule.id));
- else alarmFiltered.value.forEach((rule) => next.add(rule.id));
- selectedAlarmIds.value = next;
- };
- const updateAlarmTrigger = (id, field, value) =>
- (alarmDraft.value = { ...alarmDraft.value, triggers: alarmDraft.value.triggers.map((item) => (item.id === id ? { ...item, [field]: value } : item)) });
- const addAlarmTrigger = () =>
- (alarmDraft.value = { ...alarmDraft.value, triggers: [...alarmDraft.value.triggers, { id: -Date.now(), name: `规则 ${alarmDraft.value.triggers.length + 1}`, source: "当前设备", compareType: "数值", point: cps.value[0] ? cps.value[0].name : "", operator: ">", threshold: "" }] });
- const removeAlarmTrigger = (id) =>
- (alarmDraft.value = { ...alarmDraft.value, triggers: alarmDraft.value.triggers.filter((item) => item.id !== id) });
-
- const cpAi = useStreamComplete();
- const runCpComplete = async () => {
- if (cpAi.completing.value) return;
- cpAi.setCompleting(true); cpAi.setLog("");
- if (abortRef.value) abortRef.value.abort();
- const controller = new AbortController();
- abortRef.value = controller;
- const wd = setTimeout(() => { controller.abort(); cpAi.setCompleting(false); p.notify("AI 采集点补全超时(>10 分钟),请重试"); }, 600000);
- try {
- const res = await fetch(`/api/db/device-templates/${p.dev.id}/complete-collection-points`, { method: "POST", signal: controller.signal });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- await streamInto(res, cpAi.setLog, () => {});
- const fresh = await fetchDb(`/device-templates/${p.dev.id}`);
- cps.value = (fresh.collection_points || []).map((c, i) => ({ ...c, key: c.id || -(i + 1), checked: false }));
- p.notify("AI 采集点补全已完成(已保存到该设备模板)");
- } catch (e) {
- if (e && e.name === "AbortError" || (abortRef.value && abortRef.value.signal.aborted)) {
- cpAi.setLog((prev) => prev + "\n⏹ 已停止\n");
- p.notify("AI 采集点补全已停止");
- } else {
- p.notify("AI 采集点补全失败:" + (e && e.message ? e.message : e));
- }
- } finally {
- clearTimeout(wd); cpAi.setCompleting(false); abortRef.value = null;
- }
- };
- const stopCpComplete = async () => {
- if (!cpAi.completing.value || !abortRef.value) return;
- try { await fetchDb(`/device-templates/${p.dev.id}/complete-collection-points/stop`, { method: "POST" }); } catch {}
- abortRef.value.abort();
- };
- const viewCpCompletePrompt = () => p.onViewCpPrompt(p.dev.id);
-
- return () => [
- confirmEl && h(confirmEl),
- h("div", { class: "device-card" }, [
- h("div", { class: "device-card-head" }, [
- h("b", {}, name.value || "(未命名)"),
- h(UiButton, { class: "text-button", onClick: delDev }, "删除设备"),
- ]),
- h("div", { class: "form-grid" }, [
- h("label", {}, ["设备名称", h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value) })]),
- h("label", { style: { gridColumn: "1 / -1" } }, ["设备图片", h(DeviceImageField, { imageUrl: imgPreview.value, onPick })]),
- h("label", { style: { gridColumn: "1 / -1" } }, [
- "说明",
- h(UiInput, { textarea: true, value: description.value, onChange: (e) => (description.value = e.target.value), rows: 2, placeholder: "设备说明,可描述其用途,特性,常见使用方式等。" }),
- ]),
- ]),
- h("div", { class: "cp-list" }, [
- h("div", { class: "cp-list-head" }, [
- h("b", {}, "采集点"),
- h("span", {}, [
- h(UiButton, { class: "text-button", onClick: addCpRow }, "+ 新增一行"),
- h(UiButton, { class: "text-button", onClick: delCpRows }, "删除选中"),
- h(UiButton, { class: "text-button", onClick: saveCpRows }, "保存采集点"),
- ]),
- ]),
- h(UiTable, { class: "data-table cp-editor" }, () => [
- h("thead", {}, h("tr", {}, [
- h("th", { style: { width: 34 } }, h(UiInput, { type: "checkbox", checked: allChecked.value, onChange: toggleAll })),
- h("th", {}, "名称"), h("th", {}, "说明"), h("th", {}, "单位"), h("th", {}, "数值范围"), h("th", {}, "描述"),
- ])),
- h("tbody", {}, [
- cps.value.length === 0
- ? h("tr", {}, h("td", { colSpan: 6, class: "muted" }, "暂无采集点,点「+ 新增一行」添加"))
- : cps.value.map((c) =>
- h("tr", { key: c.key }, [
- h("td", {}, h(UiInput, { type: "checkbox", checked: !!c.checked, onChange: () => toggleCp(c.key) })),
- h("td", {}, h(UiInput, { value: c.name, onChange: (e) => updCp(c.key, "name", e.target.value), placeholder: "名称" })),
- h("td", {}, h(UiInput, { value: c.collect_note || "", onChange: (e) => updCp(c.key, "collect_note", e.target.value), placeholder: "说明" })),
- h("td", {}, h(UiInput, { value: c.unit || "", onChange: (e) => updCp(c.key, "unit", e.target.value), placeholder: "单位" })),
- h("td", {}, [
- h(UiSelect, {
- value: CP_RANGE_PRESETS.includes(c.value_range) ? c.value_range : "__custom__",
- onChange: (e) => updCp(c.key, "value_range", e.target.value === "__custom__" ? "" : e.target.value),
- }, () => [
- ...CP_RANGE_PRESETS.map((pp) => h("option", { key: pp, value: pp }, pp)),
- h("option", { value: "__custom__" }, "自定义…"),
- ]),
- !CP_RANGE_PRESETS.includes(c.value_range)
- ? h(UiInput, { class: "cp-range-custom", value: c.value_range, onChange: (e) => updCp(c.key, "value_range", e.target.value), placeholder: "如 0~100" })
- : null,
- ]),
- h("td", {}, h(UiInput, { value: c.description || "", onChange: (e) => updCp(c.key, "description", e.target.value), placeholder: "描述" })),
- ])
- ),
- ]),
- ]),
- ]),
- h("div", { class: "cp-list" }, [
- h("div", { class: "cp-list-head" }, [
- h("b", {}, "属性列表"),
- h("span", {}, [
- h(UiButton, { class: "text-button", onClick: addAttrRow }, "+ 新增一行"),
- h(UiButton, { class: "text-button", onClick: delAttrRows }, "删除选中"),
- h(UiButton, { class: "text-button", onClick: saveAttrRows }, "保存属性"),
- ]),
- ]),
- h(UiTable, { class: "data-table cp-editor" }, () => [
- h("thead", {}, h("tr", {}, [
- h("th", { style: { width: 34 } }, h(UiInput, { type: "checkbox", checked: allAttrChecked.value, onChange: toggleAllAttr })),
- h("th", {}, "关键字"), h("th", {}, "属性名称"), h("th", {}, "属性值"), h("th", {}, "单位"),
- ])),
- h("tbody", {}, [
- attrs.value.length === 0
- ? h("tr", {}, h("td", { colSpan: 5, class: "muted" }, "暂无属性,点「+ 新增一行」添加"))
- : attrs.value.map((a) =>
- h("tr", { key: a.key }, [
- h("td", {}, h(UiInput, { type: "checkbox", checked: !!a.checked, onChange: () => toggleAttr(a.key) })),
- h("td", {}, h(UiInput, { value: a.keyword || "", onChange: (e) => updAttr(a.key, "keyword", e.target.value), placeholder: "关键字" })),
- h("td", {}, h(UiInput, { value: a.name || "", onChange: (e) => updAttr(a.key, "name", e.target.value), placeholder: "属性名称" })),
- h("td", {}, h(UiInput, { value: a.value || "", onChange: (e) => updAttr(a.key, "value", e.target.value), placeholder: "属性值" })),
- h("td", {}, h(UiInput, { value: a.unit || "", onChange: (e) => updAttr(a.key, "unit", e.target.value), placeholder: "单位" })),
- ])
- ),
- ]),
- ]),
- ]),
- h("div", { class: "cp-list alarm-rule-block" }, [
- h("div", { class: "alarm-rule-head" }, [
- h("b", {}, "报警规则"),
- h("div", {}, [
- h("label", { class: "alarm-rule-search" }, [h(SearchOutlined), h(UiInput, { value: alarmQuery.value, onChange: (e) => (alarmQuery.value = e.target.value), placeholder: "报警名称" })]),
- h(Button, { onClick: () => (alarmDraft.value = newAlarmDraft()) }, [h(PlusOutlined), " 新增"]),
- ]),
- ]),
- h(UiTable, { class: "data-table alarm-rule-table" }, () => [
- h("thead", {}, h("tr", {}, [
- h("th", { style: { width: 42 } }, h(UiInput, { type: "checkbox", checked: allAlarmsChecked.value, onChange: toggleAllAlarms })),
- h("th", {}, "报警名称"), h("th", {}, "报警等级"), h("th", {}, "报警类别"), h("th", {}, "处理方式"), h("th", {}, "报警描述"),
- h("th", { style: { width: 76 } }, "开关"), h("th", { style: { width: 128 } }, "操作"),
- ])),
- h("tbody", {}, [
- alarmFiltered.value.length === 0
- ? h("tr", {}, h("td", { colSpan: 8, class: "muted" }, "暂无报警规则,点击“新增”进行配置"))
- : alarmFiltered.value.map((rule) =>
- h("tr", { key: rule.id }, [
- h("td", {}, h(UiInput, { type: "checkbox", checked: selectedAlarmIds.value.has(rule.id), onChange: () => toggleAlarmSelect(rule.id) })),
- h("td", {}, h(UiButton, { class: "link alarm-name-link", onClick: () => editAlarm(rule) }, rule.name)),
- h("td", {}, rule.level),
- h("td", {}, rule.category),
- h("td", {}, rule.method),
- h("td", { class: "alarm-description-cell", title: rule.description }, rule.description || "—"),
- h("td", {}, h(UiButton, { type: "button", role: "switch", "aria-checked": rule.enabled, class: rule.enabled ? "alarm-switch on" : "alarm-switch", onClick: () => toggleAlarm(rule.id) }, [h("i")])),
- h("td", {}, h("div", { class: "alarm-row-actions" }, [
- h(AntButton, { type: "text", title: "编辑", "aria-label": "编辑报警规则", icon: h(EditOutlined), onClick: () => editAlarm(rule) }),
- h(AntButton, { type: "text", title: "复制", "aria-label": "复制报警规则", icon: h(CopyOutlined), onClick: () => copyAlarm(rule) }),
- h(AntButton, { type: "text", danger: true, title: "删除", "aria-label": "删除报警规则", icon: h(DeleteOutlined), onClick: () => removeAlarm(rule) }),
- ])),
- ])
- ),
- ]),
- ]),
- ]),
- h("div", { class: "field-assist", style: { marginTop: 16 } }, [
- h(Button, { secondary: true, onClick: () => (aiOpen.value = true) }, [h(RobotOutlined), " AI 智能补全"]),
- h("span", { class: "muted" }, "基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加"),
- ]),
- h(Drawer, {
- class: "alarm-rule-drawer",
- title: alarmDraft.value && alarmDraft.value.id == null ? "新增报警规则" : "编辑报警规则",
- width: 1000,
- open: !!alarmDraft.value,
- onClose: () => (alarmDraft.value = null),
- footer: () => h("div", { class: "node-drawer-footer" }, [
- h(Button, { secondary: true, onClick: () => (alarmDraft.value = null) }, "取消"),
- h(Button, { onClick: saveAlarm }, "确定"),
- ]),
- }, () =>
- alarmDraft.value
- ? h("div", { class: "alarm-rule-form" }, [
- h("section", {}, [
- h("h3", {}, "基本信息"),
- h("div", { class: "alarm-form-grid" }, [
- h("label", {}, [h("span", {}, [h("i", {}, "*"), " 报警名称"]), h(UiInput, { value: alarmDraft.value.name, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, name: e.target.value }), placeholder: "请输入报警名称" })]),
- h("label", {}, [h("span", {}, [h("i", {}, "*"), " 报警等级"]), h(UiSelect, { value: alarmDraft.value.level, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, level: e.target.value }) }, () => [h("option", {}, "紧急"), h("option", {}, "高"), h("option", {}, "中"), h("option", {}, "低")])]),
- h("label", { class: "wide" }, [h("span", {}, "报警描述"), h(UiInput, { value: alarmDraft.value.description, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, description: e.target.value }), placeholder: "请输入报警描述" })]),
- ]),
- ]),
- h("section", {}, [
- h("h3", {}, "类别设定"),
- h("div", { class: "alarm-form-grid" }, [
- h("label", {}, [h("span", {}, [h("i", {}, "*"), " 类别名称"]), h(UiSelect, { value: alarmDraft.value.category, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, category: e.target.value }) }, () => [h("option", {}, "报警"), h("option", {}, "故障"), h("option", {}, "事件"), h("option", {}, "通知")])]),
- h("label", {}, [h("span", {}, [h("i", {}, "*"), " 类型名称"]), h(UiSelect, { value: alarmDraft.value.typeName, onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, typeName: e.target.value }) }, () => [h("option", {}, "阈值报警"), h("option", {}, "离线报警"), h("option", {}, "状态报警"), h("option", {}, "组合报警")])]),
- ]),
- ]),
- h("section", {}, [
- h("h3", {}, "灵敏度设定"),
- h("div", { class: "alarm-sensitivity" }, [
- h("span", {}, [h("i", {}, "*"), " 灵敏度类型"]),
- h("label", {}, [h(UiInput, { type: "radio", name: "alarm-sensitivity", checked: alarmDraft.value.sensitivityType === "duration", onChange: () => (alarmDraft.value = { ...alarmDraft.value, sensitivityType: "duration" }) }), " 持续时间"]),
- h(UiInput, { type: "number", min: "1", value: alarmDraft.value.sensitivityType === "duration" ? alarmDraft.value.sensitivityValue : "", disabled: alarmDraft.value.sensitivityType !== "duration", onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, sensitivityValue: e.target.value }) }),
- h("em", {}, "秒,报警一次"),
- h("label", {}, [h(UiInput, { type: "radio", name: "alarm-sensitivity", checked: alarmDraft.value.sensitivityType === "consecutive", onChange: () => (alarmDraft.value = { ...alarmDraft.value, sensitivityType: "consecutive" }) }), " 连续触发"]),
- h(UiInput, { type: "number", min: "1", value: alarmDraft.value.sensitivityType === "consecutive" ? alarmDraft.value.sensitivityValue : "", disabled: alarmDraft.value.sensitivityType !== "consecutive", onChange: (e) => (alarmDraft.value = { ...alarmDraft.value, sensitivityValue: e.target.value }) }),
- h("em", {}, "次,报警一次"),
- ]),
- ]),
- h("section", {}, [
- h("h3", {}, "触发规则设定"),
- h("div", { class: "alarm-trigger-panel" }, [
- h("header", {}, [
- h("b", {}, "触发规则列表"),
- h("span", {}, [
- h(Button, { secondary: true, onClick: () => p.notify("规则策略设定将在后续接入") }, [h(ControlOutlined), " 规则策略设定"]),
- h(Button, { onClick: addAlarmTrigger }, [h(PlusOutlined), " 新增"]),
- ]),
- ]),
- h(UiTable, { class: "data-table alarm-trigger-table" }, () => [
- h("thead", {}, h("tr", {}, [
- h("th", {}, "名称"), h("th", {}, "报警来源"), h("th", {}, "比较类型"), h("th", {}, "监测点"), h("th", {}, "运算符号"), h("th", {}, "阈值"), h("th", {}, "操作"),
- ])),
- h("tbody", {}, alarmDraft.value.triggers.map((trigger) =>
- h("tr", { key: trigger.id }, [
- h("td", {}, h(UiInput, { value: trigger.name, onChange: (e) => updateAlarmTrigger(trigger.id, "name", e.target.value) })),
- h("td", {}, h(UiSelect, { value: trigger.source, onChange: (e) => updateAlarmTrigger(trigger.id, "source", e.target.value) }, () => [h("option", {}, "当前设备"), h("option", {}, "关联设备")])),
- h("td", {}, h(UiSelect, { value: trigger.compareType, onChange: (e) => updateAlarmTrigger(trigger.id, "compareType", e.target.value) }, () => [h("option", {}, "数值"), h("option", {}, "状态"), h("option", {}, "字符串")])),
- h("td", {}, h(UiSelect, { value: trigger.point, onChange: (e) => updateAlarmTrigger(trigger.id, "point", e.target.value) }, () => [h("option", { value: "" }, "请选择"), ...cps.value.map((point) => h("option", { key: point.key, value: point.name }, point.name || "未命名采集点"))])),
- h("td", {}, h(UiSelect, { value: trigger.operator, onChange: (e) => updateAlarmTrigger(trigger.id, "operator", e.target.value) }, () => [h("option", {}, ">"), h("option", {}, ">="), h("option", {}, "<"), h("option", {}, "<="), h("option", {}, "="), h("option", {}, "!=")])),
- h("td", {}, h(UiInput, { value: trigger.threshold, onChange: (e) => updateAlarmTrigger(trigger.id, "threshold", e.target.value), placeholder: "请输入" })),
- h("td", {}, h(AntButton, { type: "text", danger: true, title: "删除触发规则", icon: h(DeleteOutlined), onClick: () => removeAlarmTrigger(trigger.id) })),
- ])
- )),
- ]),
- alarmDraft.value.triggers.length === 0 ? h("div", { class: "alarm-trigger-empty" }, "暂无数据") : null,
- ]),
- ]),
- ])
- : null
- ),
- h(Drawer, {
- title: "AI 智能补全",
- width: 440,
- open: aiOpen.value,
- onClose: () => (aiOpen.value = false),
- }, () =>
- h("div", { class: "ai-assist-drawer" }, [
- h("div", { class: "ai-assist-intro" }, [h(RobotOutlined), h("div", {}, [h("h3", {}, "补充采集点"), h("p", {}, "AI 将根据设备名称、所属领域和说明生成合适的采集点,并以追加模式写入该设备模板。")])]),
- h("p", { class: "hint" }, "为当前设备模板补充一套合适的采集点(追加模式,不覆盖已手动录入的采集点)。"),
- h(UiActions, { class: "drawer-actions" }, () => [
- h(Button, { disabled: cpAi.completing.value, onClick: runCpComplete }, cpAi.completing.value ? "采集中…" : "开始补全"),
- cpAi.completing.value ? h(Button, { secondary: true, onClick: stopCpComplete }, "停止") : null,
- h(Button, { secondary: true, onClick: viewCpCompletePrompt }, "查看提示词"),
- ]),
- (cpAi.completing.value || cpAi.log.value)
- ? h("div", { class: "modal-console" }, [
- h("div", { class: "modal-console-head" }, "AI 智能补全"),
- h("pre", { ref: cpAi.ref, class: "modal-console-body" }, cpAi.log.value || "(等待 AI 分析…)"),
- ])
- : null,
- ])
- ),
- h("div", { style: { marginTop: 8 } }, [h(Button, { onClick: save }, "保存设备")]),
- ]),
- ];
- },
-};
-
-export { DeviceImageField, DeviceTemplateCard, CP_RANGE_PRESETS, fileToBase64 };
+++ /dev/null
-<script setup>
-import { ref, watch, onMounted } from "vue";
-import { loadRenderer } from "../lib/api.js";
-
-const props = defineProps({
- jsonText: { type: String, default: "" },
- emptyLabel: { type: String, default: "未生成" },
- fit: { type: Boolean, default: false },
- fixedHeight: { type: Number, default: undefined },
-});
-
-const outer = ref(null);
-const inner = ref(null);
-
-async function render() {
- const o = outer.value;
- const i = inner.value;
- if (!o || !i) return;
- if (!props.jsonText || props.jsonText.trim() === "{}" || props.jsonText.trim() === "") {
- i.innerHTML = '<div class="thumb-empty">' + props.emptyLabel + "</div>";
- o.style.height = props.fixedHeight ? `${props.fixedHeight}px` : "auto";
- return;
- }
- try {
- const json = JSON.parse(props.jsonText);
- const renderDashboard = await loadRenderer();
- renderDashboard(json, i, {});
- const w = o.clientWidth || 300;
- const pw = json.grid?.pageWidth || 1920;
- const rootEl = i.firstElementChild;
- const rh = rootEl ? rootEl.offsetHeight : json.grid?.pageHeight || 1080;
- const availH = props.fixedHeight || (props.fit ? o.parentElement?.clientHeight || rh : Infinity);
- const s = props.fit ? Math.max(0.02, Math.min(w / pw, availH / rh)) : Math.max(0.02, w / pw);
- i.style.height = `${rh}px`;
- i.style.transform = `scale(${s})`;
- i.style.transformOrigin = "top left";
- o.style.height = props.fixedHeight ? `${props.fixedHeight}px` : `${rh * s}px`;
- } catch (e) {
- i.innerHTML = '<div class="thumb-empty">预览不可用</div>';
- }
-}
-
-onMounted(render);
-watch(() => [props.jsonText, props.emptyLabel, props.fit, props.fixedHeight], render);
-</script>
-
-<template>
- <div ref="outer" class="dashboard-thumb-live" style="overflow: hidden">
- <div ref="inner" style="width: 1920px; height: 1080px; position: relative"></div>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import SvgIcon from "./SvgIcon.vue";
-
-defineProps({
- label: { type: String, required: true },
- btnIcon: { type: String, default: "" },
- value: { type: [String, Number], default: "" },
- trigger: { type: Array, default: () => ["click"] },
- placement: { type: String, default: "bottom" },
- disabled: { type: Boolean, default: false },
- visible: { type: Boolean, default: undefined },
-});
-const emit = defineEmits(["openChange"]);
-</script>
-
-<template>
- <button type="button" class="dropdown-btn">
- <a-dropdown
- :disabled="disabled"
- :trigger="trigger"
- :placement="placement"
- :open="visible"
- :class="'self-dropdown'"
- @open-change="(open) => emit('openChange', open)"
- >
- <div class="btn-content">
- <SvgIcon v-if="btnIcon" :name="btnIcon" :size="24" class="btn-icon" />
- <span v-if="value" class="filter-text">{{ value }}</span>
- <span class="text">{{ label }}</span>
- </div>
- <template #overlay>
- <slot />
- </template>
- </a-dropdown>
- </button>
-</template>
+++ /dev/null
-<script setup>
-import { Form } from "ant-design-vue";
-const props = defineProps({
- label: { type: String, default: "" },
- required: { type: Boolean, default: false },
- hint: { type: String, default: "" },
-});
-</script>
-
-<template>
- <Form.Item class="field" :label="label" :required="required" :extra="hint" layout="vertical">
- <slot />
- </Form.Item>
-</template>
+++ /dev/null
-<script setup>
-const props = defineProps({ src: { type: String, required: true } });
-</script>
-
-<template>
- <div class="content dashboard-page">
- <img :src="src" alt="" loading="lazy" />
- </div>
-</template>
+++ /dev/null
-<script setup>
-import UiButton from "../ui/UiButton.vue";
-
-const props = defineProps({
- onLogin: { type: Function, required: true },
-});
-</script>
-
-<template>
- <div class="login-page">
- <div class="login-top">
- <div class="brand-mark">⬡</div>
- <b>iStation</b>
- <span>StationBuilder</span>
- </div>
- <div class="login-card">
- <div class="login-logo">⬡</div>
- <h1>iStation StationBuilder</h1>
- <p>面向 iStation 团队的 AI 站点与领域方案生成工具</p>
- <UiButton className="sso-button" @click="onLogin">
- <span class="avatar">W</span>
- <b>使用公司账号登录</b>
- <i>→</i>
- </UiButton>
- <small>仅限 iStation 团队内部成员访问</small>
- </div>
- <footer>© 2026 Advantech · iStation Team · v0.3</footer>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, nextTick } from "vue";
-import UiOverlay from "../ui/UiOverlay.vue";
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import Field from "./Field.vue";
-import PrimaryButton from "./PrimaryButton.vue";
-import StatusTag from "./StatusTag.vue";
-
-const props = defineProps({
- type: { type: String, required: true },
- close: { type: Function, required: true },
- notify: { type: Function, default: () => {} },
- promptData: { type: Object, default: null },
- feedbackCube: { type: Object, default: null },
- refineCube: { type: Function, default: null },
- refining: { type: Boolean, default: false },
- refineLog: { type: String, default: "" },
-});
-
-const feedbackText = ref("");
-const refineConsoleRef = ref(null);
-
-watch(
- () => props.refineLog,
- async () => {
- await nextTick();
- if (refineConsoleRef.value) refineConsoleRef.value.scrollTop = refineConsoleRef.value.scrollHeight;
- }
-);
-
-const title = computed(
- () =>
- ({
- feedback: `提出修改意见 · ${props.feedbackCube?.name || ""}`,
- download: "下载站点交付物",
- history: "版本历史",
- prompt: `生成提示词 · ${props.promptData?.name || ""}`,
- completePrompt: "AI 智能补全提示词",
- }[props.type])
-);
-
-const submitFeedback = () => {
- if (!props.feedbackCube) {
- props.notify("未选择目标 Cube 页面,请关闭后重试");
- props.close();
- return;
- }
- if (!props.refineCube) {
- props.notify("反馈功能未正确初始化");
- return;
- }
- props.refineCube(props.feedbackCube, feedbackText.value);
-};
-</script>
-
-<template>
- <UiOverlay className="modal-backdrop" :onMouseDown="close">
- <div class="modal" @mousedown.stop>
- <header>
- <h2>{{ title }}</h2>
- <UiButton @click="close">×</UiButton>
- </header>
- <div class="modal-body">
- <template v-if="type === 'feedback'">
- <div class="impact-title">系统已识别本次修改范围(仅针对「{{ feedbackCube?.name }}」页面)</div>
- <Field label="修改意见">
- <UiInput
- autoFocus
- :value="feedbackText"
- @change="(e) => (feedbackText = e.target.value)"
- placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"
- />
- </Field>
- <p class="hint">在上方输入框填写修改意见,提交后会在当前页面已生成的 JSON 基础上按意见修改,并保留完整提示词(含本次修改意见),可随时通过「提示词」按钮查看。</p>
- <ul class="impact-list">
- <li><b>当前页面 JSON</b><span>将依据修改意见重新生成</span></li>
- <li><b>页面模拟数据</b><span>将同步更新</span></li>
- <li><b>Asset Excel</b><span>不受影响</span></li>
- <li><b>其他 Cube 页面</b><span>不受影响</span></li>
- </ul>
- <div v-if="refining || refineLog" class="modal-console">
- <div class="modal-console-head">AI 生成工作流</div>
- <pre class="modal-console-body" ref="refineConsoleRef">{{ refineLog || "(等待 AI 反馈…)" }}</pre>
- </div>
- </template>
-
- <template v-else-if="type === 'download'">
- <div class="download-list">
- <label v-for="x in ['Cube 页面 JSON', '站点菜单配置', '生成及校验报告']" :key="x">
- <UiInput type="checkbox" defaultChecked /><span>{{ x }}</span>
- </label>
- <div><b>station-package.zip</b><span>包含全部生成产物</span></div>
- </div>
- </template>
-
- <template v-else-if="type === 'history'">
- <div class="history-list">
- <div v-for="x in [['V1', '首次生成', new Date().toLocaleString()]]" :key="x[0]">
- <i>{{ x[0] }}</i>
- <span><b>{{ x[1] }}</b><small>{{ x[2] }} · 王宇</small></span>
- <StatusTag>当前版本</StatusTag>
- </div>
- </div>
- </template>
-
- <template v-else-if="type === 'prompt'">
- <p class="hint">这是发送给 Cube 生成模型的完整组合提示词。在点击「开始生成」前可在此核对站点上下文、页面要求与 Cube 规则是否准确,便于定位生成质量问题。</p>
- <pre class="prompt-view">{{ promptData?.text || "(暂无提示词)" }}</pre>
- </template>
-
- <template v-else-if="type === 'completePrompt'">
- <p class="hint">这是「AI 智能补全」发送给分析模型的完整提示词。它基于站点名称、所属领域与站点基本情况描述,要求模型先输出分析思路,再以「【补充描述】」给出最终要写入「补充要求」的文本。</p>
- <pre class="prompt-view">{{ promptData?.text || "(暂无提示词)" }}</pre>
- </template>
- </div>
- <footer>
- <PrimaryButton secondary @click="close">关闭</PrimaryButton>
- <PrimaryButton v-if="type === 'feedback'" :disabled="refining" @click="submitFeedback">
- {{ refining ? "重新生成中…" : "提交修改意见" }}
- </PrimaryButton>
- </footer>
- </div>
- </UiOverlay>
-</template>
+++ /dev/null
-<script setup>
-import { computed } from "vue";
-import SvgIcon from "./SvgIcon.vue";
-
-const props = defineProps({
- current: { type: Number, required: true },
- totalPages: { type: Number, required: true },
- pageSize: { type: Number, required: true },
-});
-const emit = defineEmits(["pageChange", "pageSizeChange"]);
-
-const atFirst = computed(() => props.current <= 1);
-const atLast = computed(() => props.current >= props.totalPages);
-</script>
-
-<template>
- <footer class="page-footer">
- <div class="pagination">
- <SvgIcon
- name="First"
- :size="24"
- :class="['page-icon', { disabled: atFirst }]"
- @click="() => !atFirst && emit('pageChange', 1)"
- />
- <SvgIcon
- name="Previous"
- :size="24"
- :class="['page-icon', { disabled: atFirst }]"
- @click="() => !atFirst && emit('pageChange', current - 1)"
- />
- <span class="page-indicator">{{ current }} / {{ totalPages }}</span>
- <SvgIcon
- name="Next"
- :size="24"
- :class="['page-icon', { disabled: atLast }]"
- @click="() => !atLast && emit('pageChange', current + 1)"
- />
- <SvgIcon
- name="Last"
- :size="24"
- :class="['page-icon', { disabled: atLast }]"
- @click="() => !atLast && emit('pageChange', totalPages)"
- />
- <a-select
- class="page-size"
- :value="pageSize"
- :options="[10, 20, 50, 100].map((v) => ({ label: v + ' / page', value: v }))"
- @change="(v) => emit('pageSizeChange', v)"
- />
- </div>
- </footer>
-</template>
+++ /dev/null
-<script setup>
-import { computed, h } from "vue";
-import { Card } from "ant-design-vue";
-import UiActions from "../ui/UiActions.vue";
-
-const props = defineProps({
- title: { type: String, default: "" },
- className: { type: String, default: "" },
-});
-const slots = defineSlots();
-
-const titleVNode = computed(() => (props.title ? h("strong", props.title) : undefined));
-</script>
-
-<template>
- <Card
- class="panel"
- :class="className"
- :title="titleVNode"
- :class-names="{ header: 'panel-head', body: 'panel-body' }"
- >
- <template v-if="title && slots.tools" #extra>
- <UiActions class="station-inline-actions"><slot name="tools" /></UiActions>
- </template>
- <slot />
- </Card>
-</template>
+++ /dev/null
-<script setup>
-import { Button as AntButton } from "ant-design-vue";
-
-// Port of React local `Button`: primary by default, secondary => default.
-const props = defineProps({
- secondary: { type: Boolean, default: false },
- onClick: { type: Function, default: null },
- disabled: { type: Boolean, default: false },
- title: { type: String, default: undefined },
-});
-</script>
-
-<template>
- <AntButton
- :title="title"
- :disabled="disabled"
- :onClick="onClick"
- :type="secondary ? 'default' : 'primary'"
- class="button"
- :class="secondary ? 'secondary' : ''"
- >
- <slot />
- </AntButton>
-</template>
+++ /dev/null
-<script setup>
-import Panel from "./Panel.vue";
-
-const props = defineProps({ title: { type: String, required: true } });
-</script>
-
-<template>
- <Panel :title="title">
- <div class="empty-state">该模块正在建设中,敬请期待。</div>
- </Panel>
-</template>
+++ /dev/null
-<script setup>
-import SvgIcon from "./SvgIcon.vue";
-
-const props = defineProps({
- value: { type: String, default: "" },
- placeholder: { type: String, default: "" },
- className: { type: String, default: "" },
-});
-const emit = defineEmits(["change"]);
-</script>
-
-<template>
- <a-input
- class="search-input"
- :class="className"
- :value="value"
- :placeholder="placeholder"
- allow-clear
- @change="(e) => emit('change', e.target.value)"
- >
- <template #prefix>
- <span class="search-prefix">
- <SvgIcon name="search" :size="24" />
- </span>
- </template>
- </a-input>
-</template>
+++ /dev/null
-<script setup>
-import { h } from "vue";
-import SvgIcon from "./SvgIcon.vue";
-
-defineProps({
- items: { type: Array, default: () => [] },
- collapsed: { type: Boolean, default: false },
- selectedKeys: { type: Array, default: () => [] },
- openKeys: { type: Array, default: null },
-});
-
-const emit = defineEmits(["click", "openChange"]);
-
-const expandIcon = ({ isOpen }) =>
- h(SvgIcon, { name: isOpen ? "arrow-down" : "arrow-right", size: 24 });
-</script>
-
-<template>
- <a-menu
- mode="inline"
- theme="light"
- :inline-collapsed="collapsed"
- :selected-keys="selectedKeys"
- :open-keys="openKeys"
- :items="items"
- :expand-icon="expandIcon"
- @click="(info) => emit('click', info)"
- @open-change="(keys) => emit('openChange', keys)"
- />
-</template>
+++ /dev/null
-<script setup>
-import UiButton from "../ui/UiButton.vue";
-import { SITE_EDIT_STEPS, meta } from "../lib/meta.js";
-
-const props = defineProps({
- section: { type: String, required: true },
- onChange: { type: Function, required: true },
-});
-</script>
-
-<template>
- <nav class="site-edit-tabs" aria-label="站点编辑功能">
- <UiButton
- v-for="(item, index) in SITE_EDIT_STEPS"
- :key="item"
- :class="section === item ? 'active' : ''"
- :aria-current="section === item ? 'page' : undefined"
- @click="onChange(item)"
- >
- <i>{{ index + 1 }}</i>
- <span>{{ meta[item].label }}</span>
- </UiButton>
- </nav>
-</template>
+++ /dev/null
-<script setup>
-import { computed } from "vue";
-import { Tag } from "ant-design-vue";
-
-const props = defineProps({ value: { type: String, default: "" } });
-const tone = computed(() => {
- const v = props.value;
- if (/不可用|失败|异常|failed/.test(v)) return "bad";
- if (/完成|正常|成功|通过|推荐|可用|已配置|已生成|已上传|generated/.test(v)) return "ok";
- if (/生成中|校验中|告警|running/.test(v)) return "warn";
- return "muted";
-});
-</script>
-
-<template>
- <Tag class="status" :class="tone"><slot>{{ value }}</slot></Tag>
-</template>
+++ /dev/null
-<script setup>
-import UiButton from "../ui/UiButton.vue";
-import { SITE_EDIT_STEPS, meta } from "../lib/meta.js";
-
-const props = defineProps({
- page: { type: String, required: true },
- go: { type: Function, required: true },
-});
-</script>
-
-<template>
- <div class="steps">
- <UiButton
- v-for="(item, index) in SITE_EDIT_STEPS"
- :key="item"
- :class="index === SITE_EDIT_STEPS.indexOf(page) ? 'active' : index < SITE_EDIT_STEPS.indexOf(page) ? 'done' : ''"
- @click="index <= SITE_EDIT_STEPS.indexOf(page) && go(item)"
- >
- <i>{{ index < SITE_EDIT_STEPS.indexOf(page) ? "✓" : index + 1 }}</i>
- <span><small>STEP {{ index + 1 }}</small>{{ meta[item].label }}</span>
- <b v-if="index < SITE_EDIT_STEPS.length - 1">›</b>
- </UiButton>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { computed } from "vue";
-
-const props = defineProps({
- name: { type: String, required: true },
- size: { type: Number, default: 24 },
- color: { type: String, default: "" },
- onClick: { type: Function, default: null },
-});
-
-// All icons live in src-vue/assets/icons/*.svg and use fill="currentColor".
-const modules = import.meta.glob("../assets/icons/*.svg", {
- query: "?raw",
- import: "default",
- eager: true,
-});
-
-const ICONS = {};
-for (const path in modules) {
- const key = path.split("/").pop().replace(".svg", "");
- ICONS[key] = modules[path];
-}
-
-const raw = computed(() => ICONS[props.name] || "");
-</script>
-
-<template>
- <i
- class="sb-svg-icon"
- :style="{ width: size + 'px', height: size + 'px', color }"
- :onClick="onClick"
- v-html="raw"
- />
-</template>
-
-<style scoped>
-.sb-svg-icon {
- display: inline-flex;
- flex: none;
-}
-.sb-svg-icon :deep(svg) {
- width: 100%;
- height: 100%;
- display: block;
-}
-</style>
+++ /dev/null
-//带有菜单的按钮
-.dropdown-btn {
- height: 34px;
- padding: 0;
- border: 1px solid #C8C8C8;
- background: #FFFFFF;
- box-shadow: none;
- border-radius: 4px;
- cursor: pointer;
- font-family: 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
- 'Helvetica Neue', sans-serif;
-
- &:hover,
- &:focus {
- border-color: #008CD6;
- }
-
- &:focus {
- outline: none;
- box-shadow: 0px 2px 8px 0px rgba(0, 140, 214, 0.5);
- }
-
- .btn-content {
- height: 100%;
- padding: 0 8px;
- display: flex;
- flex-wrap: nowrap;
- justify-content: left;
- }
-
- &.blue {
- color: #008CD6;
- }
-
- &.blue:not(.disabled):not([disabled]) .btn-content {
- &:hover {
- color: #006AA5;
- border-color: #89898A;
- }
-
- &:active,
- &.ant-dropdown-open,
- &.ant-dropdown-open:hover {
- color: #008CD6;
- background: linear-gradient(315deg, rgba(11, 76, 217, 0.1) 0%, rgba(0, 140, 214, 0.1) 100%), #FFFFFF;
- border-color: #008CD6;
- }
- }
-
- &.filter {
- color: #323233;
-
- .btn-content {
- flex-direction: row-reverse;
- padding: 0 8px;
- height: 100%;
- display: flex;
- align-items: center;
- gap: 4px;
- font-size: 16px;
- line-height: 24px;
-
- .filter-text {
- font-weight: 400;
- height: 24px;
- line-height: 24px;
- color: #323233;
- display: inline-block;
- max-width: 212px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- .text {
- color: #89898A;
- font-weight: 700;
- }
-
- // 图标颜色由 currentColor 驱动
- .btn-icon {
- display: block;
- color: #89898A;
- transition: color .3s ease-in;
- }
-
- &.ant-dropdown-open {
- .btn-icon {
- color: var(--ant-color-primary);
- }
- }
-
- &:active:not([disabled]),
- &.ant-dropdown-open,
- &.ant-dropdown-open:hover {
- background: transparent !important;
-
- .text {
- color: #008CD6 !important;
- }
- }
- }
- }
-}
-
-// 下拉菜单(挂载到 body)
-.self-dropdown.ant-dropdown {
- .ant-dropdown-menu {
- max-height: 300px;
- overflow-y: auto;
- padding: 8px 0;
- border: 1px solid #008CD6;
- border-radius: 4px;
- box-shadow: 0 2px 8px 0 rgba(50, 50, 51, 0.2);
-
- .ant-dropdown-menu-item {
- font-size: 1rem;
- line-height: 24px;
- padding: 8px 12px;
-
- &.selected-menu,
- &:hover,
- &.selected,
- &.selected:hover {
- background: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%);
- }
-
- .blue {
- color: #008CD6;
- }
- }
- }
-}
\ No newline at end of file
+++ /dev/null
-@g-brand-hover: linear-gradient(180deg, rgba(0, 140, 214, 0.05) 0%, rgba(0, 110, 255, 0.05) 100%), #FFF;
-@g-brand-active: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%), #FFF;
-
-.header {
- background-color: var(--ant-color-bg-container);
- box-shadow: 0px 2px 8px rgba(50, 50, 51, 0.20);
- position: fixed;
- width: 100%;
- top: 0;
- z-index: 100;
- height: 56px;
-
- .top-bar {
- height: 8px;
- // 顶部品牌渐变条(token 表达不了)
- background: linear-gradient(90deg, #008cd6 0%, #004280 100%);
- }
-
- .header-info {
- padding: 0 24px;
-
- // 折叠开关图标:常态 / hover / active 由 currentColor 驱动
- .menu-toggle {
- color: #89898A;
- cursor: pointer;
- transition: color .3s ease-in;
-
- &:hover {
- color: var(--ant-color-text-base);
- }
-
- &:active {
- color: var(--ant-color-primary);
- }
- }
-
- .portal-logo {
- height: 40px;
- min-width: auto;
- margin-right: 12px
- }
-
- .portal-name {
- color: var(--ant-color-text-base);
- font-size: 20px;
- font-weight: 700;
- line-height: 32px;
- }
-
- .drop-menu {
- cursor: pointer;
- height: 48px;
- display: flex;
- align-items: center;
- transition: .4s ease-in;
-
- &:hover {
- background: @g-brand-hover;
-
- .nav-icon.drop {
- color: var(--ant-color-text-base);
- }
- }
-
- .ant-dropdown-open {
- height: 100%;
- display: flex;
- align-items: center;
- background: @g-brand-active;
-
- &::after {
- content: '';
- height: 3px;
- width: 100%;
- background: var(--ant-color-primary);
- position: absolute;
- bottom: 0;
- }
- }
-
- // 图标颜色由 SvgIcon 的 currentColor 驱动
- .file-icon {
- cursor: pointer;
- color: #89898A;
- transition: color .3s ease-in;
-
- &.active {
- color: var(--ant-color-primary);
- }
- }
-
- .nav-icon {
- cursor: pointer;
- transition: color .3s ease-in;
-
- &.drop {
- color: #89898A;
-
- &.active {
- color: var(--ant-color-primary);
- }
- }
- }
- }
-
- .right-content {
- padding: 8px 0;
-
- // 右侧图标:常态 / hover / active 由 currentColor 驱动
- .switch-manage,
- .right-icon {
- svg {
- color: #89898A;
- transition: color .3s ease-in;
- }
-
- &:hover svg {
- color: var(--ant-color-text-base);
- }
-
- &:active svg {
- color: var(--ant-color-primary);
- }
- }
-
- .has-user {
- width: 24px;
- height: 24px;
- line-height: 24px;
- font-style: normal;
- font-weight: 400;
- font-size: 12px;
- text-align: center;
- color: var(--ant-color-primary);
- // 品牌浅蓝,无对应 token
- background: #B4E3F6;
- mix-blend-mode: normal;
- border-radius: 50%;
- }
-
- .nav-icon {
- display: flex;
- width: 24px;
- height: 24px;
- }
- }
- }
-
- @media (max-width: 767px) {
- .header-info {
- padding: 0 12px;
-
- .portal-name {
- font-size: 16px;
- line-height: 24px;
- }
- }
- }
-
- @media (max-width: 539px) {
- .header-info {
- .portal-logo {
- display: none;
- }
- }
- }
-}
-
-// Header 内部工具类:嵌套在 .header 内,作用域不外泄,
-// 避免与页面(含 LLM 生成的)类名冲突 —— 页面里能用的类必须都是显式定义的。
-.header {
- .flex {
- display: flex;
- }
-
- .items-center {
- align-items: center;
- }
-
- .justify-center {
- justify-content: center;
- }
-
- .justify-between {
- justify-content: space-between;
- }
-
- .cursor-pointer {
- cursor: pointer;
- }
-
- .gray {
- color: #89898A;
- }
-
- .black {
- color: var(--ant-color-text-base);
- }
-
- .blue {
- color: var(--ant-color-primary);
- }
-
- .ml-1 {
- margin-left: 4px;
- }
-
- .ml-2 {
- margin-left: 8px;
- }
-
- .mr-4 {
- margin-right: 16px;
- }
-
- .mr-12 {
- margin-right: 12px;
- }
-
- .px-4 {
- padding-left: 4px;
- padding-right: 4px;
- }
-
- .text-small {
- font-size: 12px;
- line-height: 16px;
- }
-
- .text-normal {
- font-size: 16px;
- line-height: 24px;
- }
-
- .bold {
- font-weight: 700;
- }
-
- .font-w-4 {
- font-weight: 400;
- }
-}
+++ /dev/null
-// SideMenu 私有样式。选择器为全局 .ant-menu:折叠态子菜单由 antd 渲染到 body 下的 portal,
-// 限定作用域会漏掉 popup(等效 L0 第 5 层兜底)。
-@g-brand-active: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%), #FFF;
-
-.ant-menu {
- width: 316px;
- height: 100%;
- background: #E1E1E1;
-
- /* 折叠态弹出子菜单 */
- &.ant-menu-submenu-popup {
- width: unset;
- height: unset;
- padding-left: 0;
- border: 1px solid var(--ant-color-primary);
- box-shadow: 0px 2px 8px 0px rgba(0, 140, 214, 0.5);
-
- .ant-menu {
- width: unset;
- height: unset;
-
- .ant-menu-item {
- width: unset;
- margin-inline: 0;
-
- &:hover {
- background: @g-brand-active !important;
- }
-
- &.ant-menu-item-selected {
- &::before {
- display: none;
- }
- }
- }
-
- .ant-menu-submenu {
- &:hover {
- background: @g-brand-active !important;
- }
-
- .ant-menu-submenu-title {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-inline: 16px;
- }
- }
- }
- }
-
- .ant-menu-submenu-expand-icon {
- width: auto;
- }
-
- .ant-menu-item {
- margin: 0;
- margin-inline: 0;
- width: 100%;
-
- /* 渐变背景:antd 用 background-color 无法设渐变,必须直接覆盖 background */
- &:hover {
- background: @g-brand-active !important;
- }
-
- &.ant-menu-item-selected {
- background: @g-brand-active;
-
- &::before {
- content: "";
- display: block;
- position: absolute;
- left: 0;
- width: 4px;
- height: 24px;
- background-color: var(--ant-color-primary);
- }
- }
- }
-
- .ant-menu-submenu {
- .ant-menu-submenu-title {
- margin: 0;
- width: 100%;
- &:hover {
- background: @g-brand-active !important;
- }
- }
-
- &.ant-menu-submenu-selected {
- background: #FAFAFA;
-
- .ant-menu-sub {
- background: #FAFAFA;
- }
- }
-
- .ant-menu-sub {
- position: relative;
-
- &::before {
- content: '';
- width: 1px;
- height: 100%;
- background: #C8C8C8;
- position: absolute;
- left: 33px;
- top: 0;
- z-index: 2;
- }
-
- .ant-menu-submenu {
- svg {
- position: relative;
- left: -3px;
- }
-
- .ant-menu-submenu-title {
- margin: 0 0 0 3px;
- }
-
- .ant-menu-sub {
- .ant-menu-item {
- .ant-menu-title-content {
- &::before {
- content: '';
- width: 1px;
- height: 100%;
- background: #C8C8C8;
- position: absolute;
- left: 53px;
- top: 0;
- }
- }
- }
- }
- }
-
- .ant-menu-item {
- margin-left: 3px;
-
- &.ant-menu-item-selected {
- &::before {
- content: "";
- display: block;
- position: relative;
- left: -18px;
- top: 0;
- width: 4px;
- height: 24px;
- background-color: var(--ant-color-primary);
- }
-
- .ant-menu-title-content {
- margin-left: -4px;
- }
- }
- }
- }
- }
-
- &.ant-menu-inline-collapsed {
- width: 72px;
- background: #E1E1E1;
- border-right: 1px solid #C8C8C8;
-
- .ant-menu-item {
- margin-top: 4px;
-
- &:hover {
- background: @g-brand-active !important;
- }
-
- &::before {
- top: 8px;
- }
- }
-
- .ant-menu-submenu {
- .ant-menu-submenu-title:hover {
- background: @g-brand-active;
- }
-
- &.ant-menu-submenu-selected {
- position: relative;
- background: @g-brand-active;
-
- &::before {
- content: "";
- display: block;
- position: absolute;
- left: 0;
- top: 8px;
- width: 4px;
- height: 24px;
- background-color: var(--ant-color-primary);
- }
- }
- }
- }
-}
+++ /dev/null
-// 底部分页栏
-@g-brand-hover: linear-gradient(180deg, rgba(0, 140, 214, 0.05) 0%, rgba(0, 110, 255, 0.05) 100%), #FFF;
-@g-brand-active: linear-gradient(180deg, rgba(0, 140, 214, 0.10) 0%, rgba(0, 110, 255, 0.10) 100%), #FFF;
-
-.page-footer {
- background: var(--ant-color-bg-layout);
- border-top: 1px solid #E1E1E1;
- display: flex;
- align-items: center;
- justify-content: flex-end;
- padding: 8px 24px;
-
- .pagination {
- display: flex;
- align-items: center;
- gap: 8px;
-
- .page-indicator {
- margin: 0 8px;
- color: var(--ant-color-text-base);
- }
-
- // 翻页箭头:常态 / hover / active / disabled 由 currentColor 驱动
- .page-icon {
- color: #89898A;
- cursor: pointer;
- transition: color .3s ease-in;
-
- &:hover {
- color: var(--ant-color-text-base);
- }
-
- &:active {
- color: var(--ant-color-primary);
- }
-
- &.disabled {
- color: #C0C0C0;
- cursor: not-allowed;
- pointer-events: none;
- }
- }
-
- .page-size {
- margin-left: 24px;
- }
- }
-
- // Select(pageSize 切换)
- .ant-select {
- border-radius: var(--ant-border-radius);
-
- .ant-select-selector {
- background: var(--ant-color-bg-layout);
- border: none !important;
- font-size: 16px;
- line-height: 24px;
- padding: 4px 8px;
-
- .ant-select-selection-item {
- color: var(--ant-color-text-base);
- }
- }
-
- // 箭头图标通过 suffixIcon 传入,颜色由 currentColor 驱动
- .ant-select-arrow {
- margin-left: 4px;
- top: 40%;
- right: 0;
- width: 24px;
- height: 24px;
- color: #89898A;
- transition: color .3s ease-in;
- }
-
- &:hover {
- .ant-select-selector {
- background: @g-brand-hover;
- }
-
- .ant-select-arrow {
- color: var(--ant-color-text-base);
- }
- }
-
- &.ant-select-focused {
- .ant-select-selector {
- box-shadow: none;
- background: @g-brand-active;
-
- .ant-select-selection-item {
- color: var(--ant-color-primary);
- }
- }
-
- .ant-select-arrow {
- color: var(--ant-color-primary);
- }
- }
- }
-}
+++ /dev/null
-// Input(作用域限定到本组件,避免影响页面上其它带 affix 的 Input)
-.search-input.ant-input-affix-wrapper {
- width: 240px;
- padding: 4px 8px;
- font-size: 1rem;
- line-height: 24px;
- border-color: #C8C8C8;
-
- &:hover {
- border-color: #008CD6;
- }
-
- .ant-input {
- font-size: 1rem;
- line-height: 24px;
- }
-
- // 搜索图标:颜色由 currentColor 驱动
- .search-prefix svg {
- color: #89898A;
- transition: color .3s ease-in;
-
- &:hover {
- color: var(--ant-color-text-base);
- }
-
- &:active {
- color: var(--ant-color-primary);
- }
- }
-
- &.ant-input-affix-wrapper-focused {
- border-color: #008CD6;
- box-shadow: 0px 2px 8px 0px rgba(0, 140, 214, 0.5);
- }
-}
\ No newline at end of file
+++ /dev/null
-<!doctype html>
-<html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>StationBuilder 建站工具</title>
- </head>
- <body>
- <div id="root"></div>
- <script type="module" src="./main.js"></script>
- </body>
-</html>
+++ /dev/null
-// Shared helpers ported from app/page.tsx (data layer + small utilities).
-
-export async function fetchDb(path, opts) {
- const res = await fetch(`/api/db${path}`, opts);
- if (!res.ok) {
- const text = await res.text().catch(() => "");
- throw new Error(text || `HTTP ${res.status}`);
- }
- const ct = res.headers.get("content-type") || "";
- if (ct.includes("application/json")) return res.json();
- return res;
-}
-
-export function downloadBlob(blob, filename) {
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- a.click();
- URL.revokeObjectURL(url);
-}
-
-export function formatInstances(rule, count) {
- if (!rule || count <= 0) return "";
- const pad = Math.max(2, String(count).length);
- const parts = [];
- for (let i = 1; i <= count; i++) {
- const num = String(i).padStart(pad, "0");
- if (rule.includes("#")) {
- parts.push(rule.replace(/#+/g, (m) => String(i).padStart(Math.max(pad, m.length), "0")));
- } else {
- const sep = rule.endsWith("-") ? "" : "-";
- parts.push(`${rule}${sep}${num}`);
- }
- }
- return parts.join("、");
-}
-
-export function buildMuLeaves(nodes, parent = "") {
- const out = [];
- for (const n of nodes) {
- const myParent = parent ? `${parent}/${n.key}` : n.key;
- if (n.tabs && n.tabs.length) {
- for (const t of n.tabs) {
- if (typeof t === "string") out.push({ key: `${myParent}/${t}`, label: t, parentMenu: parent || n.key });
- else out.push({ key: `${myParent}/${t.label}`, label: t.label, disabled: t.disabled, note: t.note, parentMenu: parent || n.key });
- }
- } else if (n.children) {
- out.push(...buildMuLeaves(n.children, myParent));
- } else {
- out.push({ key: myParent, label: n.key, parentMenu: parent || "" });
- }
- }
- return out;
-}
-
-export function countChars(v) {
- return Array.from(v || "").length;
-}
-
-export function domainTagColor(name) {
- const map = { 数据中心: "blue", 智慧水务: "cyan", 燃气: "orange", "水务 · 自来水厂": "green", 新能源: "purple", 其他: "default" };
- if (map[name]) return map[name];
- const palette = ["blue", "cyan", "orange", "green", "purple", "red"];
- let hash = 0;
- for (let i = 0; i < name.length; i++) {
- hash = (hash << 5) - hash + name.charCodeAt(i);
- hash |= 0;
- }
- return palette[Math.abs(hash) % palette.length];
-}
-
-// Cube renderer loader (used by DashboardThumb / DomainPageThumb).
-// Mirrors React page.tsx: echarts.min.js first, then dashboard-renderer.js,
-// resolving window.renderDashboard; resets on failure so it can be retried.
-let rendererPromise = null;
-function loadScript(src) {
- return new Promise((resolve, reject) => {
- const s = document.createElement("script");
- s.src = src;
- s.onload = resolve;
- s.onerror = () => reject(new Error("renderer load failed"));
- document.head.appendChild(s);
- });
-}
-export function loadRenderer() {
- if (rendererPromise) return rendererPromise;
- rendererPromise = loadScript("/t/stationbuilder/echarts.min.js")
- .then(() => loadScript("/t/stationbuilder/dashboard-renderer.js"))
- .then(() => window.renderDashboard)
- .catch((error) => {
- rendererPromise = null;
- throw error;
- });
- return rendererPromise;
-}
+++ /dev/null
-// Port of page.tsx meta / SITE_EDIT_STEPS / MODELS constants.
-
-export const PAGE_KEYS = [
- "login", "projects", "siteEdit", "basic", "assets", "opsflow", "pageConfig",
- "functions", "generate", "result", "preview", "domains", "domainDetail",
- "styleLibrary", "deviceTemplates", "toolSettings", "domainSettings", "dashboard",
- "report-dashboard", "report-doc", "basic-components", "func-components", "filters",
- "datasource", "monitor-dashboard", "twin-model", "ops-template", "knowledge-template",
-];
-
-export const meta = {
- login: { label: "内部登录", icon: "◇", subtitle: "使用公司账号登录" },
- projects: { label: "站点管理", icon: "▦", subtitle: "先建立上下级站点,再按模板配置各站点" },
- siteEdit: { label: "站点编辑", icon: "", subtitle: "维护已建站点的基本属性与参考资料" },
- basic: { label: "站点信息", icon: "▤", subtitle: "维护站点基本信息与参考资料" },
- assets: { label: "资产设置", icon: "▧", subtitle: "在一个工作区完成资产结构、设备节点、属性与数据绑定" },
- opsflow: { label: "运维流程配置", icon: "", subtitle: "" },
- pageConfig: { label: "页面配置", icon: "", subtitle: "选择站点所需的标准功能页面、领域仪表板及数字孪生场景" },
- functions: { label: "菜单配置", icon: "☷", subtitle: "设置应用基本信息,配置第四步已选择页面的菜单" },
- generate: { label: "生成确认", icon: "↻", subtitle: "确认生成范围,调用 Cube Skill 生成页面和模拟数据" },
- result: { label: "站点结果", icon: "◫", subtitle: "集中查看、预览、校验并下载站点交付物" },
- preview: { label: "Cube 页面预览", icon: "▣", subtitle: "检查单个 Cube 页面效果,并通过反馈快速重新生成" },
- domains: { label: "领域方案", icon: "◆", subtitle: "查看已沉淀的领域能力,并按需预览、下载或导入方案" },
- domainDetail: { label: "方案详情", icon: "◇", subtitle: "集中查看领域方案包含的资产、页面、知识与菜单能力" },
- deviceTemplates: { label: "设备模板", icon: "⬡", subtitle: "管理设备模板及采集点配置,供领域方案和站点复用" },
- styleLibrary: { label: "页面样式", icon: "❖", subtitle: "管理可复用的 Cube 页面视觉样式与预览资源" },
- toolSettings: { label: "内部工具配置", icon: "", subtitle: "配置 StationBuilder 使用的内部能力、AI 服务和默认模型" },
- domainSettings: { label: "领域配置", icon: "", subtitle: "维护领域分类,供领域方案与站点项目统一使用" },
- dashboard: { label: "仪表板", icon: "◈", subtitle: "仅展示仪表盘示意图片" },
- "report-dashboard": { label: "仪表板报表", icon: "", subtitle: "" },
- "report-doc": { label: "文档报表", icon: "", subtitle: "" },
- "basic-components": { label: "基础组件库", icon: "", subtitle: "" },
- "func-components": { label: "功能组件库", icon: "", subtitle: "" },
- filters: { label: "筛选器", icon: "", subtitle: "" },
- datasource: { label: "数据源", icon: "", subtitle: "" },
- "monitor-dashboard": { label: "监控仪表板", icon: "", subtitle: "" },
- "twin-model": { label: "数字孪生模型", icon: "", subtitle: "" },
- "ops-template": { label: "运维模板", icon: "", subtitle: "" },
- "knowledge-template": { label: "知识模板", icon: "", subtitle: "" },
-};
-
-export const SITE_EDIT_STEPS = ["basic", "assets", "opsflow", "pageConfig", "functions"];
-
-export const MODELS = [
- { name: "Auto", tag: "自动选择", rate: "—" },
- { name: "Hy3", tag: "免费", rate: "0.00x" },
- { name: "GLM-5.4", tag: "订阅优先", rate: "0.79x" },
- { name: "GLM-5.3", tag: "订阅优先", rate: "0.79x" },
- { name: "GLM-5.2", tag: "夜间折扣", rate: "0.79x" },
- { name: "GLM-5.1", tag: "", rate: "0.79x" },
- { name: "GLM-5v-Turbo", tag: "视觉理解", rate: "0.95x" },
- { name: "MiniMax-M3", tag: "高性价比", rate: "0.25x" },
- { name: "Kimi-K3", tag: "订阅优先", rate: "1.62x" },
-];
+++ /dev/null
-import { createApp } from "vue";
-import Antd from "ant-design-vue";
-import "ant-design-vue/dist/reset.css";
-
-// Brand / layout styles — order mirrors the React app's src/main.tsx.
-import "./styles/globals.css";
-import "./styles/site-workspace.css";
-import "./styles/ui-system.css";
-import "./styles/ui-layout-compat.css";
-import "./styles/antd.less";
-import "./styles/theme-overrides.css";
-import "./styles/styles.less";
-import "./styles/brand-layout.css";
-
-// Brand component styles.
-import "./components/menu.less";
-import "./components/header.less";
-import "./components/search-input.less";
-import "./components/pagination.less";
-import "./components/dropdown-button.less";
-
-import App from "./App.vue";
-
-// Visible error overlay — surfaces runtime errors that would otherwise blank the page.
-function showFatalError(title, detail) {
- try {
- let box = document.getElementById("sb-fatal-error");
- if (!box) {
- box = document.createElement("div");
- box.id = "sb-fatal-error";
- box.setAttribute("style", "position:fixed;top:0;left:0;right:0;z-index:99999;background:#fff2f0;border-bottom:2px solid #ff4d4f;padding:12px 16px;font:13px/1.6 monospace;color:#a8071a;white-space:pre-wrap;word-break:break-all;max-height:60vh;overflow:auto;");
- document.body && document.body.appendChild(box);
- }
- box.textContent += `[${title}] ${detail}\n\n`;
- } catch (e) { /* ignore */ }
-}
-window.addEventListener("error", (e) => showFatalError("error", (e.message || "") + "\n" + (e.filename || "") + ":" + (e.lineno || "") + "\n" + (e.error && e.error.stack ? e.error.stack : "")));
-window.addEventListener("unhandledrejection", (e) => showFatalError("unhandledrejection", String(e.reason && e.reason.stack ? e.reason.stack : e.reason)));
-
-const app = createApp(App);
-app.config.errorHandler = (err, instance, info) => {
- showFatalError("vue:" + info, String(err && err.stack ? err.stack : err));
-};
-app.use(Antd).mount("#root");
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted, h } from "vue";
-import { Tag } from "ant-design-vue";
-import { EyeOutlined } from "@ant-design/icons-vue";
-
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiTable from "../ui/UiTable.vue";
-import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
-import UiActions from "../ui/UiActions.vue";
-import Panel from "../components/Panel.vue";
-import PrimaryButton from "../components/PrimaryButton.vue";
-import ManualAssetConfiguration from "./ManualAssetConfiguration.vue";
-
-import { fetchDb } from "../lib/api.js";
-
-const props = defineProps({
- site: { type: Object, default: null },
- onChange: { type: Function, default: null },
- saving: { type: Boolean, default: false },
- notify: { type: Function, default: () => {} },
- onViewCompletePrompt: { type: Function, default: null },
- mode: { type: String, default: "quick" }, // "quick" | "manual"
- onModeChange: { type: Function, default: null },
- go: { type: Function, default: null },
-});
-
-const notify = (m) => (props.notify ? props.notify(m) : null);
-const change = (assets) => props.onChange && props.onChange(assets);
-
-// ---- port of React local `Status` component ----
-const Status = (p, { slots }) => {
- const def = slots.default ? slots.default() : [];
- const v = (Array.isArray(def) ? def : [def])
- .map((n) =>
- typeof n === "string" || typeof n === "number"
- ? String(n)
- : n && n.children
- ? String(n.children)
- : ""
- )
- .join("");
- const tone = /不可用|失败|异常|failed/.test(v)
- ? "bad"
- : /完成|正常|成功|通过|推荐|可用|已配置|已生成|已上传|generated/.test(v)
- ? "ok"
- : /生成中|校验中|告警|running/.test(v)
- ? "warn"
- : "muted";
- return h(Tag, { class: "status " + tone }, slots.default);
-};
-
-// ---- device template loading (filtered by site domain) ----
-const deviceTemplates = ref([]);
-const loadDeviceTemplates = async () => {
- try {
- const all = (await fetchDb("/device-templates")) || [];
- deviceTemplates.value = all.filter((t) => {
- try {
- return (JSON.parse(t.domain_names || "[]") || []).includes(props.site?.domain);
- } catch {
- return false;
- }
- });
- } catch {
- /* 静默失败,下拉框为空 */
- }
-};
-onMounted(loadDeviceTemplates);
-watch(() => props.site?.domain, loadDeviceTemplates);
-
-// ---- AI asset completion console ----
-const completing = ref(false);
-const completeLog = ref("");
-const completeConsoleRef = ref(null);
-watch(completeLog, async () => {
- await Promise.resolve();
- if (completeConsoleRef.value)
- completeConsoleRef.value.scrollTop = completeConsoleRef.value.scrollHeight;
-});
-const appendAssets = (items) => {
- const base = props.site?.assets || [];
- const next = [
- ...base,
- ...items.map((it, i) => ({
- type: it.type,
- count: Number(it.count) || 0,
- rule: it.rule || "",
- sort_order: base.length + i,
- })),
- ];
- change(next);
- notify(`AI 已补充 ${items.length} 类设备到资产配置(位于您已填项下方)`);
-};
-const runCompleteAssets = async () => {
- if (!props.site?.id || completing.value) return;
- completing.value = true;
- completeLog.value = "";
- const watchdog = setTimeout(() => {
- completing.value = false;
- notify("AI 资产补全超时(>6 分钟),请重试");
- }, 360000);
- try {
- const res = await fetch(`/api/db/sites/${props.site.id}/complete-assets`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: props.site.name,
- domain: props.site.domain,
- description: props.site.description,
- supplement: props.site.supplement,
- assets: props.site.assets,
- }),
- });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- const reader = res.body.getReader();
- const dec = new TextDecoder();
- let buf = "";
- for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += dec.decode(value, { stream: true });
- const lines = buf.split("\n");
- buf = lines.pop() || "";
- for (const line of lines) {
- if (!line.trim()) continue;
- try {
- const ev = JSON.parse(line);
- if (ev.log) completeLog.value += ev.log;
- if (Array.isArray(ev.result)) appendAssets(ev.result);
- } catch {}
- }
- }
- notify("AI 资产补全已完成");
- } catch (e) {
- notify("AI 资产补全失败:" + (e?.message || e));
- } finally {
- clearTimeout(watchdog);
- completing.value = false;
- }
-};
-
-const matchedTemplate = (row) => deviceTemplates.value.find((t) => t.name === row.type);
-const onEye = (row) => {
- const matched = matchedTemplate(row);
- if (matched) {
- if (props.go) props.go("deviceTemplates");
- window.history.replaceState({}, "", "?page=deviceTemplates&openTemplate=" + matched.id);
- }
-};
-
-// ---- table rows ----
-const rows = computed(() => {
- const assets = props.site?.assets || [];
- if (assets.length) return assets;
- return Array.from({ length: 4 }, (_, i) => ({ type: "", count: 0, rule: "", sort_order: i }));
-});
-const total = computed(() => rows.value.reduce((n, a) => n + Number(a.count || 0), 0));
-const kpis = computed(() => [
- [rows.value.length, "设备类型", "类"],
- [total.value, "设备实例", "个"],
- [Math.max(2, rows.value.filter((r) => r.type).length + 1), "资产层级", "层"],
-]);
-const update = (i, patch) => {
- const next = rows.value.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
- change(next);
-};
-const add = () => change([...rows.value, { type: "", count: 0, rule: "", sort_order: rows.value.length }]);
-const remove = (i) => change(rows.value.filter((_, idx) => idx !== i));
-</script>
-
-<template>
- <div class="asset-config-shell" v-if="site">
- <UiTabbedRegion>
- <template #navigation>
- <div class="asset-config-tabs" role="tablist" aria-label="资产配置方式">
- <UiButton
- role="tab"
- :aria-selected="mode === 'quick'"
- :class="mode === 'quick' ? 'active' : ''"
- @click="() => props.onModeChange && props.onModeChange('quick')"
- ><span>快速资产配置</span></UiButton>
- <UiButton
- role="tab"
- :aria-selected="mode === 'manual'"
- :class="mode === 'manual' ? 'active' : ''"
- @click="() => props.onModeChange && props.onModeChange('manual')"
- ><span>进阶配置</span></UiButton>
- </div>
- </template>
-
- <ManualAssetConfiguration v-if="mode === 'manual'" :site="site" :notify="notify" />
-
- <div v-else class="two-column quick-asset-config">
- <div>
- <div class="kpi-row">
- <div v-for="x in kpis" :key="x[1]">
- <span>{{ x[1] }}</span><b>{{ x[0] }}</b><small>{{ x[2] }}</small>
- </div>
- <div v-if="saving"><span>保存</span><Status>保存中…</Status></div>
- </div>
- <Panel title="设备资产配置">
- <template #tools>
- <PrimaryButton @click="add">+ 添加设备类型</PrimaryButton>
- </template>
- <UiTable class="data-table editable">
- <thead>
- <tr>
- <th>设备类型 *</th>
- <th></th>
- <th>数量 *</th>
- <th>命名规则</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="(row, i) in rows" :key="i" :class="!row.type ? 'empty-row' : ''">
- <td>
- <UiSelect
- v-if="deviceTemplates.length > 0"
- :value="row.type"
- @change="(e) => update(i, { type: e.target.value })"
- >
- <option value="">请选择设备模板</option>
- <option v-for="t in deviceTemplates" :key="t.id" :value="t.name">{{ t.name }}</option>
- </UiSelect>
- <UiInput
- v-else
- :value="row.type"
- placeholder="例如:冷水机组"
- @change="(e) => update(i, { type: e.target.value })"
- />
- </td>
- <td>
- <UiButton
- :class="'asset-eye-btn' + (matchedTemplate(row) ? '' : ' disabled')"
- :title="matchedTemplate(row) ? `查看模板:${matchedTemplate(row).name}` : '请先选择设备模板'"
- @click="() => onEye(row)"
- ><EyeOutlined /></UiButton>
- </td>
- <td>
- <UiInput
- class="number"
- type="number"
- min="0"
- :value="row.count || ''"
- placeholder="0"
- @change="(e) => update(i, { count: Number(e.target.value) })"
- />
- </td>
- <td>
- <UiInput
- :value="row.rule"
- placeholder="例如:CH-##"
- @change="(e) => update(i, { rule: e.target.value })"
- />
- </td>
- <td>
- <UiButton class="trash" @click="() => remove(i)">⌫</UiButton>
- </td>
- </tr>
- </tbody>
- </UiTable>
- </Panel>
-
- <div class="asset-ai-console">
- <PrimaryButton :disabled="completing" @click="runCompleteAssets">
- {{ completing ? "补全中…" : "AI 资产补全" }}
- </PrimaryButton>
- <span class="muted">基于站点需求与已填资产,自动推荐设备类型、数量与命名规则</span>
- </div>
- <div v-if="completing || completeLog" class="modal-console">
- <div class="modal-console-head">生成进度</div>
- <pre class="modal-console-body" ref="completeConsoleRef">{{ completeLog || "(等待 AI 分析…)" }}</pre>
- </div>
- </div>
- </div>
- </UiTabbedRegion>
- </div>
- <Panel v-else>
- <div class="empty-state">加载中…</div>
- </Panel>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, nextTick, onMounted } from "vue";
-import { Drawer, Button } from "ant-design-vue";
-import { RobotOutlined, EyeOutlined } from "@ant-design/icons-vue";
-
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import UiActions from "../ui/UiActions.vue";
-import Panel from "../components/Panel.vue";
-import Field from "../components/Field.vue";
-import { countChars } from "../lib/api.js";
-
-const props = defineProps({
- site: { type: Object, default: null },
- domains: { type: Array, default: () => [] }, // string[]
- onChange: { type: Function, default: null },
- onAddDomain: { type: Function, default: null },
- onUpload: { type: Function, default: null },
- saving: { type: Boolean, default: false },
- notify: { type: Function, default: () => {} },
- onViewCompletePrompt: { type: Function, default: null },
- go: { type: Function, default: null },
- siteId: { type: [Number, null], default: null },
- siteHierarchy: { type: Object, default: null },
-});
-
-const notify = (m) => (props.notify ? props.notify(m) : null);
-const change = (patch) => props.onChange && props.onChange(patch);
-
-const newDomain = ref("");
-const domainInput = ref(props.site?.domain || "");
-const showAdd = ref(false);
-const aiOpen = ref(false);
-const completing = ref(false);
-const completeLog = ref("");
-const completeConsoleRef = ref(null);
-
-watch(
- () => props.site?.domain,
- (v) => {
- if (props.site) domainInput.value = v || "";
- }
-);
-watch(completeLog, async () => {
- await nextTick();
- if (completeConsoleRef.value) completeConsoleRef.value.scrollTop = completeConsoleRef.value.scrollHeight;
-});
-
-const nameTooLong = computed(() => countChars(props.site?.name || "") > 20);
-const domainTooLong = computed(() => newDomain.value.length > 6);
-
-const runComplete = async () => {
- if (!props.site?.id || completing.value) return;
- completing.value = true;
- completeLog.value = "";
- const watchdog = setTimeout(() => {
- completing.value = false;
- notify("AI 智能补全超时(>6 分钟),请重试");
- }, 360000);
- try {
- const res = await fetch(`/api/db/sites/${props.site.id}/complete`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: props.site.name,
- domain: props.site.domain,
- description: props.site.description,
- }),
- });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- const reader = res.body.getReader();
- const dec = new TextDecoder();
- let buf = "";
- for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += dec.decode(value, { stream: true });
- const lines = buf.split("\n");
- buf = lines.pop() || "";
- for (const line of lines) {
- if (!line.trim()) continue;
- try {
- const ev = JSON.parse(line);
- if (ev.log) completeLog.value += ev.log;
- if (ev.result) change({ supplement: ev.result });
- } catch {}
- }
- }
- notify("AI 智能补全已完成,已写入补充描述");
- } catch (e) {
- notify("AI 智能补全失败:" + (e?.message || e));
- } finally {
- clearTimeout(watchdog);
- completing.value = false;
- }
-};
-
-onMounted(() => {
- if (props.site) domainInput.value = props.site.domain || "";
-});
-</script>
-
-<template>
- <div v-if="site" class="company-form-page">
- <aside class="form-section-nav">
- <i class="form-section-nav-dot" />
- <span>站点信息</span>
- <a href="#site-basic">基本信息</a>
- <a href="#site-description">情况描述</a>
- <template v-if="siteHierarchy">
- <i class="form-section-nav-dot" />
- <span class="site-structure-title">站点结构</span>
- <div class="site-structure">
- <template v-for="(a, idx) in siteHierarchy.ancestors" :key="a.id">
- <div class="tree-branch">
- <div
- class="tree-node"
- :class="a.id === siteId ? 'current-site' : ''"
- style="cursor: pointer"
- @click="() => props.go && props.go('siteEdit', a.id)"
- >
- {{ a.name || "未命名站点" }}
- </div>
- <template v-if="idx === siteHierarchy.ancestors.length - 1">
- <div v-for="c in siteHierarchy.children" :key="c.id" class="tree-branch">
- <div
- class="tree-node"
- :class="c.id === siteId ? 'current-site' : ''"
- style="cursor: pointer"
- @click="() => props.go && props.go('siteEdit', c.id)"
- >
- {{ c.name || "未命名站点" }}
- </div>
- </div>
- </template>
- </div>
- </template>
- </div>
- </template>
- </aside>
- <div class="single-column-form">
- <Panel title="站点基本信息">
- <div class="form-grid" id="site-basic">
- <Field label="站点名称" required hint="最多 20 个汉字">
- <UiInput
- :value="site.name"
- :maxLength="20"
- :class="nameTooLong ? 'invalid' : ''"
- @change="(e) => change({ name: e.target.value })"
- />
- </Field>
- <Field label="所属领域" required>
- <div class="domain-select">
- <UiInput
- list="domain-list"
- :value="domainInput"
- placeholder="选择或输入新领域"
- @change="(e) => { domainInput = e.target.value; change({ domain: e.target.value }); }"
- />
- <datalist id="domain-list">
- <option v-for="d in domains" :key="d" :value="d" />
- </datalist>
- <UiButton className="text-button" @click="() => (showAdd = !showAdd)">
- {{ showAdd ? "收起新增" : "新增领域" }}
- </UiButton>
- </div>
- <div v-if="showAdd" class="domain-add">
- <UiInput
- :value="newDomain"
- :maxLength="6"
- placeholder="新领域名称(最多6字)"
- @change="(e) => (newDomain = e.target.value)"
- />
- <Button
- secondary
- :disabled="!newDomain || domainTooLong"
- @click="() => { if (props.onAddDomain) props.onAddDomain(newDomain); newDomain = ''; showAdd = false; }"
- >
- 保存
- </Button>
- </div>
- </Field>
- <div class="form-section-heading" id="site-description">
- <h3>情况描述</h3>
- <p>说明站点用途、重点设备、管理目标及关注指标。</p>
- </div>
- <Field label="站点基本情况描述">
- <UiInput textarea :value="site.description" @change="(e) => change({ description: e.target.value })" />
- </Field>
- <div class="field-assist">
- <Button secondary @click="() => (aiOpen = true)"><RobotOutlined /> AI 智能补全</Button>
- <span class="muted">补充要求、填写建议与参考资料均在右侧抽屉中维护</span>
- </div>
- <div v-if="saving" class="save-indicator">正在保存…</div>
- </div>
- </Panel>
- </div>
- <Drawer title="AI 智能补全" :width="440" :open="aiOpen" :onClose="() => (aiOpen = false)">
- <div class="ai-assist-drawer">
- <div class="ai-assist-intro">
- <RobotOutlined />
- <div>
- <h3>补充站点需求</h3>
- <p>AI 将根据站点名称、所属领域和基本情况描述生成补充要求。生成结果会写入下方,仍可继续编辑。</p>
- </div>
- </div>
- <Field label="补充要求">
- <UiInput textarea class="small" :value="site.supplement" @change="(e) => change({ supplement: e.target.value })" />
- </Field>
- <div class="form-guidance">
- <strong>填写建议</strong>
- <span>站点承担什么业务?哪些设备最重要?管理人员关注哪些指标?首页首先需要看到什么?</span>
- </div>
- <div class="form-section-heading" id="site-files">
- <h3>参考资料</h3>
- <p>上传需求文档、设备清单或参考图片,帮助生成结果贴近真实需求。</p>
- </div>
- <Field label="文件上传">
- <label class="upload">
- <input type="file" hidden @change="(e) => { const f = e.target.files?.[0]; if (f && props.onUpload) props.onUpload(f); }" />
- <b>+</b><span>点击或拖拽上传文件</span>
- <small>支持 PDF、DOCX、XLSX、PNG,单个文件不超过 20 MB</small>
- </label>
- <ul v-if="site.files && site.files.length" class="file-list">
- <li v-for="f in site.files" :key="f.id">
- <a :href="`/api/db/sites/${site.id}/files/${f.id}`" download>{{ f.filename }}</a>
- <small>({{ (f.size / 1024).toFixed(1) }} KB)</small>
- </li>
- </ul>
- </Field>
- <UiActions className="drawer-actions">
- <Button :disabled="completing" @click="runComplete">{{ completing ? "补全中…" : "开始补全" }}</Button>
- <Button secondary @click="() => props.onViewCompletePrompt && props.onViewCompletePrompt()">查看提示词</Button>
- </UiActions>
- <div v-if="completing || completeLog" class="modal-console">
- <div class="modal-console-head">生成进度</div>
- <pre class="modal-console-body" ref="completeConsoleRef">{{ completeLog || "(等待 AI 分析…)" }}</pre>
- </div>
- </div>
- </Drawer>
- </div>
- <Panel v-else>
- <div class="empty-state">加载中…</div>
- </Panel>
-</template>
+++ /dev/null
-<template>
- <div class="content dashboard-page">
- <img src="/t/stationbuilder/dashboard-preview.jpg" alt="仪表盘示意" loading="lazy" />
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted, h } from "vue";
-import { Button as AntButton, Dropdown, Tag } from "ant-design-vue";
-import { EditOutlined, DeleteOutlined, EllipsisOutlined } from "@ant-design/icons-vue";
-import UiButton from "../ui/UiButton.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiTable from "../ui/UiTable.vue";
-import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
-import UiActions from "../ui/UiActions.vue";
-import Panel from "../components/Panel.vue";
-import SearchInput from "../components/SearchInput.vue";
-import StatusTag from "../components/StatusTag.vue";
-import Pagination from "../components/Pagination.vue";
-import PrimaryButton from "../components/PrimaryButton.vue";
-import DeviceTemplateCreate from "../pages/domain/DeviceTemplateCreate.vue";
-import { DeviceTemplateCard } from "../components/DeviceTemplateCard.js";
-import { fetchDb, domainTagColor } from "../lib/api.js";
-import { useConfirm } from "../pages/domain/useConfirm.js";
-
-const props = defineProps({
- notify: { type: Function, default: () => {} },
- onViewCpPrompt: { type: Function, default: () => {} },
- go: { type: Function, default: () => {} },
- siteId: { type: Number, default: null },
- domainOptions: { type: Array, default: () => [] },
- initialDomain: { type: String, default: "" },
-});
-
-const list = ref([]);
-const loading = ref(true);
-const openId = ref(null); // number | "create" | null
-const query = ref("");
-const domainFilter = ref(props.initialDomain || "");
-const pointFilter = ref("");
-const pageIndex = ref(1);
-const pageSize = ref(10);
-const fromSite = ref(false);
-const { confirm, el: confirmEl } = useConfirm();
-
-async function load() {
- try {
- list.value = (await fetchDb("/device-templates")) || [];
- } catch (e) {
- props.notify("加载失败:" + (e && e.message ? e.message : e));
- } finally {
- loading.value = false;
- }
-}
-
-onMounted(() => {
- load();
- const p = new URLSearchParams(window.location.search);
- const tid = p.get("openTemplate");
- if (tid) {
- fromSite.value = true;
- openId.value = Number(tid);
- window.history.replaceState({}, "", "?page=deviceTemplates");
- }
-});
-
-watch(
- () => props.initialDomain,
- (v) => {
- domainFilter.value = v || "";
- }
-);
-
-function pointCount(row) {
- return (row.collection_points || []).length;
-}
-function domainNames(row) {
- if (Array.isArray(row.domain_names)) return row.domain_names.map(String);
- try {
- return JSON.parse(row.domain_names || "[]");
- } catch {
- return [];
- }
-}
-
-async function remove(row) {
- if (!(await confirm(`确定删除设备模板「${row.name}」?`))) return;
- try {
- await fetchDb(`/device-templates/${row.id}`, { method: "DELETE" });
- await load();
- props.notify("设备模板已删除");
- } catch (e) {
- props.notify("删除失败:" + (e && e.message ? e.message : e));
- }
-}
-
-const counts = computed(() => ({
- all: list.value.length,
- configured: list.value.filter((x) => pointCount(x) > 0).length,
- empty: list.value.filter((x) => pointCount(x) === 0).length,
-}));
-
-const filtered = computed(() =>
- list.value.filter((row) => {
- if (pointFilter.value) {
- const ok = pointFilter.value === "configured" ? pointCount(row) > 0 : pointCount(row) === 0;
- if (!ok) return false;
- }
- if (domainFilter.value && !domainNames(row).includes(domainFilter.value)) return false;
- if (
- query.value.trim() &&
- !`${row.name} ${row.description || ""}`.toLowerCase().includes(query.value.trim().toLowerCase())
- )
- return false;
- return true;
- })
-);
-
-watch([pointFilter, domainFilter, query], () => {
- pageIndex.value = 1;
-});
-
-const tabs = computed(() => [
- { key: "", label: "全部", count: counts.value.all },
- { key: "configured", label: "已配置采集点", count: counts.value.configured },
- { key: "empty", label: "待配置", count: counts.value.empty },
-]);
-
-const dev = computed(() =>
- typeof openId.value === "number" ? list.value.find((d) => d.id === openId.value) : null
-);
-
-function back() {
- openId.value = null;
- if (fromSite.value && props.siteId != null) props.go("siteEdit", props.siteId);
-}
-
-function rowMenu(row) {
- return {
- items: [
- { key: "edit", label: "编辑模板", icon: h(EditOutlined) },
- { key: "delete", label: "删除", danger: true, icon: h(DeleteOutlined) },
- ],
- onClick: ({ key }) => (key === "edit" ? (openId.value = row.id) : remove(row)),
- };
-}
-</script>
-
-<template>
- <template v-if="openId === 'create'">
- <div class="resource-back">
- <UiButton class="text-button" @click="back">{{ fromSite ? "← 返回站点编辑" : "← 返回设备模板列表" }}</UiButton>
- </div>
- <DeviceTemplateCreate
- :notify="notify"
- :on-done="async (id) => { await load(); openId = id; }"
- :on-cancel="() => (openId = null)"
- />
- </template>
- <template v-else-if="typeof openId === 'number'">
- <div class="resource-back">
- <UiButton class="text-button" @click="back">{{ fromSite ? "← 返回站点编辑" : "← 返回设备模板列表" }}</UiButton>
- </div>
- <DeviceTemplateCard
- v-if="dev"
- :dev="dev"
- :notify="notify"
- :on-change="load"
- :on-back="() => (openId = null)"
- :on-view-cp-prompt="onViewCpPrompt"
- />
- <div v-else class="empty-state">加载中…</div>
- </template>
- <template v-else>
- <component :is="confirmEl" />
- <UiTabbedRegion>
- <template #navigation>
- <div class="company-status-tabs" role="tablist" aria-label="设备模板状态筛选">
- <UiButton
- v-for="item in tabs"
- :key="item.key || 'all'"
- role="tab"
- :aria-selected="pointFilter === item.key"
- :class="pointFilter === item.key ? 'active' : ''"
- @click="pointFilter = item.key"
- >
- <span>{{ item.label }}</span><b>{{ item.count }}</b>
- </UiButton>
- </div>
- </template>
- <Panel class="company-list-panel resource-list-panel">
- <div class="filterbar company-filterbar">
- <SearchInput :value="query" placeholder="搜索设备名称或说明" @change="(v) => (query = v)" />
- <UiSelect aria-label="领域筛选" :value="domainFilter" @change="(e) => (domainFilter = e.target.value)">
- <option value="">全部领域</option>
- <option v-for="domain in domainOptions" :key="domain.id" :value="domain.name">{{ domain.name }}</option>
- </UiSelect>
- <UiActions class="filter-actions">
- <AntButton @click="() => { query = ''; pointFilter = ''; domainFilter = ''; }">重置</AntButton>
- <PrimaryButton @click="openId = 'create'">+ 新增模板</PrimaryButton>
- </UiActions>
- </div>
- <template v-if="loading">
- <div class="empty-state">加载中…</div>
- </template>
- <template v-else>
- <UiTable class="data-table company-table resource-table device-resource-table">
- <thead>
- <tr>
- <th>设备模板名称</th>
- <th>采集点</th>
- <th>配置状态</th>
- <th>所属领域</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- <tr
- v-for="row in filtered.slice((pageIndex - 1) * pageSize, pageIndex * pageSize)"
- :key="row.id"
- @click="openId = row.id"
- >
- <td>
- <UiButton class="link company-record-link" :title="row.name" @click.stop="openId = row.id">
- <b>{{ row.name || "未命名设备模板" }}</b>
- </UiButton>
- </td>
- <td>{{ pointCount(row) }}</td>
- <td>
- <StatusTag :value="pointCount(row) > 0 ? '已配置' : '待配置'" />
- </td>
- <td class="domain-cell">
- <div class="domain-tag-list">
- <Tag v-for="n in domainNames(row)" :key="n" class="tag-new" :color="domainTagColor(n)">{{ n }}</Tag>
- </div>
- </td>
- <td @click.stop>
- <Dropdown :trigger="['click']" :menu="rowMenu(row)">
- <AntButton type="text" aria-label="更多操作" :icon="h(EllipsisOutlined)" />
- </Dropdown>
- </td>
- </tr>
- </tbody>
- </UiTable>
- <div v-if="filtered.length === 0" class="empty-state">暂无符合条件的设备模板</div>
- <Pagination
- :current="pageIndex"
- :total-pages="Math.max(1, Math.ceil(filtered.length / pageSize))"
- :page-size="pageSize"
- @page-change="(p) => (pageIndex = p)"
- @page-size-change="(s) => { pageSize = s; pageIndex = 1; }"
- />
- </template>
- </Panel>
- </UiTabbedRegion>
- </template>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted, h } from "vue";
-import { Drawer, Button as AntButton } from "ant-design-vue";
-import {
- EditOutlined, DeleteOutlined, CopyOutlined, PlusOutlined, RobotOutlined,
- SearchOutlined, ControlOutlined, EllipsisOutlined,
-} from "@ant-design/icons-vue";
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiTable from "../ui/UiTable.vue";
-import UiActions from "../ui/UiActions.vue";
-import Panel from "../components/Panel.vue";
-import Field from "../components/Field.vue";
-import StatusTag from "../components/StatusTag.vue";
-import PrimaryButton from "../components/PrimaryButton.vue";
-import { fetchDb, loadRenderer } from "../lib/api.js";
-import { MODELS } from "../lib/meta.js";
-import { useConfirm } from "../pages/domain/useConfirm.js";
-import { DeviceImageField, DeviceTemplateCard, CP_RANGE_PRESETS, fileToBase64 } from "../components/DeviceTemplateCard.js";
-
-// React local `Button` (AntButton primary/default + "button" class).
-const Button = PrimaryButton;
-// React `Status` (Tag with tone class).
-const Status = StatusTag;
-
-const CURRENT_USER_EMAIL = "wang.yu@advantech.com";
-function useStreamComplete() {
- const completing = ref(false);
- const log = ref("");
- const preRef = ref(null);
- watch(log, () => { if (preRef.value) preRef.value.scrollTop = preRef.value.scrollHeight; });
- const setCompleting = (v) => (completing.value = typeof v === "function" ? v(completing.value) : v);
- const setLog = (v) => (log.value = typeof v === "function" ? v(log.value) : v);
- return { completing, setCompleting, log, setLog, ref: preRef };
-}
-
-async function streamInto(res, setLog, onEv) {
- const reader = res.body.getReader();
- const dec = new TextDecoder();
- let buf = "";
- for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += dec.decode(value, { stream: true });
- const lines = buf.split("\n");
- buf = lines.pop() || "";
- for (const line of lines) {
- if (!line.trim()) continue;
- try {
- const ev = JSON.parse(line);
- if (ev.log) setLog((prev) => prev + ev.log);
- onEv(ev);
- } catch {}
- }
- }
-}
-
-// ---- root component (DomainSettings) props / behavior ----
-const props = defineProps({
- domains: { type: Array, default: () => [] },
- notify: { type: Function, default: () => {} },
- onDeleted: { type: Function, default: () => Promise.resolve() },
-});
-
-const { confirm, el: confirmEl } = useConfirm();
-
-async function remove(row) {
- if (!(await confirm(`确定删除领域「${row.name}」?其下的方案将移入「其他」领域。`))) return;
- try {
- await fetchDb(`/domains/${row.id}`, { method: "DELETE" });
- await props.onDeleted(row.id);
- props.notify("已删除领域,相关方案已归入「其他」");
- } catch (e) {
- props.notify("删除失败:" + (e && e.message ? e.message : e));
- }
-}
-
-// ============================================================================
-// Co-located port of every component defined in app/page.tsx lines 1543–1928.
-// (DomainLibrary, CreateTemplate, DeviceImageField, DeviceTemplateCard,
-// DeviceTemplateCreate, DeviceTemplates, DeviceTemplatePicker, PageRow,
-// OpsFlowsViewer)
-// ============================================================================
-
-// ---- CreateTemplate (1648-1663) ----
-const CreateTemplate = {
- props: {
- doms: { type: Array, default: () => [] },
- notify: { type: Function, default: () => {} },
- onCreated: { type: Function, default: () => {} },
- },
- setup(p) {
- const domain_id = ref(p.doms[0] ? p.doms[0].id : 0);
- const name = ref("");
- const description = ref("");
- const status = ref("available");
- const version = ref("1.0");
- const creator = ref(CURRENT_USER_EMAIL);
- watch(
- () => p.doms,
- (d) => { if (d.length && !domain_id.value) domain_id.value = d[0].id; },
- { immediate: true }
- );
- const save = async () => {
- if (!domain_id.value) { p.notify("请选择所属领域"); return; }
- try {
- const r = await fetchDb("/domain-templates", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- domain_id: domain_id.value, name: name.value, description: description.value,
- status: status.value, version: version.value, creator: creator.value,
- }),
- });
- p.notify("已创建");
- p.onCreated(r.id);
- } catch (e) {
- p.notify("创建失败:" + (e && e.message ? e.message : e));
- }
- };
- return () =>
- h(Panel, { title: "新增领域方案" }, () =>
- h("div", { class: "form-grid" }, [
- h("label", {}, [
- "所属领域",
- h(UiSelect, { value: domain_id.value, onChange: (e) => (domain_id.value = Number(e.target.value)) }, () =>
- p.doms.map((d) => h("option", { key: d.id, value: d.id }, d.name))
- ),
- ]),
- h("label", {}, [
- "方案名称",
- h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value), placeholder: "如:自来水厂标准方案" }),
- ]),
- h("label", {}, ["模板版本", h(UiInput, { value: version.value, onChange: (e) => (version.value = e.target.value) })]),
- h("label", {}, [
- "状态",
- h(UiSelect, { value: status.value, onChange: (e) => (status.value = e.target.value) }, () => [
- h("option", { value: "available" }, "可用"),
- h("option", { value: "unavailable" }, "不可用"),
- ]),
- ]),
- h("label", {}, ["创建人", h(UiInput, { value: creator.value, onChange: (e) => (creator.value = e.target.value) })]),
- h("label", { style: { gridColumn: "1 / -1" } }, [
- "领域描述",
- h(UiInput, { textarea: true, value: description.value, onChange: (e) => (description.value = e.target.value), rows: 3 }),
- ]),
- h("div", { style: { gridColumn: "1 / -1" } }, [h(Button, { onClick: save }, "创建")]),
- ])
- );
- },
-};
-
-// ---- DomainLibrary (1559-1644) ----
-const DomainLibrary = {
- props: {
- go: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
- onOpen: { type: Function, default: () => {} },
- domainFilter: { type: Number, default: null },
- domainOptions: { type: Array, default: () => [] },
- },
- setup(p) {
- const list = ref([]);
- const loading = ref(true);
- const view = ref("list");
- const q = ref("");
- const domainSel = ref(p.domainFilter != null ? String(p.domainFilter) : "");
- const statusSel = ref("");
- const sort = ref({ key: "", dir: 1 });
- const { confirm, el: confirmEl } = useConfirm();
-
- const load = async () => {
- try {
- list.value = (await fetchDb("/domain-templates")) || [];
- } catch (e) {
- p.notify("加载失败:" + (e && e.message ? e.message : e));
- } finally {
- loading.value = false;
- }
- };
- onMounted(load);
-
- const del = async (id) => {
- if (!(await confirm("确定删除该领域方案?此操作不可恢复"))) return;
- try {
- await fetchDb(`/domain-templates/${id}`, { method: "DELETE" });
- p.notify("已删除");
- load();
- } catch (e) {
- p.notify("删除失败:" + (e && e.message ? e.message : e));
- }
- };
-
- const statusLabel = (s) => (s === "available" ? "可用" : "不可用");
- const toggleSort = (key) =>
- sort.value = sort.value.key === key ? { key, dir: sort.value.dir * -1 } : { key, dir: 1 };
- const sortInd = (key) =>
- sort.value.key === key ? h("span", { class: "sort-ind" }, sort.value.dir === 1 ? "▲" : "▼") : null;
-
- const filtered = computed(() => {
- let arr = list.value
- .filter((t) => domainSel.value === "" || String(t.domain_id) === domainSel.value)
- .filter((t) => statusSel.value === "" || t.status === statusSel.value)
- .filter((t) => {
- const k = q.value.trim().toLowerCase();
- if (!k) return true;
- return (t.name || "").toLowerCase().includes(k) || (t.creator || "").toLowerCase().includes(k);
- });
- arr = arr.slice().sort((a, b) => {
- if (!sort.value.key) return 0;
- let av, bv;
- if (sort.value.key === "domain") { av = a.domain_name; bv = b.domain_name; }
- else if (sort.value.key === "name") { av = a.name; bv = b.name; }
- else if (sort.value.key === "status") { av = statusLabel(a.status); bv = statusLabel(b.status); }
- else { av = a.creator || ""; bv = b.creator || ""; }
- av = String(av).toLowerCase(); bv = String(bv).toLowerCase();
- if (av < bv) return -1 * sort.value.dir;
- if (av > bv) return 1 * sort.value.dir;
- return 0;
- });
- return arr;
- });
-
- return () => [
- h(confirmEl),
- h("div", { class: "domain-hero" }, [
- h("div", {}, [
- h("span", { class: "eyebrow" }, "DOMAIN SOLUTION LIBRARY"),
- h("h2", {}, "领域方案库"),
- h("p", {}, "沉淀各领域标准方案,供客户站点直接使用或快速微调。"),
- ]),
- h("div", { class: "domain-hero-stats" }, p.domainOptions.map((d) =>
- h("div", { key: d.id }, [h("b", {}, list.value.filter((t) => t.domain_id === d.id).length), h("span", {}, d.name)])
- )),
- ]),
- h(Panel, {
- title: "领域方案列表",
- tools: () =>
- h("div", { class: "view-toggle" }, [
- h(UiButton, { class: view.value === "list" ? "active" : "", onClick: () => (view.value = "list") }, "▤ 列表"),
- h(UiButton, { class: view.value === "card" ? "active" : "", onClick: () => (view.value = "card") }, "▦ 卡片"),
- ]),
- }, () =>
- h("div", {}, [
- h("div", { class: "filter-bar" }, [
- h(UiInput, { class: "filter-search", value: q.value, onChange: (e) => (q.value = e.target.value), placeholder: "查找 方案名称 / 创建人" }),
- h(UiSelect, { value: domainSel.value, onChange: (e) => (domainSel.value = e.target.value) }, () => [
- h("option", { value: "" }, "全部所属领域"),
- ...p.domainOptions.map((d) => h("option", { key: d.id, value: String(d.id) }, d.name)),
- ]),
- h(UiSelect, { value: statusSel.value, onChange: (e) => (statusSel.value = e.target.value) }, () => [
- h("option", { value: "" }, "全部状态"),
- h("option", { value: "available" }, "可用"),
- h("option", { value: "unavailable" }, "不可用"),
- ]),
- h("span", { class: "filter-count muted" }, `共 ${filtered.value.length} 条`),
- ]),
- loading.value
- ? h("p", {}, "加载中…")
- : filtered.value.length === 0
- ? h("p", { class: "muted" }, "暂无方案,点击右上角「+ 新增领域方案」创建")
- : view.value === "list"
- ? h(UiTable, { class: "data-table domain-list" }, () => [
- h("thead", {}, h("tr", {}, [
- h("th", { class: "sortable", style: { width: 160 }, onClick: () => toggleSort("domain") }, ["所属领域", sortInd("domain")]),
- h("th", { class: "sortable", style: { width: "40%" }, onClick: () => toggleSort("name") }, ["方案名称", sortInd("name")]),
- h("th", { style: { width: 64 } }, "版本"),
- h("th", { class: "sortable", style: { width: 82 }, onClick: () => toggleSort("status") }, ["状态", sortInd("status")]),
- h("th", { class: "sortable", style: { width: 140 }, onClick: () => toggleSort("creator") }, ["创建人", sortInd("creator")]),
- h("th", { style: { width: 110 } }, "设备/页面/文档"),
- h("th", { style: { width: 120 } }, "操作"),
- ])),
- h("tbody", {}, filtered.value.map((t) =>
- h("tr", { key: t.id }, [
- h("td", {}, t.domain_name),
- h("td", {}, t.name),
- h("td", {}, t.version),
- h("td", {}, h(Status, { value: t.status === "available" ? "可用" : "不可用" })),
- h("td", {}, t.creator || "—"),
- h("td", {}, `${t.device_count}/${t.page_count}/${t.doc_count}`),
- h("td", {}, [
- h(UiButton, { class: "text-button", onClick: () => p.onOpen(t.id) }, "编辑"),
- " ",
- h(UiButton, { class: "text-button", onClick: () => del(t.id) }, "删除"),
- ]),
- ])
- )),
- ])
- : h("div", { class: "domain-card-grid" }, filtered.value.map((t) =>
- h("article", { key: t.id, class: "domain-card", onClick: () => p.onOpen(t.id) }, [
- h("header", {}, [h("h3", {}, t.name), h(Status, { value: t.status === "available" ? "可用" : "不可用" })]),
- h("span", { class: "badge" }, t.domain_name),
- h("div", { class: "meta" }, [h("span", {}, [h("b", {}, t.version)]), h("span", {}, ["创建人 ", h("b", {}, t.creator || "—")])]),
- h("div", { class: "meta" }, [
- h("span", {}, ["设备 ", h("b", {}, t.device_count)]),
- h("span", {}, ["页面 ", h("b", {}, t.page_count)]),
- h("span", {}, ["文档 ", h("b", {}, t.doc_count)]),
- ]),
- h("footer", { class: "card-actions" }, [
- h(UiButton, { class: "text-button", onClick: (e) => { e.stopPropagation(); p.onOpen(t.id); } }, "编辑"),
- h(UiButton, { class: "text-button", onClick: (e) => { e.stopPropagation(); del(t.id); } }, "删除"),
- ]),
- ])
- )),
- ])
- ),
- ];
- },
-};
-
-// ---- DeviceTemplateCreate (1830-1842) ----
-const DeviceTemplateCreate = {
- props: {
- notify: { type: Function, default: () => {} },
- onDone: { type: Function, default: () => {} },
- onCancel: { type: Function, default: () => {} },
- },
- setup(p) {
- const name = ref("");
- const description = ref("");
- const imgPreview = ref(null);
- const imgB64 = ref(null);
- const onPick = async (f) => {
- imgPreview.value = URL.createObjectURL(f);
- const b = await fileToBase64(f);
- imgB64.value = { filename: b.filename, mime: b.mime, data: b.data };
- };
- const add = async () => {
- if (!name.value.trim()) { p.notify("请填写设备名称"); return; }
- try {
- const body = { name: name.value, description: description.value };
- if (imgB64.value) body.image = imgB64.value;
- const r = await fetchDb("/device-templates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
- p.notify("已创建设备模版");
- p.onDone(r.id);
- } catch (e) {
- p.notify("创建失败:" + (e && e.message ? e.message : e));
- }
- };
- return () =>
- h(Panel, {
- title: "新增设备模版",
- tools: () => h(UiButton, { class: "text-button", onClick: p.onCancel }, "取消"),
- }, () =>
- h("div", { class: "form-grid" }, [
- h("label", {}, ["设备名称", h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value), placeholder: "如:PLC 控制器" })]),
- h("label", { style: { gridColumn: "1 / -1" } }, ["设备图片", h(DeviceImageField, { imageUrl: imgPreview.value, onPick })]),
- h("label", { style: { gridColumn: "1 / -1" } }, ["说明", h(UiInput, { textarea: true, value: description.value, onChange: (e) => (description.value = e.target.value), rows: 2 })]),
- h("div", { style: { gridColumn: "1 / -1" } }, [h(Button, { onClick: add }, "创建")]),
- ])
- );
- },
-};
-
-// ---- DeviceTemplates (1844-1883) ----
-const DeviceTemplates = {
- props: {
- notify: { type: Function, default: () => {} },
- onViewCpPrompt: { type: Function, default: () => {} },
- },
- setup(p) {
- const list = ref([]);
- const loading = ref(true);
- const openId = ref(null);
- const q = ref("");
- const { confirm, el: confirmEl } = useConfirm();
-
- const load = async () => {
- try {
- list.value = (await fetchDb("/device-templates")) || [];
- } catch (e) {
- p.notify("加载失败:" + (e && e.message ? e.message : e));
- } finally {
- loading.value = false;
- }
- };
- onMounted(load);
-
- const filtered = computed(() =>
- list.value.filter((d) => {
- if (!q.value.trim()) return true;
- const s = q.value.toLowerCase();
- return (d.name || "").toLowerCase().includes(s) || (d.description || "").toLowerCase().includes(s);
- })
- );
- const delDev = async (id) => {
- if (!(await confirm("删除该设备模板?"))) return;
- await fetchDb(`/device-templates/${id}`, { method: "DELETE" });
- load();
- };
- const dev = computed(() => (typeof openId.value === "number" ? list.value.find((d) => d.id === openId.value) : null));
-
- return () => {
- if (openId.value === null) {
- return [
- h(confirmEl),
- h("div", { class: "domain-hero" }, [
- h("div", {}, [h("span", { class: "eyebrow" }, "DEVICE TEMPLATE LIBRARY"), h("h2", {}, "设备模版"), h("p", {}, "独立维护设备模版及其采集点,编辑后可在领域方案中按需选用。")]),
- ]),
- h(Panel, {
- title: "设备模版",
- tools: () => h(Button, { onClick: () => (openId.value = "create") }, "+ 新增设备模版"),
- }, () =>
- h("div", {}, [
- h("div", { class: "list-toolbar" }, [h(UiInput, { class: "search", placeholder: "搜索设备名称 / 说明", value: q.value, onChange: (e) => (q.value = e.target.value) })]),
- loading.value
- ? h("p", {}, "加载中…")
- : filtered.value.length === 0
- ? h("p", { class: "muted" }, list.value.length === 0 ? "暂无设备模版,点击「+ 新增设备模版」创建。" : "无匹配结果")
- : h(UiTable, { class: "data-table device-tpl-list" }, () => [
- h("thead", {}, h("tr", {}, [h("th", {}, "图片"), h("th", {}, "名称"), h("th", {}, "说明"), h("th", {}, "采集点"), h("th", {}, "操作")])),
- h("tbody", {}, filtered.value.map((d) =>
- h("tr", { key: d.id, onClick: () => (openId.value = d.id) }, [
- h("td", {}, d.image_path ? h("img", { class: "tpl-thumb", src: `/api/db/device-templates/${d.id}/image`, alt: "" }) : h("span", { class: "tpl-thumb-empty" }, "—")),
- h("td", {}, d.name),
- h("td", { class: "muted" }, d.description || ""),
- h("td", {}, (d.collection_points || []).length),
- h("td", { onClick: (e) => e.stopPropagation() }, [
- h(UiButton, { class: "text-button", onClick: () => (openId.value = d.id) }, "编辑"),
- " ",
- h(UiButton, { class: "text-button", onClick: () => delDev(d.id) }, "删除"),
- ]),
- ])
- )),
- ]),
- ])
- ),
- ];
- }
- if (openId.value === "create") {
- return [
- h("div", { class: "domain-detail-head" }, [h(UiButton, { class: "text-button", onClick: () => (openId.value = null) }, "← 返回列表")]),
- h(DeviceTemplateCreate, {
- notify: p.notify,
- onDone: async (id) => { await load(); openId.value = id; },
- onCancel: () => (openId.value = null),
- }),
- ];
- }
- const dv = dev.value;
- if (!dv) return h("p", {}, "加载中…");
- return [
- h("div", { class: "domain-detail-head" }, [h(UiButton, { class: "text-button", onClick: () => (openId.value = null) }, "← 返回列表")]),
- h(DeviceTemplateCard, { dev: dv, notify: p.notify, onChange: load, onBack: () => (openId.value = null), onViewCpPrompt: p.onViewCpPrompt }),
- ];
- };
- },
-};
-
-// ---- DeviceTemplatePicker (1885-1909) ----
-const DeviceTemplatePicker = {
- props: {
- tmplId: { type: Number, default: 0 },
- go: { type: Function, default: () => {} },
- selectedIds: { type: Array, default: () => [] },
- notify: { type: Function, default: () => {} },
- onChange: { type: Function, default: () => {} },
- },
- setup(p) {
- const all = ref([]);
- const sel = ref(new Set(p.selectedIds));
- watch(
- () => p.selectedIds,
- (v) => (sel.value = new Set(v)),
- { immediate: true }
- );
- onMounted(() => {
- fetchDb("/device-templates").then((d) => (all.value = d || [])).catch(() => {});
- });
- const toggle = async (id, checked) => {
- const next = new Set(sel.value);
- if (checked) next.add(id); else next.delete(id);
- sel.value = next;
- try {
- await fetchDb(`/domain-templates/${p.tmplId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ device_template_ids: [...next] }),
- });
- p.notify(checked ? "已选用设备模版" : "已移除设备模版");
- p.onChange();
- } catch (e) {
- p.notify("保存失败:" + (e && e.message ? e.message : e));
- const back = new Set(sel.value);
- if (checked) back.delete(id); else back.add(id);
- sel.value = back;
- }
- };
- return () =>
- h("div", { class: "device-picker" }, [
- all.value.length === 0
- ? h("p", { class: "muted" }, "暂无设备模版,点击下方「+ 新增设备模版」创建后选用。")
- : all.value.map((d) => {
- let doms = [];
- try { doms = JSON.parse(d.domain_names || "[]"); } catch {}
- return h("label", { key: d.id, class: "picker-item" + (sel.value.has(d.id) ? " on" : "") }, [
- h(UiInput, { type: "checkbox", checked: sel.value.has(d.id), onChange: (e) => toggle(d.id, e.target.checked) }),
- h("span", { class: "picker-name" }, d.name),
- h("span", { class: "picker-doms" }, doms.length ? doms.join("、") : "未归类"),
- ]);
- }),
- h("div", { style: { marginTop: 10 } }, [
- h("a", { class: "text-button", href: "#", onClick: (e) => { e.preventDefault(); p.go("deviceTemplates"); } }, "+ 新增设备模版"),
- ]),
- ]);
- },
-};
-
-// ---- PageRow (1911-1924) ----
-const PageRow = {
- props: {
- p: { type: Object, default: () => ({}) },
- notify: { type: Function, default: () => {} },
- onChange: { type: Function, default: () => {} },
- },
- setup(p) {
- const name = ref(p.p.name);
- const description = ref(p.p.description || "");
- const json_text = ref(p.p.json_text);
- const editing = ref(false);
- const { confirm, el: confirmEl } = useConfirm();
- const save = async () => {
- try { JSON.parse(json_text.value); } catch { p.notify("Cube JSON 不是合法 JSON"); return; }
- await fetchDb(`/domain-pages/${p.p.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: name.value, description: description.value, json_text: json_text.value }),
- });
- p.notify("页面已保存");
- editing.value = false;
- p.onChange();
- };
- const del = async () => {
- if (!(await confirm("删除该页面?"))) return;
- await fetchDb(`/domain-pages/${p.p.id}`, { method: "DELETE" });
- p.onChange();
- };
- return () => [
- h(confirmEl),
- h("div", { class: "page-row" }, [
- h("div", { class: "page-row-head" }, [
- h("b", {}, name.value),
- h("span", {}, editing.value
- ? [h(Button, { onClick: save }, "保存"), " ", h(UiButton, { class: "text-button", onClick: () => (editing.value = false) }, "取消")]
- : [h(UiButton, { class: "text-button", onClick: () => (editing.value = true) }, "编辑"), " ", h(UiButton, { class: "text-button", onClick: del }, "删除")]),
- ]),
- editing.value
- ? h("div", { class: "form-grid" }, [
- h("label", {}, ["页面名称", h(UiInput, { value: name.value, onChange: (e) => (name.value = e.target.value) })]),
- h("label", {}, ["说明", h(UiInput, { value: description.value, onChange: (e) => (description.value = e.target.value) })]),
- h("label", { style: { gridColumn: "1 / -1" } }, [
- "Cube JSON",
- h(UiInput, { textarea: true, value: json_text.value, onChange: (e) => (json_text.value = e.target.value), rows: 10, style: { fontFamily: "monospace" } }),
- ]),
- ])
- : null,
- ]),
- ];
- },
-};
-
-// ---- OpsFlowsViewer (1926-1927) ----
-const OpsFlowsViewer = {
- setup() {
- const flows = ref([]);
- onMounted(() => {
- fetchDb("/ops-flows").then((d) => (flows.value = d || [])).catch(() => {});
- });
- return () =>
- h(Panel, { title: "运维流程(本期为占位,后续实现)" }, () => [
- h(UiTable, { class: "data-table" }, () => [
- h("thead", {}, h("tr", {}, [h("th", {}, "流程名称"), h("th", {}, "内容")])),
- h("tbody", {}, flows.value.length === 0
- ? h("tr", {}, [h("td", { colSpan: 2, class: "muted" }, "暂无")])
- : flows.value.map((f) => h("tr", { key: f.id }, [h("td", {}, f.name), h("td", { style: { maxWidth: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, f.content)]))),
- ]),
- h("p", { class: "muted" }, "运维流程功能尚未实现,当前仅保留占位记录。"),
- ]);
- },
-};
-
-</script>
-
-<template>
- <component :is="confirmEl" />
- <div class="system-settings-page">
- <Panel title="领域配置">
- <div class="settings-table-wrap">
- <UiTable class="data-table company-table settings-domain-table">
- <thead>
- <tr><th>领域名称</th><th>使用范围</th><th>状态</th><th>操作</th></tr>
- </thead>
- <tbody>
- <tr v-for="row in domains" :key="row.id">
- <td><b>{{ row.name }}</b></td>
- <td>领域方案、站点项目</td>
- <td><Status :value="row.name === '其他' ? '系统预置' : '可用'" /></td>
- <td>
- <span v-if="row.name === '其他'" class="muted">不可删除</span>
- <UiButton v-else class="text-button danger" @click="() => remove(row)">删除</UiButton>
- </td>
- </tr>
- </tbody>
- </UiTable>
- <div v-if="domains.length === 0" class="empty-state">暂无领域,请点击右上角“新增领域”创建</div>
- </div>
- </Panel>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch } from "vue";
-import { Drawer, Button, Tooltip } from "ant-design-vue";
-import {
- ApartmentOutlined,
- CaretRightOutlined,
- CaretDownOutlined,
- ArrowUpOutlined,
- ArrowDownOutlined,
- EditOutlined,
- EyeOutlined,
-} from "@ant-design/icons-vue";
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiTable from "../ui/UiTable.vue";
-import UiActions from "../ui/UiActions.vue";
-import UiOverlay from "../ui/UiOverlay.vue";
-import {
- APPLICATION_COLOR_SCHEMES,
- buildSiteMenu,
- menuSelectionSignature,
- orderMenu,
- selectedMenuSources,
-} from "../site-menu-model.js";
-
-const props = defineProps({
- site: { type: Object, default: null },
- leaves: { type: Array, default: () => [] },
- onSiteChange: { type: Function, default: null },
- notify: { type: Function, default: () => {} },
- registerSave: { type: Function, default: null },
-});
-
-async function api(path, body, method = "POST") {
- const res = await fetch(
- `/api/db${path}`,
- body === undefined
- ? undefined
- : { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
- );
- if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
- return res.json();
-}
-
-const menu = ref(buildSiteMenu(props.site, props.leaves));
-const appName = ref(props.site?.app_name || props.site?.name || "");
-const appIcon = ref(props.site?.app_icon || null);
-const iconFilename = ref(props.site?.app_icon_filename || "");
-const scheme = ref(props.site?.app_color_scheme || "blue");
-const saving = ref(false);
-const savingRef = ref(false);
-const collapsed = ref(new Set());
-const previewCollapsed = ref(new Set());
-const previewOpen = ref(false);
-const previewPage = ref(null);
-const editDraft = ref(null);
-const restoreConfirm = ref(false);
-
-const signature = computed(() => menuSelectionSignature(props.site, props.leaves));
-watch(signature, (newSig, oldSig) => {
- if (oldSig !== undefined && oldSig !== newSig) {
- menu.value = buildSiteMenu(props.site, props.leaves, menu.value);
- collapsed.value = new Set();
- }
-});
-
-const sources = computed(() => selectedMenuSources(props.site, props.leaves));
-const color = computed(
- () => APPLICATION_COLOR_SCHEMES.find((item) => item.key === scheme.value)?.color || "#009fe3"
-);
-const ordered = computed(() => orderMenu(menu.value));
-const children = (id) => menu.value.filter((item) => item.parent_id === id);
-const depth = (item) => {
- let level = 0;
- let parent = item.parent_id;
- const seen = new Set();
- while (parent != null && !seen.has(parent)) {
- seen.add(parent);
- level++;
- parent = menu.value.find((other) => other.id === parent)?.parent_id ?? null;
- }
- return level;
-};
-const visible = (item, closed) => {
- let parent = item.parent_id;
- while (parent != null) {
- if (closed.has(parent)) return false;
- parent = menu.value.find((other) => other.id === parent)?.parent_id ?? null;
- }
- return true;
-};
-const toggle = (id, preview = false) => {
- const target = preview ? previewCollapsed : collapsed;
- const next = new Set(target.value);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- target.value = next;
-};
-const movable = (item) => item.page_type === "sc" || item.page_type === "cube";
-const move = (item, direction) => {
- if (saving.value || !movable(item)) return;
- const siblings = children(item.parent_id);
- const index = siblings.findIndex((other) => other.id === item.id);
- const target = siblings[index + direction];
- if (!target) return;
- const next = [...menu.value];
- const a = next.findIndex((other) => other.id === item.id);
- const b = next.findIndex((other) => other.id === target.id);
- [next[a], next[b]] = [next[b], next[a]];
- menu.value = orderMenu(next);
-};
-const readIcon = (file) => {
- if (!file) return;
- if (!["image/png", "image/jpeg", "image/svg+xml"].includes(file.type) || file.size > 100 * 1024) {
- props.notify("Icon 支持 PNG、JPG、SVG,文件不大于 100 KB");
- return;
- }
- const reader = new FileReader();
- reader.onload = () => {
- appIcon.value = reader.result;
- iconFilename.value = file.name;
- };
- reader.onerror = () => props.notify("Icon 读取失败,请重新选择");
- reader.readAsDataURL(file);
-};
-const save = async () => {
- if (savingRef.value) throw new Error("菜单正在保存,请稍后重试");
- if (editDraft.value) throw new Error("请先确定或取消当前菜单编辑");
- if (!appName.value.trim()) throw new Error("请填写应用名称");
- if (menu.value.some((item) => !item.label.trim())) throw new Error("请填写所有菜单名称");
- const pageKeys = menu.value
- .filter((item) => item.page_type)
- .map((item) => `${item.page_type}:${item.ref_id}`);
- if (pageKeys.length !== sources.value.length || sources.value.some((source) => !pageKeys.includes(source.key)))
- throw new Error("页面选择已变化,请重新进入菜单配置");
- savingRef.value = true;
- saving.value = true;
- try {
- await api(
- `/sites/${props.site.id}/menu`,
- {
- menu: orderMenu(menu.value),
- application: {
- app_name: appName.value.trim(),
- app_icon: appIcon.value,
- app_icon_filename: iconFilename.value,
- app_color_scheme: scheme.value,
- },
- },
- "PUT"
- );
- const saved = await api(`/sites/${props.site.id}`);
- menu.value = buildSiteMenu(saved, props.leaves);
- props.onSiteChange && props.onSiteChange(saved);
- } finally {
- savingRef.value = false;
- saving.value = false;
- }
-};
-
-watch(
- [menu, appName, appIcon, iconFilename, scheme, saving, signature, () => editDraft.value],
- (_, __, onInvalidate) => {
- props.registerSave && props.registerSave(save);
- onInvalidate(() => props.registerSave && props.registerSave(null));
- },
- { immediate: true }
-);
-
-const fixedGroup = (item) => ["group:sc", "group:cube"].includes(item.source_key);
-const kind = (item) =>
- item.page_type === "sc"
- ? "数字孪生场景"
- : item.page_type === "cube"
- ? "领域仪表板"
- : item.page_type === "mu"
- ? "标准功能页面"
- : "菜单分组";
-const selectedPreview = computed(() => menu.value.find((item) => item.id === previewPage.value));
-const rowSiblings = (item) => (item.parent_id == null ? [] : children(item.parent_id));
-const rowIndex = (item) => rowSiblings(item).findIndex((other) => other.id === item.id);
-
-function collapseAll() {
- collapsed.value = collapsed.value.size
- ? new Set()
- : new Set(menu.value.filter((item) => !item.page_type).map((item) => item.id));
-}
-function openPreview() {
- previewOpen.value = true;
- previewPage.value = null;
- previewCollapsed.value = new Set();
-}
-function confirmEditDraft() {
- if (!editDraft.value?.label.trim()) {
- props.notify("请填写菜单名称");
- return;
- }
- const id = editDraft.value.id;
- const label = editDraft.value.label.trim();
- menu.value = menu.value.map((item) => (item.id === id ? { ...item, label } : item));
- editDraft.value = null;
-}
-function restoreDefault() {
- menu.value = buildSiteMenu(props.site, props.leaves, []);
- collapsed.value = new Set();
- restoreConfirm.value = false;
-}
-</script>
-
-<template>
- <div class="station-menu-configuration station-application-settings">
- <section class="station-application-basic">
- <header><h2>基本信息</h2></header>
- <div class="station-application-fields">
- <label class="station-application-name">
- <span><i>*</i> 应用名称</span>
- <UiInput
- aria-label="应用名称"
- :maxLength="100"
- :value="appName"
- :disabled="saving"
- placeholder="请输入应用名称"
- @change="(e) => (appName = e.target.value)"
- />
- </label>
- <div class="station-application-icon">
- <span>Icon</span>
- <div class="station-icon-control">
- <div class="station-icon-image">
- <img v-if="appIcon" :src="appIcon" alt="应用 Icon" />
- <ApartmentOutlined v-else />
- </div>
- <label :class="saving ? 'station-icon-upload disabled' : 'station-icon-upload'">
- <span>{{ appIcon ? "更换 Icon" : "上传 Icon" }}</span>
- <input
- aria-label="上传应用 Icon"
- type="file"
- hidden
- :disabled="saving"
- accept="image/png,image/jpeg,image/svg+xml"
- @change="(e) => { readIcon(e.target.files?.[0]); e.target.value = ''; }"
- />
- </label>
- <span class="station-icon-file" :title="iconFilename">{{ iconFilename || "PNG / JPG / SVG,≤100 KB" }}</span>
- <UiButton v-if="appIcon" className="link" :disabled="saving" @click="() => { appIcon = null; iconFilename = ''; }">清除</UiButton>
- </div>
- </div>
- <label class="station-application-scheme">
- <span>色系</span>
- <div>
- <i :style="{ background: color }" />
- <UiSelect
- aria-label="应用色系"
- :value="scheme"
- :disabled="saving"
- @change="(e) => (scheme = e.target.value)"
- >
- <option v-for="item in APPLICATION_COLOR_SCHEMES" :key="item.key" :value="item.key">{{ item.label }}</option>
- </UiSelect>
- </div>
- </label>
- </div>
- </section>
- <section class="station-application-menu">
- <header>
- <h2>菜单列表 <small>{{ sources.length }} 个页面</small></h2>
- <UiActions className="station-application-menu-actions">
- <Button :disabled="saving" @click="collapseAll">{{ collapsed.size ? "展开全部" : "收起全部" }}</Button>
- <Button :disabled="saving" @click="() => (restoreConfirm = true)">恢复默认</Button>
- <Button type="primary" @click="openPreview">
- <template #icon><EyeOutlined /></template>
- 预览菜单
- </Button>
- </UiActions>
- </header>
- <div class="station-application-menu-scroll">
- <UiTable className="data-table company-table station-application-menu-table">
- <thead>
- <tr><th>菜单名称</th><th>关联页面</th><th>页面类型</th><th>操作</th></tr>
- </thead>
- <tbody>
- <tr
- v-for="item in ordered.filter((i) => visible(i, collapsed))"
- :key="item.id"
- :class="!item.page_type ? 'station-menu-group-row' : ''"
- >
- <td>
- <div class="station-menu-name" :style="{ paddingLeft: depth(item) * 28 + 'px' }">
- <template v-if="!item.page_type">
- <UiButton
- className="station-menu-disclosure"
- :aria-label="`${collapsed.has(item.id) ? '展开' : '收起'}菜单${item.label}`"
- :aria-expanded="!collapsed.has(item.id)"
- @click="() => toggle(item.id)"
- >
- <CaretRightOutlined v-if="collapsed.has(item.id)" />
- <CaretDownOutlined v-else />
- </UiButton>
- </template>
- <span v-else class="station-menu-child-guide" />
- <span>{{ item.label }}</span>
- <small v-if="!item.page_type">{{ children(item.id).length }} 项</small>
- </div>
- </td>
- <td>
- <span :class="!item.page_type ? 'station-menu-association muted' : 'station-menu-association'">{{ !item.page_type ? "—" : item.source_name }}</span>
- </td>
- <td>{{ kind(item) }}</td>
- <td>
- <div class="station-menu-row-actions">
- <Tooltip :title="!movable(item) ? '保持原有菜单结构与顺序' : '上移'">
- <Button
- type="text"
- :aria-label="`上移菜单${item.label}`"
- :disabled="saving || !movable(item) || rowIndex(item) <= 0"
- @click="() => move(item, -1)"
- >
- <template #icon><ArrowUpOutlined /></template>
- </Button>
- </Tooltip>
- <Tooltip :title="!movable(item) ? '保持原有菜单结构与顺序' : '下移'">
- <Button
- type="text"
- :aria-label="`下移菜单${item.label}`"
- :disabled="saving || !movable(item) || rowIndex(item) >= rowSiblings(item).length - 1"
- @click="() => move(item, 1)"
- >
- <template #icon><ArrowDownOutlined /></template>
- </Button>
- </Tooltip>
- <Tooltip :title="fixedGroup(item) ? '领域菜单名称固定' : '编辑'">
- <Button
- type="text"
- :aria-label="`编辑菜单${item.label}`"
- :disabled="saving || fixedGroup(item)"
- @click="() => (editDraft = { ...item })"
- >
- <template #icon><EditOutlined /></template>
- </Button>
- </Tooltip>
- </div>
- </td>
- </tr>
- </tbody>
- </UiTable>
- </div>
- <footer class="station-application-menu-note">
- <span>数字孪生、领域监控固定在前;标准功能保留原有结构与顺序。</span>
- <span>{{ saving ? "正在保存…" : (!props.site.mu?.length && !props.site.page_config?.standard_pages_initialized ? "标准功能默认全选,可在第四步调整" : "增减页面请返回第四步“页面配置”") }}</span>
- </footer>
- </section>
- <Drawer
- className="station-info-drawer station-menu-edit-drawer"
- :width="560"
- title="编辑菜单"
- :open="!!editDraft"
- @close="() => (editDraft = null)"
- >
- <div v-if="editDraft" class="station-menu-edit-fields">
- <label>
- <span><i>*</i> 菜单名称</span>
- <UiInput
- aria-label="编辑菜单名称"
- :value="editDraft.label"
- :maxLength="50"
- @change="(e) => (editDraft = { ...editDraft, label: e.target.value })"
- />
- </label>
- <label>
- <span>上级菜单</span>
- <UiInput readonly :value="menu.find((item) => item.id === editDraft.parent_id)?.label || '一级菜单'" />
- </label>
- <label>
- <span>关联页面</span>
- <UiInput readonly :value="editDraft.page_type ? editDraft.source_name : '菜单分组'" />
- </label>
- </div>
- <template #footer>
- <UiActions className="station-drawer-actions">
- <Button @click="() => (editDraft = null)">取消</Button>
- <Button type="primary" @click="confirmEditDraft">确定</Button>
- </UiActions>
- </template>
- </Drawer>
- <Drawer
- className="station-info-drawer station-application-preview-drawer"
- :width="460"
- title="菜单预览"
- :open="previewOpen"
- @close="() => (previewOpen = false)"
- >
- <div class="station-application-preview" :style="{ '--application-color': color }">
- <header>
- <img v-if="appIcon" :src="appIcon" alt="应用 Icon" />
- <ApartmentOutlined v-else />
- <strong>{{ appName || "未命名应用" }}</strong>
- </header>
- <nav>
- <div
- v-for="item in ordered.filter((i) => visible(i, previewCollapsed))"
- :key="item.id"
- :style="{ paddingLeft: 18 + depth(item) * 26 + 'px' }"
- >
- <UiButton
- :class="!item.page_type ? 'group' : previewPage === item.id ? 'page active' : 'page'"
- @click="() => (item.page_type ? (previewPage = item.id) : toggle(item.id, true))"
- >
- <span>{{ item.label }}</span>
- <CaretRightOutlined v-if="!item.page_type && previewCollapsed.has(item.id)" />
- <CaretDownOutlined v-else-if="!item.page_type" />
- </UiButton>
- </div>
- </nav>
- <div v-if="selectedPreview" class="station-menu-preview-current">
- <span>{{ kind(selectedPreview) }}</span>
- <b>{{ selectedPreview.source_name }}</b>
- <small>菜单预览仅展示入口,不打开实际页面。</small>
- </div>
- </div>
- </Drawer>
- <UiOverlay v-if="restoreConfirm" className="modal-backdrop">
- <div class="modal station-replace-confirm">
- <header><h2>恢复默认菜单</h2><UiButton @click="() => (restoreConfirm = false)">×</UiButton></header>
- <div class="modal-body">
- <p>按第四步的页面选择恢复菜单名称和排序。应用名称、Icon 与色系保持不变,点击“保存草稿”后生效。</p>
- </div>
- <footer>
- <Button @click="() => (restoreConfirm = false)">取消</Button>
- <Button type="primary" @click="restoreDefault">恢复默认</Button>
- </footer>
- </div>
- </UiOverlay>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { computed } from "vue";
-import { Button } from "ant-design-vue";
-import { DownloadOutlined } from "@ant-design/icons-vue";
-import Panel from "../components/Panel.vue";
-import UiButton from "../ui/UiButton.vue";
-
-const props = defineProps({
- site: { type: Object, default: null },
- onSiteChange: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
- viewPrompt: { type: Function, default: () => {} },
- viewSitePrompt: { type: Function, default: () => {} },
-});
-
-// NOTE (faithful port): the React source defines running/tasks/progress/zipUrl/
-// log state plus a `start()` streaming generator, but the returned JSX only
-// renders the confirmation grid below — the generate trigger is not wired in the
-// source, so only the confirmation DOM is reproduced here.
-
-const scope = computed(() => {
- const s = props.site;
- if (!s) return [];
- const selectedCubes = (s.cubes || []).filter(
- (cube) => !s.page_config?.dashboard_ids || s.page_config.dashboard_ids.includes(cube.id)
- );
- return [
- ["站点", s.name || "未命名站点", s.domain],
- [
- "Asset 资产",
- `${s.assets.filter((a) => a.type).length} 类 / ${s.assets.reduce((n, a) => n + Number(a.count || 0), 0)} 个设备`,
- `使用当前站点已保存的资产组结构`,
- ],
- ["标准页面", `${s.mu.length} 个标准页面`, `使用第四步页面选择与第五步菜单配置`],
- ["Cube 页面", `${selectedCubes.length} 个扩展页面`, `生成 ${selectedCubes.length} 个独立 JSON`],
- ];
-});
-</script>
-
-<template>
- <Panel v-if="!site"><div class="empty-state">加载中…</div></Panel>
- <div v-else class="generate-layout">
- <Panel title="生成范围确认">
- <div class="confirm-grid">
- <div v-for="x in scope" :key="x[0]">
- <span>{{ x[0] }}</span>
- <b>{{ x[1] }}</b>
- <small>{{ x[2] }}</small>
- <div style="display: flex; gap: 6px">
- <UiButton>返回修改</UiButton>
- <UiButton class="text-button"><DownloadOutlined /> 下载</UiButton>
- </div>
- </div>
- </div>
- <div style="margin-top: 16px; text-align: right">
- <Button type="primary" class="button"><DownloadOutlined /> 下载全部</Button>
- </div>
- </Panel>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted, onUnmounted, h, defineComponent } from "vue";
-import { Drawer, Dropdown, QRCode, Button as AntButton } from "ant-design-vue";
-import {
- CopyOutlined, SnippetsOutlined, ControlOutlined, DeleteOutlined, PlusSquareOutlined,
- PlusOutlined, BlockOutlined, ApartmentOutlined, BarsOutlined, ZoomOutOutlined,
- ZoomInOutlined, AimOutlined, SearchOutlined, EditOutlined, LinkOutlined, UploadOutlined,
-} from "@ant-design/icons-vue";
-
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
-import UiActions from "../ui/UiActions.vue";
-import UiOverlay from "../ui/UiOverlay.vue";
-import Field from "../components/Field.vue";
-import PrimaryButton from "../components/PrimaryButton.vue";
-
-import { fetchDb } from "../lib/api.js";
-
-const props = defineProps({
- site: { type: Object, required: true },
- notify: { type: Function, default: () => {} },
- onSiteChange: { type: Function, default: null },
- registerSave: { type: Function, default: null },
-});
-const notify = (m) => (props.notify ? props.notify(m) : null);
-
-// ---------------------------------------------------------------------------
-// helpers (ported from app/page.tsx 1106-1133, 1179-1188, 127)
-// ---------------------------------------------------------------------------
-function newAssetNodeId() {
- return "node-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
-}
-function cloneAssetNode(node) {
- return {
- ...node,
- id: newAssetNodeId(),
- properties: node.properties.map((item) => ({ ...item })),
- bindings: node.bindings.map((item) => ({ ...item })),
- children: node.children.map(cloneAssetNode),
- };
-}
-function findAssetNode(nodes, id) {
- for (const node of nodes) {
- if (node.id === id) return node;
- const child = findAssetNode(node.children, id);
- if (child) return child;
- }
- return null;
-}
-function updateAssetNode(nodes, id, next) {
- return nodes.map((node) =>
- node.id === id ? next : { ...node, children: updateAssetNode(node.children, id, next) }
- );
-}
-function appendAssetNode(nodes, parentId, node) {
- if (!parentId) return [...nodes, node];
- return nodes.map((item) =>
- item.id === parentId
- ? { ...item, children: [...item.children, node] }
- : { ...item, children: appendAssetNode(item.children, parentId, node) }
- );
-}
-function removeAssetNode(nodes, id) {
- return nodes
- .filter((node) => node.id !== id)
- .map((node) => ({ ...node, children: removeAssetNode(node.children, id) }));
-}
-function findAssetParentId(nodes, id, parentId = null) {
- for (const node of nodes) {
- if (node.id === id) return parentId;
- const found = findAssetParentId(node.children, id, node.id);
- if (found !== null) return found;
- }
- return null;
-}
-function flattenAssetNodes(nodes, depth = 0) {
- return nodes.flatMap((node) => [{ node, depth }, ...flattenAssetNodes(node.children, depth + 1)]);
-}
-const assetPropertyFields = [
- "型号", "价值", "地址", "安装位置", "负责人", "品牌", "供应商", "经销商",
- "出厂日期", "投产日期", "质保截止日期", "NFC ID", "备注", "设备运行状态计算阈值",
-];
-function assetPropertyValue(node, name) {
- return node.properties.find((item) => item.name === name)?.value || "";
-}
-function setAssetProperty(node, name, value) {
- const exists = node.properties.some((item) => item.name === name);
- return {
- ...node,
- properties: exists
- ? node.properties.map((item) => (item.name === name ? { ...item, value } : item))
- : [...node.properties, { name, value, unit: "" }],
- };
-}
-function formatInstances(rule, count) {
- if (!rule || count <= 0) return "";
- const pad = Math.max(2, String(count).length);
- const parts = [];
- for (let i = 1; i <= count; i++) {
- const num = String(i).padStart(pad, "0");
- if (rule.includes("#")) {
- parts.push(rule.replace(/#+/g, (m) => String(i).padStart(Math.max(pad, m.length), "0")));
- } else {
- const sep = rule.endsWith("-") ? "" : "-";
- parts.push(`${rule}${sep}${num}`);
- }
- }
- return parts.join("、");
-}
-
-// ---------------------------------------------------------------------------
-// load / save
-// ---------------------------------------------------------------------------
-const group = ref(null);
-const deviceTemplates = ref([]);
-const templateNames = ref({});
-const loading = ref(true);
-const loadError = ref("");
-
-const seedChildren = () =>
- props.site.assets
- .filter((item) => item.type)
- .map((item) => {
- const count = Math.max(0, Number(item.count) || 0);
- return {
- id: newAssetNodeId(),
- name: item.type,
- node_type: "group",
- template_name: item.type,
- description: `${item.type}设备集合`,
- properties: [],
- bindings: [],
- children: Array.from({ length: count }, (_, index) => ({
- id: newAssetNodeId(),
- name:
- formatInstances(item.rule, count).split("、")[index] ||
- `${item.type}-${String(index + 1).padStart(2, "0")}`,
- node_type: "device",
- template_name: item.type,
- description: "",
- properties: [],
- bindings: [],
- children: [],
- })),
- };
- });
-const seedStructure = () => [
- {
- id: newAssetNodeId(),
- name: props.site.name || "未命名站点",
- node_type: "group",
- template_name: "站点资产根节点",
- description: "当前站点的资产结构根节点",
- properties: [],
- bindings: [],
- children: seedChildren(),
- },
-];
-
-const loadGroup = async () => {
- if (!props.site?.id) return;
- loading.value = true;
- loadError.value = "";
- try {
- const [loadedGroups, allDevices] = await Promise.all([
- fetchDb(`/sites/${props.site.id}/asset-groups`),
- fetchDb("/device-templates"),
- ]);
- templateNames.value = Object.fromEntries(allDevices.map((device) => [String(device.id), device.name]));
- let rows = loadedGroups || [];
- if (rows.length === 0) {
- const created = await fetchDb(`/sites/${props.site.id}/asset-groups`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: `${props.site.name || "未命名站点"}资产组`,
- category: props.site.domain || "站点资产",
- template_name: "通用资产模板",
- structure: seedStructure(),
- }),
- });
- rows = [created];
- }
- const current = rows[0];
- const g = {
- ...current,
- name: current.name?.trim() || `${props.site.name || "未命名站点"}资产组`,
- structure: current.structure && current.structure.length ? current.structure : seedStructure(),
- };
- group.value = g;
- draft.value = { ...g, structure: normalizeLegacy(g.structure) };
- selectedId.value = draft.value.structure[0]?.id || null;
- } catch (e) {
- const message = "加载资产组失败:" + (e?.message || e);
- loadError.value = message;
- notify(message);
- } finally {
- loading.value = false;
- }
-};
-
-const normalizeLegacy = (nodes) =>
- nodes.map((node) => ({
- ...node,
- name: templateNames.value[node.name] || node.name,
- template_name: templateNames.value[node.template_name] || node.template_name,
- children: normalizeLegacy(node.children),
- }));
-
-const saveGroup = async (g) => {
- const saved = await fetchDb(`/sites/${props.site.id}/asset-groups/${g.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(g),
- });
- group.value = saved;
- if (props.onSiteChange) props.onSiteChange(await fetchDb(`/sites/${props.site.id}`));
- notify("资产组已保存");
- return saved;
-};
-
-onMounted(loadGroup);
-watch(
- () => props.site?.id,
- () => {
- group.value = null;
- loadGroup();
- }
-);
-
-// ---------------------------------------------------------------------------
-// editor state (folded from React ManualAssetEditor)
-// ---------------------------------------------------------------------------
-const draft = ref(null);
-const selectedId = ref(null);
-const nodeOpen = ref(false);
-const nodeTab = ref("basic");
-const nodeDraft = ref(null);
-const batchOpen = ref(false);
-const batchTab = ref("online");
-const batchRows = ref([{ template_name: "进水提升泵", count: 2, rule: "beng-ts-##" }]);
-const clipboard = ref(null);
-const search = ref("");
-const viewMode = ref("tree");
-const zoom = ref(1);
-const pan = ref({ x: 0, y: 0 });
-const dragging = ref(false);
-const saving = ref(false);
-const templateCategory = ref("site");
-const creatingNodeId = ref(null);
-const bindingComponent = ref("HealthStatus");
-const bindingImportOpen = ref(false);
-
-const selected = computed(() =>
- selectedId.value ? findAssetNode(draft.value ? draft.value.structure : [], selectedId.value) : null
-);
-
-const assetTemplates = computed(() =>
- Array.from(
- new Set([
- ...deviceTemplates.value,
- ...flattenAssetNodes(draft.value ? draft.value.structure : [])
- .map(({ node }) => node.template_name)
- .filter(Boolean),
- "不绑定设备的模板",
- ])
- )
-);
-const templateList = computed(() =>
- templateCategory.value === "site" ? assetTemplates.value : ["不绑定设备的模板"]
-);
-const visibleRows = computed(() =>
- flattenAssetNodes(draft.value ? draft.value.structure : [])
- .filter(
- ({ node }) =>
- !search.value ||
- node.name.toLowerCase().includes(search.value.toLowerCase()) ||
- node.template_name.toLowerCase().includes(search.value.toLowerCase())
- )
-);
-const bindingComponents = computed(() =>
- Array.from(
- new Set([
- "HealthStatus",
- "Motor DE H",
- "Motor NDE H",
- ...(nodeDraft.value?.bindings.map((item) => item.source).filter(Boolean) || []),
- ])
- )
-);
-
-// ---- node / draft helpers ----
-const patchNode = (patch) => {
- if (nodeDraft.value) nodeDraft.value = { ...nodeDraft.value, ...patch };
-};
-const applyAssetProperty = (name, value) => {
- if (nodeDraft.value) nodeDraft.value = setAssetProperty(nodeDraft.value, name, value);
-};
-const openNode = (node) => {
- creatingNodeId.value = null;
- selectedId.value = node.id;
- nodeDraft.value = {
- ...node,
- alias: node.alias || `${node.name}-alias`,
- asset_type: node.asset_type || (node.node_type === "device" ? "Machine" : "Group"),
- properties: node.properties.map((item) => ({ ...item })),
- bindings: node.bindings.map((item) => ({ ...item })),
- children: node.children,
- };
- bindingComponent.value = node.bindings[0]?.source || "HealthStatus";
- nodeTab.value = "basic";
- nodeOpen.value = true;
-};
-const addNode = (parent, templateName) => {
- const p = parent ?? selected.value;
- const tpl = templateName ?? (draft.value ? draft.value.template_name : "");
- const node = {
- id: newAssetNodeId(),
- name: "新建资产节点",
- alias: "new-asset-alias",
- asset_type: "Machine",
- node_type: "device",
- template_name: tpl,
- description: "",
- properties: [],
- bindings: [],
- children: [],
- };
- draft.value = {
- ...draft.value,
- structure: appendAssetNode(draft.value.structure, p?.id || null, node),
- };
- creatingNodeId.value = node.id;
- selectedId.value = node.id;
- nodeDraft.value = node;
- bindingComponent.value = "HealthStatus";
- nodeTab.value = "basic";
- nodeOpen.value = true;
-};
-const addSibling = () => {
- const parentId = selectedId.value ? findAssetParentId(draft.value.structure, selectedId.value) : null;
- const node = {
- id: newAssetNodeId(),
- name: "新建同级节点",
- alias: "new-peer-alias",
- asset_type: "Machine",
- node_type: "device",
- template_name: draft.value.template_name,
- description: "",
- properties: [],
- bindings: [],
- children: [],
- };
- draft.value = {
- ...draft.value,
- structure: appendAssetNode(draft.value.structure, parentId, node),
- };
- creatingNodeId.value = node.id;
- selectedId.value = node.id;
- nodeDraft.value = node;
- bindingComponent.value = "HealthStatus";
- nodeTab.value = "basic";
- nodeOpen.value = true;
-};
-const pasteNode = () => {
- if (!clipboard.value) {
- notify("请先复制一个节点");
- return;
- }
- const node = cloneAssetNode(clipboard.value);
- draft.value = {
- ...draft.value,
- structure: appendAssetNode(draft.value.structure, selectedId.value, node),
- };
- selectedId.value = node.id;
- notify("节点已粘贴");
-};
-const deleteNode = () => {
- if (!selectedId.value) {
- notify("请先选择节点");
- return;
- }
- draft.value = {
- ...draft.value,
- structure: removeAssetNode(draft.value.structure, selectedId.value),
- };
- selectedId.value = null;
- notify("节点已移除,保存后生效");
-};
-const saveNode = () => {
- if (!nodeDraft.value) return;
- if (
- !nodeDraft.value.name.trim() ||
- !nodeDraft.value.alias?.trim() ||
- !nodeDraft.value.template_name.trim() ||
- !nodeDraft.value.asset_type?.trim()
- ) {
- notify("请填写名称、编号/别名、模板和类型");
- return;
- }
- draft.value = {
- ...draft.value,
- structure: updateAssetNode(draft.value.structure, nodeDraft.value.id, nodeDraft.value),
- };
- creatingNodeId.value = null;
- nodeOpen.value = false;
- notify("节点信息已更新");
-};
-const cancelNode = () => {
- if (creatingNodeId.value) {
- draft.value = {
- ...draft.value,
- structure: removeAssetNode(draft.value.structure, creatingNodeId.value),
- };
- selectedId.value = null;
- }
- creatingNodeId.value = null;
- nodeOpen.value = false;
- nodeDraft.value = null;
-};
-const readNodeImage = (file) => {
- if (file.size > 2 * 1024 * 1024) {
- notify("图片不能超过 2 MB");
- return;
- }
- const reader = new FileReader();
- reader.onload = () => {
- if (nodeDraft.value) nodeDraft.value = { ...nodeDraft.value, image_data: String(reader.result || "") };
- };
- reader.readAsDataURL(file);
-};
-const onNodeImageChange = (e) => {
- const file = e.target.files?.[0];
- if (file) readNodeImage(file);
-};
-
-// ---- batch ----
-const addBatchRow = () => batchRows.value = [...batchRows.value, { template_name: "", count: 1, rule: "device-##" }];
-const updateBatchRow = (index, patch) =>
- (batchRows.value = batchRows.value.map((item, i) => (i === index ? { ...item, ...patch } : item)));
-const removeBatchRow = (index) => (batchRows.value = batchRows.value.filter((_, i) => i !== index));
-const createBatch = () => {
- let structure = draft.value.structure;
- for (const row of batchRows.value.filter((item) => item.template_name && item.count > 0)) {
- const parent = {
- id: newAssetNodeId(),
- name: row.template_name,
- node_type: "group",
- template_name: row.template_name,
- description: "批量创建",
- properties: [],
- bindings: [],
- children: Array.from({ length: row.count }, (_, index) => ({
- id: newAssetNodeId(),
- name: row.rule.includes("#")
- ? row.rule.replace(/#+/, (m) => String(index + 1).padStart(m.length, "0"))
- : `${row.rule}${String(index + 1).padStart(2, "0")}`,
- node_type: "device",
- template_name: row.template_name,
- description: "",
- properties: [],
- bindings: [],
- children: [],
- })),
- };
- structure = appendAssetNode(structure, selectedId.value, parent);
- }
- draft.value = { ...draft.value, structure };
- batchOpen.value = false;
- notify("批量节点已加入资产树");
-};
-const onBatchFileChange = (e) => {
- const file = e.target.files?.[0];
- if (file) importBatchFile(file);
-};
-const importBatchFile = async (file) => {
- try {
- const XLSX = await import("xlsx");
- const wb = XLSX.read(await file.arrayBuffer());
- const sheet = wb.Sheets[wb.SheetNames[0]];
- const data = XLSX.utils.sheet_to_json(sheet);
- const rows = data
- .map((item) => ({
- template_name: String(item["设备类型"] || item["资产模板"] || item["名称"] || ""),
- count: Number(item["数量"] || 1),
- rule: String(item["命名规则"] || "device-##"),
- }))
- .filter((item) => item.template_name);
- if (!rows.length) throw new Error("未找到“设备类型/资产模板、数量、命名规则”列");
- batchRows.value = rows;
- batchTab.value = "online";
- notify(`已读取 ${rows.length} 行配置`);
- } catch (e) {
- notify("读取文件失败:" + (e?.message || e));
- }
-};
-
-// ---- bindings ----
-const copySelected = (deep) => {
- if (!selected.value) {
- notify("请先选择节点");
- return;
- }
- clipboard.value = deep ? selected.value : { ...selected.value, children: [] };
- notify(deep ? "已深度复制节点及其下级" : "已复制节点");
-};
-const editSelected = (tab = "basic") => {
- if (!selected.value) {
- notify("请先选择节点");
- return;
- }
- openNode(selected.value);
- nodeTab.value = tab;
-};
-const bindTemplate = (templateName) => {
- if (!selected.value) {
- notify("请先选择节点");
- return;
- }
- const next = { ...selected.value, template_name: templateName };
- draft.value = {
- ...draft.value,
- structure: updateAssetNode(draft.value.structure, selected.value.id, next),
- };
- notify(`已应用${templateName}`);
-};
-const bindEdgeComponent = (component) => {
- if (!nodeDraft.value) return;
- const exists = nodeDraft.value.bindings.some((item) => item.source === component);
- const bindings = exists
- ? nodeDraft.value.bindings.map((item) =>
- item.source === component
- ? {
- ...item,
- collect_type: item.collect_type || "IoT Edge",
- group: item.group || "Default",
- device_name: item.device_name || `${nodeDraft.value.name}-device`,
- }
- : item
- )
- : [
- ...nodeDraft.value.bindings,
- {
- source: component,
- name: component,
- point: "",
- collect_type: "IoT Edge",
- group: "Default",
- device_name: `${nodeDraft.value.name}-device`,
- unit: "",
- precision: "2",
- mapping: "",
- description: "",
- },
- ];
- nodeDraft.value = { ...nodeDraft.value, bindings };
- bindingComponent.value = component;
- notify(`已绑定边缘组件 ${component}`);
-};
-const updateBinding = (index, patch) => {
- if (!nodeDraft.value) return;
- const targetIndexes = nodeDraft.value.bindings
- .map((item, i) => (item.source === bindingComponent.value ? i : -1))
- .filter((i) => i >= 0);
- const target = targetIndexes[index];
- if (target === undefined) return;
- nodeDraft.value = {
- ...nodeDraft.value,
- bindings: nodeDraft.value.bindings.map((item, i) => (i === target ? { ...item, ...patch } : item)),
- };
-};
-const removeBinding = (index) => {
- if (!nodeDraft.value) return;
- const targetIndexes = nodeDraft.value.bindings
- .map((item, i) => (item.source === bindingComponent.value ? i : -1))
- .filter((i) => i >= 0);
- const target = targetIndexes[index];
- nodeDraft.value = {
- ...nodeDraft.value,
- bindings: nodeDraft.value.bindings.filter((_, i) => i !== target),
- };
-};
-const onBindingFileChange = (e) => {
- const file = e.target.files?.[0];
- if (file) importBindings(file);
-};
-const importBindings = async (file) => {
- if (!nodeDraft.value) return;
- try {
- const XLSX = await import("xlsx");
- const wb = XLSX.read(await file.arrayBuffer());
- const sheet = wb.Sheets[wb.SheetNames[0]];
- const data = XLSX.utils.sheet_to_json(sheet);
- const rows = data
- .map((item) => ({
- source: bindingComponent.value,
- name: String(item["名称"] || item["参数名称"] || ""),
- point: String(item["采集点"] || item["点位"] || ""),
- unit: String(item["单位"] || ""),
- precision: String(item["精度"] ?? "2"),
- mapping: String(item["映射"] || ""),
- description: String(item["描述"] || ""),
- }))
- .filter((item) => item.name || item.point);
- if (!rows.length) throw new Error("未找到名称或采集点数据");
- nodeDraft.value = {
- ...nodeDraft.value,
- bindings: [
- ...nodeDraft.value.bindings.filter((item) => item.source !== bindingComponent.value),
- ...rows,
- ],
- };
- bindingImportOpen.value = false;
- notify(`已导入 ${rows.length} 个点位参数`);
- } catch (e) {
- notify("导入失败:" + (e?.message || e));
- }
-};
-
-// ---- view / zoom ----
-const setViewMode = (m) => (viewMode.value = m);
-const setTemplateCategory = (c) => (templateCategory.value = c);
-const setBatchTab = (t) => (batchTab.value = t);
-const setNodeTab = (t) => (nodeTab.value = t);
-const selectBindingComponent = (c) => (bindingComponent.value = c);
-const changeZoom = (fn) => (zoom.value = fn(zoom.value));
-const fitCanvas = () => {
- zoom.value = 1;
- pan.value = { x: 0, y: 0 };
-};
-const onCanvasPointerDown = (e) => {
- if (e.target.closest && e.target.closest("button,input,[role='button']")) return;
- dragging.value = true;
- e.currentTarget.setPointerCapture(e.pointerId);
-};
-const onCanvasPointerMove = (e) => {
- if (dragging.value) pan.value = { x: pan.value.x + e.movementX, y: pan.value.y + e.movementY };
-};
-const onCanvasPointerUp = (e) => {
- dragging.value = false;
- e.currentTarget.releasePointerCapture(e.pointerId);
-};
-
-// ---- save ----
-const saveAll = async () => {
- if (nodeOpen.value || batchOpen.value) throw new Error("请先确认或取消正在编辑的节点/批量配置");
- if (saving.value) throw new Error("资产正在保存");
- if (JSON.stringify(draft.value) === JSON.stringify(group.value)) return;
- saving.value = true;
- try {
- const saved = await saveGroup(draft.value);
- draft.value = saved;
- } finally {
- saving.value = false;
- }
-};
-const onSaveClick = async () => {
- try {
- await saveAll();
- notify("资产组已保存");
- } catch (e) {
- notify("保存失败:" + (e?.message || e));
- }
-};
-
-watch([draft, group, saving, nodeOpen, batchOpen], () => {
- if (props.registerSave) props.registerSave(saveAll);
-});
-onUnmounted(() => {
- if (props.registerSave) props.registerSave(null);
-});
-
-// ---------------------------------------------------------------------------
-// recursive tree branch (port of React AssetTreeBranch, 1269-1273)
-// ---------------------------------------------------------------------------
-const AssetTreeBranch = defineComponent({
- name: "AssetTreeBranch",
- props: {
- node: { type: Object, required: true },
- selectedId: { type: String, default: null },
- search: { type: String, default: "" },
- onSelect: { type: Function, default: null },
- onOpen: { type: Function, default: null },
- onAddChild: { type: Function, default: null },
- },
- setup(p) {
- const expanded = ref(true);
- const matched = computed(
- () =>
- !!p.search &&
- (p.node.name.toLowerCase().includes(p.search.toLowerCase()) ||
- p.node.template_name.toLowerCase().includes(p.search.toLowerCase()))
- );
- const cardClass = computed(() =>
- [
- "asset-node-card",
- p.selectedId === p.node.id ? "selected" : "",
- matched.value ? "matched" : "",
- ]
- .filter(Boolean)
- .join(" ")
- );
- return () =>
- h("div", { class: "asset-tree-branch" }, [
- h(
- "div",
- {
- role: "button",
- tabIndex: 0,
- class: cardClass.value,
- onClick: () => p.onOpen && p.onOpen(p.node),
- onKeydown: (e) => {
- if (e.key === "Enter" && p.onOpen) p.onOpen(p.node);
- },
- },
- [
- h("span", p.node.node_type === "group" ? "◇" : "◆"),
- h("b", p.node.name),
- h("small", p.node.template_name),
- p.node.children.length
- ? h(
- UiButton,
- {
- title: expanded.value ? "收起下级节点" : "展开下级节点",
- "aria-label": expanded.value ? `收起${p.node.name}` : `展开${p.node.name}`,
- onClick: (e) => {
- e.stopPropagation();
- if (p.onSelect) p.onSelect(p.node.id);
- expanded.value = !expanded.value;
- },
- },
- () => (expanded.value ? "−" : "+")
- )
- : h(
- UiButton,
- {
- title: "添加子节点",
- "aria-label": `为${p.node.name}添加子节点`,
- onClick: (e) => {
- e.stopPropagation();
- if (p.onSelect) p.onSelect(p.node.id);
- if (p.onAddChild) p.onAddChild(p.node);
- },
- },
- () => h(PlusOutlined)
- ),
- ]
- ),
- expanded.value && p.node.children.length > 0
- ? h(
- "div",
- { class: "asset-tree-children" },
- p.node.children.map((child) =>
- h(AssetTreeBranch, {
- key: child.id,
- node: child,
- selectedId: p.selectedId,
- search: p.search,
- onSelect: p.onSelect,
- onOpen: p.onOpen,
- onAddChild: p.onAddChild,
- })
- )
- )
- : null,
- ]);
- },
-});
-</script>
-
-<template>
- <div class="manual-asset-editor">
- <div v-if="loading" class="asset-editor-loading"><span>正在打开当前站点资产组…</span></div>
- <div v-else-if="loadError || !group" class="asset-editor-loading error">
- <span>{{ loadError || "资产组不可用" }}</span>
- <PrimaryButton secondary @click="loadGroup">重新加载</PrimaryButton>
- </div>
- <template v-else>
- <div class="asset-editor-title">
- <div><h2>资产组详情</h2><span>当前站点:{{ site.name || "未命名站点" }}</span></div>
- <PrimaryButton :disabled="saving" @click="onSaveClick">{{ saving ? "保存中…" : "保存" }}</PrimaryButton>
- </div>
- <div class="asset-editor-workbench">
- <aside class="asset-editor-side">
- <Field label="站点资产组">
- <UiInput :value="draft.name" @change="(e) => (draft = { ...draft, name: e.target.value })" />
- </Field>
- <UiButton class="asset-batch-entry" @click="batchOpen = true">批量创建</UiButton>
- <div class="asset-template-panel">
- <strong>资产模板</strong>
- <UiSelect aria-label="资产模板分类" :value="templateCategory" @change="(e) => setTemplateCategory(e.target.value)">
- <option value="site">场站设备类别</option>
- <option value="unbound">不绑定设备的类别</option>
- </UiSelect>
- <UiButton
- v-for="template in templateList"
- :key="template"
- @click="addNode(selected, template)"
- >
- <span>{{ templateCategory === "site" ? "◆" : "◇" }}</span>
- <b>{{ template }}</b>
- <small>点击添加到当前选中节点</small>
- </UiButton>
- </div>
- </aside>
-
- <section class="asset-tree-workspace">
- <div class="asset-editor-toolbar">
- <div class="asset-tool-group">
- <AntButton type="text" class="asset-tool-button" title="复制节点" aria-label="复制节点" :disabled="!selected" :icon="h(CopyOutlined)" @click="() => copySelected(false)" />
- <AntButton type="text" class="asset-tool-button" title="深度复制节点及其下级" aria-label="深度复制节点" :disabled="!selected" :icon="h(SnippetsOutlined)" @click="() => copySelected(true)" />
- <AntButton type="text" class="asset-tool-button" title="节点属性设置" aria-label="节点属性设置" :disabled="!selected" :icon="h(ControlOutlined)" @click="() => editSelected('properties')" />
- <AntButton type="text" class="asset-tool-button" title="删除节点" aria-label="删除节点" danger :disabled="!selected" :icon="h(DeleteOutlined)" @click="deleteNode" />
- <span class="asset-tool-divider" />
- <AntButton type="text" class="asset-tool-button primary" title="添加同级节点" aria-label="添加同级节点" :disabled="!selected" :icon="h(PlusSquareOutlined)" @click="addSibling" />
- <AntButton type="text" class="asset-tool-button primary" title="添加子节点" aria-label="添加子节点" :icon="h(PlusOutlined)" @click="() => addNode(selected)" />
- <Dropdown
- trigger="['click']"
- :menu="{ items: assetTemplates.map((name) => ({ key: name, label: `应用${name}` })), onClick: ({ key }) => bindTemplate(key) }"
- >
- <AntButton type="text" class="asset-tool-button primary" title="应用资产模板" aria-label="应用资产模板" :disabled="!selected" :icon="h(BlockOutlined)" />
- </Dropdown>
- </div>
- <div class="asset-view-switch" role="group" aria-label="视图切换">
- <UiButton :class="viewMode === 'tree' ? 'active' : ''" title="资产树视图" @click="setViewMode('tree')"><ApartmentOutlined /></UiButton>
- <UiButton :class="viewMode === 'list' ? 'active' : ''" title="列表视图" @click="setViewMode('list')"><BarsOutlined /></UiButton>
- </div>
- <div class="asset-canvas-tools">
- <AntButton type="text" class="asset-tool-button" title="缩小" aria-label="缩小" :icon="h(ZoomOutOutlined)" @click="changeZoom((v) => Math.max(0.55, Number((v - 0.1).toFixed(2))))" />
- <AntButton type="text" class="asset-tool-button" title="放大" aria-label="放大" :icon="h(ZoomInOutlined)" @click="changeZoom((v) => Math.min(1.6, Number((v + 0.1).toFixed(2))))" />
- <AntButton type="text" class="asset-tool-button" title="适合窗口" aria-label="适合窗口" :icon="h(AimOutlined)" @click="fitCanvas" />
- <label class="asset-tree-search"><SearchOutlined /><UiInput :value="search" @change="(e) => (search = e.target.value)" placeholder="搜索资产节点" /></label>
- </div>
- </div>
-
- <div
- v-if="viewMode === 'tree'"
- :class="dragging ? 'asset-tree-canvas dragging' : 'asset-tree-canvas'"
- @pointerdown="onCanvasPointerDown"
- @pointermove="onCanvasPointerMove"
- @pointerup="onCanvasPointerUp"
- >
- <div class="asset-tree-stage" :style="{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }">
- <div class="asset-node-tree">
- <AssetTreeBranch
- v-for="node in draft.structure"
- :key="node.id"
- :node="node"
- :selected-id="selectedId"
- :search="search"
- :on-select="(id) => (selectedId = id)"
- :on-open="openNode"
- :on-add-child="addNode"
- />
- </div>
- </div>
- </div>
- <div v-else class="asset-node-list">
- <div class="asset-node-list-head">
- <span>资产名称</span><span>节点类型</span><span>资产模板</span><span>下级节点</span>
- </div>
- <template v-if="visibleRows.length">
- <UiButton
- v-for="{ node, depth } in visibleRows"
- :key="node.id"
- :class="selectedId === node.id ? 'selected' : ''"
- @click="openNode(node)"
- >
- <span :style="{ paddingLeft: depth * 22 + 'px' }">{{ node.node_type === "group" ? "◇" : "◆" }} {{ node.name }}</span>
- <span>{{ node.node_type === "group" ? "资产分组" : "设备资产" }}</span>
- <span>{{ node.template_name || "—" }}</span>
- <span>{{ node.children.length }}</span>
- </UiButton>
- </template>
- <div v-else class="asset-tree-empty"><b>没有匹配的资产节点</b><span>请更换搜索关键词。</span></div>
- </div>
- </section>
- </div>
-
- <!-- node config drawer -->
- <Drawer
- class="asset-node-drawer"
- title="节点配置"
- :width="'58vw'"
- :open="nodeOpen"
- @close="cancelNode"
- >
- <template #footer>
- <div class="node-drawer-footer">
- <PrimaryButton secondary @click="cancelNode">取消</PrimaryButton>
- <PrimaryButton
- :disabled="!nodeDraft?.name.trim() || !nodeDraft?.alias?.trim() || !nodeDraft?.template_name.trim() || !nodeDraft?.asset_type?.trim()"
- @click="saveNode"
- >确定</PrimaryButton>
- </div>
- </template>
- <UiTabbedRegion>
- <template #navigation>
- <div class="node-drawer-tabs">
- <UiButton :class="nodeTab === 'basic' ? 'active' : ''" @click="setNodeTab('basic')">基础信息设定</UiButton>
- <UiButton :class="nodeTab === 'properties' ? 'active' : ''" @click="setNodeTab('properties')">属性设定</UiButton>
- <UiButton :class="nodeTab === 'binding' ? 'active' : ''" @click="setNodeTab('binding')">数据绑定</UiButton>
- </div>
- </template>
-
- <div v-if="nodeDraft && nodeTab === 'basic'" class="node-config-page">
- <section class="node-config-section"><h3>基础设定</h3>
- <div class="node-id-row"><span>节点ID</span><b>{{ nodeDraft.id.replace("node-", "").slice(0, 10) }}</b></div>
- <div class="node-basic-grid">
- <Field label="名称" required><UiInput :value="nodeDraft.name" @change="(e) => patchNode({ name: e.target.value })" /></Field>
- <Field label="编号/别名" required><UiInput :value="nodeDraft.alias || ''" @change="(e) => patchNode({ alias: e.target.value })" /></Field>
- <Field label="模板" required>
- <UiSelect :value="nodeDraft.template_name" @change="(e) => patchNode({ template_name: e.target.value })">
- <option v-for="name in Array.from(new Set([...assetTemplates, nodeDraft.template_name]))" :key="name">{{ name }}</option>
- </UiSelect>
- </Field>
- <Field label="类型" required><UiInput :value="nodeDraft.asset_type || ''" @change="(e) => patchNode({ asset_type: e.target.value })" /></Field>
- <Field label="节点描述"><UiInput :value="nodeDraft.description" placeholder="请输入" @change="(e) => patchNode({ description: e.target.value })" /></Field>
- </div>
- </section>
- <section class="node-config-section"><h3>图片</h3>
- <div class="node-image-row">
- <label class="node-image-preview">
- <input type="file" accept="image/jpeg,image/png,image/bmp,image/svg+xml" hidden @change="onNodeImageChange" />
- <img v-if="nodeDraft.image_data" :src="nodeDraft.image_data" alt="节点图片" />
- <BlockOutlined v-else />
- </label>
- <div>
- <label class="node-upload-link">
- <input type="file" accept="image/jpeg,image/png,image/bmp,image/svg+xml" hidden @change="onNodeImageChange" />
- <UploadOutlined /> 图片上传
- </label>
- <small>支持格式 jpeg/jpg/png/bmp/svg,文件不超过 2 MB</small>
- <UiButton v-if="nodeDraft.image_data" class="text-button" @click="patchNode({ image_data: '' })">移除图片</UiButton>
- </div>
- </div>
- </section>
- <section class="node-config-section"><h3>节点二维码</h3>
- <QRCode :size="112" :value="`stationbuilder://site/${group.site_id}/asset/${nodeDraft.id}`" :bordered="false" />
- </section>
- </div>
-
- <div v-if="nodeDraft && nodeTab === 'properties'" class="node-config-page">
- <section class="node-config-section"><h3>基础属性</h3>
- <div class="node-property-grid">
- <Field v-for="name in assetPropertyFields" :key="name" :label="name">
- <UiInput
- :type="name.includes('日期') ? 'date' : 'text'"
- :value="assetPropertyValue(nodeDraft, name) || (name === '设备运行状态计算阈值' ? '5' : '')"
- :placeholder="name === '设备运行状态计算阈值' ? '5' : '请输入'"
- @change="(e) => applyAssetProperty(name, e.target.value)"
- />
- </Field>
- </div>
- </section>
- </div>
-
- <div v-if="nodeDraft && nodeTab === 'binding'" class="node-config-page binding-page">
- <section class="node-config-section"><h3>边缘端绑定</h3><h4>组件列表</h4>
- <div class="edge-component-table">
- <header><span>边缘组件</span><span>采集类型</span><span>群组</span><span>设备名称</span><span>操作</span></header>
- <div v-for="component in bindingComponents" :key="component" :class="bindingComponent === component ? 'active' : ''">
- <b>{{ component }}</b>
- <span>{{ nodeDraft.bindings.find((b) => b.source === component)?.collect_type || "—" }}</span>
- <span>{{ nodeDraft.bindings.find((b) => b.source === component)?.group || "—" }}</span>
- <span>{{ nodeDraft.bindings.find((b) => b.source === component)?.device_name || "—" }}</span>
- <span>
- <UiButton title="编辑点位" @click="selectBindingComponent(component)"><EditOutlined /></UiButton>
- <UiButton title="绑定边缘设备" @click="bindEdgeComponent(component)"><LinkOutlined /></UiButton>
- </span>
- </div>
- </div>
- </section>
- <section class="node-config-section"><h3>点位绑定</h3>
- <UiTabbedRegion>
- <template #navigation>
- <div class="binding-component-tabs">
- <UiButton v-for="component in bindingComponents" :key="component" :class="bindingComponent === component ? 'active' : ''" @click="selectBindingComponent(component)">{{ component }}</UiButton>
- </div>
- </template>
- <div class="binding-parameter-head"><h4>参数列表</h4><PrimaryButton @click="bindingImportOpen = true">+ 批量导入</PrimaryButton></div>
- <div class="binding-parameter-table">
- <header><span>名称</span><span>采集点</span><span>单位</span><span>精度</span><span>映射</span><span>描述</span><span>操作</span></header>
- <div v-for="(item, index) in nodeDraft.bindings.filter((b) => b.source === bindingComponent)" :key="bindingComponent + '-' + index">
- <UiInput :value="item.name || ''" placeholder="参数名称" @change="(e) => updateBinding(index, { name: e.target.value })" />
- <UiInput :value="item.point" placeholder="采集点" @change="(e) => updateBinding(index, { point: e.target.value })" />
- <UiInput :value="item.unit || ''" @change="(e) => updateBinding(index, { unit: e.target.value })" />
- <UiInput :value="item.precision || ''" @change="(e) => updateBinding(index, { precision: e.target.value })" />
- <UiInput :value="item.mapping || ''" @change="(e) => updateBinding(index, { mapping: e.target.value })" />
- <UiInput :value="item.description || ''" @change="(e) => updateBinding(index, { description: e.target.value })" />
- <UiButton class="trash" title="删除参数" @click="removeBinding(index)"><DeleteOutlined /></UiButton>
- </div>
- <div v-if="!nodeDraft.bindings.some((b) => b.source === bindingComponent)" class="binding-empty">暂无数据,请先在组件列表中绑定设备或批量导入点位。</div>
- </div>
- </UiTabbedRegion>
- </section>
- </div>
- </UiTabbedRegion>
- </Drawer>
-
- <!-- binding import dialog -->
- <div v-if="bindingImportOpen" class="dlg-backdrop binding-import-backdrop" @mousedown="bindingImportOpen = false">
- <div class="dlg-box binding-import-dialog" @mousedown.stop>
- <h2>批量导入点位参数</h2>
- <p class="hint">导入到组件:{{ bindingComponent }}</p>
- <label class="manual-excel-upload">
- <input type="file" accept=".xlsx,.xls,.csv" hidden @change="onBindingFileChange" />
- <b>⇧</b><span>点击选择点位文件</span>
- <small>表头支持:名称、采集点、单位、精度、映射、描述</small>
- </label>
- <UiActions className="dlg-actions">
- <PrimaryButton secondary @click="bindingImportOpen = false">取消</PrimaryButton>
- </UiActions>
- </div>
- </div>
-
- <!-- batch create dialog -->
- <UiOverlay v-if="batchOpen" class="dlg-backdrop" :onMouseDown="() => (batchOpen = false)">
- <div class="dlg-box batch-create-dialog" @mousedown.stop>
- <h2>批量创建资产节点</h2>
- <UiTabbedRegion>
- <template #navigation>
- <div class="node-drawer-tabs">
- <UiButton :class="batchTab === 'online' ? 'active' : ''" @click="setBatchTab('online')">在线配置</UiButton>
- <UiButton :class="batchTab === 'excel' ? 'active' : ''" @click="setBatchTab('excel')">Excel 导入</UiButton>
- </div>
- </template>
- <div v-if="batchTab === 'online'" class="batch-table">
- <header><span>资产模板</span><span>数量</span><span>命名规则</span><span></span></header>
- <div v-for="(row, index) in batchRows" :key="index">
- <UiInput :value="row.template_name" @change="(e) => updateBatchRow(index, { template_name: e.target.value })" />
- <UiInput type="number" min="1" :value="row.count" @change="(e) => updateBatchRow(index, { count: Number(e.target.value) })" />
- <UiInput :value="row.rule" @change="(e) => updateBatchRow(index, { rule: e.target.value })" />
- <UiButton class="trash" @click="removeBatchRow(index)">×</UiButton>
- </div>
- <PrimaryButton secondary @click="addBatchRow">+ 添加一行</PrimaryButton>
- </div>
- <label v-else class="manual-excel-upload">
- <input type="file" accept=".xlsx,.xls,.csv" hidden @change="onBatchFileChange" />
- <b>⇧</b><span>点击选择资产配置文件</span>
- <small>表头支持:设备类型或资产模板、数量、命名规则</small>
- </label>
- </UiTabbedRegion>
- <UiActions className="dlg-actions">
- <PrimaryButton secondary @click="batchOpen = false">取消</PrimaryButton>
- <PrimaryButton :disabled="batchTab !== 'online' || !batchRows.some((r) => r.template_name && r.count > 0)" @click="createBatch">创建节点</PrimaryButton>
- </UiActions>
- </div>
- </UiOverlay>
- </template>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onUnmounted, h } from "vue";
-import { Drawer, Button } from "ant-design-vue";
-import { EyeOutlined } from "@ant-design/icons-vue";
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import SearchInput from "../components/SearchInput.vue";
-import { effectiveStandardPageKeys } from "../site-menu-model.js";
-
-const props = defineProps({
- site: { type: Object, default: null },
- leaves: { type: Array, default: () => [] },
- onMuChange: { type: Function, default: null },
- onPageConfigChange: { type: Function, default: null },
- onSiteChange: { type: Function, default: null },
- renderDashboard: { type: Function, default: () => null },
- renderDomainDashboard: { type: Function, default: () => null },
- notify: { type: Function, default: () => {} },
- registerSave: { type: Function, default: null },
-});
-
-// Mirror React api(): fetch /api/db + JSON; no-body calls default to GET.
-async function api(path, body, method = "POST") {
- const res = await fetch(
- `/api/db${path}`,
- body === undefined
- ? undefined
- : { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
- );
- if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
- return res.json();
-}
-
-const query = ref("");
-const saving = ref(false);
-const preview = ref(null);
-const catalog = ref({ pages: [], local_cubes: [] });
-const catalogLoading = ref(true);
-const catalogError = ref(false);
-let active = true;
-onUnmounted(() => {
- active = false;
-});
-
-watch(
- () => [props.site?.id, props.site?.template_id, props.site?.domain],
- async () => {
- if (!props.site?.id) return;
- catalogLoading.value = true;
- catalogError.value = false;
- try {
- const data = await api(`/sites/${props.site.id}/domain-dashboards`);
- if (active) catalog.value = data;
- } catch (error) {
- if (active) {
- catalogError.value = true;
- props.notify("加载领域仪表板失败:" + error.message);
- }
- } finally {
- if (active) catalogLoading.value = false;
- }
- },
- { immediate: true }
-);
-
-const domainCards = computed(() =>
- (catalog.value.pages || []).map((page) => ({
- key: `domain-${page.id}`,
- name: page.name,
- page,
- cube: (props.site?.cubes || []).find((cube) => cube.id === page.cube_id),
- }))
-);
-const dashboardCards = computed(() => [
- ...domainCards.value,
- ...(props.site?.cubes || [])
- .filter((cube) => !domainCards.value.some((card) => card.cube && card.cube.id === cube.id))
- .map((cube) => ({ key: `cube-${cube.id}`, name: cube.name, cube, page: null })),
-]);
-
-function moduleGroup(leaf) {
- const name = leaf.parentMenu || leaf.label;
- if (/设置|设定|分类|设备类型/.test(name)) return "系统与业务设置";
- if (/维修/.test(name)) return "维修管理";
- if (/保养/.test(name)) return "保养管理";
- if (/点检|异常工单/.test(name)) return "点检管理";
- if (/备件|仓库|库位|出入库/.test(name)) return "备件管理";
- if (/台账/.test(name)) return "资产台账";
- if (/工单中心|表单|流程|定时/.test(name)) return "工单与流程";
- return name;
-}
-
-const grouped = computed(() => {
- const groups = new Map();
- for (const leaf of props.leaves.filter(
- (leaf) => !query.value || `${leaf.label} ${leaf.parentMenu}`.includes(query.value)
- )) {
- const group = moduleGroup(leaf);
- if (!groups.has(group)) groups.set(group, []);
- groups.get(group).push(leaf);
- }
- return [...groups.entries()];
-});
-
-watch(
- [
- saving,
- () => props.site?.page_config?.standard_pages_initialized,
- () => (props.site?.mu || []).join("|"),
- () => props.leaves.length,
- ],
- (_, __, onInvalidate) => {
- let fn = null;
- if (saving.value) fn = async () => { throw new Error("页面选择正在保存,请稍后重试"); };
- else if (
- !props.site?.mu?.length &&
- !props.site?.page_config?.standard_pages_initialized &&
- props.leaves.length
- )
- fn = async () => {
- await props.onMuChange(effectiveStandardPageKeys(props.site, props.leaves));
- };
- props.registerSave && props.registerSave(fn);
- onInvalidate(() => props.registerSave && props.registerSave(null));
- },
- { immediate: true }
-);
-
-const selectedMu = computed(() => effectiveStandardPageKeys(props.site, props.leaves));
-const dashboardIds = computed(
- () => props.site?.page_config?.dashboard_ids ?? props.site?.cubes?.map((cube) => cube.id) ?? []
-);
-const twinIds = computed(() => props.site?.page_config?.twin_ids || []);
-const twins = [
- { id: "site-overview", name: "场站全景", description: "SC 数字孪生场景画面" },
- { id: "process-scene", name: "工艺与设备场景", description: "SC 数字孪生场景画面" },
-];
-
-function dashboardImage(card, large = false) {
- return card.cube
- ? props.renderDashboard(card.cube, large)
- : props.renderDomainDashboard(card.page, large);
-}
-function cardSelected(card) {
- return !!card.cube && dashboardIds.value.includes(card.cube.id);
-}
-
-const RenderVNode = (p) => p.node;
-
-function enabledItems(items) {
- return items.filter((item) => !item.disabled);
-}
-function groupAllSelected(items) {
- const enabled = enabledItems(items);
- return enabled.length > 0 && enabled.every((item) => selectedMu.value.includes(item.key));
-}
-async function changeMu(keys) {
- if (saving.value) return;
- saving.value = true;
- try {
- await props.onMuChange(keys);
- } catch (error) {
- props.notify("保存页面选择失败:" + error.message);
- } finally {
- saving.value = false;
- }
-}
-function changeGroupMu(items) {
- const enabled = enabledItems(items);
- if (groupAllSelected(items)) {
- changeMu(selectedMu.value.filter((key) => !enabled.some((item) => item.key === key)));
- } else {
- changeMu(Array.from(new Set([...selectedMu.value, ...enabled.map((item) => item.key)])));
- }
-}
-function changeItemMu(item) {
- changeMu(
- selectedMu.value.includes(item.key)
- ? selectedMu.value.filter((key) => key !== item.key)
- : [...selectedMu.value, item.key]
- );
-}
-async function changeConfig(config) {
- if (saving.value) return;
- saving.value = true;
- try {
- await props.onPageConfigChange(config);
- } catch (error) {
- props.notify("保存页面选择失败:" + error.message);
- } finally {
- saving.value = false;
- }
-}
-async function changeDomainDashboard(page, selected) {
- if (saving.value) return;
- saving.value = true;
- try {
- const result = await api(`/sites/${props.site.id}/domain-dashboards`, { page_id: page.id, selected }, "PUT");
- catalog.value = result.catalog;
- props.onSiteChange && props.onSiteChange(result.site);
- } catch (error) {
- props.notify("加载领域仪表板失败:" + error.message);
- } finally {
- saving.value = false;
- }
-}
-function changeDashboard(card) {
- if (card.page) changeDomainDashboard(card.page, !cardSelected(card));
- else
- changeConfig({
- ...props.site.page_config,
- dashboard_ids: cardSelected(card)
- ? dashboardIds.value.filter((id) => id !== card.cube.id)
- : [...dashboardIds.value, card.cube.id],
- twin_ids: twinIds.value,
- });
-}
-function changeTwin(twin) {
- changeConfig({
- ...props.site.page_config,
- dashboard_ids: dashboardIds.value,
- twin_ids: twinIds.value.includes(twin.id)
- ? twinIds.value.filter((id) => id !== twin.id)
- : [...twinIds.value, twin.id],
- });
-}
-</script>
-
-<template>
- <div v-if="!site" class="empty-state">正在加载站点…</div>
- <div v-else class="station-page-selection">
- <section class="station-page-module-panel">
- <header>
- <div>
- <h2>选择站点需要的功能页面</h2>
- <p>选择可用的标准页面、领域仪表板及数字孪生场景;菜单名称、层级和顺序在下一步设置。</p>
- </div>
- <SearchInput :value="query" @change="(v) => (query = v)" placeholder="搜索功能或页面名称" />
- </header>
- <div class="station-selection-summary">
- <span>标准页面 <b>{{ selectedMu.length }}</b></span>
- <span>领域仪表板 <b>{{ dashboardIds.length }}</b></span>
- <span>数字孪生场景 <b>{{ twinIds.length }}</b></span>
- <span>{{ saving ? "保存中…" : "选择后自动保存" }}</span>
- </div>
- <div class="station-module-grid">
- <section v-for="[name, items] in grouped" :key="name">
- <header>
- <label>
- <UiInput type="checkbox" :disabled="saving" :checked="groupAllSelected(items)" @change="() => changeGroupMu(items)" />
- <b>{{ name || "标准功能" }}</b>
- </label>
- <span>{{ items.filter((item) => selectedMu.includes(item.key)).length }} / {{ items.length }}</span>
- </header>
- <div>
- <label v-for="item in items" :key="item.key">
- <UiInput type="checkbox" :disabled="saving || item.disabled" :checked="selectedMu.includes(item.key)" @change="() => changeItemMu(item)" />
- {{ item.label }}
- </label>
- </div>
- </section>
- </div>
- </section>
- <section class="station-page-module-panel">
- <header>
- <div>
- <h2>领域扩展仪表板</h2>
- <p>{{ catalog.template_name ? `${catalog.template_name} · 预览领域页面,勾选后加入当前站点。` : "预览对应领域的仪表板,选择站点需要的页面。" }}</p>
- </div>
- <span>{{ dashboardIds.length }} / {{ dashboardCards.length }} 个已选择</span>
- </header>
- <div class="station-dashboard-selection">
- <article v-for="card in dashboardCards" :key="card.key" :class="cardSelected(card) ? 'selected' : ''">
- <UiButton type="button" className="station-dashboard-image" :aria-label="`预览${card.name}画面`" @click="() => (preview = card)">
- <RenderVNode :node="dashboardImage(card)" />
- </UiButton>
- <footer>
- <label>
- <UiInput type="checkbox" :aria-label="`选择仪表板${card.name}`" :disabled="saving || catalogLoading" :checked="cardSelected(card)" @change="() => changeDashboard(card)" />
- {{ card.name }}
- </label>
- <Button type="text" :aria-label="`预览${card.name}`" @click="() => (preview = card)">
- <template #icon><EyeOutlined /></template>
- </Button>
- </footer>
- </article>
- </div>
- <div v-if="catalogLoading || catalogError || !dashboardCards.length" class="station-template-empty">
- {{ catalogLoading ? "正在加载领域仪表板…" : catalogError ? "领域仪表板暂时无法加载,请刷新后重试。" : "当前领域暂无可预览的仪表板,可先完善领域模板。" }}
- </div>
- </section>
- <section class="station-page-module-panel">
- <header>
- <div>
- <h2>数字孪生场景</h2>
- <p>SC 场景选择,当前画面为设计模拟数据。</p>
- </div>
- <span>{{ twinIds.length }} 个已选择</span>
- </header>
- <div class="station-twin-selection">
- <article v-for="twin in twins" :key="twin.id">
- <div class="station-scene-thumb"><span>SC 场景示意</span><i /><i /><i /><i /><div /></div>
- <footer>
- <label>
- <UiInput type="checkbox" :disabled="saving" :checked="twinIds.includes(twin.id)" @change="() => changeTwin(twin)" />
- {{ twin.name }}
- </label>
- <small>模拟画面</small>
- </footer>
- </article>
- </div>
- </section>
- <Drawer
- className="station-info-drawer station-dashboard-preview-drawer"
- :width="'80vw'"
- :title="preview?.name || '仪表板预览'"
- :open="!!preview"
- @close="() => (preview = null)"
- >
- <RenderVNode v-if="preview" :node="dashboardImage(preview, true)" />
- </Drawer>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import Panel from "../components/Panel.vue";
-
-const props = defineProps({
- title: { type: String, default: "页面" },
- subtitle: { type: String, default: "" },
-});
-</script>
-
-<template>
- <Panel :title="title">
- <div class="empty-state">
- <p>「{{ title }}」页面正在移植中(Vue3 重写进行中)。</p>
- <p v-if="subtitle" class="muted">{{ subtitle }}</p>
- </div>
- </Panel>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch } from "vue";
-import { Button } from "ant-design-vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiButton from "../ui/UiButton.vue";
-import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
-import StatusTag from "../components/StatusTag.vue";
-import { fetchDb } from "../lib/api.js";
-
-const props = defineProps({
- site: { type: Object, default: null },
- tab: { type: String, default: "info" },
- setTab: { type: Function, default: () => {} },
- setModal: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
-});
-
-const cubeIdx = ref(0);
-const cube = computed(() => props.site?.cubes?.[cubeIdx.value]);
-
-const kpiData = [
- ["设备在线率", "92%", "23 / 25"],
- ["今日总能耗", "12,480", "kWh"],
- ["未处理告警", "7", "高危 2"],
- ["平均负载率", "68%", "较昨日 +3%"],
-];
-
-const jsonText = ref("");
-const loadingJson = ref(false);
-
-watch(
- [() => props.tab, cubeIdx],
- async () => {
- if (props.tab === "json" && cube.value && cube.value.id && props.site) {
- loadingJson.value = true;
- try {
- const d = await fetchDb(`/sites/${props.site.id}/cubes/${cube.value.id}/file`);
- jsonText.value = JSON.stringify(d, null, 2);
- } catch (e) {
- jsonText.value = "加载失败:" + (e?.message || e);
- } finally {
- loadingJson.value = false;
- }
- }
- },
- { immediate: true }
-);
-</script>
-
-<template>
- <div class="preview-shell">
- <div class="preview-toolbar">
- <div>
- <UiSelect :value="cubeIdx" @change="(e) => (cubeIdx = Number(e.target.value))">
- <option v-for="(c, i) in (site?.cubes || [])" :key="i" :value="i">{{ c.name }}</option>
- </UiSelect>
- <StatusTag>{{ cube?.status === "generated" ? "校验通过" : "未生成" }}</StatusTag>
- </div>
- <div>
- <UiButton>1440 × 900⌄</UiButton>
- <UiButton>☼ 浅色</UiButton>
- <UiButton @click="() => notify('模拟数据已刷新')">↻ 刷新数据</UiButton>
- <UiButton>⛶ 全屏</UiButton>
- </div>
- </div>
-
- <UiTabbedRegion>
- <template #navigation>
- <UiButton :aria-selected="tab === 'info'" @click="setTab('info')">信息</UiButton>
- <UiButton :aria-selected="tab === 'json'" @click="setTab('json')">JSON</UiButton>
- <UiButton :aria-selected="tab === 'feedback'" @click="setTab('feedback')">反馈</UiButton>
- </template>
-
- <div v-if="tab === 'info'" class="preview-main">
- <div class="cube-canvas">
- <div class="cube-top">
- <b>⬡ iStation</b>
- <span>{{ site?.name || "未命名站点" }}</span>
- <i>⌕ ♧ ? ▦ W</i>
- </div>
- <aside class="cube-side">
- <strong>站点监控</strong>
- <UiButton class="active">▦ {{ cube?.name || "运行总览" }}</UiButton>
- <UiButton>▧ 设备管理</UiButton>
- <UiButton>▥ 能源管理</UiButton>
- <UiButton>△ 告警中心</UiButton>
- <UiButton>⚙ 系统设置</UiButton>
- </aside>
- <div class="cube-content">
- <header>
- <div>
- <small>站点监控 / {{ cube?.name }}</small>
- <h2>{{ site?.name || "未命名站点" }}</h2>
- </div>
- <span>最后更新:刚刚 ↻</span>
- </header>
- <div class="cube-kpis">
- <div v-for="(x, i) in kpiData" :key="x[0]">
- <span>{{ x[0] }}</span>
- <b>{{ x[1] }}</b>
- <small>{{ x[2] }}</small>
- <i :class="'spark s' + i"></i>
- </div>
- </div>
- <div class="cube-grid">
- <section class="line-panel">
- <h3>近 7 日能源消耗 <span>本周⌄</span></h3>
- <div class="line-chart">
- <div></div>
- <svg viewBox="0 0 500 150" preserveAspectRatio="none">
- <polyline points="0,112 65,92 130,103 200,56 270,75 335,45 400,62 500,24" fill="none" stroke="#009fe3" stroke-width="3" />
- <polyline points="0,132 65,118 130,121 200,98 270,102 335,82 400,91 500,68" fill="none" stroke="#8ecfee" stroke-width="2" />
- </svg>
- <footer>
- <span v-for="x in ['08/08', '08/09', '08/10', '08/11', '08/12', '08/13', '今天']" :key="x">{{ x }}</span>
- </footer>
- </div>
- </section>
- <section class="donut-panel">
- <h3>设备状态分布</h3>
- <div class="donut">
- <svg viewBox="0 0 100 100">
- <circle cx="50" cy="50" r="40" fill="none" stroke="#E8E8E8" stroke-width="14" />
- <circle cx="50" cy="50" r="40" fill="none" stroke="#009fe3" stroke-width="14" stroke-dasharray="180 251" />
- </svg>
- <b>72%</b>
- <span>在线</span>
- </div>
- </section>
- </div>
- </div>
- </div>
- </div>
-
- <div v-else-if="tab === 'json'" class="preview-json">
- <pre v-if="!loadingJson">{{ jsonText }}</pre>
- <div v-else class="empty-state">加载中…</div>
- </div>
-
- <div v-else class="preview-feedback">
- <Button @click="() => setModal('feedback')">提出修改意见</Button>
- </div>
- </UiTabbedRegion>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted, h } from "vue";
-import { Button, Drawer, Dropdown, Tag } from "ant-design-vue";
-import {
- ApartmentOutlined, CaretDownOutlined, CaretRightOutlined, CheckOutlined,
- DeleteOutlined, EditOutlined, EllipsisOutlined, PlusOutlined,
-} from "@ant-design/icons-vue";
-
-import UiButton from "../ui/UiButton.vue";
-import UiInput from "../ui/UiInput.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiTable from "../ui/UiTable.vue";
-import UiTabbedRegion from "../ui/UiTabbedRegion.vue";
-import UiActions from "../ui/UiActions.vue";
-import SearchInput from "../components/SearchInput.vue";
-import Pagination from "../components/Pagination.vue";
-import { fetchDb } from "../lib/api.js";
-
-const props = defineProps({
- sites: { type: Array, default: () => [] },
- domains: { type: Array, default: () => [] }, // { id, name }[]
- loading: { type: Boolean, default: false },
- onSave: { type: Function, default: null },
- onConfigure: { type: Function, default: null },
- onDelete: { type: Function, default: null },
- notify: { type: Function, default: () => {} },
-});
-
-const notify = (m) => (props.notify ? props.notify(m) : null);
-
-// ---- templates (domain-templates) ----
-const templates = ref([]);
-const templatesLoading = ref(true);
-const available = computed(() => templates.value.filter((t) => t.status === "available"));
-
-const query = ref("");
-const domain = ref("");
-const status = ref("");
-const collapsed = ref(new Set());
-const page = ref(1);
-const pageSize = ref(10);
-const draft = ref(null);
-const saving = ref(false);
-
-function isConfigured(site) {
- return !!(site.template_applied_at || site.device_count || site.mu_count || site.cube_count);
-}
-function hasAncestor(sites, id, ancestor) {
- const seen = new Set();
- let current = sites.find((s) => s.id === id);
- while (current && !seen.has(current.id)) {
- if (current.id === ancestor) return true;
- seen.add(current.id);
- current = sites.find((s) => s.id === current.parent_id);
- }
- return false;
-}
-function sitePaths(sites, id) {
- const names = [];
- const visited = new Set();
- let current = sites.find((s) => s.id === id);
- while (current && !visited.has(current.id)) {
- visited.add(current.id);
- names.unshift(current.name || "未命名站点");
- current = sites.find((s) => s.id === current.parent_id);
- }
- return names;
-}
-
-const children = computed(() => {
- const map = new Map();
- const ids = new Set(props.sites.map((s) => s.id));
- for (const site of props.sites) {
- const parent = ids.has(site.parent_id) ? site.parent_id : 0;
- if (!map.has(parent)) map.set(parent, []);
- map.get(parent).push(site);
- }
- return map;
-});
-
-const visible = computed(() => {
- const included = new Set();
- for (const site of props.sites) {
- if (
- (!query.value || (site.name || "").toLowerCase().includes(query.value.trim().toLowerCase())) &&
- (!domain.value || site.domain === domain.value) &&
- (!status.value || (status.value === "configured" ? isConfigured(site) : !isConfigured(site)))
- ) {
- let current = site;
- const visited = new Set();
- while (current && !visited.has(current.id)) {
- visited.add(current.id);
- included.add(current.id);
- current = props.sites.find((i) => i.id === current.parent_id);
- }
- }
- }
- const rows = [];
- const visited = new Set();
- const walk = (parent, depth) => {
- for (const site of children.value.get(parent) || []) {
- if (!included.has(site.id) || visited.has(site.id)) continue;
- visited.add(site.id);
- rows.push({ site, depth });
- if (!collapsed.value.has(site.id)) walk(site.id, depth + 1);
- }
- };
- walk(0, 0);
- return rows;
-});
-
-const configuredCount = computed(() => props.sites.filter(isConfigured).length);
-const statusTabs = computed(() => [
- { key: "", label: "全部站点", count: props.sites.length },
- { key: "pending", label: "待配置", count: props.sites.length - configuredCount.value },
- { key: "configured", label: "已配置", count: configuredCount.value },
-]);
-const topCount = computed(() => (children.value.get(0) || []).length);
-const subCount = computed(() => props.sites.length - topCount.value);
-const paged = computed(() =>
- visible.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)
-);
-const totalPages = computed(() => Math.max(1, Math.ceil(visible.value.length / pageSize.value)));
-
-const draftTemplates = computed(() =>
- available.value.filter((t) => !draft.value?.domain || t.domain_name === draft.value.domain)
-);
-const selectedTemplate = computed(() =>
- available.value.find((t) => t.id === Number(draft.value?.template_id))
-);
-
-watch([query, domain, status, collapsed], () => {
- page.value = 1;
-});
-
-const openCreate = (parentId = null) => {
- const parent = props.sites.find((s) => s.id === parentId);
- draft.value = {
- parent_id: parentId,
- name: "",
- domain: parent?.domain || "",
- description: "",
- template_id: parent?.template_id || "",
- mode: "template",
- };
-};
-const openEdit = async (id) => {
- try {
- const site = await fetchDb(`/sites/${id}`);
- draft.value = { ...site, mode: site.template_id ? "template" : "blank" };
- } catch (e) {
- notify("加载站点信息失败:" + (e?.message || e));
- }
-};
-const toggle = (id) => {
- const next = new Set(collapsed.value);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- collapsed.value = next;
-};
-const toggleCollapseAll = () => {
- collapsed.value = collapsed.value.size
- ? new Set()
- : new Set(props.sites.filter((s) => (children.value.get(s.id) || []).length).map((s) => s.id));
-};
-const submit = async (keepOpen = false) => {
- if (saving.value || !draft.value) return;
- if (!draft.value.name.trim()) {
- notify("请填写站点名称");
- return;
- }
- if (!draft.value.domain) {
- notify("请选择所属领域");
- return;
- }
- if (draft.value.mode === "template" && !draft.value.template_id) {
- notify("请选择站点模板,或切换为空白站点");
- return;
- }
- saving.value = true;
- try {
- await props.onSave(
- {
- name: draft.value.name.trim(),
- domain: draft.value.domain,
- description: draft.value.description,
- parent_id: draft.value.parent_id || null,
- template_id: draft.value.mode === "template" ? Number(draft.value.template_id) : null,
- hierarchy_only: true,
- },
- draft.value.id
- );
- notify(draft.value.id ? "站点基本信息已保存" : "站点已建立,可继续创建下级站点或稍后配置");
- if (keepOpen) draft.value = { ...draft.value, id: undefined, name: "", description: "" };
- else draft.value = null;
- } catch (e) {
- notify("保存失败:" + (e?.message || e));
- } finally {
- saving.value = false;
- }
-};
-
-onMounted(() => {
- fetchDb("/domain-templates")
- .then((d) => {
- templates.value = d || [];
- })
- .catch((e) => notify("加载模板失败:" + (e?.message || e)))
- .finally(() => {
- templatesLoading.value = false;
- });
-});
-</script>
-
-<template>
- <div class="station-hierarchy-workspace">
- <UiTabbedRegion>
- <template #navigation>
- <div class="company-status-tabs" role="tablist" aria-label="站点配置状态">
- <UiButton
- v-for="item in statusTabs"
- :key="item.key"
- role="tab"
- :aria-selected="status === item.key"
- :class="status === item.key ? 'active' : ''"
- @click="() => (status = item.key)"
- >
- <span>{{ item.label }}</span><b>{{ item.count }}</b>
- </UiButton>
- </div>
- </template>
- <section class="station-list-panel">
- <div class="station-hierarchy-note">
- <ApartmentOutlined />
- <span>先建立站点层级,再进入各站点配置。新建时可选择模板,暂不配置站点内部内容。</span>
- <span>顶级站点 <b>{{ topCount }}</b> · 子站点 <b>{{ subCount }}</b></span>
- </div>
- <div class="station-list-toolbar">
- <SearchInput :value="query" :placeholder="'搜索站点名称'" @change="(v) => (query = v)" />
- <UiSelect aria-label="站点领域筛选" :value="domain" @change="(e) => (domain = e.target.value)">
- <option value="">全部领域</option>
- <option v-for="item in domains" :key="item.id">{{ item.name }}</option>
- </UiSelect>
- <div>
- <Button @click="() => { query = ''; domain = ''; status = ''; }">重置</Button>
- <Button @click="toggleCollapseAll">{{ collapsed.size ? '展开全部' : '收起全部' }}</Button>
- <Button type="primary" :icon="h(PlusOutlined)" @click="() => openCreate()">新增站点</Button>
- </div>
- </div>
- <div class="station-table-scroll">
- <UiTable class="data-table company-table station-hierarchy-table">
- <thead>
- <tr>
- <th>站点名称</th>
- <th>所属领域</th>
- <th>站点模板</th>
- <th>配置状态</th>
- <th>子站点</th>
- <th>最近修改</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="row in paged" :key="row.site.id">
- <td>
- <div class="station-name-cell" :style="{ paddingLeft: row.depth * 24 + 'px' }">
- <template v-if="(children.get(row.site.id) || []).length > 0">
- <UiButton
- :aria-label="(collapsed.has(row.site.id) ? '展开' : '收起') + row.site.name"
- @click="toggle(row.site.id)"
- >
- <CaretRightOutlined v-if="collapsed.has(row.site.id)" />
- <CaretDownOutlined v-else />
- </UiButton>
- </template>
- <span v-else class="station-tree-spacer" />
- <UiButton class="link" @click="() => openEdit(row.site.id)">{{ row.site.name || '未命名站点' }}</UiButton>
- <small v-if="row.depth === 0">顶级</small>
- </div>
- </td>
- <td>{{ row.site.domain || '—' }}</td>
- <td>
- <span class="station-template-name">{{ row.site.template_name || '未选择模板' }}</span>
- <small v-if="row.site.template_id && !row.site.template_applied_at" class="station-pending-template">待应用</small>
- </td>
- <td>
- <Tag :color="isConfigured(row.site) ? 'success' : 'default'">{{ isConfigured(row.site) ? '已配置' : '待配置' }}</Tag>
- </td>
- <td>{{ (children.get(row.site.id) || []).length }}</td>
- <td>{{ row.site.updated_at || '—' }}</td>
- <td>
- <div class="station-table-actions">
- <Button type="link" @click="() => props.onConfigure && props.onConfigure(row.site.id)">配置站点</Button>
- <Dropdown
- :trigger="['click']"
- :menu="{
- items: [
- { key: 'basic', label: '编辑基本信息', icon: h(EditOutlined) },
- { key: 'child', label: '新增子站点', icon: h(PlusOutlined) },
- { key: 'delete', label: '删除站点', danger: true, icon: h(DeleteOutlined) },
- ],
- onClick: ({ key }) => {
- if (key === 'basic') openEdit(row.site.id);
- else if (key === 'child') openCreate(row.site.id);
- else if (props.onDelete) props.onDelete([row.site.id]);
- },
- }"
- >
- <Button type="text" :icon="h(EllipsisOutlined)" :aria-label="row.site.name + '更多操作'" />
- </Dropdown>
- </div>
- </td>
- </tr>
- </tbody>
- </UiTable>
- </div>
- <div v-if="loading || !visible.length" class="empty-state">
- {{ loading ? '加载中…' : '暂无符合条件的站点,可点击“新增站点”建立层级' }}
- </div>
- <Pagination
- :current="page"
- :total-pages="totalPages"
- :page-size="pageSize"
- @pageChange="(p) => (page = p)"
- @pageSizeChange="(s) => { pageSize = s; page = 1; }"
- />
- </section>
- </UiTabbedRegion>
-
- <Drawer
- class="station-info-drawer"
- :width="720"
- :title="draft?.id ? '编辑站点基本信息' : '新增站点'"
- :open="!!draft"
- :onClose="() => { if (!saving) draft = null; }"
- >
- <template #footer>
- <UiActions class="station-drawer-actions">
- <Button :disabled="saving" @click="() => (draft = null)">取消</Button>
- <Button v-if="!draft?.id" :disabled="saving" @click="() => submit(true)">创建并继续新增</Button>
- <Button type="primary" :loading="saving" @click="() => submit()">{{ draft?.id ? '保存' : '创建站点' }}</Button>
- </UiActions>
- </template>
- <template v-if="draft">
- <p class="station-drawer-note">此处只建立站点名称、基本属性和层级,内部配置稍后在“配置站点”中完成。</p>
- <div class="station-basic-fields">
- <label>
- <span>上级站点</span>
- <UiSelect
- aria-label="上级站点"
- :value="draft.parent_id || ''"
- @change="(e) => (draft = { ...draft, parent_id: e.target.value ? Number(e.target.value) : null })"
- >
- <option value="">无,作为顶级站点</option>
- <option
- v-for="s in sites.filter((x) => !draft.id || !hasAncestor(sites, x.id, draft.id))"
- :key="s.id"
- :value="s.id"
- >{{ sitePaths(sites, s.id).join(' / ') }}</option>
- </UiSelect>
- </label>
- <label>
- <span><i>*</i> 站点名称</span>
- <UiInput
- aria-label="站点名称"
- :value="draft.name"
- :maxLength="20"
- placeholder="例如:北京市自来水厂 / 朝阳区 / 某乡镇站点"
- @change="(e) => (draft = { ...draft, name: e.target.value })"
- />
- </label>
- <label>
- <span><i>*</i> 所属领域</span>
- <UiSelect
- aria-label="站点所属领域"
- :value="draft.domain"
- @change="(e) => (draft = { ...draft, domain: e.target.value, template_id: '' })"
- >
- <option value="">请选择所属领域</option>
- <option v-for="item in domains" :key="item.id">{{ item.name }}</option>
- </UiSelect>
- </label>
- <label>
- <span>基本情况</span>
- <UiInput
- textarea
- aria-label="站点基本情况"
- :rows="3"
- :value="draft.description"
- placeholder="填写站点用途、管理范围或位置等基本属性"
- @change="(e) => (draft = { ...draft, description: e.target.value })"
- />
- </label>
- </div>
- <section class="station-create-template">
- <h3>站点模板</h3>
- <div class="station-template-modes">
- <label>
- <UiInput
- type="radio"
- :checked="draft.mode === 'template'"
- :disabled="!!draft.template_applied_at"
- @change="() => (draft = { ...draft, mode: 'template' })"
- /> 从模板建立站点
- </label>
- <label>
- <UiInput
- type="radio"
- :checked="draft.mode === 'blank'"
- :disabled="!!draft.template_applied_at"
- @change="() => (draft = { ...draft, mode: 'blank' })"
- /> 建立空白站点
- </label>
- </div>
- <template v-if="draft.mode === 'template'">
- <UiSelect
- aria-label="选择站点模板"
- :value="draft.template_id"
- :disabled="!!draft.template_applied_at"
- @change="(e) => (draft = { ...draft, template_id: e.target.value })"
- >
- <option value="">请选择领域站点模板</option>
- <option v-for="template in draftTemplates" :key="template.id" :value="template.id">
- {{ template.name }} · {{ template.version }}
- </option>
- </UiSelect>
- <div v-if="selectedTemplate" class="station-selected-template">
- <b>{{ selectedTemplate.name }}</b>
- <p>{{ selectedTemplate.description || '使用该领域已积累的资产、页面与菜单配置' }}</p>
- <span>{{ selectedTemplate.device_count || 0 }} 类设备模板 · {{ selectedTemplate.page_count || 0 }} 个领域页面</span>
- </div>
- <p class="station-template-footnote">
- {{ draft.template_applied_at
- ? '模板已应用;如需更换,请进入站点配置。'
- : '这里只记录模板选择,进入站点配置后再应用,不影响继续建立子站点。' }}
- </p>
- </template>
- </section>
- </template>
- </Drawer>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { computed, h } from "vue";
-import { Button, Dropdown } from "ant-design-vue";
-import {
- DownloadOutlined,
- EditOutlined,
- EllipsisOutlined,
- EyeOutlined,
-} from "@ant-design/icons-vue";
-import Panel from "../components/Panel.vue";
-import StatusTag from "../components/StatusTag.vue";
-import UiActions from "../ui/UiActions.vue";
-import DashboardThumb from "../components/DashboardThumb.vue";
-import { fetchDb, downloadBlob } from "../lib/api.js";
-
-const props = defineProps({
- site: { type: Object, default: null },
- go: { type: Function, default: () => {} },
- setModal: { type: Function, default: () => {} },
- viewPrompt: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
- openFeedback: { type: Function, default: () => {} },
- viewSitePrompt: { type: Function, default: () => {} },
-});
-
-const kpis = computed(() => {
- const s = props.site;
- if (!s) return [];
- const generated = (s.cubes || []).filter((c) => c.status === "generated");
- return [
- [s.assets.filter((a) => a.type).length, "Asset 资产", "类设备"],
- [s.mu.length, "MU 页面", "已配置"],
- [
- generated.length,
- "Cube 页面",
- generated.length === s.cubes.length ? "校验全部通过" : "部分未生成",
- ],
- [generated.length + 1, "交付文件", "JSON+ZIP"],
- ];
-});
-
-const actionMenu = {
- items: [
- { key: "history", label: "版本历史" },
- { key: "prompt", label: "查看站点提示词" },
- ],
- onClick: ({ key }) => {
- if (key === "history") props.setModal("history");
- else props.viewSitePrompt();
- },
-};
-
-const cubeMenu = (cube) => ({
- items: [
- { key: "json", label: "下载 JSON", icon: h(DownloadOutlined) },
- { key: "prompt", label: "查看提示词" },
- ...(cube.gen_file_id
- ? [{ key: "feedback", label: "提交修改意见", icon: h(EditOutlined) }]
- : []),
- ],
- onClick: async ({ key }) => {
- if (key === "json") {
- const r = await fetch(`/api/db/sites/${props.site.id}/cubes/${cube.id}/file`);
- const b = await r.blob();
- downloadBlob(b, cube.gen_filename || `${cube.name}.json`);
- } else if (key === "prompt") {
- props.viewPrompt(cube.id, cube.name, cube.content);
- } else if (key === "feedback") {
- props.openFeedback(cube);
- }
- },
-});
-
-const downloadAll = () => {
- window.open(`/api/db/sites/${props.site.id}/package`, "_blank");
-};
-
-const openPreview = async (cube) => {
- if (!cube.id) return;
- try {
- const json = await fetchDb(`/sites/${props.site.id}/cubes/${cube.id}/file`);
- const safe = JSON.stringify(json).replace(/</g, "\\u003c");
- const origin = window.location.origin;
- // Build the embedded HTML <script> tags with a runtime "<" so the SFC
- // compiler never sees a literal opening/closing script tag in this source.
- const LT = String.fromCharCode(60);
- const st = LT + "script";
- const et = LT + "/script";
- const html = [
- '<!doctype html><html><head><meta charset="utf-8"><title>',
- cube.name || "看板预览",
- '</title><style>html,body{margin:0;background:#fff;font-family:Arial,"Microsoft YaHei",sans-serif}</style></head><body><div id="root"></div>',
- st + ' src="' + origin + '/t/stationbuilder/echarts.min.js">' + et,
- st + ' src="' + origin + '/t/stationbuilder/dashboard-renderer.js">' + et,
- st + ">renderDashboard(" + safe + ", document.getElementById('root'), {fit:true});" + et,
- "</body></html>",
- ].join("");
- const blob = new Blob([html], { type: "text/html" });
- const url = URL.createObjectURL(blob);
- window.open(url, "_blank");
- setTimeout(() => URL.revokeObjectURL(url), 60000);
- } catch (e) {
- props.notify("预览打开失败:" + (e?.message || e));
- }
-};
-</script>
-
-<template>
- <Panel v-if="!site"><div class="empty-state">加载中…</div></Panel>
- <template v-else>
- <div class="result-header">
- <div>
- <span class="site-icon big">S</span>
- <section>
- <h2>{{ site.name || "未命名站点" }}</h2>
- <p>
- {{ site.status === "generated" ? "生成完成" : "草稿" }} · {{ site.updated_at }}
- </p>
- </section>
- <StatusTag>{{ site.status === "generated" ? "生成完成" : "草稿" }}</StatusTag>
- </div>
- <UiActions class="result-actions">
- <Button class="button secondary" @click="() => go('assets')"><EditOutlined /> 修改配置</Button>
- <Button type="primary" class="button" @click="downloadAll">
- <DownloadOutlined /> 下载全部
- </Button>
- <Dropdown :trigger="['click']" :menu="actionMenu">
- <Button class="button secondary"><EllipsisOutlined /> 更多</Button>
- </Dropdown>
- </UiActions>
- </div>
-
- <div class="kpi-row">
- <div v-for="x in kpis" :key="x[1]">
- <span>{{ x[1] }}</span>
- <b>{{ x[0] }}</b>
- <small>{{ x[2] }}</small>
- </div>
- </div>
-
- <div class="result-layout">
- <div>
- <Panel title="页面结果">
- <div class="result-pages">
- <article v-for="(cube, i) in site.cubes" :key="i">
- <DashboardThumb :site-id="site.id" :cube="cube" />
- <div>
- <header>
- <StatusTag>{{ cube.status === "generated" ? "校验通过" : "未生成" }}</StatusTag>
- <small>Cube 页面</small>
- </header>
- <h3>{{ cube.name }}</h3>
- <p>{{ cube.content || "无描述" }}</p>
- <footer>
- <Button class="button secondary" @click="openPreview(cube)"><EyeOutlined /> 预览</Button>
- <Dropdown v-if="cube.id" :trigger="['click']" :menu="cubeMenu(cube)">
- <Button class="button secondary"><EllipsisOutlined /> 更多</Button>
- </Dropdown>
- </footer>
- </div>
- </article>
- </div>
- </Panel>
- </div>
- </div>
- </template>
-</template>
+++ /dev/null
-<script setup>
-import { computed } from "vue";
-import { Tag } from "ant-design-vue";
-import UiButton from "../ui/UiButton.vue";
-
-const props = defineProps({
- site: { type: Object, default: null },
- onBack: { type: Function, default: null },
-});
-
-const hasAssets = computed(() =>
- !!(props.site?.assets?.some((a) => a.type) || props.site?.cubes?.length || props.site?.mu?.length)
-);
-const tagColor = computed(() => (props.site?.template_applied_at ? "success" : "default"));
-const tagText = computed(() => {
- const s = props.site;
- if (!s) return "";
- if (s.template_applied_at) return "已应用";
- if (s.template_id) return "待应用";
- if (hasAssets.value) return "现有配置";
- return "空白配置";
-});
-</script>
-
-<template>
- <div v-if="site" class="station-config-context">
- <div>
- <UiButton className="link" @click="onBack && onBack()">← 返回站点管理</UiButton>
- </div>
- <div>
- <span>站点模板</span><b>{{ site.template_name || "未选择" }}</b>
- <Tag :color="tagColor">{{ tagText }}</Tag>
- </div>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, h } from "vue";
-import { Button as AntButton, Dropdown, Drawer } from "ant-design-vue";
-import { EditOutlined, DeleteOutlined, EllipsisOutlined, EyeOutlined } from "@ant-design/icons-vue";
-import UiButton from "../ui/UiButton.vue";
-import UiTable from "../ui/UiTable.vue";
-import UiActions from "../ui/UiActions.vue";
-import Panel from "../components/Panel.vue";
-import SearchInput from "../components/SearchInput.vue";
-import Pagination from "../components/Pagination.vue";
-import PrimaryButton from "../components/PrimaryButton.vue";
-import StyleEditorModal from "../pages/domain/StyleEditorModal.vue";
-import { fetchDb } from "../lib/api.js";
-import { useConfirm } from "../pages/domain/useConfirm.js";
-
-const props = defineProps({
- pageStyles: { type: Array, default: () => [] },
- loadPageStyles: { type: Function, default: async () => {} },
- notify: { type: Function, default: () => {} },
-});
-
-const query = ref("");
-const preview = ref(null);
-const editor = ref({ open: false, id: undefined });
-const pageIndex = ref(1);
-const pageSize = ref(10);
-const { confirm, el: confirmEl } = useConfirm();
-
-const filtered = computed(() =>
- props.pageStyles.filter(
- (row) =>
- !query.value.trim() ||
- (row.name || "").toLowerCase().includes(query.value.trim().toLowerCase())
- )
-);
-watch(query, () => {
- pageIndex.value = 1;
-});
-
-async function afterChange(msg) {
- props.notify(msg);
- editor.value = { open: false, id: undefined };
- preview.value = null;
- await props.loadPageStyles();
-}
-async function remove(row) {
- if (!(await confirm(`确定删除页面样式「${row.name}」?此操作不可恢复`))) return;
- try {
- await fetchDb(`/page-styles/${row.id}`, { method: "DELETE" });
- await afterChange("页面样式已删除");
- } catch (e) {
- props.notify("删除失败:" + (e && e.message ? e.message : e));
- }
-}
-function rowMenu(row) {
- return {
- items: [
- { key: "edit", label: "编辑样式", icon: h(EditOutlined) },
- { key: "delete", label: "删除", danger: true, icon: h(DeleteOutlined) },
- ],
- onClick: ({ key }) =>
- key === "edit"
- ? (editor.value = { open: true, id: row.id })
- : remove(row),
- };
-}
-</script>
-
-<template>
- <component :is="confirmEl" />
- <Panel class="company-list-panel resource-list-panel style-resource-panel">
- <div class="filterbar company-filterbar">
- <SearchInput :value="query" placeholder="搜索样式名称" @change="(v) => (query = v)" />
- <UiActions class="filter-actions">
- <PrimaryButton secondary @click="query = ''">重置</PrimaryButton>
- <PrimaryButton @click="editor = { open: true }">+ 新增样式</PrimaryButton>
- </UiActions>
- </div>
- <UiTable class="data-table company-table resource-table style-resource-table">
- <thead>
- <tr>
- <th>样式方案名称</th>
- <th>缩略图</th>
- <th>创建时间</th>
- <th>预览</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- <tr
- v-for="row in filtered.slice((pageIndex - 1) * pageSize, pageIndex * pageSize)"
- :key="row.id"
- @click="preview = row"
- >
- <td>
- <UiButton class="link company-record-link" :title="row.name" @click.stop="preview = row">
- <b>{{ row.name || "未命名页面样式" }}</b>
- </UiButton>
- </td>
- <td>
- <img class="style-list-thumbnail" :src="`/api/db/page-styles/${row.id}/image?w=120`" :alt="`${row.name}缩略图`" />
- </td>
- <td>{{ row.created_at || "—" }}</td>
- <td>
- <AntButton type="link" :icon="h(EyeOutlined)" @click.stop="preview = row">查看预览</AntButton>
- </td>
- <td @click.stop>
- <Dropdown :trigger="['click']" :menu="rowMenu(row)">
- <AntButton type="text" aria-label="更多操作" :icon="h(EllipsisOutlined)" />
- </Dropdown>
- </td>
- </tr>
- </tbody>
- </UiTable>
- <div v-if="filtered.length === 0" class="empty-state">暂无符合条件的页面样式</div>
- <Pagination
- :current="pageIndex"
- :total-pages="Math.max(1, Math.ceil(filtered.length / pageSize))"
- :page-size="pageSize"
- @page-change="(p) => (pageIndex = p)"
- @page-size-change="(s) => { pageSize = s; pageIndex = 1; }"
- />
- </Panel>
- <Drawer :title="preview?.name || '页面样式预览'" :width="560" :open="!!preview" @close="preview = null">
- <template #extra>
- <PrimaryButton v-if="preview" @click="() => { editor = { open: true, id: preview.id }; preview = null; }">编辑样式</PrimaryButton>
- </template>
- <div v-if="preview" class="style-drawer">
- <img :src="`/api/db/page-styles/${preview.id}/image`" :alt="preview.name" />
- <dl>
- <dt>样式名称</dt>
- <dd>{{ preview.name }}</dd>
- <dt>创建时间</dt>
- <dd>{{ preview.created_at || "—" }}</dd>
- </dl>
- </div>
- </Drawer>
- <StyleEditorModal
- v-if="editor.open"
- :id="editor.id"
- :notify="notify"
- :after-change="afterChange"
- :on-close="() => (editor = { open: false, id: undefined })"
- />
-</template>
+++ /dev/null
-<script setup>
-import { ref, watch } from "vue";
-import UiInput from "../ui/UiInput.vue";
-import UiSelect from "../ui/UiSelect.vue";
-import UiActions from "../ui/UiActions.vue";
-import Panel from "../components/Panel.vue";
-import Field from "../components/Field.vue";
-import PrimaryButton from "../components/PrimaryButton.vue";
-import { MODELS } from "../lib/meta.js";
-
-// React `Button` maps to PrimaryButton (AntButton primary/default + "button" class).
-const Button = PrimaryButton;
-
-const props = defineProps({
- model: { type: String, default: "" },
- onModelChange: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
-});
-
-const draftModel = ref(props.model);
-watch(
- () => props.model,
- (v) => (draftModel.value = v)
-);
-
-const freeModels = MODELS.filter((item) => item.rate === "0.00x");
-</script>
-
-<template>
- <div class="system-settings-page">
- <Panel title="内部工具配置">
- <div class="settings-form">
- <Field label="工具名称"><UiInput value="StationBuilder" :readonly="true" /></Field>
- <Field label="内部 AI 服务"><UiInput value="Wordbuddy AI" :readonly="true" /></Field>
- <Field label="默认模型" hint="当前仅开放免费模型">
- <UiSelect :value="draftModel" @change="(e) => (draftModel = e.target.value)">
- <option v-for="item in freeModels" :key="item.name" :value="item.name">{{ item.name }} · {{ item.tag || "免费" }}</option>
- </UiSelect>
- </Field>
- <Field label="工具版本"><UiInput value="v0.4" :readonly="true" /></Field>
- <div class="settings-note">
- <strong>配置说明</strong>
- <span>此处集中维护原侧栏底部的内部工具配置。保存后,新建的 AI 会话将使用所选默认模型。</span>
- </div>
- <UiActions className="settings-actions">
- <Button @click="() => { onModelChange(draftModel); notify('内部工具配置已保存'); }">保存配置</Button>
- </UiActions>
- </div>
- </Panel>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted } from "vue";
-import { Tag } from "ant-design-vue";
-import {
- SearchOutlined,
- ApartmentOutlined,
- BlockOutlined,
- SnippetsOutlined,
- BarsOutlined,
-} from "@ant-design/icons-vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiInput from "../../ui/UiInput.vue";
-import UiSelect from "../../ui/UiSelect.vue";
-import { fetchDb, domainTagColor } from "../../lib/api.js";
-import { friendlyDomainName, domainScene, SOLUTION_PHOTOS, statusTone } from "./domainCommon.js";
-
-const props = defineProps({
- go: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
- onOpen: { type: Function, default: () => {} },
- domainFilter: { type: Number, default: null },
- domainOptions: { type: Array, default: () => [] },
-});
-
-const list = ref([]);
-const loading = ref(true);
-const query = ref("");
-const domainSel = ref(props.domainFilter == null ? "" : String(props.domainFilter));
-
-const load = async () => {
- try {
- list.value = (await fetchDb("/domain-templates")) || [];
- } catch (e) {
- notify("加载失败:" + (e && e.message ? e.message : e));
- } finally {
- loading.value = false;
- }
-};
-
-onMounted(load);
-watch(
- () => props.domainFilter,
- (f) => {
- domainSel.value = f == null ? "" : String(f);
- }
-);
-
-const filtered = computed(() =>
- list.value.filter(
- (row) =>
- (!domainSel.value || String(row.domain_id) === domainSel.value) &&
- (!query.value.trim() ||
- `${row.name} ${row.domain_name} ${row.description || ""}`
- .toLowerCase()
- .includes(query.value.trim().toLowerCase()))
- )
-);
-
-const totals = computed(() => ({
- assets: list.value.reduce((n, x) => n + Number(x.device_count || 0), 0),
- pages: list.value.reduce((n, x) => n + Number(x.page_count || 0), 0),
- docs: list.value.reduce((n, x) => n + Number(x.doc_count || 0), 0),
-}));
-
-const statusText = (row) => (row.status === "available" ? "可用" : "不可用");
-</script>
-
-<template>
- <div class="domain-showcase-page">
- <section class="domain-showcase-intro">
- <div><span>已沉淀领域</span><b>{{ list.length }}</b></div>
- <p>领域方案用于展示和复用成熟场站能力。每个方案都由资产、页面、知识和菜单四部分组成,可分别查看和下载。</p>
- <div class="domain-showcase-total">
- <span>设备模板 <b>{{ totals.assets }}</b></span>
- <span>仪表板 <b>{{ totals.pages }}</b></span>
- <span>知识文档 <b>{{ totals.docs }}</b></span>
- </div>
- </section>
- <div class="domain-showcase-filter">
- <label>
- <SearchOutlined />
- <UiInput :value="query" @change="(e) => (query = e.target.value)" placeholder="搜索领域或方案名称" />
- </label>
- <UiSelect :value="domainSel" @change="(e) => (domainSel = e.target.value)">
- <option value="">全部领域</option>
- <option v-for="d in domainOptions" :key="d.id" :value="d.id">{{ friendlyDomainName(d.name) }}</option>
- </UiSelect>
- <span>共 {{ filtered.length }} 个方案</span>
- </div>
- <div v-if="loading" class="empty-state">正在加载领域方案…</div>
- <div v-else class="domain-showcase-grid">
- <article
- v-for="row in filtered"
- :key="row.id"
- :class="['domain-showcase-card', domainScene(row.domain_name || '').tone]"
- @click="() => onOpen(row.id)"
- >
- <header>
- <div class="domain-showcase-mark">{{ domainScene(row.domain_name || "").code }}</div>
- <div>
- <Tag class="tag-new" :color="domainTagColor(friendlyDomainName(row.domain_name))">{{ friendlyDomainName(row.domain_name) }}</Tag>
- <Tag class="status" :class="statusTone(statusText(row))">{{ statusText(row) }}</Tag>
- </div>
- </header>
- <h2>{{ row.name || "未命名领域方案" }}</h2>
- <p>{{ row.description || domainScene(row.domain_name || "").desc }}</p>
- <img
- v-if="SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
- class="domain-showcase-photo"
- :src="SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
- :alt="row.name || domainScene(row.domain_name || '').code"
- />
- <div class="domain-showcase-capabilities">
- <span><ApartmentOutlined /><b>资产</b><small>{{ row.device_count || 0 }} 类设备</small></span>
- <span><BlockOutlined /><b>页面</b><small>{{ row.page_count || 0 }} 仪表板 + SC</small></span>
- <span><SnippetsOutlined /><b>知识</b><small>{{ row.doc_count || 0 }} 文档 + 百科</small></span>
- <span><BarsOutlined /><b>菜单</b><small>标准结构</small></span>
- </div>
- <footer>
- <span>版本 {{ row.version || "1.0" }}</span>
- <UiButton @click.stop="() => onOpen(row.id)">查看方案 <span>→</span></UiButton>
- </footer>
- </article>
- </div>
- <div v-if="!loading && filtered.length === 0" class="empty-state">暂无符合条件的领域方案,可通过右上角导入。</div>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref } from "vue";
-
-const props = defineProps({
- imageUrl: { type: String, default: null },
- onPick: { type: Function, default: () => {} },
-});
-
-const inputRef = ref(null);
-
-function onChange(e) {
- const f = e.target.files && e.target.files[0];
- if (f) props.onPick(f);
- e.target.value = "";
-}
-</script>
-
-<template>
- <div class="device-img-field" @click="() => inputRef && inputRef.click()">
- <img v-if="imageUrl" :src="imageUrl" alt="设备图片" />
- <span v-else class="device-img-empty">+ 点击上传设备图片</span>
- <input ref="inputRef" type="file" accept="image/*" hidden @change="onChange" />
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref } from "vue";
-import Panel from "../../components/Panel.vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiInput from "../../ui/UiInput.vue";
-import Button from "../../components/PrimaryButton.vue";
-import DeviceImageField from "./DeviceImageField.vue";
-import { fetchDb } from "../../lib/api.js";
-
-const props = defineProps({
- notify: { type: Function, default: () => {} },
- onDone: { type: Function, default: () => {} },
- onCancel: { type: Function, default: () => {} },
-});
-
-const name = ref("");
-const description = ref("");
-const imgPreview = ref(null);
-const imgB64 = ref(null);
-
-function readBase64(file) {
- return new Promise((resolve, reject) => {
- const r = new FileReader();
- r.onload = () => resolve({ filename: file.name, mime: file.type || "application/octet-stream", data: String(r.result).split(",")[1] });
- r.onerror = () => reject(r.error);
- r.readAsDataURL(file);
- });
-}
-
-async function onPick(f) {
- imgPreview.value = URL.createObjectURL(f);
- imgB64.value = await readBase64(f);
-}
-
-async function add() {
- if (!name.value.trim()) {
- props.notify("请填写设备名称");
- return;
- }
- try {
- const body = { name: name.value, description: description.value };
- if (imgB64.value) body.image = imgB64.value;
- const r = await fetchDb("/device-templates", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- props.notify("已创建设备模版");
- props.onDone(r.id);
- } catch (e) {
- props.notify("创建失败:" + (e && e.message ? e.message : e));
- }
-}
-</script>
-
-<template>
- <Panel title="新增设备模版">
- <template #tools>
- <UiButton class="text-button" @click="onCancel">取消</UiButton>
- </template>
- <div class="form-grid">
- <label>设备名称<UiInput :value="name" @change="(e) => (name = e.target.value)" placeholder="如:PLC 控制器" /></label>
- <label style="grid-column: 1 / -1">设备图片<DeviceImageField :image-url="imgPreview" :on-pick="onPick" /></label>
- <label style="grid-column: 1 / -1">说明<UiInput textarea :rows="2" :value="description" @change="(e) => (description = e.target.value)" /></label>
- <div style="grid-column: 1 / -1"><Button @click="add">创建</Button></div>
- </div>
- </Panel>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch, onMounted } from "vue";
-import { Tag } from "ant-design-vue";
-import {
- ApartmentOutlined,
- BlockOutlined,
- AimOutlined,
- SnippetsOutlined,
- EllipsisOutlined,
- UploadOutlined,
- PlusOutlined,
- EditOutlined,
- DownloadOutlined,
- LinkOutlined,
- EyeOutlined,
-} from "@ant-design/icons-vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiSelect from "../../ui/UiSelect.vue";
-import UiTable from "../../ui/UiTable.vue";
-import UiActions from "../../ui/UiActions.vue";
-import UiOverlay from "../../ui/UiOverlay.vue";
-import Status from "../../components/StatusTag.vue";
-import Panel from "../../components/Panel.vue";
-import Field from "../../components/Field.vue";
-import Button from "../../components/PrimaryButton.vue";
-import DomainPageThumb from "../../components/DomainPageThumb.vue";
-import SearchInput from "../../components/SearchInput.vue";
-import { fetchDb, formatInstances, domainTagColor } from "../../lib/api.js";
-import { friendlyDomainName, domainScene, SOLUTION_PHOTOS } from "./domainCommon.js";
-
-const props = defineProps({
- id: { type: Number, required: true },
- doms: { type: Array, default: () => [] },
- notify: { type: Function, default: () => {} },
- onBack: { type: Function, default: () => {} },
- onEditAssets: { type: Function, default: () => {} },
- onOpenDeviceTemplate: { type: Function, default: () => {} },
- onCreateSite: { type: Function, default: () => {} },
-});
-
-const detail = ref(null);
-const muTree = ref([]);
-const loading = ref(true);
-const preview = ref(null);
-const editing = ref(false);
-const dashboardEditorOpen = ref(false);
-const dashboardDomain = ref("");
-const dashboardQuery = ref("");
-const packageOpen = ref(false);
-const draft = ref(null);
-const creatingSite = ref(false);
-const creatingSiteRef = ref(false);
-
-const createFromTemplate = async () => {
- if (creatingSiteRef.value) return;
- creatingSiteRef.value = true;
- creatingSite.value = true;
- try {
- await props.onCreateSite(props.id);
- } catch (e) {
- props.notify("根据模板创建站点失败:" + (e && e.message ? e.message : e));
- } finally {
- creatingSiteRef.value = false;
- creatingSite.value = false;
- }
-};
-
-async function load() {
- loading.value = true;
- try {
- const [d, mt] = await Promise.all([fetchDb(`/domain-templates/${props.id}`), fetchDb("/mu-tree")]);
- detail.value = d;
- draft.value = {
- domain_id: d.domain_id,
- name: d.name || "",
- version: d.version || "1.0",
- status: d.status || "available",
- description: d.description || "",
- };
- dashboardDomain.value = d.domain_name || props.doms.find((domain) => domain.id === d.domain_id)?.name || "";
- muTree.value = mt || [];
- } catch (e) {
- props.notify("加载方案失败:" + (e && e.message ? e.message : e));
- } finally {
- loading.value = false;
- }
-}
-onMounted(load);
-
-async function saveMeta() {
- try {
- await fetchDb(`/domain-templates/${props.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(draft.value),
- });
- props.notify("方案信息已保存");
- editing.value = false;
- await load();
- } catch (e) {
- props.notify("保存失败:" + (e && e.message ? e.message : e));
- }
-}
-
-const rawDomain = computed(() => detail.value?.domain_name || props.doms.find((d) => d.id === detail.value?.domain_id)?.name || "");
-const domainName = computed(() => friendlyDomainName(rawDomain.value));
-const scene = computed(() => domainScene(rawDomain.value));
-const assets = computed(() => (detail.value?.assets || []).filter((a) => a.device_template_id || a.device_name));
-const pages = computed(() => (detail.value?.pages || []) );
-const extensionPages = computed(() =>
- Array.from(
- new Map(
- pages.value
- .filter((page) => {
- const json = (page.json_text || "").trim();
- return page.name.trim() !== "新页面" && json !== "" && json !== "{}" && json !== "null";
- })
- .map((page) => [page.name.trim(), page])
- ).values()
- )
-);
-const docs = computed(() => detail.value?.docs || []);
-const knowledgeItems = [
- { type: "document", name: `${detail.value?.name || domainName.value}运行维护手册`, meta: "PDF · 2026-08-18" },
- { type: "document", name: `${domainName.value}设备巡检规范`, meta: "DOCX · 2026-08-12" },
- { type: "document", name: "异常报警与处置规则", meta: "PDF · 2026-07-30" },
- { type: "document", name: "场站安全操作规程", meta: "PDF · 2026-07-16" },
- { type: "link", name: `${domainName.value}行业标准查询`, meta: "外部链接 · 已验证" },
- { type: "link", name: "设备厂商技术资料", meta: "外部链接 · 已验证" },
-];
-const knowledgeDocuments = computed(() => knowledgeItems.filter((item) => item.type === "document"));
-const knowledgeLinks = computed(() => knowledgeItems.filter((item) => item.type === "link"));
-const twinScenes = computed(() =>
- (scene.value.twins.length ? scene.value.twins : [`${detail.value?.name || domainName.value}全景监控`, `${domainName.value}工艺流程场景`]).map(
- (name, index) => ({ name, status: index === 0 ? "运行中" : "已配置" })
- )
-);
-const download = (label) => props.notify(`${label}配置 ZIP 已准备(设计示意)`);
-const capabilityCards = [
- { key: "assets", icon: ApartmentOutlined, title: "资产", subtitle: "设备列表与资产组结构", count: `${assets.value.length} 个类型` },
- { key: "pages", icon: BlockOutlined, title: "领域扩展仪表板", subtitle: "领域仪表板页面", count: `${extensionPages.value.length} 个仪表板` },
- { key: "twins", icon: AimOutlined, title: "数字孪生场景", subtitle: "SC 数字孪生场景画面", count: `${twinScenes.value.length} 个场景` },
- { key: "knowledge", icon: SnippetsOutlined, title: "知识", subtitle: "领域文档与参考链接", count: `${knowledgeDocuments.value.length} 份文档 / ${knowledgeLinks.value.length} 个链接` },
-];
-const previewTitle = computed(() => capabilityCards.find((x) => x.key === preview.value)?.title || "");
-const visibleDashboards = computed(() =>
- extensionPages.value.filter(
- (page) => !dashboardQuery.value.trim() || `${page.name} ${page.description || ""}`.toLowerCase().includes(dashboardQuery.value.trim().toLowerCase())
- )
-);
-
-function editHandler(card) {
- if (card.key === "assets") return () => props.onEditAssets(rawDomain.value);
- if (card.key === "pages") return () => (dashboardEditorOpen.value = true);
- return undefined;
-}
-</script>
-
-<template>
- <div v-if="loading || !detail" class="empty-state">正在加载领域能力…</div>
- <div v-else class="domain-capability-detail">
- <template v-if="dashboardEditorOpen">
- <UiButton class="domain-capability-back" @click="() => (dashboardEditorOpen = false)">← 返回领域方案</UiButton>
- <section class="dashboard-manager-panel">
- <header class="dashboard-manager-toolbar">
- <label>
- <span>领域分类</span>
- <UiSelect :value="dashboardDomain" @change="(event) => (dashboardDomain = event.target.value)">
- <option v-for="domain in doms" :key="domain.id" :value="domain.name">{{ friendlyDomainName(domain.name) }}</option>
- </UiSelect>
- </label>
- <UiActions class="dashboard-manager-actions">
- <Button secondary @click="() => notify('导入功能将在后续实现')"><UploadOutlined /> 导入</Button>
- <Button @click="() => notify('新增功能将在后续实现')"><PlusOutlined /> 新增</Button>
- <SearchInput :value="dashboardQuery" @change="(v) => (dashboardQuery = v)" placeholder="请输入仪表板名称" />
- </UiActions>
- </header>
- <div class="dashboard-manager-grid">
- <article v-for="(page, index) in visibleDashboards" :key="page.id">
- <UiButton class="dashboard-card-more" type="button" aria-label="`${page.name}更多操作`"><EllipsisOutlined /></UiButton>
- <h3>{{ page.name }}</h3>
- <p>更新时间:2026-{{ String((index % 8) + 1).padStart(2, "0") }}-{{ String((index % 21) + 5).padStart(2, "0") }} {{ String(9 + (index % 8)).padStart(2, "0") }}:30:00</p>
- <Status v-if="page.status !== 'generated'">未发布</Status>
- </article>
- </div>
- <div v-if="visibleDashboards.length === 0" class="empty-state">当前筛选条件下暂无仪表板</div>
- </section>
- </template>
-
- <template v-else>
- <UiButton class="domain-capability-back" @click="onBack">← 返回领域方案</UiButton>
- <section :class="['domain-capability-head', scene.tone]">
- <div class="domain-capability-code">{{ scene.code }}</div>
- <div class="domain-capability-copy">
- <div><Tag class="tag-new" :color="domainTagColor(domainName)">{{ domainName }}</Tag><Status>{{ detail.status === "available" ? "可用" : "不可用" }}</Status><span>版本 {{ detail.version || "1.0" }}</span></div>
- <h2>{{ detail.name || "未命名领域方案" }}</h2>
- <p>{{ detail.description || scene.desc }}</p>
- </div>
- <UiActions class="domain-capability-actions">
- <Button secondary @click="() => (editing = true)"><EditOutlined /> 维护方案</Button>
- <Button secondary @click="() => (packageOpen = true)"><DownloadOutlined /> 下载完整方案</Button>
- <Button :disabled="creatingSite" @click="createFromTemplate" title="使用当前领域方案的基本信息、资产与标准页面配置创建草稿站点">
- <PlusOutlined /> {{ creatingSite ? "正在创建站点…" : "根据模板创建领域站点" }}
- </Button>
- </UiActions>
- </section>
-
- <div class="domain-capability-summary">
- <span><b>{{ assets.length }}</b>设备类型</span>
- <span><b>{{ extensionPages.length }}</b>领域扩展仪表板</span>
- <span><b>{{ twinScenes.length }}</b>数字孪生场景</span>
- <span><b>{{ knowledgeItems.length }}</b>领域知识</span>
- </div>
-
- <div class="domain-capability-grid">
- <section v-for="card in capabilityCards" :key="card.key" :class="['domain-capability-card', card.key]">
- <header>
- <div class="domain-capability-icon"><component :is="card.icon" /></div>
- <div><h3>{{ card.title }}</h3><p>{{ card.subtitle }}</p></div>
- <strong>{{ card.count }}</strong>
- </header>
-
- <div v-if="card.key === 'assets'" class="domain-capability-body capability-asset-content">
- <div class="capability-section-title"><b>设备模板</b><span>{{ assets.length }} 个</span></div>
- <div class="device-template-grid">
- <div
- v-for="(asset, index) in assets"
- :key="`${asset.device_template_id}-${index}`"
- class="device-template-tile"
- :title="asset.device_name || '设备模板'"
- @click="(e) => { e.stopPropagation(); if (asset.device_template_id) props.onOpenDeviceTemplate(asset.device_template_id); }"
- >
- <span><BlockOutlined /></span><b>{{ asset.device_name || "设备模板" }}</b>
- </div>
- </div>
- <div v-if="assets.length === 0" class="device-template-empty">当前领域暂未关联设备模板</div>
- </div>
-
- <div v-else-if="card.key === 'pages'" class="domain-capability-body capability-page-content">
- <div class="capability-section-title"><b>领域扩展仪表板</b><span>{{ extensionPages.length }} 个</span></div>
- <div class="extension-page-grid">
- <article v-for="page in extensionPages" :key="page.id" class="extension-page-tile" :title="page.name">
- <DomainPageThumb :json-text="page.json_text" empty-label="页面缩略图" fit :fixed-height="92" />
- <b>{{ page.name }}</b>
- </article>
- </div>
- <div v-if="extensionPages.length === 0" class="device-template-empty">当前领域暂未配置扩展仪表板</div>
- </div>
-
- <div v-else-if="card.key === 'twins'" class="domain-capability-body capability-twin-content">
- <div class="capability-section-title"><b>SC 数字孪生场景</b><span>{{ twinScenes.length }} 个</span></div>
- <div class="twin-scene-card-list">
- <article v-for="(item, index) in twinScenes" :key="item.name" class="twin-scene-card">
- <div :class="['sc-scene', 'scene-' + index]"><i /><i /><i /><i /><span>SC 实时场景</span></div>
- <div><b>{{ item.name }}</b><small>{{ item.status }}</small></div>
- </article>
- </div>
- </div>
-
- <div v-else-if="card.key === 'knowledge'" class="domain-capability-body capability-knowledge-content">
- <div class="knowledge-group">
- <div class="capability-section-title"><b>文档</b><span>{{ knowledgeDocuments.length }} 份</span></div>
- <div class="knowledge-item-list">
- <div v-for="item in knowledgeDocuments" :key="item.name" class="knowledge-item">
- <span class="knowledge-type document"><SnippetsOutlined /></span>
- <div><b>{{ item.name }}</b><small>{{ item.meta }}</small></div>
- </div>
- </div>
- </div>
- <div class="knowledge-group">
- <div class="capability-section-title"><b>链接</b><span>{{ knowledgeLinks.length }} 个</span></div>
- <div class="knowledge-item-list">
- <div v-for="item in knowledgeLinks" :key="item.name" class="knowledge-item">
- <span class="knowledge-type link"><LinkOutlined /></span>
- <div><b>{{ item.name }}</b><small>{{ item.meta }}</small></div>
- </div>
- </div>
- </div>
- </div>
-
- <div v-else class="domain-capability-body capability-empty-slot" :aria-label="`${card.title}内容占位`" />
-
- <footer>
- <UiButton type="button" class="capability-edit-button" @click="editHandler(card)"><EditOutlined /> 编辑</UiButton>
- <UiButton @click="() => (preview = card.key)"><EyeOutlined /> 预览</UiButton>
- <UiButton @click="() => download(card.title)"><DownloadOutlined /> 下载配置 ZIP</UiButton>
- </footer>
- </section>
- </div>
- </template>
-
- <UiOverlay v-if="preview" class="modal-backdrop" @mousedown="() => (preview = null)">
- <div class="modal capability-preview-modal" @mousedown.stop>
- <header><h2>{{ previewTitle }}能力预览</h2><UiButton @click="() => (preview = null)">×</UiButton></header>
- <div class="modal-body">
- <div v-if="preview === 'assets'" class="capability-assets-preview">
- <section>
- <h3>设备列表</h3>
- <UiTable class="data-table">
- <thead><tr><th>设备模板</th><th>典型数量</th><th>命名规则</th></tr></thead>
- <tbody>
- <tr v-for="(a, i) in assets" :key="i"><td>{{ a.device_name || "设备模板" }}</td><td>{{ a.count || 0 }}</td><td>{{ a.rule || "—" }}</td></tr>
- </tbody>
- </UiTable>
- <div v-if="assets.length === 0" class="empty-state">暂无设备配置</div>
- </section>
- <section>
- <h3>资产组结构</h3>
- <div class="capability-full-tree">
- <b>{{ detail.name }}</b>
- <div v-for="(a, i) in assets" :key="i"><span>{{ a.device_name || "设备" }}</span><small>{{ formatInstances(a.rule || "DEV-##", Math.min(Number(a.count || 0), 4)) || `${a.count || 0} 个实例` }}</small></div>
- </div>
- </section>
- </div>
- <div v-else-if="preview === 'pages'" class="capability-dashboard-grid">
- <article v-for="p in extensionPages" :key="p.id">
- <DomainPageThumb :json-text="p.json_text" empty-label="仪表板预览" />
- <div><b>{{ p.name }}</b><span>{{ p.description || "场站运行指标与状态展示" }}</span></div>
- </article>
- <div v-if="extensionPages.length === 0" class="empty-state">暂无领域扩展仪表板</div>
- </div>
- <div v-else-if="preview === 'twins'" class="capability-twin-grid">
- <article v-for="(item, index) in twinScenes" :key="item.name">
- <div :class="['sc-scene', 'scene-' + index]"><i /><i /><i /><i /><span>SC 实时场景</span></div>
- <div><b>{{ item.name }}</b><span>设备空间关系、运行状态与告警联动</span></div>
- </article>
- </div>
- <div v-else-if="preview === 'knowledge'" class="capability-knowledge-preview">
- <section>
- <h3>领域百科</h3>
- <div v-for="(name, i) in scene.wiki" :key="name"><SnippetsOutlined /><span><b>{{ name }}</b><small>{{ i === 0 ? "领域概念、工艺流程与管理指标" : "常用设备、参数与运行原理" }}</small></span></div>
- </section>
- <section>
- <h3>文档资料</h3>
- <template v-if="docs.length">
- <div v-for="d in docs" :key="d.id"><span class="file-type">{{ d.doc_type === "link" ? "URL" : "DOC" }}</span><span><b>{{ d.name }}</b><small>{{ d.doc_type === "link" ? "参考站点" : "维修手册 / 规范 / 规则" }}</small></span></div>
- </template>
- <div v-else><span class="file-type">DOC</span><span><b>维修手册与报警处置规则</b><small>方案导入后可继续补充</small></span></div>
- </section>
- </div>
- </div>
- <footer>
- <Button secondary @click="() => (preview = null)">关闭</Button>
- <Button @click="() => { download(previewTitle); preview = null; }"><DownloadOutlined /> 下载配置 ZIP</Button>
- </footer>
- </div>
- </UiOverlay>
-
- <UiOverlay v-if="editing && draft" class="modal-backdrop" @mousedown="() => (editing = false)">
- <div class="modal domain-maintain-modal" @mousedown.stop>
- <header><h2>维护领域方案</h2><UiButton @click="() => (editing = false)">×</UiButton></header>
- <div class="modal-body">
- <div class="form-grid">
- <Field label="方案名称" required><UiInput :value="draft.name" @change="(e) => (draft = { ...draft, name: e.target.value })" /></Field>
- <Field label="所属领域" required>
- <UiSelect :value="draft.domain_id" @change="(e) => (draft = { ...draft, domain_id: Number(e.target.value) })">
- <option v-for="d in doms" :key="d.id" :value="d.id">{{ friendlyDomainName(d.name) }}</option>
- </UiSelect>
- </Field>
- <Field label="版本"><UiInput :value="draft.version" @change="(e) => (draft = { ...draft, version: e.target.value })" /></Field>
- <Field label="状态">
- <UiSelect :value="draft.status" @change="(e) => (draft = { ...draft, status: e.target.value })">
- <option value="available">可用</option>
- <option value="unavailable">不可用</option>
- </UiSelect>
- </Field>
- <Field label="方案说明"><UiInput textarea :rows="4" :value="draft.description" @change="(e) => (draft = { ...draft, description: e.target.value })" /></Field>
- </div>
- <div class="domain-maintain-note">资产、页面、知识与菜单均可分别进入预览后维护;本页不再采用分步确认流程。</div>
- </div>
- <footer>
- <Button secondary @click="() => (editing = false)">取消</Button>
- <Button @click="saveMeta">保存</Button>
- </footer>
- </div>
- </UiOverlay>
-
- <UiOverlay v-if="packageOpen" class="modal-backdrop" @mousedown="() => (packageOpen = false)">
- <div class="modal capability-package-modal" @mousedown.stop>
- <header><h2>下载完整领域方案</h2><UiButton @click="() => (packageOpen = false)">×</UiButton></header>
- <div class="modal-body">
- <p>完整方案包将包含以下四类可导入配置:</p>
- <div>
- <span v-for="card in capabilityCards" :key="card.key"><component :is="card.icon" /><b>{{ card.title }}</b><small>{{ card.subtitle }}</small></span>
- </div>
- </div>
- <footer>
- <Button secondary @click="() => (packageOpen = false)">取消</Button>
- <Button @click="() => { download('完整领域方案'); packageOpen = false; }">确认下载 ZIP</Button>
- </footer>
- </div>
- </UiOverlay>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, onMounted } from "vue";
-import { fetchDb } from "../../lib/api.js";
-// NOTE: ImportDomainPackage (app/page.tsx:2619+) and DomainCapabilityDetail (app/page.tsx:2035+)
-// are defined OUTSIDE the assigned port range (1928-2034) and are referenced faithfully below.
-// They are expected to be ported in a subsequent task; this file is not imported by Home.vue yet.
-import ImportDomainPackage from "./ImportDomainPackage.vue";
-import DomainCapabilityDetail from "./DomainCapabilityDetail.vue";
-
-const props = defineProps({
- id: { type: Number, default: null },
- go: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
- onBack: { type: Function, default: () => {} },
- onCreated: { type: Function, default: () => {} },
- viewCompletePrompt: { type: Function, default: () => {} },
- viewCompleteAssetsPrompt: { type: Function, default: () => {} },
- viewCompleteCubesPrompt: { type: Function, default: () => {} },
- pageStyles: { type: Array, default: () => [] },
- onEditAssets: { type: Function, default: () => {} },
- onOpenDeviceTemplate: { type: Function, default: () => {} },
- onCreateSite: { type: Function, default: () => {} },
-});
-
-const doms = ref([]);
-const loadDoms = async () => {
- try {
- doms.value = (await fetchDb("/domains")) || [];
- } catch (e) {
- /* ignore */
- }
-};
-onMounted(loadDoms);
-</script>
-
-<template>
- <ImportDomainPackage v-if="id == null" :doms="doms" :notify="notify" :on-imported="onCreated" />
- <DomainCapabilityDetail
- v-else
- :id="id"
- :doms="doms"
- :notify="notify"
- :on-back="onBack"
- :on-edit-assets="onEditAssets"
- :on-open-device-template="onOpenDeviceTemplate"
- :on-create-site="onCreateSite"
- />
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch } from "vue";
-import Panel from "../../components/Panel.vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiInput from "../../ui/UiInput.vue";
-import UiTable from "../../ui/UiTable.vue";
-import UiActions from "../../ui/UiActions.vue";
-import UiTabbedRegion from "../../ui/UiTabbedRegion.vue";
-import Button from "../../components/PrimaryButton.vue";
-import { buildMuLeaves } from "../../lib/api.js";
-
-const props = defineProps({
- muTree: { type: Array, default: () => [] },
- grouped: { type: Array, default: () => [] },
- effSelected: { type: Array, default: () => [] },
- onToggleMu: { type: Function, default: () => {} },
- pages: { type: Array, default: () => [] },
- onAddPage: { type: Function, default: () => {} },
- onUploadPageJson: { type: Function, default: () => {} },
- onSavePageMeta: { type: Function, default: () => {} },
- onDeletePage: { type: Function, default: () => {} },
- onGeneratePage: { type: Function, default: () => {} },
- onDownloadPage: { type: Function, default: () => {} },
- onPreviewPage: { type: Function, default: () => {} },
- genIds: { type: Object, default: () => new Set() },
- genLog: { type: String, default: "" },
- cubesAi: { type: Object, default: () => ({ completing: { value: false }, log: { value: "" }, ref: { value: null } }) },
- onRunCubesComplete: { type: Function, default: () => {} },
- onViewCompleteCubesPrompt: { type: Function, default: () => {} },
- updatePageField: { type: Function, default: () => {} },
- tName: { type: String, default: "" },
- saving: { type: Boolean, default: false },
- notify: { type: Function, default: () => {} },
- pageStyles: { type: Array, default: () => [] },
- styleId: { type: Number, default: null },
- onSelectStyle: { type: Function, default: () => {} },
- onGenerateAll: { type: Function, default: () => {} },
-});
-
-const tab = ref("cube");
-const genLogRef = ref(null);
-
-watch(
- () => props.genLog,
- () => {
- if (genLogRef.value) genLogRef.value.scrollTop = genLogRef.value.scrollHeight;
- }
-);
-
-const cubesCompleting = computed(() => (props.cubesAi && props.cubesAi.completing ? props.cubesAi.completing.value : false));
-const cubesLog = computed(() => (props.cubesAi && props.cubesAi.log ? props.cubesAi.log.value : ""));
-
-function sourceBadge(src) {
- if (src === "upload") return "↑ 上传";
- if (src === "ai") return "✦ AI生成";
- return "手动";
-}
-</script>
-
-<template>
- <Panel>
- <UiTabbedRegion>
- <template #navigation>
- <div class="tabs">
- <UiButton :class="tab === 'cube' ? 'active' : ''" @click="() => (tab = 'cube')">领域扩展页面 <i>{{ pages.length }}</i></UiButton>
- <UiButton :class="tab === 'mu' ? 'active' : ''" @click="() => (tab = 'mu')">MU 标准页面 <i>{{ effSelected.length }}</i></UiButton>
- <UiButton :class="tab === 'menu' ? 'active' : ''" @click="() => (tab = 'menu')">菜单结构</UiButton>
- </div>
- </template>
-
- <template v-if="tab === 'mu'">
- <div class="mu-tree">
- <div v-for="g in grouped" :key="g.parentMenu" class="mu-group">
- <b>{{ g.parentMenu }}</b>
- <div class="mu-items">
- <label v-for="item in g.items" :key="item.key" :class="item.disabled ? 'disabled' : ''">
- <UiInput type="checkbox" :disabled="item.disabled" :checked="effSelected.includes(item.key)" @change="() => onToggleMu(item.key)" />
- <span>{{ item.label }}</span>
- </label>
- </div>
- </div>
- </div>
- </template>
-
- <template v-if="tab === 'cube'">
- <div class="two-column">
- <div>
- <div class="style-picker">
- <h4>领域方案页面样式 <small>方案级 · 本方案全部 Cube 页面共用</small></h4>
- <p class="hint">选择一个样式库方案作为本方案所有 Cube 页面的视觉风格(生成时参考其 JSON 主题参数)。</p>
- <div class="style-pick-list">
- <UiButton :class="'style-pick' + (!styleId ? ' active' : '')" @click="() => onSelectStyle(null)">
- <div class="style-pick-thumb none">无</div><span>不使用样式</span>
- </UiButton>
- <UiButton v-for="s in pageStyles" :key="s.id" :class="'style-pick' + (styleId === s.id ? ' active' : '')" @click="() => onSelectStyle(s.id)">
- <div class="style-pick-thumb"><img :src="`/api/db/page-styles/${s.id}/image?w=360`" :alt="s.name" loading="lazy" /></div>
- <span>{{ s.name }}</span>
- </UiButton>
- </div>
- </div>
- <div class="cube-editor">
- <UiTable class="data-table editable cube-editor-table">
- <thead><tr><th>页面名称 <small>最多6字</small></th><th>页面描述</th><th>来源</th><th>Json文件</th><th>操作</th></tr></thead>
- <tbody>
- <tr v-for="(p, i) in pages" :key="p.id">
- <td><UiInput :value="p.name" maxlength="6" placeholder="例如:运行总览" @change="(e) => updatePageField(i, { name: e.target.value })" /></td>
- <td><UiInput :value="p.description" placeholder="描述该页面要展示的内容与指标" @change="(e) => updatePageField(i, { description: e.target.value })" /></td>
- <td>
- <span v-if="p.source === 'upload'" class="badge src-upload">↑ 上传</span>
- <span v-else-if="p.source === 'ai'" class="badge src-ai">✦ AI生成</span>
- <span v-else class="badge src-manual">手动</span>
- </td>
- <td>
- <UiButton v-if="p.gen_filename" class="link" @click="() => onDownloadPage(p)">下载 {{ p.gen_filename }}</UiButton>
- <span v-else class="muted">未生成</span>
- </td>
- <td class="cube-actions">
- <UiActions class="station-inline-actions">
- <UiButton @click="() => onPreviewPage(p)">预览</UiButton>
- <UiButton class="btn-ai" :disabled="genIds.has(p.id)" @click="() => onGeneratePage(p)">{{ genIds.has(p.id) ? "AI生成中" : "AI生成" }}</UiButton>
- <UiButton @click="() => onSavePageMeta(p)">保存</UiButton>
- <UiButton class="trash" @click="() => onDeletePage(p)">删除</UiButton>
- </UiActions>
- </td>
- </tr>
- </tbody>
- </UiTable>
- </div>
- <div style="display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap">
- <Button @click="onAddPage">+ 添加 Cube 页面</Button>
- <label class="button secondary" style="cursor: pointer">
- ↑ 上传页面 Json<input type="file" accept=".json,application/json" hidden @change="(e) => { const f = e.target.files && e.target.files[0]; if (f) onUploadPageJson(f); e.target.value = ''; }" />
- </label>
- <UiButton class="btn-ai" style="height: 30px" @click="onGenerateAll" title="对所有未生成的页面排队生成">⚡ AI 统一生成全部({{ pages.filter((p) => p.status !== 'generated').length }})</UiButton>
- </div>
- <div v-if="genIds.size > 0 || genLog" class="modal-console">
- <div class="modal-console-head">AI 生成页面</div>
- <pre ref="genLogRef" class="modal-console-body">{{ genLog || "(等待生成…)" }}</pre>
- </div>
- </div>
- <div class="stack">
- <Panel title="填写建议">
- <ul class="tips">
- <li>从方案业务与设备资产出发,规划监控维度(综合总览、能耗能效、设备健康、报警事件、环境监测等)。</li>
- <li>页面名称简洁,不超过 6 个汉字;描述写清页面目标与关键指标。</li>
- <li>每个 Cube 页面可「生成」(AI 创建 JSON)或「上传页面 Json」(使用已有 JSON)。</li>
- <li>已生成页面可点击「预览」查看效果,并在预览中提交修改意见重新生成。</li>
- </ul>
- </Panel>
- <Panel title="AI 智能补全">
- <p class="hint">基于方案名称 / 领域 / 描述 / 补充描述及资产,由 AI 推断并补充 Cube 扩展页面(追加在已有页面下方)。</p>
- <div style="display: flex; gap: 8px; align-items: center">
- <UiButton class="button" :disabled="cubesCompleting" @click="onRunCubesComplete">{{ cubesCompleting ? "补全中…" : "开始补全" }}</UiButton>
- <UiButton class="text-button" @click="onViewCompleteCubesPrompt">查看提示词</UiButton>
- </div>
- <div v-if="cubesCompleting || cubesLog" class="modal-console">
- <div class="modal-console-head">AI 智能补全</div>
- <pre class="modal-console-body" :ref="(el) => { if (props.cubesAi && props.cubesAi.ref) props.cubesAi.ref.value = el; }">{{ cubesLog || "(等待 AI 分析…)" }}</pre>
- </div>
- </Panel>
- <Panel v-if="saving"><div class="empty-state">正在保存…</div></Panel>
- </div>
- </div>
- </template>
-
- <template v-if="tab === 'menu'">
- <div class="menu-preview">
- <p class="hint">默认菜单结构(展示用,无需编辑):MU 标准页面在上,所有 Cube 扩展页面归入「综合监控」一级菜单下。</p>
- <div class="menu-tree">
- <div class="menu-root">⌄ {{ tName || "未命名方案" }}</div>
- <div class="menu-section">
- <div class="menu-section-title">MU 标准页面</div>
- <div v-if="effSelected.length === 0" class="menu-leaf muted">(未选择 MU 页面)</div>
- <div v-else v-for="l in buildMuLeaves(muTree).filter((x) => effSelected.includes(x.key))" :key="l.key" class="menu-leaf">· {{ l.parentMenu }}/{{ l.label }}</div>
- </div>
- <div class="menu-section">
- <div class="menu-section-title">综合监控</div>
- <div v-if="pages.filter((p) => p.name && p.name.trim()).length === 0" class="menu-leaf muted">(暂无 Cube 页面)</div>
- <div v-else v-for="p in pages.filter((p) => p.name && p.name.trim())" :key="p.id" class="menu-leaf cube">· {{ p.name }}</div>
- </div>
- </div>
- </div>
- </template>
- </UiTabbedRegion>
- </Panel>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed } from "vue";
-import Panel from "../../components/Panel.vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiTabbedRegion from "../../ui/UiTabbedRegion.vue";
-import Status from "../../components/StatusTag.vue";
-import DomainPageThumb from "../../components/DomainPageThumb.vue";
-import { fetchDb, formatInstances } from "../../lib/api.js";
-
-const props = defineProps({
- m: { type: Object, default: () => ({}) },
- domainName: { type: String, default: "" },
- assets: { type: Array, default: () => [] },
- effSelected: { type: Array, default: () => [] },
- grouped: { type: Array, default: () => [] },
- pages: { type: Array, default: () => [] },
- docs: { type: Array, default: () => [] },
- onBack: { type: Function, default: () => {} },
- onPreviewPage: { type: Function, default: null },
-});
-
-const tab = ref("dev");
-const devOpen = ref(null);
-const devDetail = ref(null);
-const devLoading = ref(false);
-
-async function openDev(id) {
- if (devOpen.value === id) {
- devOpen.value = null;
- return;
- }
- devOpen.value = id;
- devLoading.value = true;
- devDetail.value = null;
- try {
- const d = await fetchDb(`/device-templates/${id}`);
- devDetail.value = d;
- } catch (e) {
- /* ignore */
- } finally {
- devLoading.value = false;
- }
-}
-
-const devList = computed(() =>
- Array.from(new Map(props.assets.filter((a) => a.device_template_id).map((a) => [a.device_template_id, a])).values())
-);
-const assetGroups = computed(() => props.assets.filter((a) => a.device_template_id));
-const muItems = computed(() =>
- props.grouped.flatMap((g) => g.items).filter((l) => props.effSelected.includes(l.key))
-);
-
-const tabs = [
- { key: "dev", label: `设备模板 ${devList.value.length}` },
- { key: "asset", label: `资产结构 ${assetGroups.value.length}` },
- { key: "mu", label: `MU页面 ${props.effSelected.length}` },
- { key: "cube", label: `扩展页面 ${props.pages.length}` },
- { key: "knowledge", label: `领域知识 ${props.docs.length}` },
- { key: "ops", label: "运维流程" },
-];
-</script>
-
-<template>
- <div class="gen-overview">
- <Panel title="方案概览">
- <div class="overview-info">
- <div><span>所属领域</span><b>{{ domainName || "—" }}</b></div>
- <div><span>方案名称</span><b>{{ m.name || "—" }}</b></div>
- <div class="overview-desc"><span>方案描述</span><p>{{ m.description || "—" }}</p></div>
- <div class="overview-desc"><span>补充描述</span><p>{{ m.supplement || "—" }}</p></div>
- </div>
- <UiTabbedRegion>
- <template #navigation>
- <div class="tabs overview-tabs">
- <UiButton v-for="t in tabs" :key="t.key" :class="tab === t.key ? 'active' : ''" @click="() => (tab = t.key)">{{ t.label }}</UiButton>
- </div>
- </template>
-
- <div class="overview-body">
- <div v-if="tab === 'dev'" class="dev-list">
- <p v-if="devList.length === 0" class="muted">本方案未选用设备模板。</p>
- <div v-for="a in devList" :key="a.device_template_id" class="dev-row">
- <UiButton class="dev-row-head" @click="() => openDev(a.device_template_id)">
- <b>{{ a.device_name }}</b><span class="dev-count">{{ a.count }} 台</span><span class="dev-caret">{{ devOpen === a.device_template_id ? "▾" : "▸" }}</span>
- </UiButton>
- <div v-if="devOpen === a.device_template_id" class="dev-detail">
- <p v-if="devLoading" class="muted">加载中…</p>
- <template v-else-if="devDetail">
- <p class="dev-desc">{{ devDetail.description || "(无说明)" }}</p>
- <div class="dev-cp">
- <b>采集点({{ (devDetail.collection_points || []).length }})</b>
- <span v-if="(devDetail.collection_points || []).length === 0" class="muted"> 无</span>
- <ul v-else>
- <li v-for="(cp, i) in (devDetail.collection_points || []).slice(0, 40)" :key="i">{{ cp.name }}{{ cp.unit ? `(${cp.unit})` : "" }}</li>
- </ul>
- </div>
- </template>
- <p v-else class="muted">无法加载详情。</p>
- </div>
- </div>
- </div>
-
- <div v-if="tab === 'asset'" class="asset-tree-preview">
- <div class="tree-root">⌄ <b>{{ m.name || "未命名方案" }}</b></div>
- <div v-for="(row, i) in assetGroups" :key="i" class="tree-branch">
- <div class="tree-node">⌄ {{ row.device_name }}<span class="tree-count">{{ row.count }}</span></div>
- <div v-if="row.count > 0" class="tree-leaves">{{ formatInstances(row.rule, row.count) }}</div>
- </div>
- </div>
-
- <div v-if="tab === 'mu'" class="mu-tree">
- <p v-if="grouped.length === 0" class="muted">(未加载 MU 树)</p>
- <div v-else v-for="g in grouped" :key="g.parentMenu" class="mu-group">
- <b>{{ g.parentMenu }}</b>
- <div class="mu-items">
- <label v-for="item in g.items" :key="item.key" class="disabled">
- <UiInput type="checkbox" disabled :checked="effSelected.includes(item.key)" @change="() => {}" /><span>{{ item.label }}</span>
- </label>
- </div>
- </div>
- </div>
-
- <div v-if="tab === 'cube'" class="domain-review-pages">
- <div v-if="pages.length === 0" class="muted">暂无扩展页面,返回「功能与菜单 → 领域扩展页面」生成。</div>
- <article
- v-else
- v-for="p in pages"
- :key="p.id"
- class="domain-review-page"
- style="cursor: pointer"
- @click="() => onPreviewPage && onPreviewPage(p)"
- >
- <DomainPageThumb :json-text="p.json_text" empty-label="未生成" />
- <div>
- <Status>{{ p.status === "generated" ? "AI已生成" : p.status === "uploaded" ? "已上传" : "未生成" }}{{ p.source ? ` · ${p.source === "ai" ? "AI" : p.source === "upload" ? "上传" : "手动"}` : "" }}</Status>
- <h4>{{ p.name }}</h4>
- <span class="review-zoom">⛶ 点击放大</span>
- </div>
- </article>
- </div>
-
- <div v-if="tab === 'knowledge'" class="doc-list">
- <p v-if="docs.length === 0" class="muted">未上传领域知识,返回「领域知识」步骤上传。</p>
- <div v-else v-for="(d, i) in docs" :key="i" class="doc-item">
- <b>{{ d.name }}</b><span class="muted">{{ d.type === "link" ? "参考站点" : d.mime || "文档" }}{{ (d.size || d.file_size) ? ` · ${((Number(d.size || d.file_size) / 1024).toFixed(1))}KB` : "" }}</span>
- </div>
- </div>
-
- <div v-if="tab === 'ops'" class="ops-placeholder">
- <span class="status muted">待开发</span>
- <p>运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。</p>
- </div>
- </div>
- </UiTabbedRegion>
- </Panel>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref } from "vue";
-import Panel from "../../components/Panel.vue";
-import Field from "../../components/Field.vue";
-import UiInput from "../../ui/UiInput.vue";
-import UiTable from "../../ui/UiTable.vue";
-import Button from "../../components/PrimaryButton.vue";
-import Status from "../../components/StatusTag.vue";
-import { fetchDb } from "../../lib/api.js";
-import { useConfirm } from "./useConfirm.js";
-
-const props = defineProps({
- id: { type: Number, required: true },
- docs: { type: Array, default: () => [] },
- notify: { type: Function, default: () => {} },
- reload: { type: Function, default: () => {} },
- confirm: { type: Function, default: null },
-});
-
-const busy = ref(false);
-const urlName = ref("");
-const urlVal = ref("");
-
-function readBase64(file) {
- return new Promise((resolve, reject) => {
- const r = new FileReader();
- r.onload = () => resolve({ filename: file.name, mime: file.type || "application/octet-stream", data: String(r.result).split(",")[1] });
- r.onerror = () => reject(r.error);
- r.readAsDataURL(file);
- });
-}
-
-async function uploadFiles(files) {
- if (!files || !files.length) return;
- busy.value = true;
- try {
- for (const f of Array.from(files)) {
- const b = await readBase64(f);
- await fetchDb(`/domain-templates/${props.id}/docs`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ doc_type: "file", name: f.name, file: { filename: b.filename, mime: b.mime, data: b.data } }),
- });
- }
- props.notify("已上传知识文档");
- props.reload();
- } catch (e) {
- props.notify("上传失败:" + (e && e.message ? e.message : e));
- } finally {
- busy.value = false;
- }
-}
-
-const { confirm: confirmFn, el: confirmEl } = useConfirm();
-const doConfirm = props.confirm || confirmFn;
-
-async function addUrl() {
- const name = urlName.value.trim();
- const url = urlVal.value.trim();
- if (!name) {
- props.notify("请填写站点名称");
- return;
- }
- if (!/^https?:\/\//i.test(url)) {
- props.notify("请输入有效的 http(s) 链接");
- return;
- }
- busy.value = true;
- try {
- await fetchDb(`/domain-templates/${props.id}/docs`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ doc_type: "link", name, url }),
- });
- props.notify("已添加参考站点");
- urlName.value = "";
- urlVal.value = "";
- props.reload();
- } catch (e) {
- props.notify("添加失败:" + (e && e.message ? e.message : e));
- } finally {
- busy.value = false;
- }
-}
-
-async function removeDoc(docId) {
- if (!(await doConfirm("确定删除该知识来源?此操作不可恢复"))) return;
- try {
- await fetchDb(`/knowledge-docs/${docId}`, { method: "DELETE" });
- props.notify("已删除");
- props.reload();
- } catch (e) {
- props.notify("删除失败:" + (e && e.message ? e.message : e));
- }
-}
-</script>
-
-<template>
- <component :is="confirmEl" />
- <div class="two-column">
- <Panel title="上传领域知识文档">
- <template v-if="busy" #tools><Status>上传中…</Status></template>
- <p class="hint">上传与本站领域相关的文档(标准规范、设备手册、工艺说明、建设方案等)。知识文本将作为资产配置、功能菜单 AI 补全的优先参考资料,并随方案保存。支持 Word / Excel / PDF / 图片及常见文本格式;Word、Excel、PDF 会提取正文注入,图片仅作参考登记。</p>
- <label class="upload" style="height: 96px">
- <b>+</b><span>{{ busy ? "上传中…" : "选择文件或拖拽到此处(可多选)" }}</span>
- <input type="file" multiple hidden accept=".txt,.md,.markdown,.json,.csv,.log,.html,.xml,.yaml,.yml,.pdf,.doc,.docx,.xls,.xlsx,image/*" @change="(e) => uploadFiles(e.target.files)" />
- </label>
- </Panel>
- <Panel title="配置参考 URL 站点">
- <p class="hint">填写一个领域相关的参考网站地址。AI 补全时会由服务端抓取该站点正文并注入提示词(抓取失败则仅保留链接作为参考)。</p>
- <div style="display: flex; flex-direction: column; gap: 8px">
- <UiInput placeholder="站点名称,如:XX 行业标准" :value="urlName" @change="(e) => (urlName = e.target.value)" />
- <UiInput placeholder="https://..." :value="urlVal" @change="(e) => (urlVal = e.target.value)" />
- <div><Button :disabled="busy" @click="addUrl">添加站点</Button></div>
- </div>
- </Panel>
- <Panel :title="`已沉淀领域知识(${docs.length})`">
- <template #tools><Button secondary @click="reload">刷新</Button></template>
- <div v-if="docs.length === 0" class="empty-state">暂无领域知识,上传文档或配置站点后此处展示。</div>
- <UiTable v-else class="data-table">
- <thead><tr><th>名称</th><th>类型</th><th>大小</th><th>创建时间</th><th>操作</th></tr></thead>
- <tbody>
- <tr v-for="d in docs" :key="d.id">
- <td>{{ d.name }}</td>
- <td>
- <span :style="{ padding: 'var(--ant-padding-xxs) var(--ant-padding-xs)', borderRadius: 'var(--ant-border-radius)', fontSize: 'var(--ant-font-size-sm)', background: 'var(--ant-color-primary-bg)', color: 'var(--ant-color-primary)' }">{{ d.doc_type === 'link' ? 'URL 站点' : '文件' }}</span>
- </td>
- <td>{{ d.size != null ? `${Math.round(Number(d.size) / 1024 * 10) / 10} KB` : '—' }}</td>
- <td>{{ d.created_at || '' }}</td>
- <td style="display: flex; gap: 8px">
- <a v-if="d.doc_type === 'file'" class="text-button" :href="`/api/db/knowledge-docs/${d.id}/file`" target="_blank" rel="noreferrer">下载</a>
- <UiButton class="text-button" @click="() => removeDoc(d.id)">删除</UiButton>
- </td>
- </tr>
- </tbody>
- </UiTable>
- </Panel>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, watch } from "vue";
-import { Button as AntButton } from "ant-design-vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiInput from "../../ui/UiInput.vue";
-import UiOverlay from "../../ui/UiOverlay.vue";
-import DomainPageThumb from "../../components/DomainPageThumb.vue";
-import { streamInto } from "./domainCommon.js";
-
-const props = defineProps({
- page: { type: Object, default: () => ({}) },
- tmplId: { type: Number, default: 0 },
- notify: { type: Function, default: () => {} },
- onClose: { type: Function, default: () => {} },
-});
-
-const feedback = ref("");
-const refining = ref(false);
-const refineLog = ref("");
-const consoleRef = ref(null);
-
-watch(refineLog, () => {
- const node = consoleRef.value;
- if (node) node.scrollTop = node.scrollHeight;
-});
-
-const hasJson = computed(
- () => !!props.page.json_text && props.page.json_text.trim() !== "{}" && props.page.json_text.trim() !== ""
-);
-
-const setRefineLog = (v) => {
- refineLog.value = typeof v === "function" ? v(refineLog.value) : v;
-};
-
-const regenerate = async () => {
- if (!feedback.value.trim()) {
- notify("请填写修改意见");
- return;
- }
- refining.value = true;
- setRefineLog("");
- const watchdog = setTimeout(() => {
- refining.value = false;
- notify("重新生成超时(>10 分钟),请重试");
- }, 600000);
- try {
- const res = await fetch(`/api/db/domain-templates/${props.tmplId}/pages/${props.page.id}/refine`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ feedback: feedback.value }),
- });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- await streamInto(res, setRefineLog, () => {});
- notify(`已根据修改意见重新生成「${props.page.name}」`);
- onClose(true);
- } catch (e) {
- notify("重新生成失败:" + (e && e.message ? e.message : e));
- } finally {
- clearTimeout(watchdog);
- refining.value = false;
- }
-};
-</script>
-
-<template>
- <UiOverlay class="modal-backdrop" @mousedown="() => onClose(false)">
- <div class="modal" style="width: min(1100px, 94vw); max-width: 94vw" @mousedown.stop>
- <header>
- <h2>{{ page.name || "页面预览" }}</h2>
- <UiButton @click="() => onClose(false)">×</UiButton>
- </header>
- <div class="modal-body">
- <div class="domain-preview-canvas">
- <DomainPageThumb v-if="hasJson" :json-text="page.json_text" fit />
- <div v-else class="thumb-empty">尚未生成,请点击「生成」后再预览</div>
- </div>
- <div class="domain-preview-feedback">
- <h4>修改意见</h4>
- <p class="hint">输入对当前页面的修改意见,AI 将据此重新生成该页面。</p>
- <UiInput
- textarea
- :value="feedback"
- :rows="4"
- @change="(e) => (feedback = e.target.value)"
- placeholder="例如:把能耗图表改为按周维度对比,并增加告警列表"
- />
- <pre v-if="refining" ref="consoleRef" class="modal-console-body">{{ refineLog || "(重新生成中…)" }}</pre>
- <AntButton :disabled="refining" @click="regenerate">{{ refining ? "重新生成中…" : "根据意见重新生成" }}</AntButton>
- </div>
- </div>
- </div>
- </UiOverlay>
-</template>
+++ /dev/null
-<script setup>
-import { computed } from "vue";
-import UiButton from "../../ui/UiButton.vue";
-import { DOMAIN_STEPS } from "./domainCommon.js";
-
-const props = defineProps({
- step: { type: String, default: "basic" },
- go: { type: Function, default: () => {} },
- completed: { type: Boolean, default: false },
- configured: { type: Object, default: () => ({}) },
-});
-
-const currentIndex = computed(() => DOMAIN_STEPS.findIndex((x) => x.key === props.step));
-
-function isDone(item, index) {
- return props.completed ? !!props.configured[item.key] : index < currentIndex.value;
-}
-function clickable(index) {
- return props.completed || index <= currentIndex.value;
-}
-function stepClass(item, index) {
- const cls = index === currentIndex.value ? "active" : isDone(item, index) ? "done" : "";
- return cls + (clickable(index) ? "" : " locked");
-}
-function onStepClick(item, index) {
- if (clickable(index)) props.go(item.key);
-}
-</script>
-
-<template>
- <div class="steps domain-steps">
- <UiButton
- v-for="(item, index) in DOMAIN_STEPS"
- :key="item.key"
- :class="stepClass(item, index)"
- @click="() => onStepClick(item, index)"
- >
- <i>{{ isDone(item, index) ? "✓" : index + 1 }}</i>
- <span><small>STEP {{ index + 1 }}</small>{{ item.label }}</span>
- <b v-if="index < 6">›</b>
- </UiButton>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import { ref } from "vue";
-import Panel from "../../components/Panel.vue";
-import UiButton from "../../ui/UiButton.vue";
-import Button from "../../components/PrimaryButton.vue";
-import StylePreview from "./StylePreview.vue";
-import StyleEditorModal from "./StyleEditorModal.vue";
-import { fetchDb } from "../../lib/api.js";
-import { useConfirm } from "./useConfirm.js";
-
-const props = defineProps({
- pageStyles: { type: Array, default: () => [] },
- loadPageStyles: { type: Function, default: async () => {} },
- notify: { type: Function, default: () => {} },
-});
-
-const editor = ref({ open: false, id: undefined });
-const preview = ref(null);
-const { confirm, el: confirmEl } = useConfirm();
-
-async function afterChange(msg) {
- props.notify(msg);
- editor.value = { open: false, id: undefined };
- await props.loadPageStyles();
-}
-function openEdit(s) {
- editor.value = { open: true, id: s.id };
-}
-async function remove(s) {
- if (!(await confirm(`确定删除样式「${s.name}」?此操作不可恢复`))) return;
- try {
- await fetchDb(`/page-styles/${s.id}`, { method: "DELETE" });
- await afterChange(`已删除「${s.name}」`);
- } catch (e) {
- props.notify("删除失败: " + (e && e.message ? e.message : e));
- }
-}
-</script>
-
-<template>
- <component :is="confirmEl" />
- <StylePreview v-if="preview" :style="preview" :on-close="() => (preview = null)" />
- <Panel title="全部页面样式">
- <template #tools><Button @click="() => (editor = { open: true })">+ 新增样式</Button></template>
- <p v-if="pageStyles.length === 0" class="hint">暂无页面样式,点击右上角「+ 新增样式」上传 JSON 与预览图即可创建。</p>
- <div v-else class="style-grid">
- <article v-for="s in pageStyles" :key="s.id" class="style-card">
- <div class="style-thumb" title="点击查看大图" @click="() => (preview = s)">
- <img :src="`/api/db/page-styles/${s.id}/image?w=560`" :alt="s.name" loading="lazy" />
- <span class="zoom">⛶</span>
- </div>
- <div class="style-info"><h3>{{ s.name }}</h3><small>{{ s.created_at || "" }}</small></div>
- <footer>
- <UiButton class="text-button" @click="() => openEdit(s)">✎ 编辑</UiButton>
- <UiButton class="text-button" @click="() => remove(s)">🗑 删除</UiButton>
- </footer>
- </article>
- </div>
- </Panel>
- <StyleEditorModal
- v-if="editor.open"
- :id="editor.id"
- :notify="notify"
- :after-change="afterChange"
- :on-close="() => (editor = { open: false, id: undefined })"
- />
-</template>
+++ /dev/null
-<script setup>
-import { ref, computed, onMounted } from "vue";
-import { fetchDb, formatInstances, buildMuLeaves, downloadBlob } from "../../lib/api.js";
-import { DOMAIN_STEPS, useStreamComplete, streamInto } from "./domainCommon.js";
-import UiButton from "../../ui/UiButton.vue";
-import UiInput from "../../ui/UiInput.vue";
-import UiSelect from "../../ui/UiSelect.vue";
-import UiTable from "../../ui/UiTable.vue";
-import UiOverlay from "../../ui/UiOverlay.vue";
-import Panel from "../../components/Panel.vue";
-import Field from "../../components/Field.vue";
-import Button from "../../components/PrimaryButton.vue";
-import Status from "../../components/StatusTag.vue";
-import DomainSteps from "./DomainSteps.vue";
-import DomainKnowledgeStep from "./DomainKnowledgeStep.vue";
-import DomainFunctionsStep from "./DomainFunctionsStep.vue";
-import DomainGenerateReview from "./DomainGenerateReview.vue";
-import PackageModal from "./PackageModal.vue";
-import DeviceTemplateCreate from "./DeviceTemplateCreate.vue";
-import DomainPagePreviewModal from "./DomainPagePreviewModal.vue";
-import { useConfirm } from "./useConfirm.js";
-
-const props = defineProps({
- id: { type: Number, required: true },
- doms: { type: Array, default: () => [] },
- go: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
- onBack: { type: Function, default: () => {} },
- viewCompletePrompt: { type: Function, default: () => {} },
- viewCompleteAssetsPrompt: { type: Function, default: () => {} },
- viewCompleteCubesPrompt: { type: Function, default: () => {} },
- pageStyles: { type: Array, default: () => [] },
-});
-
-const t = ref(null);
-const m = ref(null);
-const assets = ref([]);
-const mu = ref([]);
-const pages = ref([]);
-const allDevices = ref([]);
-const muTreeLocal = ref([]);
-const step = ref("basic");
-const loading = ref(true);
-const saving = ref(false);
-const devCreateOpen = ref(false);
-const pendingDevRow = ref(null);
-const previewPage = ref(null);
-const pkgOpen = ref(false);
-
-const basicAi = useStreamComplete();
-const assetsAi = useStreamComplete();
-const cubesAi = useStreamComplete();
-const { completing: basicCompleting, log: basicLog, ref: basicRef, setCompleting: setBasicCompleting, setLog: setBasicLog } = basicAi;
-const { completing: assetsCompleting, log: assetsLog, ref: assetsRef, setCompleting: setAssetsCompleting, setLog: setAssetsLog } = assetsAi;
-
-const genIds = ref(new Set());
-const genLog = ref("");
-const { confirm, el: confirmEl } = useConfirm();
-
-function setGenLog(v) {
- genLog.value = typeof v === "function" ? v(genLog.value) : v;
-}
-
-const domainName = computed(() => {
- const d = props.doms.find((d) => d.id === m.value?.domain_id);
- return d ? d.name : "";
-});
-const allMuKeys = computed(() => buildMuLeaves(muTreeLocal.value).map((l) => l.key));
-const effSelected = computed(() => (mu.value.length ? mu.value : allMuKeys.value));
-const grouped = computed(() => {
- const leaves = buildMuLeaves(muTreeLocal.value);
- const map = new Map();
- for (const l of leaves) {
- if (!map.has(l.parentMenu)) map.set(l.parentMenu, { parentMenu: l.parentMenu, items: [] });
- map.get(l.parentMenu).items.push(l);
- }
- return Array.from(map.values());
-});
-
-async function loadAllDevices() {
- try {
- allDevices.value = (await fetchDb("/device-templates")) || [];
- } catch (e) {}
-}
-async function load(silent = false) {
- if (!silent) loading.value = true;
- try {
- const [d, mt] = await Promise.all([fetchDb(`/domain-templates/${props.id}`), fetchDb(`/mu-tree`)]);
- muTreeLocal.value = mt || [];
- t.value = d;
- m.value = {
- domain_id: d.domain_id,
- name: d.name,
- description: d.description || "",
- status: d.status,
- version: d.version,
- creator: d.creator || "",
- supplement: d.supplement || "",
- style_id: d.style_id != null ? Number(d.style_id) : null,
- docs: d.docs || [],
- flows: d.flows || [],
- };
- assets.value = (d.assets || []).map((a) => ({
- id: a.id,
- device_template_id: a.device_template_id,
- device_name: a.device_name || "",
- count: Number(a.count) || 0,
- rule: a.rule || "",
- sort_order: a.sort_order || 0,
- }));
- pages.value = (d.pages || []).map((p) => ({
- id: p.id,
- name: p.name,
- description: p.description || "",
- json_text: p.json_text || "",
- status: p.status || "pending",
- gen_filename: p.gen_filename || null,
- prompt: p.prompt || null,
- source: p.source || null,
- sort_order: p.sort_order || 0,
- }));
- mu.value = d.mu && d.mu.length ? d.mu : [];
- } catch (e) {
- props.notify("加载失败:" + (e && e.message ? e.message : e));
- } finally {
- if (!silent) loading.value = false;
- }
-}
-onMounted(() => {
- loadAllDevices();
- load();
-});
-
-function patchM(patch) {
- m.value = { ...(m.value || {}), ...patch };
-}
-
-async function patch(patchBody, msg = "已保存") {
- saving.value = true;
- try {
- await fetchDb(`/domain-templates/${props.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(patchBody),
- });
- props.notify(msg);
- } catch (e) {
- props.notify("保存失败:" + (e && e.message ? e.message : e));
- } finally {
- saving.value = false;
- }
-}
-const saveMeta = () => patch(m.value);
-
-async function saveAssets(next) {
- const a = next ?? assets.value;
- assets.value = a;
- await patch(
- { assets: a.map((x) => ({ device_template_id: x.device_template_id, count: x.count, rule: x.rule, sort_order: x.sort_order })) },
- "资产已保存"
- );
-}
-async function saveMu(next) {
- const sel = next ?? mu.value;
- mu.value = sel;
- await patch({ mu: sel }, "MU 选择已保存");
-}
-const toggleMu = (key) =>
- saveMu(effSelected.value.includes(key) ? effSelected.value.filter((k) => k !== key) : [...effSelected.value, key]);
-
-async function selectStyle(sid) {
- try {
- await fetchDb(`/domain-templates/${props.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ style_id: sid }),
- });
- patchM({ style_id: sid });
- props.notify(sid ? "已应用页面样式" : "已清除页面样式");
- } catch (e) {
- props.notify("应用样式失败:" + (e && e.message ? e.message : e));
- }
-}
-
-async function runBasicComplete() {
- if (basicCompleting.value) return;
- setBasicCompleting(true);
- setBasicLog("");
- let newSup = m.value.supplement || "";
- const wd = setTimeout(() => {
- setBasicCompleting(false);
- props.notify("AI 智能补全超时(>6 分钟),请重试");
- }, 360000);
- try {
- const res = await fetch(`/api/db/domain-templates/${props.id}/complete`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: m.value.name, domain: domainName.value, description: m.value.description }),
- });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- await streamInto(res, setBasicLog, (ev) => {
- if (ev.result !== undefined) {
- newSup = ev.result;
- patchM({ supplement: newSup });
- }
- });
- await patch({ supplement: newSup }, "AI 已写入补充描述");
- } catch (e) {
- props.notify("AI 智能补全失败:" + (e && e.message ? e.message : e));
- } finally {
- clearTimeout(wd);
- setBasicCompleting(false);
- }
-}
-
-async function runAssetsComplete() {
- if (assetsCompleting.value) return;
- setAssetsCompleting(true);
- setAssetsLog("");
- const wd = setTimeout(() => {
- setAssetsCompleting(false);
- props.notify("AI 资产补全超时(>10 分钟),请重试");
- }, 600000);
- try {
- const res = await fetch(`/api/db/domain-templates/${props.id}/complete-assets`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: m.value.name,
- domain: domainName.value,
- description: m.value.description,
- supplement: m.value.supplement,
- assets: assets.value.map((a) => ({ type: a.device_name, count: a.count, rule: a.rule })),
- }),
- });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- let created = [];
- await streamInto(res, setAssetsLog, (ev) => {
- if (Array.isArray(ev.result)) {
- assets.value = [
- ...assets.value,
- ...ev.result.map((it, i) => ({
- device_template_id: it.device_template_id,
- device_name: allDevices.value.find((d) => d.id === it.device_template_id)?.name || "",
- count: Number(it.count) || 0,
- rule: it.rule || "",
- sort_order: assets.value.length + i,
- })),
- ];
- }
- if (Array.isArray(ev.created)) created = ev.created;
- });
- await loadAllDevices();
- await load(true);
- props.notify("AI 资产补全已完成" + (created.length ? `,并自动创建设备模板:${created.join("、")}` : ""));
- } catch (e) {
- props.notify("AI 资产补全失败:" + (e && e.message ? e.message : e));
- } finally {
- clearTimeout(wd);
- setAssetsCompleting(false);
- }
-}
-
-async function runCubesComplete() {
- if (cubesAi.completing.value) return;
- cubesAi.setCompleting(true);
- cubesAi.setLog("");
- const wd = setTimeout(() => {
- cubesAi.setCompleting(false);
- props.notify("AI Cube 补全超时(>6 分钟),请重试");
- }, 360000);
- let failed = "";
- let items = [];
- try {
- const res = await fetch(`/api/db/domain-templates/${props.id}/complete-cubes`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: m.value.name,
- domain: domainName.value,
- description: m.value.description,
- supplement: m.value.supplement,
- assets: assets.value.map((a) => ({ type: a.device_name, count: a.count, rule: a.rule })),
- }),
- });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- await streamInto(res, cubesAi.setLog, (ev) => {
- if (Array.isArray(ev.result)) items = ev.result;
- if (ev && ev.status === "failed") failed = ev.message || "AI 分析失败";
- });
- if (failed) {
- props.notify("AI Cube 补全失败:" + failed);
- return;
- }
- let added = 0;
- for (const it of items) {
- try {
- await fetchDb(`/domain-templates/${props.id}/pages`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: it.name, description: it.content || "", json_text: "{}" }),
- });
- added++;
- } catch (e) {
- props.notify(`新增页面「${it.name || ""}」失败:${e.message}`);
- }
- }
- await load(true);
- props.notify(items.length ? `AI Cube 页面补全已完成,已新增 ${added} 个页面` : "AI 未返回可用页面");
- } catch (e) {
- props.notify("AI Cube 补全失败:" + (e && e.message ? e.message : e));
- } finally {
- clearTimeout(wd);
- cubesAi.setCompleting(false);
- }
-}
-
-function baseAssets() {
- return assets.value.length
- ? assets.value
- : Array.from({ length: 4 }, (_, k) => ({ device_template_id: 0, device_name: "", count: 0, rule: "", sort_order: k }));
-}
-const assetRows = computed(() => baseAssets());
-function updateAsset(i, patchRow) {
- const next = baseAssets().map((a, k) => (k === i ? { ...a, ...patchRow } : a));
- saveAssets(next);
-}
-function addAssetRow() {
- const next = [...baseAssets(), { device_template_id: 0, device_name: "", count: 0, rule: "", sort_order: baseAssets().length }];
- saveAssets(next);
-}
-function removeAssetRow(i) {
- const next = baseAssets().filter((_, k) => k !== i);
- saveAssets(next);
-}
-function onAssetDeviceChange(i, val) {
- if (val === "__new__") {
- devCreateOpen.value = true;
- pendingDevRow.value = i;
- return;
- }
- const did = Number(val);
- const dev = allDevices.value.find((d) => d.id === did);
- updateAsset(i, { device_template_id: did, device_name: dev?.name || "" });
-}
-async function onDevCreated(nid) {
- await loadAllDevices();
- devCreateOpen.value = false;
- if (pendingDevRow.value != null) {
- const dev = allDevices.value.concat([{ id: nid }]).find((d) => d.id === nid);
- updateAsset(pendingDevRow.value, { device_template_id: nid, device_name: dev?.name || "" });
- pendingDevRow.value = null;
- }
-}
-
-async function addPage() {
- await fetchDb(`/domain-templates/${props.id}/pages`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "新页面", description: "", json_text: "{}", source: "manual" }),
- });
- await load(true);
- props.notify("已新增页面");
-}
-async function uploadPageJson(file, replaceId) {
- const text = await file.text();
- try {
- JSON.parse(text);
- } catch (e) {
- props.notify("不是合法 JSON 文件");
- return;
- }
- const name = file.name.replace(/\.json$/i, "");
- try {
- if (replaceId) {
- await fetchDb(`/domain-pages/${replaceId}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ json_text: text }),
- });
- props.notify("已更新页面 JSON");
- } else {
- await fetchDb(`/domain-templates/${props.id}/pages`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name, description: "", json_text: text, source: "upload", gen_filename: file.name, status: "uploaded" }),
- });
- props.notify("已上传页面 JSON");
- }
- await load(true);
- } catch (e) {
- props.notify("上传失败:" + (e && e.message ? e.message : e));
- }
-}
-async function savePageMeta(p) {
- await fetchDb(`/domain-pages/${p.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: p.name, description: p.description, json_text: p.json_text }),
- });
- props.notify("页面已保存");
-}
-async function deletePage(p) {
- if (!(await confirm(`删除页面「${p.name}」?`))) return;
- await fetchDb(`/domain-pages/${p.id}`, { method: "DELETE" });
- await load(true);
-}
-async function generatePage(p) {
- if (genIds.value.has(p.id)) return;
- const n = new Set(genIds.value);
- n.add(p.id);
- genIds.value = n;
- setGenLog("");
- props.notify(`开始生成「${p.name}」…`);
- let failed = "";
- try {
- const res = await fetch(`/api/db/domain-templates/${props.id}/pages/${p.id}/generate`, { method: "POST" });
- if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
- await streamInto(res, setGenLog, (ev) => {
- if (ev && ev.status === "failed") failed = ev.message || "生成失败";
- });
- if (failed) props.notify(`「${p.name}」生成失败:${failed}`);
- else props.notify(`「${p.name}」生成完成`);
- } catch (e) {
- props.notify("生成失败:" + (e && e.message ? e.message : e));
- } finally {
- const n2 = new Set(genIds.value);
- n2.delete(p.id);
- genIds.value = n2;
- await load(true);
- }
-}
-function generateAllPages() {
- const pending = pages.value.filter((p) => p.status !== "generated" && !genIds.value.has(p.id));
- if (!pending.length) {
- props.notify("没有待生成的页面");
- return;
- }
- props.notify(`已开始排队生成 ${pending.length} 个页面…`);
- for (const p of pending) generatePage(p);
-}
-function downloadPage(p) {
- const blob = new Blob([p.json_text || "{}"], { type: "application/json" });
- downloadBlob(blob, p.gen_filename || `${p.name || "page"}.json`);
-}
-
-const curIdx = computed(() => DOMAIN_STEPS.findIndex((s) => s.key === step.value));
-function goStep(s) {
- step.value = s;
-}
-const assetRowsForTree = computed(() => assets.value.filter((r) => r.device_template_id));
-
-const pageGenerated = computed(() => pages.value.some((p) => {
- const tx = (p.json_text || "").trim();
- return tx !== "" && tx !== "{}" && tx !== "null";
-}));
-const completed = computed(() => pageGenerated.value);
-const configured = computed(() => ({
- basic: !!(m.value && m.value.name && m.value.description),
- knowledge: ((m.value && m.value.docs) || []).length > 0,
- assets: assets.value.some((a) => a.device_template_id),
- functions: pages.value.length > 0 || effSelected.value.length > 0,
- opsflow: ((m.value && m.value.flows) || []).length > 0,
- generate: pageGenerated.value,
- expert: m.value && m.value.status === "available",
-}));
-
-const kpiData = computed(() => [
- [assets.value.filter((a) => a.device_template_id).length, "设备类型", "类"],
- [assets.value.reduce((n, a) => n + Number(a.count || 0), 0), "设备实例", "个"],
- [Math.max(2, assets.value.filter((a) => a.device_template_id).length + 1), "资产层级", "层"],
-]);
-
-function updatePageField(i, patchRow) {
- pages.value = pages.value.map((p, idx) => (idx === i ? { ...p, ...patchRow } : p));
-}
-</script>
-
-<template>
- <component :is="confirmEl" />
- <p v-if="loading || !t || !m">加载中…</p>
- <template v-else>
- <UiOverlay v-if="devCreateOpen" class="modal-backdrop" @mousedown="() => { devCreateOpen = false; pendingDevRow = null; }">
- <div class="modal" style="width: 640px" @mousedown.stop>
- <header>
- <h2>新增设备模版</h2>
- <UiButton @click="() => { devCreateOpen = false; pendingDevRow = null; }">×</UiButton>
- </header>
- <div class="modal-body">
- <DeviceTemplateCreate :notify="notify" :on-done="onDevCreated" :on-cancel="() => { devCreateOpen = false; pendingDevRow = null; }" />
- </div>
- </div>
- </UiOverlay>
-
- <DomainPagePreviewModal
- v-if="previewPage"
- :page="previewPage"
- :tmpl-id="id"
- :notify="notify"
- :on-close="(reload) => { previewPage = null; if (reload) load(true); }"
- />
-
- <div class="domain-detail-head">
- <UiButton class="text-button" @click="onBack">← 返回列表</UiButton>
- </div>
- <DomainSteps :step="step" :go="goStep" :completed="completed" :configured="configured" />
-
- <div class="content">
- <template v-if="step === 'basic'">
- <div class="two-column basic-layout">
- <Panel title="方案信息">
- <div class="form-grid">
- <Field label="所属领域" required>
- <UiSelect :value="m.domain_id" @change="(e) => patchM({ domain_id: Number(e.target.value) })">
- <option v-for="d in doms" :key="d.id" :value="d.id">{{ d.name }}</option>
- </UiSelect>
- </Field>
- <Field label="方案名称" required>
- <UiInput :value="m.name" :maxlength="20" @change="(e) => patchM({ name: e.target.value })" />
- </Field>
- <Field label="版本">
- <UiInput :value="m.version" @change="(e) => patchM({ version: e.target.value })" />
- </Field>
- <Field label="状态">
- <UiSelect :value="m.status" @change="(e) => patchM({ status: e.target.value })">
- <option value="available">可用</option>
- <option value="unavailable">不可用</option>
- </UiSelect>
- </Field>
- <Field label="创建人">
- <UiInput :value="m.creator" @change="(e) => patchM({ creator: e.target.value })" />
- </Field>
- <Field label="方案描述" required hint="建议包含方案用途、重点设备、管理目标及关注指标">
- <UiInput textarea :value="m.description" :rows="3" @change="(e) => patchM({ description: e.target.value })" />
- </Field>
- <Field label="补充描述" hint="可由右侧 AI 智能补全自动生成">
- <UiInput textarea class="small" :value="m.supplement || ''" @change="(e) => patchM({ supplement: e.target.value })" />
- </Field>
- <div style="grid-column: 1 / -1"><Button @click="saveMeta">保存方案信息</Button></div>
- </div>
- </Panel>
- <div class="stack">
- <Panel title="填写建议">
- <ul class="tips">
- <li>这个方案服务于哪个业务场景?</li>
- <li>涉及哪些核心设备与子系统?</li>
- <li>管理人员最关注哪些运行指标?</li>
- <li>希望方案落地后首先解决什么问题?</li>
- </ul>
- </Panel>
- <Panel title="AI 智能补全">
- <p class="hint">根据「方案名称 / 所属领域 / 方案描述」,由 AI 搜索同类型站点的物联网建设经验,自动生成补充描述到左侧「补充描述」。</p>
- <div style="display: flex; gap: 8px; align-items: center">
- <UiButton class="button" :disabled="basicCompleting" @click="runBasicComplete">{{ basicCompleting ? "补全中…" : "开始补全" }}</UiButton>
- <UiButton class="text-button" @click="() => viewCompletePrompt(id, m.name, domainName, m.description)">查看提示词</UiButton>
- </div>
- <div v-if="basicCompleting || basicLog" class="modal-console">
- <div class="modal-console-head">AI 智能补全</div>
- <pre class="modal-console-body" :ref="(el) => (basicRef.value = el)">{{ basicLog || "(等待 AI 分析…)" }}</pre>
- </div>
- </Panel>
- <Panel v-if="saving"><div class="empty-state">正在保存…</div></Panel>
- </div>
- </div>
- </template>
-
- <template v-if="step === 'knowledge'">
- <DomainKnowledgeStep
- :id="id"
- :docs="m.docs || []"
- :notify="notify"
- :reload="() => load(true)"
- :confirm="confirm"
- />
- </template>
-
- <template v-if="step === 'assets'">
- <div class="two-column">
- <div>
- <div class="kpi-row">
- <div v-for="x in kpiData" :key="x[1]"><span>{{ x[1] }}</span><b>{{ x[0] }}</b><small>{{ x[2] }}</small></div>
- <div v-if="saving"><span>保存</span><Status>保存中…</Status></div>
- </div>
- <Panel title="设备资产配置">
- <template #tools><Button @click="addAssetRow">+ 添加设备类型</Button></template>
- <UiTable class="data-table editable">
- <thead><tr><th>设备类型 *</th><th>数量 *</th><th>命名规则</th><th>操作</th></tr></thead>
- <tbody>
- <tr v-for="(row, i) in assetRows" :key="i" :class="!row.device_template_id ? 'empty-row' : ''">
- <td>
- <UiSelect :value="row.device_template_id || ''" @change="(e) => onAssetDeviceChange(i, e.target.value)">
- <option value="">选择设备模板…</option>
- <option v-for="d in allDevices" :key="d.id" :value="d.id">{{ d.name }}</option>
- <option value="__new__">+ 新增设备模板…</option>
- </UiSelect>
- </td>
- <td><UiInput class="number" type="number" min="0" :value="row.count || ''" placeholder="0" @change="(e) => updateAsset(i, { count: Number(e.target.value) })" /></td>
- <td><UiInput :value="row.rule" placeholder="例如:CH-##" @change="(e) => updateAsset(i, { rule: e.target.value })" /></td>
- <td><UiButton class="trash" @click="() => removeAssetRow(i)">⌫</UiButton></td>
- </tr>
- </tbody>
- </UiTable>
- <div style="margin-top: 8px"><Button secondary @click="() => go('deviceTemplates')">查看全部设备模版</Button></div>
- </Panel>
- <Panel title="资产层级预览">
- <div class="asset-tree-preview">
- <div class="tree-root">⌄ <b>{{ m.name || "未命名方案" }}</b></div>
- <div v-for="(row, i) in assetRowsForTree" :key="i" class="tree-branch">
- <div class="tree-node">⌄ {{ row.device_name || "(未选设备)" }} <span class="tree-count">{{ row.count }}</span></div>
- <div v-if="row.count > 0" class="tree-leaves">{{ formatInstances(row.rule, row.count) }}</div>
- </div>
- </div>
- </Panel>
- </div>
- <div class="stack">
- <Panel title="填写建议">
- <ul class="tips">
- <li>设备类型必须从设备模板库中选择,库中没有时点击下拉框「+ 新增设备模板…」现场创建。</li>
- <li>为每种设备估算典型数量与命名规则。</li>
- <li>命名规则用 <code>#</code> 表示序号,如 <code>CH-##</code> 生成 CH-01/CH-02。</li>
- <li>点击「保存资产」才会写入;AI 补全会把建议设备追加在您已填项下方,库内缺失的模板会自动创建。</li>
- </ul>
- </Panel>
- <Panel title="AI 智能补全">
- <p class="hint">基于方案名称 / 领域 / 描述 / 补充描述,由 AI 分析并补充设备类型、数量与命名规则(位于您已填项下方)。</p>
- <div style="display: flex; gap: 8px; align-items: center">
- <UiButton class="button" :disabled="assetsCompleting" @click="runAssetsComplete">{{ assetsCompleting ? "补全中…" : "开始补全" }}</UiButton>
- <UiButton class="text-button" @click="() => viewCompleteAssetsPrompt(id, m.name, domainName, m.description, m.supplement || '')">查看提示词</UiButton>
- </div>
- <div v-if="assetsCompleting || assetsLog" class="modal-console">
- <div class="modal-console-head">AI 智能补全</div>
- <pre class="modal-console-body" :ref="(el) => (assetsRef.value = el)">{{ assetsLog || "(等待 AI 分析…)" }}</pre>
- </div>
- </Panel>
- <Panel v-if="saving"><div class="empty-state">正在保存…</div></Panel>
- </div>
- </div>
- </template>
-
- <template v-if="step === 'functions'">
- <DomainFunctionsStep
- :mu-tree="muTreeLocal"
- :grouped="grouped"
- :eff-selected="effSelected"
- :on-toggle-mu="toggleMu"
- :pages="pages"
- :on-add-page="addPage"
- :on-upload-page-json="uploadPageJson"
- :on-save-page-meta="savePageMeta"
- :on-delete-page="deletePage"
- :on-generate-page="generatePage"
- :on-download-page="downloadPage"
- :on-preview-page="(p) => (previewPage = p)"
- :on-generate-all="generateAllPages"
- :gen-ids="genIds"
- :gen-log="genLog"
- :cubes-ai="cubesAi"
- :on-run-cubes-complete="runCubesComplete"
- :on-view-complete-cubes-prompt="() => viewCompleteCubesPrompt(id, m.name, domainName, m.description, m.supplement || '', assets.map((a) => ({ type: a.device_name, count: a.count, rule: a.rule })))"
- :update-page-field="updatePageField"
- :t-name="m.name"
- :saving="saving"
- :notify="notify"
- :page-styles="pageStyles"
- :style-id="m.style_id != null ? m.style_id : null"
- :on-select-style="selectStyle"
- />
- </template>
-
- <template v-if="step === 'opsflow'">
- <Panel title="运维流程">
- <div class="placeholder-block"><span class="badge todo">待开发</span><p>运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。</p></div>
- </Panel>
- </template>
-
- <template v-if="step === 'generate'">
- <DomainGenerateReview
- :m="m"
- :domain-name="domainName"
- :assets="assets"
- :eff-selected="effSelected"
- :grouped="grouped"
- :pages="pages"
- :docs="m.docs || []"
- :on-back="() => goStep('opsflow')"
- :on-preview-page="(p) => (previewPage = p)"
- />
- </template>
-
- <template v-if="step === 'expert'">
- <Panel title="专家确认">
- <div class="placeholder-block"><span class="badge todo">待开发</span><p>专家确认流程待开发,后续版本将支持领域专家在线评审并签署方案。</p></div>
- </Panel>
- </template>
- </div>
-
- <div class="sticky-actions">
- <Button secondary @click="curIdx > 0 ? goStep(DOMAIN_STEPS[curIdx - 1].key) : onBack()">{{ curIdx > 0 ? "上一步" : "返回列表" }}</Button>
- <div>
- <Button secondary @click="() => notify('草稿已保存')">保存草稿</Button>
- <Button v-if="step === 'generate'" @click="() => (pkgOpen = true)">📦 打包下载</Button>
- <Button v-if="step !== 'generate' && curIdx < DOMAIN_STEPS.length - 1" @click="() => goStep(DOMAIN_STEPS[curIdx + 1].key)">下一步</Button>
- <Button v-if="curIdx === DOMAIN_STEPS.length - 1" @click="() => notify('方案已提交专家确认')">提交确认</Button>
- </div>
- </div>
-
- <PackageModal v-if="pkgOpen" :name="m.name" :on-close="() => (pkgOpen = false)" :notify="notify" />
- </template>
-</template>
+++ /dev/null
-<script setup>
-import { ref, watch, onMounted } from "vue";
-import { UploadOutlined } from "@ant-design/icons-vue";
-import Panel from "../../components/Panel.vue";
-import Field from "../../components/Field.vue";
-import UiInput from "../../ui/UiInput.vue";
-import UiSelect from "../../ui/UiSelect.vue";
-import UiActions from "../../ui/UiActions.vue";
-import Button from "../../components/PrimaryButton.vue";
-import { fetchDb } from "../../lib/api.js";
-import { friendlyDomainName } from "./domainCommon.js";
-
-const CURRENT_USER_EMAIL = "wang.yu@advantech.com";
-
-const props = defineProps({
- doms: { type: Array, default: () => [] },
- notify: { type: Function, default: () => {} },
- onImported: { type: Function, default: () => {} },
-});
-
-const file = ref(null);
-const domainId = ref(0);
-const name = ref("");
-const version = ref("1.0");
-const description = ref("");
-const busy = ref(false);
-
-onMounted(() => {
- if (!domainId.value && props.doms.length) domainId.value = Number(props.doms[0].id);
-});
-watch(
- () => [props.doms, domainId.value],
- () => {
- if (!domainId.value && props.doms.length) domainId.value = Number(props.doms[0].id);
- }
-);
-
-function choose(picked) {
- if (!picked) return;
- if (!/\.zip$/i.test(picked.name)) {
- props.notify("请选择 ZIP 格式的领域方案包");
- return;
- }
- file.value = picked;
- if (!name.value) name.value = picked.name.replace(/\.zip$/i, "").replace(/[-_]/g, " ");
-}
-
-async function submit() {
- if (!file.value) {
- props.notify("请先选择领域方案 ZIP 包");
- return;
- }
- if (!domainId.value || !name.value.trim()) {
- props.notify("请填写方案名称和所属领域");
- return;
- }
- busy.value = true;
- try {
- const row = await fetchDb("/domain-templates", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- domain_id: domainId.value,
- name: name.value.trim(),
- description: description.value.trim() || `由 ${file.value.name} 人工导入的领域方案`,
- status: "available",
- version: version.value.trim() || "1.0",
- creator: CURRENT_USER_EMAIL,
- }),
- });
- props.notify("领域方案导入完成,可继续维护各项能力");
- props.onImported(Number(row.id));
- } catch (e) {
- props.notify("导入失败:" + (e && e.message ? e.message : e));
- } finally {
- busy.value = false;
- }
-}
-</script>
-
-<template>
- <div class="domain-import-page">
- <Panel title="导入领域方案">
- <div class="domain-import-layout">
- <label :class="'domain-import-drop' + (file ? ' selected' : '')">
- <UploadOutlined />
- <b>{{ file ? file.name : "选择领域方案 ZIP 包" }}</b>
- <span>{{ file ? `${(file.size / 1024).toFixed(1)} KB · 点击可重新选择` : "将客户现场已有的资产、页面、知识和菜单配置导入 StationBuilder" }}</span>
- <input type="file" accept=".zip,application/zip" hidden @change="(e) => choose(e.target.files && e.target.files[0] ? e.target.files[0] : null)" />
- </label>
- <div class="domain-import-form">
- <Field label="方案名称" required><UiInput :value="name" @change="(e) => (name = e.target.value)" placeholder="例如:AIDC 标准场站方案" /></Field>
- <Field label="所属领域" required>
- <UiSelect :value="domainId" @change="(e) => (domainId = Number(e.target.value))">
- <option value="0">请选择领域</option>
- <option v-for="d in doms" :key="d.id" :value="d.id">{{ friendlyDomainName(d.name) }}</option>
- </UiSelect>
- </Field>
- <Field label="方案版本"><UiInput :value="version" @change="(e) => (version = e.target.value)" placeholder="1.0" /></Field>
- <Field label="导入说明"><UiInput textarea :rows="4" :value="description" @change="(e) => (description = e.target.value)" placeholder="说明方案来源、适用场景或需要现场调整的内容" /></Field>
- </div>
- </div>
- <div class="domain-package-check">
- <b>方案包内容</b><span>资产配置</span><span>仪表板与 SC 页面</span><span>百科与文档</span><span>菜单配置</span>
- </div>
- <UiActions class="domain-import-actions">
- <Button :disabled="busy || !file" @click="submit">{{ busy ? "正在导入…" : "开始导入" }}</Button>
- </UiActions>
- </Panel>
- </div>
-</template>
+++ /dev/null
-<script setup>
-import UiOverlay from "../../ui/UiOverlay.vue";
-import UiButton from "../../ui/UiButton.vue";
-import Button from "../../components/PrimaryButton.vue";
-
-const props = defineProps({
- name: { type: String, default: "" },
- onClose: { type: Function, default: () => {} },
- notify: { type: Function, default: () => {} },
-});
-
-const items = [
- { icon: "⬡", label: "设备模板", desc: "本方案选用的设备模版及采集点" },
- { icon: "▦", label: "资产结构", desc: "默认资产组与设备实例层级" },
- { icon: "❖", label: "扩展页面文件", desc: "已生成的 Cube 扩展页面文件" },
- { icon: "☰", label: "默认菜单", desc: "MU + 综合监控 菜单结构" },
- { icon: "📄", label: "领域知识", desc: "上传的领域知识文档" },
-];
-</script>
-
-<template>
- <UiOverlay class="modal-backdrop" @mousedown="onClose">
- <div class="modal pkg-modal" @mousedown.stop>
- <header>
- <h2>方案打包</h2>
- <UiButton @click="onClose">×</UiButton>
- </header>
- <div class="modal-body pkg-body">
- <p class="hint">即将把方案「{{ name || '未命名方案' }}」打包为可交付成果,包含以下组成部分:</p>
- <div class="pkg-box">
- <svg viewBox="0 0 260 150" class="pkg-svg" role="img" aria-label="方案打包箱">
- <polygon points="50,70 130,52 210,70 130,88" fill="#d9a05b" stroke="#b9823f" stroke-width="1.5" />
- <polygon points="50,70 50,118 130,136 130,88" fill="#c98f4c" stroke="#b9823f" stroke-width="1.5" />
- <polygon points="210,70 210,118 130,136 130,88" fill="#e0aa63" stroke="#b9823f" stroke-width="1.5" />
- <polygon points="50,70 130,52 130,30 50,48" fill="#e7b877" stroke="#b9823f" stroke-width="1.5" />
- <polygon points="210,70 130,52 130,30 210,48" fill="#e7b877" stroke="#b9823f" stroke-width="1.5" />
- <rect x="92" y="60" width="76" height="10" rx="2" fill="#fff" opacity=".9" />
- <rect x="96" y="50" width="68" height="10" rx="2" fill="#eef7fb" opacity=".95" />
- <rect x="100" y="40" width="60" height="10" rx="2" fill="#fff" opacity=".9" />
- <rect x="104" y="31" width="52" height="9" rx="2" fill="#e6f3fb" opacity=".95" />
- </svg>
- </div>
- <div class="pkg-items">
- <div v-for="it in items" :key="it.label" class="pkg-item">
- <span class="pkg-icon">{{ it.icon }}</span>
- <div class="pkg-item-text"><b>{{ it.label }}</b><small>{{ it.desc }}</small></div>
- </div>
- </div>
- </div>
- <footer>
- <Button secondary @click="onClose">取消</Button>
- <Button @click="() => { notify('方案已打包(示意),可交付给实施团队'); onClose(); }">确认打包下载</Button>
- </footer>
- </div>
- </UiOverlay>
-</template>
+++ /dev/null
-<script setup>
-import { ref, watch, onMounted } from "vue";
-import UiOverlay from "../../ui/UiOverlay.vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiInput from "../../ui/UiInput.vue";
-import Panel from "../../components/Panel.vue";
-import Field from "../../components/Field.vue";
-import Button from "../../components/PrimaryButton.vue";
-import { fetchDb } from "../../lib/api.js";
-import { useConfirm } from "./useConfirm.js";
-
-const props = defineProps({
- id: { type: Number, default: undefined },
- notify: { type: Function, default: () => {} },
- afterChange: { type: Function, default: () => {} },
- onClose: { type: Function, default: () => {} },
-});
-
-const { confirm, el: confirmEl } = useConfirm();
-
-const name = ref("");
-const jsonText = ref("");
-const image = ref(null);
-const imageUrl = ref(props.id ? `/api/db/page-styles/${props.id}/image?w=640` : null);
-const saving = ref(false);
-
-function readText(file) {
- return new Promise((resolve, reject) => {
- const r = new FileReader();
- r.onload = () => resolve(String(r.result));
- r.onerror = () => reject(r.error);
- r.readAsText(file);
- });
-}
-function readImage(file) {
- return new Promise((resolve, reject) => {
- const r = new FileReader();
- r.onload = () => resolve({ filename: file.name, data: String(r.result).split(",")[1] });
- r.onerror = () => reject(r.error);
- r.readAsDataURL(file);
- });
-}
-
-onMounted(async () => {
- if (props.id) {
- try {
- const row = await fetchDb(`/page-styles/${props.id}`);
- name.value = row.name || "";
- jsonText.value = row.json_text || "";
- } catch (e) {
- props.notify("加载样式失败: " + (e && e.message ? e.message : e));
- }
- }
-});
-
-async function onPickJson(e) {
- const f = e.target.files && e.target.files[0];
- if (!f) return;
- try {
- jsonText.value = await readText(f);
- } catch (err) {
- props.notify("读取文件失败");
- }
-}
-async function onPickImage(e) {
- const f = e.target.files && e.target.files[0];
- if (!f) return;
- try {
- const img = await readImage(f);
- image.value = img;
- imageUrl.value = URL.createObjectURL(f);
- } catch (err) {
- props.notify("读取图片失败");
- }
-}
-
-async function submit() {
- if (!name.value.trim()) {
- props.notify("请填写样式名称");
- return;
- }
- if (!jsonText.value.trim()) {
- props.notify("请填写或上传 JSON 内容");
- return;
- }
- try {
- JSON.parse(jsonText.value);
- } catch (err) {
- props.notify("JSON 内容不是合法 JSON");
- return;
- }
- if (saving.value) return;
- saving.value = true;
- try {
- const body = { name: name.value.trim(), jsonText: jsonText.value };
- if (image.value) body.image = image.value;
- if (props.id) {
- await fetchDb(`/page-styles/${props.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- } else {
- if (!image.value) {
- props.notify("请上传预览图");
- saving.value = false;
- return;
- }
- await fetchDb(`/page-styles`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- }
- await props.afterChange(props.id ? "样式已更新" : "样式已新增");
- } catch (e) {
- props.notify("保存失败: " + (e && e.message ? e.message : e));
- } finally {
- saving.value = false;
- }
-}
-
-async function remove() {
- if (!props.id) return;
- if (!(await confirm("确定删除该样式?此操作不可恢复"))) return;
- try {
- await fetchDb(`/page-styles/${props.id}`, { method: "DELETE" });
- await props.afterChange("样式已删除");
- } catch (e) {
- props.notify("删除失败: " + (e && e.message ? e.message : e));
- }
-}
-</script>
-
-<template>
- <UiOverlay class="modal-backdrop" @mousedown="onClose">
- <component :is="confirmEl" />
- <div class="modal" style="width: 640px" @mousedown.stop>
- <header>
- <h2>{{ id ? "编辑页面样式" : "新增页面样式" }}</h2>
- <UiButton @click="onClose">×</UiButton>
- </header>
- <div class="modal-body">
- <div class="modal-note">页面样式包含一份 Cube 仪表盘 JSON(配色与视觉主题)和一张预览图。名称用于在样式库中展示。</div>
- <Field label="样式名称" required><UiInput :value="name" @change="(e) => (name = e.target.value)" placeholder="例如:冰川浅蓝" /></Field>
- <Field label="预览图" :required="!id" hint="建议 16:10 比例的 PNG/JPG,将作为缩略图展示">
- <div class="style-upload-row">
- <label class="upload" style="flex: 1; height: 120px">
- <b>+</b><span>{{ image ? "已选择新图片" : "选择预览图" }}</span>
- <input type="file" accept="image/*" hidden @change="onPickImage" />
- </label>
- <div v-if="imageUrl" class="style-upload-prev"><img :src="imageUrl" alt="预览" /></div>
- </div>
- </Field>
- <Field label="仪表盘 JSON" required hint="可上传 Cube 仪表盘 JSON 文件,或直接粘贴内容(需为合法 JSON)">
- <div class="style-json-row">
- <UiInput textarea :value="jsonText" @change="(e) => (jsonText = e.target.value)" placeholder='{"name":"...","grid":[...],"panels":[...]}' />
- <label class="upload" style="width: 120px; height: 64px">
- <b>↑</b><span>上传 JSON</span>
- <input type="file" accept=".json,application/json" hidden @change="onPickJson" />
- </label>
- </div>
- </Field>
- </div>
- <footer>
- <Button v-if="id" secondary @click="remove" title="删除该样式">删除</Button>
- <span class="spacer" />
- <Button secondary @click="onClose">取消</Button>
- <Button :disabled="saving" @click="submit">{{ saving ? "保存中…" : "保存" }}</Button>
- </footer>
- </div>
- </UiOverlay>
-</template>
+++ /dev/null
-<script setup>
-import UiOverlay from "../../ui/UiOverlay.vue";
-import UiButton from "../../ui/UiButton.vue";
-
-const props = defineProps({
- style: { type: Object, required: true },
- onClose: { type: Function, default: () => {} },
-});
-</script>
-
-<template>
- <UiOverlay class="modal-backdrop" @mousedown="onClose">
- <div class="modal" style="width: auto; max-width: 92vw" @mousedown.stop>
- <header>
- <h2>{{ style.name }}</h2>
- <UiButton @click="onClose">×</UiButton>
- </header>
- <div class="modal-body">
- <img class="style-lightbox" :src="`/api/db/page-styles/${style.id}/image`" :alt="style.name" />
- </div>
- </div>
- </UiOverlay>
-</template>
+++ /dev/null
-// Shared helpers for the domain pages, ported faithfully from app/page.tsx.
-// Pure helpers live here; components needing them import from this module.
-
-import { ref, watch } from "vue";
-import wastewaterPhoto from "../../assets/solutions/wastewater-600x300.webp";
-import datacenterPhoto from "../../assets/solutions/datacenter-600x300.webp";
-import energyPhoto from "../../assets/solutions/energy-600x300.webp";
-
-// ---- app/page.tsx:2606 ----
-export function friendlyDomainName(name) {
- return /数据中心|AIDC/i.test(name || "") ? "AIDC 数据中心" : name || "其他领域";
-}
-
-// ---- app/page.tsx:2610 ----
-export function domainScene(domain) {
- if (/数据中心|AIDC/i.test(domain))
- return {
- code: "AIDC",
- tone: "violet",
- desc: "面向数据中心基础设施的资产、监控、能效与运维能力。",
- twins: ["机房三维总览", "冷源系统孪生"],
- wiki: ["数据中心基础设施百科", "制冷与供配电设备说明"],
- };
- if (/水务|污水|自来水/i.test(domain))
- return {
- code: "WATER",
- tone: "blue",
- desc: "覆盖水处理工艺、泵站设备、运行监控与标准运维资料。",
- twins: ["厂区工艺流程孪生", "泵房设备场景"],
- wiki: ["水处理工艺百科", "泵阀与仪表设备说明"],
- };
- if (/新能源|储能|光伏/i.test(domain))
- return {
- code: "ENERGY",
- tone: "green",
- desc: "覆盖储能与新能源场站的设备、运行页面和安全管理规则。",
- twins: ["储能电站全景", "电池舱运行孪生"],
- wiki: ["储能系统百科", "电池与变流器设备说明"],
- };
- return {
- code: "SITE",
- tone: "gray",
- desc: "沉淀该领域可复用的资产、页面、知识和菜单配置。",
- twins: ["场站三维总览"],
- wiki: ["领域基础百科", "设备与工艺说明"],
- };
-}
-
-// ---- app/page.tsx:2617 ----
-export const SOLUTION_PHOTOS = { WATER: wastewaterPhoto, AIDC: datacenterPhoto, ENERGY: energyPhoto };
-
-// ---- app/page.tsx:1936 ----
-export const DOMAIN_STEPS = [
- { key: "basic", label: "方案基本属性" },
- { key: "knowledge", label: "领域知识" },
- { key: "assets", label: "资产设置" },
- { key: "functions", label: "功能与菜单" },
- { key: "opsflow", label: "运维流程" },
- { key: "generate", label: "方案打包" },
- { key: "expert", label: "专家确认" },
-];
-
-// ---- app/page.tsx:95 (Status tone) ----
-export function statusTone(v) {
- const s = String(v);
- if (/不可用|失败|异常|failed/.test(s)) return "bad";
- if (/完成|正常|成功|通过|推荐|可用|已配置|已生成|已上传|generated/.test(s)) return "ok";
- if (/生成中|校验中|告警|running/.test(s)) return "warn";
- return "muted";
-}
-
-// ---- app/page.tsx:1964 ----
-export async function streamInto(res, setLog, onEv) {
- const reader = res.body.getReader();
- const dec = new TextDecoder();
- let buf = "";
- for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += dec.decode(value, { stream: true });
- const lines = buf.split("\n");
- buf = lines.pop() || "";
- for (const line of lines) {
- if (!line.trim()) continue;
- try {
- const ev = JSON.parse(line);
- if (ev.log) setLog((prev) => prev + ev.log);
- onEv(ev);
- } catch (e) {
- /* ignore malformed line */
- }
- }
- }
-}
-
-// ---- app/page.tsx:1956 ----
-export function useStreamComplete() {
- const completing = ref(false);
- const log = ref("");
- const el = ref(null);
- watch(log, () => {
- const node = el.value;
- if (node) node.scrollTop = node.scrollHeight;
- });
- return {
- completing,
- setCompleting: (v) => {
- completing.value = v;
- },
- log,
- setLog: (v) => {
- log.value = typeof v === "function" ? v(log.value) : v;
- },
- ref: el,
- };
-}
+++ /dev/null
-// Faithful port of React useConfirm() from app/page.tsx:1498.
-// Returns { confirm, el } where `el` is a Vue component (render-fn) to mount.
-import { ref, h } from "vue";
-import UiOverlay from "../../ui/UiOverlay.vue";
-import UiButton from "../../ui/UiButton.vue";
-import UiActions from "../../ui/UiActions.vue";
-
-export function useConfirm() {
- const state = ref({ open: false, message: "", resolve: null });
-
- const confirm = (message) =>
- new Promise((resolve) => {
- state.value = { open: true, message, resolve };
- });
-
- const close = (v) => {
- if (state.value.resolve) state.value.resolve(v);
- state.value = { ...state.value, open: false };
- };
-
- const ConfirmEl = {
- setup() {
- return () =>
- state.value.open
- ? h(
- UiOverlay,
- { class: "dlg-backdrop", onClick: () => close(false) },
- () => [
- h(
- "div",
- { class: "dlg-box", onClick: (e) => e.stopPropagation() },
- [
- h("p", state.value.message),
- h(
- UiActions,
- { class: "dlg-actions" },
- () => [
- h(
- UiButton,
- { class: "button secondary", onClick: () => close(false) },
- () => "取消"
- ),
- h(
- UiButton,
- { class: "button", onClick: () => close(true) },
- () => "确定"
- ),
- ]
- ),
- ]
- ),
- ]
- )
- : null;
- },
- };
-
- return { confirm, el: ConfirmEl };
-}
+++ /dev/null
-export const APPLICATION_COLOR_SCHEMES = [
- { key: "blue", label: "蓝色", color: "#009fe3" },
- { key: "green", label: "绿色", color: "#23a77b" },
- { key: "orange", label: "橙色", color: "#ed8b2c" },
- { key: "purple", label: "紫色", color: "#8860c3" },
- { key: "red", label: "红色", color: "#df5c5c" },
-];
-
-export function effectiveStandardPageKeys(site, leaves) {
- const selected = site?.mu || [];
- if (selected.length || site?.page_config?.standard_pages_initialized) return selected;
- return leaves.filter((leaf) => !leaf.disabled).map((leaf) => leaf.key);
-}
-
-export function selectedMenuSources(site, leaves) {
- const twins = site?.page_config?.twin_ids || [];
- const cubeIds = site?.page_config?.dashboard_ids ?? (site?.cubes || []).map((cube) => cube.id);
- const standardKeys = effectiveStandardPageKeys(site, leaves);
- return [
- ...twins.map((id) => ({
- key: `sc:${id}`,
- name: id === "site-overview" ? "场站全景" : id === "process-scene" ? "工艺与设备场景" : id,
- kind: "sc",
- ref: id,
- path: ["数字孪生"],
- })),
- ...(site?.cubes || [])
- .filter((cube) => cubeIds.includes(cube.id))
- .map((cube) => ({ key: `cube:${cube.id}`, name: cube.name, kind: "cube", ref: cube.id, path: ["领域监控"] })),
- ...leaves
- .filter((leaf) => !leaf.disabled && standardKeys.includes(leaf.key))
- .map((leaf) => ({ key: `mu:${leaf.key}`, name: leaf.label, kind: "mu", ref: leaf.key, path: leaf.parentMenu ? leaf.parentMenu.split("/") : [] })),
- ];
-}
-
-export function buildSiteMenu(site, leaves, saved = site?.menu || []) {
- const result = [];
- const used = new Set();
- const groups = new Map();
- const findSaved = (key, fallback) =>
- saved.find((item) => !used.has(item.id) && item.source_key === key) ||
- saved.find((item) => !used.has(item.id) && !item.source_key && fallback(item));
- const addGroup = (key, name, parent, locked = false) => {
- if (groups.has(key)) return groups.get(key);
- const aliases = key === "group:sc" ? ["数字孪生", "数字孪生场景"] : key === "group:cube" ? ["领域监控", "领域仪表板"] : [name];
- const existing = findSaved(key, (item) => !item.page_type && aliases.includes(item.label));
- const entry = {
- id: existing?.id ?? key,
- parent_id: parent,
- label: locked ? name : existing?.label || name,
- page_type: null,
- ref_id: null,
- source_key: key,
- source_name: name,
- };
- used.add(entry.id);
- groups.set(key, entry);
- result.push(entry);
- return entry;
- };
- const sc = addGroup("group:sc", "数字孪生", null, true);
- const cube = addGroup("group:cube", "领域监控", null, true);
- const sources = selectedMenuSources(site, leaves);
- const dynamic = sources.filter((source) => source.kind !== "mu");
- dynamic.sort((a, b) => {
- if (a.kind !== b.kind) return a.kind === "sc" ? -1 : 1;
- const rank = (source) => {
- const index = saved.findIndex((item) => item.source_key === source.key || (item.page_type === source.kind && String(item.ref_id) === String(source.ref)));
- return index < 0 ? Number.MAX_SAFE_INTEGER : index;
- };
- return rank(a) - rank(b);
- });
- for (const source of [...dynamic, ...sources.filter((item) => item.kind === "mu")]) {
- let parent = source.kind === "sc" ? sc.id : source.kind === "cube" ? cube.id : null;
- if (source.kind === "mu") {
- let path = "";
- for (const segment of source.path) {
- path += `/${segment}`;
- parent = addGroup(`group:mu:${path}`, segment, parent).id;
- }
- }
- const existing = findSaved(source.key, (item) => item.page_type === source.kind && String(item.ref_id) === String(source.ref));
- const entry = {
- id: existing?.id ?? source.key,
- parent_id: parent,
- label: existing?.label || source.name,
- page_type: source.kind,
- ref_id: source.ref,
- source_key: source.key,
- source_name: source.name,
- };
- used.add(entry.id);
- result.push(entry);
- }
- return orderMenu(result);
-}
-
-export function orderMenu(menu) {
- const result = [];
- const used = new Set();
- const walk = (parent) => {
- for (const item of menu.filter((item) => item.parent_id === parent)) {
- if (used.has(item.id)) continue;
- used.add(item.id);
- result.push(item);
- walk(item.id);
- }
- };
- walk(null);
- return result;
-}
-
-export function menuSelectionSignature(site, leaves) {
- return JSON.stringify(selectedMenuSources(site, leaves).map((source) => [source.key, source.name, source.path]));
-}
+++ /dev/null
-/* antd v6 兜底覆盖
- *
- * 这是 L0 的第 5 层:token 与 CSS 变量都表达不了、只能靠选择器命中的覆盖。
- * 尽量少写 —— 内部选择器依赖 v6 的 DOM 结构,大版本升级即可能失效。
- *
- * 能改 token 的 → src/theme.json
- * 能改变量或只是渐变的 → src/theme/overrides.css
- *
- * 已知待收窄项(中性灰暂无对应 antd token):#EDEDED / #FAFAFA / #E1E1E1 / #89898A
- */
-
-// Breadcrumb:token 里没有字重项
-.ant-breadcrumb {
- .ant-breadcrumb-item {
- font-weight: 700;
- }
-}
-
-// Tabs 公共样式
-.ant-tabs {
- background: #EDEDED;
- font-family: var(--ant-font-family);
-
- .ant-tabs-nav {
- margin-bottom: 0;
-
- &::before {
- border-bottom: 1px solid #E1E1E1;
- }
- }
-
- &.ant-tabs-card {
- .ant-tabs-tab {
- padding: 12px 24px;
- background: #EDEDED;
- border-bottom: 1px solid #E1E1E1;
-
- .ant-tabs-tab-btn {
- border: none;
- font-weight: 700;
-
- &>span {
- font-size: 1rem;
- line-height: 24px;
- font-weight: 700;
- color: #89898A;
- }
- }
-
- &.ant-tabs-tab-active {
- background: var(--ant-color-bg-container);
- border: 1px solid #E1E1E1;
- border-bottom-color: transparent;
-
- .ant-tabs-tab-btn {
- &>span {
- color: var(--ant-color-primary);
- }
- }
- }
- }
- }
-
- .ant-tabs-content {
- background: #FAFAFA;
- }
-
- // Tabs 中数据条目数量应用此类名
- .count-badge {
- background-color: #e1e1e1;
- border: 1px solid #c8c8c8;
- color: #c8c8c8;
- border-radius: 9999px;
- font-size: 12px;
- min-width: 16px;
- line-height: 16px;
- font-weight: 700;
- padding: 0 4px;
- box-sizing: border-box;
- margin-left: 8px;
-
- &.active {
- background-color: #b6f0d3;
- border: 1px solid var(--ant-color-success);
- color: var(--ant-color-success);
- }
- }
-}
+++ /dev/null
-/* StationBuilder business layout on top of the Skill's unmodified brand files.
- Keep the approved WISE-IoT header artwork and the existing configuration flow. */
-.sb-brand-shell.app-shell { padding-left:0; }
-.sb-brand-shell>.body { min-width:0; }
-.sb-brand-shell .sb-brand-nav { position:sticky; top:56px; align-self:flex-start; height:calc(100dvh - 56px); display:flex; flex-direction:column; background:var(--ant-color-border); overflow:auto; }
-.sb-brand-nav>nav { flex:1; min-height:0; }
-.sb-brand-nav>nav>.ant-menu { height:auto; min-height:100%; font-family:var(--ant-font-family); font-size:var(--ant-font-size); }
-.sb-brand-nav .ant-menu-item,.sb-brand-nav .ant-menu-submenu-title { font-weight:400; }
-.sb-brand-nav>.design-doc-link { position:static; margin:var(--ant-padding-sm) var(--ant-padding); height:var(--ant-control-height); flex-shrink:0; border-radius:var(--ant-border-radius); background:var(--ant-color-primary); color:var(--ant-color-text-light-solid); }
-.sb-brand-shell .workspace.main { display:flex; flex-direction:column; padding:0; min-height:calc(100dvh - 56px); }
-.sb-brand-shell .workspace>.page-heading { padding:var(--ant-padding) var(--ant-padding-lg); min-height:80px; background:var(--ant-color-bg-layout); gap:var(--ant-padding); }
-.sb-brand-shell .page-heading h1 { font-size:var(--ant-font-size-heading-4); line-height:var(--ant-line-height-heading-4); font-weight:var(--ant-font-weight-strong); }
-.sb-brand-shell .page-heading p { font-size:var(--ant-font-size-sm); line-height:var(--ant-line-height-sm); margin-top:var(--ant-padding-xxs); color:var(--ant-color-text-secondary); }
-.sb-brand-shell .page-heading>div:last-child { display:flex; flex-wrap:nowrap; align-items:center; gap:var(--ant-padding-xs); }
-.sb-brand-shell .workspace>.content { flex:1; min-width:0; padding:var(--ant-padding) var(--ant-padding-lg) var(--ant-padding-lg); }
-.sb-brand-shell .workspace>.sticky-actions { position:sticky; inset:auto; bottom:0; width:100%; min-height:64px; height:auto; margin:0; padding:var(--ant-padding) var(--ant-padding-lg); border-top:1px solid var(--ant-color-border); background:var(--ant-color-bg-container); z-index:20; }
-.sb-brand-shell .station-config-footer,.sb-brand-shell .sticky-actions>div { flex-wrap:nowrap; gap:var(--ant-padding-xs); }
-.sb-brand-shell .filterbar.company-filterbar,.sb-brand-shell .station-list-toolbar { display:flex; align-items:center; gap:var(--ant-padding-xs); padding-block:var(--ant-padding); min-height:64px; height:auto; }
-.sb-brand-shell .station-list-toolbar>div:last-child,.sb-brand-shell .filter-actions { margin-left:auto; display:flex; align-items:center; flex-wrap:nowrap; gap:var(--ant-padding-xs); }
-.sb-brand-shell .search-input.ant-input-affix-wrapper { flex-shrink:0; max-width:100%; height:var(--ant-control-height); }
-.sb-brand-shell .page-footer>.pagination { height:auto; min-height:0; padding:0; margin:0; border:0; background:transparent; }
-.sb-brand-shell .page-footer .page-size { min-width:128px; }
-.sb-brand-shell .station-ui-table .ant-table-cell { height:auto; line-height:var(--ant-line-height); padding-inline:var(--ant-padding); }
-.sb-brand-shell .station-ui-table .ant-table-thead>tr>th { padding-block:var(--ant-padding-sm); text-align:left; border-bottom:2px solid var(--ant-color-primary); }
-.sb-brand-shell .station-ui-table .ant-table-tbody>tr>td { padding-block:var(--ant-table-cell-padding-block); }
-.sb-brand-shell .panel.ant-card>.ant-card-head.panel-head { display:block; min-height:48px; padding:0 var(--ant-padding-lg); }
-.sb-brand-shell .panel.ant-card .ant-card-head-wrapper { gap:var(--ant-padding); }
-.sb-brand-shell .panel.ant-card .ant-card-head-title { font-size:var(--ant-font-size); font-weight:var(--ant-font-weight-strong); }
-.sb-brand-shell .panel.ant-card>.ant-card-body { padding:var(--ant-padding-lg); }
-.sb-brand-shell .company-list-panel.ant-card>.ant-card-body { padding:0 var(--ant-padding) var(--ant-padding-sm); }
-.sb-brand-shell .station-ui-tabs>.ant-tabs-nav { margin:0; background:var(--ant-color-fill-quaternary); }
-.sb-brand-shell .station-ui-tabs>.ant-tabs-nav .ant-tabs-tab { padding:var(--ant-padding-sm) var(--ant-padding-lg); }
-.sb-brand-shell .station-ui-tabs>.ant-tabs-content-holder { background:var(--ant-color-bg-layout); }
-.sb-brand-shell .station-ui-tabs .ant-tabs-tab-btn b,.sb-brand-shell .station-ui-tabs .ant-tabs-tab-btn i { display:inline-flex; align-items:center; min-width:16px; line-height:16px; padding:0 4px; margin-left:8px; border:1px solid var(--ant-color-border); border-radius:9999px; }
-.sb-brand-shell .station-ui-tabs .ant-tabs-tab-active .ant-tabs-tab-btn b { border-color:var(--ant-color-success); }
-.sb-brand-shell .domain-capability-grid { gap:var(--ant-padding-sm); }
-.sb-brand-shell .domain-capability-detail { height:calc(100dvh - 176px); min-height:800px; }
-.sb-brand-shell .domain-capability-grid { flex:1; min-height:0; }
-.sb-brand-shell .domain-capability-card { height:100%; min-height:0; max-height:none; }
-.sb-brand-shell .domain-capability-card>footer { padding:var(--ant-padding-xs); min-height:48px; flex-wrap:nowrap; }
-.sb-brand-shell .domain-capability-card>footer .ant-btn { padding-inline:var(--ant-padding-xs); }
-.sb-brand-shell .dashboard-manager-actions { display:flex; flex-wrap:nowrap; align-items:center; gap:var(--ant-padding-xs); }
-@media (max-width:1440px) {
- .sb-brand-shell .domain-capability-detail { height:auto; min-height:0; }
- .sb-brand-shell .domain-capability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
- .sb-brand-shell .domain-capability-card { height:520px; min-height:0; }
-}
-@media (max-width:1000px) {
- .sb-brand-shell .workspace>.content { padding:var(--ant-padding); }
- .sb-brand-shell .station-list-toolbar,.sb-brand-shell .filterbar.company-filterbar { overflow-x:auto; }
- .sb-brand-shell .domain-capability-grid { grid-template-columns:1fr; }
-}
+++ /dev/null
-@import "tailwindcss";
-*{box-sizing:border-box}html,body{margin:0;min-height:100%;font-family:var(--ant-font-family);color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout)}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}.app-shell{min-height:100vh;padding-top:56px;padding-left:192px}.topbar{position:fixed;z-index:50;left:0;right:0;top:0;height:56px;border-top:3px solid transparent;background-image:linear-gradient(var(--ant-color-bg-container),var(--ant-color-bg-container)),linear-gradient(90deg,var(--ant-color-primary),var(--ant-color-primary));background-origin:border-box;background-clip:padding-box,border-box;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 12px;gap:8px;box-shadow:0 1px 3px color-mix(in srgb,var(--ant-color-text-base) 7%,transparent)}.hamburger{border:0;background:transparent;font-size:var(--ant-font-size);color:var(--ant-color-text-secondary)}.brand-mark,.login-logo{color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-3);line-height:1;font-weight:600}.topbar>strong{font-size:var(--ant-font-size)}.divider{height:19px;border-left:1px solid var(--ant-color-border);margin:0 4px}.top-actions{margin-left:auto;display:flex;align-items:center;gap:16px;color:var(--ant-color-text-secondary)}.top-actions>span{font-size:var(--ant-font-size);position:relative}.top-actions i{position:absolute;width:5px;height:5px;background:var(--ant-color-error);border-radius:50%;right:-2px;top:-2px}.top-actions small{font-size:var(--ant-font-size-sm);line-height:1.2}.avatar{width:23px;height:23px;border-radius:50%;background:var(--ant-color-primary-bg);color:var(--ant-color-primary);display:grid!important;place-items:center;font-size:var(--ant-font-size-sm)!important}.site-switcher{margin:4px 8px 4px;height:30px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);padding:0 8px;width:calc(100% - 18px)}.site-switcher:focus{outline:1px solid var(--ant-color-primary)}.side-nav{position:fixed;z-index:35;top:56px;bottom:0;left:0;width:190px;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);display:flex;flex-direction:column;padding:0}.side-title{height:48px;padding:16px 16px 0;font-weight:600;font-size:var(--ant-font-size)}.side-title span{float:right;color:var(--ant-color-text-secondary)}.side-nav>button{height:41px;border:0;background:transparent;text-align:left;padding:0 16px;display:flex;align-items:center;gap:12px;border-left:3px solid transparent;font-size:var(--ant-font-size)}.side-nav>button:hover{background:var(--ant-color-fill-quaternary);color:var(--ant-color-primary-hover)}.side-nav>button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);border-left-color:var(--ant-color-primary)}.side-nav>button b{width:18px;text-align:center}.side-nav>button em{font-style:normal;width:21px;height:21px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;place-items:center;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container)}.side-nav>button.active em{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-bg-container)}.side-nav>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);padding:20px 16px 8px;text-transform:uppercase;letter-spacing:.8px}.side-help{position:relative;margin:auto 12px 12px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:12px;display:flex;flex-direction:column;gap:4px;font-size:var(--ant-font-size-sm)}.side-help span{color:var(--ant-color-text-secondary)}
-.model-trigger{display:flex;align-items:center;justify-content:space-between;cursor:pointer;color:var(--ant-color-text-secondary)}
-.model-trigger:hover{color:var(--ant-color-primary-hover)}
-.model-trigger .caret{font-size:var(--ant-font-size-sm);margin-left:4px}
-.model-backdrop{position:fixed;inset:0;z-index:60}
-.model-popover{position:absolute;left:0;right:0;bottom:calc(100% + 6px);z-index:61;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:0 8px 22px color-mix(in srgb,var(--ant-color-text-base) 13%,transparent);padding:8px;display:flex;flex-direction:column;gap:4px}
-.model-popover-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
-.model-popover-head>span{font-weight:600;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}
-.model-popover-head>small{color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-sm)}
-.model-row{display:flex;align-items:center;gap:8px;padding:4px 8px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);font-size:var(--ant-font-size-sm);cursor:pointer}
-.model-row input[type=radio]{margin:0;accent-color:var(--ant-color-primary)}
-.model-row .model-name{font-weight:600;color:var(--ant-color-text-base);flex:1}
-.model-row .model-tag{color:var(--ant-color-success);font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-success-bg);padding:4px 4px;border-radius:var(--ant-border-radius);white-space:nowrap}
-.model-row .model-rate{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);min-width:34px;text-align:right}
-.model-row.disabled{opacity:.45;cursor:not-allowed;background:var(--ant-color-bg-layout)}
-.model-row.disabled .model-tag{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}
-.model-row:not(.disabled):hover{border-color:var(--ant-color-primary-bg);background:var(--ant-color-bg-layout)}.workspace{min-height:calc(100vh - 56px);padding-bottom:64px}.crumb{height:34px;padding:12px 20px 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.crumb b{color:var(--ant-color-primary)}.page-heading{min-height:60px;padding:4px 20px 12px;display:flex;align-items:center;justify-content:space-between}.page-heading h1{font-size:var(--ant-font-size-heading-4);margin:0 0 4px}.page-heading p{margin:0;color:var(--ant-color-text-secondary)}.button{border:1px solid var(--ant-color-primary-active);background:var(--ant-color-primary-active);color:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);min-width:92px;height:31px;padding:0 16px;margin-left:8px}.button:hover{background:var(--ant-color-primary-hover)}.button.secondary{background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);border-color:var(--ant-color-border)}.button.secondary:hover{border-color:var(--ant-color-primary);color:var(--ant-color-primary)}.button:disabled{opacity:.55;cursor:not-allowed}.steps{height:70px;background:var(--ant-color-bg-container);border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);display:grid;grid-template-columns:repeat(4,1fr);padding:0 24px}.steps button{border:0;background:transparent;display:flex;align-items:center;position:relative;text-align:left;color:var(--ant-color-text-secondary);padding:0 20px}.steps button>i{font-style:normal;width:29px;height:29px;border-radius:50%;border:1px solid var(--ant-color-border);display:grid;place-items:center;margin-right:12px;background:var(--ant-color-bg-container)}.steps button span{display:flex;flex-direction:column;gap:4px}.steps button small{font-size:var(--ant-font-size-sm)}.steps button>b{position:absolute;right:0;font-size:var(--ant-font-size-heading-3);color:var(--ant-color-text-disabled)}.steps button.active{color:var(--ant-color-text-base)}.steps button.active>i{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}.steps button.done>i{background:var(--ant-color-success);border-color:var(--ant-color-success);color:var(--ant-color-bg-container)}.steps button.locked{cursor:default}.content{padding:16px 20px}.panel{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);margin-bottom:16px}.panel-head{min-height:43px;border-bottom:1px solid var(--ant-color-border);padding:8px 12px;display:flex;justify-content:space-between;align-items:center}.panel-head>strong{font-size:var(--ant-font-size)}.panel-head .button{height:28px}.panel-body{padding:12px}.summary-strip{display:grid;grid-template-columns:repeat(5,1fr);height:64px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);margin-bottom:16px}.summary-strip div{display:flex;align-items:center;justify-content:center;gap:8px;border-right:1px solid var(--ant-color-border)}.summary-strip div:last-child{border:0}.summary-strip b{font-size:var(--ant-font-size-heading-3)}.summary-strip span{color:var(--ant-color-text-secondary)}.filterbar{height:45px;display:flex;align-items:center;gap:8px;padding-bottom:12px;border-bottom:1px solid var(--ant-color-border)}.filterbar>label{width:205px;height:30px;border:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 8px;color:var(--ant-color-text-secondary)}.filterbar input{border:0;outline:0;width:100%;padding-left:8px}.filterbar select,.preview-toolbar select{height:30px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 24px 0 8px}.filterbar>span{flex:1}.view-switch{display:flex}.view-switch button,.icon-button{width:31px;height:29px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.view-switch button.active{background:var(--ant-color-primary);color:var(--ant-color-bg-container);border-color:var(--ant-color-primary)}.icon-button{border:0;font-size:var(--ant-font-size)}.data-table{width:100%;border-collapse:collapse}.data-table th{height:35px;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);text-align:left;font-size:var(--ant-font-size-sm);font-weight:600;border-bottom:1px solid var(--ant-color-border);padding:0 12px}.data-table td{height:48px;border-bottom:1px solid var(--ant-color-border);padding:8px 12px;font-size:var(--ant-font-size-sm)}.data-table tr:hover td{background:var(--ant-color-bg-layout)}.link{border:0;background:transparent;color:var(--ant-color-primary);padding:0}.site-icon{display:inline-grid;place-items:center;width:30px;height:30px;margin-right:8px;color:var(--ant-color-primary);border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);font-size:var(--ant-font-size);vertical-align:middle}.site-icon.big{width:40px;height:40px;font-size:var(--ant-font-size-heading-3)}.status{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm);line-height:1.2;border:1px solid}.status.ok{color:var(--ant-color-success);background:var(--ant-color-bg-layout);border-color:var(--ant-color-success)}.status.warn{color:var(--ant-color-error);background:var(--ant-color-primary-bg);border-color:var(--ant-color-error-bg)}.status.bad{color:var(--ant-color-error);background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}.status.muted{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}.dots,.trash{border:0;background:transparent;color:var(--ant-color-text-secondary)}.pagination{height:46px;display:flex;justify-content:flex-end;align-items:center;gap:8px;padding-top:12px}.pagination span{margin-right:12px;color:var(--ant-color-text-secondary)}.pagination button{width:28px;height:28px;border:0;background:var(--ant-color-bg-container)}.pagination button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.pagination select{border:0;background:var(--ant-color-bg-container)}.project-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:12px 0}.project-grid article{border:1px solid var(--ant-color-border);padding:16px;cursor:pointer}.project-grid article:hover{border-color:var(--ant-color-primary);box-shadow:none}.project-grid article>div{display:flex;justify-content:space-between}.project-grid h3{margin:12px 0 4px}.project-grid p,.project-grid small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.project-grid section{display:flex;gap:20px;border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);padding:12px 0;margin:12px 0}.project-grid section span{display:flex;flex-direction:column}.project-grid section b{font-size:var(--ant-font-size)}.two-column{display:grid;grid-template-columns:1fr 340px;gap:16px}.two-column.equal{grid-template-columns:1fr 1fr}.basic-layout>.panel>.panel-body{padding:16px}.stack{display:flex;flex-direction:column}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.form-grid>label{display:flex;flex-direction:column;gap:8px;font-size:var(--ant-font-size-sm);font-weight:600}.form-grid>label>input,.form-grid>label>select,.form-grid>label>textarea{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:8px;outline:none;font-weight:400}.form-grid>label>input:focus,.form-grid>label>select:focus,.form-grid>label>textarea:focus{border-color:var(--ant-color-primary)}.form-grid>label>input,.form-grid>label>select{height:34px}.form-grid>label>textarea{min-height:80px;resize:vertical;line-height:1.6}.field{display:flex;flex-direction:column;gap:8px}.field>span{font-size:var(--ant-font-size-sm);font-weight:600}.field>span i{color:red;font-style:normal;margin-right:4px}.field input,.field select,.field textarea{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:8px;outline:none}.field input,.field select{height:34px}.field textarea{min-height:130px;resize:vertical;line-height:1.6}.field textarea.small{min-height:80px}.field small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.form-grid .field:nth-child(n+3){grid-column:1/-1}.upload{height:90px;border:1px dashed var(--ant-color-border);background:var(--ant-color-bg-layout);display:flex;flex-direction:column;justify-content:center;align-items:center;color:var(--ant-color-text-secondary);gap:4px}.upload b{color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-4)}.ai-box{text-align:center;padding:12px}.ai-box>span{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid);display:grid;place-items:center;margin:auto;font-weight:600}.ai-box h3{font-size:var(--ant-font-size);margin:12px}.ai-box p{color:var(--ant-color-text-secondary);line-height:1.7;text-align:left}.meter{height:7px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);overflow:hidden}.meter i{display:block;height:100%;background:linear-gradient(90deg,var(--ant-color-primary),var(--ant-color-success));transition:width .35s}.tips{padding-left:24px;line-height:2;color:var(--ant-color-text-secondary)}.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}.kpi-row>div{height:75px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:12px 16px;position:relative}.kpi-row span{display:block;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm);margin-bottom:8px}.kpi-row b{font-size:var(--ant-font-size-heading-3)}.kpi-row small{margin-left:4px;color:var(--ant-color-text-secondary)}.kpi-row .status{margin-top:8px}.editable td{padding:8px}.editable input,.editable select{height:29px;border:1px solid var(--ant-color-border);width:100%;padding:0 8px;background:var(--ant-color-bg-container)}.editable .number{width:65px}.tree{line-height:2.2}.tree .l1{padding-left:24px}.tree .l2{padding-left:52px;color:var(--ant-color-text-secondary)}.tree span{border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);padding:4px 8px;font-size:var(--ant-font-size-sm)}.file-card{display:flex;align-items:center;gap:12px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);padding:12px}.file-card .excel{width:36px;height:42px;background:var(--ant-color-success);color:var(--ant-color-bg-container);display:grid;place-items:center;font-weight:600}.file-card>div{flex:1}.file-card p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.checks{display:flex;gap:20px;color:var(--ant-color-success);padding:12px 4px 0;font-size:var(--ant-font-size-sm)}.tabs{height:46px;border-bottom:1px solid var(--ant-color-border);display:flex;gap:8px;margin:-12px -12px 0;padding-left:12px}.tabs button{padding:0 20px;border:0;border-bottom:2px solid transparent;background:var(--ant-color-bg-container)}.tabs button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.tabs i{font-style:normal;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);padding:4px 8px;margin-left:4px}.section-toolbar{display:flex;justify-content:space-between;align-items:center;padding:16px 4px}.section-toolbar p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.mu-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.mu-grid article{border:1px solid var(--ant-color-border);display:grid;grid-template-columns:42% 58%;min-height:128px;cursor:pointer}.mu-grid article.selected{border:2px solid var(--ant-color-primary);background:var(--ant-color-bg-layout)}.mu-thumb{margin:8px;background:var(--ant-color-bg-layout);display:grid;place-items:center;position:relative;overflow:hidden}.mu-thumb:after{content:"";width:72%;height:40%;border:1px solid var(--ant-color-primary-bg);background:var(--ant-color-bg-container);box-shadow:none}.mu-thumb b{position:absolute;z-index:2;color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-3)}.mu-grid article>div:last-child{padding:12px 12px 8px 0}.mu-grid header{display:flex;gap:8px;align-items:center}.mu-grid h3{font-size:var(--ant-font-size-sm);margin:0}.mu-grid p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.5;min-height:32px}.mu-grid button{border:0;background:transparent;color:var(--ant-color-primary);padding:0}.blue{color:var(--ant-color-primary)}.type-tag{background:var(--ant-color-bg-layout);padding:4px 8px;border-radius:var(--ant-border-radius)}.wide-cell{min-width:300px}.menu-builder{display:grid;grid-template-columns:230px 1fr 240px;gap:16px;background:var(--ant-color-bg-layout);padding:16px}.page-pool,.menu-tree,.menu-preview{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:12px}.page-pool p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.page-pool div{border:1px solid var(--ant-color-border);padding:12px;margin-top:8px;background:var(--ant-color-bg-layout)}.menu-tree section{border:1px solid var(--ant-color-border);margin-bottom:8px;padding:12px}.menu-tree section>span{float:right}.menu-tree section>div{margin:8px 0 0 16px;padding:8px;border-top:1px solid var(--ant-color-border)}.menu-tree i{font-style:normal;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);color:var(--ant-color-primary);padding:4px;margin-right:8px}.menu-tree i.cube{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}.menu-tree>button{border:1px dashed var(--ant-color-border);background:var(--ant-color-bg-layout);width:100%;height:34px}.menu-preview aside{background:var(--ant-color-bg-layout);min-height:250px;display:flex;flex-direction:column;padding-top:12px}.menu-preview strong{padding:12px}.menu-preview button{border:0;background:transparent;text-align:left;padding:12px}.menu-preview button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.generate-layout{display:grid;grid-template-columns:1.1fr .9fr;gap:16px}.confirm-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.confirm-grid div{border:1px solid var(--ant-color-border);padding:12px;display:flex;flex-direction:column;gap:4px;position:relative}.confirm-grid span,.confirm-grid small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.confirm-grid button{position:absolute;right:10px;top:10px;border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.option-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}.option-list label{display:flex;align-items:center;gap:8px}.task-panel{height:100%}.overall{background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-primary);padding:12px}.overall>div:first-child{display:flex;justify-content:space-between;margin-bottom:8px}.overall p{margin:8px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.task-list{padding:12px 0}.task-list>div{display:flex;align-items:center;border-bottom:1px solid var(--ant-color-border);padding:8px 4px}.task-list>div>i{font-style:normal;width:25px;height:25px;border-radius:50%;border:1px solid var(--ant-color-border);display:grid;place-items:center;margin-right:8px}.task-list>div>i.done{background:var(--ant-color-success);border-color:var(--ant-color-success);color:var(--ant-color-bg-container)}.task-list>div>i.spin{color:var(--ant-color-primary)}.task-list>div>span{display:flex;flex:1;flex-direction:column;gap:4px}.task-list small{color:var(--ant-color-text-secondary)}.task-panel>.panel-body>.button{width:100%;margin:4px 0}.result-header{display:flex;align-items:center;justify-content:space-between;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:12px;margin-bottom:16px}.result-header>div{display:flex;align-items:center;gap:12px}.result-header h2{margin:0 0 4px}.result-header p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.result-layout{display:grid;grid-template-columns:1fr 280px;gap:16px}.result-pages{display:grid;grid-template-columns:1fr;gap:12px}.result-pages article{border:1px solid var(--ant-color-border)}.result-pages article>div:last-child{padding:12px}.result-pages article header{display:flex;justify-content:space-between}.result-pages h3{font-size:var(--ant-font-size-sm);margin:12px 0 4px}.result-pages p,.result-pages small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.result-pages footer{display:flex;margin-top:8px}.result-pages footer .button{margin:0 4px 0 0;min-width:68px;height:27px}.dashboard-thumb{height:115px;background:var(--ant-color-bg-layout);position:relative;padding:12px 12px 12px 36px}.dashboard-thumb>i{position:absolute;left:0;top:0;bottom:0;width:25px;background:var(--ant-color-fill-secondary)}.dashboard-thumb>header{display:block;height:10px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}.dashboard-thumb>section{display:flex;gap:4px;margin-top:4px}.dashboard-thumb>section b{height:22px;flex:1;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-primary)}.dashboard-thumb>footer{position:absolute;left:35px;right:46px;bottom:10px;height:50px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:flex;align-items:flex-end;gap:4px;padding:8px}.dashboard-thumb>footer em{flex:1;background:var(--ant-color-primary);height:45%}.dashboard-thumb>footer em:nth-child(2){height:75%}.dashboard-thumb>footer em:nth-child(4){height:92%}.dashboard-thumb>span{position:absolute;right:9px;bottom:10px;width:31px;height:50px;background:conic-gradient(var(--ant-color-success) 0 72%,var(--ant-color-fill-secondary) 72%)}.dashboard-thumb-live{position:relative;min-height:115px;background:var(--ant-color-bg-layout);border-bottom:1px solid var(--ant-color-border);overflow:hidden}.dashboard-thumb-live>div{position:relative}.thumb-empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}result-menu div{display:flex;justify-content:space-between;border-bottom:1px solid var(--ant-color-border);padding:12px 4px}.result-menu span{background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius);padding:4px 8px}.text-button{border:0;background:transparent;color:var(--ant-color-primary);margin-top:12px}.score{display:flex;align-items:center;justify-content:center;gap:12px;padding:12px}.score b{width:55px;height:55px;border-radius:50%;border:5px solid var(--ant-color-success);display:grid;place-items:center;font-size:var(--ant-font-size)}.score span{color:var(--ant-color-success)}.validation{padding-left:12px;list-style:none;line-height:2;color:var(--ant-color-success)}.preview-shell{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);margin:-4px}.preview-toolbar{height:45px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--ant-color-border)}.preview-toolbar>div{display:flex;align-items:center;gap:8px}.preview-toolbar button{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);height:29px;padding:0 12px}.preview-main{display:grid;grid-template-columns:1fr 300px;height:655px}.cube-canvas{position:relative;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);overflow:hidden}.cube-top{position:absolute;left:0;right:0;top:0;height:32px;background:var(--ant-color-bg-container);border-top:3px solid var(--ant-color-primary-active);border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 12px;gap:16px}.cube-top b{color:var(--ant-color-primary)}.cube-top i{margin-left:auto;font-style:normal;color:var(--ant-color-text-secondary)}.cube-side{position:absolute;left:0;top:32px;bottom:0;width:130px;background:var(--ant-color-bg-layout);display:flex;flex-direction:column}.cube-side strong{padding:16px 12px}.cube-side button{height:37px;border:0;background:transparent;text-align:left;padding-left:12px}.cube-side button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.cube-content{position:absolute;left:130px;top:32px;right:0;bottom:0;padding:12px}.cube-content>header{display:flex;justify-content:space-between;align-items:center}.cube-content>header h2{font-size:var(--ant-font-size);margin:4px 0 8px}.cube-content>header span,.cube-content>header small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.cube-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.cube-kpis>div{height:78px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px;position:relative;display:flex;flex-direction:column}.cube-kpis span{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.cube-kpis b{font-size:var(--ant-font-size-heading-4);margin:4px 0}.cube-kpis small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.spark{position:absolute;right:9px;bottom:14px;width:42px;height:18px;border-top:3px solid var(--ant-color-primary);transform:skewY(-12deg);border-radius:50%}.spark.s1{transform:skewY(8deg)}.spark.s2{border-color:var(--ant-color-error)}.cube-grid{display:grid;grid-template-columns:1.7fr .8fr;gap:8px;margin-top:8px}.cube-grid>section{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:12px}.cube-grid h3{font-size:var(--ant-font-size-sm);margin:0 0 8px}.cube-grid h3 span,.cube-grid h3 button{float:right;border:0;background:transparent;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.line-chart{height:175px;position:relative}.line-chart>div{position:absolute;inset:0 0 20px;background:repeating-linear-gradient(to bottom,var(--ant-color-bg-container) 0,var(--ant-color-bg-container) 29px,var(--ant-color-fill-secondary) 30px)}.line-chart svg{position:absolute;left:0;right:0;top:0;height:145px;width:100%}.line-chart footer{position:absolute;left:0;right:0;bottom:0;display:flex;justify-content:space-between;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.donut{width:115px;height:115px;border-radius:50%;background:conic-gradient(var(--ant-color-success) 0 80%,var(--ant-color-error-bg) 80% 92%,var(--ant-color-text-disabled) 92%);margin:12px auto;display:grid;place-items:center}.donut:before{content:"";position:absolute;width:72px;height:72px;background:var(--ant-color-bg-container);border-radius:50%}.donut b{z-index:1;font-size:var(--ant-font-size-heading-4);display:flex;flex-direction:column;align-items:center}.donut small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.donut-panel footer{display:flex;justify-content:space-around;font-size:var(--ant-font-size-sm)}.donut-panel footer i{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px}.green{background:var(--ant-color-success)}.orange{background:var(--ant-color-error-bg)}.gray{background:var(--ant-color-text-disabled)}.alarm-panel{grid-column:1/-1}.alarm-panel table{border-collapse:collapse;width:100%;font-size:var(--ant-font-size-sm)}.alarm-panel td{border-top:1px solid var(--ant-color-border);padding:8px}.inspector{background:var(--ant-color-bg-container);overflow:auto}.inspector>nav{height:42px;display:flex;border-bottom:1px solid var(--ant-color-border)}.inspector>nav button{flex:1;border:0;border-bottom:2px solid transparent;background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}.inspector>nav button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.inspector-body{padding:16px}.inspector-body h3{font-size:var(--ant-font-size)}.inspector-body p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.7}.inspector-body dl{display:grid;grid-template-columns:85px 1fr;border-top:1px solid var(--ant-color-border);margin:16px 0}.inspector-body dt,.inspector-body dd{margin:0;padding:8px 0;border-bottom:1px solid var(--ant-color-border);font-size:var(--ant-font-size-sm)}.inspector-body dt{color:var(--ant-color-text-secondary)}.component{padding:8px;border:1px solid var(--ant-color-border);margin-top:8px}.component span{float:right}.code-body header{text-align:right}.code-body header button{border:0;background:transparent;color:var(--ant-color-primary)}.code-body pre{font-size:var(--ant-font-size-sm);line-height:1.55;background:var(--ant-color-text-base);color:var(--ant-color-primary-bg);padding:12px;min-height:410px;overflow:auto}.inspector-body textarea{width:100%;height:120px;border:1px solid var(--ant-color-border);padding:8px;resize:none}.suggestion{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius);padding:4px 8px;margin:0 4px 8px 0;font-size:var(--ant-font-size-sm)}.inspector-body>.button{width:100%;margin:12px 0}.sticky-actions{position:fixed;z-index:20;left:232px;right:0;bottom:0;height:54px;background:var(--ant-color-bg-container);border-top:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:space-between;padding:0 20px;box-shadow:none}.toast{position:fixed;z-index:100;right:24px;top:56px;background:var(--ant-color-text-base);color:var(--ant-color-bg-container);padding:12px 20px;box-shadow:none}.toast b{color:var(--ant-color-success);margin-right:8px}.modal-backdrop{position:fixed;z-index:90;inset:0;background:color-mix(in srgb,var(--ant-color-bg-mask) 47%,transparent);display:grid;place-items:center}.modal{width:610px;max-height:78vh;background:var(--ant-color-bg-container);box-shadow:0 10px 30px color-mix(in srgb,var(--ant-color-text-base) 27%,transparent);display:flex;flex-direction:column}.modal>header{height:47px;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:space-between;padding:0 16px}.modal h2{font-size:var(--ant-font-size)}.modal>header button{border:0;background:transparent;font-size:var(--ant-font-size-heading-3);color:var(--ant-color-text-secondary)}.modal-body{padding:16px;overflow:auto;flex:1 1 auto;min-height:0}.modal>footer{border-top:1px solid var(--ant-color-border);padding:12px 16px;text-align:right}.modal-note{background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);padding:12px;margin-bottom:16px;color:var(--ant-color-primary)}.impact-title{font-weight:600;margin-bottom:8px}.impact-list{list-style:none;padding:0;border:1px solid var(--ant-color-border)}.impact-list li{display:flex;justify-content:space-between;padding:12px;border-bottom:1px solid var(--ant-color-border)}.impact-list span{color:var(--ant-color-primary)}.download-list{display:flex;flex-direction:column}.download-list label{padding:12px;border-bottom:1px solid var(--ant-color-border);display:flex;gap:8px}.download-list>div{margin-top:16px;background:var(--ant-color-bg-layout);padding:12px;display:flex;justify-content:space-between}.history-list>div{display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ant-color-border);padding:12px}.history-list i{font-style:normal;width:34px;height:34px;border-radius:50%;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center}.history-list span{display:flex;flex:1;flex-direction:column;gap:4px}.history-list small{color:var(--ant-color-text-secondary)}.history-list button{border:0;background:transparent;color:var(--ant-color-primary)}.login-page{min-height:100vh;background:linear-gradient(145deg,var(--ant-color-bg-layout),var(--ant-color-bg-container) 55%,var(--ant-color-bg-layout));border-top:4px solid var(--ant-color-primary-active);display:flex;align-items:center;justify-content:center}.login-top{position:absolute;left:18px;top:14px;display:flex;align-items:center;gap:12px}.login-top b{font-size:var(--ant-font-size)}.login-top span{border-left:1px solid var(--ant-color-border);padding-left:12px;color:var(--ant-color-text-secondary)}.login-card{width:420px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);box-shadow:none;padding:44px;text-align:center}.login-logo{font-size:var(--ant-font-size-heading-2)}.login-card h1{font-size:var(--ant-font-size-heading-3)}.login-card p{color:var(--ant-color-text-secondary);line-height:1.6}.sso-button{width:100%;height:48px;margin:24px 0 12px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;gap:12px;padding:0 12px}.sso-button b{flex:1;text-align:left}.sso-button i{font-style:normal;font-size:var(--ant-font-size);color:var(--ant-color-primary)}.login-card>small{color:var(--ant-color-text-secondary)}.login-page>footer{position:absolute;bottom:18px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-.avatar-button{border:0;padding:0}
-.design-link{margin-left:auto;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm);text-decoration:none;padding:0 12px;border-right:1px solid var(--ant-color-border)}
-.design-link:hover{text-decoration:underline}.avatar-button:hover{outline:1px solid var(--ant-color-primary)}
-.steps{grid-template-columns:repeat(5,1fr)}
-.domain-steps{grid-template-columns:repeat(7,1fr)}
-.module-switch{padding:12px 8px 8px;border-bottom:1px solid var(--ant-color-border);display:grid;gap:8px}.module-switch>button{height:47px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;text-align:left;padding:0 8px;gap:8px;font-size:var(--ant-font-size)}.module-switch>button>b{width:23px;height:23px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary)}.module-switch>button>span{display:flex;flex-direction:column;font-weight:600;gap:4px}.module-switch>button small{font-weight:400;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.module-switch>button.active{border-color:transparent;background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.module-switch>button:hover{background:var(--ant-color-fill-quaternary)}.module-switch>button.active>b{background:var(--ant-color-primary);color:var(--ant-color-bg-container)}.side-nav>.side-title{display:none}
-.domain-hero{min-height:160px;background:linear-gradient(120deg,var(--ant-color-primary),var(--ant-color-primary) 58%,var(--ant-color-primary));color:var(--ant-color-bg-container);margin-bottom:16px;padding:28px 32px;display:flex;align-items:center;justify-content:space-between;position:relative;overflow:hidden}.domain-hero:after{content:"◆";position:absolute;right:34%;top:-55px;font-size:var(--ant-font-size-heading-2);color:color-mix(in srgb,var(--ant-color-text-light-solid) 4%,transparent);transform:rotate(16deg)}.domain-hero>div:first-child{max-width:640px;z-index:1}.domain-hero .eyebrow{font-size:var(--ant-font-size-sm);letter-spacing:2px;opacity:.8}.domain-hero h2{font-size:var(--ant-font-size-heading-3);margin:8px 0}.domain-hero p{margin:0;line-height:1.7;opacity:.86}.domain-hero-stats{display:grid;grid-template-columns:1fr 1fr;gap:4px;background:color-mix(in srgb,var(--ant-color-bg-container) 22%,transparent);z-index:1}.domain-hero-stats div{width:105px;height:58px;background:color-mix(in srgb,var(--ant-color-bg-container) 8%,transparent);display:flex;flex-direction:column;align-items:center;justify-content:center}.domain-hero-stats b{font-size:var(--ant-font-size-heading-4)}.domain-hero-stats span{font-size:var(--ant-font-size-sm);opacity:.8}.domain-filter label{width:300px}.domain-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:16px 0 4px}.domain-grid article{border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-border);padding:16px;min-height:230px;background:var(--ant-color-bg-container)}.domain-grid article.water{border-top-color:var(--ant-color-primary)}.domain-grid article.gas{border-top-color:var(--ant-color-error)}.domain-grid article.energy{border-top-color:var(--ant-color-error-bg)}.domain-grid article.dc{border-top-color:var(--ant-color-primary)}.domain-grid article>header{display:flex;justify-content:space-between;align-items:center}.domain-grid article>header>span{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-primary)}.domain-grid article>header>div{display:flex;align-items:center;gap:8px}.domain-grid h3{font-size:var(--ant-font-size);margin:12px 0 8px}.domain-grid p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.65;min-height:34px}.domain-metrics{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);padding:8px 0;gap:8px}.domain-metrics span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.domain-metrics b{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base);margin-right:4px}.domain-grid footer{display:flex;justify-content:space-between;margin-top:12px}.domain-grid footer button{border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.domain-grid footer button:last-child{border:1px solid var(--ant-color-primary-bg);padding:4px 8px}.solution-parts{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.solution-parts>div{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);padding:12px;display:flex;gap:8px;min-height:80px}.solution-parts i{font-style:normal;font-size:var(--ant-font-size-heading-4);color:var(--ant-color-primary)}.solution-parts span{display:flex;flex-direction:column;gap:8px}.solution-parts small{font-size:var(--ant-font-size-sm);line-height:1.5;color:var(--ant-color-text-secondary)}
-.solution-banner{min-height:126px;border:1px solid var(--ant-color-border);background:linear-gradient(100deg,var(--ant-color-bg-layout),var(--ant-color-bg-container));display:flex;align-items:center;padding:20px;margin-bottom:12px}.solution-logo{width:72px;height:72px;background:var(--ant-color-primary);color:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);display:grid;place-items:center;font-size:var(--ant-font-size-heading-2);margin-right:20px}.solution-banner>div:nth-child(2){flex:1}.solution-banner h2{margin:4px 0;font-size:var(--ant-font-size-heading-4)}.solution-banner p{margin:0 0 8px;color:var(--ant-color-text-secondary)}.solution-banner small{margin-left:12px;color:var(--ant-color-text-secondary)}.solution-banner aside{display:flex}.solution-summary{display:grid;grid-template-columns:repeat(6,1fr);background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);margin-bottom:12px}.solution-summary>div{height:65px;border-right:1px solid var(--ant-color-border);display:flex;flex-direction:column;align-items:center;justify-content:center}.solution-summary b{font-size:var(--ant-font-size-heading-4)}.solution-summary span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin-top:4px}.detail-grid{display:grid;grid-template-columns:1fr 310px;gap:12px}.asset-template{display:grid;grid-template-columns:1fr 1.3fr;gap:16px}.asset-tree{background:var(--ant-color-bg-layout);padding:12px;display:flex;flex-direction:column;gap:12px}.asset-tree span{padding-left:16px}.asset-tree em{font-style:normal;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.template-specs{display:grid;gap:8px}.template-specs>div{border:1px solid var(--ant-color-border);padding:8px}.template-specs p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.45}.domain-page-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}.domain-page-list article{display:grid;grid-template-columns:115px 1fr;border:1px solid var(--ant-color-border);min-height:145px}.domain-page-thumb{margin:8px;background:var(--ant-color-bg-layout);position:relative;padding:28px 8px 8px;display:grid;grid-template-columns:repeat(3,1fr);gap:4px}.domain-page-thumb span{position:absolute;left:7px;top:6px;color:var(--ant-color-primary)}.domain-page-thumb i{background:var(--ant-color-primary);height:40px}.domain-page-thumb i:nth-child(2){height:62px}.domain-page-thumb i:nth-child(3){height:50px}.domain-page-list section{padding:12px 8px 8px 0}.domain-page-list section>div{display:flex;justify-content:space-between}.domain-page-list h3{font-size:var(--ant-font-size-sm);margin:8px 0 4px}.domain-page-list section>b{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.domain-page-list p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.5}.domain-page-list footer button{border:0;background:transparent;color:var(--ant-color-primary);padding:0;margin-right:12px;font-size:var(--ant-font-size-sm)}.flow-list{display:grid;gap:8px}.flow-list>div{display:flex;align-items:center;border:1px solid var(--ant-color-border);padding:8px}.flow-list i{font-style:normal;width:29px;height:29px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;margin-right:8px}.flow-list span{display:flex;flex:1;flex-direction:column;gap:4px}.flow-list small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.flow-list em{font-style:normal;font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.flow-list .future-item{border-style:dashed;opacity:.75}.solution-menu{display:flex;flex-direction:column}.solution-menu>b{padding:8px;background:var(--ant-color-bg-layout);margin-top:4px}.solution-menu>span{padding:8px 8px 8px 16px;border-bottom:1px solid var(--ant-color-border)}.solution-menu i{font-style:normal;font-size:var(--ant-font-size-sm);color:var(--ant-color-primary);margin-right:8px}.knowledge-list>div{display:flex;align-items:center;border-bottom:1px solid var(--ant-color-border);padding:8px 4px}.knowledge-list>div>i{font-style:normal;width:27px;color:var(--ant-color-primary)}.knowledge-list span{display:flex;flex:1;flex-direction:column;gap:4px}.knowledge-list small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.knowledge-list button{border:0;background:transparent;color:var(--ant-color-primary)}
-.ai-config-layout{display:grid;grid-template-columns:1fr 370px;gap:16px}.wordbuddy-card{background:var(--ant-color-text-base);color:var(--ant-color-fill-secondary);border-radius:var(--ant-border-radius);overflow:hidden;box-shadow:none}.wordbuddy-card>header{height:58px;border-bottom:1px solid var(--ant-color-border);display:flex;justify-content:space-between;align-items:center;padding:0 16px}.wordbuddy-card>header>div{display:flex;gap:8px;align-items:center}.wb-logo{width:31px;height:31px;border-radius:var(--ant-border-radius);background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));display:grid;place-items:center;font-weight:600}.wordbuddy-card header section{display:flex;flex-direction:column}.wordbuddy-card header small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin-top:4px}.wordbuddy-card header label{display:flex;align-items:center;gap:8px}.model-list{padding:8px}.model-list>button{width:100%;min-height:56px;border:0;border-radius:var(--ant-border-radius);background:transparent;color:var(--ant-color-fill-secondary);display:flex;align-items:center;padding:8px 8px;text-align:left;position:relative}.model-list>button:hover,.model-list>button.selected{background:var(--ant-color-text-base)}.model-list>button>i{font-style:normal;width:25px;color:var(--ant-color-text-disabled);font-weight:600}.model-list>button>span{display:flex;flex:1;flex-direction:column;gap:4px}.model-list>button>span>b{font-size:var(--ant-font-size-sm)}.model-list>button small{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary)}.model-list>button em{font-style:normal;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.model-list>button>strong{color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-sm);margin-right:24px}.model-list>button>u{position:absolute;right:9px;color:var(--ant-color-success);text-decoration:none;font-size:var(--ant-font-size)}.wordbuddy-card>footer{border-top:1px solid var(--ant-color-border);padding:8px}.wordbuddy-card>footer button{border:0;background:transparent;color:var(--ant-color-text-disabled)}.selected-model{display:flex;align-items:center;gap:12px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-primary);padding:12px}.selected-model>span{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-bg-container);display:grid;place-items:center}.selected-model section{flex:1}.selected-model h3{margin:4px 0;font-size:var(--ant-font-size)}.selected-model p{margin:0;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.model-facts{display:grid;grid-template-columns:110px 1fr;margin:12px 0 0}.model-facts dt,.model-facts dd{margin:0;padding:8px;border-bottom:1px solid var(--ant-color-border);font-size:var(--ant-font-size-sm)}.model-facts dt{color:var(--ant-color-text-secondary)}.strategy-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}.strategy-list label{border:1px solid var(--ant-color-border);padding:8px;display:flex;gap:8px}.strategy-list label>span{display:flex;flex-direction:column;gap:4px}.strategy-list small,.setting-row small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.setting-row{display:flex;align-items:center;border-top:1px solid var(--ant-color-border);padding:8px 4px}.setting-row>span{display:flex;flex:1;flex-direction:column;gap:4px}.context-list{display:grid;gap:8px}.context-list>span{display:flex;align-items:center;background:var(--ant-color-bg-layout);padding:8px}.context-list i{font-style:normal;width:25px;color:var(--ant-color-primary)}.context-list b{flex:1;font-size:var(--ant-font-size-sm)}.context-list em{font-style:normal;color:var(--ant-color-success);font-size:var(--ant-font-size-sm)}.token-estimate{display:flex;flex-direction:column;border-top:1px solid var(--ant-color-border);margin-top:12px;padding-top:12px;gap:4px}.token-estimate span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
-@media(max-width:1100px){.app-shell{padding-left:44px}.side-nav{display:none}.workspace{min-width:900px}.sticky-actions{left:42px}.top-actions small{display:none}.result-layout{grid-template-columns:1fr}.preview-main{grid-template-columns:1fr 260px}.mu-grid{grid-template-columns:repeat(2,1fr)}}
-
-/* 右下角浮动 AI 会话 */
-.ai-fab{position:fixed;right:22px;bottom:22px;z-index:80;width:54px;height:54px;border-radius:50%;border:0;background:transparent;box-shadow:0 8px 22px color-mix(in srgb,var(--ant-color-primary) 33%,transparent);display:grid;place-items:center;cursor:pointer;touch-action:none;user-select:none}.ai-fab-icon{width:54px;height:54px;border-radius:50%;object-fit:cover;-webkit-user-drag:none;user-drag:none;user-select:none;pointer-events:none}
-.design-doc-link{display:block;margin-top:auto;margin-left:12px;margin-right:12px;margin-bottom:12px;padding:4px 8px;font-size:var(--ant-font-size-sm);line-height:1.4;font-weight:600;text-align:center;color:var(--ant-color-text-light-solid);text-decoration:none;border-radius:var(--ant-border-radius);background:linear-gradient(135deg,var(--ant-color-primary) 0%,var(--ant-color-primary-bg) 100%);box-shadow:none;opacity:.95;transition:opacity .15s}
-.design-doc-link:hover{opacity:1;color:var(--ant-color-text-light-solid)}
-.ai-fab:active{cursor:grabbing}
-.ai-fab:hover:not(:active){transform:translateY(-1px);box-shadow:0 12px 26px color-mix(in srgb,var(--ant-color-primary-hover) 40%,transparent)}
-.ai-dialog{position:fixed;right:22px;bottom:88px;z-index:81;width:25vw;min-width:320px;height:70vh;min-height:420px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:0 18px 48px color-mix(in srgb,var(--ant-color-text-base) 20%,transparent);display:flex;flex-direction:column;overflow:hidden}
-.ai-dialog header{display:flex;align-items:center;gap:8px;padding:12px 12px;border-bottom:1px solid var(--ant-color-border);background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid)}
-.ai-dialog .ai-dialog-logo{width:28px;height:28px;border-radius:var(--ant-border-radius);background:color-mix(in srgb,var(--ant-color-bg-container) 20%,transparent);display:grid;place-items:center;font-size:var(--ant-font-size)}
-.ai-dialog .ai-dialog-title{flex:1;display:flex;flex-direction:column;line-height:1.25}
-.ai-dialog .ai-dialog-title b{font-size:var(--ant-font-size-sm)}
-.ai-dialog .ai-dialog-title small{font-size:var(--ant-font-size-sm);opacity:.85}
-.ai-dialog .ai-close{border:0;background:transparent;color:var(--ant-color-text-light-solid);font-size:var(--ant-font-size-heading-4);line-height:1;width:28px;height:28px;border-radius:var(--ant-border-radius)}
-.ai-dialog .ai-close:hover{background:color-mix(in srgb,var(--ant-color-bg-container) 20%,transparent)}
-.ai-messages{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:12px;background:var(--ant-color-bg-layout)}
-.ai-welcome{width:100%;margin:auto 0;display:flex;flex-direction:column;gap:12px;color:var(--ant-color-text-base)}.ai-welcome-intro{display:flex;align-items:center;gap:12px;padding-bottom:4px}.ai-welcome-intro>span{width:32px;height:32px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;flex:0 0 auto}.ai-welcome-intro>div{min-width:0}.ai-welcome-intro b{display:block;font-size:var(--ant-font-size);line-height:1.4}.ai-welcome-intro p{margin:4px 0 0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.5}.ai-capability-list{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.ai-capability-list>div{min-height:52px;padding:8px 12px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--ant-color-border)}.ai-capability-list>div:last-child{border-bottom:0}.ai-capability-list i{width:22px;height:22px;border-radius:50%;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;flex:0 0 auto;font-size:var(--ant-font-size-sm);font-style:normal;font-weight:600}.ai-capability-list span{display:flex;flex-direction:column;gap:4px;min-width:0}.ai-capability-list b{font-size:var(--ant-font-size-sm);line-height:1.35;font-weight:600}.ai-capability-list small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.35}.ai-welcome-tip{padding:8px 12px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.5}.ai-welcome-tip b{color:var(--ant-color-text-base)}
-.ai-bubble{display:flex;gap:8px;align-items:flex-start;max-width:88%}
-.ai-bubble .ai-avatar{width:26px;height:26px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;font-size:var(--ant-font-size-sm);background:var(--ant-color-fill-secondary);color:var(--ant-color-primary)}
-.ai-bubble.me{flex-direction:row-reverse;align-self:flex-end}
-.ai-bubble.me .ai-avatar{background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid)}
-.ai-bubble .ai-text{white-space:pre-wrap;word-break:break-word;font-size:var(--ant-font-size-sm);line-height:1.6;padding:8px 12px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}
-.ai-bubble.me .ai-text{background:var(--ant-color-primary);color:var(--ant-color-text-light-solid);border-color:var(--ant-color-primary)}
-.ai-dialog footer{display:flex;gap:8px;padding:8px;border-top:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}
-.ai-dialog .ai-input{flex:1;resize:none;height:42px;max-height:120px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 12px;font-size:var(--ant-font-size-sm);line-height:1.4;outline:none}
-.ai-dialog .ai-input:focus{border-color:var(--ant-color-primary-bg)}
-.ai-dialog .ai-send{border:0;background:var(--ant-color-primary);color:var(--ant-color-text-light-solid);border-radius:var(--ant-border-radius);padding:0 16px;font-size:var(--ant-font-size-sm);align-self:flex-end;height:42px}
-.ai-dialog .ai-send:disabled{opacity:.5;cursor:not-allowed}
-.ai-dialog .ai-send:not(:disabled):hover{background:var(--ant-color-primary-hover)}
-
-/* ===== 新建站点向导:基本属性 ===== */
-.domain-select{display:flex;align-items:center;gap:8px}
-.domain-select input{flex:1;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container)}
-.domain-select input:focus{border-color:var(--ant-color-primary);outline:none}
-.domain-add{display:flex;align-items:center;gap:8px;margin-top:8px}
-.domain-add input{flex:1;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container)}
-.domain-add input:focus{border-color:var(--ant-color-primary);outline:none}
-.file-list{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:8px}
-.file-list li{display:flex;align-items:center;gap:8px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 8px;font-size:var(--ant-font-size-sm)}
-.file-list a{color:var(--ant-color-primary);text-decoration:none}
-.file-list a:hover{text-decoration:underline}
-.file-list small{color:var(--ant-color-text-secondary);margin-left:auto}
-.invalid{border-color:var(--ant-color-error)!important;background:var(--ant-color-bg-layout)!important}
-.hint{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);margin:0 0 12px;line-height:1.6}
-
-/* ===== 可编辑表格内的输入控件 ===== */
-.data-table input,.data-table select,.data-table textarea{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:28px;padding:0 8px;width:100%;background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}
-.data-table input:focus,.data-table select:focus,.data-table textarea:focus{border-color:var(--ant-color-primary);outline:none}
-.data-table .number{width:100%;text-align:right}
-.data-table input::placeholder{color:var(--ant-color-text-disabled)}
-/* 站点列表:列宽可手动拖动调整(拖拽表头右缘的分隔条) */
-.data-table.resizable-cols{table-layout:fixed;width:100%}
-.data-table.resizable-cols th{position:relative}
-.data-table .col-resizer{position:absolute;top:0;right:0;width:6px;height:100%;cursor:col-resize;z-index:2;user-select:none}
-.data-table .col-resizer::after{content:"";position:absolute;top:0;right:2px;width:2px;height:100%;background:transparent}
-.data-table .col-resizer:hover::after,.data-table .col-resizer.active::after{background:var(--ant-color-primary)}
-/* 可编辑表格使用固定列宽,避免输入框在自动布局下被压窄(设备类型框变窄的 BUG) */
-.data-table.editable{table-layout:fixed}
-.data-table.editable th:nth-child(1){width:40%}
-.data-table.editable th:nth-child(2){width:92px}
-.data-table.editable th:nth-child(3){width:56px}
-.data-table.editable th:nth-child(4){width:auto}
-.data-table.editable th:nth-child(5){width:48px}
-.data-table.editable input,.data-table.editable select,.data-table.editable textarea{min-width:0;width:100%}
-.data-table.editable .trash{border:0;background:transparent;color:var(--ant-color-error);font-size:var(--ant-font-size);cursor:pointer;line-height:1}
-.data-table.editable .trash:hover{color:var(--ant-color-error)}
-.data-table.editable .asset-eye-btn{border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size);cursor:pointer;line-height:1;padding:0;display:inline-flex;align-items:center}
-.data-table.editable .asset-eye-btn.disabled{color:var(--ant-color-text-disabled);cursor:default}
-
-/* ===== 领域方案库:列表筛选栏 + 可排序列头 ===== */
-.filter-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px}
-.filter-bar .filter-search{flex:1 1 220px;min-width:180px;height:30px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container)}
-.filter-bar .filter-search:focus{border-color:var(--ant-color-primary);outline:none}
-.filter-bar select{height:30px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 8px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary)}
-.filter-bar select:focus{border-color:var(--ant-color-primary);outline:none}
-.filter-bar .filter-count{margin-left:auto;font-size:var(--ant-font-size-sm)}
-.data-table th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
-.data-table th.sortable:hover{color:var(--ant-color-primary)}
-.data-table th.sortable .sort-ind{color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}
-
-/* ===== Assets 资产配置 ===== */
-.empty-row td{background:var(--ant-color-bg-layout)}
-.empty-row input{color:var(--ant-color-text-disabled);background:var(--ant-color-bg-layout)}
-.empty-row input::placeholder{color:var(--ant-color-text-disabled)}
-.asset-tree-preview{background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:12px 16px;font-size:var(--ant-font-size-sm)}
-.tree-root{font-weight:600;color:var(--ant-color-text-secondary);margin-bottom:8px;display:flex;align-items:center;gap:4px}
-.tree-branch{margin:4px 0 4px 16px;padding-left:12px;border-left:1px dashed var(--ant-color-border)}
-.tree-node{display:flex;align-items:center;gap:8px;font-weight:600;color:var(--ant-color-text-secondary)}
-.site-structure-title{margin-top:20px}
-.site-structure{grid-column:2;margin-top:8px}
-.site-structure .tree-branch{margin:4px 0 4px 0;padding-left:12px;border-left:1px solid var(--ant-color-border)}
-.site-structure .tree-node{font-weight:400;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}
-.site-structure .tree-node.current-site{color:var(--ant-color-primary);background:var(--ant-color-primary-bg);border-radius:var(--ant-border-radius);padding:4px 4px;margin-left:-4px}
-.tree-count{background:var(--ant-color-primary-bg);color:var(--ant-color-primary-active);border-radius:var(--ant-border-radius);padding:0 8px;font-size:var(--ant-font-size-sm);font-weight:400}
-.tree-leaves{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.7;margin:4px 0 0 20px;word-break:break-all}
-
-/* ===== Functions 功能与菜单 ===== */
-.mu-tree{display:flex;flex-direction:column;gap:16px}
-.mu-group>b{display:block;color:var(--ant-color-primary-active);font-size:var(--ant-font-size-sm);margin-bottom:8px}
-.mu-items{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px 20px;border-left:1px dashed var(--ant-color-border);margin-left:8px}
-.mu-items label{display:flex;align-items:flex-start;gap:8px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 8px;background:var(--ant-color-bg-container);cursor:pointer;font-size:var(--ant-font-size-sm)}
-.mu-items label:hover{border-color:var(--ant-color-primary-bg);background:var(--ant-color-bg-layout)}
-.mu-items label.disabled{opacity:.5;cursor:not-allowed;background:var(--ant-color-bg-layout)}
-.mu-items label input{margin-top:4px;accent-color:var(--ant-color-primary)}
-.mu-items label span{display:flex;flex-direction:column;line-height:1.3}
-.mu-items label small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-.two-column>div:first-child{min-width:0}
-.cube-editor{overflow-x:auto}
-.cube-editor table{width:100%}
-.cube-editor input{width:100%}
-.cube-editor .cube-editor-table th:nth-child(1){width:120px}
-.cube-editor .cube-editor-table th:nth-child(2){width:auto}
-.cube-editor .cube-editor-table th:nth-child(3){width:140px}
-.cube-editor .cube-editor-table th:nth-child(4){width:200px}
-.cube-editor .cube-editor-table td:nth-child(3){white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.cube-actions{display:flex;gap:4px;white-space:nowrap}
-.cube-actions button{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);height:26px;padding:0 8px;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
-.cube-actions button:hover{border-color:var(--ant-color-primary);color:var(--ant-color-primary)}
-.cube-actions .trash{border-color:var(--ant-color-error-bg);color:var(--ant-color-error)}
-.cube-actions .trash:hover{background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}
-/* 醒目 AI 操作按钮(渐变蓝紫) */
-.btn-ai{display:inline-flex;align-items:center;gap:4px;background:linear-gradient(135deg,var(--ant-color-primary),var(--ant-color-primary));color:var(--ant-color-text-light-solid);border:1px solid transparent;border-radius:var(--ant-border-radius);height:26px;padding:0 12px;font-size:var(--ant-font-size-sm);font-weight:600;cursor:pointer;box-shadow:none;white-space:nowrap}
-.btn-ai:hover{filter:brightness(1.08)}
-.btn-ai:active{transform:translateY(1px)}
-.btn-ai:disabled{opacity:.65;cursor:default;box-shadow:none}
-.cube-actions .btn-ai{height:26px}
-.menu-editor{display:flex;flex-direction:column;gap:12px}
-.menu-row{display:grid;grid-template-columns:1.4fr 1fr 1.4fr auto;gap:8px;align-items:center}
-.menu-row input,.menu-row select{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container);width:100%}
-.menu-row .trash{border:1px solid var(--ant-color-error-bg);background:var(--ant-color-bg-container);color:var(--ant-color-error);border-radius:var(--ant-border-radius);height:30px;width:34px;font-size:var(--ant-font-size)}
-.menu-row .trash:hover{background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}
-.menu-preview .menu-tree{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}
-.menu-preview .menu-root{padding:12px 12px;font-weight:600;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}
-.menu-preview .menu-section{padding:12px 12px;border-bottom:1px solid var(--ant-color-border)}
-.menu-preview .menu-section:last-child{border-bottom:0}
-.menu-preview .menu-section-title{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary-active);font-weight:600;margin-bottom:8px}
-.menu-preview .menu-leaf{padding:4px 0 4px 16px;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);border-left:2px solid var(--ant-color-border);margin:4px 0 4px 4px}
-.menu-preview .menu-leaf.cube{border-left-color:var(--ant-color-primary)}
-.menu-preview .menu-leaf.muted{color:var(--ant-color-text-disabled);border-left-color:var(--ant-color-border)}
-
-/* ===== Generate 预览 line 面板 ===== */
-.line-panel{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:12px 12px;display:flex;flex-direction:column;gap:8px}
-.line-panel h3{margin:0;font-size:var(--ant-font-size-sm);display:flex;align-items:center;justify-content:space-between}
-.line-panel h3 span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);font-weight:400}
-
-/* ===== 生成提示词查看 ===== */
-.prompt-list{display:flex;flex-direction:column;gap:8px}
-.prompt-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 12px;background:var(--ant-color-bg-container)}
-.prompt-row span{display:flex;flex-direction:column;line-height:1.35;min-width:0}
-.prompt-row span small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px}
-.prompt-row b{font-size:var(--ant-font-size-sm)}
-.prompt-view{margin:0;background:#0f1115;color:#e6edf3;border:1px solid #2a2f36;border-radius:6px;padding:14px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:60vh;overflow:auto}
-
-/* ===== 站点列表:多选与删除 ===== */
-.data-table input[type=checkbox]{display:block;width:16px;height:16px;padding:0;margin:0 auto;appearance:none;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:none;background:var(--ant-color-bg-container);cursor:pointer;vertical-align:middle}
-.data-table input[type=checkbox]:checked{border-color:var(--ant-color-primary);background-color:var(--ant-color-primary);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.2 6.6 11 12.5 4.8' fill='none' stroke='var(--ant-color-bg-container)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:center;background-repeat:no-repeat;background-size:14px 14px}
-.data-table input[type=checkbox]:focus-visible{outline:2px solid var(--ant-color-primary-bg);outline-offset:1px}
-.data-table thead th:first-child{width:34px;text-align:center}
-.link.danger{color:var(--ant-color-error)}
-.link.danger:hover{text-decoration:underline}
-.project-grid article{position:relative}
-.card-del{position:absolute;top:8px;right:8px;border:1px solid var(--ant-color-error-bg);background:var(--ant-color-bg-container);color:var(--ant-color-error);border-radius:var(--ant-border-radius);height:26px;padding:0 8px;font-size:var(--ant-font-size-sm);cursor:pointer}
-.card-del:hover{background:var(--ant-color-bg-layout);border-color:var(--ant-color-error)}
-.style-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;padding:16px 0 4px}
-.style-card{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;flex-direction:column;border-radius:var(--ant-border-radius);overflow:hidden}
-.style-card .style-thumb{position:relative;aspect-ratio:16/9;min-height:200px;background:var(--ant-color-bg-layout);display:grid;place-items:center;cursor:zoom-in;overflow:hidden}
-.style-card .style-thumb img{width:100%;height:100%;object-fit:contain}
-.style-card .style-thumb .zoom{position:absolute;right:8px;bottom:8px;background:color-mix(in srgb,var(--ant-color-bg-mask) 53%,transparent);color:var(--ant-color-text-light-solid);width:24px;height:24px;border-radius:var(--ant-border-radius);display:grid;place-items:center;font-size:var(--ant-font-size-sm)}
-.style-info{padding:12px 12px 4px}
-.style-info h3{font-size:var(--ant-font-size);margin:0}
-.style-info small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-.style-card footer{display:flex;justify-content:flex-end;gap:16px;padding:8px 12px}
-.style-lightbox{max-width:92vw;max-height:88vh}
-.style-upload-row{display:flex;gap:12px;align-items:stretch}
-.style-upload-prev{width:150px;height:120px;border:1px solid var(--ant-color-border);display:grid;place-items:center;background:var(--ant-color-bg-layout);overflow:hidden}
-.style-upload-prev img{width:100%;height:100%;object-fit:contain}
-.style-json-row{display:flex;gap:12px;align-items:flex-start}
-.style-json-row textarea{flex:1;min-height:150px;resize:vertical;font-family:var(--ant-font-family);font-size:var(--ant-font-size-sm);line-height:1.5}
-.style-json-row .upload{width:120px;height:64px;flex:0 0 auto;margin-top:4px}
-.modal>footer .spacer{flex:1}
-.style-picker{margin-top:20px;border-top:1px dashed var(--ant-color-border);padding-top:16px}
-.style-picker h4{margin:0 0 4px;font-size:var(--ant-font-size)}
-.style-picker h4 small{color:var(--ant-color-text-secondary);font-weight:400;font-size:var(--ant-font-size-sm);margin-left:8px}
-.style-pick-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px;margin-top:8px}
-.style-pick{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:8px;display:flex;flex-direction:column;gap:8px;cursor:pointer;text-align:center;color:var(--ant-color-text-secondary)}
-.style-pick:hover{border-color:var(--ant-color-primary)}
-.style-pick.active{border:2px solid var(--ant-color-primary);background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
-.style-pick-thumb{height:78px;background:var(--ant-color-bg-layout);display:grid;place-items:center;overflow:hidden}
-.style-pick-thumb img{width:100%;height:100%;object-fit:contain}
-.style-pick-thumb.none{color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-sm)}
-.style-pick span{font-size:var(--ant-font-size-sm);line-height:1.3}
-
-/* ========================================================================== */
-/* Company workspace standard — measured from the WISE-IoT service desk UI. */
-/* This final layer intentionally normalizes legacy prototype styles while the */
-/* remaining feature pages are migrated to shared company components. */
-/* ========================================================================== */
-:root{
-
-
-
-
-
-
-}
-html,body{font-family:var(--ant-font-family);font-size:var(--ant-font-size);line-height:1.5;color:var(--ant-color-text-base);background:var(--ant-color-bg-container)}
-.app-shell{min-height:100vh;padding-top:56px;padding-left:200px;background:var(--ant-color-bg-container)}
-.topbar{height:56px;border-top:4px solid var(--ant-color-primary);border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);background-image:none;box-shadow:none;padding:0 20px;gap:12px}
-.topbar>strong{font-size:var(--ant-font-size-heading-3);color:var(--ant-color-primary);letter-spacing:-.3px}.topbar .brand-mark{font-size:var(--ant-font-size-heading-4);color:var(--ant-color-primary)}
-.top-actions{gap:16px}.top-actions a{color:var(--ant-color-primary);text-decoration:none}.top-actions small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
-.hamburger{font-size:var(--ant-font-size);color:var(--ant-color-text-secondary)}.divider{height:22px}
-.side-nav{top:56px;width:200px;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border)}
-.module-switch{padding:0;border-bottom:1px solid var(--ant-color-border);display:flex;flex-direction:column;gap:0}
-.module-switch>button{height:50px;border:0;border-radius:0;background:transparent;padding:0 20px;gap:12px;font-size:var(--ant-font-size);font-weight:600}
-.module-switch>button>b{width:18px;height:auto;background:transparent;color:var(--ant-color-text-secondary)}
-.module-switch>button>span{display:block;font-weight:600}.module-switch>button small{display:none}
-.module-switch>button:hover{background:var(--ant-color-bg-layout)}.module-switch>button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}
-.module-switch>button.active>b{background:transparent;color:var(--ant-color-primary)}
-.side-nav>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);padding:20px 20px 8px;letter-spacing:0;text-transform:none}
-.side-nav>button{height:44px;padding:0 20px;border-left:0;font-size:var(--ant-font-size);gap:12px}
-.side-nav>button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);border-left:0;font-weight:600}
-.side-nav>button em{width:20px;height:20px;font-size:var(--ant-font-size-sm)}
-.site-switcher{height:32px;width:calc(100% - 32px);margin:8px 16px;border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);font-size:var(--ant-font-size-sm)}
-.side-help{margin:auto 16px 16px;border:0;border-top:1px solid var(--ant-color-border);background:transparent;padding:16px 4px 0}
-.workspace{min-height:calc(100vh - 56px);padding-bottom:72px;background:var(--ant-color-bg-container)}.crumb{display:none}
-.page-heading{min-height:64px;padding:12px 24px;background:var(--ant-color-bg-layout);border-bottom:1px solid var(--ant-color-border)}
-.page-heading h1{font-size:var(--ant-font-size-heading-3);line-height:36px;font-weight:600;margin:0;color:var(--ant-color-text-base)}
-.page-heading p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin:0}.page-heading>div:last-child{display:flex;gap:8px}
-.content{padding:0 24px 24px;background:var(--ant-color-bg-container)}
-.button.ant-btn{min-width:112px;height:32px;margin:0;border-radius:var(--ant-border-radius);padding:0 24px;font-size:var(--ant-font-size);font-weight:600;box-shadow:none}
-.button.ant-btn-primary{background:var(--ant-color-primary);border-color:var(--ant-color-primary)}.button.ant-btn-primary:hover{background:var(--ant-color-primary-hover)!important;border-color:var(--ant-color-primary-hover)!important}
-.button.ant-btn-default,.button.secondary{background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);border-color:var(--ant-color-border)}.button.ant-btn-default:hover,.button.secondary:hover{color:var(--ant-color-primary)!important;border-color:var(--ant-color-primary)!important;background:var(--ant-color-bg-container)!important}
-.steps{height:64px;border-top:0;border-bottom:1px solid var(--ant-color-border);padding:0 24px;background:var(--ant-color-bg-container)}.steps button{padding:0 20px;font-size:var(--ant-font-size)}.steps button>i{width:26px;height:26px}.steps button.active>i{background:var(--ant-color-primary);border-color:var(--ant-color-primary)}
-.panel{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);margin:16px 0;box-shadow:none}.panel-head{min-height:48px;padding:0 16px;background:var(--ant-color-bg-container)}.panel-head>strong{font-size:var(--ant-font-size)}.panel-body{padding:16px}
-.company-status-tabs{height:56px;display:flex;align-items:flex-end;border-bottom:1px solid var(--ant-color-border);gap:0;padding-top:8px}
-.company-status-tabs > button{height:48px;min-width:120px;padding:0 24px;border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}
-.company-status-tabs > button span{margin-right:8px}.company-status-tabs > button b{display:inline-grid;place-items:center;min-width:24px;height:20px;padding:0 8px;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-.company-status-tabs > button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary);font-weight:600}.company-status-tabs > button.active b{background:var(--ant-color-fill-secondary);color:var(--ant-color-success)}
-.company-list-panel{margin-top:16px}.company-list-panel>.panel-body{padding:0 16px 12px}
-.company-filterbar{height:auto;min-height:56px;padding:12px 0;gap:8px}.company-filterbar .search-field{width:240px;height:32px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;background:var(--ant-color-bg-container)}.company-filterbar .search-field:focus-within{border-color:var(--ant-color-primary)}
-.company-filterbar select{height:32px;min-width:160px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:0 28px 0 12px}
-.filter-actions{margin-left:auto;display:flex;gap:8px}.filter-actions .button{min-width:96px}
-.company-table{table-layout:fixed}.company-table th{height:44px;background:var(--ant-color-bg-layout);font-size:var(--ant-font-size);font-weight:600;padding:0 12px}.company-table td{height:50px;padding:8px 12px;font-size:var(--ant-font-size);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.company-table tbody tr{cursor:pointer}.company-table tbody tr:hover td{background:var(--ant-color-bg-layout)}.company-table th:first-child,.company-table td:first-child{width:44px;overflow:visible;text-overflow:clip}.company-table th:last-child,.company-table td:last-child{width:70px;text-align:center}
-.company-record-link{font-size:var(--ant-font-size)}.company-record-link b{color:var(--ant-color-primary)}
-.status.ant-tag{height:24px;line-height:21px;border-radius:var(--ant-border-radius);padding:0 12px;margin:0;font-size:var(--ant-font-size-sm)}.status.ok{color:var(--ant-color-success);background:var(--ant-color-success-bg);border-color:var(--ant-color-success)}.status.warn{color:var(--ant-color-warning);background:var(--ant-color-warning-bg);border-color:var(--ant-color-warning)}.status.bad{color:var(--ant-color-error);background:var(--ant-color-error-bg);border-color:var(--ant-color-error)}.status.muted{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}
-.pagination{height:56px;padding:12px 0 0;gap:0}.pagination span{border:1px solid var(--ant-color-border);height:32px;line-height:30px;padding:0 12px;margin-right:8px}.pagination button,.pagination select{height:32px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.pagination button{width:34px}.pagination button.active{background:var(--ant-color-bg-container);color:var(--ant-color-text-base)}.pagination select{margin-left:8px;padding:0 12px}
-.company-detail .detail-status{margin-bottom:20px}.company-detail h3{font-size:var(--ant-font-size);margin:20px 0 8px}.company-detail dl{display:grid;grid-template-columns:110px 1fr;margin:0}.company-detail dt,.company-detail dd{margin:0;padding:12px 0;border-bottom:1px solid var(--ant-color-border)}.company-detail dt{color:var(--ant-color-text-secondary)}
-.ant-drawer .ant-drawer-header{min-height:64px;border-bottom:1px solid var(--ant-color-border)}.ant-drawer .ant-drawer-title{font-size:var(--ant-font-size-heading-4);font-weight:600}.ant-drawer .ant-drawer-body{padding:20px 24px}
-.company-form-page{display:grid;grid-template-columns:220px minmax(0,860px);gap:32px;align-items:start;padding-top:20px}
-.form-section-nav{position:sticky;top:76px;display:grid;grid-template-columns:32px 1fr;padding:4px 0;color:var(--ant-color-text-base)}.form-section-nav>i.form-section-nav-dot{width:8px;height:8px;border-radius:50%;background:var(--ant-color-primary);justify-self:center;align-self:center}.form-section-nav>span{font-size:var(--ant-font-size);font-weight:600;line-height:24px;margin-bottom:12px}.form-section-nav>a{grid-column:2;color:var(--ant-color-text-secondary);text-decoration:none;padding:8px 0 8px 12px;border-left:1px solid var(--ant-color-border)}.form-section-nav>a:hover{color:var(--ant-color-primary)}
-.single-column-form{width:100%}.single-column-form .panel{margin:0}.single-column-form .panel-body{padding:20px 24px 32px}.single-column-form .form-grid{display:grid;grid-template-columns:1fr!important;gap:20px}.single-column-form .form-grid>*{grid-column:1!important}.single-column-form .field{max-width:680px;gap:8px}.single-column-form .field>span{font-size:var(--ant-font-size)}.single-column-form .field input,.single-column-form .field select{height:40px;border-radius:var(--ant-border-radius);padding:0 12px}.single-column-form .field textarea{min-height:112px;border-radius:var(--ant-border-radius);padding:12px 12px}.single-column-form .field textarea.small{min-height:88px}.single-column-form .field small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
-.domain-select{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center}.domain-select .text-button{margin:0}.domain-add{max-width:520px}.domain-add .button{min-width:88px}.field-assist{display:flex;align-items:flex-end;justify-content:flex-start;gap:12px;margin-top:4px}.field-assist .button{flex:none;min-width:140px}.form-section-heading{max-width:680px;border-top:1px solid var(--ant-color-border);padding-top:20px;margin-top:4px}.form-section-heading h3{font-size:var(--ant-font-size);margin:0 0 4px}.form-section-heading p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.form-guidance{max-width:680px;padding:12px 16px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);display:flex;flex-direction:column;gap:4px}.form-guidance span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.save-indicator{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-.upload{height:96px;border-color:var(--ant-color-border);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius)}.upload b{font-size:var(--ant-font-size-heading-4)}.upload span{font-size:var(--ant-font-size)}.upload small{font-size:var(--ant-font-size-sm)}
-.ai-assist-intro{display:flex;gap:16px;padding:16px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border)}.ai-assist-intro>.anticon{font-size:var(--ant-font-size-heading-3);color:var(--ant-color-primary)}.ai-assist-intro h3{font-size:var(--ant-font-size);margin:0 0 8px}.ai-assist-intro p{margin:0;color:var(--ant-color-text-secondary);line-height:1.7}.ai-assist-drawer .field>span{font-size:var(--ant-font-size);font-weight:600}.drawer-actions{display:flex;gap:8px;margin:20px 0}.drawer-actions .button{min-width:120px}
-.result-header{min-height:72px;border:0;border-bottom:1px solid var(--ant-color-border);padding:12px 0;margin:0}.result-header h2{font-size:var(--ant-font-size-heading-4)}.result-header .site-icon{border-radius:var(--ant-border-radius);background:var(--ant-color-primary);color:var(--ant-color-text-light-solid);border:0;font-size:var(--ant-font-size)}.result-actions{display:flex;gap:8px}.result-actions .button{min-width:112px}
-.kpi-row{border-bottom:1px solid var(--ant-color-border);gap:0;margin:0 0 16px}.kpi-row>div{height:72px;border:0;border-right:1px solid var(--ant-color-border);padding:12px 16px}.kpi-row>div:last-child{border-right:0}.kpi-row span{color:var(--ant-color-text-secondary)}.kpi-row b{font-size:var(--ant-font-size-heading-3)}
-.result-layout{grid-template-columns:1fr}.result-pages{display:grid;grid-template-columns:1fr;gap:0}.result-pages article{display:grid;grid-template-columns:300px 1fr;min-height:180px;border:0;border-bottom:1px solid var(--ant-color-border);padding:16px 0}.result-pages article>div:last-child{padding:0 20px}.result-pages h3{font-size:var(--ant-font-size);margin:16px 0 8px}.result-pages p{font-size:var(--ant-font-size-sm);line-height:1.6}.result-pages footer{gap:8px}.result-pages footer .button{min-width:96px;height:32px;margin:0}.dashboard-thumb-live{min-height:160px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}
-.text-button{color:var(--ant-color-primary);font-size:var(--ant-font-size)}.modal,.login-card{border-radius:var(--ant-border-radius)}.modal{box-shadow:0 6px 24px color-mix(in srgb,var(--ant-color-text-base) 13%,transparent)}.toast{top:68px;border-radius:var(--ant-border-radius);background:var(--ant-color-text-base)}
-.ai-fab{box-shadow:0 4px 12px color-mix(in srgb,var(--ant-color-text-base) 20%,transparent)}.ai-dialog{border-radius:var(--ant-border-radius)}.ai-dialog header{background:var(--ant-color-primary)}
-@media(max-width:1200px){.company-form-page{grid-template-columns:180px minmax(0,1fr);gap:20px}.single-column-form .field,.form-section-heading,.form-guidance{max-width:100%}}
-@media(max-width:900px){.app-shell{padding-left:0}.side-nav{display:none}.workspace{min-width:0}.company-form-page{grid-template-columns:1fr}.form-section-nav{display:none}.content{padding:0 16px 20px}.page-heading{padding:12px 16px}.company-status-tabs{overflow-x:auto}.company-status-tabs > button{min-width:100px}.company-filterbar{align-items:stretch;flex-wrap:wrap}.filter-actions{margin-left:0;width:100%}.result-pages article{grid-template-columns:1fr}.sticky-actions{left:0}}
-
-/* Company navigation IA: global menu contains business modules only. */
-.side-nav{display:none!important}
-.app-shell{padding-left:200px}
-.company-side-nav{position:fixed;z-index:35;top:56px;bottom:0;left:0;width:200px;background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);display:flex;flex-direction:column;color:var(--ant-color-text-base);font-family:var(--ant-font-family)}
-.side-menu{padding:0;overflow:auto;flex:1}.menu-section{margin:0}.menu-section button{width:100%;border:0;background:transparent;color:inherit;text-align:left;font-family:var(--ant-font-family);font-weight:600}
-.menu-section-title{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;font-size:var(--ant-font-size-heading-4);line-height:56px}
-.menu-section-title>span{display:block}.menu-section-title>.anticon{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base)}
-.menu-section-title:hover{background:var(--ant-color-fill-secondary)}.menu-section.current>.menu-section-title{color:var(--ant-color-text-base);font-weight:600}
-.menu-section-items button{height:50px;padding:0 20px 0 40px;display:flex;align-items:center;font-size:var(--ant-font-size);line-height:50px;color:var(--ant-color-text-base)}
-.menu-section-items button:hover{background:var(--ant-color-fill-secondary);color:var(--ant-color-primary)}
-.menu-section-items button.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
-.extension-menu-items{padding-bottom:4px}.extension-menu-item{height:50px;padding:0 20px 0 40px;display:flex;align-items:center;font-size:var(--ant-font-size);line-height:50px;color:var(--ant-color-text-base);font-weight:600;cursor:default}.extension-submenu-title{justify-content:space-between;cursor:pointer}.extension-submenu-title>.anticon{font-size:var(--ant-font-size-sm);margin-right:4px}.extension-third-menu>div{height:50px;padding:0 20px 0 60px;display:flex;align-items:center;color:var(--ant-color-text-base);font-size:var(--ant-font-size);line-height:50px;font-weight:600;cursor:default}.extension-menu-section .extension-menu-item:hover,.extension-menu-section .extension-third-menu>div:hover{background:transparent;color:inherit}
-.extension-third-menu>button{height:50px;padding:0 20px 0 60px;display:flex;align-items:center;color:var(--ant-color-text-base);font-size:var(--ant-font-size);line-height:50px;font-weight:600;cursor:pointer;border:0;background:transparent;width:100%;text-align:left;font-family:var(--ant-font-family)}
-.extension-menu-section .extension-third-menu>button.clickable:hover{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
-.extension-menu-section .extension-third-menu>button.clickable.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
-.company-side-footer{position:relative;margin:auto 16px 16px;padding:16px 4px 0;border-top:1px solid var(--ant-color-border);display:flex;flex-direction:column;gap:8px;font-size:var(--ant-font-size-sm)}
-.company-side-footer>b{color:var(--ant-color-primary)}.company-side-footer>button{height:26px;padding:0;border:0;background:transparent;display:flex;align-items:center;justify-content:space-between;color:var(--ant-color-text-secondary);text-align:left}
-.company-model-menu{position:absolute;left:0;right:0;bottom:30px;padding:8px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);box-shadow:none}
-.company-model-menu button{width:100%;height:32px;border:0;background:var(--ant-color-bg-container);display:flex;justify-content:space-between;align-items:center;padding:0 8px}.company-model-menu button:hover{background:var(--ant-color-bg-layout)}.company-model-menu span{color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}
-.sticky-actions{left:200px}
-@media(max-width:720px){.company-side-nav{display:none}.app-shell{padding-left:0}.sticky-actions{left:0}}
-
-.site-edit-switcher{display:flex;align-items:center;gap:12px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}.site-edit-switcher select{width:220px;height:34px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:0 32px 0 12px;color:var(--ant-color-text-base)}.site-edit-switcher select:focus{outline:1px solid var(--ant-color-primary);border-color:var(--ant-color-primary)}
-.site-edit-tabs{height:64px;background:var(--ant-color-bg-container);border-top:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border);display:flex;padding:0 24px;gap:0}.site-edit-tabs>button{height:64px;flex:1;min-width:0;border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);display:flex;align-items:center;gap:12px;padding:0 20px;font-size:var(--ant-font-size);text-align:left}.site-edit-tabs>button i{font-style:normal;width:26px;height:26px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;place-items:center;background:var(--ant-color-bg-container)}.site-edit-tabs>button:hover{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.site-edit-tabs>button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary);font-weight:600}.site-edit-tabs>button.active i{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}.site-step-blank{min-height:calc(100vh - 286px);background:var(--ant-color-bg-container)}
-.asset-config-shell{min-width:0}.asset-config-tabs{height:66px;display:flex;align-items:flex-end;border:1px solid var(--ant-color-border);border-bottom:0;background:var(--ant-color-bg-container);padding:0 20px;gap:36px}.asset-config-tabs>button{height:65px;min-width:210px;padding:12px 4px 8px;border:0;border-bottom:3px solid transparent;background:transparent;text-align:left;display:flex;flex-direction:column;justify-content:center;gap:4px;color:var(--ant-color-text-secondary)}.asset-config-tabs>button span{font-size:var(--ant-font-size);font-weight:600}.asset-config-tabs>button small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-config-tabs>button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.asset-config-tabs>button.active small{color:var(--ant-color-text-secondary)}.quick-asset-config{border-top:1px solid var(--ant-color-border);padding-top:16px}.manual-asset-list{border-top:1px solid var(--ant-color-border);padding-top:16px}.manual-asset-list>.panel{margin:0}.manual-asset-filter{padding:0 0 16px;margin-bottom:0}.asset-category{border-top:1px solid var(--ant-color-border);margin:0 -12px}.asset-category>header{height:46px;display:flex;align-items:center;gap:8px;padding:0 16px;background:var(--ant-color-bg-layout)}.asset-category>header strong{font-size:var(--ant-font-size)}.asset-category>header span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.asset-category .company-table{margin:0}.asset-category .company-table th:first-child,.asset-category .company-table td:first-child{padding-left:40px}.asset-group-dialog{width:540px}.dialog-form{display:flex;flex-direction:column;gap:20px;margin:20px 0}.dialog-form input,.dialog-form select{height:38px;border:1px solid var(--ant-color-border);padding:0 12px;background:var(--ant-color-bg-container)}
-.manual-asset-editor{border-top:1px solid var(--ant-color-border);padding-top:0;background:var(--ant-color-bg-container)}.asset-editor-title{height:64px;border:1px solid var(--ant-color-border);border-top:0;display:flex;align-items:center;padding:0 16px;gap:20px}.asset-editor-title>.text-button{margin:0;padding-right:20px;border-right:1px solid var(--ant-color-border)}.asset-editor-title>div{flex:1;display:flex;align-items:baseline;gap:12px}.asset-editor-title h2{font-size:var(--ant-font-size);margin:0}.asset-editor-title>div>span{color:var(--ant-color-text-secondary)}.asset-editor-title>.button{margin:0}.asset-editor-workbench{min-height:620px;border:1px solid var(--ant-color-border);border-top:0;display:grid;grid-template-columns:250px 1fr}.asset-editor-side{border-right:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}.asset-editor-side>.field{padding:16px;border-bottom:1px solid var(--ant-color-border)}.asset-editor-side>.field input{background:var(--ant-color-bg-container)}.asset-batch-entry{width:100%;height:54px;border:0;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);text-align:left;padding:0 16px;font-size:var(--ant-font-size)}.asset-batch-entry:hover{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.asset-template-panel{padding:16px;display:flex;flex-direction:column;gap:12px}.asset-template-panel>strong{font-size:var(--ant-font-size)}.asset-template-panel>select{height:34px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 8px}.asset-template-panel>button{min-height:58px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:grid;grid-template-columns:24px 1fr;grid-template-rows:1fr 1fr;text-align:left;padding:8px;column-gap:8px}.asset-template-panel>button>span{grid-row:1/3;color:var(--ant-color-primary);align-self:center}.asset-template-panel>button>b{font-size:var(--ant-font-size-sm)}.asset-template-panel>button>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-template-panel>button:hover{border-color:var(--ant-color-primary)}.asset-tree-workspace{min-width:0;background:var(--ant-color-bg-layout)}.asset-editor-toolbar{height:52px;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 12px;display:flex;align-items:center;justify-content:space-between}.asset-editor-toolbar>div{display:flex;align-items:center;gap:8px}.asset-editor-toolbar .button{margin:0;min-width:auto}.asset-tree-search{width:210px;height:30px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;padding:0 8px;color:var(--ant-color-text-secondary)}.asset-tree-search input{border:0;outline:0;width:100%;padding-left:8px}.asset-tree-canvas{min-height:567px;padding:28px;overflow:auto}.asset-tree-root-card{width:220px;min-height:54px;margin:0 auto 24px;border:1px solid var(--ant-color-border);border-left:4px solid var(--ant-color-primary);background:var(--ant-color-bg-container);box-shadow:none;padding:8px 12px;display:flex;flex-direction:column;gap:4px;position:relative}.asset-tree-root-card:after{content:"";position:absolute;left:50%;bottom:-25px;height:24px;border-left:1px solid var(--ant-color-primary-bg)}.asset-tree-root-card small{color:var(--ant-color-text-secondary)}.asset-node-tree{display:flex;justify-content:center;align-items:flex-start;gap:20px}.asset-tree-branch{position:relative;display:flex;flex-direction:column;align-items:center}.asset-node-card{width:190px;min-height:50px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);box-shadow:none;display:grid;grid-template-columns:24px 1fr 22px;grid-template-rows:1fr 1fr;text-align:left;padding:8px 8px;column-gap:4px;position:relative}.asset-node-card>span{grid-row:1/3;color:var(--ant-color-primary);align-self:center}.asset-node-card>b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.asset-node-card>small{color:var(--ant-color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.asset-node-card>em{grid-column:3;grid-row:1/3;align-self:center;justify-self:end;font-style:normal;width:20px;height:20px;border-radius:50%;background:var(--ant-color-bg-layout);display:grid;place-items:center;color:var(--ant-color-text-secondary)}.asset-node-card:hover,.asset-node-card.selected{border-color:var(--ant-color-primary);box-shadow:none}.asset-tree-children{margin-top:28px;padding-top:0;display:flex;align-items:flex-start;justify-content:center;gap:16px;position:relative}.asset-tree-children:before{content:"";position:absolute;left:50%;top:-28px;height:28px;border-left:1px solid var(--ant-color-primary-bg)}.asset-tree-children>.asset-tree-branch:before{content:"";position:absolute;left:50%;top:-14px;height:14px;border-left:1px solid var(--ant-color-primary-bg)}.asset-tree-children>.asset-tree-branch:not(:only-child):after{content:"";position:absolute;top:-14px;left:-7px;right:-7px;border-top:1px solid var(--ant-color-primary-bg)}.asset-tree-empty{width:420px;margin:60px auto;text-align:center;display:flex;flex-direction:column;gap:8px;color:var(--ant-color-text-secondary)}.asset-tree-empty b{color:var(--ant-color-text-secondary)}.node-drawer-tabs{height:45px;border-bottom:1px solid var(--ant-color-border);display:flex;margin:-8px 0 20px}.node-drawer-tabs button{border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);padding:0 20px;font-size:var(--ant-font-size)}.node-drawer-tabs button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.node-drawer-form{display:flex;flex-direction:column;gap:20px}.node-drawer-form input,.node-drawer-form select,.node-drawer-form textarea{width:100%;border:1px solid var(--ant-color-border);padding:8px;background:var(--ant-color-bg-container)}.node-drawer-form input,.node-drawer-form select{height:38px}.node-drawer-form textarea{min-height:110px}.node-config-list>header,.node-config-list>div{display:grid;grid-template-columns:1fr 1fr 90px;gap:8px;margin-bottom:8px}.node-config-list.binding>header,.node-config-list.binding>div{grid-template-columns:1fr 1fr}.node-config-list>header{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.node-config-list input{height:36px;border:1px solid var(--ant-color-border);padding:0 8px}.node-config-list>.button{margin:8px 0 0}.batch-create-dialog{width:min(760px,calc(100vw - 40px))}.batch-create-dialog .node-drawer-tabs{margin:12px 0 20px}.batch-table>header,.batch-table>div{display:grid;grid-template-columns:1.4fr 90px 1.4fr 32px;gap:8px;margin-bottom:8px}.batch-table>header{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.batch-table input{height:36px;border:1px solid var(--ant-color-border);padding:0 8px}.batch-table>.button{margin:8px 0 12px}.manual-excel-upload{height:180px;border:1px dashed var(--ant-color-border);background:var(--ant-color-bg-layout);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;cursor:pointer}.manual-excel-upload>b{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-primary)}.manual-excel-upload>span{font-size:var(--ant-font-size)}.manual-excel-upload>small{color:var(--ant-color-text-secondary)}
-@media(max-width:1100px){.asset-editor-workbench{grid-template-columns:220px 1fr}.asset-node-card{width:160px}.asset-tree-children{flex-direction:column}.asset-tree-children>.asset-tree-branch:after{display:none}.asset-config-tabs{gap:20px}.asset-config-tabs>button{min-width:180px}}
-
-/* Resource center list pages */
-.resource-list-panel>.panel-body{padding:0 16px 12px;overflow-x:auto}
-.resource-table{min-width:980px;table-layout:auto}.resource-table th,.resource-table td{vertical-align:middle}.resource-table th:last-child,.resource-table td:last-child{width:64px;text-align:center}
-.resource-table th:nth-child(3),.resource-table td:nth-child(3){width:76px}.resource-table th:nth-child(4),.resource-table td:nth-child(4),.resource-table th:nth-child(5),.resource-table td:nth-child(5),.resource-table th:nth-child(6),.resource-table td:nth-child(6){width:70px;text-align:center}
-.resource-table th:nth-child(7),.resource-table td:nth-child(7){width:96px}.resource-table th:nth-child(8),.resource-table td:nth-child(8){width:180px}
-.domain-resource-table th:first-child,.domain-resource-table td:first-child{width:260px;min-width:260px;text-align:left}.domain-resource-table .company-record-link{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
-.resource-detail>p{margin:8px 0;color:var(--ant-color-text-secondary);line-height:1.8;white-space:pre-wrap}.resource-detail-image{display:block;width:100%;max-height:220px;object-fit:contain;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);margin-bottom:20px}
-.device-resource-table{min-width:820px}.device-resource-table th:nth-child(1),.device-resource-table td:nth-child(1){width:280px;min-width:280px;text-align:left}.device-resource-table th:nth-child(2),.device-resource-table td:nth-child(2){width:90px;text-align:center}.device-resource-table th:nth-child(3),.device-resource-table td:nth-child(3){width:110px;text-align:left}.device-resource-table th:nth-child(4),.device-resource-table td.domain-cell{width:240px;min-width:180px;text-align:left;white-space:normal}.device-resource-table th:nth-child(5),.device-resource-table td:nth-child(5){width:64px;text-align:center}.device-resource-table .company-record-link{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
-.domain-tag-list{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
-.ant-tag.tag-new{display:inline-flex;align-items:center;height:24px;line-height:21px;border-radius:var(--ant-border-radius);padding:0 12px;font-size:var(--ant-font-size-sm);white-space:nowrap;margin:0}
-.resource-name-cell{display:flex;align-items:center;gap:12px;min-width:0}.resource-name-cell>img,.resource-placeholder{flex:none;width:48px;height:36px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);object-fit:cover;border-radius:var(--ant-border-radius)}.resource-placeholder{display:grid;place-items:center;color:var(--ant-color-text-secondary)}.resource-name-cell .company-record-link{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
-.resource-point-list{border-top:1px solid var(--ant-color-border)}.resource-point-list>div{height:40px;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:space-between}.resource-point-list span{color:var(--ant-color-text-secondary)}.resource-point-list>p{color:var(--ant-color-text-secondary)}
-.resource-back{height:44px;display:flex;align-items:center;border-bottom:1px solid var(--ant-color-border);margin:0 0 16px}.resource-back .text-button{margin:0}
-.style-resource-panel{margin-top:16px}.style-resource-table{min-width:820px}.style-resource-table th:nth-child(1),.style-resource-table td:nth-child(1){width:260px;min-width:260px;text-align:left}.style-resource-table th:nth-child(2),.style-resource-table td:nth-child(2){width:140px}.style-resource-table th:nth-child(3),.style-resource-table td:nth-child(3){width:190px}.style-resource-table th:nth-child(4),.style-resource-table td:nth-child(4){width:130px}.style-resource-table th:nth-child(5),.style-resource-table td:nth-child(5){width:64px;text-align:center}.style-resource-table .company-record-link{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}.style-list-thumbnail{display:block;width:96px;height:54px;object-fit:cover;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius)}
-.style-drawer>img{display:block;width:100%;max-height:440px;object-fit:contain;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border)}.style-drawer dl{display:grid;grid-template-columns:110px 1fr;margin-top:20px}.style-drawer dt,.style-drawer dd{margin:0;padding:12px 0;border-bottom:1px solid var(--ant-color-border)}.style-drawer dt{color:var(--ant-color-text-secondary)}
-/* 生成过程只读终端 */
-.gen-right{display:flex;flex-direction:column;gap:16px}
-.console-panel .panel-body{padding:0}
-.gen-console{height:300px;max-height:42vh;overflow-y:auto;background:var(--ant-color-text-base);color:var(--ant-color-primary);font:12px/1.5 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;padding:12px 12px}
-.gen-console pre{margin:0;white-space:pre-wrap;word-break:break-word}
-.gen-console pre:empty:before{content:""}
-.modal-console{margin-top:12px;border:1px solid var(--ant-color-primary);border-radius:var(--ant-border-radius);overflow:hidden}
-.modal-console-head{background:var(--ant-color-text-base);color:var(--ant-color-primary-bg);font-size:var(--ant-font-size-sm);padding:8px 12px;border-bottom:1px solid var(--ant-color-primary)}
-.modal-console-body{background:var(--ant-color-text-base);color:var(--ant-color-primary);font:12px/1.5 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;margin:0;padding:12px 12px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
-
-/* ===== 视图切换(列表/卡片) ===== */
-.view-toggle{display:inline-flex;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);overflow:hidden}
-.view-toggle button{border:0;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);height:28px;padding:0 12px;font-size:var(--ant-font-size-sm)}
-.view-toggle button+button{border-left:1px solid var(--ant-color-border)}
-.view-toggle button.active{background:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}
-/* ===== 领域方案卡片视图 ===== */
-.domain-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(245px,1fr));gap:12px;padding:8px 0}
-.domain-card{border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);background:var(--ant-color-bg-container);padding:16px;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:.15s}
-.domain-card:hover{box-shadow:none;border-top-color:var(--ant-color-primary-active)}
-.domain-card>header{display:flex;align-items:center;justify-content:space-between;gap:8px}
-.domain-card h3{font-size:var(--ant-font-size);margin:0}
-.domain-card .badge{display:inline-block;align-self:flex-start;background:var(--ant-color-primary-bg);color:var(--ant-color-primary-active);border:1px solid var(--ant-color-primary);border-radius:var(--ant-border-radius);padding:4px 12px;font-size:var(--ant-font-size-sm)}
-.domain-card .meta{display:flex;gap:16px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-.domain-card .meta b{color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);margin-right:4px}
-
-/* 领域方案 7 步向导 */
-.placeholder-block{display:flex;flex-direction:column;gap:12px;padding:8px 4px}
-.badge.todo{display:inline-block;align-self:flex-start;background:var(--ant-color-primary-bg);color:var(--ant-color-error);border:1px solid var(--ant-color-error-bg);border-radius:var(--ant-border-radius);padding:4px 12px;font-size:var(--ant-font-size-sm)}
-/* Cube 页面来源徽标 */
-.badge.src-upload{display:inline-flex;align-items:center;gap:4px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);border:1px solid var(--ant-color-primary-bg);border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm)}
-.badge.src-ai{display:inline-flex;align-items:center;gap:4px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);border:1px solid var(--ant-color-primary);border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm)}
-.badge.src-manual{display:inline-flex;align-items:center;gap:4px;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:4px 8px;font-size:var(--ant-font-size-sm)}
-
-/* 扩展菜单可点击项 */
-.extension-menu-section .extension-menu-item.clickable{cursor:pointer}
-.extension-menu-section .extension-menu-item.clickable:hover{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
-.extension-menu-section .extension-menu-item.clickable.active{background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
-
-/* 仪表盘页面 */
-.dashboard-page{min-height:calc(100vh - 56px);padding:20px;background:var(--ant-color-bg-layout);display:flex;align-items:flex-start;justify-content:center}
-.dashboard-page img{display:block;max-width:100%;height:auto;background:var(--ant-color-bg-container);box-shadow:none;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius)}
-.domain-preview-canvas{background:var(--ant-color-text-base);height:64vh;border-radius:var(--ant-border-radius);overflow:auto}
-.domain-preview-canvas .dashboard-thumb-live{min-height:0}
-.domain-preview-feedback{margin-top:16px}
-.domain-preview-feedback textarea{width:100%;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px;resize:vertical;line-height:1.6;margin-top:8px}
-.domain-review-pages{display:grid;grid-template-columns:1fr;gap:12px}
-.domain-review-page{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);overflow:hidden;transition:border-color .15s,box-shadow .15s}
-.domain-review-page:hover{border-color:var(--ant-color-primary);box-shadow:none}
-.domain-review-page .dashboard-thumb-live{border-bottom:0;min-height:240px}
-.domain-review-page>div{padding:8px 12px;display:flex;align-items:center;justify-content:space-between;gap:8px}
-.domain-review-page h4{margin:0;font-size:var(--ant-font-size-sm)}
-.domain-review-page .review-zoom{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary);white-space:nowrap}
-
-.domain-card .card-actions{display:flex;gap:16px;margin-top:4px;padding-top:8px;border-top:1px solid var(--ant-color-border)}
-/* ===== 设备模版 ===== */
-.device-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px;padding:4px 0}
-.device-card{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:16px}
-.device-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
-.device-card-head b{font-size:var(--ant-font-size)}
-.cp-list{margin-top:12px}
-.cp-list>b{display:flex;align-items:center;gap:8px;font-size:var(--ant-font-size-sm)}
-.cp-list .data-table{margin-top:8px}
-.chk{display:flex;flex-wrap:wrap;gap:8px}
-.chk-item{display:inline-flex;align-items:center;gap:4px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:4px 8px;cursor:pointer}
-.chk-item input{margin:0;accent-color:var(--ant-color-primary)}
-/* ===== 方案详情:选用设备模版 ===== */
-.device-picker{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px}
-.picker-item{display:flex;align-items:center;gap:8px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);border-radius:var(--ant-border-radius);padding:8px 12px;cursor:pointer}
-.picker-item.on{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}
-.picker-item input{margin:0;accent-color:var(--ant-color-primary)}
-.picker-name{font-weight:600;font-size:var(--ant-font-size-sm)}
-.picker-doms{margin-left:auto;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);max-width:42%;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-/* ===== 设备模版:图片与采集点编辑 ===== */
-.device-list{display:grid;grid-template-columns:1fr;gap:16px;padding:4px 0}
-.device-img-field{margin-top:8px;border:1px dashed var(--ant-color-border);border-radius:var(--ant-border-radius);width:160px;height:160px;background:var(--ant-color-bg-layout);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;position:relative}
-.device-img-field:hover{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}
-.device-img-field img{width:100%;height:100%;object-fit:cover;display:block}
-.device-img-field .device-img-empty{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);text-align:center;padding:0 8px}
-.cp-list-head{display:flex;align-items:center;justify-content:space-between;margin:12px 0 8px}
-.cp-list-head span{display:flex;gap:12px}
-.cp-editor td{padding:4px 8px;vertical-align:middle}
-.cp-editor input,.cp-editor select{width:100%;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:30px;padding:0 8px;background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}
-.cp-editor input:focus,.cp-editor select:focus{outline:1px solid var(--ant-color-primary)}
-.cp-range-custom{margin-top:4px}
-/* ===== 设备模板:报警规则 ===== */
-.alarm-rule-block{margin-top:20px;border-top:1px solid var(--ant-color-border);padding-top:8px}.alarm-rule-head{min-height:44px;display:flex;align-items:center;justify-content:space-between;gap:16px}.alarm-rule-head>b{font-size:var(--ant-font-size)}.alarm-rule-head>div{display:flex;align-items:center;gap:12px}.alarm-rule-head .button{height:32px;min-width:74px;margin:0}.alarm-rule-search{width:210px;height:32px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);display:flex;align-items:center;gap:8px;padding:0 8px}.alarm-rule-search:focus-within{border-color:var(--ant-color-primary)}.alarm-rule-search input{width:100%;border:0;outline:0;background:transparent}.alarm-rule-table{margin-top:0!important;border-top:1px solid var(--ant-color-border)}.alarm-rule-table th{height:40px;border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.alarm-rule-table td{height:48px;font-size:var(--ant-font-size-sm)}.alarm-rule-table input[type=checkbox]{accent-color:var(--ant-color-primary)}.alarm-name-link{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base)}.alarm-name-link:hover{color:var(--ant-color-primary)}.alarm-description-cell{max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.alarm-switch{width:25px;height:16px;padding:0;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-fill-secondary);position:relative;vertical-align:middle}.alarm-switch i{position:absolute;left:2px;top:2px;width:10px;height:10px;border-radius:50%;background:var(--ant-color-bg-container);transition:left .15s}.alarm-switch.on{border-color:var(--ant-color-primary);background:var(--ant-color-primary)}.alarm-switch.on i{left:11px}.alarm-row-actions{display:flex;align-items:center;gap:4px}.alarm-row-actions .ant-btn{width:30px;height:30px;padding:0;color:var(--ant-color-primary);font-size:var(--ant-font-size)}.alarm-row-actions .ant-btn-dangerous{color:var(--ant-color-error)}
-.alarm-rule-drawer .ant-drawer-header{min-height:56px;padding:0 24px;border-bottom:1px solid var(--ant-color-border)}.alarm-rule-drawer .ant-drawer-title{font-size:var(--ant-font-size);font-weight:600}.alarm-rule-drawer .ant-drawer-body{padding:28px 32px 32px;overflow:auto;background:var(--ant-color-bg-container)}.alarm-rule-drawer .ant-drawer-footer{height:56px;padding:12px 24px;border-top:1px solid var(--ant-color-border)}.alarm-rule-form{display:flex;flex-direction:column;gap:28px}.alarm-rule-form section>h3{margin:0 0 20px;padding-left:16px;border-left:4px solid var(--ant-color-primary);font-size:var(--ant-font-size);line-height:19px}.alarm-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px 40px;padding:0 20px}.alarm-form-grid label{display:grid;grid-template-columns:100px minmax(0,1fr);align-items:center;gap:0}.alarm-form-grid label.wide{grid-column:1/-1}.alarm-form-grid label>span{text-align:right;padding-right:16px;color:var(--ant-color-text-secondary)}.alarm-form-grid i,.alarm-sensitivity i{font-style:normal;color:var(--ant-color-error)}.alarm-form-grid input,.alarm-form-grid select{width:100%;height:34px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:0 12px;outline:0}.alarm-form-grid input:focus,.alarm-form-grid select:focus{border-color:var(--ant-color-primary)}.alarm-sensitivity{display:flex;align-items:center;gap:12px;padding:0 28px}.alarm-sensitivity>span{margin-right:8px}.alarm-sensitivity label{display:flex;align-items:center;gap:8px}.alarm-sensitivity input[type=radio]{accent-color:var(--ant-color-primary)}.alarm-sensitivity input[type=number]{width:55px;height:34px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 8px}.alarm-sensitivity input:disabled{background:var(--ant-color-bg-layout);color:var(--ant-color-text-disabled)}.alarm-sensitivity em{font-style:normal;color:var(--ant-color-text-secondary);margin-right:16px}.alarm-trigger-panel{border:1px solid var(--ant-color-border);margin:0 20px;min-height:250px}.alarm-trigger-panel>header{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--ant-color-border)}.alarm-trigger-panel>header b{font-size:var(--ant-font-size)}.alarm-trigger-panel>header>span{display:flex;align-items:center;gap:8px}.alarm-trigger-panel>header .button{height:32px;min-width:72px;margin:0}.alarm-trigger-table{table-layout:fixed}.alarm-trigger-table th{border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.alarm-trigger-table th:nth-child(1){width:120px}.alarm-trigger-table th:nth-child(2){width:125px}.alarm-trigger-table th:nth-child(3){width:110px}.alarm-trigger-table th:nth-child(4){width:160px}.alarm-trigger-table th:nth-child(5){width:90px}.alarm-trigger-table th:nth-child(6){width:auto}.alarm-trigger-table th:nth-child(7){width:64px}.alarm-trigger-table td{height:48px;padding:8px 8px}.alarm-trigger-table input,.alarm-trigger-table select{width:100%;height:30px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 8px;outline:0}.alarm-trigger-table input:focus,.alarm-trigger-table select:focus{border-color:var(--ant-color-primary)}.alarm-trigger-empty{height:130px;display:grid;place-items:center;color:var(--ant-color-text-secondary)}
-@media(max-width:900px){.alarm-rule-drawer .ant-drawer-content-wrapper{width:100vw!important}.alarm-form-grid{grid-template-columns:1fr}.alarm-form-grid label.wide{grid-column:auto}.alarm-sensitivity{flex-wrap:wrap}.alarm-rule-table{min-width:900px}.alarm-rule-block{overflow:auto}}
-/* ===== 设备模版:列表视图 ===== */
-.list-toolbar{margin-bottom:12px}
-.list-toolbar .search{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:32px;padding:0 12px;width:320px;background:var(--ant-color-bg-container)}
-.list-toolbar .search:focus{outline:1px solid var(--ant-color-primary)}
-.device-tpl-list tbody tr{cursor:pointer}
-.device-tpl-list tbody tr:hover{background:var(--ant-color-bg-layout)}
-.tpl-thumb{width:48px;height:34px;object-fit:cover;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);display:block;background:var(--ant-color-bg-layout)}
-.tpl-thumb-empty{color:var(--ant-color-text-disabled)}
-
-/* ===== 自定义确认 / 输入弹窗(替代原生 confirm/prompt,居中显示) ===== */
-.dlg-backdrop{position:fixed;inset:0;z-index:90;background:color-mix(in srgb,var(--ant-color-bg-mask) 35%,transparent);display:flex;align-items:center;justify-content:center;padding:20px}
-.dlg-box{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);box-shadow:0 12px 36px color-mix(in srgb,var(--ant-color-text-base) 20%,transparent);width:min(420px,100%);max-width:100%;padding:20px 24px}
-.dlg-box p{margin:0 0 20px;font-size:var(--ant-font-size-sm);line-height:1.6;color:var(--ant-color-text-base);white-space:pre-wrap;word-break:break-word}
-.dlg-box input{width:100%;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:32px;padding:0 8px;font-size:var(--ant-font-size-sm)}
-.dlg-box input:focus{outline:1px solid var(--ant-color-primary)}
-.dlg-actions{display:flex;justify-content:flex-end;gap:12px}
-.dlg-actions .button{margin-left:0}
-/* 领域方案库 · 管理领域 */
-.domain-manage-list{display:flex;flex-direction:column;gap:8px}
-.domain-manage-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container)}
-.domain-manage-name{font-weight:600;color:var(--ant-color-text-base)}
-.domain-manage-row .muted{font-size:var(--ant-font-size-sm)}
-.text-button.danger{color:var(--ant-color-error)}
-.text-button.danger:hover{text-decoration:underline}
-.domain-create-dialog h2{margin:0 0 8px;font-size:var(--ant-font-size-heading-4)}.domain-create-dialog p{margin:0 0 16px;color:var(--ant-color-text-secondary)}.domain-create-dialog .dlg-actions{margin-top:20px}.domain-create-dialog .button{min-width:96px;margin:0}
-.system-settings-page{max-width:920px;padding-top:16px}.system-settings-page>.panel{margin:0}.system-settings-page .panel-head{height:52px}.settings-form{max-width:680px;display:flex;flex-direction:column;gap:20px;padding:8px 8px 16px}.settings-form .field{gap:8px}.settings-form .field>span{font-size:var(--ant-font-size)}.settings-form input,.settings-form select{width:100%;height:40px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;background:var(--ant-color-bg-container)}.settings-form input[readonly]{background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary)}.settings-note{padding:16px 16px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);display:flex;flex-direction:column;gap:4px}.settings-note span{color:var(--ant-color-text-secondary)}.settings-actions{display:flex;justify-content:flex-start}.settings-actions .button{margin:0}.settings-table-wrap{overflow-x:auto}.settings-domain-table{table-layout:auto;min-width:680px}.settings-domain-table th:first-child,.settings-domain-table td:first-child{width:260px;min-width:260px;text-align:left}.settings-domain-table th:nth-child(2),.settings-domain-table td:nth-child(2){width:auto}.settings-domain-table th:nth-child(3),.settings-domain-table td:nth-child(3){width:130px}.settings-domain-table th:nth-child(4),.settings-domain-table td:nth-child(4){width:100px;text-align:center}.settings-domain-table .text-button{margin:0}
-
-/* 领域方案库 · 左侧导航栏「领域分类」行内编辑(删除× / 新增+) */
-.domain-nav-row{display:flex;align-items:stretch;flex-wrap:nowrap}
-.domain-nav-row>button:not(.domain-nav-del):not(.domain-nav-ok){flex:1;min-width:0;text-align:left;height:41px;border:0;background:transparent;padding:0 16px;display:flex;align-items:center;color:var(--ant-color-text-base);font-size:var(--ant-font-size-sm);white-space:nowrap}
-.domain-nav-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.domain-nav-row>button b{width:18px;color:var(--ant-color-text-secondary);margin-right:8px;font-weight:400;flex:0 0 auto}
-.domain-nav-row>button:hover{background:var(--ant-color-fill-quaternary)}
-.domain-nav-row.active{background:var(--ant-color-primary-bg)}
-.domain-nav-row>button.active:not(.domain-nav-del):not(.domain-nav-ok){background:var(--ant-color-primary-bg);color:var(--ant-color-primary);font-weight:600}
-.domain-nav-row.active>button b,.domain-nav-row>button.active b{color:var(--ant-color-primary)}
-.domain-nav-del{border:0;background:transparent;width:30px;color:var(--ant-color-text-disabled);font-size:var(--ant-font-size);line-height:1;flex:0 0 auto}
-.domain-nav-del:hover{color:var(--ant-color-error);background:var(--ant-color-bg-layout)}
-.domain-nav-ok{border:0;background:transparent;width:30px;color:var(--ant-color-success);font-size:var(--ant-font-size);flex:0 0 auto}
-.domain-nav-ok:hover{background:var(--ant-color-bg-layout)}
-.domain-nav-add-cta{display:block}
-.domain-nav-add{height:34px;border:0;background:transparent;text-align:left;padding:0 16px;display:flex;align-items:center;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm);width:100%}
-.domain-nav-add:hover{background:var(--ant-color-primary-bg)}
-.domain-nav-add b{margin-right:8px}
-.domain-nav-row.add{align-items:center;padding:4px 8px}
-.domain-nav-input{flex:1;min-width:0;margin:0 4px;height:28px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 8px;font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-container)}
-.domain-nav-input:focus{outline:1px solid var(--ant-color-primary);border-color:var(--ant-color-primary)}
-
-/* ===== 方案打包 / 方案概览(v0.32) ===== */
-.gen-overview{display:block}
-.gen-overview .panel{margin-bottom:0}
-.overview-info{display:grid;grid-template-columns:1fr 1fr;gap:12px 20px;padding:4px 4px 16px;border-bottom:1px solid var(--ant-color-border);margin-bottom:12px}
-.overview-info>div{display:flex;flex-direction:column;gap:4px}
-.overview-info span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);font-weight:600;letter-spacing:.3px}
-.overview-info b{font-size:var(--ant-font-size);color:var(--ant-color-text-base)}
-.overview-info .overview-desc{grid-column:1 / -1}
-.overview-info .overview-desc p{margin:0;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.6;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:8px 8px}
-.overview-tabs{margin:0 0 12px}
-.overview-tabs button{padding:0 16px;font-size:var(--ant-font-size-sm)}
-.overview-body{min-height:120px}
-.overview-body .muted{padding:12px 4px}
-.dev-list{display:flex;flex-direction:column;gap:8px}
-.dev-row{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);overflow:hidden;background:var(--ant-color-bg-container)}
-.dev-row-head{width:100%;border:0;background:transparent;display:flex;align-items:center;gap:12px;padding:12px 12px;text-align:left;cursor:pointer}
-.dev-row-head:hover{background:var(--ant-color-bg-layout)}
-.dev-row-head b{font-size:var(--ant-font-size-sm);flex:1}
-.dev-count{font-size:var(--ant-font-size-sm);color:var(--ant-color-primary);background:var(--ant-color-bg-layout);border-radius:var(--ant-border-radius);padding:4px 8px}
-.dev-caret{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-.dev-detail{border-top:1px solid var(--ant-color-border);padding:12px 12px;background:var(--ant-color-bg-layout)}
-.dev-desc{margin:0 0 8px;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.7}
-.dev-cp b{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base);display:block;margin-bottom:4px}
-.dev-cp ul{margin:0;padding-left:20px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.8}
-.doc-list{display:flex;flex-direction:column;gap:8px}
-.doc-item{display:flex;align-items:baseline;gap:12px;padding:8px 12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container)}
-.doc-item b{font-size:var(--ant-font-size-sm);flex:1}
-.doc-item .muted{font-size:var(--ant-font-size-sm);white-space:nowrap}
-.ops-placeholder{display:flex;flex-direction:column;gap:12px;padding:20px;border:1px dashed var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout)}
-.ops-placeholder .status{align-self:flex-start}
-.ops-placeholder p{margin:0;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.7}
-/* ===== 方案打包示意图弹窗(v0.32) ===== */
-.pkg-modal{width:560px}
-.pkg-body{padding:20px}
-.pkg-body .hint{margin:0 0 16px}
-.pkg-box{display:flex;justify-content:center;padding:8px 0 16px}
-.pkg-svg{width:240px;height:auto;filter:drop-shadow(0 8px 16px rgba(120,80,20,.18))}
-.pkg-items{display:grid;grid-template-columns:1fr 1fr;gap:12px}
-.pkg-item{display:flex;align-items:flex-start;gap:12px;padding:12px 12px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);box-shadow:none}
-.pkg-item:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}
-.pkg-icon{width:34px;height:34px;flex:0 0 auto;border-radius:var(--ant-border-radius);background:linear-gradient(135deg,var(--ant-color-bg-layout),var(--ant-color-primary-bg));color:var(--ant-color-primary);display:grid;place-items:center;font-size:var(--ant-font-size)}
-.pkg-item-text{display:flex;flex-direction:column;gap:4px;min-width:0}
-.pkg-item-text b{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-base)}
-.pkg-item-text small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);line-height:1.5}
-
-
-/* WISE-IoT Desk header — aligned to the reference application's native header. */
-.topbar.wise-header{height:56px;padding:0 8px 0 12px;gap:0;border-top:4px solid #008cd6;border-bottom:1px solid #c8c8c8;background:#fff;box-shadow:none;overflow:hidden;color:#787878}
-.wise-header button{border:0;background:transparent;padding:0;box-shadow:none}
-.wise-menu-button{flex:0 0 24px;width:24px;height:24px;margin-right:12px;color:#787878;display:grid;place-items:center}
-.wise-menu-button img{display:block;width:24px;height:24px}
-.wise-logo-link{flex:0 0 123px;width:123px;height:40px;display:block}
-.wise-logo-link img{display:block;width:123px;height:40px}
-.wise-product-switch{flex:0 0 auto;height:48px;margin-left:12px;display:flex;align-items:center;color:#787878;white-space:nowrap}
-.wise-product-switch>img:first-child{width:24px;height:24px;margin-right:8px}
-.wise-product-switch>span,.wise-product-switch>strong{font-family:Roboto,"Noto Sans SC","Microsoft YaHei",Arial,sans-serif;font-size:16px;line-height:24px;font-weight:700;color:#787878}
-.wise-product-switch>strong{margin-left:6px}
-.wise-product-switch>img:last-child{width:24px;height:24px}
-.wise-header-actions{height:48px;margin-left:auto;display:flex;align-items:center;justify-content:flex-end;flex:0 0 auto}
-.wise-header-actions>button:not(.wise-avatar){flex:0 0 40px;width:40px;height:40px;display:grid;place-items:center;border-radius:0}
-.wise-header-actions>button:not(.wise-avatar):hover{background:#f5f5f5}
-.wise-header-actions>button>img{display:block;width:24px;height:24px}
-.wise-avatar{flex:0 0 24px;width:24px;height:24px;margin:0 8px;border-radius:50%!important;background:#dff3fc!important;color:#008cd6!important;font-size:12px;line-height:24px;text-align:center}
-.wise-powered-divider{flex:0 0 1px;width:1px;height:24px;background:#dedede;margin:0 4px 0 5px}
-.wise-powered-logo{display:block;flex:0 0 122px;width:122px;height:48px;object-fit:contain}
-@media(max-width:1120px){.wise-powered-divider,.wise-powered-logo{display:none}.wise-avatar{margin-right:8px}}
-@media(max-width:700px){.wise-header-secondary{display:none!important}.wise-product-switch>strong{display:none}.wise-header-actions>button:not(.wise-avatar){width:36px;flex-basis:36px}}
-@media(max-width:560px){.wise-logo-link{display:none}.wise-product-switch{margin-left:4px}.wise-header-actions>button:nth-child(1),.wise-header-actions>button:nth-child(2){display:none}}
-
-/* 当前站点资产组编辑器:直接进入详情,不再经过资产组列表。 */
-.asset-editor-loading{min-height:680px;border:1px solid var(--ant-color-border);border-top:0;background:var(--ant-color-bg-container);display:flex;align-items:center;justify-content:center;gap:16px;color:var(--ant-color-text-secondary)}.asset-editor-loading.error{color:var(--ant-color-error)}
-.manual-asset-editor{border-top:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.asset-editor-title{height:58px;border:1px solid var(--ant-color-border);border-top:0;padding:0 20px}.asset-editor-title>div{display:flex;align-items:baseline;gap:20px}.asset-editor-title h2{font-size:var(--ant-font-size-heading-4);font-weight:600;color:var(--ant-color-text-base)}.asset-editor-title>div>span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-editor-workbench{min-height:680px;height:calc(100vh - 306px);grid-template-columns:232px minmax(720px,1fr);border-color:var(--ant-color-border)}
-.asset-editor-side{background:var(--ant-color-bg-container);border-right:1px solid var(--ant-color-border)}.asset-editor-side>.field{padding:16px 20px 16px}.asset-editor-side>.field>span{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base);margin-bottom:8px}.asset-editor-side>.field input{height:36px;border:1px solid var(--ant-color-border);padding:0 8px}.asset-batch-entry{height:72px;padding:0 20px;font-size:var(--ant-font-size);color:var(--ant-color-text-base)}.asset-batch-entry:hover{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.asset-template-panel{padding:16px 20px;gap:12px}.asset-template-panel>strong{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.asset-template-panel>select{height:34px}.asset-template-panel>button{min-height:50px;border:0;background:transparent;grid-template-columns:24px 1fr;padding:8px 8px}.asset-template-panel>button:hover{background:var(--ant-color-bg-layout);border:0}.asset-template-panel>button>span{font-size:var(--ant-font-size)}.asset-template-panel>button>small{color:var(--ant-color-text-secondary)}
-.asset-tree-workspace{background:var(--ant-color-bg-container);display:flex;flex-direction:column;min-height:0}.asset-editor-toolbar{height:49px;min-height:49px;padding:0 16px;position:relative}.asset-editor-toolbar>div{gap:4px}.asset-tool-group,.asset-canvas-tools{display:flex;align-items:center}.asset-tool-button.ant-btn{width:34px;min-width:34px;height:32px;padding:0;border-radius:var(--ant-border-radius);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}.asset-tool-button.ant-btn:not(:disabled){color:var(--ant-color-primary)}.asset-tool-button.ant-btn:hover:not(:disabled){color:var(--ant-color-primary-hover);background:var(--ant-color-bg-layout)}.asset-tool-button.primary.ant-btn{color:var(--ant-color-primary)}.asset-tool-divider{height:22px;border-left:1px solid var(--ant-color-border);margin:0 4px}.asset-view-switch{position:absolute;left:50%;transform:translateX(-50%);height:32px;border:1px solid var(--ant-color-border);display:flex!important;gap:0!important}.asset-view-switch button{width:38px;border:0;border-right:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size)}.asset-view-switch button:last-child{border-right:0}.asset-view-switch button.active{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.asset-tree-search{width:172px;height:30px;margin-left:8px;color:var(--ant-color-text-secondary)}.asset-tree-search input{font-size:var(--ant-font-size-sm)}
-.asset-tree-canvas{flex:1;min-height:625px;padding:44px;overflow:auto;background:var(--ant-color-bg-layout);cursor:grab;touch-action:none;user-select:none}.asset-tree-canvas.dragging{cursor:grabbing}.asset-tree-stage{width:max-content;min-width:100%;min-height:100%;display:flex;align-items:center;justify-content:center;transform-origin:center center;transition:transform .12s ease}.dragging .asset-tree-stage{transition:none}.asset-node-tree{display:flex;flex-direction:column;justify-content:center;align-items:flex-start;gap:28px;min-width:max-content}.asset-tree-branch{display:flex;flex-direction:row;align-items:center;position:relative}.asset-node-card{width:174px;min-height:42px;grid-template-columns:24px 1fr 22px;grid-template-rows:1fr 1fr;padding:8px 8px;border:0;border-radius:var(--ant-border-radius);box-shadow:none;background:var(--ant-color-bg-container);cursor:pointer}.asset-node-card>span{font-size:var(--ant-font-size)}.asset-node-card>b{font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary)}.asset-node-card>small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.asset-node-card>button{grid-column:3;grid-row:1/3;align-self:center;justify-self:end;width:19px;height:19px;border:0;border-radius:50%;padding:0;background:var(--ant-color-text-disabled);color:var(--ant-color-text-light-solid);display:grid;place-items:center;font-size:var(--ant-font-size-sm)}.asset-node-card>button:hover{background:var(--ant-color-primary)}.asset-node-card:hover,.asset-node-card.selected{border:0;box-shadow:none}.asset-node-card.matched{box-shadow:none}.asset-tree-children{margin:0 0 0 36px;padding:0 0 0 36px;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:16px;position:relative}.asset-tree-children:before{left:0;top:21px;bottom:21px;height:auto;border-left:1px solid var(--ant-color-primary)}.asset-tree-children>.asset-tree-branch:before{content:"";position:absolute;left:-34px;top:50%;width:34px;height:0;border-top:1px solid var(--ant-color-primary)}.asset-tree-children>.asset-tree-branch:not(:only-child):after{display:none}.asset-tree-branch>.asset-node-card+.asset-tree-children:after{content:"";position:absolute;left:-34px;top:50%;width:34px;border-top:1px solid var(--ant-color-primary)}.asset-tree-empty{margin:80px auto;color:var(--ant-color-text-secondary)}.asset-tree-empty b{font-weight:600}
-.asset-node-list{flex:1;min-height:625px;overflow:auto;background:var(--ant-color-bg-container)}.asset-node-list-head,.asset-node-list>button{height:43px;display:grid;grid-template-columns:minmax(260px,1.5fr) 120px minmax(180px,1fr) 90px;align-items:center;text-align:left;border:0;border-bottom:1px solid var(--ant-color-border);padding:0 16px;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary)}.asset-node-list-head{height:40px;background:var(--ant-color-bg-layout);font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary)}.asset-node-list>button:hover,.asset-node-list>button.selected{background:var(--ant-color-bg-layout)}.asset-node-list>button.selected{box-shadow:none}.asset-node-list>button>span:first-child{color:var(--ant-color-text-base)}
-@media(max-width:1100px){.asset-editor-workbench{grid-template-columns:210px minmax(660px,1fr)}.asset-editor-title span{display:none}.asset-tree-search{width:130px}.asset-zoom-value{display:none}}
-
-/* Asset 节点配置抽屉:对齐原资产设定的三 Tab 信息架构。 */
-.asset-node-drawer .ant-drawer-header{min-height:56px;padding:0 24px;border-bottom:1px solid var(--ant-color-border)}.asset-node-drawer .ant-drawer-title{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.asset-node-drawer .ant-drawer-body{padding:0;overflow:auto}.asset-node-drawer .ant-drawer-footer{height:56px;padding:12px 24px;border-top:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.node-drawer-footer{display:flex;justify-content:flex-end;align-items:center;gap:8px}.node-drawer-footer .button{margin:0}
-.asset-node-drawer .node-drawer-tabs{height:49px;margin:0;padding-left:20px;gap:0;border-bottom:1px solid var(--ant-color-border)}.asset-node-drawer .node-drawer-tabs button{height:49px;padding:0 20px;border-right:1px solid var(--ant-color-border);border-bottom:3px solid transparent;color:var(--ant-color-text-secondary)}.asset-node-drawer .node-drawer-tabs button:first-child{padding-left:0;margin-right:20px}.asset-node-drawer .node-drawer-tabs button.active{color:var(--ant-color-text-secondary);border-bottom-color:var(--ant-color-primary)}
-.node-config-page{padding:24px 24px 48px;display:flex;flex-direction:column;gap:40px}.node-config-section>h3{height:22px;line-height:22px;margin:0 0 24px;padding-left:16px;border-left:4px solid var(--ant-color-primary);font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.node-config-section>h4,.binding-parameter-head h4{margin:0 0 12px;font-size:var(--ant-font-size);color:var(--ant-color-text-secondary)}.node-id-row{display:grid;grid-template-columns:105px 1fr;align-items:center;max-width:420px;margin:0 0 20px 48px;color:var(--ant-color-text-secondary)}.node-id-row b{font-weight:400;color:var(--ant-color-text-secondary)}.node-basic-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 36px}.node-basic-grid .field,.node-property-grid .field{display:grid;grid-template-columns:105px minmax(0,1fr);align-items:center;gap:12px}.node-basic-grid .field>span,.node-property-grid .field>span{font-size:var(--ant-font-size);font-weight:400;color:var(--ant-color-text-secondary);text-align:right}.node-basic-grid .field input,.node-basic-grid .field select,.node-property-grid .field input{width:100%;height:34px;padding:0 12px;border:1px solid var(--ant-color-border)}.node-basic-grid .field:nth-child(5){grid-column:1/-1}.node-image-row{display:flex;align-items:flex-start;gap:12px;margin-left:20px}.node-image-preview{width:108px;height:108px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);display:grid;place-items:center;overflow:hidden;color:var(--ant-color-text-disabled);font-size:var(--ant-font-size-heading-2);cursor:pointer}.node-image-preview img{width:100%;height:100%;object-fit:contain}.node-image-row>div{display:flex;flex-direction:column;align-items:flex-start;gap:4px}.node-upload-link{color:var(--ant-color-primary);font-size:var(--ant-font-size);cursor:pointer}.node-image-row small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-disabled)}.node-image-row .text-button{margin:4px 0 0}.node-config-section .ant-qrcode{margin-left:20px;padding:0;background:var(--ant-color-bg-container)}
-.node-property-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 36px}.node-property-grid .field{min-width:0}.node-property-grid .field:nth-last-child(2){grid-column:auto}.node-property-grid input[type="date"]{color:var(--ant-color-text-secondary)}
-.binding-page{gap:28px}.edge-component-table{border:1px solid var(--ant-color-border);max-height:200px;overflow:auto}.edge-component-table>header,.edge-component-table>div{min-height:45px;display:grid;grid-template-columns:1.2fr 1fr 1fr 1.2fr 88px;align-items:center;padding:0 8px;border-bottom:1px solid var(--ant-color-border)}.edge-component-table>header{min-height:38px;background:var(--ant-color-bg-layout);border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary)}.edge-component-table>div:last-child{border-bottom:0}.edge-component-table>div.active{background:var(--ant-color-bg-layout)}.edge-component-table b{font-weight:400}.edge-component-table>div>span:last-child{display:flex;justify-content:flex-end;gap:8px}.edge-component-table button{width:28px;height:28px;border:0;background:transparent;color:var(--ant-color-primary);font-size:var(--ant-font-size)}.edge-component-table button:hover{background:var(--ant-color-bg-layout)}.binding-component-tabs{height:42px;display:flex;align-items:flex-end;justify-content:center;margin-bottom:12px}.binding-component-tabs button{height:34px;padding:0 20px;border:0;border-right:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);color:var(--ant-color-text-disabled)}.binding-component-tabs button:last-child{border-right:0}.binding-component-tabs button.active{color:var(--ant-color-primary);font-weight:600}.binding-parameter-head{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--ant-color-border);border-bottom:0;padding:8px 12px}.binding-parameter-head h4{margin:0}.binding-parameter-head .button{height:30px;margin:0}.binding-parameter-table{border:1px solid var(--ant-color-border);overflow:auto}.binding-parameter-table>header,.binding-parameter-table>div:not(.binding-empty){min-width:760px;display:grid;grid-template-columns:1fr 1.2fr 70px 64px 1fr 1.2fr 42px;align-items:center;gap:8px;padding:0 8px}.binding-parameter-table>header{height:38px;background:var(--ant-color-bg-layout);border-bottom:2px solid var(--ant-color-primary);font-size:var(--ant-font-size-sm);font-weight:600}.binding-parameter-table>div:not(.binding-empty){min-height:44px;border-bottom:1px solid var(--ant-color-border)}.binding-parameter-table input{width:100%;height:30px;border:1px solid var(--ant-color-border);padding:0 8px}.binding-parameter-table .trash{color:var(--ant-color-error)}.binding-empty{height:120px;display:grid;place-items:center;color:var(--ant-color-text-secondary)}.binding-import-backdrop{z-index:1300}.binding-import-dialog{width:min(620px,calc(100vw - 48px))}
-@media(max-width:1200px){.asset-node-drawer .ant-drawer-content-wrapper{width:78vw!important}.node-basic-grid,.node-property-grid{grid-template-columns:1fr}.node-basic-grid .field:nth-child(5){grid-column:auto}}@media(max-width:760px){.asset-node-drawer .ant-drawer-content-wrapper{width:100vw!important}.node-basic-grid .field,.node-property-grid .field{grid-template-columns:90px minmax(0,1fr)}.node-config-page{padding:20px 16px}.edge-component-table>header,.edge-component-table>div{grid-template-columns:1.2fr 1fr 1fr 72px}.edge-component-table>header>span:nth-child(4),.edge-component-table>div>span:nth-child(4){display:none}}
-
-/* 领域方案:从分步编辑调整为领域能力展示。 */
-.domain-showcase-page{display:flex;flex-direction:column;gap:16px}.domain-showcase-intro{min-height:92px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);display:grid;grid-template-columns:150px 1fr auto;align-items:center;padding:16px 20px;gap:24px}.domain-showcase-intro>div:first-child{display:flex;align-items:baseline;gap:8px;color:var(--ant-color-text-secondary)}.domain-showcase-intro>div:first-child b{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-text-base)}.domain-showcase-intro>p{margin:0;padding-left:24px;border-left:1px solid var(--ant-color-border);color:var(--ant-color-text-secondary);line-height:1.8}.domain-showcase-total{display:flex;gap:20px}.domain-showcase-total span{display:flex;flex-direction:column;gap:4px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-showcase-total b{color:var(--ant-color-text-base);font-size:var(--ant-font-size)}.domain-showcase-filter{height:52px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:flex;align-items:center;gap:12px;padding:12px 12px}.domain-showcase-filter label{width:300px;height:32px;border:1px solid var(--ant-color-border);display:flex;align-items:center;padding:0 12px;color:var(--ant-color-text-secondary)}.domain-showcase-filter label:focus-within{border-color:var(--ant-color-primary)}.domain-showcase-filter label .station-ui-input.ant-input{flex:1;min-width:0;width:auto;height:100%;border:0;outline:0;padding-left:8px}.domain-showcase-filter select{height:32px;min-width:180px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 32px 0 12px}.domain-showcase-filter>span{margin-left:auto;color:var(--ant-color-text-secondary)}.domain-showcase-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.domain-showcase-card{min-height:340px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);padding:20px;display:flex;flex-direction:column;cursor:pointer;transition:box-shadow .15s,border-color .15s}.domain-showcase-card:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}.domain-showcase-card.violet{border-top-color:var(--ant-color-primary)}.domain-showcase-card.green{border-top-color:var(--ant-color-success)}.domain-showcase-card.gray{border-top-color:var(--ant-color-border)}.domain-showcase-card>header{height:42px;display:flex;align-items:flex-start;justify-content:space-between}.domain-showcase-card>header>div:last-child{display:flex;align-items:center;gap:8px}.domain-showcase-mark{font-size:var(--ant-font-size-sm);font-weight:600;letter-spacing:1px;color:var(--ant-color-primary);background:var(--ant-color-bg-layout);padding:8px 8px}.violet .domain-showcase-mark{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.green .domain-showcase-mark{color:var(--ant-color-success);background:var(--ant-color-bg-layout)}.gray .domain-showcase-mark{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}.domain-showcase-card h2{font-size:var(--ant-font-size);margin:16px 0 8px;color:var(--ant-color-text-base)}.domain-showcase-card>p{height:44px;margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.75;overflow:hidden}.domain-showcase-card>img.domain-showcase-photo{display:block;width:100%;aspect-ratio:2/1;object-fit:cover;margin:10px 0 0;background:var(--ant-color-bg-layout)}.domain-showcase-capabilities{display:grid;grid-template-columns:1fr 1fr;margin-top:16px;border:1px solid var(--ant-color-border)}.domain-showcase-capabilities>span{min-height:62px;padding:12px;display:grid;grid-template-columns:22px 1fr;grid-template-rows:auto auto;align-items:center;border-right:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border)}.domain-showcase-capabilities>span:nth-child(2n){border-right:0}.domain-showcase-capabilities>span:nth-last-child(-n+2){border-bottom:0}.domain-showcase-capabilities .anticon{grid-row:1/3;color:var(--ant-color-primary);font-size:var(--ant-font-size)}.domain-showcase-capabilities b{font-size:var(--ant-font-size-sm)}.domain-showcase-capabilities small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-showcase-card>footer{margin-top:auto;padding-top:16px;display:flex;align-items:center;justify-content:space-between;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-showcase-card>footer button{height:30px;border:0;background:transparent;color:var(--ant-color-primary);padding:0;font-weight:600}.domain-showcase-card>footer button:hover{text-decoration:underline}
-
-.domain-capability-detail{display:flex;flex-direction:column;gap:12px}.domain-capability-back{align-self:flex-start;border:0;background:transparent;color:var(--ant-color-primary);padding:0;height:28px}.domain-capability-head{min-height:156px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);border-top:3px solid var(--ant-color-primary);padding:24px;display:grid;grid-template-columns:76px 1fr auto;gap:20px;align-items:center}.domain-capability-head.violet{border-top-color:var(--ant-color-primary)}.domain-capability-head.green{border-top-color:var(--ant-color-success)}.domain-capability-head.gray{border-top-color:var(--ant-color-border)}.domain-capability-code{width:76px;height:76px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-weight:600;font-size:var(--ant-font-size-sm);letter-spacing:.8px}.domain-capability-head.violet .domain-capability-code{color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.domain-capability-head.green .domain-capability-code{color:var(--ant-color-success);background:var(--ant-color-bg-layout)}.domain-capability-head.gray .domain-capability-code{color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}.domain-capability-copy>div{display:flex;align-items:center;gap:8px}.domain-capability-copy>div>span:last-child{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-capability-copy h2{margin:8px 0 4px;font-size:var(--ant-font-size-heading-3)}.domain-capability-copy p{margin:0;color:var(--ant-color-text-secondary);line-height:1.6}.domain-capability-copy small{display:block;margin-top:8px;color:var(--ant-color-text-secondary)}.domain-capability-actions{display:flex;align-items:center;gap:4px}.domain-capability-summary{height:66px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:grid;grid-template-columns:repeat(4,1fr)}.domain-capability-summary span{display:flex;align-items:baseline;justify-content:center;gap:8px;border-right:1px solid var(--ant-color-border);color:var(--ant-color-text-secondary)}.domain-capability-summary span:last-child{border-right:0}.domain-capability-summary b{font-size:var(--ant-font-size-heading-3);color:var(--ant-color-text-base)}.domain-capability-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.domain-capability-card{min-height:286px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);display:flex;flex-direction:column}.domain-capability-card>header{height:72px;padding:12px 16px;border-bottom:1px solid var(--ant-color-border);display:grid;grid-template-columns:38px 1fr auto;align-items:center;gap:12px}.domain-capability-icon{width:36px;height:36px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;font-size:var(--ant-font-size-heading-4)}.domain-capability-card>header h3{margin:0 0 4px;font-size:var(--ant-font-size)}.domain-capability-card>header p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.domain-capability-card>header>strong{font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout);padding:4px 8px}.domain-capability-body{min-height:158px;padding:16px;background:var(--ant-color-bg-layout)}.domain-capability-card>footer{height:50px;margin-top:auto;border-top:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:flex-end;padding:0 12px;gap:8px}.domain-capability-card>footer button{height:29px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary);padding:0 12px}.domain-capability-card>footer button:first-child{color:var(--ant-color-primary);border-color:var(--ant-color-primary-bg)}.domain-capability-card>footer button.capability-edit-button{margin-right:auto}.domain-capability-card>footer button:hover{border-color:var(--ant-color-primary);color:var(--ant-color-primary)}.capability-device-tags{display:flex;flex-wrap:wrap;gap:8px}.capability-device-tags span{height:28px;display:flex;align-items:center;gap:8px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:0 8px;color:var(--ant-color-text-secondary)}.capability-device-tags b{min-width:18px;height:18px;line-height:18px;text-align:center;border-radius:var(--ant-border-radius);background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.capability-mini-tree{margin-top:16px;height:62px;display:flex;align-items:center;position:relative;padding-left:4px}.capability-mini-tree>i{width:10px;height:10px;border:2px solid var(--ant-color-primary);border-radius:50%;margin-right:8px}.capability-mini-tree>b{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px 12px;font-size:var(--ant-font-size-sm)}.capability-mini-tree>span{position:relative;margin-left:28px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px 8px;font-size:var(--ant-font-size-sm)}.capability-mini-tree>span:before{content:"";position:absolute;left:-29px;top:50%;width:28px;border-top:1px solid var(--ant-color-primary)}.capability-page-pair{display:grid;grid-template-columns:1fr 1fr;gap:16px;height:128px}.mini-dashboard,.mini-twin{position:relative;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout);padding:20px 8px 8px;display:grid;grid-template-columns:1.2fr .8fr;gap:4px}.mini-dashboard:before{content:"仪表板";position:absolute;left:8px;top:5px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.mini-dashboard i{background:var(--ant-color-primary)}.mini-dashboard i:nth-child(2),.mini-dashboard i:nth-child(3){background:var(--ant-color-primary-bg)}.mini-twin{overflow:hidden;background:linear-gradient(160deg,var(--ant-color-bg-layout),var(--ant-color-bg-layout))}.mini-twin:before{content:"SC 数字孪生";position:absolute;left:8px;top:5px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.mini-twin i{width:38px;height:27px;background:var(--ant-color-primary);transform:skewY(-18deg);align-self:end;box-shadow:none}.mini-twin span{position:absolute;right:7px;bottom:5px;color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.capability-file-list{display:flex;flex-direction:column;gap:8px}.capability-file-list span{height:36px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;align-items:center;padding:0 8px;color:var(--ant-color-text-secondary)}.capability-file-list b{width:24px;height:24px;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);display:grid;place-items:center;margin-right:8px;font-size:var(--ant-font-size-sm)}.capability-menu-preview{width:180px;min-height:126px;background:var(--ant-color-bg-layout);border:1px solid var(--ant-color-border);display:flex;flex-direction:column;padding:8px;margin:auto}.capability-menu-preview>b{height:27px;background:var(--ant-color-bg-container);padding:8px 8px;font-size:var(--ant-font-size-sm)}.capability-menu-preview>span{height:22px;padding:4px 12px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.capability-menu-preview>span:first-of-type{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}
-
-.domain-capability-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.domain-capability-card{height:calc(100vh - 450px);min-height:500px;max-height:680px}.domain-capability-card>header{height:auto;min-height:82px;padding:12px 16px;grid-template-columns:38px minmax(0,1fr)}.domain-capability-card>header>strong{grid-column:1/-1;justify-self:end}.domain-capability-body.capability-empty-slot{flex:1;min-height:0;padding:0;background:var(--ant-color-bg-container)}.domain-capability-body.capability-asset-content,.domain-capability-body.capability-page-content,.domain-capability-body.capability-twin-content,.domain-capability-body.capability-knowledge-content{flex:1;min-height:0;padding:16px;background:var(--ant-color-bg-container);overflow-y:auto;scrollbar-gutter:stable}.capability-section-title{height:30px;display:flex;align-items:flex-start;justify-content:space-between;border-bottom:1px solid var(--ant-color-border);margin-bottom:12px}.capability-section-title b{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.capability-section-title span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.device-template-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(86px,1fr));gap:8px}.device-template-tile{aspect-ratio:1/1;min-height:88px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:8px;text-align:center;cursor:pointer;transition:border-color .15s,box-shadow .15s}.device-template-tile:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}.device-template-tile>span{width:38px;height:38px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-4)}.device-template-tile>b{width:100%;font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.extension-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.extension-page-tile{min-width:0;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);overflow:hidden}.extension-page-tile .dashboard-thumb-live{height:92px;min-height:92px;border:0;border-bottom:1px solid var(--ant-color-border);background:var(--ant-color-bg-layout)}.extension-page-tile>b{display:block;height:34px;line-height:34px;padding:0 8px;font-size:var(--ant-font-size-sm);font-weight:600;color:var(--ant-color-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.extension-page-tile:hover{border-color:var(--ant-color-primary-bg);box-shadow:none}.twin-scene-card-list{display:flex;flex-direction:column;gap:12px}.twin-scene-card{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);overflow:hidden}.twin-scene-card .sc-scene{height:145px}.twin-scene-card .sc-scene:after{height:72px;bottom:7px}.twin-scene-card .sc-scene i{transform:scale(.7);transform-origin:left bottom}.twin-scene-card>div:last-child{height:42px;display:flex;align-items:center;justify-content:space-between;padding:0 12px}.twin-scene-card b{font-size:var(--ant-font-size-sm);font-weight:600}.twin-scene-card small{font-size:var(--ant-font-size-sm);color:var(--ant-color-success);background:var(--ant-color-bg-layout);padding:4px 8px}.knowledge-group+.knowledge-group{margin-top:20px}.knowledge-item-list{display:flex;flex-direction:column;gap:8px}.knowledge-item{min-height:55px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);display:grid;grid-template-columns:38px minmax(0,1fr);align-items:center;gap:12px;padding:8px}.knowledge-type{width:34px;height:34px;display:grid;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size)}.knowledge-type.link{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}.knowledge-item>div{min-width:0;display:flex;flex-direction:column;gap:4px}.knowledge-item b{font-size:var(--ant-font-size-sm);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.knowledge-item small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.knowledge-item:hover{border-color:var(--ant-color-primary-bg)}.device-template-empty{min-height:150px;display:grid;place-items:center;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}
-
-.capability-preview-modal{width:min(1040px,94vw);max-width:94vw}.capability-preview-modal>.modal-body{min-height:520px;padding:20px}.capability-assets-preview,.capability-knowledge-preview{display:grid;grid-template-columns:1.2fr .8fr;gap:16px}.capability-assets-preview>section,.capability-knowledge-preview>section{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:16px}.capability-assets-preview h3,.capability-knowledge-preview h3{margin:0 0 12px;font-size:var(--ant-font-size)}.capability-full-tree{min-height:380px;background:var(--ant-color-bg-layout);padding:24px}.capability-full-tree>b{display:block;width:180px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:12px 12px;margin-bottom:20px}.capability-full-tree>div{margin:0 0 12px 36px;border-left:1px solid var(--ant-color-primary);padding-left:24px;position:relative}.capability-full-tree>div:before{content:"";position:absolute;left:0;top:13px;width:20px;border-top:1px solid var(--ant-color-primary)}.capability-full-tree span{display:block;width:170px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:8px 8px}.capability-full-tree small{display:block;margin:4px 0 0 12px;color:var(--ant-color-text-secondary)}.capability-preview-tabs{height:44px;display:flex;border-bottom:1px solid var(--ant-color-border);margin-bottom:16px}.capability-preview-tabs button{height:44px;border:0;border-bottom:3px solid transparent;background:var(--ant-color-bg-container);padding:0 20px;color:var(--ant-color-text-secondary)}.capability-preview-tabs button.active{color:var(--ant-color-primary);border-bottom-color:var(--ant-color-primary)}.capability-preview-tabs b{font-size:var(--ant-font-size-sm);background:var(--ant-color-bg-layout);padding:4px 8px;border-radius:var(--ant-border-radius);margin-left:4px}.capability-dashboard-grid,.capability-twin-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.capability-dashboard-grid article,.capability-twin-grid article{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.capability-dashboard-grid .dashboard-thumb-live{height:230px!important;min-height:230px;background:var(--ant-color-bg-layout)}.capability-dashboard-grid article>div:last-child,.capability-twin-grid article>div:last-child{padding:12px 12px;display:flex;flex-direction:column;gap:4px}.capability-dashboard-grid article span,.capability-twin-grid article span{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.sc-scene{height:230px;position:relative;overflow:hidden;background:linear-gradient(#edf5f7,#d9e5e8);perspective:500px}.sc-scene:after{content:"";position:absolute;left:8%;right:8%;bottom:18px;height:105px;background:linear-gradient(160deg,#b7cbd1,#eef4f5);transform:rotateX(58deg);border:1px solid #9fb7bf}.sc-scene i{position:absolute;z-index:2;width:75px;height:62px;background:#779aa5;border:1px solid #63828c;box-shadow:9px -9px #aec4ca}.sc-scene i:nth-child(1){left:20%;bottom:68px}.sc-scene i:nth-child(2){left:43%;bottom:86px;background:#7daeb9}.sc-scene i:nth-child(3){left:62%;bottom:57px;width:56px;height:76px}.sc-scene i:nth-child(4){left:76%;bottom:96px;width:40px;height:45px;background:#51a7c6}.sc-scene>span{position:absolute;z-index:3;right:12px;top:10px;background:#ffffffdd;color:#477783;padding:4px 8px;font-size:10px}.capability-knowledge-preview>section>div{height:58px;border-bottom:1px solid var(--ant-color-border);display:flex;align-items:center;gap:12px}.capability-knowledge-preview>section>div>svg{font-size:var(--ant-font-size-heading-4);color:var(--ant-color-primary)}.capability-knowledge-preview>section>div>span:last-child{display:flex;flex-direction:column;gap:4px}.capability-knowledge-preview small{color:var(--ant-color-text-secondary)}.file-type{width:38px;height:27px;display:grid!important;place-items:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-sm)}.capability-menu-full{display:grid;grid-template-columns:230px 1fr;min-height:500px;border:1px solid var(--ant-color-border)}.capability-menu-full aside{background:var(--ant-color-bg-layout);border-right:1px solid var(--ant-color-border);padding:12px}.capability-menu-full aside>b{display:block;padding:12px 8px;border-bottom:1px solid var(--ant-color-border)}.capability-menu-full aside>div{display:flex;flex-direction:column}.capability-menu-full aside strong{padding:12px 8px;font-weight:600}.capability-menu-full aside span{padding:8px 24px;color:var(--ant-color-text-secondary)}.capability-menu-full aside>div:first-of-type strong{background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}.capability-menu-full>section{background:var(--ant-color-bg-layout)}.menu-screen-title{height:48px;background:var(--ant-color-bg-container);border-bottom:1px solid var(--ant-color-border);padding:16px 20px;font-weight:600}.menu-screen-canvas{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:24px}.menu-screen-canvas i{height:170px;background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);box-shadow:none}.menu-screen-canvas i:nth-child(2){box-shadow:none}.menu-screen-canvas i:nth-child(3){box-shadow:none}.menu-screen-canvas i:nth-child(4){box-shadow:none}
-
-.domain-import-page{max-width:980px}.domain-import-layout{display:grid;grid-template-columns:1fr 1fr;gap:24px;padding:8px}.domain-import-drop{min-height:310px;border:1px dashed var(--ant-color-primary-bg);background:var(--ant-color-bg-layout);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:28px;cursor:pointer}.domain-import-drop:hover,.domain-import-drop.selected{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.domain-import-drop>svg{font-size:var(--ant-font-size-heading-2);color:var(--ant-color-primary)}.domain-import-drop>b{font-size:var(--ant-font-size)}.domain-import-drop>span{max-width:320px;color:var(--ant-color-text-secondary);line-height:1.7}.domain-import-form{display:flex;flex-direction:column;gap:16px}.domain-import-form .field input,.domain-import-form .field select,.domain-import-form .field textarea{width:100%}.domain-package-check{margin:20px 8px 0;padding:16px;background:var(--ant-color-bg-layout);display:flex;align-items:center;gap:8px}.domain-package-check>b{margin-right:12px}.domain-package-check>span{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);padding:8px 8px;color:var(--ant-color-text-secondary)}.domain-import-actions{display:flex;justify-content:flex-end;margin:16px 0 4px}.domain-maintain-modal{width:min(720px,92vw)}.domain-maintain-note{margin-top:20px;padding:12px 16px;background:var(--ant-color-bg-layout);border-left:3px solid var(--ant-color-primary);color:var(--ant-color-text-secondary)}.capability-package-modal{width:min(660px,92vw)}.capability-package-modal>.modal-body>div{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}.capability-package-modal>.modal-body>div>span{min-height:70px;border:1px solid var(--ant-color-border);padding:12px;display:grid;grid-template-columns:28px 1fr;grid-template-rows:auto auto;align-items:center}.capability-package-modal svg{grid-row:1/3;color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-4)}.capability-package-modal small{color:var(--ant-color-text-secondary)}
-@media(max-width:1320px){.domain-showcase-grid{grid-template-columns:1fr 1fr}.domain-showcase-intro{grid-template-columns:130px 1fr}.domain-showcase-total{grid-column:1/-1;border-top:1px solid var(--ant-color-border);padding-top:12px}.domain-capability-grid{grid-template-columns:1fr 1fr}.domain-capability-card{height:390px;min-height:0}.domain-capability-body.capability-empty-slot{min-height:0}.domain-capability-mini-tree>span:nth-last-child(n+4){display:none}}
-@media(max-width:900px){.domain-showcase-grid,.domain-capability-grid,.capability-assets-preview,.capability-knowledge-preview,.domain-import-layout{grid-template-columns:1fr}.domain-showcase-intro{grid-template-columns:1fr}.domain-showcase-intro>p{padding:0;border:0}.domain-capability-head{grid-template-columns:62px 1fr}.domain-capability-code{width:62px;height:62px}.domain-capability-actions{grid-column:1/-1;justify-content:flex-end}.domain-capability-summary{grid-template-columns:1fr 1fr;height:auto}.domain-capability-summary span{min-height:55px;border-bottom:1px solid var(--ant-color-border)}.domain-capability-summary span:nth-child(2n){border-right:0}.capability-dashboard-grid,.capability-twin-grid{grid-template-columns:1fr}}
-.capability-dashboard-grid article span,.capability-twin-grid article span{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;line-height:1.55}
-@media(max-width:600px){.domain-showcase-filter{height:auto;flex-wrap:wrap}.domain-showcase-filter label,.domain-showcase-filter select{width:100%;min-width:0}.domain-showcase-filter>span{width:100%;text-align:right}.domain-capability-head{grid-template-columns:1fr;padding:16px}.domain-capability-code{width:auto;height:38px;justify-self:start;padding:0 12px}.domain-capability-actions{width:100%;flex-direction:column}.domain-capability-actions .button{width:100%;margin:0}.domain-capability-card>header{grid-template-columns:38px 1fr}.domain-capability-card>header>strong{grid-column:2}.capability-mini-tree>span:nth-of-type(n+2){display:none}.capability-menu-full{grid-template-columns:1fr}.capability-menu-full>section{display:none}.capability-package-modal>.modal-body>div{grid-template-columns:1fr}.domain-package-check{align-items:flex-start;flex-wrap:wrap}.capability-preview-modal>.modal-body{padding:12px}}
-
-/* 领域扩展仪表板管理:用领域下拉框替代分类树。 */
-.domain-dashboard-manager{display:flex;flex-direction:column;gap:12px}.dashboard-manager-panel{min-height:620px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container)}.dashboard-manager-toolbar{height:54px;border-bottom:1px solid var(--ant-color-border);display:grid;grid-template-columns:300px 1fr auto;align-items:center}.dashboard-manager-toolbar>label{height:100%;display:flex;align-items:center;gap:12px;padding:0 12px;border-right:1px solid var(--ant-color-border)}.dashboard-manager-toolbar>label span{font-weight:600;white-space:nowrap}.dashboard-manager-toolbar select{height:32px;min-width:190px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);padding:0 28px 0 8px}.dashboard-manager-toolbar>strong{padding-left:20px;font-size:var(--ant-font-size)}.dashboard-manager-actions{display:flex;align-items:center;gap:8px;padding-right:12px}.dashboard-manager-actions .button{margin:0;min-width:72px}.dashboard-manager-search{width:220px;height:32px;border:1px solid var(--ant-color-border);display:flex!important;align-items:center;gap:8px;padding:0 8px!important}.dashboard-manager-search input{width:100%;height:28px;border:0;outline:0}.dashboard-manager-grid{display:grid;grid-template-columns:repeat(2,minmax(280px,354px));gap:24px;padding:20px 24px;align-content:start}.dashboard-manager-grid article{height:124px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:16px 44px 12px 16px;position:relative}.dashboard-manager-grid h3{margin:0 0 12px;font-size:var(--ant-font-size);font-weight:600}.dashboard-manager-grid p{margin:0 0 8px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.dashboard-card-more{position:absolute;right:8px;top:10px;width:30px;height:30px;border:0;background:transparent;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-heading-4)}.dashboard-card-more:hover{background:var(--ant-color-bg-layout);color:var(--ant-color-primary)}
-@media(max-width:1100px){.dashboard-manager-toolbar{height:auto;grid-template-columns:1fr}.dashboard-manager-toolbar>label{height:50px;border-right:0;border-bottom:1px solid var(--ant-color-border)}.dashboard-manager-toolbar>strong{height:44px;display:flex;align-items:center;padding-left:12px}.dashboard-manager-actions{padding:0 12px 12px}.dashboard-manager-grid{grid-template-columns:1fr 1fr}}@media(max-width:720px){.dashboard-manager-actions{flex-wrap:wrap}.dashboard-manager-search{width:100%}.dashboard-manager-grid{grid-template-columns:1fr;padding:12px}}
-
-/* 仪表板资源页视觉密度:宽屏四列,等宽卡片,不保留分类树的空白。 */
-.dashboard-manager-panel{min-height:calc(100vh - 154px);background:var(--ant-color-bg-layout);border-color:var(--ant-color-border)}.dashboard-manager-toolbar{height:56px;grid-template-columns:286px 1fr;background:var(--ant-color-bg-container)}.dashboard-manager-toolbar>label{padding:0 16px;gap:12px}.dashboard-manager-toolbar>label span{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}.dashboard-manager-toolbar select{height:32px;min-width:176px;border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);color:var(--ant-color-text-secondary)}.dashboard-manager-actions{justify-self:end;gap:8px;padding-right:12px}.dashboard-manager-actions .button{height:32px;min-width:72px;padding:0 12px;border-radius:var(--ant-border-radius);font-weight:600}.dashboard-manager-search{width:210px;height:32px;background:var(--ant-color-bg-container);border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);color:var(--ant-color-text-secondary)}.dashboard-manager-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 20px;padding:20px 20px;align-items:start}.dashboard-manager-grid article{width:auto;height:124px;border-color:var(--ant-color-border);border-radius:var(--ant-border-radius);padding:16px 44px 12px 16px;box-shadow:none;transition:border-color .15s,box-shadow .15s}.dashboard-manager-grid article:hover{border-color:var(--ant-color-primary-hover);box-shadow:none}.dashboard-manager-grid h3{margin:0 0 12px;font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dashboard-manager-grid p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.dashboard-card-more{right:8px;top:9px;transform:rotate(90deg)}.dashboard-card-more:hover{background:transparent;color:var(--ant-color-primary)}
-@media(max-width:1550px){.dashboard-manager-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
-@media(max-width:1120px){.dashboard-manager-toolbar{height:auto;grid-template-columns:1fr}.dashboard-manager-toolbar>label{height:50px;border-right:0;border-bottom:1px solid var(--ant-color-border)}.dashboard-manager-actions{justify-self:stretch;padding:0 12px 12px}.dashboard-manager-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
-@media(max-width:720px){.dashboard-manager-actions{flex-wrap:wrap}.dashboard-manager-search{width:100%}.dashboard-manager-grid{grid-template-columns:1fr;padding:12px}}
+++ /dev/null
-/* Station hierarchy and five-step configuration: company-style flat workspaces. */
-.station-application-settings{display:flex;flex-direction:column;gap:16px}
-.station-application-basic,.station-application-menu{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}
-.station-application-basic>header,.station-application-menu>header{display:flex;align-items:center;justify-content:space-between;min-height:52px;padding:12px 20px;border-bottom:1px solid var(--ant-color-border);gap:16px}
-.station-application-settings h2{font-size:var(--ant-font-size);font-weight:600;margin:0;color:var(--ant-color-text-base)}
-.station-application-settings h2 small{font-size:var(--ant-font-size-sm);font-weight:400;color:var(--ant-color-text-secondary);margin-left:12px}
-.station-application-fields{display:grid;grid-template-columns:minmax(240px,1.2fr) minmax(280px,1.6fr) minmax(180px,.75fr);gap:32px;padding:20px;align-items:center}
-.station-application-fields>label,.station-application-icon{display:flex;align-items:center;gap:16px;min-width:0}
-.station-application-fields>label>span,.station-application-icon>span{flex-shrink:0;min-width:56px;white-space:nowrap;color:var(--ant-color-text-secondary)}
-.station-application-fields i:not(.station-application-scheme i),.station-menu-edit-fields>label>span>i{color:var(--ant-color-error);font-style:normal}
-.station-application-name input{width:100%;min-width:0}
-.station-application-settings input:not([type=file]),.station-application-settings select,.station-menu-edit-fields input{font:inherit;height:32px;min-width:0;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:4px 12px;background:var(--ant-color-bg-container)}
-.station-icon-control{display:flex;align-items:center;gap:12px;min-width:0}
-.station-icon-image{flex-shrink:0;width:40px;height:40px;border:1px solid var(--ant-color-border);display:flex;align-items:center;justify-content:center;background:var(--ant-color-bg-layout);color:var(--ant-color-primary);font-size:var(--ant-font-size-heading-3)}
-.station-icon-image img{width:100%;height:100%;object-fit:contain}
-.station-icon-upload{font-size:var(--ant-font-size);color:var(--ant-color-primary);cursor:pointer;white-space:nowrap;flex-shrink:0}
-.station-icon-upload.disabled{opacity:.5;cursor:default}
-.station-icon-file{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}
-.station-icon-control>.link{border:0;background:none;white-space:nowrap;font-size:var(--ant-font-size-sm);padding:0}
-.station-application-scheme>div{display:flex;align-items:center;gap:12px;flex:1;min-width:0}
-.station-application-scheme>div>i{width:20px;height:20px;flex-shrink:0;border:1px solid color-mix(in srgb,var(--ant-color-border) 7%,transparent)}
-.station-application-scheme select{width:100%}
-.station-application-menu-actions{display:flex;align-items:center;gap:8px;white-space:nowrap}
-.station-application-menu-scroll{height:calc(100vh - 515px);min-height:240px;overflow:auto}
-.station-application-menu-table{table-layout:fixed;width:100%;min-width:680px}
-.station-application-menu-table thead{position:sticky;top:0;z-index:1}
-.station-application-menu-table th:first-child{width:40%}.station-application-menu-table th:nth-child(2){width:30%}.station-application-menu-table th:nth-child(3){width:18%}.station-application-menu-table th:last-child{width:120px}
-.station-application-menu-table td{height:48px;padding:8px 16px;font-size:var(--ant-font-size)}.station-application-menu-table th{font-size:var(--ant-font-size);font-weight:600}
-.station-application-menu-table tr.station-menu-group-row td{background:var(--ant-color-bg-layout);color:var(--ant-color-text-base)}
-.station-menu-name{display:flex;align-items:center;gap:8px;min-width:0}
-.station-menu-name>span:not(.station-menu-child-guide){white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.station-menu-name small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);flex-shrink:0;font-weight:400}
-.station-menu-disclosure{flex-shrink:0;border:0;background:none;width:16px;padding:0;color:var(--ant-color-text-secondary);cursor:pointer}
-.station-menu-child-guide{width:16px;flex-shrink:0;align-self:stretch;position:relative}
-.station-menu-child-guide:before{content:"";position:absolute;left:3px;top:3px;width:8px;height:9px;border-left:1px solid var(--ant-color-border);border-bottom:1px solid var(--ant-color-border)}
-.station-menu-association{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ant-color-text-secondary)}
-.station-application-settings .station-menu-row-actions .ant-btn{color:var(--ant-color-primary)}.station-application-settings .station-menu-row-actions .ant-btn:disabled{color:var(--ant-color-text-disabled)}
-.station-application-menu-note{padding:12px 20px;display:flex;justify-content:space-between;gap:16px;border-top:1px solid var(--ant-color-border);font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
-.station-menu-edit-fields{display:grid;gap:24px;padding-top:12px}.station-menu-edit-fields>label{display:grid;grid-template-columns:80px minmax(0,1fr);gap:16px;align-items:center}.station-menu-edit-fields>label>span{text-align:right;color:var(--ant-color-text-secondary)}.station-menu-edit-fields input[readonly]{background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary)}
-.station-application-preview{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);min-height:calc(100vh - 150px)}
-.station-application-preview>header{display:flex;align-items:center;gap:12px;border-top:3px solid var(--application-color);border-bottom:1px solid var(--ant-color-border);padding:16px;font-size:var(--ant-font-size)}
-.station-application-preview>header img{height:36px;width:36px;object-fit:contain}.station-application-preview>header>.anticon{font-size:var(--ant-font-size-heading-3);color:var(--application-color)}.station-application-preview strong{font-weight:600}
-.station-application-preview nav{padding:8px 0}.station-application-preview nav>button{display:flex;align-items:center;justify-content:space-between;width:100%;border:0;background:var(--ant-color-bg-container);padding:16px 20px;font:inherit;text-align:left;color:var(--ant-color-text-base);cursor:pointer}
-.station-application-preview nav>button.group{background:var(--ant-color-bg-layout);font-size:var(--ant-font-size)}.station-application-preview nav>button.page.active{background:color-mix(in srgb,var(--application-color) 14%,var(--ant-color-bg-container));color:var(--application-color);border-left:3px solid var(--application-color)}
-.station-menu-preview-current{display:flex;flex-direction:column;gap:12px;border-top:1px solid var(--ant-color-border);padding:20px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-menu-preview-current b{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-color-text-base)}
-@media(max-width:1400px){.station-application-fields{gap:16px;grid-template-columns:minmax(210px,1.1fr) minmax(220px,1.2fr) minmax(150px,.75fr)}.station-icon-file{display:none}.station-application-menu-note>span:last-child{display:none}}
-@media(max-width:1050px){.station-application-fields{grid-template-columns:1fr;gap:20px}.station-application-fields>label>span,.station-application-icon>span{width:70px}.station-application-name input{max-width:450px}.station-application-scheme>div{max-width:260px}}
-.station-menu-configuration .station-menu-editor-table th:first-child{width:32%;white-space:nowrap}.station-menu-configuration .station-menu-editor-table th:nth-child(2){width:28%}.station-menu-configuration .station-menu-editor-table th:nth-child(3){width:20%}.station-menu-configuration .station-menu-editor-table th:last-child{width:110px;white-space:nowrap}.station-menu-configuration .station-menu-editor-table th,.station-menu-configuration .station-menu-editor-table td{font-size:var(--ant-font-size)}
-@media(max-width:1400px){.station-menu-configuration .station-menu-live-preview{display:block;grid-column:1/-1;border-left:0;border-top:1px solid var(--ant-color-border);max-height:220px;overflow:auto}.station-menu-configuration .station-menu-live-preview nav{display:flex;flex-wrap:wrap}.station-menu-configuration .station-menu-live-preview nav>div{min-width:150px}}
-.station-page-selection .station-module-grid{max-height:440px;overflow:auto}.station-page-selection .station-module-grid>section>div{grid-template-columns:repeat(2,minmax(0,1fr))}.station-page-selection .station-module-grid>section>div>label{font-size:var(--ant-font-size-sm)}
-.station-hierarchy-workspace .station-hierarchy-table{min-width:1100px;table-layout:fixed}
-.station-hierarchy-workspace .station-hierarchy-table th:first-child,.station-hierarchy-workspace .station-hierarchy-table td:first-child{width:28%;overflow:hidden}
-.station-hierarchy-workspace .station-hierarchy-table th:nth-child(2){width:14%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(3){width:17%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(4){width:10%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(5){width:6%}.station-hierarchy-workspace .station-hierarchy-table th:nth-child(6){width:15%}.station-hierarchy-workspace .station-hierarchy-table th:last-child,.station-hierarchy-workspace .station-hierarchy-table td:last-child{width:130px}
-.station-hierarchy-workspace .station-name-cell>.link{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.station-hierarchy-workspace,.station-page-selection,.station-menu-configuration{font-size:var(--ant-font-size);color:var(--ant-color-text-base);min-width:0}
-.station-list-panel,.station-template-panel,.station-page-module-panel,.station-menu-panel,.station-menu-app-info{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border)}
-.station-list-panel{min-height:calc(100vh - 260px);display:flex;flex-direction:column;border-radius:var(--ant-border-radius-lg,8px);overflow:hidden}
-/* 品牌标准:surface 容器统一使用品牌圆角 */
-.station-template-panel,.station-page-module-panel,.station-menu-panel,.station-menu-app-info,.station-application-basic,.station-application-menu,.station-config-context,.station-opsflow-blank,.station-page-selection,.station-application-preview{border-radius:var(--ant-border-radius);overflow:hidden}
-.station-hierarchy-note{display:flex;align-items:center;gap:12px;padding:16px;border-bottom:1px solid var(--ant-color-border);color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}
-.station-hierarchy-note>.anticon{color:var(--ant-color-primary)}.station-hierarchy-note>span:last-child{margin-left:auto;white-space:nowrap}
-.station-list-toolbar{display:flex;gap:12px;align-items:center;padding:16px}.station-list-toolbar>div{margin-left:auto;display:flex;gap:8px}
-.station-search{display:flex;align-items:center;gap:8px;height:32px;width:260px;border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);padding:0 12px;background:var(--ant-color-bg-container);color:var(--ant-color-text-secondary)}.station-search input{border:0!important;outline:0;width:100%;height:28px;background:transparent}
-.station-list-toolbar select,.station-basic-fields select,.station-basic-fields input,.station-basic-fields textarea,.station-create-template>select,.station-template-filter select,.station-menu-app-info input,.station-menu-editor-table input,.station-menu-editor-table select{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);background:var(--ant-color-bg-container);padding:4px 12px;min-height:32px;font:inherit}
-.station-list-toolbar select{width:190px}.station-table-scroll{overflow:auto;flex:1}.station-hierarchy-table th:first-child{width:29%}.station-hierarchy-table th:nth-child(3){width:19%}.station-hierarchy-table td{height:54px}.station-hierarchy-table th{white-space:nowrap}.station-name-cell{display:flex;align-items:center;gap:8px;min-width:210px}.station-name-cell>button:first-child:not(.link){border:0;background:none;width:18px;padding:0}.station-tree-spacer{width:18px}.station-name-cell small{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);border:1px solid var(--ant-color-border);padding:0 4px}.station-template-name{display:block}.station-pending-template{display:block;font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.station-table-actions{display:flex;align-items:center;white-space:nowrap}
-.station-drawer-actions{display:flex;justify-content:flex-end;gap:8px}.station-drawer-note{padding:12px;background:var(--ant-color-bg-layout);color:var(--ant-color-text-secondary);margin:0 0 24px}.station-basic-fields{display:grid;gap:20px}.station-basic-fields>label{display:grid;grid-template-columns:100px 1fr;gap:12px;align-items:start}.station-basic-fields>label>span{padding-top:8px;text-align:right}.station-basic-fields i{color:var(--ant-color-error);font-style:normal}.station-basic-fields textarea{resize:vertical}.station-create-template{margin-top:32px;border-top:1px solid var(--ant-color-border);padding-top:16px}.station-create-template h3{font-size:var(--ant-font-size);font-weight:600}.station-template-modes{display:flex;gap:24px;margin-bottom:16px}.station-template-modes input{accent-color:var(--ant-color-primary)}.station-create-template>select{width:100%}.station-selected-template{padding:16px;border:1px solid var(--ant-color-primary-bg);background:var(--ant-color-bg-layout);margin-top:16px}.station-selected-template p,.station-template-footnote{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);line-height:1.7}.station-selected-template span{color:var(--ant-color-text-secondary)}
-.station-config-context{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding:12px 16px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);font-size:var(--ant-font-size-sm)}.station-config-context>div{display:flex;align-items:center;gap:12px}.station-context-divider{height:16px;border-left:1px solid var(--ant-color-border)}.station-config-context>div:last-child>span{color:var(--ant-color-text-secondary)}.station-config-context .ant-tag{margin:0}
-.station-template-panel{border-top:3px solid var(--ant-color-primary);margin-bottom:16px}.station-template-panel>header,.station-page-module-panel>header,.station-menu-panel>header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 20px;border-bottom:1px solid var(--ant-color-border)}.station-template-panel h2,.station-page-module-panel h2,.station-menu-panel h2{font-size:var(--ant-font-size);font-weight:600;margin:0 0 8px}.station-template-panel>header p,.station-page-module-panel>header p,.station-menu-panel>header p{margin:0;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-template-panel>header>div:last-child,.station-menu-panel>header>div:last-child{display:flex;gap:8px;white-space:nowrap}
-.station-template-filter{display:flex;align-items:center;gap:12px;padding:16px 20px;color:var(--ant-color-text-secondary)}.station-template-filter>span:last-child{margin-left:auto;font-size:var(--ant-font-size-sm)}.station-template-filter select{width:200px}.station-template-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:0 20px 20px;max-height:300px;overflow:auto}.station-template-cards>button{border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);text-align:left;padding:16px;min-width:0}.station-template-cards>button.selected{border-color:var(--ant-color-primary);background:var(--ant-color-bg-layout)}.station-template-cards>button>div{display:flex;align-items:center;gap:8px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-template-cards .anticon{margin-left:auto;color:var(--ant-color-primary)}.station-template-cards h3{font-size:var(--ant-font-size);font-weight:600;margin:16px 0 8px}.station-template-cards p{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);height:34px;overflow:hidden;margin:0 0 16px}.station-template-cards footer{display:flex;gap:24px;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-template-cards footer b{color:var(--ant-color-text-base)}.station-template-empty{padding:28px;text-align:center;color:var(--ant-color-text-secondary)}
-.station-overview-layout .form-section-nav{display:none}.station-overview-layout .company-form-page{display:block}.station-overview-layout .single-column-form{max-width:none}.station-overview-layout .company-form-page>.single-column-form{width:100%}.station-opsflow-blank{background:var(--ant-color-bg-container);border:1px solid var(--ant-color-border);min-height:calc(100vh - 342px)}
-.station-unified-assets .asset-editor-workbench{height:calc(100vh - 390px);min-height:430px;grid-template-columns:220px minmax(0,1fr)}.station-unified-assets .asset-tree-canvas{min-height:0;height:calc(100% - 52px);overflow:auto}.station-unified-assets .asset-template-panel{max-height:calc(100vh - 575px);min-height:220px;overflow:auto}.station-unified-assets .asset-editor-toolbar{gap:8px;padding:0 8px}.station-unified-assets .asset-editor-toolbar>div{gap:4px}.station-unified-assets .asset-tree-search{width:160px}.station-unified-assets .asset-node-list{max-height:calc(100vh - 445px);overflow:auto}
-.station-page-selection{display:flex;flex-direction:column;gap:16px}.station-selection-summary{display:flex;gap:28px;border-bottom:1px solid var(--ant-color-border);padding:12px 20px;color:var(--ant-color-text-secondary);background:var(--ant-color-bg-layout)}.station-selection-summary>span:last-child{margin-left:auto;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-module-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;padding:20px}.station-module-grid>section{border:1px solid var(--ant-color-border)}.station-module-grid>section>header{display:flex;justify-content:space-between;padding:12px;background:var(--ant-color-bg-layout);border-bottom:1px solid var(--ant-color-border)}.station-module-grid>section>header span{color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm)}.station-module-grid label{display:flex;gap:8px;align-items:center}.station-module-grid>section>div{display:grid;gap:12px;padding:16px;max-height:210px;overflow:auto}.station-page-selection input[type=checkbox]{accent-color:var(--ant-color-primary)}
-.station-dashboard-selection{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:20px}.station-dashboard-selection article,.station-twin-selection article{border:1px solid var(--ant-color-border);min-width:0}.station-dashboard-image{display:block;width:100%;height:145px;padding:0;border:0;overflow:hidden;background:var(--ant-color-bg-layout);cursor:pointer;text-align:left;font:inherit}.station-dashboard-image:focus-visible{outline:2px solid var(--ant-color-primary);outline-offset:-2px}.station-dashboard-selection article.selected{border-color:var(--ant-color-primary)}.station-dashboard-selection article.selected footer{background:var(--ant-color-bg-layout)}.station-dashboard-image>div{height:100%}.station-dashboard-selection footer,.station-twin-selection footer{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-top:1px solid var(--ant-color-border)}.station-dashboard-selection footer label,.station-twin-selection footer label{display:flex;align-items:center;gap:8px}.station-twin-selection{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:20px}.station-twin-selection small{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}.station-scene-thumb{height:145px;background:#edf4f6;position:relative;overflow:hidden}.station-scene-thumb>span{position:absolute;right:10px;top:10px;font-size:11px;color:#65909e}.station-scene-thumb>div{position:absolute;left:10%;right:10%;bottom:20px;height:45px;background:#d0e0e5;transform:skew(-20deg);border:1px solid #a5c3ce}.station-scene-thumb i{position:absolute;width:45px;height:43px;bottom:54px;background:#7ba8b6;border:1px solid #608a97;box-shadow:5px -5px #bfd2d9;z-index:1}.station-scene-thumb i:nth-of-type(1){left:18%}.station-scene-thumb i:nth-of-type(2){left:40%;bottom:70px}.station-scene-thumb i:nth-of-type(3){left:64%}.station-scene-thumb i:nth-of-type(4){left:80%;width:22px;height:28px;bottom:78px}
-.station-menu-app-info{padding:16px 20px;margin-bottom:16px;display:flex;align-items:center;gap:20px}.station-menu-app-info label{display:flex;align-items:center;gap:16px}.station-menu-app-info input{width:300px}.station-menu-app-info p{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary);margin:0}.station-menu-workbench{display:grid;grid-template-columns:250px minmax(0,1fr) 220px;min-height:calc(100vh - 480px)}.station-menu-workbench>aside{background:var(--ant-color-bg-layout)}.station-menu-source{border-right:1px solid var(--ant-color-border);padding:16px}.station-menu-workbench h3{font-size:var(--ant-font-size);font-weight:600;margin:0 0 16px}.station-menu-workbench h3 small{color:var(--ant-color-text-secondary);float:right}.station-menu-source .station-search{width:100%;margin-bottom:16px}.station-menu-source>div{max-height:calc(100vh - 585px);min-height:230px;overflow:auto}.station-menu-source>div>button{display:flex;align-items:center;justify-content:space-between;width:100%;padding:12px 8px;margin-bottom:8px;border:1px solid var(--ant-color-border);background:var(--ant-color-bg-container);text-align:left}.station-menu-source b{font-size:var(--ant-font-size-sm);font-weight:400}.station-menu-source small{display:block;color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);margin-top:4px}.station-menu-source .anticon{color:var(--ant-color-primary)}.station-menu-source button:disabled{opacity:.5;cursor:default}.station-menu-editor-table{overflow:auto;max-height:calc(100vh - 445px)}.station-menu-editor-table .data-table{min-width:520px}.station-menu-editor-table td{padding:12px}.station-menu-editor-table input,.station-menu-editor-table select{width:100%}.station-menu-row-actions{display:flex;gap:0}.station-menu-row-actions .ant-btn{width:28px}.station-menu-live-preview{border-left:1px solid var(--ant-color-border);padding:16px 0}.station-menu-live-preview h3{padding:0 16px}.station-menu-live-preview>b{display:block;padding:16px;background:var(--ant-color-bg-layout);font-size:var(--ant-font-size);font-weight:600}.station-menu-live-preview nav>div{padding:16px;font-size:var(--ant-font-size)}.station-menu-live-preview nav>.group{background:var(--ant-color-bg-layout)}.station-menu-live-preview nav>.page{color:var(--ant-color-primary)}.station-menu-live-preview .anticon{float:right}.station-menu-empty{padding:60px 16px;color:var(--ant-color-text-secondary);text-align:center}.station-warning{color:var(--ant-color-error);background:var(--ant-color-bg-layout);padding:16px}.station-replace-confirm{max-width:520px}
-@media(max-width:1400px){.station-menu-workbench{grid-template-columns:220px minmax(0,1fr)}.station-menu-live-preview{display:none}.station-hierarchy-note>span:last-child{display:none}.station-module-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.station-dashboard-selection,.station-twin-selection{grid-template-columns:repeat(3,minmax(0,1fr))}.station-unified-assets .asset-tree-search{width:120px}.station-template-cards{grid-template-columns:repeat(2,minmax(0,1fr))}}
+++ /dev/null
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: var(--ant-font-family);
- background: var(--ant-color-bg-layout);
- color: var(--ant-color-text-base);
-}
-
-#root {
- min-height: 100vh;
-}
-
-/* ===== 整体布局 ===== */
-// Header 高度(含 8px 顶部渐变条),同时也是内容区的避让量
-@header-height: 56px;
-
-.page {
- display: flex;
- flex-direction: column;
- min-height: 100vh;
- padding-top: @header-height;
-
- // 左右两栏:宽度全部交给 flex,不写死 calc
- .body {
- display: flex;
- flex: 1 1 auto;
- align-items: stretch;
- }
-
- // 左栏宽度由 SideMenu 自身决定(展开 316px / 折叠 72px),随整页一起滚动
- .left-menu {
- flex: 0 0 auto;
- }
-
- /* 注:#ededed 暂无对应 antd token(colorBgLayout 已被覆盖为 #fafafa,与此值不同) */
- .main {
- // flex:1 自动占满剩余宽度;min-width:0 允许收缩,避免内部宽内容把布局撑破
- flex: 1 1 auto;
- min-width: 0;
- padding: 8px 0 0;
- background: #ededed;
-
- .page-header {
- padding: 0 24px;
- }
- }
-}
+++ /dev/null
-/* ============================================================
- * overrides.css — L0 设计覆盖
- *
- * 只放 antd token 表达不了的东西:
- * - 渐变(token 只能设单一颜色)
- * - 多层阴影
- * - 同一组件内不同部位要不同值(如表头 vs 表体)
- *
- * 能用 token 表达的一律写进 src/theme.json。
- * 消费品牌值一律用 var(--ant-*),不要硬编码。
- * ============================================================ */
-
-/* ── Menu:hover / 选中的渐变背景 ──
- * 载体在 src/components/menu/index.less(需覆盖 inline 与 portal 两种 DOM),此处不重复。
- */
-
-/* ── Select 下拉 ──
- * 注意:Select 没有 dropdownPadding / boxShadow 组件 token,
- * 写 --ant-select-dropdown-padding / --ant-select-box-shadow 是空操作,必须用真实声明。
- */
-.ant-select-dropdown {
- padding: 8px 0;
- border: 1px solid var(--ant-color-primary);
- box-shadow: 0 2px 8px 0 rgba(50, 50, 51, 0.2);
-}
-
-.ant-select.ant-select-focused {
- box-shadow: 0 2px 8px 0 rgba(50, 50, 51, 0.2);
-}
-
-/* 选项 hover 渐变(渐变 token 表达不了) */
-.ant-select-dropdown .ant-select-item:hover,
-.ant-select-dropdown .ant-select-item-option-active {
- background: linear-gradient(
- 180deg,
- rgba(0, 140, 214, 0.10) 0%,
- rgba(0, 110, 255, 0.10) 100%
- ), #FFF !important;
-}
-
-/* ── Table:表头与表体内边距不同 ──
- * cellPaddingBlock 作用于所有单元格(已设 8),表头要 12,
- * token 区分不了表头/表体,只能用变量。
- */
-.ant-table .ant-table-thead > tr > th {
- --ant-table-cell-padding-block: 12px;
-}
+++ /dev/null
-{
- "token": {
- "colorPrimary": "#008cd6",
- "colorInfo": "#008cd6",
- "colorSuccess": "#00cc66",
- "colorError": "#f55d3e",
- "colorWarning": "#ffa400",
- "colorTextBase": "#323233",
- "colorBgLayout": "#fafafa",
- "fontSize": 16,
- "borderRadius": 4,
- "fontFamily": "'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
- "fontFamilyCode": "\"Roboto\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", sans-serif"
- },
- "components": {
- "Table": {
- "borderColor": "rgb(225,225,225)",
- "headerColor": "rgb(50,50,51)",
- "footerColor": "rgb(50,50,51)",
- "rowHoverBg": "rgb(230,242,254)",
- "rowSelectedBg": "rgb(230,242,254)",
- "cellPaddingBlock": 8
- },
- "Button": {
- "defaultActiveBorderColor": "rgb(0,140,214)",
- "defaultActiveColor": "rgb(0,140,214)",
- "defaultBorderColor": "rgb(200,200,200)",
- "defaultColor": "rgb(137,137,138)",
- "defaultHoverBorderColor": "rgb(137,137,138)",
- "defaultHoverColor": "rgb(50,50,51)",
- "defaultActiveBg": "rgb(229,243,252)",
- "fontWeight": 600
- },
- "Switch": {
- "handleSize": 12,
- "trackHeight": 16,
- "trackMinWidth": 24,
- "trackPadding": 2,
- "innerMaxMargin": 16
- },
- "Menu": {
- "itemColor": "rgb(50,50,51)",
- "itemHoverColor": "rgb(50,50,51)",
- "subMenuItemBg": "rgb(255,255,255)",
- "subMenuItemBorderRadius": 0,
- "itemBorderRadius": 0
- },
- "Breadcrumb": {
- "itemColor": "rgb(137,137,138)",
- "lastItemColor": "rgb(137,137,138)",
- "linkColor": "rgb(137,137,138)",
- "linkHoverColor": "rgb(0,140,214)",
- "separatorColor": "rgb(192,192,192)"
- },
- "Select": {
- "optionActiveBg": "rgb(229,242,253)",
- "optionSelectedBg": "rgb(229,242,253)",
- "optionSelectedColor": "rgb(0,140,214)",
- "optionPadding": "8px 12px"
- }
- }
-}
+++ /dev/null
-/* Layout-only compatibility for existing custom antd button surfaces.
- * Preserves approved navigation, thumbnail and canvas sizing, including responsive rules.
- * Branding and states remain owned by theme.json and ui-system.css. */
-.side-nav>button.station-ui-button.ant-btn{height:41px;border:0;text-align:left;padding:0 16px;display:flex;align-items:center;gap:12px;}
-.side-nav>button.station-ui-button.ant-btn b{width:18px;text-align:center;}
-.side-nav>button.station-ui-button.ant-btn em{width:21px;height:21px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;}
-.steps button.station-ui-button.ant-btn{border:0;display:flex;align-items:center;position:relative;text-align:left;padding:0 20px;}
-.steps button.station-ui-button.ant-btn>i{width:29px;height:29px;border-radius:50%;border:1px solid var(--ant-color-border);display:grid;margin-right:12px;}
-.steps button.station-ui-button.ant-btn span{display:flex;flex-direction:column;gap:4px;}
-.steps button.station-ui-button.ant-btn>b{position:absolute;right:0;}
-.view-switch button.station-ui-button.ant-btn{width:31px;height:29px;border:1px solid var(--ant-color-border);}
-.link.station-ui-button.ant-btn{border:0;padding:0;}
-.trash.station-ui-button.ant-btn{border:0;}
-.pagination button.station-ui-button.ant-btn{width:28px;height:28px;border:0;}
-.tabs button.station-ui-button.ant-btn{padding:0 20px;border:0;}
-.mu-grid button.station-ui-button.ant-btn{border:0;padding:0;}
-.menu-tree>button.station-ui-button.ant-btn{border:1px dashed var(--ant-color-border);width:100%;height:34px;}
-.menu-preview button.station-ui-button.ant-btn{border:0;text-align:left;padding:12px;}
-.confirm-grid button.station-ui-button.ant-btn{position:static;border:0;margin:0;}
-.text-button.station-ui-button.ant-btn{border:0;margin-top:12px;}
-.preview-toolbar button.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);height:29px;padding:0 12px;}
-.cube-side button.station-ui-button.ant-btn{height:37px;border:0;text-align:left;padding-left:12px;}
-.cube-grid h3 button.station-ui-button.ant-btn{border:0;}
-.inspector>nav button.station-ui-button.ant-btn{flex:1;border:0;}
-.code-body header button.station-ui-button.ant-btn{border:0;}
-.modal>header button.station-ui-button.ant-btn{border:0;}
-.history-list button.station-ui-button.ant-btn{border:0;}
-.module-switch>button.station-ui-button.ant-btn{height:47px;border:1px solid var(--ant-color-border);display:flex;align-items:center;text-align:left;padding:0 8px;gap:8px;}
-.module-switch>button.station-ui-button.ant-btn>b{width:23px;height:23px;display:grid;}
-.module-switch>button.station-ui-button.ant-btn>span{display:flex;flex-direction:column;gap:4px;}
-.domain-grid footer button.station-ui-button.ant-btn{border:0;}
-.domain-grid footer button.station-ui-button.ant-btn:last-child{border:1px solid var(--ant-color-primary-bg);padding:4px 8px;}
-.domain-page-list footer button.station-ui-button.ant-btn{border:0;padding:0;margin-right:12px;}
-.knowledge-list button.station-ui-button.ant-btn{border:0;}
-.model-list>button.station-ui-button.ant-btn{width:100%;min-height:56px;border:0;border-radius:var(--ant-border-radius);display:flex;align-items:center;padding:8px 8px;text-align:left;position:relative;}
-.model-list>button.station-ui-button.ant-btn>i{width:25px;}
-.model-list>button.station-ui-button.ant-btn>span{display:flex;flex:1;flex-direction:column;gap:4px;}
-.model-list>button.station-ui-button.ant-btn>strong{margin-right:24px;}
-.model-list>button.station-ui-button.ant-btn>u{position:absolute;right:9px;}
-.wordbuddy-card>footer button.station-ui-button.ant-btn{border:0;}
-.ai-fab.station-ui-button.ant-btn{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;border:0;display:grid;}
-.ai-dialog .ai-close.station-ui-button.ant-btn{border:0;line-height:1;width:28px;height:28px;border-radius:var(--ant-border-radius);}
-.data-table.editable .trash.station-ui-button.ant-btn{border:0;line-height:1;}
-.data-table.editable .asset-eye-btn.station-ui-button.ant-btn{border:0;line-height:1;padding:0;display:inline-flex;align-items:center;}
-.cube-actions button.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);border-radius:var(--ant-border-radius);height:26px;padding:0 8px;}
-.menu-row .trash.station-ui-button.ant-btn{border:1px solid var(--ant-color-error-bg);border-radius:var(--ant-border-radius);height:30px;width:34px;}
-.menu-preview .menu-section-title.station-ui-button.ant-btn{margin-bottom:8px;}
-.style-pick.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);padding:8px;display:flex;flex-direction:column;gap:8px;text-align:center;}
-.module-switch>button.station-ui-button.ant-btn{height:50px;border:0;border-radius:0;padding:0 20px;gap:12px;}
-.module-switch>button.station-ui-button.ant-btn>b{width:18px;height:auto;}
-.module-switch>button.station-ui-button.ant-btn>span{display:block;}
-.module-switch>button.station-ui-button.ant-btn small{display:none;}
-.side-nav>button.station-ui-button.ant-btn{height:44px;padding:0 20px;gap:12px;}
-.side-nav>button.station-ui-button.ant-btn em{width:20px;height:20px;}
-.steps button.station-ui-button.ant-btn{padding:0 20px;}
-.steps button.station-ui-button.ant-btn>i{width:26px;height:26px;}
-.company-status-tabs button.station-ui-button.ant-btn{height:48px;min-width:120px;padding:0 24px;border:0;}
-.company-status-tabs button.station-ui-button.ant-btn span{margin-right:8px;}
-.company-status-tabs button.station-ui-button.ant-btn b{display:inline-grid;min-width:24px;height:20px;padding:0 8px;border-radius:var(--ant-border-radius);}
-.pagination button.station-ui-button.ant-btn{height:32px;border:1px solid var(--ant-color-border);}
-.pagination button.station-ui-button.ant-btn{width:34px;}
-.domain-select .text-button.station-ui-button.ant-btn{margin:0;}
-@media (max-width:900px){.company-status-tabs button.station-ui-button.ant-btn{min-width:100px;}}
-.menu-section button.station-ui-button.ant-btn{width:100%;border:0;text-align:left;}
-.menu-section-title.station-ui-button.ant-btn{height:56px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;line-height:56px;}
-.menu-section-items button.station-ui-button.ant-btn{height:50px;padding:0 20px 0 40px;display:flex;align-items:center;line-height:50px;}
-.extension-third-menu>button.station-ui-button.ant-btn{height:50px;padding:0 20px 0 60px;display:flex;align-items:center;line-height:50px;border:0;width:100%;text-align:left;}
-.company-side-footer>button.station-ui-button.ant-btn{height:26px;padding:0;border:0;display:flex;align-items:center;justify-content:space-between;text-align:left;}
-.company-model-menu button.station-ui-button.ant-btn{width:100%;height:32px;border:0;display:flex;justify-content:space-between;align-items:center;padding:0 8px;}
-.site-edit-tabs>button.station-ui-button.ant-btn{height:64px;flex:1;min-width:0;border:0;display:flex;align-items:center;gap:12px;padding:0 20px;text-align:left;}
-.site-edit-tabs>button.station-ui-button.ant-btn i{width:26px;height:26px;border:1px solid var(--ant-color-border);border-radius:50%;display:grid;}
-.asset-config-tabs>button.station-ui-button.ant-btn{height:65px;min-width:210px;padding:12px 4px 8px;border:0;text-align:left;display:flex;flex-direction:column;justify-content:center;gap:4px;}
-.asset-editor-title>.text-button.station-ui-button.ant-btn{margin:0;padding-right:20px;}
-.asset-batch-entry.station-ui-button.ant-btn{width:100%;height:54px;border:0;text-align:left;padding:0 16px;}
-.asset-template-panel>button.station-ui-button.ant-btn{min-height:58px;border:1px solid var(--ant-color-border);display:grid;text-align:left;padding:8px;column-gap:8px;}
-.asset-template-panel>button.station-ui-button.ant-btn>span{align-self:center;}
-.node-drawer-tabs button.station-ui-button.ant-btn{border:0;padding:0 20px;}
-@media (max-width:1100px){.asset-config-tabs>button.station-ui-button.ant-btn{min-width:180px;}}
-.domain-resource-table .company-record-link.station-ui-button.ant-btn{display:block;max-width:100%;overflow:hidden;white-space:nowrap;text-align:left;}
-.device-resource-table .company-record-link.station-ui-button.ant-btn{display:block;max-width:100%;overflow:hidden;white-space:nowrap;text-align:left;}
-.resource-name-cell .company-record-link.station-ui-button.ant-btn{min-width:0;overflow:hidden;white-space:nowrap;text-align:left;}
-.resource-back .text-button.station-ui-button.ant-btn{margin:0;}
-.style-resource-table .company-record-link.station-ui-button.ant-btn{display:block;max-width:100%;overflow:hidden;white-space:nowrap;text-align:left;}
-.view-toggle button.station-ui-button.ant-btn{border:0;height:28px;padding:0 12px;}
-.settings-domain-table .text-button.station-ui-button.ant-btn{margin:0;}
-.domain-nav-row>button.station-ui-button.ant-btn:not(.domain-nav-del):not(.domain-nav-ok){flex:1;min-width:0;text-align:left;height:41px;border:0;padding:0 16px;display:flex;align-items:center;white-space:nowrap;}
-.domain-nav-row>button.station-ui-button.ant-btn b{width:18px;margin-right:8px;flex:0 0 auto;}
-.domain-nav-del.station-ui-button.ant-btn{border:0;width:30px;line-height:1;flex:0 0 auto;}
-.domain-nav-ok.station-ui-button.ant-btn{border:0;width:30px;flex:0 0 auto;}
-.domain-nav-add.station-ui-button.ant-btn{height:34px;border:0;text-align:left;padding:0 16px;display:flex;align-items:center;width:100%;}
-.overview-tabs button.station-ui-button.ant-btn{padding:0 16px;}
-.dev-row-head.station-ui-button.ant-btn{width:100%;border:0;display:flex;align-items:center;gap:12px;padding:12px 12px;text-align:left;}
-.wise-header button.station-ui-button.ant-btn{border:0;padding:0;}
-.wise-menu-button.station-ui-button.ant-btn{flex:0 0 24px;width:24px;height:24px;margin-right:12px;display:grid;}
-.wise-product-switch.station-ui-button.ant-btn{flex:0 0 auto;height:48px;margin-left:12px;display:flex;align-items:center;white-space:nowrap;}
-.wise-header-actions>button.station-ui-button.ant-btn:not(.wise-avatar){flex:0 0 40px;width:40px;height:40px;display:grid;border-radius:0;}
-.wise-header-actions>button.station-ui-button.ant-btn>img{display:block;width:24px;height:24px;}
-.wise-avatar.station-ui-button.ant-btn{flex:0 0 24px;width:24px;height:24px;margin:0 8px;border-radius:50%!important;line-height:24px;text-align:center;}
-@media (max-width:1120px){.wise-avatar.station-ui-button.ant-btn{margin-right:8px;}}
-@media (max-width:700px){.wise-header-actions>button.station-ui-button.ant-btn:not(.wise-avatar){width:36px;flex-basis:36px;}}
-@media (max-width:560px){.wise-product-switch.station-ui-button.ant-btn{margin-left:4px;}}
-@media (max-width:560px){.wise-header-actions>button.station-ui-button.ant-btn:nth-child(1),.wise-header-actions>button.station-ui-button.ant-btn:nth-child(2){display:none;}}
-.asset-batch-entry.station-ui-button.ant-btn{height:72px;padding:0 20px;}
-.asset-template-panel>button.station-ui-button.ant-btn{min-height:50px;border:0;padding:8px 8px;}
-.asset-template-panel>button.station-ui-button.ant-btn:hover{border:0;}
-.asset-view-switch button.station-ui-button.ant-btn{width:38px;border:0;}
-.asset-node-card>button.station-ui-button.ant-btn{align-self:center;justify-self:end;width:19px;height:19px;border:0;border-radius:50%;padding:0;display:grid;}
-.asset-node-list>button.station-ui-button.ant-btn{height:43px;display:grid;align-items:center;text-align:left;border:0;padding:0 16px;}
-.asset-node-drawer .node-drawer-tabs button.station-ui-button.ant-btn{height:49px;padding:0 20px;}
-.asset-node-drawer .node-drawer-tabs button.station-ui-button.ant-btn:first-child{padding-left:0;margin-right:20px;}
-.node-image-row .text-button.station-ui-button.ant-btn{margin:4px 0 0;}
-.edge-component-table button.station-ui-button.ant-btn{width:28px;height:28px;border:0;}
-.binding-component-tabs button.station-ui-button.ant-btn{height:34px;padding:0 20px;border:0;}
-.domain-showcase-card>footer button.station-ui-button.ant-btn{height:30px;border:0;padding:0;}
-.domain-capability-back.station-ui-button.ant-btn{align-self:flex-start;border:0;padding:0;height:28px;}
-.domain-capability-card>footer button.station-ui-button.ant-btn{height:29px;border:1px solid var(--ant-color-border);padding:0 12px;}
-.domain-capability-card>footer button.station-ui-button.ant-btn.capability-edit-button{margin-right:auto;}
-.capability-preview-tabs button.station-ui-button.ant-btn{height:44px;border:0;padding:0 20px;}
-.dashboard-card-more.station-ui-button.ant-btn{position:absolute;right:8px;top:10px;width:30px;height:30px;border:0;}
-.dashboard-card-more.station-ui-button.ant-btn{right:8px;top:9px;}
-.station-icon-control>.link.station-ui-button.ant-btn{border:0;white-space:nowrap;padding:0;}
-.station-menu-disclosure.station-ui-button.ant-btn{flex-shrink:0;border:0;width:16px;padding:0;}
-.station-application-preview nav>button.station-ui-button.ant-btn{display:flex;align-items:center;justify-content:space-between;width:100%;border:0;padding:16px 20px;text-align:left;}
-.station-hierarchy-workspace .station-name-cell>.link.station-ui-button.ant-btn{white-space:nowrap;overflow:hidden;}
-.station-name-cell>button.station-ui-button.ant-btn:first-child:not(.link){border:0;width:18px;padding:0;}
-.station-template-cards>button.station-ui-button.ant-btn{border:1px solid var(--ant-color-border);text-align:left;padding:16px;min-width:0;}
-.station-template-cards>button.station-ui-button.ant-btn>div{display:flex;align-items:center;gap:8px;}
-.station-dashboard-image.station-ui-button.ant-btn{display:block;width:100%;height:145px;padding:0;border:0;overflow:hidden;text-align:left;}
-.station-menu-source>div>button.station-ui-button.ant-btn{display:flex;align-items:center;justify-content:space-between;width:100%;padding:12px 8px;margin-bottom:8px;border:1px solid var(--ant-color-border);text-align:left;}
-.steps button.station-ui-button.ant-btn{height:100%;}
+++ /dev/null
-/* Supplied Skill values are resolved in company-theme.tsx; no independent palette. */
-html,body,.ant-app{font-family:var(--ant-font-family);font-size:var(--ant-font-size);color:var(--ant-color-text-base);background:var(--ant-color-bg-layout)}
-.workspace{background:var(--ant-color-bg-layout);min-width:0}
-.page-heading{background:var(--ant-color-bg-layout)}
-.company-side-nav{font-family:var(--ant-font-family)}
-.company-side-nav .menu-section button{font-weight:400;font-size:var(--ant-font-size);border-radius:0}
-.company-side-nav .menu-section-title{font-weight:600!important}
-.company-side-nav .menu-section-items button.active{font-weight:400;background:var(--ant-color-primary-bg);color:var(--ant-color-primary)}
-.company-side-nav .menu-section-items button:hover{background:var(--ant-table-row-hover-bg);color:var(--ant-color-primary)}
-.extension-menu-item,.extension-third-menu>div,.extension-third-menu>button{font-family:var(--ant-font-family);font-size:var(--ant-font-size);font-weight:400}
-.button.ant-btn{height:var(--ant-control-height);font-size:var(--ant-font-size);border-radius:var(--ant-border-radius);padding-inline:var(--ant-padding);margin-left:0;box-shadow:none}
-.button.ant-btn-primary,.btn-ai.ant-btn{background:var(--ant-color-primary);border-color:var(--ant-color-primary);color:var(--ant-color-text-light-solid)}
-.button.ant-btn-primary:not(:disabled):hover,.btn-ai.ant-btn:not(:disabled):hover{background:var(--ant-color-primary-hover);border-color:var(--ant-color-primary-hover)}
-.button.secondary.ant-btn{background:var(--ant-color-bg-container);color:var(--ant-button-default-color);border-color:var(--ant-button-default-border-color)}
-.button.secondary.ant-btn:not(:disabled):hover{color:var(--ant-button-default-hover-color);border-color:var(--ant-button-default-hover-border-color)}
-.button.ant-btn:disabled,.button.ant-btn-primary:disabled{background:var(--ant-color-bg-container-disabled);border-color:var(--ant-color-border);color:var(--ant-color-text-disabled);opacity:1}
-.station-ui-select{min-width:120px;height:var(--ant-control-height);font-size:var(--ant-font-size)}
-.field>.station-ui-select,.form-grid .station-ui-select,.station-basic-fields .station-ui-select,.station-create-template>.station-ui-select,.node-basic-grid .station-ui-select,.alarm-form-grid .station-ui-select{width:100%}
-.station-ui-input.ant-input{height:var(--ant-control-height);font-size:var(--ant-font-size);border-radius:var(--ant-border-radius);padding:var(--ant-padding-xxs) var(--ant-padding-xs)}
-.station-ui-input.ant-input:focus{border-color:var(--ant-color-primary);box-shadow:0 0 0 2px var(--ant-color-primary-bg)}
-.station-ui-input.ant-input:disabled{background:var(--ant-color-bg-container-disabled);color:var(--ant-color-text-disabled)}
-.search-field>.station-ui-input,.station-search>.station-ui-input,.dashboard-manager-search>.station-ui-input{border:0!important;box-shadow:none!important;background:transparent!important;min-width:0;flex:1}
-.station-ui-choice{flex-shrink:0;margin:0}
-.station-ui-table{border-collapse:initial;max-width:100%}
-.station-ui-table table{width:100%}
-.station-ui-table .ant-table-thead>tr>th{font-size:var(--ant-font-size);font-weight:600;color:var(--ant-table-header-color);background:var(--ant-color-bg-layout);padding-block:var(--ant-padding-sm);border-color:var(--ant-table-border-color)}
-.station-ui-table .ant-table-tbody>tr>td{font-size:var(--ant-font-size);font-weight:400;color:var(--ant-color-text-base);padding-block:var(--ant-padding-xs);border-color:var(--ant-table-border-color)}
-.station-ui-table .ant-table-tbody>tr:hover>td,.station-ui-table .ant-table-tbody>tr>td.ant-table-cell-row-hover{background:var(--ant-table-row-hover-bg)}
-.station-ui-table input[type=checkbox]{appearance:auto;width:auto;height:auto;padding:0;border:0;background:none;margin:0;opacity:0}
-.station-ui-table .ant-checkbox-inner{border-radius:var(--ant-border-radius-sm)}
-.ant-tag.status,.ant-tag.tag-new{border-radius:var(--ant-border-radius);font-size:var(--ant-font-size-sm);line-height:var(--ant-line-height-sm);padding:var(--ant-padding-xxs) var(--ant-padding-xs)}
-.status.ok{color:var(--ant-color-success-text);background:var(--ant-color-success-bg);border-color:var(--ant-color-success-border)}
-.status.warn{color:var(--ant-color-warning-text);background:var(--ant-color-warning-bg);border-color:var(--ant-color-warning-border)}
-.status.bad{color:var(--ant-color-error-text);background:var(--ant-color-error-bg);border-color:var(--ant-color-error-border)}
-.status.muted{color:var(--ant-color-text-secondary);background:var(--ant-color-fill-quaternary);border-color:var(--ant-color-border)}
-.ant-drawer-title{font-size:var(--ant-font-size);font-weight:600}
-.ant-drawer-body{background:var(--ant-color-bg-container)}
-.ant-drawer-footer .station-drawer-actions,.ant-drawer-footer .drawer-actions{display:flex;flex-wrap:nowrap;justify-content:flex-end;gap:var(--ant-padding-xs)}
-.domain-capability-actions,.domain-capability-card>footer,.filter-actions,.station-application-menu-actions,.sticky-actions>div{display:flex;flex-wrap:nowrap;align-items:center;gap:var(--ant-padding-xs)}
-.domain-capability-card>footer{overflow-x:auto;flex-shrink:0}
-.domain-capability-card>footer .ant-btn{height:var(--ant-control-height);font-size:var(--ant-font-size);white-space:nowrap}
-.domain-capability-card,.station-page-module-panel,.station-list-panel,.station-application-basic,.station-application-menu{background:var(--ant-color-bg-container);border-color:var(--ant-color-border);border-radius:var(--ant-border-radius)}
-.domain-capability-card{overflow:hidden}
-.domain-capability-card>header h3,.station-application-settings h2{font-size:var(--ant-font-size);font-weight:600}
-.domain-capability-body{min-height:0;overflow:auto}
-.device-template-tile b{font-size:var(--ant-font-size-sm);font-weight:400;line-height:var(--ant-line-height-sm)}
-.station-dashboard-selection,.station-twin-selection{gap:var(--ant-padding);padding:var(--ant-padding)}
-.station-application-settings input:not([type=file]),.station-menu-edit-fields input{border-radius:var(--ant-border-radius);height:var(--ant-control-height)}
-.station-application-settings .station-menu-row-actions .ant-btn{color:var(--ant-color-primary)}
-.station-application-settings .station-menu-row-actions .ant-btn:disabled{color:var(--ant-color-text-disabled)}
-.station-hierarchy-workspace,.station-page-selection,.station-menu-configuration{font-size:var(--ant-font-size)}
-.station-application-fields{gap:var(--ant-padding-lg);padding:var(--ant-padding-lg)}
-.station-ui-table .station-menu-group-row>td{background:var(--ant-color-bg-layout)}
-.station-ui-table .station-menu-group-row:hover>td{background:var(--ant-table-row-hover-bg)}
-.station-opsflow-blank{background:var(--ant-color-bg-container)}
-@media(max-width:1200px){.domain-capability-actions{overflow:auto;max-width:100%}.station-application-fields{gap:var(--ant-padding)}}
-.panel.ant-card{border-radius:var(--ant-border-radius-lg)}
-.panel.ant-card>.ant-card-head{padding-inline:var(--ant-padding);min-height:48px}
-.panel.ant-card>.ant-card-head .ant-card-head-title{padding:var(--ant-padding-xs) 0;font-size:var(--ant-font-size);font-weight:600}
-.panel.ant-card>.ant-card-body{padding:var(--ant-padding-lg)}
-.company-list-panel.ant-card>.ant-card-body{padding:0 var(--ant-padding) var(--ant-padding-sm)}
-.field.ant-form-item{display:block;gap:initial;margin:0;min-width:0}
-.field.ant-form-item .ant-form-item-label{padding:0 0 var(--ant-padding-xs)}
-.field.ant-form-item .ant-form-item-label>label{height:auto;font-size:var(--ant-font-size);font-weight:400;color:var(--ant-color-text-base)}
-.field.ant-form-item .ant-form-item-extra{font-size:var(--ant-font-size-sm);color:var(--ant-color-text-secondary)}
-.field.ant-form-item .ant-input:not(textarea){height:var(--ant-control-height)}
-.station-ui-tabs.company-status-tabs,.station-ui-tabs.node-drawer-tabs,.station-ui-tabs.asset-config-tabs,.station-ui-tabs.binding-component-tabs,.station-ui-tabs.tabs{display:block;height:auto;padding-top:0;gap:initial;border:0;margin:0;background:transparent}
-.station-ui-tabs>.ant-tabs-nav{margin:0 0 var(--ant-padding)}
-.station-ui-tabs>.ant-tabs-nav .ant-tabs-tab{padding:var(--ant-padding-sm) var(--ant-padding);font-size:var(--ant-font-size);font-weight:400}
-.station-ui-tabs>.ant-tabs-nav .ant-tabs-ink-bar{height:2px;background:var(--ant-color-primary)}
-.station-ui-tabs .ant-tabs-tab-btn{font-weight:400}
-.station-ui-tabs .ant-tabs-tab-btn b,.station-ui-tabs .ant-tabs-tab-btn i{margin-left:var(--ant-padding-xs);background:var(--ant-color-fill-secondary);color:var(--ant-color-text-secondary);font-size:var(--ant-font-size-sm);font-style:normal;font-weight:600;padding:0 var(--ant-padding-xxs);border-radius:var(--ant-border-radius)}
-.station-ui-tabs .ant-tabs-tab-active .ant-tabs-tab-btn b{background:var(--ant-color-success-bg);color:var(--ant-color-success-text)}
-.station-ui-tabs .ant-tabs-content-holder{min-width:0}
-.station-ui-tabs .ant-tabs-content{background:var(--ant-color-bg-layout)}
-.node-config-page .field.ant-form-item .ant-form-item-row{display:flex;flex-flow:row nowrap;align-items:center}
-.node-config-page .field.ant-form-item .ant-form-item-label{flex:0 0 100px;padding:0 var(--ant-padding-xs) 0 0;text-align:right}
-.node-config-page .field.ant-form-item .ant-form-item-control{flex:1;min-width:0}
-.alarm-enable-switch.ant-switch{min-width:var(--ant-switch-track-min-width);height:var(--ant-switch-track-height);padding:0;border:0}
-.station-inline-actions,.station-modal-actions,.station-drawer-actions,.drawer-actions,.result-actions,.settings-actions{display:flex;flex-wrap:nowrap;align-items:center;gap:var(--ant-padding-xs)}
-.station-modal-actions{justify-content:flex-end}
-/* Ant Modal owns positioning, focus and chrome; old class names only identify business content. */
-.station-ui-modal.modal,.station-ui-modal.dlg-box{display:block;position:relative;inset:auto;margin-inline:auto;padding:0;max-width:calc(100vw - 32px);max-height:none;height:auto;background:none;border:0;border-radius:0;box-shadow:none;overflow:visible;z-index:auto;color:var(--ant-color-text-base);font-size:var(--ant-font-size)}
-.station-ui-modal .ant-modal-container{padding:var(--ant-padding-lg);border-radius:var(--ant-border-radius-lg)}
-.station-ui-modal .ant-modal-title{font-size:var(--ant-font-size-heading-4);font-weight:600}
-.station-ui-modal .ant-modal-body{font-size:var(--ant-font-size);color:var(--ant-color-text-base)}
-.station-ui-modal .modal-body{padding:0;max-height:70vh;overflow:auto}
-.station-ui-modal .ant-modal-footer{margin-top:var(--ant-padding-lg)}
-.station-ui-modal .station-modal-actions .ant-btn{margin:0}
-.station-ui-select .ant-select-selector{font-size:var(--ant-font-size)}
-.filterbar.company-filterbar{height:auto;min-height:48px;flex-wrap:nowrap}
-.filterbar.company-filterbar .station-ui-select{flex-shrink:0;min-width:164px}
+++ /dev/null
-<script>
-import { computed, h, cloneVNode, useSlots, useAttrs, Fragment } from "vue";
-import {
- Button as AntButton,
- Dropdown as AntDropdown,
- Menu as AntMenu,
-} from "ant-design-vue";
-import { EllipsisOutlined } from "@ant-design/icons-vue";
-import UiButton from "./UiButton.vue";
-import { elements, textOf, classOf, childContent } from "./parse.js";
-
-export default {
- setup() {
- const slots = useSlots();
- const attrs = useAttrs();
-
- const parts = computed(() => elements(slots.default ? slots.default() : []));
-
- const isActionType = (v) =>
- v.type === UiButton || v.type === AntButton || v.type === AntDropdown;
- const businessAction = (v) => !!(v.type && v.type.uiPrimaryAction);
- const actionable = (v) => isActionType(v) || businessAction(v);
-
- const actions = computed(() => parts.value.filter(actionable));
- const extras = computed(() => parts.value.filter((v) => !actionable(v)));
-
- const isPrimary = (v) => {
- const p = v.props || {};
- const cn = classOf(v);
- if (p.type === "primary") return true;
- if (businessAction(v) && !p.secondary) return true;
- if (
- v.type === UiButton &&
- /button|btn-ai/.test(cn) &&
- !p.secondary &&
- !/secondary/.test(cn)
- )
- return true;
- return false;
- };
-
- const primaryIndex = computed(() => {
- let idx = -1;
- actions.value.forEach((a, i) => {
- if (isPrimary(a)) idx = i;
- });
- return idx;
- });
-
- const rendered = computed(() =>
- actions.value.map((action, index) => {
- if (index === primaryIndex.value || !isPrimary(action)) return action;
- const custom = action.type === UiButton || businessAction(action);
- return cloneVNode(action, custom ? { secondary: true } : { type: "default" });
- })
- );
-
- const showMore = computed(() => actions.value.length > 3);
-
- function triggerAction(vnode, e) {
- if (vnode.props && vnode.props.onClick) vnode.props.onClick(e);
- }
-
- function classAttr() {
- const c = attrs.class;
- return typeof c === "string" ? c : Array.isArray(c) ? c.join(" ") : "";
- }
-
- return () => {
- const children = [];
- if (showMore.value) {
- rendered.value.slice(0, 2).forEach((a) => children.push(a));
- const menuItems = rendered.value.slice(2).map((a, i) =>
- h(
- AntMenu.Item,
- {
- key: String(i),
- disabled: !!(a.props && a.props.disabled),
- onClick: (e) => triggerAction(a, e.domEvent || e),
- },
- () => textOf(childContent(a))
- )
- );
- children.push(
- h(
- AntDropdown,
- { trigger: ["click"] },
- {
- overlay: () => h(AntMenu, {}, { default: () => menuItems }),
- default: () =>
- h(
- AntButton,
- { "aria-label": "更多操作", icon: h(EllipsisOutlined) },
- () => "更多"
- ),
- }
- )
- );
- } else {
- rendered.value.forEach((a) => children.push(a));
- }
- extras.value.forEach((ex) => children.push(ex));
- return h("div", { class: classAttr() }, children);
- };
- },
-};
-</script>
+++ /dev/null
-<script setup>
-import { computed, useAttrs, defineOptions } from "vue";
-
-defineOptions({ inheritAttrs: false });
-defineProps({
- type: { type: String, default: "button" },
- secondary: { type: Boolean, default: false },
-});
-
-const attrs = useAttrs();
-const classStr = computed(() => {
- const c = attrs.class;
- return typeof c === "string" ? c : Array.isArray(c) ? c.join(" ") : "";
-});
-
-const isSwitch = computed(() => attrs.role === "switch");
-const isAction = computed(() => /(^|\s)(button|btn-ai|ai-send|sso-button)(\s|$)/.test(classStr.value));
-const isSecondary = computed(() => /(^|\s)secondary(\s|$)/.test(classStr.value));
-const isLink = computed(() => /(^|\s)(link|text-button|domain-capability-back)(\s|$)/.test(classStr.value));
-const isDanger = computed(() => /(^|\s)(danger|trash)(\s|$)/.test(classStr.value));
-const btnType = computed(() =>
- isLink.value ? "link" : isAction.value ? (isSecondary.value ? "default" : "primary") : "text"
-);
-
-const passthrough = computed(() => ({
- onClick: attrs.onClick,
- disabled: attrs.disabled,
- title: attrs.title,
- "aria-label": attrs["aria-label"],
- "aria-expanded": attrs["aria-expanded"],
- role: attrs.role,
-}));
-</script>
-
-<template>
- <a-switch
- v-if="isSwitch"
- :checked="attrs['aria-checked'] === true"
- :disabled="attrs.disabled"
- :aria-label="attrs['aria-label']"
- :class="attrs.class"
- @change="() => attrs.onClick && attrs.onClick()"
- />
- <a-button
- v-else
- :html-type="type || 'button'"
- :type="btnType"
- :danger="isDanger"
- class="station-ui-button"
- v-bind="passthrough"
- >
- <slot />
- </a-button>
-</template>
+++ /dev/null
-<script setup>
-import { computed, useAttrs, defineOptions } from "vue";
-
-defineOptions({ inheritAttrs: false });
-const props = defineProps({
- type: { type: String, default: "text" },
- value: { type: [String, Number], default: "" },
- checked: { type: Boolean, default: false },
- defaultValue: { type: [String, Number, Boolean], default: undefined },
- disabled: { type: Boolean, default: false },
- id: { type: String, default: undefined },
- name: { type: String, default: undefined },
- placeholder: { type: String, default: "" },
- maxLength: { type: [String, Number], default: undefined },
- rows: { type: [String, Number], default: undefined },
- readonly: { type: Boolean, default: false },
- autofocus: { type: Boolean, default: false },
- "aria-label": { type: String, default: undefined },
- textarea: { type: Boolean, default: false },
-});
-
-const attrs = useAttrs();
-const emit = defineEmits(["change"]);
-
-const kind = computed(() =>
- props.textarea ? "textarea" : props.type === "checkbox" ? "checkbox" : props.type === "radio" ? "radio" : "text"
-);
-
-const shared = computed(() => ({
- disabled: props.disabled,
- id: props.id,
- name: props.name,
- "aria-label": props["aria-label"],
- onClick: attrs.onClick,
- onBlur: attrs.onBlur,
- onKeydown: attrs.onKeydown,
- onFocus: attrs.onFocus,
- onInput: attrs.onInput,
-}));
-
-const passthrough = computed(() => ({
- ...shared.value,
- class: attrs.class,
-}));
-
-function onInput(e) {
- emit("change", e);
-}
-</script>
-
-<template>
- <a-checkbox v-if="kind === 'checkbox'" :checked="checked" :value="value" v-bind="shared" @change="onInput">
- <slot />
- </a-checkbox>
- <a-radio v-else-if="kind === 'radio'" :checked="checked" :value="value" v-bind="shared" @change="onInput">
- <slot />
- </a-radio>
- <a-textarea
- v-else-if="kind === 'textarea'"
- :value="value"
- :placeholder="placeholder"
- :rows="rows"
- :maxlength="maxLength"
- :disabled="disabled"
- :readonly="readonly"
- :autofocus="autofocus"
- v-bind="passthrough"
- @change="onInput"
- >
- <slot />
- </a-textarea>
- <a-input
- v-else
- :type="type"
- :value="value"
- :placeholder="placeholder"
- :maxlength="maxLength"
- :disabled="disabled"
- :readonly="readonly"
- :autofocus="autofocus"
- v-bind="passthrough"
- @change="onInput"
- >
- <template v-if="$slots.prefix" #prefix>
- <slot name="prefix" />
- </template>
- </a-input>
-</template>
+++ /dev/null
-<script>
-import { h, useSlots, useAttrs, Fragment } from "vue";
-import { Modal as AntModal } from "ant-design-vue";
-import UiActions from "./UiActions.vue";
-import { elements, textOf, classOf, childContent } from "./parse.js";
-
-export default {
- setup() {
- const slots = useSlots();
- const attrs = useAttrs();
-
- return () => {
- const children = slots.default ? slots.default() : [];
- const all = elements(children);
- const inner = all.find((c) => /\b(modal|dlg-box)\b/.test(classOf(c)));
- if (!inner) return null;
-
- const innerChildren = elements(childContent(inner));
- const header = innerChildren.find((p) => p.type === "header");
- const heading =
- (header ? elements(childContent(header)) : []).find((p) => p.type === "h2") ||
- innerChildren.find((p) => p.type === "h2");
- const footer = innerChildren.find(
- (p) =>
- p.type === "footer" ||
- (p.props && p.props.class && String(p.props.class).split(" ").includes("dlg-actions"))
- );
-
- const name = classOf(inner);
- const styleW = inner.props?.style?.width;
- const width =
- styleW && styleW !== "auto"
- ? styleW
- : /preview|prompt/.test(name)
- ? 1200
- : /editor|batch-create/.test(name)
- ? 960
- : /maintain|import|package/.test(name)
- ? 720
- : 480;
-
- let close;
- if (attrs.onMouseDown) close = attrs.onMouseDown;
- else if (attrs.onClick) close = attrs.onClick;
- else {
- const hdrBtn = header
- ? elements(childContent(header)).find(
- (p) => p.props && p.props.onClick && !p.props.disabled
- )
- : null;
- if (hdrBtn) close = hdrBtn.props.onClick;
- else if (footer) {
- const cancel = elements(childContent(footer)).find(
- (p) =>
- textOf(childContent(p)).includes("取消") &&
- p.props &&
- !p.props.disabled
- );
- if (cancel) close = cancel.props.onClick;
- }
- }
-
- const extras = all.filter((c) => !/\b(modal|dlg-box)\b/.test(classOf(c)));
-
- let body = innerChildren.filter((p) => p !== header && p !== footer);
- if (heading) body = body.filter((p) => p !== heading);
-
- const closeFn = (e) => {
- if (close) close(e);
- };
-
- const modalProps = {
- open: true,
- centered: true,
- title: heading ? childContent(heading) : undefined,
- width,
- class: name + " station-ui-modal",
- closable: !!close,
- keyboard: !!close,
- maskClosable: !!close,
- onCancel: closeFn,
- };
-
- const modalSlots = { default: () => body };
- if (footer) {
- modalSlots.footer = () =>
- h(UiActions, { class: "station-modal-actions" }, () => childContent(footer));
- } else {
- modalProps.footer = null;
- }
-
- return h(Fragment, {}, [h(AntModal, modalProps, modalSlots), ...extras]);
- };
- },
-};
-</script>
+++ /dev/null
-<script setup>
-import { computed, useSlots, useAttrs, defineOptions } from "vue";
-import { elements, textOf } from "./parse.js";
-
-defineOptions({ inheritAttrs: false });
-
-const props = defineProps({
- value: { type: [String, Number], default: undefined },
- defaultValue: { type: [String, Number], default: undefined },
- disabled: { type: Boolean, default: false },
- id: { type: String, default: undefined },
- name: { type: String, default: undefined },
- "aria-label": { type: String, default: undefined },
-});
-
-const slots = useSlots();
-const attrs = useAttrs();
-const emit = defineEmits(["change"]);
-
-// Port of React UiSelect: parse <option> children into antd Select options.
-const options = computed(() => {
- const opts = elements(slots.default ? slots.default() : []).filter(
- (o) => o.type === "option"
- );
- return opts.map((option) => ({
- value: String(option.props?.value ?? textOf(option.children)),
- label: option.children,
- disabled: !!option.props?.disabled,
- }));
-});
-
-const modelValue = computed(() =>
- props.value === undefined ? undefined : String(props.value)
-);
-
-const defaultVal = computed(() => {
- if (props.defaultValue === undefined)
- return options.value.find((o) => !o.disabled)?.value;
- return String(props.defaultValue);
-});
-
-function onChange(selected) {
- emit("change", {
- target: { value: selected, name: props.name },
- currentTarget: { value: selected, name: props.name },
- });
-}
-</script>
-
-<template>
- <a-select
- :id="id"
- :name="name"
- :disabled="disabled"
- :aria-label="props['aria-label']"
- :value="modelValue"
- :default-value="defaultVal"
- :options="options"
- :class="(typeof attrs.class === 'string' ? attrs.class : '') + ' station-ui-select'"
- :style="attrs.style"
- @change="onChange"
- />
-</template>
+++ /dev/null
-<script>
-import { computed, h, useSlots } from "vue";
-import { Tabs as AntTabs, TabPane } from "ant-design-vue";
-import { elements, classOf, childContent } from "./parse.js";
-
-export default {
- setup() {
- const slots = useSlots();
-
- const buttons = computed(() => {
- const nav = slots.navigation ? slots.navigation() : [];
- return elements(nav);
- });
- const navClass = computed(() => {
- const nav = slots.navigation ? slots.navigation() : [];
- const first = elements(nav)[0];
- return first ? classOf(first) : "";
- });
- const active = computed(
- () =>
- buttons.value.find(
- (b) =>
- b.props &&
- (b.props["aria-selected"] === true ||
- /(^|\s)active(\s|$)/.test(classOf(b)))
- ) ||
- buttons.value[0]
- );
- const activeKey = computed(() => String(active.value?.key ?? 0));
-
- function onChange(key) {
- const idx = buttons.value.findIndex(
- (item, index) => String(item.key ?? index) === key
- );
- const btn = buttons.value[idx];
- if (btn && btn.props && btn.props.onClick) btn.props.onClick(undefined);
- }
-
- return () =>
- h(
- AntTabs,
- {
- class: navClass.value + " station-ui-tabs",
- activeKey: activeKey.value,
- onChange,
- },
- () =>
- buttons.value.map((button, index) =>
- h(
- TabPane,
- {
- key: String(button.key ?? index),
- tab: childContent(button),
- },
- { default: () => (slots.default ? slots.default() : null) }
- )
- )
- );
- },
-};
-</script>
+++ /dev/null
-<script setup>
-import { computed, useAttrs, defineOptions } from "vue";
-import { elements, classOf, childContent } from "./parse.js";
-
-defineOptions({ inheritAttrs: false });
-
-const props = defineProps({
- className: { type: String, default: "" },
-});
-
-const slots = defineSlots();
-const attrs = useAttrs();
-
-// Port of React UiTable: translate <thead>/<tbody> markup into antd Table columns/data.
-const sections = computed(() => elements(slots.default ? slots.default() : []));
-const head = computed(() =>
- sections.value.find((s) => s.type === "thead")
-);
-const body = computed(() =>
- sections.value.find((s) => s.type === "tbody")
-);
-const headerRow = computed(() => {
- const rows = elements(head.value?.children);
- return rows[0];
-});
-const headers = computed(() => elements(headerRow.value?.children));
-const cols = computed(() => {
- const cg = sections.value.find((s) => s.type === "colgroup");
- return elements(cg?.children);
-});
-
-const data = computed(() =>
- elements(body.value?.children).map((row, index) => ({
- key: String(row.key ?? index),
- cells: elements(row.children),
- props: row.props || {},
- }))
-);
-
-const columns = computed(() =>
- headers.value.map((header, index) => {
- const hprops = header.props || {};
- const hrest = { ...hprops };
- delete hrest.children;
- const colEl = cols.value[index];
- const width =
- colEl?.props?.style?.width ||
- colEl?.props?.width ||
- hprops.style?.width;
- return {
- key: String(index),
- title: childContent(header),
- width,
- customRender: ({ index: ri }) => {
- const cell = data.value[ri]?.cells[index];
- return cell ? childContent(cell) : undefined;
- },
- onHeaderCell: () => hrest,
- onCell: (record) => {
- const cell = record.cells[index];
- if (!cell) return { colSpan: 0 };
- const crest = { ...(cell.props || {}) };
- delete crest.children;
- return crest;
- },
- };
- })
-);
-
-function customRow(record) {
- const rrest = { ...(record.props || {}) };
- delete rrest.children;
- return rrest;
-}
-</script>
-
-<template>
- <a-table
- :class="(typeof attrs.class === 'string' ? attrs.class : '') + ' station-ui-table'"
- :style="attrs.style"
- :columns="columns"
- :data-source="data"
- :pagination="false"
- :row-key="(record) => record.key"
- :custom-row="customRow"
- :locale="{ emptyText: '暂无数据' }"
- />
-</template>
+++ /dev/null
-import { Fragment, Comment, Text } from "vue";
-
-// Mirror React ui-system.tsx's Children/elements/textOf helpers for Vue vnodes.
-// React used className; Vue vnodes carry `class`. We normalise both.
-
-function isVNode(n) {
- return !!n && typeof n === "object" && n !== null && "type" in n;
-}
-
-function classOf(vnode) {
- const c = vnode && vnode.props ? vnode.props.class : undefined;
- if (c == null) return "";
- if (typeof c === "string") return c;
- if (Array.isArray(c)) return c.filter(Boolean).join(" ");
- if (typeof c === "object") return Object.keys(c).filter((k) => c[k]).join(" ");
- return String(c);
-}
-
-function elements(children) {
- if (children == null) return [];
- const arr = Array.isArray(children) ? children : [children];
- const out = [];
- for (const child of arr) {
- if (!isVNode(child)) continue;
- if (child.type === Fragment) {
- out.push(...elements(child.children));
- } else if (child.type === Comment) {
- // skip comments
- } else {
- out.push(child);
- }
- }
- return out;
-}
-
-// Vue 3 vnodes carry children at `vnode.children`; component vnodes carry a
-// slots object there ({ default: fn }). Normalise both to renderable content.
-// (React's `props.children` does not exist in Vue vnodes.)
-function childContent(vnode) {
- if (!vnode) return undefined;
- const c = vnode.children;
- if (c && typeof c === "object" && !Array.isArray(c) && typeof c.default === "function") {
- return c.default();
- }
- return c;
-}
-
-function textOf(children) {
- if (children == null) return "";
- const arr = Array.isArray(children) ? children : [children];
- let s = "";
- for (const child of arr) {
- if (child == null) continue;
- if (typeof child === "string" || typeof child === "number") {
- s += String(child);
- continue;
- }
- if (isVNode(child)) {
- if (child.type === Text) {
- s += String(child.children ?? "");
- continue;
- }
- s += textOf(child.children);
- }
- }
- return s;
-}
-
-export { isVNode, classOf, elements, textOf, childContent };
+++ /dev/null
-import vue from "@vitejs/plugin-vue";
-import { fileURLToPath } from "node:url";
-import { defineConfig } from "vite";
-
-// Self-contained local UI configuration for the Vue 3 rewrite of StationBuilder.
-// Same application base path and business components as the React source; a client-only
-// local preview without production hosting metadata or the RSC development runtime.
-export default defineConfig({
- // Vue 使用独立的 root 与 HTML 入口,避免与 React 的 index.html 耦合。
- root: "src-vue",
- base: "/t/stationbuilder-vue",
- build: {
- // root 已切到 src-vue,outDir 用绝对路径固定到仓库根 dist-vue,
- // 与 stationbuilder-vue.service 的 vite preview 保持一致。
- outDir: fileURLToPath(new URL("./dist-vue", import.meta.url)),
- emptyOutDir: true,
- },
- resolve: {
- alias: {
- "@/assets": fileURLToPath(new URL("./src-vue/assets", import.meta.url)),
- "@": fileURLToPath(new URL("./src-vue", import.meta.url)),
- },
- },
- server: {
- host: "127.0.0.1",
- allowedHosts: true,
- hmr: { overlay: false },
- proxy: {
- "/api/db": {
- target: "http://127.0.0.1:37823",
- changeOrigin: true,
- rewrite: (path) => path.replace(/^\/api\/db/, "/api"),
- },
- "/api/chat": {
- target: "http://127.0.0.1:37822",
- changeOrigin: true,
- },
- },
- },
- plugins: [
- vue({
- // StationBuilder serves its images from /public at the app base path, referenced as
- // absolute /t/stationbuilder/... URLs. Disable SFC asset URL rewriting so these stay
- // as runtime public URLs instead of being bundled as module imports.
- template: { transformAssetUrls: false },
- }),
- ],
-});