Commit 91d91703 authored by Data Governance Dev's avatar Data Governance Dev

feat(web2): IdCardTool UI — 折叠结果面板 + 弃用 SSE 改轮询 + done 分支兜底

- 弃用 SSE(EventSource)
  - 原方案 push 大 payload + Vue 响应式触发 1000+ 行 el-table 重渲,主线程卡 2s+ 导致页面「无响应」
  - 改为 setInterval(1000ms) 轮询 + 立即首次触发
- 删除大进度卡片(3 指标 + el-progress)
  - 改为单行扫描提示:Loading 图标 + 「已发现 X 张问题表 / Y 行违规(Zs)」
- 结果区改为 el-collapse 折叠面板(每张表一个 collapse-item)
- 新增 violationsByTable + liveBadRowCount 两个 ref(模板读这两个,不直接读 scanResult)
- polling 累加 + done 兜底:
  - running 阶段 partial 来的表 push 到 violationsByTable(dedupe by renderedTableNames Set)
  - done 分支兜底:若 violationsByTable 为空,从 data.response.violations_by_table 同步灌入
    (解决扫描太快<1s 时 running partial 没机会被观察到的问题)
- 错误处理:
  - 404 = scan_id 过期(10 分钟 TTL),友好提示
  - 网络瞬断不停轮询,下次自动恢复
