fix: 大屏背景图被兜底渐变盖住 + 配置/规则也搬到用户数据目录

1) 大屏背景图不生效:App.vue 里 .bg 与 .bg-fallback 都是 absolute inset:0,
   而兜底渐变是不透明的且在图片之后绘制 → 配了背景图也只看得到渐变。
   改成 v-if/v-else 互斥(用无头 Chromium 真渲染对照验证:修复后取到图片像素,
   回退版本只剩渐变)。新增 background-layer.test.ts 锁住层次关系。

2) 规则/config 仍在安装目录,覆盖安装/卸载会连配置一起删:现在三样都落在
   %LOCALAPPDATA%\ScoreboardConsole\(与数据目录同一基座):
   - console\config.json(Console 读写都用这份)
   - config.yaml(Console 用绝对路径 -config 传给服务端与 passwd)
   - rules\(首次从安装目录复制过去,之后只认用户目录那份)
   首次运行复制迁移、已存在不覆盖、不可写则回落安装目录并记日志(不影响启动);
   开发态行为不变。备份脚本与文档同步。
   注意 resolveExecutable 必须显式传部署包根目录,否则会去用户目录找 scoreboard.exe。

自检:服务端 11 包全绿;console 语法 16 文件 + node:test 20/20;
前端 vue-tsc + build、node --test 69/69。
This commit is contained in:
AskaEth committed 2026-09-10 10:06:21 +08:00
1 parent 2d207fd654
commit 407abec3b0
15 files changed
+759 -81

No files matched your search

