项目 7 情景控制:虚拟时钟、项目资源、Windows Media Player、TrackBar
- 虚拟时钟:实训台时钟面板可拨时间、1×/10×/60× 倍速、恢复真实时间;编译时把 DateTime.Now / Today / UtcNow 改写成 SimClock;评分规则加 clock 步骤 - 项目资源(Properties.Resources):平台素材库(自绘开关图片、合成门铃声),"项目资源"对话框勾选, 平台生成 Properties/Resources.Designer.cs;设计器图片框的"图片"属性可选资源;程序目录下的 Resources 指向素材库;未添加资源时给中文提示 - Windows Media Player(AxWMPLib):设置 URL 自动播放、Ctlcontrols.play/stop,浏览器里播放声音, 实训台加广播喇叭;编译时给窗体嵌空资源,OcxState 取到 null 不报错;图片和声音统一按内容缓存(blob) - TrackBar:拖动、点轨道、方向键,先 ValueChanged 再 Scroll;设计器加"滑动条""媒体播放器" - 评分规则加 value、media 检查;示例项目 7 任务 1–3(修正书上开关灯判断写反、漏填串口列表) - 修正:Properties/ 下的资源类不当作窗体;带命名空间的 System.AppDomain.CurrentDomain.BaseDirectory 放行 - 测试:虚拟时钟、TrackBar、项目 7 示例评分、作业起点带资源类 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9af26c2d6
commit
eedf1aa2fb
66 files changed
+3538
-43
No files matched your search
@@ -31,6 +31,8 @@ const props = defineProps<{
|
||||
codeText: string
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
/** 项目资源(图片属性的选项) */
|
||||
resources?: { names: string[]; prefix: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -42,6 +44,8 @@ const emit = defineEmits<{
|
||||
openDesignerFile: []
|
||||
undo: []
|
||||
redo: []
|
||||
/** 属性窗口里要添加项目资源 */
|
||||
addResource: []
|
||||
}>()
|
||||
|
||||
/** Ctrl+Z 撤销,Ctrl+Y / Ctrl+Shift+Z 重做;在属性窗口的输入框里打字时不拦截(输入框有自己的撤销) */
|
||||
@@ -492,11 +496,13 @@ const noop = () => {}
|
||||
class="properties"
|
||||
:doc="doc"
|
||||
:selected="selected"
|
||||
:resources="resources"
|
||||
@set-prop="onSetProp"
|
||||
@set-items="onSetItems"
|
||||
@rename="onRename"
|
||||
@open-event="openEvent"
|
||||
@clear-event="clearEvent"
|
||||
@add-resource="emit('addResource')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,12 +4,15 @@ import { computed, ref, watch } from 'vue'
|
||||
import { type ControlDef, FORM_DEF, type PropDef, controlDef } from './catalog'
|
||||
import type { DesignerDocument } from './document'
|
||||
import { checkName, getEventHandler, getItems, getPropText, typeOf } from './edit'
|
||||
import { resourceNameOf } from './resources'
|
||||
import { NAMED_COLORS, hexToRgb, parseValue, rgb, type Value } from './values'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: DesignerDocument
|
||||
/** '' 表示窗体本身 */
|
||||
selected: string
|
||||
/** 项目资源(图片属性从这里选)和引用资源的前缀 global::命名空间.Properties.Resources. */
|
||||
resources?: { names: string[]; prefix: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,6 +22,8 @@ const emit = defineEmits<{
|
||||
/** 双击事件或点"生成":绑定(必要时生成)事件处理方法并跳到代码 */
|
||||
openEvent: [target: string, event: string, handler?: string]
|
||||
clearEvent: [target: string, event: string]
|
||||
/** 图片属性里选了"添加资源…":打开项目资源对话框 */
|
||||
addResource: []
|
||||
}>()
|
||||
|
||||
const tab = ref<'props' | 'events'>('props')
|
||||
@@ -37,6 +42,22 @@ function set(prop: PropDef, value: Value) {
|
||||
emit('setProp', props.selected, prop.name, isDefault ? null : value)
|
||||
}
|
||||
|
||||
/** 图片属性:项目资源里的名字,没有设置时为空 */
|
||||
function imageOf(prop: PropDef) {
|
||||
const text = getPropText(props.doc, props.selected, prop.name)
|
||||
return text === undefined ? '' : resourceNameOf(text) ?? text
|
||||
}
|
||||
|
||||
function setImage(prop: PropDef, choice: string, select: HTMLSelectElement) {
|
||||
if (choice === '+') {
|
||||
select.value = imageOf(prop)
|
||||
emit('addResource')
|
||||
return
|
||||
}
|
||||
if (choice === '') reset(prop)
|
||||
else emit('setProp', props.selected, prop.name, { kind: 'raw', text: `${props.resources?.prefix ?? ''}${choice}` })
|
||||
}
|
||||
|
||||
function reset(prop: PropDef) {
|
||||
emit('setProp', props.selected, prop.name, null)
|
||||
}
|
||||
@@ -152,7 +173,7 @@ function renameHandler(event: string, value: string) {
|
||||
<div v-for="prop in def.props" :key="prop.name" class="row">
|
||||
<label :title="`${prop.name}:${prop.description}`">{{ prop.label }}</label>
|
||||
<div class="editor">
|
||||
<code v-if="raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
|
||||
<code v-if="prop.type !== 'image' && raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
|
||||
|
||||
<input v-else-if="prop.type === 'string'" :value="str(prop)" @change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })" />
|
||||
|
||||
@@ -213,6 +234,14 @@ function renameHandler(event: string, value: string) {
|
||||
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
|
||||
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
|
||||
|
||||
<select v-else-if="prop.type === 'image'" :value="imageOf(prop)"
|
||||
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)">
|
||||
<option value="">(无)</option>
|
||||
<option v-for="name in resources?.names ?? []" :key="name" :value="name">Resources.{{ name }}</option>
|
||||
<option v-if="imageOf(prop) && !resources?.names.includes(imageOf(prop))" :value="imageOf(prop)">{{ imageOf(prop) }}(不在项目资源里)</option>
|
||||
<option value="+">添加资源…</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// 项目资源(相当于 VS 的"项目属性 → 资源 → 添加资源"):从平台素材库里勾选图片和声音,
|
||||
// 代码里用 Properties.Resources.名字,设计器里图片框的"图片"属性也可以选它们。
|
||||
import { NButton, NCheckbox, NModal } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { gallery, loadGallery } from './resources'
|
||||
|
||||
const props = defineProps<{ show: boolean; selected: string[] }>()
|
||||
const emit = defineEmits<{ 'update:show': [show: boolean]; save: [names: string[]] }>()
|
||||
|
||||
const chosen = ref(new Set<string>())
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
void loadGallery()
|
||||
chosen.value = new Set(props.selected)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggle(name: string, on: boolean) {
|
||||
const next = new Set(chosen.value)
|
||||
if (on) next.add(name)
|
||||
else next.delete(name)
|
||||
chosen.value = next
|
||||
}
|
||||
|
||||
let audio: HTMLAudioElement | undefined
|
||||
function listen(file: string) {
|
||||
audio?.pause()
|
||||
audio = new Audio(`/resources/${file}`)
|
||||
void audio.play()
|
||||
}
|
||||
|
||||
function save() {
|
||||
// 按素材库的顺序保存,生成的代码稳定
|
||||
emit('save', gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)))
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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)" />
|
||||
<img v-if="item.kind === 'image'" :src="`/resources/${item.file}`" alt="" class="thumb" />
|
||||
<NButton v-else size="tiny" class="audio" @click.prevent="listen(item.file)">▶ 试听</NButton>
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="label">{{ item.label }}</span>
|
||||
</label>
|
||||
<div v-if="gallery.length === 0" class="note">正在加载素材库…</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="buttons">
|
||||
<NButton type="primary" @click="save">确定</NButton>
|
||||
<NButton @click="emit('update:show', false)">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 64px 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item.on {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.item > :first-child {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.thumb,
|
||||
.audio {
|
||||
grid-row: span 2;
|
||||
max-width: 64px;
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
.audio {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
// 只列出仿真运行时已经实现的控件和属性;默认值与 WinForms 一致(等于默认值的属性不写进 Designer.cs)。
|
||||
import type { Value } from './values'
|
||||
|
||||
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items'
|
||||
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items' | 'image'
|
||||
|
||||
export interface PropDef {
|
||||
/** WinForms 的属性名,写进 Designer.cs,代码里也用它 */
|
||||
@@ -187,6 +187,7 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'Click',
|
||||
props: [
|
||||
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'),
|
||||
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
|
||||
{
|
||||
name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`,
|
||||
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
|
||||
@@ -254,6 +255,38 @@ export const CONTROLS: ControlDef[] = [
|
||||
events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'ValueChanged', label: '值改变', ...EVENT }],
|
||||
initial: () => [['Size', size(200, 21)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.TrackBar`,
|
||||
label: '滑动条',
|
||||
group: '常用控件',
|
||||
namePrefix: 'trackBar',
|
||||
defaultEvent: 'Scroll',
|
||||
props: [
|
||||
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'),
|
||||
{ name: 'Minimum', label: '最小值', description: '滑块在最小端时的值', type: 'int', default: int(0), category: '行为' },
|
||||
{ name: 'Maximum', label: '最大值', description: '滑块在最大端时的值', type: 'int', default: int(10), category: '行为' },
|
||||
{ name: 'Value', label: '值', description: '滑块现在的值', type: 'int', default: int(0), category: '行为' },
|
||||
{ name: 'TickFrequency', label: '刻度间隔', description: '每隔多少画一个刻度', type: 'int', default: int(1), category: '外观' },
|
||||
{ name: 'LargeChange', label: '大步长', description: '点击轨道时每次改变多少', type: 'int', default: int(5), category: '行为' },
|
||||
{ name: 'SmallChange', label: '小步长', description: '按方向键时每次改变多少', type: 'int', default: int(1), category: '行为' },
|
||||
{
|
||||
name: 'TickStyle', label: '刻度位置', description: '刻度画在哪一边', type: 'enum', enumType: `${FORMS}.TickStyle`,
|
||||
options: ['None', 'TopLeft', 'BottomRight', 'Both'], optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
|
||||
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'), category: '外观',
|
||||
},
|
||||
{
|
||||
name: 'Orientation', label: '方向', description: '横向或竖向', type: 'enum', enumType: `${FORMS}.Orientation`,
|
||||
options: ['Horizontal', 'Vertical'], optionLabels: { Horizontal: '横向', Vertical: '竖向' },
|
||||
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'), category: '外观',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
{ name: 'Scroll', label: '拖动滑块', ...EVENT },
|
||||
{ name: 'ValueChanged', label: '值改变', ...EVENT },
|
||||
],
|
||||
initial: () => [['Size', size(104, 45)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.CheckBox`,
|
||||
label: '复选框',
|
||||
@@ -312,6 +345,16 @@ export const CONTROLS: ControlDef[] = [
|
||||
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
|
||||
initial: () => [],
|
||||
},
|
||||
{
|
||||
type: 'AxWMPLib.AxWindowsMediaPlayer',
|
||||
label: '媒体播放器',
|
||||
group: '物联网设备',
|
||||
namePrefix: 'axWindowsMediaPlayer',
|
||||
defaultEvent: 'Enter',
|
||||
props: COMMON_PROPS.filter((p) => ['Location', 'Size', 'Enabled', 'Visible', 'TabIndex'].includes(p.name)),
|
||||
events: [{ name: 'Enter', label: '获得焦点', ...EVENT }],
|
||||
initial: () => [['Enabled', bool(true)], ['Size', size(245, 240)]],
|
||||
},
|
||||
{
|
||||
type: 'WinFormsDeviceControlLibrary.LED',
|
||||
label: 'LED 显示屏',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ControlNode } from '@iot/shared'
|
||||
import { controlDef } from './catalog'
|
||||
import type { DesignerDocument } from './document'
|
||||
import { childrenOf, findBlock, getItems, listControls } from './edit'
|
||||
import { resourceNameOf, resourceUrl } from './resources'
|
||||
import { parseValue, type Value } from './values'
|
||||
|
||||
/** 窗体在预览树里的 id;控件的 id 就是控件名 */
|
||||
@@ -11,6 +12,7 @@ export const FORM_ID = '__form'
|
||||
|
||||
/** 运行时控件树里的类型名:System.Windows.Forms.Button → Button,教材控件库保留完整类名 */
|
||||
function clientType(type: string) {
|
||||
if (type === 'AxWMPLib.AxWindowsMediaPlayer') return 'AxWindowsMediaPlayer'
|
||||
return type.startsWith('System.Windows.Forms.') ? type.slice('System.Windows.Forms.'.length) : type
|
||||
}
|
||||
|
||||
@@ -43,6 +45,12 @@ function propsOf(doc: DesignerDocument, target: string): Record<string, unknown>
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const stmt of findBlock(doc, target)?.stmts ?? []) {
|
||||
if (stmt.kind !== 'prop' || stmt.path.includes('.')) continue
|
||||
// 项目资源里的图片:预览时直接显示素材文件
|
||||
const resource = resourceNameOf(stmt.value)
|
||||
if (resource) {
|
||||
props[stmt.path] = { key: '', url: resourceUrl(resource), width: 0, height: 0 }
|
||||
continue
|
||||
}
|
||||
const value = runtimeValue(parseValue(stmt.value))
|
||||
if (value !== undefined) props[stmt.path] = value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 项目资源(Properties.Resources):学生从平台素材库里选图片和声音,平台生成 Properties/Resources.Designer.cs。
|
||||
// 生成的类和 VS 的强类型资源类用法一样(Properties.Resources.switch_on 是 Bitmap,声音是 byte[]),
|
||||
// 取值时调用运行器的素材库。这个文件由平台生成,不在标签页里显示。
|
||||
import { ref } from 'vue'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
|
||||
export interface GalleryItem {
|
||||
/** 资源名,也是 Properties.Resources 的属性名,和教材一致(如 switch_on) */
|
||||
name: string
|
||||
file: string
|
||||
/** 中文说明 */
|
||||
label: string
|
||||
kind: 'image' | 'audio'
|
||||
}
|
||||
|
||||
export const RESOURCES_FILE = 'Properties/Resources.Designer.cs'
|
||||
|
||||
/** 素材清单(服务器上 /resources/index.json),第一次用到时加载 */
|
||||
export const gallery = ref<GalleryItem[]>([])
|
||||
let loading: Promise<void> | undefined
|
||||
|
||||
export function loadGallery() {
|
||||
loading ??= fetch('/resources/index.json')
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((items: GalleryItem[]) => {
|
||||
gallery.value = items
|
||||
})
|
||||
.catch(() => {
|
||||
loading = undefined
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
/** 素材文件的网址(设计器预览、资源对话框里显示) */
|
||||
export function resourceUrl(name: string) {
|
||||
const item = gallery.value.find((g) => g.name === name)
|
||||
return `/resources/${item?.file ?? `${name}.png`}`
|
||||
}
|
||||
|
||||
/** 工程里已添加的资源名(从生成的 Resources.Designer.cs 里读出) */
|
||||
export function projectResources(files: SourceFile[]): string[] {
|
||||
const text = files.find((f) => f.path === RESOURCES_FILE)?.content ?? ''
|
||||
return [...text.matchAll(/internal static \S+ ([\p{L}\p{N}_]+) \{/gu)].map((m) => m[1])
|
||||
}
|
||||
|
||||
/** 设计器里引用资源的写法:global::命名空间.Properties.Resources.名字 */
|
||||
export const resourcePrefix = (namespace: string) => `global::${namespace}.Properties.Resources.`
|
||||
|
||||
/** 从属性值里认出资源名:global::X.Properties.Resources.switch_off → switch_off */
|
||||
export function resourceNameOf(text: string) {
|
||||
return /\.Properties\.Resources\.([\p{L}\p{N}_]+)$/u.exec(text.trim())?.[1]
|
||||
}
|
||||
|
||||
/** 生成 Properties/Resources.Designer.cs;没有资源时返回 null(删掉这个文件) */
|
||||
export function resourcesFile(namespace: string, names: string[]): SourceFile | null {
|
||||
const items = names.map((name) => gallery.value.find((g) => g.name === name)).filter((g): g is GalleryItem => g !== undefined)
|
||||
if (items.length === 0) return null
|
||||
const members = items.map((item) =>
|
||||
item.kind === 'image'
|
||||
? ` /// <summary>${item.label}</summary>\n internal static System.Drawing.Bitmap ${item.name} {\n get { return global::Sim.Host.ProjectResources.Image("${item.file}"); }\n }`
|
||||
: ` /// <summary>${item.label}</summary>\n internal static byte[] ${item.name} {\n get { return global::Sim.Host.ProjectResources.Bytes("${item.file}"); }\n }`,
|
||||
)
|
||||
const content = `//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// 项目资源(平台素材库)。在"项目资源"里添加或移除,不要手动修改这个文件。
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace ${namespace}.Properties {
|
||||
|
||||
/// <summary>
|
||||
/// 项目资源:Properties.Resources.资源名
|
||||
/// </summary>
|
||||
internal class Resources {
|
||||
|
||||
${members.join('\n\n')}
|
||||
}
|
||||
}
|
||||
`
|
||||
return { path: RESOURCES_FILE, content }
|
||||
}
|
||||
@@ -31,7 +31,7 @@ test('新窗体的 Designer.cs 能被设计器打开,添加控件后格式正
|
||||
/** 读取 samples/ 下的示例工程 */
|
||||
async function sample(id: string) {
|
||||
const dir = `${import.meta.dir}/../../../samples/${id}`
|
||||
const paths = await Array.fromAsync(new Bun.Glob('*.cs').scan({ cwd: dir }))
|
||||
const paths = await Array.fromAsync(new Bun.Glob('**/*.cs').scan({ cwd: dir }))
|
||||
return Promise.all(paths.sort().map(async (path) => ({ path, content: await Bun.file(`${dir}/${path}`).text() })))
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ test('作业起点"界面已做好":多个窗体都保留,其他类(Public
|
||||
expect(files.find((f) => f.path === 'AddUser.cs')!.content).toContain('public partial class AddUser : Form')
|
||||
})
|
||||
|
||||
test('作业起点"界面已做好":图片引用了项目资源时带上资源类,资源类不当作窗体', async () => {
|
||||
const files = assignmentTemplate(await sample('p7-t1-智慧教室'), 'ui')
|
||||
expect(files.map((f) => f.path).sort()).toEqual(['Program.cs', 'Properties/Resources.Designer.cs', '教室情景模式.Designer.cs', '教室情景模式.cs'])
|
||||
expect(files.find((f) => f.path === '教室情景模式.Designer.cs')!.content).toContain('global::教室情景模式.Properties.Resources.switch_off')
|
||||
})
|
||||
|
||||
test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
|
||||
const files = assignmentTemplate(await sample('p2-t2-模拟量采集'), 'blank')
|
||||
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
|
||||
|
||||
@@ -123,7 +123,10 @@ export function assignmentTemplate(files: SourceFile[], mode: StartMode): Source
|
||||
return [...(programText ? [{ path: 'Program.cs', content: programText }] : []), ...newFormFiles(namespace, 'Form1')]
|
||||
}
|
||||
const result: SourceFile[] = program ? [{ ...program }] : []
|
||||
for (const designer of files.filter((f) => f.path.endsWith('.Designer.cs'))) {
|
||||
// 界面里的图片引用了项目资源,资源类一起带上(空白工程由学生自己按书上的步骤添加)
|
||||
const resources = files.find((f) => f.path.startsWith('Properties/') && f.path.endsWith('.Designer.cs'))
|
||||
if (resources) result.push({ ...resources })
|
||||
for (const designer of files.filter((f) => isFormDesigner(f.path))) {
|
||||
const doc = parseDesigner(designer.content)
|
||||
for (const block of doc.blocks) block.stmts = block.stmts.filter((s) => s.kind !== 'event')
|
||||
const [code] = newFormFiles(namespace, doc.className)
|
||||
@@ -131,3 +134,6 @@ export function assignmentTemplate(files: SourceFile[], mode: StartMode): Source
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 窗体的 Designer.cs(Properties/ 下平台生成的 Resources.Designer.cs 不是窗体) */
|
||||
export const isFormDesigner = (path: string) => path.endsWith('.Designer.cs') && !path.startsWith('Properties/')
|
||||
@@ -1,5 +1,6 @@
|
||||
// 提交作业时给每个窗体截一张图:用设计器的预览树和运行时同一套 FormWindow 在屏幕外渲染,再转成 PNG。
|
||||
// 截图随提交保存,老师在评阅页直接看界面,AI 评阅也据此评"窗体界面"。截图失败不影响提交。
|
||||
import { isFormDesigner } from '../designer/templates'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import { domToPng } from 'modern-screenshot'
|
||||
import type { Screenshot, SourceFile } from '@iot/shared'
|
||||
@@ -12,7 +13,7 @@ const MAX_FORMS = 6
|
||||
const MAX_PNG_LENGTH = 700_000
|
||||
|
||||
export async function captureForms(files: SourceFile[]): Promise<Screenshot[]> {
|
||||
const designers = files.filter((f) => f.path.endsWith('.Designer.cs')).slice(0, MAX_FORMS)
|
||||
const designers = files.filter((f) => isFormDesigner(f.path)).slice(0, MAX_FORMS)
|
||||
if (designers.length === 0) return []
|
||||
const host = document.createElement('div')
|
||||
host.style.cssText = 'position: fixed; left: -10000px; top: 0; width: 2000px; height: 2000px; pointer-events: none;'
|
||||
|
||||
@@ -8,7 +8,9 @@ import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, N
|
||||
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
|
||||
import FormDesigner from '../designer/FormDesigner.vue'
|
||||
import { createDesignerHistory } from '../designer/history'
|
||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
|
||||
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
|
||||
import ResourcesDialog from '../designer/ResourcesDialog.vue'
|
||||
import { formNameTaken, isFormDesigner, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
|
||||
import CodeEditor from '../editor/CodeEditor.vue'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
@@ -34,7 +36,7 @@ const DESIGN = '设计:'
|
||||
const forms = computed(() => {
|
||||
const startup = startupForm(files.value)
|
||||
return files.value
|
||||
.filter((f) => f.path.endsWith('.Designer.cs'))
|
||||
.filter((f) => isFormDesigner(f.path))
|
||||
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
|
||||
// 启动窗体排在最前
|
||||
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
|
||||
@@ -43,6 +45,7 @@ const forms = computed(() => {
|
||||
* 标签页按窗体分组:每个窗体依次是 [设计]、代码;其他文件(Program.cs 等)放最后。
|
||||
* Designer.cs 由设计器生成,平时不显示(和 VS 里藏在窗体文件下面一样),
|
||||
* 在设计器里点"查看生成的代码"或点到其中的编译错误时才临时出现,切走后收起。
|
||||
* 项目资源生成的 Properties/Resources.Designer.cs 也一样,在"项目资源"里管理。
|
||||
*/
|
||||
const tabs = computed(() => {
|
||||
const grouped = new Set(forms.value.flatMap((f) => [f.code, f.designer]))
|
||||
@@ -52,7 +55,9 @@ const tabs = computed(() => {
|
||||
{ name: f.code, label: f.code },
|
||||
...(activeTab.value === f.designer ? [{ name: f.designer, label: f.designer }] : []),
|
||||
]),
|
||||
...files.value.filter((f) => !grouped.has(f.path)).map((f) => ({ name: f.path, label: f.path })),
|
||||
...files.value
|
||||
.filter((f) => !grouped.has(f.path) && (f.path !== RESOURCES_FILE || activeTab.value === RESOURCES_FILE))
|
||||
.map((f) => ({ name: f.path, label: f.path })),
|
||||
]
|
||||
})
|
||||
const activeForm = computed(() => forms.value.find((f) => f.tab === activeTab.value))
|
||||
@@ -270,6 +275,24 @@ function filesChanged() {
|
||||
if (workKey.value) scheduleSave()
|
||||
}
|
||||
|
||||
// ---------- 项目资源 ----------
|
||||
void loadGallery()
|
||||
const resourcesOpen = ref(false)
|
||||
const resourceNames = computed(() => projectResources(files.value))
|
||||
/** 设计器里图片属性的选项:项目资源里的图片(声音不能当图片用) */
|
||||
const designerResources = computed(() => ({
|
||||
names: resourceNames.value.filter((name) => gallery.value.find((g) => g.name === name)?.kind !== 'audio'),
|
||||
prefix: resourcePrefix(projectNamespace(files.value)),
|
||||
}))
|
||||
|
||||
/** 保存勾选的项目资源:重新生成 Properties/Resources.Designer.cs(一个都没有时删掉) */
|
||||
function saveResources(names: string[]) {
|
||||
const file = resourcesFile(projectNamespace(files.value), names)
|
||||
const rest = files.value.filter((f) => f.path !== RESOURCES_FILE)
|
||||
files.value = file ? [...rest, file] : rest
|
||||
filesChanged()
|
||||
}
|
||||
|
||||
// ---------- 新建、删除窗体 ----------
|
||||
const newFormOpen = ref(false)
|
||||
const newFormName = ref('')
|
||||
@@ -444,6 +467,9 @@ function run() {
|
||||
<NButton size="small" type="primary" :disabled="!!newFormError" @click="createForm">创建</NButton>
|
||||
</div>
|
||||
</NPopover>
|
||||
<NButton size="small" quaternary title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字" @click="resourcesOpen = true">
|
||||
项目资源{{ resourceNames.length ? `(${resourceNames.length})` : '' }}
|
||||
</NButton>
|
||||
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>删除窗体</NButton>
|
||||
@@ -463,7 +489,10 @@ function run() {
|
||||
@redo="designerHistory.redo(activeForm.designer, activeForm.code)"
|
||||
@open-code="openCode"
|
||||
@open-designer-file="activeTab = activeForm.designer"
|
||||
:resources="designerResources"
|
||||
@add-resource="resourcesOpen = true"
|
||||
/>
|
||||
<ResourcesDialog v-model:show="resourcesOpen" :selected="resourceNames" @save="saveResources" />
|
||||
<CodeEditor
|
||||
v-show="!activeForm"
|
||||
ref="editorRef"
|
||||
|
||||
@@ -37,7 +37,7 @@ const viewing = ref<{ path: string; url: string } | null>(null)
|
||||
/>
|
||||
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
||||
</div>
|
||||
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
|
||||
<TrainingBoard :board="state.board" :playing="state.playing" @set="session.setBoard" @reset="session.resetBoard" />
|
||||
<div v-if="state.savedImages.length" class="photos">
|
||||
<div class="photos-title">程序保存的图片({{ state.savedImages.length }})<span>只在这次运行中保留</span></div>
|
||||
<div class="photos-list">
|
||||
|
||||
+36
-1
@@ -42,6 +42,8 @@ export function createRunSession() {
|
||||
board: null as BoardState | null,
|
||||
/** 本次运行保存的图片,显示在运行区的照片列表里;下次运行时清空 */
|
||||
savedImages: [] as SavedImage[],
|
||||
/** 正在播放的声音(媒体播放器控件,实训台上的广播喇叭随之亮起) */
|
||||
playing: null as { name: string } | null,
|
||||
metrics: { compileMs: 0, startMs: 0, lastRoundTripMs: 0 },
|
||||
})
|
||||
/** id → 控件节点,patch 按 id 修改节点属性 */
|
||||
@@ -52,6 +54,34 @@ export function createRunSession() {
|
||||
/** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */
|
||||
let connecting: Promise<void> | undefined
|
||||
let actionSentAt = 0
|
||||
/** 每个媒体播放器控件一个 <audio> */
|
||||
const players = new Map<string, HTMLAudioElement>()
|
||||
|
||||
function stopAudio() {
|
||||
for (const audio of players.values()) audio.pause()
|
||||
players.clear()
|
||||
state.playing = null
|
||||
}
|
||||
|
||||
function media(message: Extract<ServerMessage, { type: 'media' }>) {
|
||||
players.get(message.id)?.pause()
|
||||
players.delete(message.id)
|
||||
if (message.action === 'stop' || !message.key) {
|
||||
if (players.size === 0) state.playing = null
|
||||
return
|
||||
}
|
||||
const url = images.get(message.key)
|
||||
if (!url) return
|
||||
const audio = new Audio(url)
|
||||
players.set(message.id, audio)
|
||||
state.playing = { name: message.name ?? '' }
|
||||
audio.onended = () => {
|
||||
players.delete(message.id)
|
||||
if (players.size === 0) state.playing = null
|
||||
}
|
||||
// 浏览器可能因为页面还没被点过而拒绝自动播放:喇叭照样亮一会儿
|
||||
audio.play().catch(() => setTimeout(() => audio.onended?.(new Event('ended')), 2000))
|
||||
}
|
||||
|
||||
function connect() {
|
||||
connecting ??= validAccessToken().then(
|
||||
@@ -87,6 +117,7 @@ export function createRunSession() {
|
||||
state.forms = []
|
||||
state.messageBoxes = []
|
||||
state.savedImages = []
|
||||
stopAudio()
|
||||
nodes.clear()
|
||||
for (const url of images.values()) URL.revokeObjectURL(url)
|
||||
images.clear()
|
||||
@@ -186,11 +217,14 @@ export function createRunSession() {
|
||||
case 'messageBox':
|
||||
state.messageBoxes.push(message)
|
||||
break
|
||||
case 'image': {
|
||||
case 'blob': {
|
||||
const bytes = Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0))
|
||||
images.set(message.key, URL.createObjectURL(new Blob([bytes], { type: message.mime })))
|
||||
break
|
||||
}
|
||||
case 'media':
|
||||
media(message)
|
||||
break
|
||||
case 'imageSaved':
|
||||
state.savedImages.push({ path: message.path, image: message.image })
|
||||
break
|
||||
@@ -207,6 +241,7 @@ export function createRunSession() {
|
||||
state.errors.push(message.message)
|
||||
break
|
||||
case 'exited':
|
||||
stopAudio()
|
||||
state.forms = []
|
||||
state.messageBoxes = []
|
||||
break
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 虚拟实训台:按教材实训台布局图(顶棚 → LED 屏 → 传感器 → 采集模块)排列。
|
||||
// 传感器卡片上的滑块和开关调节环境(经服务器推给学生程序),灯和继电器的状态由学生程序控制。
|
||||
import { computed, ref } from 'vue'
|
||||
import { ANALOG_INFO, type AnalogKey, type BoardState, type ClientMessage, type DigitalKey } from '@iot/shared'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { ANALOG_INFO, clockWall, type AnalogKey, type BoardState, type ClientMessage, type DigitalKey } from '@iot/shared'
|
||||
|
||||
type BoardInput = Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>
|
||||
|
||||
const props = defineProps<{ board: BoardState | null }>()
|
||||
/** playing:学生程序正在播放的声音(媒体播放器),广播喇叭亮起 */
|
||||
const props = defineProps<{ board: BoardState | null; playing?: { name: string } | null }>()
|
||||
const emit = defineEmits<{ set: [input: BoardInput]; reset: [] }>()
|
||||
|
||||
const MORE_SENSORS: AnalogKey[] = ['airPressure', 'airQuality', 'co2', 'wind']
|
||||
@@ -78,6 +79,38 @@ const streetLampOn = computed(() => props.board?.zigbee[1] === true)
|
||||
const alarmOn = computed(() => relays.value[0] === true)
|
||||
const ledText = computed(() => props.board?.ledText ?? '')
|
||||
const camera = computed(() => props.board?.camera ?? { on: false, x: 3, y: 1 })
|
||||
// ---------- 虚拟时钟:显示学生程序看到的时间,可以拨时间、调倍速 ----------
|
||||
const SPEEDS = [1, 10, 60]
|
||||
const tick = ref(Date.now())
|
||||
const ticker = setInterval(() => (tick.value = Date.now()), 250)
|
||||
onBeforeUnmount(() => clearInterval(ticker))
|
||||
|
||||
const clock = computed(() => props.board?.clock ?? null)
|
||||
/** 现在的墙上时间 yyyy-MM-ddTHH:mm:ss:拨过时间时按虚拟时钟算,否则是本机时间 */
|
||||
const wall = computed(() => {
|
||||
if (clock.value) return clockWall(clock.value, tick.value)
|
||||
const d = new Date(tick.value)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
})
|
||||
const clockDate = ref('')
|
||||
const clockTime = ref('')
|
||||
|
||||
function openClockEditor() {
|
||||
clockDate.value = wall.value.slice(0, 10)
|
||||
clockTime.value = wall.value.slice(11, 19)
|
||||
}
|
||||
|
||||
function setClock() {
|
||||
if (!clockDate.value || !clockTime.value) return
|
||||
const time = clockTime.value.length === 5 ? `${clockTime.value}:00` : clockTime.value
|
||||
emit('set', { clock: { time: `${clockDate.value}T${time}` } })
|
||||
}
|
||||
|
||||
function setSpeed(speed: number) {
|
||||
emit('set', { clock: { speed } })
|
||||
}
|
||||
|
||||
const cameraTitle = computed(() =>
|
||||
camera.value.on ? `网络摄像头:正在传送画面(云台左右 ${camera.value.x + 1}/7,上下 ${camera.value.y + 1}/3)` : '网络摄像头(项目 4 使用):未开启',
|
||||
)
|
||||
@@ -106,6 +139,7 @@ const cameraTitle = computed(() =>
|
||||
<i />{{ input.label }}
|
||||
</button>
|
||||
<span v-if="ledText" class="summary-led" :title="ledText">LED:{{ ledText }}</span>
|
||||
<span v-if="clock" class="summary-clock" title="虚拟时钟:学生程序里的 DateTime.Now">🕒 {{ wall.slice(11) }}({{ clock.speed }}×)</span>
|
||||
<button class="link expand" @click="setCollapsed(false)">展开 ▾</button>
|
||||
</div>
|
||||
|
||||
@@ -190,6 +224,15 @@ const cameraTitle = computed(() =>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
|
||||
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
|
||||
<path v-if="playing" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="card-title">广播</div>
|
||||
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
|
||||
</div>
|
||||
|
||||
<button class="card toggle" :class="{ active: digital.smoke }" title="点击切换:有烟雾 / 正常" @click="toggle('smoke')">
|
||||
<div class="smoke" :class="{ on: digital.smoke }"><i /><i /><i /></div>
|
||||
<div class="card-title">烟雾传感器</div>
|
||||
@@ -254,6 +297,31 @@ const cameraTitle = computed(() =>
|
||||
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟时钟:学生程序里的 DateTime.Now 跟着它走 -->
|
||||
<div class="clock-strip" :class="{ virtual: clock }">
|
||||
<span class="module-title">时钟</span>
|
||||
<b class="clock-time">{{ wall.slice(11) }}</b>
|
||||
<span class="clock-date">{{ wall.slice(0, 10) }}</span>
|
||||
<span class="clock-tag">{{ clock ? `虚拟时间 · ${clock.speed}×` : '真实时间' }}</span>
|
||||
<span class="spacer" />
|
||||
<input v-model="clockDate" type="date" :disabled="!board" @focus="!clockDate && openClockEditor()" />
|
||||
<input v-model="clockTime" type="time" step="1" :disabled="!board" @focus="!clockTime && openClockEditor()" />
|
||||
<button class="chip-button" :disabled="!board || !clockDate || !clockTime" title="把学生程序看到的时间拨到这个时刻" @click="setClock">拨到</button>
|
||||
<span class="speeds" title="倍速:60× 时真实的 1 秒是 1 分钟">
|
||||
<button
|
||||
v-for="speed in SPEEDS"
|
||||
:key="speed"
|
||||
class="chip-button"
|
||||
:class="{ active: (clock?.speed ?? 1) === speed && (clock !== null || speed === 1) }"
|
||||
:disabled="!board"
|
||||
@click="setSpeed(speed)"
|
||||
>
|
||||
{{ speed }}×
|
||||
</button>
|
||||
</span>
|
||||
<button v-if="clock" class="chip-button" :disabled="!board" @click="emit('set', { clock: null })">恢复真实时间</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -580,6 +648,91 @@ button {
|
||||
}
|
||||
|
||||
/* 模块 */
|
||||
.clock-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
padding: 6px 8px;
|
||||
background: #3a4556;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.clock-strip.virtual {
|
||||
box-shadow: inset 0 0 0 1px #f5a623;
|
||||
}
|
||||
|
||||
.clock-time {
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.clock-date {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clock-tag {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.virtual .clock-tag {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.clock-strip .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.clock-strip input {
|
||||
background: #2b3340;
|
||||
color: var(--text);
|
||||
border: 1px solid #556175;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
font: inherit;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.chip-button {
|
||||
background: #2b3340;
|
||||
color: var(--text);
|
||||
border: 1px solid #556175;
|
||||
border-radius: 3px;
|
||||
padding: 1px 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip-button.active {
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
background: #2f5f99;
|
||||
}
|
||||
|
||||
.chip-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.speeds {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.speaker {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.speaker.on {
|
||||
color: #4cd964;
|
||||
animation: blink 0.8s steps(2) infinite;
|
||||
}
|
||||
|
||||
.summary-clock {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.modules {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.5fr 1fr;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RUN_IMAGES, type ImageValue } from './images'
|
||||
import DataGridViewControl from './DataGridViewControl.vue'
|
||||
import DateTimePickerControl from './DateTimePickerControl.vue'
|
||||
import LedControl from './LedControl.vue'
|
||||
import TrackBarControl from './TrackBarControl.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
node: ControlNode
|
||||
@@ -55,9 +56,11 @@ function selectItem(index: number) {
|
||||
// PictureBox:按 SizeMode 摆放图片
|
||||
const runImages = inject(RUN_IMAGES, new Map<string, string>())
|
||||
const image = computed(() => p.value.Image as ImageValue | null | undefined)
|
||||
const imageUrl = computed(() => (image.value ? runImages.get(image.value.key) : undefined))
|
||||
const imageUrl = computed(() => (image.value ? image.value.url ?? runImages.get(image.value.key) : undefined))
|
||||
// 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小
|
||||
const naturalSize = computed(() => (image.value?.width ? { width: `${image.value.width}px`, height: `${image.value.height}px` } : {}))
|
||||
const imageStyle = computed((): CSSProperties => {
|
||||
const natural = { width: `${image.value?.width ?? 0}px`, height: `${image.value?.height ?? 0}px` }
|
||||
const natural = naturalSize.value
|
||||
switch (p.value.SizeMode) {
|
||||
case 'StretchImage':
|
||||
return { width: '100%', height: '100%', objectFit: 'fill' }
|
||||
@@ -168,6 +171,14 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
|
||||
|
||||
<DateTimePickerControl v-else-if="node.type === 'DateTimePicker'" :node="node" :style="style" :enabled="enabled" :act="act" />
|
||||
|
||||
<TrackBarControl v-else-if="node.type === 'TrackBar'" :node="node" :style="style" :enabled="enabled" :act="act" />
|
||||
|
||||
<!-- Windows Media Player(教材里通常设为不可见,只用来播放声音) -->
|
||||
<div v-else-if="node.type === 'AxWindowsMediaPlayer'" class="wf-control wf-wmp" :style="style">
|
||||
<div class="wf-wmp-screen">Windows Media Player</div>
|
||||
<div class="wf-wmp-bar"><span>▶</span><span>■</span><span class="wf-wmp-progress" /></div>
|
||||
</div>
|
||||
|
||||
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
|
||||
<div
|
||||
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
// 滑动条(仿 Windows 10 的 TrackBar):拖动滑块、点轨道(按 LargeChange 靠近)、方向键(按 SmallChange)改变值,
|
||||
// 新值发给运行器,由运行器决定最终的 Value(并触发 ValueChanged、Scroll)。竖向时最小值在下面,和 WinForms 一样。
|
||||
import { computed, ref, type CSSProperties } from 'vue'
|
||||
import type { ControlNode, UserAction } from '@iot/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
node: ControlNode
|
||||
style: CSSProperties
|
||||
enabled: boolean
|
||||
act: (action: UserAction) => void
|
||||
}>()
|
||||
|
||||
/** 轨道两端留出的距离(滑块半宽 + 边距),和 WinForms 差不多 */
|
||||
const PAD = 13
|
||||
|
||||
const p = computed(() => props.node.props)
|
||||
const min = computed(() => Number(p.value.Minimum ?? 0))
|
||||
const max = computed(() => Number(p.value.Maximum ?? 10))
|
||||
const value = computed(() => Number(p.value.Value ?? 0))
|
||||
const vertical = computed(() => p.value.Orientation === 'Vertical')
|
||||
const tickStyle = computed(() => String(p.value.TickStyle ?? 'BottomRight'))
|
||||
const size = computed(() => p.value.Size as { width: number; height: number } | undefined)
|
||||
/** 轨道方向上的长度 */
|
||||
const length = computed(() => (vertical.value ? size.value?.height ?? 104 : size.value?.width ?? 104))
|
||||
|
||||
/** 值 → 沿轨道的位置(像素);竖向时最小值在下面 */
|
||||
function position(v: number) {
|
||||
const ratio = max.value === min.value ? 0 : (v - min.value) / (max.value - min.value)
|
||||
const along = PAD + ratio * (length.value - 2 * PAD)
|
||||
return vertical.value ? length.value - along : along
|
||||
}
|
||||
|
||||
const ticks = computed(() => {
|
||||
const frequency = Math.max(1, Number(p.value.TickFrequency ?? 1))
|
||||
const list: number[] = []
|
||||
for (let v = min.value; v <= max.value && list.length < 200; v += frequency) list.push(v)
|
||||
if (list.at(-1) !== max.value) list.push(max.value)
|
||||
return list
|
||||
})
|
||||
|
||||
const root = ref<HTMLDivElement>()
|
||||
let dragging = false
|
||||
|
||||
function valueAt(event: PointerEvent) {
|
||||
const rect = root.value!.getBoundingClientRect()
|
||||
const along = vertical.value ? rect.bottom - event.clientY : event.clientX - rect.left
|
||||
const ratio = Math.min(1, Math.max(0, (along - PAD) / (length.value - 2 * PAD)))
|
||||
return Math.round(min.value + ratio * (max.value - min.value))
|
||||
}
|
||||
|
||||
function send(next: number) {
|
||||
const clamped = Math.min(max.value, Math.max(min.value, next))
|
||||
if (clamped !== value.value) props.act({ action: 'scroll', value: String(clamped) })
|
||||
}
|
||||
|
||||
function down(event: PointerEvent) {
|
||||
if (!props.enabled) return
|
||||
root.value?.focus()
|
||||
const target = event.target as HTMLElement
|
||||
if (target.classList.contains('wf-trackbar-thumb')) {
|
||||
dragging = true
|
||||
root.value!.setPointerCapture(event.pointerId)
|
||||
return
|
||||
}
|
||||
// 点轨道:向点击的位置移动一个 LargeChange
|
||||
const large = Number(p.value.LargeChange ?? 5)
|
||||
send(value.value + (valueAt(event) > value.value ? large : -large))
|
||||
}
|
||||
|
||||
function move(event: PointerEvent) {
|
||||
if (dragging) send(valueAt(event))
|
||||
}
|
||||
|
||||
function up() {
|
||||
dragging = false
|
||||
}
|
||||
|
||||
function key(event: KeyboardEvent) {
|
||||
if (!props.enabled) return
|
||||
const small = Number(p.value.SmallChange ?? 1)
|
||||
const large = Number(p.value.LargeChange ?? 5)
|
||||
const delta: Record<string, number> = { ArrowRight: small, ArrowUp: small, ArrowLeft: -small, ArrowDown: -small, PageUp: large, PageDown: -large }
|
||||
if (event.key in delta) send(value.value + delta[event.key])
|
||||
else if (event.key === 'Home') send(min.value)
|
||||
else if (event.key === 'End') send(max.value)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const thumbStyle = computed((): CSSProperties => (vertical.value ? { top: `${position(value.value) - 5}px` } : { left: `${position(value.value) - 5}px` }))
|
||||
/** 刻度:滑块下方(BottomRight)或上方(TopLeft),两端的刻度长一点 */
|
||||
const tickStyleOf = (v: number, side: 'a' | 'b'): CSSProperties => {
|
||||
const extent = v === min.value || v === max.value ? '5px' : '3px'
|
||||
const offset = side === 'a' ? '0px' : '28px'
|
||||
return vertical.value ? { top: `${position(v)}px`, left: offset, width: extent } : { left: `${position(v)}px`, top: offset, height: extent }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="wf-control wf-trackbar"
|
||||
:class="{ 'wf-trackbar-vertical': vertical, 'wf-disabled': !enabled }"
|
||||
:style="style"
|
||||
tabindex="0"
|
||||
@pointerdown="down"
|
||||
@pointermove="move"
|
||||
@pointerup="up"
|
||||
@keydown="key"
|
||||
>
|
||||
<div class="wf-trackbar-track" :style="vertical ? { top: `${PAD}px`, bottom: `${PAD}px` } : { left: `${PAD}px`, right: `${PAD}px` }" />
|
||||
<template v-if="tickStyle === 'TopLeft' || tickStyle === 'Both'">
|
||||
<span v-for="v in ticks" :key="`a${v}`" class="wf-trackbar-tick" :style="tickStyleOf(v, 'a')" />
|
||||
</template>
|
||||
<template v-if="tickStyle === 'BottomRight' || tickStyle === 'Both'">
|
||||
<span v-for="v in ticks" :key="`b${v}`" class="wf-trackbar-tick" :style="tickStyleOf(v, 'b')" />
|
||||
</template>
|
||||
<span class="wf-trackbar-thumb" :style="thumbStyle" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,4 +9,6 @@ export interface ImageValue {
|
||||
key: string
|
||||
width: number
|
||||
height: number
|
||||
/** 设计器预览里的项目资源:直接给出素材网址(运行时用 key) */
|
||||
url?: string
|
||||
}
|
||||
@@ -301,6 +301,96 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* TrackBar:灰色轨道、刻度、蓝色滑块(Windows 10 样式) */
|
||||
.wf-trackbar {
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wf-trackbar-track {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
height: 4px;
|
||||
background: #e7e7e7;
|
||||
border: 1px solid #d6d6d6;
|
||||
}
|
||||
|
||||
.wf-trackbar-vertical .wf-trackbar-track {
|
||||
top: auto;
|
||||
left: 12px;
|
||||
width: 4px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.wf-trackbar-tick {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
background: #c4c4c4;
|
||||
}
|
||||
|
||||
.wf-trackbar-vertical .wf-trackbar-tick {
|
||||
width: auto;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.wf-trackbar-thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 11px;
|
||||
height: 20px;
|
||||
background: #007ad9;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.wf-trackbar-vertical .wf-trackbar-thumb {
|
||||
top: auto;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.wf-trackbar:focus-visible .wf-trackbar-thumb {
|
||||
outline: 1px dotted #000;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.wf-disabled .wf-trackbar-thumb {
|
||||
background: #cccccc;
|
||||
}
|
||||
|
||||
.wf-wmp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
color: #9aa4b2;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wf-wmp-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wf-wmp-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: linear-gradient(#3a3f47, #1c1f24);
|
||||
}
|
||||
|
||||
.wf-wmp-progress {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.wf-listbox {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/resources': 'http://localhost:3000',
|
||||
'/ws': { target: 'ws://localhost:3000', ws: true },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user