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");
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)) {
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,
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 });
}
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 : []) {
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$/);
// 页面样式库
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);
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";
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;
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));
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;
<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>
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";
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);
});
props.notify("缩写必须为英文大写字母");
return;
}
+ if (!imgB64.value) {
+ props.notify("请上传领域方案图片");
+ return;
+ }
busy.value = true;
try {
const row = await fetchDb("/domain-templates", {
status: status.value,
version: version.value.trim() || "1.0",
creator: CURRENT_USER_EMAIL,
+ image: imgB64.value,
}),
});
props.notify("领域方案导入完成,可继续维护各项能力");
</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">