]> acesimba.cloud Git - StationBuilder-vue.git/commitdiff
领域方案支持必选上传图片并在卡片展示
authorroot <root@localhost.localdomain>
Tue, 29 Sep 2026 01:29:55 +0000 (09:29 +0800)
committerroot <root@localhost.localdomain>
Tue, 29 Sep 2026 01:29:55 +0000 (09:29 +0800)
- db-sidecar: domain_templates 增加 image_path 列;导入/维护接口支持 base64 图片存盘与清除;新增 GET /api/domain-templates/:id/image 服务图片;列表返回 image_path
- ImportDomainPackage: 导入表单新增必选「方案图片」上传
- DomainCapabilityDetail: 维护对话框新增必选「方案图片」,显示已有预览
- CompanyDomainLibrary: 领域卡片优先展示上传图片,回退 SOLUTION_PHOTOS
- 重新构建 dist-vue;经 API 与 Playwright 端到端验证(创建带图/图片端点 200/卡片渲染正确)

server/data/db/StationBuilder.sqlite
server/db-sidecar.mjs
src-vue/pages/domain/CompanyDomainLibrary.vue
src-vue/pages/domain/DomainCapabilityDetail.vue
src-vue/pages/domain/ImportDomainPackage.vue

index b69c358a45931361c7125b5b89335728450b4cf7..d847e046c61d0dd58411d8df032ec73a8d4c6672 100644 (file)
Binary files a/server/data/db/StationBuilder.sqlite and b/server/data/db/StationBuilder.sqlite differ
index 02e4d319fb50d183fc0614c682cc4732af201aea..4c207f13e8ce81b7a85109d082e8f6461fd88239 100644 (file)
@@ -385,6 +385,7 @@ function ensureColumn(table, column, def) {
   if (!cols.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${def}`);
 }
 ensureColumn("cubes", "prompt", "TEXT");
+ensureColumn("domain_templates", "image_path", "TEXT");
 ensureColumn("sites", "style_id", "INTEGER");
 ensureColumn("sites", "parent_id", "INTEGER");
 ensureColumn("sites", "app_name", "TEXT");
@@ -1856,6 +1857,16 @@ async function handleDeviceTemplateImage(res, id, query) {
   res.end(buf);
 }
 
+async function handleDomainTemplateImage(res, id, query) {
+  const row = db.prepare("SELECT image_path FROM domain_templates WHERE id=?").get(id);
+  if (!row || !row.image_path || !existsSync(row.image_path)) return respondError(res, 404, "图片不存在");
+  const w = query ? Number(query.get("w")) : 0;
+  if (w && await serveImageThumb(res, row.image_path, w)) return;
+  const buf = await readFile(row.image_path);
+  res.writeHead(200, { "Content-Type": imgContentType(row.image_path), "Content-Length": buf.length, "Cache-Control": "public, max-age=3600" });
+  res.end(buf);
+}
+
 async function handlePackageDownload(res, siteId) {
   const zipPath = join(GEN_DIR, String(siteId), "station-package.zip");
   if (!existsSync(zipPath)) {
@@ -1895,7 +1906,7 @@ function linkDevice(body, devId) {
 
 async function handleDomainTemplatesList(res) {
   const rows = db.prepare(`
-    SELECT dt.id, dt.name, dt.description, dt.status, dt.version, dt.creator, dt.created_at,
+    SELECT dt.id, dt.name, dt.description, dt.status, dt.version, dt.creator, dt.created_at, dt.image_path,
            d.name AS domain_name, d.id AS domain_id,
            (SELECT COUNT(*) FROM device_template_domain_templates td WHERE td.domain_template_id=dt.id) AS device_count,
            (SELECT COUNT(*) FROM domain_pages dp WHERE dp.domain_template_id=dt.id) AS page_count,
@@ -1914,7 +1925,9 @@ async function handleDomainTemplateCreate(req, res) {
   if (!db.prepare("SELECT 1 FROM domains WHERE id=?").get(domain_id)) return respondError(res, 404, "领域不存在");
   const abbr = body.abbr != null ? body.abbr.toString().trim() : "";
   const status = ["available", "editing", "unavailable"].includes(body.status) ? body.status : "available";
-  const info = db.prepare("INSERT INTO domain_templates (domain_id,name,abbr,description,status,version,creator) VALUES (?,?,?,?,?,?,?)").run(domain_id, name, abbr, body.description || "", status, (body.version || "V1.0").toString(), (body.creator || "").toString());
+  let image_path = null;
+  if (body.image && body.image.data) image_path = (await saveBase64File("domain-templates", body.image.filename, body.image.mime, body.image.data)).path;
+  const info = db.prepare("INSERT INTO domain_templates (domain_id,name,abbr,description,status,version,creator,image_path) VALUES (?,?,?,?,?,?,?,?)").run(domain_id, name, abbr, body.description || "", status, (body.version || "V1.0").toString(), (body.creator || "").toString(), image_path);
   respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
 }
 
@@ -1952,7 +1965,15 @@ async function handleDomainTemplateUpdate(req, res, id) {
   const supplement = body.supplement != null ? body.supplement : row.supplement;
   const style_id = body.style_id !== undefined ? (body.style_id != null ? Number(body.style_id) : null) : row.style_id;
   if (!name) return respondError(res, 400, "模板名称不能为空");
-  db.prepare("UPDATE domain_templates SET domain_id=?,name=?,abbr=?,description=?,status=?,version=?,creator=?,supplement=?,style_id=? WHERE id=?").run(domain_id, name, abbr, description, status, version, creator, supplement, style_id, id);
+  let image_path = row.image_path;
+  if (body.image && body.image.data) {
+    if (image_path && existsSync(image_path)) { try { await rm(image_path); } catch {} }
+    image_path = (await saveBase64File("domain-templates", body.image.filename, body.image.mime, body.image.data)).path;
+  } else if (body.image === null || body.image === "") {
+    if (image_path && existsSync(image_path)) { try { await rm(image_path); } catch {} }
+    image_path = null;
+  }
+  db.prepare("UPDATE domain_templates SET domain_id=?,name=?,abbr=?,description=?,status=?,version=?,creator=?,supplement=?,style_id=?,image_path=? WHERE id=?").run(domain_id, name, abbr, description, status, version, creator, supplement, style_id, image_path, id);
   if (body.device_template_ids !== undefined) {
     db.prepare("DELETE FROM device_template_domain_templates WHERE domain_template_id=?").run(id);
     for (const devId of Array.isArray(body.device_template_ids) ? body.device_template_ids : []) {
@@ -2586,6 +2607,7 @@ async function route(req, res) {
   const mDevAttr = path.match(/^\/api\/device-templates\/(\d+)\/attributes$/);
   const mDev = path.match(/^\/api\/device-templates\/(\d+)$/);
   const mDevImage = path.match(/^\/api\/device-templates\/(\d+)\/image$/);
+  const mDtImage = path.match(/^\/api\/domain-templates\/(\d+)\/image$/);
   const mDevCpComplete = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points$/);
   const mDevCpCompleteStop = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points\/stop$/);
   const mDevCpCompletePrompt = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points-prompt$/);
@@ -2813,6 +2835,7 @@ async function route(req, res) {
     // 页面样式库
     if (mPageStylesImage) return handlePageStyleImage(res, Number(mPageStylesImage[1]), parsed.searchParams);
     if (mDevImage) return handleDeviceTemplateImage(res, Number(mDevImage[1]), parsed.searchParams);
+    if (mDtImage) return handleDomainTemplateImage(res, Number(mDtImage[1]), parsed.searchParams);
     if (mPageStyles) {
       if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT id,name,created_at FROM page_styles ORDER BY id").all());
       if (req.method === "POST") return handleCreatePageStyle(req, res);
index 8c8ba83f0baac710dcd7236c41b259d5b0320e2c..98d744f119d243e5c6856d4caf6c74eb4551b988 100644 (file)
@@ -103,9 +103,9 @@ const statusText = (row) => (row.status === "available" ? "可用" : "不可用"
         <h2>{{ row.name || "未命名领域方案" }}</h2>
         <p>{{ row.description || domainScene(row.domain_name || "").desc }}</p>
         <img
-          v-if="SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
+          v-if="row.image_path || SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
           class="domain-showcase-photo"
-          :src="SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
+          :src="row.image_path ? '/t/stationbuilder-vue/api/db/domain-templates/' + row.id + '/image' : SOLUTION_PHOTOS[domainScene(row.domain_name || '').code]"
           :alt="row.name || domainScene(row.domain_name || '').code"
         />
         <div class="domain-showcase-capabilities">
index 5cdc85474b99e0bfe3564f7a7e60a0ed5e7e8487..b190b24131864be40ea3f4fc7ffcc296dbfb599c 100644 (file)
@@ -12,6 +12,7 @@ import Status from "../../components/StatusTag.vue";
 import Panel from "../../components/Panel.vue";
 import Field from "../../components/Field.vue";
 import ElButton from "../../components/PrimaryButton.vue";
+import DeviceImageField from "./DeviceImageField.vue";
 import DomainPageThumb from "../../components/DomainPageThumb.vue";
 import SearchInput from "../../components/SearchInput.vue";
 import { fetchDb, formatInstances, domainTagColor } from "../../lib/api.js";
@@ -39,6 +40,21 @@ const packageOpen = ref(false);
 const draft = ref(null);
 const creatingSite = ref(false);
 const creatingSiteRef = ref(false);
+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);
+}
 
 const createFromTemplate = async () => {
   if (creatingSiteRef.value) return;
@@ -68,6 +84,8 @@ async function load() {
       description: d.description || "",
     };
     dashboardDomain.value = d.domain_name || props.doms.find((domain) => domain.id === d.domain_id)?.name || "";
+    imgPreview.value = d.image_path ? `/t/stationbuilder-vue/api/db/domain-templates/${props.id}/image` : null;
+    imgB64.value = null;
     muTree.value = mt || [];
   } catch (e) {
     props.notify("加载方案失败:" + (e && e.ElMessage ? e.ElMessage : e));
@@ -80,11 +98,14 @@ onMounted(load);
 async function saveMeta() {
   if (!draft.value.name.trim()) { props.notify("请填写方案名称"); return; }
   if (!/^[A-Z]+$/.test((draft.value.abbr || "").trim())) { props.notify("缩写必须为英文大写字母"); return; }
+  if (!detail.value.image_path && !imgB64.value) { props.notify("请上传领域方案图片"); return; }
   try {
+    const body = { ...draft.value, name: draft.value.name.trim(), abbr: (draft.value.abbr || "").trim() };
+    if (imgB64.value) body.image = imgB64.value;
     await fetchDb(`/domain-templates/${props.id}`, {
       method: "PUT",
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ ...draft.value, name: draft.value.name.trim(), abbr: (draft.value.abbr || "").trim() }),
+      body: JSON.stringify(body),
     });
     props.notify("方案信息已保存");
     editing.value = false;
@@ -369,6 +390,10 @@ function editHandler(card) {
           <span class="field-label">方案说明</span>
           <UiInput textarea :rows="4" :value="draft.description" placeholder="请输入方案说明" @change="(e) => (draft = { ...draft, description: e.target.value })" />
         </label>
+        <label class="field">
+          <span class="field-label"><i class="req">*</i>方案图片</span>
+          <DeviceImageField :image-url="imgPreview" :on-pick="onPick" />
+        </label>
         <UiActions className="dlg-actions">
           <ElButton secondary @click="() => (editing = false)">取消</ElButton>
           <ElButton :disabled="!draft.name.trim() || !/^[A-Z]+$/.test((draft.abbr || '').trim())" @click="saveMeta">保存</ElButton>
index 9ca94a5fa90286b5344a2972cd895715548d7ca0..59efbcd95675e81b347800ea5ee488e1db36e7b3 100644 (file)
@@ -7,6 +7,7 @@ import UiInput from "../../ui/UiInput.vue";
 import UiSelect from "../../ui/UiSelect.vue";
 import UiActions from "../../ui/UiActions.vue";
 import ElButton from "../../components/PrimaryButton.vue";
+import DeviceImageField from "./DeviceImageField.vue";
 import { fetchDb } from "../../lib/api.js";
 import { friendlyDomainName } from "./domainCommon.js";
 
@@ -25,8 +26,23 @@ const abbr = ref("");
 const version = ref("1.0");
 const status = ref("available");
 const description = ref("");
+const imgPreview = ref(null);
+const imgB64 = ref(null);
 const busy = ref(false);
 
+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);
+}
+
 onMounted(() => {
   if (!domainId.value && props.doms.length) domainId.value = Number(props.doms[0].id);
 });
@@ -60,6 +76,10 @@ async function submit() {
     props.notify("缩写必须为英文大写字母");
     return;
   }
+  if (!imgB64.value) {
+    props.notify("请上传领域方案图片");
+    return;
+  }
   busy.value = true;
   try {
     const row = await fetchDb("/domain-templates", {
@@ -73,6 +93,7 @@ async function submit() {
         status: status.value,
         version: version.value.trim() || "1.0",
         creator: CURRENT_USER_EMAIL,
+        image: imgB64.value,
       }),
     });
     props.notify("领域方案导入完成,可继续维护各项能力");
@@ -114,6 +135,7 @@ async function submit() {
             </UiSelect>
           </Field>
           <Field label="方案说明"><UiInput textarea :rows="4" :value="description" @change="(e) => (description = e.target.value)" placeholder="说明方案来源、适用场景或需要现场调整的内容" /></Field>
+          <Field label="方案图片" required><DeviceImageField :image-url="imgPreview" :on-pick="onPick" /></Field>
         </div>
       </div>
       <div class="domain-package-check">