素材图片在高分辨率屏幕上清晰显示;项目资源对话框点整行勾选
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 素材库每张图另外渲染一份两倍尺寸(name.2x.png),build-gallery.sh 把图片编号(原尺寸 PNG 的
  SHA-1,和运行器给图片的编号相同)和两倍图写进 index.json
- 网页认出素材图片(按编号或素材网址),图片框用 srcset、背景用 image-set,系统缩放 150%、200%
  的屏幕上不再发虚;学生程序读到的仍是原尺寸图片,宽高、评分都不变
- 素材清单 index.json 每次向服务器确认(不再缓存一小时),部署新素材后立即生效
- 项目资源对话框:点整行就能勾选(原来只能点复选框,"试听"不会勾选);缩略图用灰色棋盘格底,
  透明背景上的白色图片(ico_title)也看得见

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-25 18:49:34 +08:00
1 parent f304fc1ffc
commit c217eb5ca0
33 files changed
+98 -41

No files matched your search

+1 -1
View File
@@ -2,6 +2,6 @@
学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和尺寸与教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。 学生在"项目资源"里添加的图片和声音(`Properties.Resources.xxx`),名字和尺寸与教材一致,全部由平台自绘或合成(不使用教材配套资料里的素材)。
- `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型)。 - `gallery/`:发布给运行器和网页的文件,`index.json` 是清单(名字、文件、中文说明、类型;图片还有编号 key 和两倍图 hidpi,由 `build-gallery.sh` 写入)。`name.png` 是原尺寸,学生程序读到的就是它;`name.2x.png` 只给高分辨率屏幕的浏览器显示用,宽高仍按原尺寸。
- `svg/`:图片的源文件;`project8.py` 生成项目 8 的那些 SVG。改了以后运行 `python3 project8.py && sh build-gallery.sh` 重新生成 PNG(需要 rsvg-convert 和思源黑体)。 - `svg/`:图片的源文件;`project8.py` 生成项目 8 的那些 SVG。改了以后运行 `python3 project8.py && sh build-gallery.sh` 重新生成 PNG(需要 rsvg-convert 和思源黑体)。
- `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。 - `gallery/ring.mp3`:用 ffmpeg 合成的门铃声(叮—咚),命令见提交记录。
@@ -1,6 +1,23 @@
#!/bin/sh #!/bin/sh
# 把 svg/ 下的素材渲染成 gallery/ 下的 PNG(尺寸取 SVG 自己的 width / height)。需要 rsvg-convert 和思源黑体等中文字体。 # 把 svg/ 下的素材渲染成 gallery/ 下的 PNG:name.png 是原尺寸(学生程序读到的图片,宽高和教材一致),
# name.2x.png 是两倍尺寸(只给高分辨率屏幕的浏览器显示用)。然后把每张图的编号(原尺寸 PNG 的 SHA-1,
# 和运行器给图片的编号相同)和两倍图的文件名写进 gallery/index.json。需要 rsvg-convert 和思源黑体等中文字体。
cd "$(dirname "$0")" cd "$(dirname "$0")"
for f in svg/*.svg; do for f in svg/*.svg; do
rsvg-convert "$f" -o "gallery/$(basename "$f" .svg).png" name=$(basename "$f" .svg)
rsvg-convert "$f" -o "gallery/$name.png"
rsvg-convert -z 2 "$f" -o "gallery/$name.2x.png"
done done
python3 - <<'PY'
import hashlib, json, os
items = json.load(open("gallery/index.json", encoding="utf-8"))
for item in items:
if item["kind"] != "image":
continue
item["key"] = hashlib.sha1(open(f"gallery/{item['file']}", "rb").read()).hexdigest()
hidpi = item["file"].replace(".png", ".2x.png")
if os.path.exists(f"gallery/{hidpi}"):
item["hidpi"] = hidpi
lines = ",\n".join(" " + json.dumps(i, ensure_ascii=False) for i in items)
open("gallery/index.json", "w", encoding="utf-8").write(f"[\n{lines}\n]\n")
PY
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,27 +1,27 @@
[ [
{ "name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image" }, {"name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image", "key": "6e660e1ff6fc2ff3167919bc386c300112bbce72", "hidpi": "switch_on.2x.png"},
{ "name": "switch_off", "file": "switch_off.png", "label": "开关:关", "kind": "image" }, {"name": "switch_off", "file": "switch_off.png", "label": "开关:关", "kind": "image", "key": "5d748b614084e878d6189d22f27813477cb8f4d9", "hidpi": "switch_off.2x.png"},
{ "name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" }, {"name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio"},
{ "name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image" }, {"name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image", "key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f", "hidpi": "ico_title.2x.png"},
{ "name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image" }, {"name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image", "key": "e56bcc13f7128dec305b6012b255d63fb0f94450", "hidpi": "bg_home.2x.png"},
{ "name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image" }, {"name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image", "key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d", "hidpi": "icon_02.2x.png"},
{ "name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image" }, {"name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image", "key": "d085064a505544968278a85233b7d9ac23661169", "hidpi": "icon_03.2x.png"},
{ "name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image" }, {"name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image", "key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3", "hidpi": "icon_04.2x.png"},
{ "name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image" }, {"name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image", "key": "05065abdb4538906bd7c86e940ab5fb02d51469d", "hidpi": "day_01.2x.png"},
{ "name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image" }, {"name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image", "key": "470d523e90e188fe5c423d8cffeae6993d5528ff", "hidpi": "day_02.2x.png"},
{ "name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image" }, {"name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image", "key": "7a8c0a16fcab37b28a66e041024b25341cc69462", "hidpi": "bg_from1.2x.png"},
{ "name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image" }, {"name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image", "key": "52497df301d2b7e4eac0c297ce2316ef24863c47", "hidpi": "bg_from2.2x.png"},
{ "name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image" }, {"name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image", "key": "23e6f1efc0f382c17d8a091b76531a3b5879349d", "hidpi": "icon_monitor.2x.png"},
{ "name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image" }, {"name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image", "key": "396443a1d8c141942c0f447e0c69786cf6ad3350", "hidpi": "panel_video_ctrl.2x.png"},
{ "name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image" }, {"name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image", "key": "3c8b75ad465c8a61d0279facc233a0c64b62205a", "hidpi": "btn_arrow_up_normal.2x.png"},
{ "name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image" }, {"name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image", "key": "51e1b9926d2bd611e3fda74a01231b46a930c460", "hidpi": "btn_arrow_up_press.2x.png"},
{ "name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image" }, {"name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image", "key": "caf46f81c15aaca788462ebc47e58df299f097d9", "hidpi": "btn_arrow_down_normal.2x.png"},
{ "name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image" }, {"name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image", "key": "88c1bea8d6fad99b14658d8265a88b04bc595006", "hidpi": "btn_arrow_down_press.2x.png"},
{ "name": "btn_arrow_left_normal", "file": "btn_arrow_left_normal.png", "label": "方向键:左", "kind": "image" }, {"name": "btn_arrow_left_normal", "file": "btn_arrow_left_normal.png", "label": "方向键:左", "kind": "image", "key": "a38beb2373154d0474ad1715035aec5e2adf8c3f", "hidpi": "btn_arrow_left_normal.2x.png"},
{ "name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image" }, {"name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image", "key": "47bd7a8ffc077130a8ceca627383a4647729c638", "hidpi": "btn_arrow_left_press.2x.png"},
{ "name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image" }, {"name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image", "key": "e69e459f54d67e2c52128015bd6cfcf0e800a893", "hidpi": "btn_arrow_right_normal.2x.png"},
{ "name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image" }, {"name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image", "key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170", "hidpi": "btn_arrow_right_press.2x.png"},
{ "name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image" }, {"name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image", "key": "5a9036955f791f0c41ba52afffb5ecc640d137f1", "hidpi": "arrow_left_normal.2x.png"},
{ "name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image" }, {"name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image", "key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9", "hidpi": "arrow_right_normal.2x.png"},
{ "name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image" } {"name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image", "key": "e361965dbd881dc21511208ce11f649517e8ddb6", "hidpi": "windowstate_closed_hover.2x.png"}
] ]
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -1
View File
@@ -80,7 +80,8 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
if (!/^[\w.-]+$/.test(name)) return c.notFound() if (!/^[\w.-]+$/.test(name)) return c.notFound()
const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}`) const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}`)
if (!(await file.exists())) return c.notFound() if (!(await file.exists())) return c.notFound()
return new Response(file, { headers: { 'Cache-Control': 'public, max-age=3600' } }) // 清单随部署更新(新素材、图片编号),每次都向服务器确认;图片缓存一小时
return new Response(file, { headers: { 'Cache-Control': name === 'index.json' ? 'no-cache' : 'public, max-age=3600' } })
}) })
// 其余接口都要先登录(请求头 Authorization: Bearer <令牌>) // 其余接口都要先登录(请求头 Authorization: Bearer <令牌>)
+17 -5
View File
@@ -45,13 +45,20 @@ function save() {
<NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)"> <NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
<p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p> <p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p>
<div class="items"> <div class="items">
<label v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }"> <!-- 点整行都能勾选;复选框和"试听"自己处理点击,不再传给这一行 -->
<NCheckbox :checked="chosen.has(item.name)" @update:checked="toggle(item.name, $event)" /> <div v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }" @click="toggle(item.name, !chosen.has(item.name))">
<img v-if="item.kind === 'image'" :src="`/resources/${item.file}`" alt="" class="thumb" /> <NCheckbox :checked="chosen.has(item.name)" @click.stop @update:checked="toggle(item.name, $event)" />
<NButton v-else size="tiny" class="audio" @click.prevent="listen(item.file)">▶ 试听</NButton> <img
v-if="item.kind === 'image'"
:src="`/resources/${item.file}`"
:srcset="item.hidpi ? `/resources/${item.file} 1x, /resources/${item.hidpi} 2x` : undefined"
alt=""
class="thumb"
/>
<NButton v-else size="tiny" class="audio" @click.stop="listen(item.file)">▶ 试听</NButton>
<span class="name">{{ item.name }}</span> <span class="name">{{ item.name }}</span>
<span class="label">{{ item.label }}</span> <span class="label">{{ item.label }}</span>
</label> </div>
<div v-if="gallery.length === 0" class="note">正在加载素材库…</div> <div v-if="gallery.length === 0" class="note">正在加载素材库…</div>
</div> </div>
<template #footer> <template #footer>
@@ -106,6 +113,11 @@ function save() {
max-height: 40px; max-height: 40px;
} }
/* 灰色棋盘格底:透明背景上的白色图片(如 ico_title 的白字)也看得见 */
.thumb {
background: repeating-conic-gradient(#9ca3af 0 25%, #c4c9d0 0 50%) 0 0 / 10px 10px;
}
.audio { .audio {
justify-self: start; justify-self: start;
} }
+4
View File
@@ -11,6 +11,10 @@ export interface GalleryItem {
/** 中文说明 */ /** 中文说明 */
label: string label: string
kind: 'image' | 'audio' kind: 'image' | 'audio'
/** 图片的编号(原尺寸 PNG 的 SHA-1,和运行器给图片的编号相同) */
key?: string
/** 两倍尺寸的图片文件,高分辨率屏幕上显示用 */
hidpi?: string
} }
export const RESOURCES_FILE = 'Properties/Resources.Designer.cs' export const RESOURCES_FILE = 'Properties/Resources.Designer.cs'
+3
View File
@@ -2,6 +2,7 @@
// 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。 // 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。
// 实验页和老师的评阅页共用。 // 实验页和老师的评阅页共用。
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue'
import { loadGallery } from '../designer/resources'
import TrainingBoard from '../scene/TrainingBoard.vue' import TrainingBoard from '../scene/TrainingBoard.vue'
import type { SizeValue } from '../winforms/format' import type { SizeValue } from '../winforms/format'
import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue' import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue'
@@ -14,6 +15,8 @@ const emit = defineEmits<{ diagnostic: [file: string, line: number, column: numb
const state = props.session.state const state = props.session.state
provide(RUN_IMAGES, props.session.images) provide(RUN_IMAGES, props.session.images)
// 素材库清单:认出素材图片,在高分辨率屏幕上换成两倍图
void loadGallery()
/** 照片列表里点开看大图 */ /** 照片列表里点开看大图 */
const viewing = ref<{ path: string; url: string } | null>(null) const viewing = ref<{ path: string; url: string } | null>(null)
+3 -3
View File
@@ -4,7 +4,7 @@
import { computed, inject, provide, ref, type CSSProperties } from 'vue' import { computed, inject, provide, ref, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared' import type { ControlNode, UserAction } from '@iot/shared'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format' import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format'
import { backgroundCss, RUN_IMAGES, type ImageValue } from './images' import { backgroundCss, imageSources, RUN_IMAGES, srcset, type ImageValue } from './images'
import ChartControl from './ChartControl.vue' import ChartControl from './ChartControl.vue'
import DataGridViewControl from './DataGridViewControl.vue' import DataGridViewControl from './DataGridViewControl.vue'
import DateTimePickerControl from './DateTimePickerControl.vue' import DateTimePickerControl from './DateTimePickerControl.vue'
@@ -72,7 +72,7 @@ function selectItem(index: number) {
// PictureBox:按 SizeMode 摆放图片 // PictureBox:按 SizeMode 摆放图片
const image = computed(() => p.value.Image as ImageValue | null | undefined) const image = computed(() => p.value.Image as ImageValue | null | undefined)
const imageUrl = computed(() => (image.value ? image.value.url ?? runImages.get(image.value.key) : undefined)) const imageSrc = computed(() => imageSources(image.value, runImages))
// 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小 // 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小
const naturalSize = computed(() => (image.value?.width ? { width: `${image.value.width}px`, height: `${image.value.height}px` } : {})) const naturalSize = computed(() => (image.value?.width ? { width: `${image.value.width}px`, height: `${image.value.height}px` } : {}))
const imageStyle = computed((): CSSProperties => { const imageStyle = computed((): CSSProperties => {
@@ -182,7 +182,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
@pointerdown="mouse('mouseDown', $event)" @pointerdown="mouse('mouseDown', $event)"
@pointerup="mouse('mouseUp', $event)" @pointerup="mouse('mouseUp', $event)"
> >
<img v-if="imageUrl" :src="imageUrl" :style="imageStyle" alt="" draggable="false" /> <img v-if="imageSrc" :src="imageSrc.src" :srcset="srcset(imageSrc)" :style="imageStyle" alt="" draggable="false" />
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" /> <ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div> </div>
+24 -4
View File
@@ -1,6 +1,7 @@
// 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。 // 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。
// 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。 // 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。
import type { CSSProperties, InjectionKey } from 'vue' import type { CSSProperties, InjectionKey } from 'vue'
import { gallery } from '../designer/resources'
export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages') export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages')
@@ -13,12 +14,31 @@ export interface ImageValue {
url?: string url?: string
} }
/**
* 图片的网址。素材库里的图片(按编号或素材网址认出来)另有两倍尺寸的版本,
* 高分辨率屏幕(系统缩放 150%、200%)上用它,不然浏览器放大原图会发虚;程序读到的宽高不变。
*/
export function imageSources(value: unknown, images: ReadonlyMap<string, string>): { src: string; hidpi?: string } | undefined {
const image = value as ImageValue | null | undefined
if (!image) return undefined
const item = gallery.value.find((g) => (image.key && g.key === image.key) || (image.url && image.url === `/resources/${g.file}`))
if (item?.hidpi) return { src: `/resources/${item.file}`, hidpi: `/resources/${item.hidpi}` }
const src = image.url ?? images.get(image.key)
return src ? { src } : undefined
}
/** <img> 的 srcset:有两倍图时浏览器按屏幕分辨率选 */
export const srcset = (sources: { src: string; hidpi?: string } | undefined) =>
sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */ /** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties { export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
const image = value as ImageValue | null | undefined const sources = imageSources(value, images)
const url = image ? image.url ?? images.get(image.key) : undefined if (!sources) return {}
if (!url) return {} // image-set 里的两倍图按 2x 显示,平铺、居中、左上角时仍是原尺寸
const base = { backgroundImage: `url("${url}")` } const base: CSSProperties = {
backgroundImage: sources.hidpi ? `image-set(url("${sources.src}") 1x, url("${sources.hidpi}") 2x)` : `url("${sources.src}")`,
}
switch (layout) { switch (layout) {
case 'None': case 'None':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: '0 0' } return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: '0 0' }