update at 2026-02-13 15:02:51

This commit is contained in:
douboer@gmail.com
2026-02-13 15:02:51 +08:00
parent 36befbb63f
commit bb3a64b624
3 changed files with 478 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
[更新时间] 2026-02-13 [更新时间] 2026-02-13(第二次更新)
[项目] 星程桑基图 [项目] 星程桑基图
一、已完成Done 一、已完成Done
@@ -14,6 +14,8 @@
6. 已实现默认样例加载:页面首次进入自动读取 `data/example0.xlsx`。 6. 已实现默认样例加载:页面首次进入自动读取 `data/example0.xlsx`。
7. 已有核心单测parser + sankey 聚合 + xlsx 读取)。 7. 已有核心单测parser + sankey 聚合 + xlsx 读取)。
8. 小程序端已完成视觉骨架(非完整业务)。 8. 小程序端已完成视觉骨架(非完整业务)。
9. 已实现本地持久化:用户上传文件、映射配置与预览选项会写入 localStorage刷新后自动恢复。
10. 已新增“汇聚对齐”配置Between/Middle/Top/Bottom可控制 target 侧对齐,且 gap 作为源侧基准。
二、当前状态In Progress 二、当前状态In Progress
1. 无进行中的代码重构任务。 1. 无进行中的代码重构任务。
@@ -26,7 +28,7 @@
- IMPLEMENTATION_PLAN.md - IMPLEMENTATION_PLAN.md
三、已知问题 / 风险Known Issues 三、已知问题 / 风险Known Issues
1. 当前无后端、无持久化,刷新页面后状态丢失(符合当前范围) 1. 本地持久化基于 localStorage受浏览器容量限制超大文件可能无法完整保存
2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。 2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。
3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。 3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。
4. 小程序仅骨架,尚未接入真实解析、渲染与导出。 4. 小程序仅骨架,尚未接入真实解析、渲染与导出。

View File

