- 根因:postbuild-css 解包 :where() 时直接去壳,Tailwind 暗色变体 `.x:where(.dark, .dark *)` 被拆成 `.x.dark, .dark *`,`.dark *` 成为全局规则,暗色下所有元素被刷上同一背景/边框(开发服务器不经后处理,故本地截图正常)。 改为先展开逗号列表(`.dark .x`),并在产物中出现全局 `.dark *` 规则时直接构建失败 - 外观切换改为整宽三段分段控件 + 滑动高亮,窄屏不再挤压换行 - 我的页对比度:续费 VIP / 兑换按钮改固定深棕字(≥4.6:1),奖励数字改 amber-600
This commit is contained in:
@@ -27,7 +27,106 @@ const TARGETS = {
|
||||
android: 78 << 16,
|
||||
}
|
||||
|
||||
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
|
||||
/** 按顶层逗号拆分(忽略括号/方括号内的逗号) */
|
||||
function splitTopLevel(s) {
|
||||
const parts = []
|
||||
let depth = 0
|
||||
let start = 0
|
||||
for (let k = 0; k < s.length; k++) {
|
||||
const ch = s[k]
|
||||
if (ch === '(' || ch === '[') depth++
|
||||
else if (ch === ')' || ch === ']') depth--
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(s.slice(start, k))
|
||||
start = k + 1
|
||||
}
|
||||
}
|
||||
parts.push(s.slice(start))
|
||||
return parts.map((p) => p.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 :where 的单个备选项 alt 合并回原选择器(before + :where(alt) + after)。
|
||||
* - alt 是单个复合选择器(如 `.dark`、`[type=button]`):直接拼进同一复合选择器;
|
||||
* - alt 形如 `A *`(祖先 A 的任意后代,Tailwind dark 变体即 `.dark *`):
|
||||
* 语义为「元素满足 before/after,且有祖先满足 A」→ `A ` + before + after。
|
||||
* 若 before 自身含组合符(祖先链),A 放在最前面;dark 变体的 `.dark` 在 <html> 上,
|
||||
* 是所有元素的祖先,结果与原语义一致。
|
||||
* - 其他带组合符的形式无法在不支持 :is() 的老内核上等价表达 → 直接报错,绝不静默产出错误 CSS。
|
||||
*/
|
||||
function expandAlternative(before, alt, after, whole) {
|
||||
const parts = alt.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1 && !/[>+~]/.test(alt)) return before + alt + after
|
||||
if (parts.length === 2 && parts[1] === '*' && !/[>+~]/.test(parts[0])) {
|
||||
// `before` 为空(规则本身就是 `:where(A *)`)时退化为 `A *`
|
||||
const rest = before + after
|
||||
return parts[0] + ' ' + (rest.trim() ? rest : '*')
|
||||
}
|
||||
throw new Error(`[postbuild-css] 无法安全展开的 :where 备选项「${alt}」(选择器:${whole})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开选择器里「带逗号列表」的 :where(A, B):
|
||||
* X:where(A, B)Y → XAY, XBY(含组合符的备选项见 expandAlternative)
|
||||
* 直接去壳会得到 `XA, BY`:逗号把选择器拆开,后半段 B 变成一条独立的全局规则。
|
||||
* 典型事故:dark 变体 `.dark\:bg-x:where(.dark, .dark *)` 去壳成
|
||||
* `.dark\:bg-x.dark, .dark *` —— `.dark *` 命中暗色下所有元素,全站被刷成同一背景色。
|
||||
* 只处理规则选择器(`{` 之前的部分),声明块内容原样保留。
|
||||
*/
|
||||
function expandWhereLists(css) {
|
||||
return css.replace(/([^{}]+)\{/g, (whole, selectorList) => {
|
||||
if (!selectorList.includes(':where(')) return whole
|
||||
// 跳过 at-rule 前缀(如 @media (...) / @supports (...)),这些不是选择器
|
||||
if (selectorList.trim().startsWith('@')) return whole
|
||||
const expanded = []
|
||||
for (const sel of splitTopLevel(selectorList)) {
|
||||
let queue = [sel]
|
||||
for (let guard = 0; guard < 8; guard++) {
|
||||
let changed = false
|
||||
const next = []
|
||||
for (const s of queue) {
|
||||
const m = s.indexOf(':where(')
|
||||
if (m < 0) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let k = m + 6; k < s.length; k++) {
|
||||
if (s[k] === '(') depth++
|
||||
else if (s[k] === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
end = k
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end < 0) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
const inner = splitTopLevel(s.slice(m + 7, end))
|
||||
if (inner.length < 2) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
const before = s.slice(0, m)
|
||||
const after = s.slice(end + 1)
|
||||
for (const alt of inner) next.push(expandAlternative(before, alt, after, s))
|
||||
}
|
||||
queue = next
|
||||
if (!changed) break
|
||||
}
|
||||
expanded.push(...queue)
|
||||
}
|
||||
return expanded.join(',') + '{'
|
||||
})
|
||||
}
|
||||
|
||||
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则)。
|
||||
* 须先经 expandWhereLists 展开逗号列表,此处只剩单项 :where(A),去壳即等价 */
|
||||
function unwrapWhere(css) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
@@ -122,8 +221,9 @@ for (const file of files) {
|
||||
const path = join(assetsDir, file)
|
||||
const src = await readFile(path)
|
||||
|
||||
// ① 解包 :where()(lightningcss 不会处理它,需先做)
|
||||
let prepared = Buffer.from(unwrapWhere(src.toString('utf8')))
|
||||
// ① 解包 :where()(lightningcss 不会处理它,需先做):
|
||||
// 先把 :where(A, B) 展开成多条选择器,再对剩余单项 :where(A) 去壳
|
||||
let prepared = Buffer.from(unwrapWhere(expandWhereLists(src.toString('utf8'))))
|
||||
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
|
||||
|
||||
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
|
||||
@@ -142,6 +242,10 @@ for (const file of files) {
|
||||
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
|
||||
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
|
||||
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
|
||||
// 暗色变体去壳错误的特征:出现一条以 `.dark *` 开头的独立全局规则(会把暗色下所有元素刷成同一样式)
|
||||
if (/(^|[{},])\s*\.dark \*\s*\{/.test(out)) {
|
||||
throw new Error(`[postbuild-css] ${file} 出现全局 \`.dark *\` 规则,:where 逗号列表展开失败`)
|
||||
}
|
||||
assertBalanced(out, file)
|
||||
|
||||
await writeFile(path, out)
|
||||
|
||||
@@ -17,6 +17,10 @@ const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'light', label: '浅色', icon: Sun },
|
||||
{ value: 'dark', label: '深色', icon: Moon },
|
||||
]
|
||||
const themeIndex = computed(() => Math.max(0, themeOptions.findIndex((o) => o.value === themeMode.value)))
|
||||
const themeHint = computed(() =>
|
||||
themeMode.value === 'auto' ? '跟随时间:18:00–06:00 为深色' : themeMode.value === 'dark' ? '始终使用深色' : '始终使用浅色',
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
@@ -391,7 +395,7 @@ async function handleRedeemInvite() {
|
||||
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
|
||||
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full shadow"
|
||||
@click="openRedeemModal('vip')"
|
||||
>
|
||||
续费 VIP
|
||||
@@ -414,7 +418,7 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
|
||||
class="press redeem-btn flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-semibold shadow-sm hover:brightness-105"
|
||||
@click="openRedeemModal('count')"
|
||||
>
|
||||
<Gift class="w-3 h-3" />
|
||||
@@ -454,7 +458,7 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1 leading-relaxed">
|
||||
邀请好友即可获得
|
||||
<span class="text-amber-500 font-medium">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
|
||||
<span class="text-amber-600 font-semibold">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
|
||||
积分
|
||||
</p>
|
||||
</div>
|
||||
@@ -468,7 +472,7 @@ async function handleRedeemInvite() {
|
||||
v-if="inviteCode"
|
||||
data-guide="profile-invite-redeem"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
|
||||
:class="canRedeem ? 'redeem-btn hover:brightness-105 shadow-sm' : 'bg-gray-100 text-gray-400 cursor-not-allowed'"
|
||||
:disabled="!canRedeem || redeemingInvite"
|
||||
@click="handleRedeemInvite"
|
||||
>
|
||||
@@ -544,28 +548,33 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 外观(主题三档切换) -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 py-3.5 flex items-center justify-between gap-3">
|
||||
<!-- 外观(主题三档切换):标题一行 + 整宽分段控件一行,窄屏也不挤压换行 -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 pt-3.5 pb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center">
|
||||
<div class="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center flex-shrink-0">
|
||||
<SunMoon class="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">外观</p>
|
||||
<p class="text-xs text-gray-400">自动:18:00–06:00 深色</p>
|
||||
<p class="text-xs text-gray-400">{{ themeHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex p-1 bg-gray-100 rounded-full" role="radiogroup" aria-label="外观">
|
||||
<!-- 分段控件:滑动高亮块指示当前项 -->
|
||||
<div class="relative mt-3 grid grid-cols-3 p-1 rounded-xl bg-gray-100" role="radiogroup" aria-label="外观">
|
||||
<div
|
||||
class="absolute top-1 bottom-1 left-1 rounded-lg bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[var(--ease-spring)] dark:bg-gray-300 dark:ring-white/5"
|
||||
:style="{ width: 'calc((100% - 8px) / 3)', transform: `translateX(${themeIndex * 100}%)` }"
|
||||
/>
|
||||
<button
|
||||
v-for="opt in themeOptions"
|
||||
:key="opt.value"
|
||||
role="radio"
|
||||
:aria-checked="themeMode === opt.value"
|
||||
class="press flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold transition-all duration-300"
|
||||
:class="themeMode === opt.value ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
|
||||
class="relative z-10 flex items-center justify-center gap-1.5 h-9 rounded-lg text-sm whitespace-nowrap transition-colors duration-200"
|
||||
:class="themeMode === opt.value ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
|
||||
@click="setThemeMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" class="w-3.5 h-3.5" />
|
||||
<component :is="opt.icon" class="w-4 h-4" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -819,10 +828,18 @@ async function handleRedeemInvite() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 兑换按钮:柠檬金底 + 深棕字(固定色,亮暗主题下对比度一致 ≥ 7:1) */
|
||||
.redeem-btn {
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe08a 0%, #f5bd2e 100%);
|
||||
}
|
||||
|
||||
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
|
||||
.vip-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
}
|
||||
.vip-btn::after {
|
||||
|
||||
Reference in New Issue
Block a user