From 670a5323e9e662acadeef59682521f8abff76146 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 09:29:55 +0800 Subject: [PATCH] =?utf8?q?=E9=A2=86=E5=9F=9F=E6=96=B9=E6=A1=88=E6=94=AF?= =?utf8?q?=E6=8C=81=E5=BF=85=E9=80=89=E4=B8=8A=E4=BC=A0=E5=9B=BE=E7=89=87?= =?utf8?q?=E5=B9=B6=E5=9C=A8=E5=8D=A1=E7=89=87=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 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 | Bin 15376384 -> 15376384 bytes server/db-sidecar.mjs | 29 ++++++++++++++++-- src-vue/pages/domain/CompanyDomainLibrary.vue | 4 +-- .../pages/domain/DomainCapabilityDetail.vue | 27 +++++++++++++++- src-vue/pages/domain/ImportDomainPackage.vue | 22 +++++++++++++ 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/server/data/db/StationBuilder.sqlite b/server/data/db/StationBuilder.sqlite index b69c358a45931361c7125b5b89335728450b4cf7..d847e046c61d0dd58411d8df032ec73a8d4c6672 100644 GIT binary patch delta 1237 zcmYMvSyYSx0LAh7Mr5myA#2%}nmuKTvhRELtv>pyVW!E~G=wxGjGctB??iT`$(~)- z46^SO4<5byJ09F~eoyzDd(L&17x_D}u~p+zg(9#>@ju%92%}JXHfeL55LECx?ww%B z^ToS;@dA{n1wYhA9r&Xz>Y+XwpdkVfh#)jVV>Cf9nnHzUXpRuHKufejYqUXIgdz;> z&>kJo5uMN(UCi83fEpTTp@SX<7!ipch(a`a!h{&aq8EB24u8M*L0|Mke+)zkCSnpMV+y8X8m40gW+D+u5HSn0kqikbNJSdV zn1i{nz>0L(yuSF1UX@BeZ*jh}&@b0DC@4^&Of-h7!i@?=u&a%b^s{K~kJGN2eXj32 zuhy(`e=94g_LNp{y->Y>WuiXXq>GA-jE*uGdzc)OY);7zGh4+}DJ(0)ViS`^hiJBl zvnkI|cA42JQcBgbgdY*tB|U>EIXTYA4N zvn5%Q)s8Gln<>c-Pi{nxs+mpIfHNzq@rcJilH5uutZl`LV*Qt`%T@*~-z)E?J zQn=*}7_z!tsGpY;RIN`!lV+Gt;iB(vQHCT&vSdR_Zh;lndL#|u>9C0^k*-rz0X;XOX!BR=6X VzThjq;XA5PgCE}5s-L$j{s5c4P0|1W delta 1113 zcmYMv>0iqM0D$rRMp9ZSW|YX0b4#`8R3db^Rpcs^tP)M`qY;*fQkpa7))*&;rXSkpzT;YaZ=nX0QK!(2P2X}a&KRhu21L1{17>pqp z3U3UN}14_PQFIuGUswZ_^i~ zrK)mO+3AV?x(fFkPq$!&e~_1NV2E!}NPu5tLR3t=RwKNcoQ57} za2Drq9v5&Cmv9-CFyIQV!ia0Qjw;+hHEM7ZwJ@O$^=QB?+{PW;#XU6QJ|5s99-#@3 z@dQuNjAwX`7kG(RcnvdJV8I)_g%z!Mhxhn^kNAW(e8v}i#W#G%5Bx+sI$*;ubeck2 Ie>XM%1sbdzWdHyG diff --git a/server/db-sidecar.mjs b/server/db-sidecar.mjs index 02e4d31..4c207f1 100644 --- a/server/db-sidecar.mjs +++ b/server/db-sidecar.mjs @@ -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); diff --git a/src-vue/pages/domain/CompanyDomainLibrary.vue b/src-vue/pages/domain/CompanyDomainLibrary.vue index 8c8ba83..98d744f 100644 --- a/src-vue/pages/domain/CompanyDomainLibrary.vue +++ b/src-vue/pages/domain/CompanyDomainLibrary.vue @@ -103,9 +103,9 @@ const statusText = (row) => (row.status === "available" ? "可用" : "不可用"

{{ row.name || "未命名领域方案" }}

{{ row.description || domainScene(row.domain_name || "").desc }}

diff --git a/src-vue/pages/domain/DomainCapabilityDetail.vue b/src-vue/pages/domain/DomainCapabilityDetail.vue index 5cdc854..b190b24 100644 --- a/src-vue/pages/domain/DomainCapabilityDetail.vue +++ b/src-vue/pages/domain/DomainCapabilityDetail.vue @@ -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) { 方案说明 + 取消 保存 diff --git a/src-vue/pages/domain/ImportDomainPackage.vue b/src-vue/pages/domain/ImportDomainPackage.vue index 9ca94a5..59efbcd 100644 --- a/src-vue/pages/domain/ImportDomainPackage.vue +++ b/src-vue/pages/domain/ImportDomainPackage.vue @@ -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() { +
-- 2.43.0