@@ -260,6 +260,18 @@
</option> </option>
</select> </select>
</label> </label>
<label class="target-align-control">
<span class="target-align-label">汇聚对齐</span>
<select v-model="targetAlignMode" class="target-align-select">
<option
v-for="option in targetAlignOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</div> </div>
</div> </div>
@@ -301,7 +313,7 @@ import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
import { import {
applyDirection, applyDirection,
buildSankeyData, buildSankeyData,
parseDataFile, parseCsvText,
parseXlsxBuffer, parseXlsxBuffer,
type MappingConfig, type MappingConfig,
type RawTable, type RawTable,
@@ -328,6 +340,34 @@ import iconPadding from '../assets/icons/padding.svg';
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]); echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
const WORKSPACE_STORAGE_KEY = 'sankey-workspace-v1';
const WORKSPACE_STORAGE_VERSION = 1;
type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
type TargetAlignMode = 'between' | 'middle' | 'top' | 'bottom';
interface PersistedUploadedFile {
name: string;
type: string;
base64: string;
}
interface PersistedWorkspace {
version: number;
uploadedFile?: PersistedUploadedFile;
mapping?: MappingConfig;
direction?: 'source-to-target' | 'target-to-source';
nodeGap?: number;
chartPadding?: number;
selectedThemeId?: string;
labelPositionMode?: LabelPositionMode;
targetAlignMode?: TargetAlignMode;
sectionVisible?: Partial<{
sourceData: boolean;
sourceDesc: boolean;
targetDesc: boolean;
}>;
}
const themes = THEME_PRESETS; const themes = THEME_PRESETS;
const selectedThemeId = ref<string>('figma-violet'); const selectedThemeId = ref<string>('figma-violet');
const showThemePicker = ref(false); const showThemePicker = ref(false);
@@ -336,6 +376,7 @@ const parseError = ref('');
const buildError = ref(''); const buildError = ref('');
const chartRef = ref<HTMLDivElement | null>(null); const chartRef = ref<HTMLDivElement | null>(null);
const chartClientHeight = ref(0);
const fileInputRef = ref<HTMLInputElement | null>(null); const fileInputRef = ref<HTMLInputElement | null>(null);
const themeWheelRef = ref<HTMLDivElement | null>(null); const themeWheelRef = ref<HTMLDivElement | null>(null);
const themeTriggerRef = ref<HTMLDivElement | null>(null); const themeTriggerRef = ref<HTMLDivElement | null>(null);
@@ -348,6 +389,8 @@ const THEME_ROW_HEIGHT = 42;
const rawTable = ref<RawTable | null>(null); const rawTable = ref<RawTable | null>(null);
const buildResult = ref<SankeyBuildResult | null>(null); const buildResult = ref<SankeyBuildResult | null>(null);
const uploadedFileSnapshot = ref<PersistedUploadedFile | null>(null);
const isRestoringWorkspace = ref(false);
const mapping = reactive<MappingConfig>({ const mapping = reactive<MappingConfig>({
sourceDataColumn: 2, sourceDataColumn: 2,
@@ -359,7 +402,6 @@ const mapping = reactive<MappingConfig>({
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target'); const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(5); const nodeGap = ref(5);
const chartPadding = ref(24); const chartPadding = ref(24);
type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
const labelPositionMode = ref<LabelPositionMode>('inner'); const labelPositionMode = ref<LabelPositionMode>('inner');
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [ const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
{ value: 'inner', label: '向内' }, { value: 'inner', label: '向内' },
@@ -367,6 +409,13 @@ const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> =
{ value: 'left', label: '向左' }, { value: 'left', label: '向左' },
{ value: 'right', label: '向右' } { value: 'right', label: '向右' }
]; ];
const targetAlignMode = ref<TargetAlignMode>('between');
const targetAlignOptions: Array<{ value: TargetAlignMode; label: string }> = [
{ value: 'between', label: 'Between' },
{ value: 'middle', label: 'Middle' },
{ value: 'top', label: 'Top' },
{ value: 'bottom', label: 'Bottom' }
];
/** /**
* 左侧字段区块的展开/折叠状态。 * 左侧字段区块的展开/折叠状态。
* true 表示展开false 表示折叠。 * true 表示展开false 表示折叠。
@@ -426,11 +475,103 @@ function resolveEdgeLabelPosition(
return isLeftEdge ? 'left' : 'right'; return isLeftEdge ? 'left' : 'right';
} }
/**
* 计算 target 侧节点 localY0~1
* - gap 滑块只作为源侧基准
* - target 侧通过“汇聚对齐”决定布局
*/
function buildTargetLocalYMap(
links: Array<{ source: string; target: string; value: number }>
): Map<string, number> {
const map = new Map<string, number>();
const layoutHeight = Math.max(0, chartClientHeight.value - chartPadding.value * 2);
if (layoutHeight <= 0 || links.length === 0) {
return map;
}
const incomingCountMap = new Map<string, number>();
const outgoingCountMap = new Map<string, number>();
const sourceValueMap = new Map<string, number>();
const targetValueMap = new Map<string, number>();
const appearanceOrder: string[] = [];
const seen = new Set<string>();
links.forEach((link) => {
outgoingCountMap.set(link.source, (outgoingCountMap.get(link.source) ?? 0) + 1);
incomingCountMap.set(link.target, (incomingCountMap.get(link.target) ?? 0) + 1);
sourceValueMap.set(link.source, (sourceValueMap.get(link.source) ?? 0) + link.value);
targetValueMap.set(link.target, (targetValueMap.get(link.target) ?? 0) + link.value);
if (!seen.has(link.source)) {
seen.add(link.source);
appearanceOrder.push(link.source);
}
if (!seen.has(link.target)) {
seen.add(link.target);
appearanceOrder.push(link.target);
}
});
const sourceNames = appearanceOrder.filter((name) => {
const incoming = incomingCountMap.get(name) ?? 0;
const outgoing = outgoingCountMap.get(name) ?? 0;
return incoming === 0 && outgoing > 0;
});
const targetNames = appearanceOrder.filter((name) => {
const incoming = incomingCountMap.get(name) ?? 0;
const outgoing = outgoingCountMap.get(name) ?? 0;
return outgoing === 0 && incoming > 0;
});
if (sourceNames.length === 0 || targetNames.length === 0) {
return map;
}
const totalValue = sourceNames.reduce((sum, name) => sum + (sourceValueMap.get(name) ?? 0), 0);
if (totalValue <= 0) {
return map;
}
const sourceGap = Math.max(0, nodeGap.value);
const sourceGapCount = Math.max(0, sourceNames.length - 1);
const sourceContentHeight = Math.max(0, layoutHeight - sourceGap * sourceGapCount);
const unitHeight = totalValue > 0 ? sourceContentHeight / totalValue : 0;
const totalNodeHeight = totalValue * unitHeight;
const sourceSpanHeight = totalNodeHeight + sourceGap * sourceGapCount;
let targetGap = sourceGap;
if (targetAlignMode.value === 'between' && targetNames.length > 1) {
targetGap = (sourceSpanHeight - totalNodeHeight) / (targetNames.length - 1);
}
targetGap = Math.max(0, targetGap);
const targetSpanHeight = totalNodeHeight + targetGap * Math.max(0, targetNames.length - 1);
let targetStartY = 0;
if (targetAlignMode.value === 'middle') {
targetStartY = (sourceSpanHeight - targetSpanHeight) / 2;
} else if (targetAlignMode.value === 'bottom') {
targetStartY = sourceSpanHeight - targetSpanHeight;
}
targetStartY = Math.max(0, targetStartY);
let cursorY = targetStartY;
targetNames.forEach((name, index) => {
map.set(name, cursorY / layoutHeight);
cursorY += (targetValueMap.get(name) ?? 0) * unitHeight;
if (index < targetNames.length - 1) {
cursorY += targetGap;
}
});
return map;
}
const chartNodes = computed(() => { const chartNodes = computed(() => {
const links = chartLinks.value; const links = chartLinks.value;
const names = new Set<string>(); const names = new Set<string>();
const incomingCountMap = new Map<string, number>(); const incomingCountMap = new Map<string, number>();
const outgoingCountMap = new Map<string, number>(); const outgoingCountMap = new Map<string, number>();
const targetLocalYMap = buildTargetLocalYMap(links);
links.forEach((link) => { links.forEach((link) => {
names.add(link.source); names.add(link.source);
@@ -450,6 +591,7 @@ const chartNodes = computed(() => {
itemStyle: { itemStyle: {
color: palette[index % palette.length] color: palette[index % palette.length]
}, },
localY: targetLocalYMap.get(name),
label: position label: position
? { ? {
position position
@@ -548,7 +690,34 @@ watch(
{ deep: true } { deep: true }
); );
watch(
() => [
mapping.sourceDataColumn,
mapping.sourceDescriptionColumns.join(','),
mapping.targetDescriptionColumns.join(','),
mapping.delimiter,
direction.value,
nodeGap.value,
chartPadding.value,
selectedThemeId.value,
labelPositionMode.value,
targetAlignMode.value,
sectionVisible.sourceData,
sectionVisible.sourceDesc,
sectionVisible.targetDesc,
uploadedFileSnapshot.value?.name ?? '',
uploadedFileSnapshot.value?.base64.length ?? 0
],
() => {
if (isRestoringWorkspace.value) {
return;
}
persistWorkspace();
}
);
function syncChartSize(): void { function syncChartSize(): void {
chartClientHeight.value = chartRef.value?.clientHeight ?? chartClientHeight.value;
chartInstance?.resize(); chartInstance?.resize();
} }
@@ -788,6 +957,262 @@ function setDefaultMappingByHeaders(headers: string[]): void {
); );
} }
/**
* 将映射配置整体应用到响应式对象,避免散落赋值导致状态不一致。
*/
function applyMappingConfig(next: MappingConfig): void {
mapping.sourceDataColumn = next.sourceDataColumn;
mapping.sourceDescriptionColumns = [...next.sourceDescriptionColumns];
mapping.targetDescriptionColumns = [...next.targetDescriptionColumns];
mapping.delimiter = next.delimiter;
}
/**
* 根据文件名解析上传内容,统一返回 RawTable。
*/
function parseTableByFileName(fileName: string, buffer: ArrayBuffer): RawTable {
const lowerName = fileName.toLowerCase();
if (lowerName.endsWith('.csv')) {
const csvText = new TextDecoder().decode(buffer);
return parseCsvText(csvText);
}
if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
return parseXlsxBuffer(buffer);
}
throw new Error('仅支持 .csv / .xlsx / .xls 文件');
}
/**
* 将 ArrayBuffer 编码为 Base64便于写入 localStorage。
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let index = 0; index < bytes.length; index += chunkSize) {
const chunk = bytes.subarray(index, index + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
/**
* 将 Base64 解码回 ArrayBuffer。
*/
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
/**
* 将上传文件快照化,写入持久层用于刷新后恢复。
*/
function createUploadedFileSnapshot(
fileName: string,
fileType: string,
fileBuffer: ArrayBuffer
): PersistedUploadedFile {
return {
name: fileName,
type: fileType,
base64: arrayBufferToBase64(fileBuffer)
};
}
/**
* 对持久层中的 mapping 做安全化处理,避免列越界导致恢复失败。
*/
function sanitizePersistedMapping(
persistedMapping: MappingConfig | undefined,
columnSize: number
): MappingConfig {
const safeSize = Math.max(columnSize, 1);
const safeColumn = (index: number): number => Math.min(Math.max(index, 0), safeSize - 1);
const normalizeColumns = (columns: unknown): number[] => {
if (!Array.isArray(columns)) {
return [];
}
return columns
.filter((item): item is number => Number.isInteger(item))
.map((item) => safeColumn(item))
.filter((item, index, list) => list.indexOf(item) === index)
.sort((a, b) => a - b);
};
const defaultMapping: MappingConfig = {
sourceDataColumn: safeColumn(2),
sourceDescriptionColumns: [safeColumn(0)].filter(
(item, index, list) => list.indexOf(item) === index
),
targetDescriptionColumns: [safeColumn(2)].filter(
(item, index, list) => list.indexOf(item) === index
),
delimiter: '-'
};
if (!persistedMapping) {
return defaultMapping;
}
const sourceDataColumn =
typeof persistedMapping.sourceDataColumn === 'number' && Number.isInteger(persistedMapping.sourceDataColumn)
? safeColumn(persistedMapping.sourceDataColumn)
: defaultMapping.sourceDataColumn;
const sourceDescriptionColumns = normalizeColumns(persistedMapping.sourceDescriptionColumns);
const targetDescriptionColumns = normalizeColumns(persistedMapping.targetDescriptionColumns);
return {
sourceDataColumn,
sourceDescriptionColumns,
targetDescriptionColumns:
targetDescriptionColumns.length > 0 ? targetDescriptionColumns : defaultMapping.targetDescriptionColumns,
delimiter:
typeof persistedMapping.delimiter === 'string' && persistedMapping.delimiter.length > 0
? persistedMapping.delimiter
: '-'
};
}
/**
* 保存当前工作区(文件 + 映射 + 控件状态),用于刷新后恢复。
*/
function persistWorkspace(): void {
if (typeof window === 'undefined') {
return;
}
const workspace: PersistedWorkspace = {
version: WORKSPACE_STORAGE_VERSION,
uploadedFile: uploadedFileSnapshot.value ?? undefined,
mapping: {
sourceDataColumn: mapping.sourceDataColumn,
sourceDescriptionColumns: [...mapping.sourceDescriptionColumns],
targetDescriptionColumns: [...mapping.targetDescriptionColumns],
delimiter: mapping.delimiter
},
direction: direction.value,
nodeGap: nodeGap.value,
chartPadding: chartPadding.value,
selectedThemeId: selectedThemeId.value,
labelPositionMode: labelPositionMode.value,
targetAlignMode: targetAlignMode.value,
sectionVisible: {
sourceData: sectionVisible.sourceData,
sourceDesc: sectionVisible.sourceDesc,
targetDesc: sectionVisible.targetDesc
}
};
try {
window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify(workspace));
} catch (error) {
// localStorage 可能因为容量上限失败,记录日志便于排查,但不阻断主流程。
console.warn('工作区保存失败:', error);
}
}
/**
* 从持久层恢复工作区。
* 返回值表示是否恢复了“用户上传文件”。
*/
async function restoreWorkspaceFromStorage(): Promise<{
restoredUploadedFile: boolean;
restoredMapping?: MappingConfig;
}> {
if (typeof window === 'undefined') {
return { restoredUploadedFile: false };
}
const rawWorkspace = window.localStorage.getItem(WORKSPACE_STORAGE_KEY);
if (!rawWorkspace) {
return { restoredUploadedFile: false };
}
isRestoringWorkspace.value = true;
try {
const workspace = JSON.parse(rawWorkspace) as PersistedWorkspace;
if (workspace.version !== WORKSPACE_STORAGE_VERSION) {
return { restoredUploadedFile: false };
}
if (workspace.direction === 'source-to-target' || workspace.direction === 'target-to-source') {
direction.value = workspace.direction;
}
if (typeof workspace.nodeGap === 'number' && Number.isFinite(workspace.nodeGap)) {
nodeGap.value = Math.max(0, Math.min(30, workspace.nodeGap));
}
if (typeof workspace.chartPadding === 'number' && Number.isFinite(workspace.chartPadding)) {
chartPadding.value = Math.max(0, Math.min(80, workspace.chartPadding));
}
if (typeof workspace.selectedThemeId === 'string' && themes.some((item) => item.id === workspace.selectedThemeId)) {
selectedThemeId.value = workspace.selectedThemeId;
}
if (
workspace.labelPositionMode === 'inner' ||
workspace.labelPositionMode === 'outer' ||
workspace.labelPositionMode === 'left' ||
workspace.labelPositionMode === 'right'
) {
labelPositionMode.value = workspace.labelPositionMode;
}
if (
workspace.targetAlignMode === 'between' ||
workspace.targetAlignMode === 'middle' ||
workspace.targetAlignMode === 'top' ||
workspace.targetAlignMode === 'bottom'
) {
targetAlignMode.value = workspace.targetAlignMode;
}
if (workspace.sectionVisible) {
if (typeof workspace.sectionVisible.sourceData === 'boolean') {
sectionVisible.sourceData = workspace.sectionVisible.sourceData;
}
if (typeof workspace.sectionVisible.sourceDesc === 'boolean') {
sectionVisible.sourceDesc = workspace.sectionVisible.sourceDesc;
}
if (typeof workspace.sectionVisible.targetDesc === 'boolean') {
sectionVisible.targetDesc = workspace.sectionVisible.targetDesc;
}
}
if (!workspace.uploadedFile) {
return { restoredUploadedFile: false, restoredMapping: workspace.mapping };
}
const fileBuffer = base64ToArrayBuffer(workspace.uploadedFile.base64);
const parsed = parseTableByFileName(workspace.uploadedFile.name, fileBuffer);
rawTable.value = parsed;
uploadedFileSnapshot.value = workspace.uploadedFile;
applyMappingConfig(sanitizePersistedMapping(workspace.mapping, parsed.headers.length));
uploadMessage.value = `已恢复: ${workspace.uploadedFile.name}${parsed.rows.length} 行)`;
parseError.value = '';
return { restoredUploadedFile: true };
} catch (error) {
window.localStorage.removeItem(WORKSPACE_STORAGE_KEY);
console.warn('工作区恢复失败,已清理坏数据:', error);
return { restoredUploadedFile: false };
} finally {
isRestoringWorkspace.value = false;
}
}
/** /**
* 统一处理上传文件,支持点击上传和拖拽上传两种入口。 * 统一处理上传文件,支持点击上传和拖拽上传两种入口。
*/ */
@@ -795,10 +1220,13 @@ async function loadDataFile(file: File): Promise<void> {
parseError.value = ''; parseError.value = '';
try { try {
const parsed = await parseDataFile(file); const fileBuffer = await file.arrayBuffer();
const parsed = parseTableByFileName(file.name, fileBuffer);
rawTable.value = parsed; rawTable.value = parsed;
setDefaultMappingByHeaders(parsed.headers); setDefaultMappingByHeaders(parsed.headers);
uploadedFileSnapshot.value = createUploadedFileSnapshot(file.name, file.type, fileBuffer);
uploadMessage.value = `已加载: ${file.name}${parsed.rows.length} 行)`; uploadMessage.value = `已加载: ${file.name}${parsed.rows.length} 行)`;
persistWorkspace();
} catch (error) { } catch (error) {
parseError.value = error instanceof Error ? error.message : '文件解析失败'; parseError.value = error instanceof Error ? error.message : '文件解析失败';
} }
@@ -842,6 +1270,7 @@ async function loadDefaultExampleFile(): Promise<void> {
const parsed = parseXlsxBuffer(buffer); const parsed = parseXlsxBuffer(buffer);
rawTable.value = parsed; rawTable.value = parsed;
setDefaultMappingByHeaders(parsed.headers); setDefaultMappingByHeaders(parsed.headers);
uploadedFileSnapshot.value = null;
uploadMessage.value = `已加载: example0.xlsx${parsed.rows.length} 行)`; uploadMessage.value = `已加载: example0.xlsx${parsed.rows.length} 行)`;
parseError.value = ''; parseError.value = '';
} catch (error) { } catch (error) {
@@ -920,8 +1349,19 @@ onMounted(() => {
} }
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' }); chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
chartClientHeight.value = container.clientHeight;
chartInstance.setOption(chartOption.value); chartInstance.setOption(chartOption.value);
void loadDefaultExampleFile(); void (async () => {
const { restoredUploadedFile, restoredMapping } = await restoreWorkspaceFromStorage();
if (restoredUploadedFile) {
return;
}
await loadDefaultExampleFile();
if (restoredMapping && rawTable.value) {
applyMappingConfig(sanitizePersistedMapping(restoredMapping, rawTable.value.headers.length));
}
})();
window.addEventListener('resize', syncChartSize); window.addEventListener('resize', syncChartSize);
window.addEventListener('resize', updateThemePopoverPosition); window.addEventListener('resize', updateThemePopoverPosition);
window.addEventListener('scroll', updateThemePopoverPosition, true); window.addEventListener('scroll', updateThemePopoverPosition, true);

View File

@@ -609,6 +609,32 @@ body {
line-height: 22px; line-height: 22px;
} }
.target-align-control {
display: inline-flex;
align-items: center;
gap: 6px;
}
.target-align-label {
font-size: 14px;
font-weight: 600;
color: #000;
line-height: 1;
white-space: nowrap;
}
.target-align-select {
min-width: 88px;
height: 24px;
border: 1px solid #c9aee0;
border-radius: 2px;
background: #fff;
color: #1d2129;
font-size: 14px;
padding: 0 20px 0 6px;
line-height: 22px;
}
.example-line { .example-line {
margin: 4px 0 8px; margin: 4px 0 8px;
color: var(--text-4); color: var(--text-4);
@@ -694,6 +720,10 @@ body {
margin-left: 0; margin-left: 0;
} }
.target-align-control {
margin-left: 0;
}
.footer { .footer {
font-size: 14px; font-size: 14px;
} }