feat: 支持 CellData 文件夹上传处理

This commit is contained in:
2026-06-25 04:43:04 +08:00
parent c36dd21838
commit 9349c549e9
5 changed files with 470 additions and 30 deletions
+93 -2
View File
@@ -5,13 +5,13 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, File, HTTPException, UploadFile
from app import state from app import state
from app.api.routers.task_runtime import set_task_stage from app.api.routers.task_runtime import set_task_stage
from app.config import AppConfig, CACHE_DIR from app.config import AppConfig, CACHE_DIR
from app.processor import ProcessLogger from app.processor import ProcessLogger
from app.services.cell_data import refresh_cell_data from app.services.cell_data import CellDataProcessor, refresh_cell_data
router = APIRouter(tags=["cell-data"]) router = APIRouter(tags=["cell-data"])
@@ -57,6 +57,67 @@ async def start_cell_data_processing():
return {"success": True, "message": "CellData 处理已启动", "task_id": task_id, "stage": current_stage} return {"success": True, "message": "CellData 处理已启动", "task_id": task_id, "stage": current_stage}
@router.post("/api/cell-data/process/upload")
async def upload_and_start_cell_data_processing(files: list[UploadFile] = File(...)):
if not files:
raise HTTPException(status_code=400, detail="没有上传文件")
if state.global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成")
task_id = "cell_data_" + datetime.now().strftime("%Y%m%d_%H%M%S")
work_dir = CACHE_DIR / task_id
upload_dir = work_dir / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
saved_count = 0
for file in files:
if not file.filename:
continue
target = upload_dir / file.filename.replace("\\", "/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(await file.read())
saved_count += 1
if saved_count == 0:
raise HTTPException(status_code=400, detail="没有有效上传文件")
logs: list[str] = []
current_stage = "parsing"
def log_callback(message: str) -> None:
logs.append(message)
set_task_stage(task_id, current_stage, logs)
def stage_callback(stage: str) -> None:
nonlocal current_stage
current_stage = stage
set_task_stage(task_id, current_stage, logs)
logger = ProcessLogger(
log_file=work_dir / "log.txt",
callback=log_callback,
stage_callback=stage_callback,
)
app_config = state.current_config()
state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage}
state.global_task_lock.update(
{
"locked": True,
"task_id": task_id,
"stage": current_stage,
"started_at": datetime.now().isoformat(),
}
)
thread = Thread(target=_run_uploaded_cell_data_processing, args=(task_id, upload_dir, work_dir, logger, app_config), daemon=True)
thread.start()
return {
"success": True,
"message": "CellData 处理已启动",
"task_id": task_id,
"stage": current_stage,
"file_count": saved_count,
}
@router.post("/api/cell-data/process/status") @router.post("/api/cell-data/process/status")
async def get_cell_data_processing_status(task_id: str = Body(..., embed=True)): async def get_cell_data_processing_status(task_id: str = Body(..., embed=True)):
if task_id in state.processing_tasks: if task_id in state.processing_tasks:
@@ -101,3 +162,33 @@ def _run_cell_data_processing(task_id: str, work_dir: Path, logger: ProcessLogge
} }
finally: finally:
state.reset_task_lock() state.reset_task_lock()
def _run_uploaded_cell_data_processing(task_id: str, upload_dir: Path, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
started = time.time()
try:
result = CellDataProcessor(app_config, work_dir, logger).run_local(upload_dir)
elapsed = round(time.time() - started, 2)
state.processing_tasks[task_id] = {
"logs": logger.get_logs(),
"status": "completed",
"stage": "completed",
"elapsed_time": elapsed,
"result": {
"selected_files": result.selected_files,
"parsed_rows": result.parsed_rows,
"imported_rows": result.imported_rows,
"skipped_rows": result.skipped_rows,
},
}
except Exception as exc:
logger.error(str(exc))
state.processing_tasks[task_id] = {
"logs": logger.get_logs(),
"status": "failed",
"stage": "failed",
"error": str(exc),
"elapsed_time": round(time.time() - started, 2),
}
finally:
state.reset_task_lock()
+70 -4
View File
@@ -84,6 +84,24 @@ class CellDataProcessor:
self._log(f"CellData 导入完成,共 {result.imported_rows} 行") self._log(f"CellData 导入完成,共 {result.imported_rows} 行")
return result return result
def run_local(self, upload_root: Path) -> CellDataResult:
self._set_stage("locating")
local_files = self._select_local_zip_files(upload_root)
if not local_files:
raise RuntimeError("未找到可处理的 CellData ZIP 文件")
self._log(f"已选择 {len(local_files)} 个 CellData ZIP 文件")
self._set_stage("parsing")
result = CellDataResult(selected_files=len(local_files))
rows = self._parse_zip_files(local_files, result)
if not rows:
raise RuntimeError("CellData ZIP 中未解析到有效数据")
self._set_stage("importing")
result.imported_rows = self._replace_cellinfo(rows)
self._log(f"CellData 导入完成,共 {result.imported_rows} 行")
return result
def _select_latest_zip_files(self) -> list[SelectedZip]: def _select_latest_zip_files(self) -> list[SelectedZip]:
selected: list[SelectedZip] = [] selected: list[SelectedZip] = []
for template in self.config.scan_paths: for template in self.config.scan_paths:
@@ -182,6 +200,47 @@ class CellDataProcessor:
ssh.close() ssh.close()
return local_files return local_files
def _select_local_zip_files(self, upload_root: Path) -> list[tuple[SelectedZip, Path]]:
grouped: dict[str, list[Path]] = {}
for path in upload_root.rglob("*.zip"):
if not self.file_name_re.search(path.name):
continue
try:
parent = str(path.parent.relative_to(upload_root)).replace("\\", "/")
except ValueError:
parent = ""
grouped.setdefault("" if parent == "." else parent, []).append(path)
selected: list[tuple[SelectedZip, Path]] = []
for parent, paths in sorted(grouped.items(), key=lambda item: item[0]):
candidates = []
for path in paths:
match = self.file_time_re.search(path.name)
if match:
candidates.append((match.group("timestamp"), path))
if not candidates:
continue
timestamp, path = max(candidates, key=lambda item: item[0])
band = Path(parent).name if parent else ""
selected_zip = SelectedZip(
scan_path=str(upload_root),
band=band,
remote_file=RemoteFileInfo(
path=str(path),
relative_path=str(path.relative_to(upload_root)).replace("\\", "/"),
parent=parent,
name=path.name,
size=path.stat().st_size,
),
timestamp=timestamp,
)
if not band:
self._log(f"未从目录名识别频段: {path.name}")
else:
self._log(f"{band}: {path.name}")
selected.append((selected_zip, path))
return selected
@staticmethod @staticmethod
def _download_result(): def _download_result():
from app.services.remote_download import RemoteDownloadResult from app.services.remote_download import RemoteDownloadResult
@@ -194,15 +253,22 @@ class CellDataProcessor:
rows_by_key: dict[str, dict[str, str]] = {} rows_by_key: dict[str, dict[str, str]] = {}
for selected, local_path in local_files: for selected, local_path in local_files:
with zipfile.ZipFile(local_path) as zf: with zipfile.ZipFile(local_path) as zf:
for source in mapping["sources"]: sources = list(mapping["sources"])
if source["band"] != selected.band:
continue
for info in zf.infolist(): for info in zf.infolist():
name = Path(info.filename).name name = Path(info.filename).name
if not name.lower().endswith(".csv"): if not name.lower().endswith(".csv"):
continue continue
if not name.startswith(source["file_prefix"]): matching_sources = [
source
for source in sources
if name.startswith(source["file_prefix"]) and (not selected.band or source["band"] == selected.band)
]
if not matching_sources:
continue continue
if not selected.band and len(matching_sources) > 1:
self._log(f"跳过无法识别频段的文件: {name}")
continue
for source in matching_sources:
raw = zf.read(info.filename) raw = zf.read(info.filename)
text = self._decode_csv(raw) text = self._decode_csv(raw)
reader = csv.DictReader(text.splitlines()) reader = csv.DictReader(text.splitlines())
+13
View File
@@ -753,3 +753,16 @@
- 接口:新增 `app/api/routers/cell_data.py`,提供 `POST /api/cell-data/process/start` 与 `/status`,数据处理页新增独立 CellData 卡片,可只刷新 CellData,不跑容量处理。 - 接口:新增 `app/api/routers/cell_data.py`,提供 `POST /api/cell-data/process/start` 与 `/status`,数据处理页新增独立 CellData 卡片,可只刷新 CellData,不跑容量处理。
- 接入:本地上传、远程手动和自动调度入口都会在容量处理前调用 CellData 预处理;CellData 单独处理和容量处理共用现有全局任务锁,避免并发写库。 - 接入:本地上传、远程手动和自动调度入口都会在容量处理前调用 CellData 预处理;CellData 单独处理和容量处理共用现有全局任务锁,避免并发写库。
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过;远程定位可从最新年份 `2026年/300表` 选出 `2.6G` 与 `700M` 各自最新 `Result_300` ZIP;用 SFTP MCP 读取的小样本 ZIP 验证解析和入库,`celldata.cellinfo` 写入 1 行且中文字段正常(样本 `CGI=460-00-12683845-1`)。 - 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过;远程定位可从最新年份 `2026年/300表` 选出 `2.6G` 与 `700M` 各自最新 `Result_300` ZIP;用 SFTP MCP 读取的小样本 ZIP 验证解析和入库,`celldata.cellinfo` 写入 1 行且中文字段正常(样本 `CGI=460-00-12683845-1`)。
## 2026-06-25:CellData 卡片支持本地上传处理
- 数据处理页 CellData 卡片改为与主上传区一致的拖拽/点击上传样式,支持拖入或选择多个 `Result_300_*.zip`,也支持选择文件夹上传。
- 新增 `/api/cell-data/process/upload`,上传后复用 CellData 解析入库逻辑;若 ZIP 不在 `700M/2.6G` 等目录下且无法识别频段,会跳过无法唯一匹配的 CSV。
- CellData 卡片仍保留「远程刷新」操作,用于按系统设置中的 CellData SFTP/FTP 配置拉取处理。
- 验证:`frontend` `npm run build` 通过;构建产物已清理。
## 2026-06-25:数据处理卡片补说明按钮并规范 CellData 上传
- 数据处理页的容量数据卡片与 CellData 卡片左上角均显示数据类型,右上角均提供说明图标按钮,点击后以小弹窗展示所需文件格式和目录结构。
- CellData 卡片改为点击/拖拽文件夹上传,不再提供单文件选择;直接拖入单个 ZIP 会提示选择包含 `Result_300` ZIP 的文件夹。
- 验证:`frontend` `npm run build` 通过;构建产物已清理。
+215 -5
View File
@@ -15,6 +15,20 @@
@dragleave="onDragLeave" @dragleave="onDragLeave"
@drop.prevent.stop="onDrop" @drop.prevent.stop="onDrop"
> >
<span class="upload-card-label">容量数据</span>
<n-button
quaternary
circle
size="small"
class="upload-help-button"
aria-label="查看容量数据说明"
title="查看说明"
@click.stop="capacityHelpVisible = true"
>
<template #icon>
<n-icon><InformationCircleOutline /></n-icon>
</template>
</n-button>
<div class="upload-icon"> <div class="upload-icon">
<n-icon size="24"><FolderOpenOutline /></n-icon> <n-icon size="24"><FolderOpenOutline /></n-icon>
</div> </div>
@@ -59,11 +73,45 @@
/> />
</section> </section>
<section v-if="!taskInProgress" class="cell-data-card"> <section
<div> v-if="!taskInProgress"
<h3>CellData</h3> class="cell-data-upload-zone"
<p>刷新 CellData 数据。</p> :class="{ dragover: isCellDataDragging, disabled: cellDataStarting }"
role="button"
tabindex="0"
:aria-disabled="cellDataStarting"
@click="cellDataFolderInput?.click()"
@keydown.enter.prevent="cellDataFolderInput?.click()"
@keydown.space.prevent="cellDataFolderInput?.click()"
@dragenter.prevent.stop="onCellDataDragEnter"
@dragover.prevent.stop="onCellDataDragOver"
@dragleave="onCellDataDragLeave"
@drop.prevent.stop="onCellDataDrop"
>
<span class="upload-card-label">CellData</span>
<n-button
quaternary
circle
size="small"
class="upload-help-button"
aria-label="查看 CellData 文件说明"
title="查看说明"
@click.stop="cellDataHelpVisible = true"
>
<template #icon>
<n-icon><InformationCircleOutline /></n-icon>
</template>
</n-button>
<div class="upload-icon">
<n-icon size="24"><FolderOpenOutline /></n-icon>
</div> </div>
<div class="cell-data-upload-text">
<h3>拖拽文件夹到这里</h3>
<p>包含 700M、2.6G 等目录</p>
</div>
<div class="upload-zone-actions">
<span class="upload-hint">或者点击选择文件夹</span>
<n-space size="small" @click.stop @keydown.stop>
<n-button <n-button
type="primary" type="primary"
secondary secondary
@@ -71,8 +119,20 @@
:disabled="working || remoteStarting || cellDataStarting" :disabled="working || remoteStarting || cellDataStarting"
@click="startCellDataProcessing" @click="startCellDataProcessing"
> >
刷新 CellData <template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
远程刷新
</n-button> </n-button>
</n-space>
</div>
<input
ref="cellDataFolderInput"
class="hidden-input"
type="file"
multiple
webkitdirectory
directory
@change="pickCellDataFiles"
/>
</section> </section>
<div v-if="!taskInProgress && files.length > 0" class="file-list"> <div v-if="!taskInProgress && files.length > 0" class="file-list">
@@ -193,6 +253,58 @@
</div> </div>
</template> </template>
</n-modal> </n-modal>
<n-modal
v-model:show="capacityHelpVisible"
preset="card"
class="cell-data-help-modal"
title="容量数据说明"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
>
<n-scrollbar class="cell-data-help-scroll">
<section class="cell-data-help-section">
<h4>支持格式</h4>
<p>支持 ZIP、Excel 和 CSV 文件。</p>
</section>
<section class="cell-data-help-section">
<h4>上传方式</h4>
<p>可拖拽文件或文件夹,也可点击卡片选择文件。</p>
</section>
<section class="cell-data-help-section">
<h4>远程处理</h4>
<p>使用系统设置中的数据源配置下载并处理。</p>
</section>
</n-scrollbar>
</n-modal>
<n-modal
v-model:show="cellDataHelpVisible"
preset="card"
class="cell-data-help-modal"
title="CellData 文件说明"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
>
<n-scrollbar class="cell-data-help-scroll">
<section class="cell-data-help-section">
<h4>目录结构</h4>
<p>请选择包含频段目录的文件夹,例如:</p>
<code>300表/700M/Result_300_*.zip</code>
<code>300表/2.6G/Result_300_*.zip</code>
</section>
<section class="cell-data-help-section">
<h4>文件要求</h4>
<p>只处理文件名以 <strong>Result_300_</strong> 开头的 ZIP。</p>
<p>每个频段目录只取文件名末尾时间戳最新的 ZIP。</p>
</section>
<section class="cell-data-help-section">
<h4>ZIP 内容</h4>
<p>识别以下 CSV 文件名前缀:</p>
<code>LTE_ITBBU_CellInfo</code>
<code>LTE_SDR_CellInfo</code>
<code>NR_CellInfo</code>
</section>
</n-scrollbar>
</n-modal>
</div> </div>
</template> </template>
@@ -204,6 +316,7 @@ import {
CloudUploadOutline, CloudUploadOutline,
DocumentTextOutline, DocumentTextOutline,
FolderOpenOutline, FolderOpenOutline,
InformationCircleOutline,
RefreshOutline, RefreshOutline,
TrashOutline TrashOutline
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
@@ -238,12 +351,16 @@ const validExtensions = new Set(['.zip', '.xlsx', '.xls', '.csv']);
const message = useMessage(); const message = useMessage();
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const folderInput = ref<HTMLInputElement | null>(null); const folderInput = ref<HTMLInputElement | null>(null);
const cellDataFolderInput = ref<HTMLInputElement | null>(null);
const files = ref<PickedFile[]>([]); const files = ref<PickedFile[]>([]);
const uploadProgress = ref(0); const uploadProgress = ref(0);
const working = ref(false); const working = ref(false);
const remoteStarting = ref(false); const remoteStarting = ref(false);
const cellDataStarting = ref(false); const cellDataStarting = ref(false);
const isDragging = ref(false); const isDragging = ref(false);
const isCellDataDragging = ref(false);
const capacityHelpVisible = ref(false);
const cellDataHelpVisible = ref(false);
const taskStatus = ref<TaskStatus | null>(null); const taskStatus = ref<TaskStatus | null>(null);
const activeTask = ref<ActiveTask | null>(null); const activeTask = ref<ActiveTask | null>(null);
const keepLatestLog = ref(false); const keepLatestLog = ref(false);
@@ -320,6 +437,10 @@ onMounted(() => {
if (folder) { if (folder) {
folder.webkitdirectory = true; folder.webkitdirectory = true;
} }
const cellDataFolder = cellDataFolderInput.value as (HTMLInputElement & { webkitdirectory?: boolean }) | null;
if (cellDataFolder) {
cellDataFolder.webkitdirectory = true;
}
void checkActiveTask(); void checkActiveTask();
}); });
@@ -367,6 +488,43 @@ async function onDrop(event: DragEvent) {
addFiles(dropped); addFiles(dropped);
} }
function onCellDataDragEnter() {
if (!cellDataStarting.value && !taskInProgress.value) {
isCellDataDragging.value = true;
}
}
function onCellDataDragOver() {
if (!cellDataStarting.value && !taskInProgress.value) {
isCellDataDragging.value = true;
}
}
function onCellDataDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement;
const related = event.relatedTarget as Node | null;
if (!related || !target.contains(related)) {
isCellDataDragging.value = false;
}
}
async function onCellDataDrop(event: DragEvent) {
isCellDataDragging.value = false;
if (cellDataStarting.value || taskInProgress.value || !event.dataTransfer) return;
if (!hasDroppedDirectory(event.dataTransfer)) {
message.warning('请拖入包含 Result_300 ZIP 的文件夹');
return;
}
await startCellDataUpload(await readDroppedFiles(event.dataTransfer));
}
async function pickCellDataFiles(event: Event) {
const input = event.target as HTMLInputElement;
const selected = Array.from(input.files || []).map(toDroppedFile);
input.value = '';
await startCellDataUpload(selected);
}
async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile[]> { async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile[]> {
const result: DroppedFile[] = []; const result: DroppedFile[] = [];
const items = Array.from(dataTransfer.items || []); const items = Array.from(dataTransfer.items || []);
@@ -386,6 +544,13 @@ async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile
return Array.from(dataTransfer.files || []).map(toDroppedFile); return Array.from(dataTransfer.files || []).map(toDroppedFile);
} }
function hasDroppedDirectory(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.items || []).some(item => {
if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return false;
return Boolean(item.webkitGetAsEntry()?.isDirectory);
});
}
async function traverseEntry(entry: FileSystemEntry, parentPath: string): Promise<DroppedFile[]> { async function traverseEntry(entry: FileSystemEntry, parentPath: string): Promise<DroppedFile[]> {
if (entry.isFile) { if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry; const fileEntry = entry as FileSystemFileEntry;
@@ -481,6 +646,11 @@ function isSupportedFile(path: string): boolean {
return validExtensions.has(path.slice(dotIndex).toLowerCase()); return validExtensions.has(path.slice(dotIndex).toLowerCase());
} }
function isCellDataZip(path: string): boolean {
const name = normalizePath(path).split('/').pop() || '';
return /^Result_300_.*\.zip$/i.test(name);
}
function normalizePath(path: string): string { function normalizePath(path: string): string {
return path.replace(/\\/g, '/'); return path.replace(/\\/g, '/');
} }
@@ -589,6 +759,46 @@ async function startCellDataProcessing() {
} }
} }
async function startCellDataUpload(items: DroppedFile[]) {
if (cellDataStarting.value || taskInProgress.value) return;
const accepted = items
.map(item => ({ ...item, path: normalizePath(item.path) }))
.filter(item => isCellDataZip(item.path));
if (accepted.length === 0) {
message.warning('请选择 Result_300 ZIP 文件');
return;
}
const formData = new FormData();
for (const item of accepted) {
formData.append('files', item.file, item.path);
}
cellDataStarting.value = true;
try {
const result = await upload<UploadResponse>('/api/cell-data/process/upload', formData);
message.success(result.message || `已上传 ${accepted.length} 个文件`);
activeTask.value = {
has_active: true,
task_id: result.task_id,
stage: result.stage || 'parsing',
started_at: new Date().toISOString()
};
taskStatus.value = {
task_id: result.task_id,
status: 'processing',
stage: result.stage || 'parsing',
logs: ['CellData 文件已上传,等待处理日志...']
};
taskMode.value = 'cell_data';
startPolling(result.task_id);
} catch (error) {
message.error(error instanceof Error ? error.message : 'CellData 上传处理失败');
} finally {
cellDataStarting.value = false;
}
}
async function startUploadedTask(taskId: string) { async function startUploadedTask(taskId: string) {
await apiPost('/api/process/start', { task_id: taskId }); await apiPost('/api/process/start', { task_id: taskId });
taskMode.value = 'local'; taskMode.value = 'local';
+69 -9
View File
@@ -497,6 +497,21 @@ select {
transition: all var(--td-transition); transition: all var(--td-transition);
} }
.upload-card-label {
position: absolute;
top: 12px;
left: 16px;
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.upload-help-button {
position: absolute;
top: 10px;
right: 10px;
}
.upload-zone:hover, .upload-zone:hover,
.upload-zone.dragover { .upload-zone.dragover {
background: var(--td-brand-color-light); background: var(--td-brand-color-light);
@@ -559,35 +574,80 @@ select {
display: inline-flex; display: inline-flex;
} }
.cell-data-card { .cell-data-upload-zone {
display: flex; display: flex;
position: relative;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 16px; flex-direction: column;
gap: 10px;
margin-top: 16px; margin-top: 16px;
padding: 16px 18px; padding: 24px 18px;
text-align: center;
background: var(--td-bg-color-container); background: var(--td-bg-color-container);
border: 1px solid var(--td-border-color-light); border: 1px dashed var(--td-border-color);
border-radius: var(--td-radius-large); border-radius: var(--td-radius-large);
cursor: pointer;
transition: all var(--td-transition);
} }
.cell-data-card h3, .cell-data-upload-zone:hover,
.cell-data-card p { .cell-data-upload-zone.dragover {
background: var(--td-brand-color-light);
border-color: var(--td-brand-color);
}
.cell-data-upload-zone.disabled {
cursor: not-allowed;
opacity: 0.72;
}
.cell-data-upload-text h3,
.cell-data-upload-text p {
margin: 0; margin: 0;
} }
.cell-data-card h3 { .cell-data-upload-text h3 {
color: var(--td-text-color-primary); color: var(--td-text-color-primary);
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
} }
.cell-data-card p { .cell-data-upload-text p {
margin-top: 4px; margin-top: 4px;
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
font-size: 13px; font-size: 13px;
} }
.cell-data-help-scroll {
max-height: 360px;
}
.cell-data-help-section {
display: grid;
gap: 6px;
padding-bottom: 14px;
}
.cell-data-help-section + .cell-data-help-section {
padding-top: 12px;
border-top: 1px solid var(--td-border-color-light);
}
.cell-data-help-section h4,
.cell-data-help-section p {
margin: 0;
}
.cell-data-help-section code {
display: block;
overflow-x: auto;
padding: 6px 8px;
color: var(--td-text-color-primary);
background: var(--td-bg-color-secondarycontainer);
border-radius: var(--td-radius-small);
}
.license-dialog-body { .license-dialog-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;