+2 -1
View File
@@ -99,8 +99,9 @@ onUnmounted(() => {
<div class="screen">
<div class="stage" :style="[stageStyle, themeVars]">
<!-- 兜底渐变与背景图互斥:两者都是 absolute inset:0 同层,同时存在时后画的渐变会整块盖住图片 -->
<img v-if="background" class="bg" :src="background" alt="" />
<div class="bg-fallback" />
<div v-else class="bg-fallback" />
<div class="content">
<TopBar />
@@ -0,0 +1,179 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { compileTemplate, parse } from 'vue/compiler-sfc'
// 大屏层次:背景图 / 兜底渐变在最底,.content 压在其上,DetailOverlay 再压住 .content。
// 项目没有 jsdom 与组件测试工具,这里直接编译 App.vue 的模板取 AST,
// 用渲染条件(v-if/v-else 分支)而不是文本匹配来断言,改坏层次会直接失败。
const APP_SOURCE = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const OVERLAY_SOURCE = readFileSync(new URL('../components/DetailOverlay.vue', import.meta.url), 'utf8')
// @vue/compiler-core 的 NodeTypes 没有从 compiler-sfc 再导出,按枚举值取用。
const NODE_ELEMENT = 1
const NODE_IF = 9
interface AstAttr {
type: number
name?: string
value?: { content?: string }
}
interface AstNode {
type: number
tag?: string
props?: AstAttr[]
children?: AstNode[]
branches?: { children: AstNode[] }[]
}
interface FoundElement {
tag: string
classes: string[]
/** 是否位于 v-if/v-else 分支内:false 表示无条件渲染。 */
conditional: boolean
}
function classTokens(node: AstNode): string[] {
const attr = node.props?.find((prop) => prop.type === 6 && prop.name === 'class')
return attr?.value?.content?.split(/\s+/).filter(Boolean) ?? []
}
/** 收集整棵子树里的元素;v-if 分支内的元素标记为 conditional。 */
function collectElements(node: AstNode, conditional: boolean, out: FoundElement[]): void {
if (node.type === NODE_IF) {
for (const branch of node.branches ?? []) {
for (const child of branch.children) collectElements(child, true, out)
}
return
}
if (node.type === NODE_ELEMENT) {
out.push({ tag: node.tag ?? '', classes: classTokens(node), conditional })
}
for (const child of node.children ?? []) collectElements(child, conditional, out)
}
function collect(node: AstNode): FoundElement[] {
const out: FoundElement[] = []
collectElements(node, false, out)
return out
}
/** 只展开一层 v-if 分支,得到同一父级下的元素(用于核对兄弟顺序)。 */
function topLevelElements(node: AstNode): FoundElement[] {
const out: FoundElement[] = []
const push = (child: AstNode): void => {
if (child.type === NODE_IF) {
for (const branch of child.branches ?? []) for (const inner of branch.children) push(inner)
return
}
if (child.type === NODE_ELEMENT) out.push({ tag: child.tag ?? '', classes: classTokens(child), conditional: false })
}
for (const child of node.children ?? []) push(child)
return out
}
/** 条件渲染节点藏在普通元素里,也可能嵌在别的分支里,两种都要走到。 */
function findIfNode(node: AstNode, match: (candidate: AstNode) => boolean): AstNode | null {
if (node.type === NODE_IF && match(node)) return node
for (const child of node.children ?? []) {
const hit = findIfNode(child, match)
if (hit !== null) return hit
}
for (const branch of node.branches ?? []) {
for (const child of branch.children) {
const hit = findIfNode(child, match)
if (hit !== null) return hit
}
}
return null
}
/** AST 的根是 RootNode,模板里的最外层元素是它的第一个子节点。 */
function rootElement(ast: AstNode): AstNode {
const root = ast.children?.[0]
if (root == null) throw new Error('模板缺少根元素')
return root
}
function templateAst(source: string): AstNode {
const { descriptor, errors } = parse(source, { filename: 'App.vue' })
assert.equal(errors.length, 0, 'App.vue 应能被解析')
const template = descriptor.template
if (template == null) throw new Error('App.vue 缺少 <template>')
const built = compileTemplate({
source: template.content,
filename: 'App.vue',
id: 'display-app',
})
assert.deepEqual(built.errors, [], 'App.vue 模板应能编译')
const ast = built.ast
if (ast === undefined) throw new Error('模板未产出 AST')
return ast as unknown as AstNode
}
function styleText(source: string): string {
const { descriptor } = parse(source, { filename: 'style.vue' })
const block = descriptor.styles[0]
if (block == null) throw new Error('缺少 <style>')
return block.content
}
function cssRule(style: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(style)
assert.ok(match !== null, `样式里应存在 ${selector} 规则`)
return match[1] ?? ''
}
function cssZIndex(rule: string): number | null {
const match = /z-index:\s*(-?\d+)/.exec(rule)
return match === null ? null : Number(match[1])
}
const APP_AST = templateAst(APP_SOURCE)
test('兜底渐变与背景图互斥,不会盖住图片', () => {
const elements = collect(APP_AST)
const bg = elements.find((el) => el.classes.includes('bg'))
const fallback = elements.find((el) => el.classes.includes('bg-fallback'))
assert.ok(bg !== undefined, '模板里应有 .bg 背景图')
assert.equal(bg.conditional, true, '.bg 应只在配置了背景图时渲染')
assert.ok(fallback !== undefined, '模板里应保留 .bg-fallback')
assert.equal(fallback.conditional, true, '.bg-fallback 必须挂在 else 分支上')
// 同一个 v-if/v-else:有图走 img 分支,没图才走渐变分支。
const ifNode = findIfNode(APP_AST, (candidate) =>
(candidate.branches ?? []).some((branch) => classTokens(branch.children[0] ?? { type: 0 }).includes('bg')),
)
assert.ok(ifNode !== null, '背景图与兜底渐变应同属一个条件渲染节点')
assert.equal(ifNode.branches?.length, 2, '应是一对 v-if / v-else 分支')
const [yes, no] = ifNode.branches ?? []
assert.deepEqual(classTokens(yes?.children[0] ?? { type: 0 }), ['bg'])
assert.deepEqual(classTokens(no?.children[0] ?? { type: 0 }), ['bg-fallback'])
})
test('背景层压在内容与弹层之下', () => {
const appStyle = styleText(APP_SOURCE)
const contentZ = cssZIndex(cssRule(appStyle, '.content'))
assert.ok(contentZ !== null && contentZ >= 1, '.content 应显式抬到背景层之上')
for (const selector of ['.bg', '.bg-fallback']) {
const z = cssZIndex(cssRule(appStyle, selector))
assert.ok(z === null || z < contentZ, `${selector} 不得声明高于 .content 的 z-index`)
}
const overlayZ = cssZIndex(cssRule(styleText(OVERLAY_SOURCE), '.overlay'))
assert.ok(overlayZ !== null && overlayZ > contentZ, 'DetailOverlay 应压在 .content 之上')
})
test('自绘标题栏在舞台之外,缩放后的舞台盖不住它', () => {
const top = topLevelElements(rootElement(APP_AST))
assert.deepEqual(
top.map((el) => el.tag),
['WindowedTitleBar', 'div'],
'WindowedTitleBar 应是 .root 的首个子级,且与 .screen 平级',
)
assert.deepEqual(top[1]?.classes, ['screen'])
assert.match(cssRule(styleText(APP_SOURCE), '.screen'), /overflow:\s*hidden/, '.screen 应裁剪溢出的舞台')
})