parent f2291cf0
......@@ -86,108 +86,83 @@
</el-form>
</el-card>
<!-- 扫描进度条 -->
<el-card v-if="scanning" class="idcard-page__progress" shadow="never">
<div class="idcard-page__progress-head">
<el-icon class="idcard-page__progress-icon"><Loading /></el-icon>
<span class="idcard-page__progress-title">正在扫描身份证合规性</span>
<span class="idcard-page__progress-stat">
{{ progress.done }} / {{ progress.total || '?' }}
</span>
<span class="idcard-page__progress-elapsed">({{ (progress.elapsed_ms / 1000).toFixed(1) }}s)</span>
</div>
<el-progress
:percentage="progressPercent"
:stroke-width="14"
:text-inside="true"
:status="progress.status === 'error' ? 'exception' : ''"
/>
<div class="idcard-page__progress-current">
当前:<code>{{ progress.current_column || '初始化 …' }}</code>
</div>
</el-card>
<!-- 扫描结果 -->
<el-card v-if="scanResult" class="idcard-page__result" shadow="never">
<!-- 扫描中状态行(极简:每张表完成后会立刻追加到下方结果区) -->
<div v-if="scanning" class="idcard-page__scanning-tip">
<el-icon class="idcard-page__scanning-icon"><Loading /></el-icon>
<span>
正在扫描 · 已发现
<strong>{{ violationsByTable.length }}</strong> 张问题表 /
<strong>{{ liveBadRowCount.toLocaleString() }}</strong> 行违规
</span>
<span class="idcard-page__scanning-elapsed">
({{ ((elapsedMsRef) / 1000).toFixed(1) }}s)
</span>
</div>
<!-- 扫描结果(轮询追加:每张表完成后立刻渲染,可折叠) -->
<el-card v-if="scanResult || violationsByTable.length" class="idcard-page__result" shadow="never">
<!-- 概览统计 -->
<div class="idcard-page__overview">
<div class="idcard-page__stat">
<div class="idcard-page__stat-label">数据库</div>
<div class="idcard-page__stat-value">{{ scanResult.overview.database }}</div>
<div class="idcard-page__stat-value">{{ scanResult?.overview?.database || '—' }}</div>
</div>
<div class="idcard-page__stat">
<div class="idcard-page__stat-label">命中列</div>
<div class="idcard-page__stat-value">
{{ scanResult.overview.matched_columns }}
<span class="idcard-page__stat-sub">/ {{ scanResult.overview.total_columns_in_db }}</span>
</div>
<div class="idcard-page__stat-label">匹配列</div>
<div class="idcard-page__stat-value">{{ scanResult?.overview?.matched_columns ?? '—' }}</div>
</div>
<div class="idcard-page__stat">
<div class="idcard-page__stat-label">违规列</div>
<div class="idcard-page__stat-value idcard-page__stat-value--warn">
{{ scanResult.overview.columns_with_violations }}
{{ scanResult?.overview?.columns_with_violations ?? '—' }}
</div>
</div>
<div class="idcard-page__stat">
<div class="idcard-page__stat-label">问题表</div>
<div class="idcard-page__stat-value idcard-page__stat-value--warn">
{{ violationsByTable.length }}
</div>
</div>
<div class="idcard-page__stat">
<div class="idcard-page__stat-label">问题行</div>
<div class="idcard-page__stat-value idcard-page__stat-value--danger">
{{ scanResult.overview.total_bad_rows }}
{{ liveBadRowCount.toLocaleString() }}
</div>
</div>
<div class="idcard-page__stat">
<div class="idcard-page__stat-label">耗时</div>
<div class="idcard-page__stat" v-if="scanResult?.overview">
<div class="idcard-page__stat-label">{{ scanning ? '耗时(实时)' : '总耗时' }}</div>
<div class="idcard-page__stat-value">{{ scanResult.overview.elapsed_ms }} ms</div>
</div>
</div>
<!-- 命中列清单 -->
<template v-if="scanResult.matches.length">
<h4 class="idcard-page__section-title">命中的列({{ scanResult.matches.length }})</h4>
<el-table :data="scanResult.matches" size="small" border stripe>
<el-table-column prop="table_name" label="表名" min-width="180" />
<el-table-column prop="column_name" label="列名" min-width="160" />
<el-table-column prop="column_comment" label="列注释" min-width="160" show-overflow-tooltip />
<el-table-column prop="data_type" label="类型" min-width="100" />
<el-table-column prop="distinct_count" label="distinct" width="90" align="right" />
<el-table-column prop="invalid_count" label="违规数" width="90" align="right">
<template #default="{ row }">
<el-tag v-if="row.invalid_count > 0" type="danger" size="small">
{{ row.invalid_count }}
</el-tag>
<span v-else class="idcard-page__ok">0</span>
</template>
</el-table-column>
<el-table-column label="违反规则" min-width="220">
<template #default="{ row }">
<el-tag
v-for="(cnt, rule) in row.violations"
:key="rule"
size="small"
type="warning"
effect="plain"
style="margin-right: 4px"
>
{{ ruleLabels[rule] || rule }} ×{{ cnt }}
</el-tag>
<span v-if="!row.invalid_count" class="idcard-page__ok">无违规</span>
</template>
</el-table-column>
</el-table>
</template>
<!-- 问题表折叠列表 -->
<template v-if="violationsByTable.length">
<div class="idcard-page__section-head">
<h4 class="idcard-page__section-title">
问题表({{ violationsByTable.length }})
<span class="idcard-page__section-sub">· {{ liveBadRowCount.toLocaleString() }} 行违规</span>
</h4>
<div class="idcard-page__section-actions">
<el-button text size="small" @click="expandAll">
<el-icon><Plus /></el-icon>
展开全部
</el-button>
<el-button text size="small" @click="collapseAll">
<el-icon><Minus /></el-icon>
折叠全部
</el-button>
</div>
</div>
<!-- 有问题的表(折叠) -->
<template v-if="scanResult.violations_by_table.length">
<h4 class="idcard-page__section-title">
有问题的表({{ scanResult.violations_by_table.length }})
</h4>
<el-collapse v-model="activeTableNames" class="idcard-page__collapse">
<el-collapse v-model="expandedTables" class="idcard-page__collapse">
<el-collapse-item
v-for="t in scanResult.violations_by_table"
v-for="t in violationsByTable"
:key="t.table_name"
:name="t.table_name"
class="idcard-page__collapse-item"
>
<template #title>
<div class="idcard-page__table-title">
<div class="idcard-page__collapse-head">
<el-icon class="idcard-page__table-icon"><Grid /></el-icon>
<span class="idcard-page__table-name">{{ t.table_name }}</span>
<el-tag
......@@ -211,34 +186,32 @@
>
{{ ruleLabels[r] || r }}
</el-tag>
<el-tag
v-for="idCol in idColumnsOf(t)"
:key="`idcol-${idCol}`"
size="small"
type="primary"
effect="plain"
class="idcard-page__table-idcol"
>
身份证列:{{ idCol }}
</el-tag>
</div>
</template>
<el-table :data="t.bad_rows" size="small" border stripe max-height="400">
<el-table-column
v-for="col in t.columns"
:key="col.column_name"
:prop="col.column_name"
:label="`${col.column_name}${col.column_comment ? ' · ' + col.column_comment : ''}`"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
<span :class="{ 'idcard-page__bad': isIdCardColumn(t, col) && !isValidValue(row[col.column_name]) }">
{{ formatCell(row[col.column_name]) }}
</span>
</template>
</el-table-column>
</el-table>
<div v-if="t.bad_row_count >= 5000" class="idcard-page__trunc">
⚠ 仅展示前 5000 行(防爆上限)
</div>
<IdCardTable
:columns="t.columns"
:column-order="buildColumnOrder(t)"
:rows="t.bad_rows"
:id-card-column="idColumnsOf(t)[0] || ''"
:truncate-at="5000"
/>
</el-collapse-item>
</el-collapse>
</template>
<el-empty
v-else-if="scanResult.matches.length"
v-else-if="!scanning"
description="所有命中列均无违规 ✓"
/>
</el-card>
......@@ -259,12 +232,15 @@ import {
Warning,
Grid,
Loading,
Plus,
Minus,
} from '@element-plus/icons-vue'
import {
DEFAULT_NAME_KEYWORDS,
DEFAULT_COMMENT_KEYWORDS,
} from '@/config/idCardKeywords'
import { useDbConnection } from '@/stores/useDbConnection'
import IdCardTable from '@/components/IdCardTable.vue'
const { state, currentConnection } = useDbConnection()
......@@ -285,24 +261,20 @@ const query = reactive({
commentKw: DEFAULT_COMMENT_KEYWORDS.join(', '),
})
const querying = ref(false)
/* ──── 扫描结果(响应式:流式追加) ──── */
// scanResult 是顶层包装,violations_by_table 用 ref 数组以便 push
const scanResult = ref(null)
const activeTableNames = ref([])
const violationsByTable = ref([]) // 流式 push 进来
const expandedTables = ref([]) // 当前展开的表名(el-collapse v-model)
const liveBadRowCount = ref(0) // 已发现违规行总数(实时累加)
/* ──── 扫描进度(多线程版) ──── */
/* ──── 扫描状态 ──── */
const scanning = ref(false)
const scanId = ref(null)
const progress = reactive({
done: 0,
total: 0,
status: 'pending', // pending | running | done | error
current_column: null,
elapsed_ms: 0,
error: null,
})
const progressPercent = computed(() => {
if (!progress.total) return 0
return Math.min(100, Math.round((progress.done / progress.total) * 100))
})
const elapsedMsRef = ref(0) // 客户端自维护的「已耗时」(轮询响应里也有,仅做显示兜底)
const polledTablesCount = ref(0) // 服务端报告的「已完成表数」
const polledTablesTotal = ref(0) // 服务端报告的「预计总表数」
let pollTimer = null
function stopPolling() {
......@@ -323,51 +295,70 @@ function parseKeywords(text) {
]
}
/** 展开 / 折叠全部 */
function expandAll() {
expandedTables.value = violationsByTable.value.map((t) => t.table_name)
}
function collapseAll() {
expandedTables.value = []
}
function onReset() {
query.nameKw = DEFAULT_NAME_KEYWORDS.join(', ')
query.commentKw = DEFAULT_COMMENT_KEYWORDS.join(', ')
scanResult.value = null
activeTableNames.value = []
// 取消可能还在跑的后台扫描(前端只是放弃显示,后端继续跑完)
// 停轮询 + 清空结果(后端 scan 继续在后台跑完,前端不再显示)
stopPolling()
scanResult.value = null
violationsByTable.value = []
expandedTables.value = []
liveBadRowCount.value = 0
elapsedMsRef.value = 0
polledTablesCount.value = 0
polledTablesTotal.value = 0
scanning.value = false
querying.value = false
scanId.value = null
}
/**
* 判断一张表的某列是不是本次扫描的「身份证列」(命中列之一)。
* 用于在折叠表格里高亮问题单元格。
* 这张表的「身份证列」有哪些。
*
* 数据来源:扫描结果里的 matches 数组(每条 = {table_name, column_name, ...}),
* 用 table_name 反查属于这张表的命中列。一张表可能命中多个身份证列(少见)。
*/
const matchedIdCardCols = ref(new Set())
function isIdCardColumn(t, col) {
return matchedIdCardCols.value.has(`${t.table_name}.${col.column_name}`)
function idColumnsOf(t) {
const set = new Set()
for (const m of (scanResult.value?.matches || [])) {
if (m.table_name === t.table_name) set.add(m.column_name)
}
return [...set]
}
/**
* 判断单元格值是否合法(粗略,用于单元格红字样式)。
* 真值校验在后端,前端只做颜色提示。
* 给一张问题表构造「显示列顺序」:
* - 身份证列放第一位(按命中顺序,多个身份证列也都在前面)
* - 其余列按 t.columns 的原始顺序(ordinal_position)
*
* 数据列不一定都出现(bad_rows 里某些列可能是 NULL 没拉回)—— 顺序只是
* 「用户想看到的顺序」,实际渲染时 IdCardTable 会再过滤一遍。
*/
function isValidValue(v) {
if (v === null || v === undefined || v === '') return false
const s = String(v).trim()
return s.length === 18 && /^\d{17}[0-9X]$/.test(s)
}
function formatCell(v) {
if (v === null || v === undefined) return '∅'
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
function buildColumnOrder(t) {
const idCols = idColumnsOf(t)
const allNames = (t.columns || []).map((c) => c.column_name)
const idSet = new Set(idCols)
const others = allNames.filter((n) => !idSet.has(n))
return [...idCols, ...others]
}
/**
* 查询:调 /api/id-card/scan(异步版)
* 提交扫描 → 拿到 scan_id → 每秒轮询 /partial 拉取已完成的违规表
* 流程:
* 1. POST → 立即拿到 scan_id(不阻塞)
* 2. setInterval 轮询 /api/id-card/scan/{scan_id}/progress 拿进度(更新 el-progress)
* 3. progress.status=done → 拉 /api/id-card/scan/{scan_id}/result 拿完整结果 → 渲染
* 4. progress.status=error → 弹错误 → 停轮询
* 5. 任何 fetch 失败 → 停轮询 + 弹错误
* 1. POST /api/id-card/scan → scan_id
* 2. setInterval(1000ms) GET /api/id-card/scan/{scan_id}/partial
* - status=done → 停轮询 + 拿完整 response + 成功提示
* - status=error → 停轮询 + 错误提示
* - status=running→ 把 response.violations_by_table 中新出现的表 push 到 UI
* 3. 前端去重:按 table_name 跳过已经渲染过的表,避免表格重新挂载闪烁
*/
async function onQuery() {
const names = parseKeywords(query.nameKw)
......@@ -386,10 +377,13 @@ async function onQuery() {
querying.value = true
scanning.value = true
scanResult.value = null
Object.assign(progress, {
done: 0, total: 0, status: 'pending',
current_column: null, elapsed_ms: 0, error: null,
})
violationsByTable.value = []
expandedTables.value = []
liveBadRowCount.value = 0
elapsedMsRef.value = 0
polledTablesCount.value = 0
polledTablesTotal.value = 0
renderedTableNames.value = new Set()
// 1. POST 启动扫描
let started
......@@ -435,87 +429,105 @@ async function onQuery() {
const startResp = await started.json()
scanId.value = startResp.scan_id
// 2. 开始轮询(每 800ms 一次)
pollTimer = setInterval(pollProgress, 800)
pollProgress() // 立即跑一次(不等 800ms)
// 2. 开始每秒轮询
startPolling(startResp.scan_id)
}
const renderedTableNames = ref(new Set())
function startPolling(sid) {
stopPolling()
// 立即跑一次(不等 1000ms)
pollOnce(sid)
pollTimer = setInterval(() => pollOnce(sid), 1000)
}
async function pollProgress() {
if (!scanId.value) return
async function pollOnce(sid) {
if (!sid || !scanning.value) return
try {
const r = await fetch(`/api/id-card/scan/${scanId.value}/progress`)
const r = await fetch(`/api/id-card/scan/${sid}/partial`)
if (!r.ok) {
// 404 = scan_id 已过期(10 分钟 TTL);停轮询并提示
// 404 = scan_id 已过期(10 分钟 TTL)
stopPolling()
scanning.value = false
querying.value = false
if (r.status === 404) {
ElMessage.warning('扫描任务已过期(10 分钟后自动清理),请重新查询')
} else {
ElMessage.error(`拉取进度失败(HTTP ${r.status})`)
ElMessage.error(`拉取结果失败(HTTP ${r.status})`)
}
return
}
const p = await r.json()
Object.assign(progress, p)
const data = await r.json()
if (p.status === 'done') {
stopPolling()
await fetchResult()
} else if (p.status === 'error') {
// 更新状态
if (typeof data.tables_done === 'number') polledTablesCount.value = data.tables_done
if (typeof data.tables_total === 'number') polledTablesTotal.value = data.tables_total
if (typeof data.elapsed_ms === 'number') elapsedMsRef.value = data.elapsed_ms
if (data.status === 'done') {
// 扫描完成 → 完整结果在 data.response
stopPolling()
const finalResp = data.response
scanResult.value = finalResp
// ★ 兜底:把最终结果里的 violations_by_table 同步到 violationsByTable + liveBadRowCount。
// 当数据库扫描很快(几百毫秒结束)时,running 阶段的 partial 可能一次都拿不到表,
// 只到 done 这一拍才有完整 violations_by_table —— 必须在这里灌进去,否则折叠列表为空。
// 仍走 dedupe 逻辑,避免 running 阶段已 push 的表重复。
const finalTables = finalResp?.violations_by_table || []
if (finalTables.length && violationsByTable.value.length === 0) {
for (const t of finalTables) {
if (!renderedTableNames.value.has(t.table_name)) {
renderedTableNames.value.add(t.table_name)
violationsByTable.value.push(t)
liveBadRowCount.value += (t.bad_row_count || 0)
}
}
}
scanning.value = false
querying.value = false
ElMessageBox.alert(p.error || '未知错误', '❌ 扫描失败', { type: 'error' })
ElMessage.success(
`扫描完成:问题表 ${violationsByTable.value.length} 张,违规行 ${liveBadRowCount.value.toLocaleString()} 条(${(elapsedMsRef.value / 1000).toFixed(1)}s)`,
)
return
}
// status=pending/running 继续轮询
} catch (e) {
// 网络瞬断:不停轮询(下次会自动好),只提示一次
console.warn('[scan] 拉取进度失败:', e.message)
}
}
async function fetchResult() {
if (!scanId.value) return
try {
const r = await fetch(`/api/id-card/scan/${scanId.value}/result`)
if (!r.ok) {
if (data.status === 'error') {
stopPolling()
scanning.value = false
querying.value = false
let detail
try { detail = await r.json() } catch { detail = { detail: `HTTP ${r.status}` } }
ElMessageBox.alert(
detail.detail || JSON.stringify(detail),
`❌ 拉取结果失败(HTTP ${r.status})`,
{ type: 'error' },
)
ElMessageBox.alert(data.error || '未知错误', '❌ 扫描失败', { type: 'error' })
return
}
const data = await r.json()
// 后端 ScanResultResponse 包装:{ scan_id, status, response: IdCardScanResponse }
const result = data.response
scanResult.value = result
// 记录命中列集合(用于单元格红字高亮)
matchedIdCardCols.value = new Set(
(result.matches || []).map((m) => `${m.table_name}.${m.column_name}`),
)
// 默认展开第一个问题表
if (result.violations_by_table?.length) {
activeTableNames.value = [result.violations_by_table[0].table_name]
}
ElMessage.success(
`扫描完成:匹配 ${result.overview.matched_columns} 列,`
+ `违规 ${result.overview.columns_with_violations} 列,`
+ `问题行 ${result.overview.total_bad_rows} 条(${result.overview.elapsed_ms}ms)`,
)
// status=running/pending:把新增的 violations_by_table push 到 UI
const newTables = data.violations_by_table || []
for (const t of newTables) {
if (!renderedTableNames.value.has(t.table_name)) {
renderedTableNames.value.add(t.table_name)
violationsByTable.value.push(t)
liveBadRowCount.value += (t.bad_row_count || 0)
}
}
// 初始化 scanResult 壳(让概览区出现)
if (!scanResult.value && violationsByTable.value.length > 0) {
scanResult.value = {
overview: {
database: '',
matched_columns: 0,
columns_with_violations: 0,
total_bad_rows: 0,
elapsed_ms: 0,
},
matches: [],
violations_by_table: violationsByTable.value,
}
}
} catch (e) {
ElMessage.error(`拉取结果失败: ${e.message}`)
} finally {
scanning.value = false
querying.value = false
// 网络瞬断:不停轮询(下次会自动好),只 console.warn
console.warn('[poll] 拉取结果失败:', e.message)
}
}
......@@ -608,20 +620,26 @@ const rules = [
margin-bottom: 12px;
}
/* 扫描进度条 */
.idcard-page__progress {
margin-bottom: 12px;
}
.idcard-page__progress-head {
/* 扫描中状态行(极简一行) */
.idcard-page__scanning-tip {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
padding: 10px 14px;
margin-bottom: 12px;
background: #f5f7fa;
border-left: 3px solid #409eff;
border-radius: 4px;
font-size: 13px;
color: #606266;
}
.idcard-page__scanning-tip strong {
color: #303133;
font-weight: 600;
margin: 0 2px;
}
.idcard-page__progress-icon {
font-size: 16px;
.idcard-page__scanning-icon {
font-size: 14px;
color: #409eff;
animation: idcard-spin 1s linear infinite;
}
......@@ -629,32 +647,13 @@ const rules = [
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.idcard-page__progress-title {
font-weight: 600;
}
.idcard-page__progress-stat {
.idcard-page__scanning-elapsed {
margin-left: auto;
font-weight: 600;
color: #409eff;
font-variant-numeric: tabular-nums;
}
.idcard-page__progress-elapsed {
color: #909399;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.idcard-page__progress-current {
margin-top: 8px;
font-size: 12px;
color: #606266;
}
.idcard-page__progress-current code {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
color: #303133;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.idcard-page__form {
display: flex;
flex-wrap: wrap;
......@@ -778,18 +777,41 @@ const rules = [
font-weight: 600;
color: #303133;
}
.idcard-page__section-sub {
font-size: 13px;
font-weight: normal;
color: #909399;
margin-left: 4px;
}
.idcard-page__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 16px 0 4px;
}
.idcard-page__section-actions {
display: flex;
gap: 4px;
}
/* 折叠表 */
/* 问题表列表(折叠) */
.idcard-page__collapse {
border: 1px solid #ebeef5;
border-radius: 4px;
}
.idcard-page__table-title {
.idcard-page__collapse-item {
border-bottom: 1px solid #ebeef5;
}
.idcard-page__collapse-item:last-child {
border-bottom: none;
}
.idcard-page__collapse-head {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
flex-wrap: wrap;
font-size: 13px;
width: 100%;
}
.idcard-page__table-icon {
color: #409eff;
......@@ -797,9 +819,26 @@ const rules = [
.idcard-page__table-name {
font-weight: 600;
color: #303133;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.idcard-page__table-comment {
margin-right: auto;
color: #606266;
}
.idcard-page__table-idcol {
margin-left: 4px;
}
/* 折叠态时,整行可点;展开时表格 padding */
.idcard-page__collapse-item :deep(.el-collapse-item__header) {
padding: 8px 12px;
background: #fafbfc;
font-size: 13px;
}
.idcard-page__collapse-item :deep(.el-collapse-item__content) {
padding: 8px 12px 16px;
}
.idcard-page__collapse-item :deep(.el-collapse-item__header):hover {
background: #f0f4f9;
}
.idcard-page__ok {
......@@ -809,12 +848,4 @@ const rules = [
color: #f56c6c;
font-weight: 600;
}
.idcard-page__trunc {
margin-top: 8px;
padding: 6px 10px;
font-size: 12px;
color: #e6a23c;
background: #fdf6ec;
border-radius: 4px;
}
</style>
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment