fix(ui): 修复新 UI 侧栏被顶出与平台设置页导航重复 (#1706)

* fix(ui): 去掉 html/body 的 overflow-x: hidden,恢复侧栏与页头吸顶

#1704 引入的 bg-global.css 给 html、body 加了 overflow-x: hidden,任何非 visible
的 overflow 都会把 body 变成滚动容器,侧栏(layout.module.scss .sider)和
PageHeader 的 position: sticky 于是相对 body 而不是视口定位——主内容一长,
整页滚动就把左侧导航和「保存」按钮一起顶出视口。

- 删除 html/body 的 overflow-x: hidden,并在两处样式注明这条约束
- 两个 position: fixed 的背景层改用 inset: 0 铺满视口,不再依赖 100vw,
  也不会撑出横向滚动条

问题 1 of #1705(侧边导航被顶出)。

* fix(ui): 空间配置「平台设置」只保留左侧平台列表一套导航

#1704 把平台设置改成「左列平台列表 + 右栏 Collapse」后,右栏的手风琴仍然
为每个平台渲染一条带平台名的折叠栏,与左列重复(#1705 第二点)。

- 新增 app/ui/plugins/PlatformPanel.tsx:各平台插件的外壳,传入 bare 时只
  输出字段,否则保持原来的 Collapse.Panel(配置覆写弹窗仍在用)
- 10 个平台插件把 Collapse.Panel 换成 PlatformPanel,并透传 bare
- dashboard 右栏不再用 Collapse,改为每个平台一个 <section hidden>:所有
  字段始终挂载,只切换 hidden,保证 PUT /v1/configuration 整表覆盖时其他
  平台的参数与凭据不会因为字段卸载而丢失
- 左列平台列表在页头下方吸顶(position: sticky),右栏面板很长时列表不再
  随内容滚出视口;PageHeader 把自身高度写入 --page-header-height 供其取用;
  Tabs 关闭 tabPaneMotion 并放开 .semi-tabs-pane 的 overflow,否则 pane 会
  成为 sticky 的滚动容器。移动端横排 chip 行保持随内容滚动
- 左列按钮补 aria-pressed,nav 补 aria-label

Fixes #1705

* feat(ui): 空间配置保存校验失败时自动定位到第一个出错字段

平台设置的字段全部挂载、只切换 hidden,未选中的 Tab / 平台面板里的校验
错误用户看不到,表现为点「保存」没有任何反应。

- Form 加 onSubmitFail:把 Semi 的嵌套 errors 拍平成字段路径,按 DOM 顺序
  找到第一个出错字段,切到它所在的 Tab(Tabs 改为受控)与平台面板,再
  scrollIntoView 并聚焦,同时 Toast 提示未通过的项数
- TabPane 内容加 data-tab、平台 section 加 data-platform,供定位使用;
  不改插件接口

Refs #1705

* refactor(ui): 改用应用壳布局解决侧栏被顶出,移除 sticky 补救与 tabPaneMotion 取舍

按 review 意见(#1706)不再靠 position: sticky 解决问题一,改成简单的应用壳:

- layout.module.scss:.app 改为 height: 100vh(100dvh)+ overflow: hidden 的 flex 容器,
  页面本身不滚动;.sider 是固定宽度的普通子元素(撑满壳高,去掉 sticky/top/100vh);
  .main 加 min-height: 0 + overflow: auto,成为唯一的滚动容器。侧栏、页脚服务灯
  自然留在视口内,PageHeader 原有的 position: sticky 相对 .main 吸顶,无需改动
- 去掉为让平台列表在 Tab 面板内 sticky 而加的补救:.platformNav 的 sticky /
  --page-header-height / max-height、PageHeader 的 ResizeObserver、Tabs 的
  tabPaneMotion={false} 以及 .semi-tabs-pane 的 overflow 覆盖,Tab 切换动画恢复。
  平台列表改回随内容滚动
- bg-global.css:文档不再滚动,html/body 不需要 overflow 规则,注释改为说明现状;
  两个 fixed 背景层保持 inset: 0

验证:tsc 0 错误、next build 通过;13 页 × 浅色 / 深色 / 折叠 / 移动端 390 / 平板 760,
主区滚到底后 document.scrollHeight == innerHeight(文档不滚)、aside top 0、页脚
bottom == innerHeight、header top 0、main 无横向溢出;Semi Select 下拉(主区预滚 1.2k px
及打开后继续滚动)、Popconfirm、配置覆写 Modal 及其内部 Select、Toast 位置全部正确;
切遍 10 个平台后保存 PUT 73/73 键(3 次);校验失败自动定位三场景通过(2 轮)。

Refs #1705

* refactor(ui): 平台设置去掉外层嵌套卡片,改两栏独立滚动布局

按 review 第二条意见(#1706):平台列表不用 sticky,也不随内容滚走——去掉包在
「平台列表 + 面板」外面的嵌套卡片,让两栏并排、各自独立滚动。

- dashboard/page.tsx:删掉 dc.content / rootConfigPanel / main / content 四层包裹和
  平台设置里的 framePlatformConfig 卡片与重复的「平台设置」标题;页面主体改为
  .page(占满页头以下的剩余高度)→ Form → Tabs,每个 Tab 面板在自己内部滚动,
  页面本身不滚,页头「保存」始终可见。去掉底部两个 Space 占位
- dashboard.module.scss:重写。删除 #1704 之前遗留的整段 .rootConfigPanel 嵌套规则
  (其中 frameInside / group / lineStory 等类早已无人引用);Form / Tabs /
  semi-tabs-content / semi-tabs-pane(含开启动画时的 motion-overlay)改为
  flex: 1 + min-height: 0 的列容器,把高度让给当前面板,并补回
  .semi-tabs-pane-inactive 的 display: none(本模块选择器特异性更高)
- 平台设置:.platformLayout 两栏,.platformNav 160px、align-self: flex-start、
  max-height: 100% + overflow-y: auto(矮视口时列表自己滚),.platformBody
  flex: 1 + overflow-y: auto。右栏再长列表也不动
- 全局设置 / 开发者选项一致处理:frameDownload / frameUpload / frameDeveloper 不再是
  带边框和 50px 内边距的卡片,改为分区标题 + 分隔线的普通分区,面板 .pane 自身滚动
- 移动端(≤680px):两栏改为上下堆叠,平台列表是横向滑动的标签行,固定在面板顶部
  不随字段滚走
- 自定义背景开启时给两个滚动面板垫一层 75% 的 bg-1 半透明底色,保证文字可读

验证:tsc 0 错误、next build 通过;空间配置页在 1440 / 760 / 390 × 浅 / 深 / 折叠 /
520px 矮视口下:文档与 <main> 均不滚动,页头与「保存」始终在视口内,右栏滚到底
(哔哩哔哩面板 1223px)时平台列表位置不变,矮视口下列表自身可滚;全局设置面板独立
滚动;切遍 10 个平台后保存 PUT 73/73 键(4 次);校验失败自动定位三场景通过(3 轮,
scrollIntoView 在嵌套滚动容器内正常);Select 下拉在面板预滚 1k px 及滚动中跟随触发器,
Popconfirm / Modal / Toast 位置正确;其余 12 页布局探针与上一提交一致。

Refs #1705

* fix(ui): 更新日志页内容不再覆盖导航顶栏

应用壳布局下页头真正吸顶后暴露出来的问题(review 第三条,#1706):更新日志页把
PageHeader 又包在一层 position: sticky; z-index: 1 的 Semi Layout.Header 里,
形成一个层级只有 1 的 stacking context,PageHeader 自己的 z-index: 10 只在这层
内部生效;而版本卡片流 .feed 又设了 position: relative; z-index: 1,DOM 顺序在
后,滚动时就画在页头之上。

- changelog/page.tsx:去掉外层 Header,与其它页一样直接渲染 PageHeader
- changelog.module.scss:删掉 .feed / .center 的 position: relative; z-index: 1

验证:10 个带页头的页面滚到底后对页头内 6 个点做 elementFromPoint 命中测试,
全部命中页头自身(修前 /changelog 有 4 个点命中版本卡片头);/history 播放器
Modal 居中且在最上层;/logviewer、/status 等无层叠。

Refs #1705
This commit is contained in:
Noah authored and GitHub committed 2026-09-22 04:20:48 +00:00
1 parent ea3298340f
commit 45ab54ba90
18 files changed
+320 -491

No files matched your search

+4 -12
View File
@@ -1,16 +1,11 @@
/* 全局自定义背景:当 body 带 app-has-bg 时生效 */
html,
body {
overflow-x: hidden;
}
/* 两个背景层用 position: fixed + inset: 0 铺满视口。页面滚动由应用壳的主内容区承担
(layout.module.scss .app / .main),文档本身不滚,这里不需要再给 html / body 设 overflow */
body.app-has-bg::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
inset: 0;
z-index: -2;
background-image: var(--app-bg-image);
background-repeat: no-repeat;
@@ -23,10 +18,7 @@ body.app-has-bg::before {
body.app-has-bg::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
inset: 0;
background: var(--semi-color-bg-0);
opacity: var(--app-bg-mask-opacity, 0.35);
z-index: -1;
@@ -3,12 +3,6 @@
min-height: calc(100vh - 69px);
}
.feed,
.center {
position: relative;
z-index: 1;
}
.center {
display: flex;
align-items: center;
+8 -15
View File
@@ -9,7 +9,7 @@ import { formatVersion } from '@/app/lib/status'
import { SLOW_REFRESH_MS } from '@/app/lib/use-dashboard'
import styles from './changelog.module.scss'
const { Header, Content } = Layout
const { Content } = Layout
const { Title, Text } = Typography
// 数据源:biliup 官方 CHANGELOG(随版本自动更新,零后端改动)
@@ -210,20 +210,13 @@ export default function Changelog() {
return (
<>
<Header
style={{
backgroundColor: 'var(--semi-color-bg-1)',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
<PageHeader
icon={<IconBook size="large" />}
title="更新日志"
description="biliup 版本更新记录"
/>
</Header>
{/* PageHeader 自带 sticky 与 z-index,不要再包一层带 position / z-index 的容器,
否则会形成更低层级的 stacking context,滚动时被下方内容盖住 */}
<PageHeader
icon={<IconBook size="large" />}
title="更新日志"
description="biliup 版本更新记录"
/>
<Content>
<main className={styles.content}>
{isLoading && (
+125 -94
View File
@@ -1,19 +1,16 @@
'use client'
import React, { useRef, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
Button,
Form,
Collapse,
Avatar,
Select,
Space,
Toast,
Notification,
Typography,
Tabs,
TabPane,
} from '@douyinfe/semi-ui'
import { IconPlusCircle, IconStar, IconGlobe } from '@douyinfe/semi-icons'
import { IconPlusCircle, IconStar } from '@douyinfe/semi-icons'
import useSWR from 'swr'
import { fetcher, put } from '@/app/lib/api-streamer'
import useSWRMutation from 'swr/mutation'
@@ -21,14 +18,28 @@ import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import { useBiliUsers } from '../../lib/use-streamers'
import styles from '../../styles/dashboard.module.scss'
import PageHeader from '../components/PageHeader'
import SectionTitle from '../components/SectionTitle'
import dc from '@/app/ui/data-card.module.scss'
// 注册各平台组件
import { PlatformPanels } from '../../ui/plugins'
import Global from '../../ui/plugins/global'
import Developer from '../../ui/plugins/developer'
const TAB_GLOBAL = '1'
const TAB_PLATFORM = '2'
const TAB_DEVELOPER = '3'
/** Semi 把校验错误按字段路径存成嵌套对象({ user: { bili_cookie: '…' } }),拍平成 x-field-id 形式 */
function errorFieldPaths(errors: unknown, prefix = ''): string[] {
if (!errors || typeof errors !== 'object' || Array.isArray(errors) || '$$typeof' in errors) {
return prefix ? [prefix] : []
}
return Object.entries(errors as Record<string, unknown>).flatMap(([k, v]) =>
errorFieldPaths(v, prefix ? `${prefix}.${k}` : k),
)
}
const fieldElement = (path: string) =>
document.querySelector<HTMLElement>(`.semi-form-field[x-field-id="${path}"]`)
const Dashboard: React.FC = () => {
const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
@@ -61,8 +72,34 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
const { biliUsers } = useBiliUsers()
// 平台设置:左列平台名 + 右栏仅展开选中平台。列表来自插件注册表 PlatformPanels
// 平台设置:左列平台名是唯一的导航,右栏只显示选中平台的字段。列表来自插件注册表 PlatformPanels
const [activePlatform, setActivePlatform] = useState(PlatformPanels[0].key)
const [activeTab, setActiveTab] = useState(TAB_GLOBAL)
// 校验失败时出错字段可能藏在未选中的 Tab / 平台面板里(字段全部挂载、只切 hidden),
// 用户看不到红字也不知道为什么保存没反应。这里切到第一个出错字段所在的面板并滚过去。
const [pendingField, setPendingField] = useState<string | null>(null)
const handleSubmitFail = (errors: Record<string, unknown>) => {
const fields = errorFieldPaths(errors)
.map(path => ({ path, el: fieldElement(path) }))
.filter((f): f is { path: string; el: HTMLElement } => !!f.el)
.sort((a, b) => (a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1))
const first = fields[0]
if (!first) return
const tab = first.el.closest<HTMLElement>('[data-tab]')?.dataset.tab
const platform = first.el.closest<HTMLElement>('[data-platform]')?.dataset.platform
if (tab) setActiveTab(tab)
if (platform) setActivePlatform(platform)
setPendingField(first.path)
Toast.warning(`有 ${fields.length} 项未通过校验,已定位到第一项`)
}
useEffect(() => {
if (!pendingField) return
const el = fieldElement(pendingField)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
el?.querySelector<HTMLElement>('input, textarea')?.focus({ preventScroll: true })
setPendingField(null)
}, [pendingField])
if (isLoading) {
return <>Loading</>
@@ -105,92 +142,86 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
</Button>
}
/>
<div className={dc.content}>
<main className={styles.rootConfigPanel}>
<div className={styles.main}>
<div className={styles.content}>
<Form
className={styles.form}
// key={formKey}
initValues={entity}
onSubmit={async values => {
try {
const payload = { ...values }
if (payload.file_size === undefined || payload.file_size === '') {
payload.file_size = null
}
if (payload.segment_time === undefined || payload.segment_time === '') {
payload.segment_time = null
}
await trigger(payload)
Toast.success('保存成功')
} catch (e: any) {
// error handling
Notification.error({
title: '保存失败',
content: <Typography style={{ maxWidth: 450 }}>{e.message}</Typography>,
// theme: 'light',
// duration: 0,
style: { width: 'min-content' },
})
throw e
}
}}
getFormApi={formApi => (formRef.current = formApi)}
>
<Tabs
type="line"
contentStyle={{
margin: '10px 0 0 0',
}}
>
<TabPane tab="全局设置" itemKey="1">
{/* 全局设置 */}
<Global />
</TabPane>
<TabPane tab="平台设置" itemKey="2">
{/* 平台设置:左列平台名 + 右栏选中表单 */}
<div className={styles.framePlatformConfig}>
<SectionTitle icon={<IconGlobe size="small" />} title="平台设置" />
<div className={styles.platformLayout}>
<nav className={styles.platformNav}>
{PlatformPanels.map(p => (
<button
key={p.key}
type="button"
className={`${styles.platformNavItem} ${
activePlatform === p.key ? styles.platformNavItemActive : ''
}`}
onClick={() => setActivePlatform(p.key)}
>
{p.name}
</button>
))}
</nav>
<div className={styles.platformBody}>
{/* 所有平台组件保持挂载(keepDOM),仅展开当前平台面板。
卸载会注销 Semi Form 字段状态,提交时仅剩挂载字段;后端 PUT /configuration
整表覆盖保存,会清空其他平台的参数与凭据 */}
<Collapse keepDOM activeKey={[activePlatform]}>
{PlatformPanels.map(p => (
<p.Component key={p.key} entity={entity} list={list} />
))}
</Collapse>
</div>
</div>
</div>
</TabPane>
<TabPane tab="开发者选项" itemKey="3">
{/* 开发者选项 */}
<Developer />
</TabPane>
</Tabs>
<Space />
<Space style={{ height: '160px' }} />
</Form>
</div>
</div>
</main>
{/* 页头下方占满剩余高度;每个 Tab 面板在内部滚动,页面本身不滚,「保存」始终可见 */}
<div className={styles.page}>
<Form
className={styles.form}
initValues={entity}
onSubmit={async values => {
try {
const payload = { ...values }
if (payload.file_size === undefined || payload.file_size === '') {
payload.file_size = null
}
if (payload.segment_time === undefined || payload.segment_time === '') {
payload.segment_time = null
}
await trigger(payload)
Toast.success('保存成功')
} catch (e: any) {
// error handling
Notification.error({
title: '保存失败',
content: <Typography style={{ maxWidth: 450 }}>{e.message}</Typography>,
// theme: 'light',
// duration: 0,
style: { width: 'min-content' },
})
throw e
}
}}
onSubmitFail={handleSubmitFail}
getFormApi={formApi => (formRef.current = formApi)}
>
<Tabs type="line" className={styles.tabs} activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="全局设置" itemKey={TAB_GLOBAL}>
<div className={styles.pane} data-tab={TAB_GLOBAL}>
<Global />
</div>
</TabPane>
<TabPane tab="平台设置" itemKey={TAB_PLATFORM}>
{/* 左列平台列表 + 右栏选中平台的字段,两栏并排、各自独立滚动 */}
<div className={styles.platformLayout} data-tab={TAB_PLATFORM}>
<nav className={styles.platformNav} aria-label="平台列表">
{PlatformPanels.map(p => (
<button
key={p.key}
type="button"
className={`${styles.platformNavItem} ${
activePlatform === p.key ? styles.platformNavItemActive : ''
}`}
aria-pressed={activePlatform === p.key}
onClick={() => setActivePlatform(p.key)}
>
{p.name}
</button>
))}
</nav>
<div className={styles.platformBody}>
{/* 所有平台的字段始终保持挂载,只用 hidden 切换显示。
卸载会注销 Semi Form 字段状态,提交时仅剩挂载字段;后端 PUT /configuration
整表覆盖保存,会清空其他平台的参数与凭据 */}
{PlatformPanels.map(p => (
<section
key={p.key}
className={styles.platformPanel}
hidden={activePlatform !== p.key}
aria-label={p.name}
data-platform={p.key}
>
<p.Component entity={entity} list={list} bare />
</section>
))}
</div>
</div>
</TabPane>
<TabPane tab="开发者选项" itemKey={TAB_DEVELOPER}>
<div className={styles.pane} data-tab={TAB_DEVELOPER}>
<Developer />
</div>
</TabPane>
</Tabs>
</Form>
</div>
</>
)
+10 -5
View File
@@ -1,8 +1,12 @@
/* ===== 应用骨架:自绘侧边栏 ===== */
/* 应用壳:整体占满视口且自身不滚动;侧栏是固定宽度的普通子元素,
主内容区(.main)独立滚动。页面本身不滚,长内容不会把侧栏顶出视口(#1705) */
.app {
display: flex;
min-height: 100vh;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--semi-color-bg-0);
}
@@ -17,14 +21,13 @@
}
/* ===== 侧边栏 ===== */
/* 撑满应用壳高度;导航项多于一屏时只有 .nav 内部滚动 */
.sider {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
height: 100%;
background: var(--semi-color-bg-1);
border-right: 1px solid var(--semi-color-border);
transition: width 0.18s ease, transform 0.2s ease;
@@ -265,12 +268,14 @@
height: 16px;
}
/* ===== 主内容区 ===== */
/* ===== 主内容区:唯一的滚动容器,页头(PageHeader)的 position: sticky 相对它吸顶 ===== */
.main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
/* ===== 移动端抽屉 ===== */
+84 -319
View File
@@ -1,325 +1,76 @@
@media screen and (min-width: 640px) {
.rootConfigPanel {
padding: 0px 50px 0px 30px;
//align-items: center;
//background: var(--semi-color-bg-0);
//flex-direction: column;
//height: auto;
//overflow: auto;
//row-gap: 0;
//min-width: auto;
//max-width: auto;
//display: flex;
//color: var(--semi-color-text-0);
//font-family: Inter;
//font-size: 20px;
//font-weight: 600;
//letter-spacing: 0.4px;
//line-height: 28px;
//text-align: left;
.main {
//align-items: center;
display: flex;
flex-basis: 0;
flex-direction: column;
flex-grow: 1;
//padding: 40px 0px 0px;
row-gap: 32px;
align-self: stretch;
.content {
//align-items: flex-start;
//align-items: center;
display: inline-flex;
flex-direction: column;
row-gap: 32px;
flex-shrink: 0;
.item {
min-width: 175px;
vertical-align: middle;
flex-shrink: 0;
}
.form {
//align-items: flex-start;
//align-items: center;
//display: flex;
//flex-direction: column;
//row-gap: 16px;
//min-width: 50px;
//max-width: 965px;
flex-shrink: 0;
//color: var(--semi-color-text-0);
//font-family: "PingFang SC";
//font-size: 16px;
//font-weight: 500;
//letter-spacing: 0.32px;
//line-height: 22px;
//text-align: left;
.frameDownload {
//align-items: flex-start;
border-color: rgb(var(--semi-grey-2));
border-radius: 6px;
border-style: solid;
border-width: 1px;
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 25px 50px 50px;
row-gap: 10px;
align-self: stretch;
min-width: 12px;
max-width: auto;
.frameInside {
align-items: center;
column-gap: 4px;
display: inline-flex;
flex-shrink: 0;
.group {
height: 20px;
position: relative;
width: 20px;
flex-shrink: 0;
.buttonOnlyIconSecond {
background: rgb(16, 192, 25);
border-radius: 8px;
height: 20px;
left: 0;
overflow: hidden;
top: 0;
width: 20px;
position: absolute;
}
.lineStory {
height: 10px;
left: 4.5px;
overflow: hidden;
top: 4.5px;
width: 12px;
position: absolute;
}
}
.meegoSharedWebWorkIt {
min-width: 68px;
vertical-align: bottom;
flex-shrink: 0;
}
}
}
.frameUpload {
align-items: flex-start;
border-color: rgb(var(--semi-grey-2));
border-radius: 6px;
border-style: solid;
border-width: 1px;
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 25px 50px 50px;
row-gap: 10px;
align-self: stretch;
.frameInside {
align-items: center;
column-gap: 4px;
display: inline-flex;
flex-shrink: 0;
.group {
height: 20px;
position: relative;
width: 20px;
flex-shrink: 0;
.buttonOnlyIconSecond {
background: rgb(236, 23, 66);
border-radius: 8px;
height: 20px;
left: 0;
overflow: hidden;
top: 0;
width: 20px;
position: absolute;
}
.lineStory {
height: 10px;
left: 4.5px;
overflow: hidden;
top: 4.5px;
width: 12px;
position: absolute;
}
}
.meegoSharedWebWorkIt {
min-width: 68px;
vertical-align: bottom;
flex-shrink: 0;
}
}
}
.framePlatformConfig {
align-items: flex-start;
border-color: rgb(var(--semi-grey-2));
border-radius: 6px;
border-style: solid;
border-width: 1px;
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 25px 50px 50px;
row-gap: 10px;
align-self: stretch;
.frameInside {
align-items: center;
column-gap: 4px;
display: inline-flex;
flex-shrink: 0;
.group {
height: 20px;
position: relative;
width: 20px;
flex-shrink: 0;
.outlinedVersion {
height: 10px;
left: 5px;
overflow: hidden;
top: 5px;
width: 10px;
position: absolute;
}
.buttonOnlyIconSecond {
background: rgb(47, 76, 206);
border-radius: 8px;
height: 20px;
left: 0;
overflow: hidden;
top: 0;
width: 20px;
position: absolute;
}
.lineStory {
height: 15px;
left: 4.5px;
overflow: hidden;
top: 4.1px;
width: 12px;
position: absolute;
}
}
.meegoSharedWebSettin {
min-width: 68px;
vertical-align: bottom;
flex-shrink: 0;
}
}
}
.frameDeveloper {
align-items: flex-start;
border-color: rgb(var(--semi-grey-2));
border-radius: 6px;
border-style: solid;
border-width: 1px;
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 25px 50px 50px;
row-gap: 10px;
align-self: stretch;
.frameInside {
align-items: center;
column-gap: 4px;
display: inline-flex;
flex-shrink: 0;
.group {
height: 20px;
position: relative;
width: 20px;
flex-shrink: 0;
.outlinedVersion {
height: 10px;
left: 5px;
overflow: hidden;
top: 5px;
width: 10px;
position: absolute;
}
.buttonOnlyIconSecond {
background: rgb(200, 109, 5);
border-radius: 8px;
height: 20px;
left: 0;
overflow: hidden;
top: 0;
width: 20px;
position: absolute;
}
.lineStory {
height: 15px;
left: 4.5px;
overflow: hidden;
top: 4.1px;
width: 12px;
position: absolute;
}
}
.meegoSharedWebSettin {
min-width: 68px;
vertical-align: bottom;
flex-shrink: 0;
}
}
}
.line {
flex-shrink: 0;
height: 0;
align-self: stretch;
}
}
}
}
}
/* 空间配置页
页头下方是 Tabs,每个 Tab 面板占满剩余高度并在自己内部滚动;
页面本身(应用壳的 .main)不滚动,页头的「保存」始终可见。 */
.page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
width: 100%;
max-width: 1280px;
margin: 0 auto;
box-sizing: border-box;
padding: 12px 28px 20px;
}
/* 表单卡片:必须加实色背景,避免全局背景图穿透导致文字不可读 */
/* Semi 的 Form / Tabs 默认按内容撑开,这里让它们把剩余高度让给当前面板
(semi-tabs-pane-motion-overlay 是开启切换动画时 Semi 多包的一层) */
.form,
.tabs,
.tabs :global(.semi-tabs-content),
.tabs :global(.semi-tabs-pane),
.tabs :global(.semi-tabs-pane-motion-overlay) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tabs :global(.semi-tabs-content) {
padding: 0;
}
/* 上面的 display: flex 特异性高于 Semi 自己的 .semi-tabs-pane-inactive,未选中的面板要补回隐藏 */
.tabs :global(.semi-tabs-pane-inactive) {
display: none;
}
/* 全局设置 / 开发者选项:面板自身滚动 */
.pane {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 4px 32px;
}
/* 表单分区:不再套卡片,只用分区标题 + 分隔线区分 */
.frameDownload,
.frameUpload,
.framePlatformConfig,
.frameDeveloper {
background: var(--semi-color-bg-1);
display: flex;
flex-direction: column;
row-gap: 10px;
}
.frameUpload {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--semi-color-border);
}
/* 平台设置:左列平台名 + 右栏选中表单 */
/* 平台设置:左列平台列表 + 右栏选中平台的字段。两栏并排、各自独立滚动,
右栏再长,列表也不会跟着滚走 */
.platformLayout {
flex: 1;
min-height: 0;
display: flex;
gap: 16px;
align-items: flex-start;
margin-top: 16px;
padding-top: 16px;
}
.platformNav {
width: 160px;
flex-shrink: 0;
align-self: flex-start;
max-height: 100%;
box-sizing: border-box;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
@@ -351,30 +102,44 @@
.platformBody {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding-bottom: 32px;
}
/* 右栏:只显示选中平台的字段;不设 display,以免覆盖 hidden 属性 */
.platformPanel {
padding: 4px 16px 8px 8px;
}
/* 移动端:卡片内边距收紧,避免左右留白/溢出 */
/* 自定义背景开启时表单直接铺在背景图上,给滚动面板垫一层半透明底色保证可读 */
:global(body.app-has-bg) .pane,
:global(body.app-has-bg) .platformBody {
background: color-mix(in srgb, var(--semi-color-bg-1) 75%, transparent);
border-radius: 12px;
}
/* 移动端:两栏改为上下堆叠,平台列表变成横向滑动的标签行,仍不随内容滚走 */
@media (max-width: 680px) {
.rootConfigPanel {
padding: 0 12px !important;
}
.frameDownload,
.frameUpload,
.framePlatformConfig,
.frameDeveloper {
padding: 16px 14px 24px !important;
border-radius: 10px;
.page {
padding: 8px 12px 12px;
}
.platformLayout {
flex-direction: column;
gap: 10px;
padding-top: 10px;
}
.platformNav {
width: 100%;
max-height: none;
align-self: stretch;
flex-direction: row;
overflow-x: auto;
flex-shrink: 0;
overflow-y: hidden;
}
.platformNavItem {
white-space: nowrap;
}
.platformPanel {
padding: 4px 4px 8px;
}
}
+28
View File
@@ -0,0 +1,28 @@
'use client'
import React from 'react'
import { Collapse } from '@douyinfe/semi-ui'
type Props = {
header: string
itemKey: string
/**
* 只输出字段,不套 Collapse.Panel。
* 空间配置页由左侧平台列表负责切换,右侧再出现一排带平台名的折叠栏就是重复导航(#1705);
* 直播管理页的「配置覆写」弹窗仍以 Collapse.Panel 形式嵌在 Collapse 里。
*/
bare?: boolean
children: React.ReactNode
}
const PlatformPanel: React.FC<Props> = ({ header, itemKey, bare, children }) => {
if (bare) {
return <>{children}</>
}
return (
<Collapse.Panel header={header} itemKey={itemKey}>
{children}
</Collapse.Panel>
)
}
export default PlatformPanel
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const Bilibili: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const Bilibili: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="哔哩哔哩" itemKey="bilibili">
<PlatformPanel header="哔哩哔哩" itemKey="bilibili" bare={bare}>
<Form.Select
allowCreate={true}
filter
@@ -273,7 +275,7 @@ const Bilibili: React.FC<Props> = props => {
}}
showClear={true}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const CC: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const CC: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="CC" itemKey="cc">
<PlatformPanel header="CC" itemKey="cc" bare={bare}>
<Form.Select
field="cc_protocol"
extraText="CC 直播流协议,默认 hls。录制经常异常断开、分段过多时可尝试切换为 flv。"
@@ -38,7 +40,7 @@ const CC: React.FC<Props> = props => {
<Select.Option value="flv">flv</Select.Option>
<Select.Option value="hls">hls(默认)</Select.Option>
</Form.Select>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+5 -3
View File
@@ -1,10 +1,12 @@
'use client'
import React from 'react'
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
import { Form, Select } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
bare?: boolean
}
const Cookie: React.FC<Props> = props => {
@@ -12,7 +14,7 @@ const Cookie: React.FC<Props> = props => {
const list = props.list
return (
<>
<Collapse.Panel header="用户 Cookie" itemKey="user">
<PlatformPanel header="用户 Cookie" itemKey="user" bare={props.bare}>
<Form.Input
field="user.kuaishou_cookie"
extraText={
@@ -94,7 +96,7 @@ const Cookie: React.FC<Props> = props => {
padding: 0,
}}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const Douyin: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const Douyin: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="抖音" itemKey="douyin">
<PlatformPanel header="抖音" itemKey="douyin" bare={bare}>
<Form.Select
field="douyin_quality"
extraText={
@@ -122,7 +124,7 @@ const Douyin: React.FC<Props> = props => {
padding: 0,
}}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const Douyu: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const Douyu: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="斗鱼" itemKey="douyu">
<PlatformPanel header="斗鱼" itemKey="douyu" bare={bare}>
<Form.Select
allowCreate={true}
filter
@@ -102,7 +104,7 @@ const Douyu: React.FC<Props> = props => {
padding: 0,
}}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const Huya: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const Huya: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="虎牙" itemKey="huya">
<PlatformPanel header="虎牙" itemKey="huya" bare={bare}>
<Form.Select
allowCreate={true}
filter
@@ -144,7 +146,7 @@ const Huya: React.FC<Props> = props => {
padding: 0,
}}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+2 -1
View File
@@ -40,7 +40,8 @@ const plugins = {
/**
* 空间配置「平台设置」左栏的展示顺序与文案。
* key 必须与各插件 Collapse.Panel 的 itemKey 一致;新增插件时在这里登记一行即可出现在 UI 里。
* 各插件组件接受 `bare` 属性:为 true 时只渲染字段(空间配置页),否则渲染成 Collapse.Panel(配置覆写弹窗)。
* 新增插件时在这里登记一行即可出现在 UI 里。
*/
export const PlatformPanels: { key: string; name: string; Component: React.FC<any> }[] = [
{ key: 'bilibili', name: '哔哩哔哩', Component: Bilibili },
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const Kilakila: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const Kilakila: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="克拉克拉" itemKey="kilakila">
<PlatformPanel header="克拉克拉" itemKey="kilakila" bare={bare}>
<Form.Select
field="kila_protocol"
extraText="直播流协议,默认 hls"
@@ -38,7 +40,7 @@ const Kilakila: React.FC<Props> = props => {
<Select.Option value="hls">hls</Select.Option>
<Select.Option value="flv">flv</Select.Option>
</Form.Select>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const TwitCasting: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const TwitCasting: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="TwitCasting" itemKey="twitcasting">
<PlatformPanel header="TwitCasting" itemKey="twitcasting" bare={bare}>
<Form.Select
field="twitcasting_quality"
extraText="录制画质,默认取最高可用画质。所选画质不存在时自动降到更低一档;更低的也没有时取最高可用画质。"
@@ -72,7 +74,7 @@ const TwitCasting: React.FC<Props> = props => {
padding: 0,
}}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const Twitch: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const Twitch: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="Twitch" itemKey="twitch">
<PlatformPanel header="Twitch" itemKey="twitch" bare={bare}>
<Form.Switch
field="twitch_danmaku"
extraText="录制 Twitch 弹幕,默认关闭"
@@ -75,7 +77,7 @@ const Twitch: React.FC<Props> = props => {
padding: 0,
}}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}
+6 -4
View File
@@ -1,15 +1,17 @@
'use client'
import React, { useEffect } from 'react'
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import PlatformPanel from './PlatformPanel'
type Props = {
entity: any
list: any
initValues?: Record<string, any>
bare?: boolean
}
const YouTube: React.FC<Props> = props => {
const { entity, list, initValues } = props
const { entity, list, initValues, bare } = props
const formApi = useFormApi()
useEffect(() => {
@@ -22,7 +24,7 @@ const YouTube: React.FC<Props> = props => {
return (
<>
<Collapse.Panel header="YouTube" itemKey="youtube">
<PlatformPanel header="YouTube" itemKey="youtube" bare={bare}>
<Form.Input
field="user.youtube_cookie"
extraText={
@@ -166,7 +168,7 @@ const YouTube: React.FC<Props> = props => {
padding: 0,
}}
/>
</Collapse.Panel>
</PlatformPanel>
</>
)
}