update at 2026-02-12 17:30:41

This commit is contained in:
douboer@gmail.com
2026-02-12 17:30:41 +08:00
parent 8ce67dae5e
commit b6804cc2f1
32 changed files with 5765 additions and 0 deletions

588
src/App.vue Normal file
View File

@@ -0,0 +1,588 @@
<template>
<div class="page">
<header class="top-bar">
<div class="brand">
<img :src="iconWebLogo" alt="webicon" class="logo" />
<img :src="iconTitle" alt="星程桑基图" class="title-logo" />
</div>
<div class="toolbar">
<div class="tool-item">
<span class="tool-label">选择主题</span>
<button class="icon-btn" type="button" @click="toggleThemePicker">
<img :src="iconChooseColor" alt="choose-color" />
</button>
</div>
<div class="tool-item">
<span class="tool-label">文件上传</span>
<button class="icon-btn tiny" type="button" @click="toggleUploadTip">
<img :src="iconContent" alt="内容说明" />
</button>
<button class="icon-btn" type="button" @click="openFileDialog">
<img :src="iconUpload" alt="upload" />
</button>
</div>
<label
class="upload-area"
@dragover.prevent
@drop.prevent="onDropFile"
@keydown.enter.prevent="openFileDialog"
@click="closeThemePicker"
>
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept=".csv,.xls,.xlsx"
@change="onFileChange"
/>
<span class="upload-text">{{ uploadMessage }}</span>
<span v-if="showUploadTip" class="upload-tip">
支持点击上传或拖拽 CSV/XLS/XLSX模板
<a href="/data/example.xlsx" download>xlsx</a>
/
<a href="/data/拉流切图节点与算法池信息.csv" download>csv</a>
</span>
</label>
<div class="export-box">
<img :src="iconExport" alt="export" class="export-main" />
<button class="icon-btn export-item" type="button" @click="exportSvg">
<img :src="iconExportSvg" alt="export-svg" />
</button>
<button class="icon-btn export-item" type="button" @click="exportPng">
<img :src="iconExportPng" alt="export-png" />
</button>
</div>
</div>
<div v-if="showThemePicker" class="theme-popover">
<div class="theme-header">选择配色主题</div>
<div class="theme-list">
<button
v-for="theme in themes"
:key="theme.id"
class="theme-row"
type="button"
@click="pickTheme(theme.id)"
>
<img :src="selectedThemeId === theme.id ? iconRadioOn : iconRadioOff" alt="主题选择" />
<div class="palette">
<span
v-for="color in theme.colors"
:key="`${theme.id}-${color}`"
class="palette-cell"
:style="{ backgroundColor: color }"
/>
</div>
</button>
</div>
</div>
</header>
<main class="content">
<section class="left-pane">
<article class="panel block-panel">
<h2>源数据</h2>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>数据列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`source-data-${index}`"
class="column-row"
>
<img :src="iconData" alt="数据列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="mapping.sourceDataColumn = index">
<img
:src="mapping.sourceDataColumn === index ? iconRadioOn : iconRadioOff"
alt="单选"
/>
</button>
</div>
</div>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>描述列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`source-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleSourceDescription(index)">
<img
:src="
mapping.sourceDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff
"
alt="复选"
/>
</button>
</div>
</div>
</article>
<article class="panel block-panel">
<h2>目标数据</h2>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>描述列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`target-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleTargetDescription(index)">
<img
:src="
mapping.targetDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff
"
alt="复选"
/>
</button>
</div>
</div>
</article>
</section>
<section class="panel preview-panel">
<div class="preview-head">
<h2>桑基图预览</h2>
<div class="preview-controls">
<label>
方向
<select v-model="direction">
<option value="source-to-target">source -&gt; target</option>
<option value="target-to-source">target -&gt; source</option>
</select>
</label>
<label>
间距
<input v-model.number="nodeGap" type="range" min="8" max="42" />
</label>
<label>
边距
<input v-model.number="chartPadding" type="range" min="10" max="70" />
</label>
</div>
</div>
<div v-if="buildError" class="error-text">{{ buildError }}</div>
<div v-if="parseError" class="error-text">{{ parseError }}</div>
<div class="example-line">示例{{ previewExample }}</div>
<div ref="chartRef" class="chart-area" />
<div v-if="buildWarnings.length > 0" class="warning-area">
<strong>解析告警 8 </strong>
<ul>
<li v-for="warning in buildWarnings" :key="warning">{{ warning }}</li>
</ul>
</div>
</section>
</main>
<footer class="footer">@版权说明星程社所有反馈邮箱douboer@gmail.com</footer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue';
import * as echarts from 'echarts/core';
import type { EChartsOption } from 'echarts';
import { SankeyChart } from 'echarts/charts';
import { TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import {
applyDirection,
buildSankeyData,
parseDataFile,
type MappingConfig,
type RawTable,
type SankeyBuildResult
} from './core';
import iconWebLogo from '../assets/icons/webicon.png';
import iconTitle from '../assets/icons/星程字体转换.svg';
import iconChooseColor from '../assets/icons/choose-color.svg';
import iconUpload from '../assets/icons/upload.svg';
import iconContent from '../assets/icons/content.svg';
import iconExport from '../assets/icons/export.svg';
import iconExportSvg from '../assets/icons/export-svg.svg';
import iconExportPng from '../assets/icons/export-png.svg';
import iconData from '../assets/icons/data.svg';
import iconDescription from '../assets/icons/description.svg';
import iconRadioOn from '../assets/icons/radiobutton.svg';
import iconRadioOff from '../assets/icons/radiobutton-no.svg';
import iconCheckboxOn from '../assets/icons/checkbox.svg';
import iconCheckboxOff from '../assets/icons/checkbox-no.svg';
import iconExpand from '../assets/icons/expand.svg';
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer]);
/**
* 主题色板列表。
* 颜色来自 Figma 示例,保证与设计稿观感一致。
*/
const themes = [
{
id: 'morandi',
colors: ['#F4F1DE', '#EAB69F', '#E07A5F', '#8F5D5D', '#3D405B', '#5F797B', '#81B29A', '#9EB998']
},
{
id: 'purple',
colors: ['#F72585', '#B5179E', '#7209B7', '#560BAD', '#480CA8', '#3A0CA3', '#3F37C9', '#4895EF', '#4CC9F0']
},
{
id: 'fog',
colors: ['#E8EDDF', '#CFDBD5', '#B7B7A4', '#A5A58D', '#6B705C', '#4F5D75', '#5D576B', '#6D597A']
},
{
id: 'sunset',
colors: ['#355070', '#515575', '#6D597A', '#915F78', '#B56576', '#CD6873', '#E56B6F', '#E88C7D', '#EAAC8B']
}
] as const;
const selectedThemeId = ref<(typeof themes)[number]['id']>('purple');
const showThemePicker = ref(false);
const showUploadTip = ref(false);
const uploadMessage = ref('点击上传或将csv/xls文件拖到这里上传');
const parseError = ref('');
const buildError = ref('');
const chartRef = ref<HTMLDivElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
let chartInstance: echarts.EChartsType | null = null;
const rawTable = ref<RawTable | null>(null);
const buildResult = ref<SankeyBuildResult | null>(null);
const mapping = reactive<MappingConfig>({
sourceDataColumn: 1,
sourceDescriptionColumns: [0, 1],
targetDescriptionColumns: [2],
delimiter: '-'
});
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(24);
const chartPadding = ref(30);
const selectedTheme = computed(() => themes.find((item) => item.id === selectedThemeId.value) ?? themes[0]);
const columnHeaders = computed(() => {
const headers = rawTable.value?.headers ?? [];
if (headers.length > 0) {
return headers.map((item, index) => item || `${index + 1}`);
}
return ['列1', '列2', '列3', '列4'];
});
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
const previewExample = computed(() => {
const table = rawTable.value;
if (!table || table.rows.length === 0) {
return '宁波北欧10-2582 -> 嘉兴四级算力池-11623-小模型';
}
const firstRow = table.rows[0];
const sourceParts =
mapping.sourceDescriptionColumns.length > 0
? mapping.sourceDescriptionColumns.map((column) => firstRow[column] ?? '').filter(Boolean)
: [firstRow[mapping.sourceDataColumn ?? 0] ?? ''];
const targetParts = mapping.targetDescriptionColumns
.map((column) => firstRow[column] ?? '')
.filter(Boolean);
return `${sourceParts.join(mapping.delimiter)} -> ${targetParts.join(mapping.delimiter)}`;
});
const chartNodes = computed(() => {
const links = buildResult.value ? applyDirection(buildResult.value.links, direction.value) : [];
const names = new Set<string>();
links.forEach((link) => {
names.add(link.source);
names.add(link.target);
});
const palette = selectedTheme.value.colors;
return Array.from(names).map((name, index) => ({
name,
itemStyle: {
color: palette[index % palette.length]
}
}));
});
const chartLinks = computed(() => {
if (!buildResult.value) {
return [];
}
return applyDirection(buildResult.value.links, direction.value);
});
const chartOption = computed<EChartsOption>(() => {
return {
backgroundColor: '#f7f8fa',
tooltip: {
trigger: 'item'
},
series: [
{
type: 'sankey',
left: chartPadding.value,
top: chartPadding.value,
right: chartPadding.value,
bottom: chartPadding.value,
nodeAlign: 'justify',
nodeGap: nodeGap.value,
nodeWidth: 14,
roam: true,
label: {
color: '#4e5969',
fontSize: 12
},
lineStyle: {
color: 'source',
curveness: 0.45,
opacity: 0.45
},
data: chartNodes.value,
links: chartLinks.value
}
]
};
});
/**
* 每次映射配置变化都实时重新聚合,保持“输入即预览”的交互。
*/
watchEffect(() => {
const table = rawTable.value;
if (!table) {
buildResult.value = null;
buildError.value = '';
return;
}
if (mapping.sourceDataColumn === null) {
buildResult.value = null;
buildError.value = '请选择源数据列';
return;
}
if (mapping.targetDescriptionColumns.length === 0) {
buildResult.value = null;
buildError.value = '请至少选择一个目标描述列';
return;
}
try {
buildResult.value = buildSankeyData(table, {
sourceDataColumn: mapping.sourceDataColumn,
sourceDescriptionColumns: [...mapping.sourceDescriptionColumns],
targetDescriptionColumns: [...mapping.targetDescriptionColumns],
delimiter: mapping.delimiter
});
buildError.value = '';
} catch (error) {
buildResult.value = null;
buildError.value = error instanceof Error ? error.message : '构建桑基图失败';
}
});
watch(
chartOption,
() => {
if (!chartInstance) {
return;
}
chartInstance.setOption(chartOption.value, true);
},
{ deep: true }
);
function syncChartSize(): void {
chartInstance?.resize();
}
function closeThemePicker(): void {
showThemePicker.value = false;
}
function toggleThemePicker(): void {
showThemePicker.value = !showThemePicker.value;
}
function pickTheme(themeId: (typeof themes)[number]['id']): void {
selectedThemeId.value = themeId;
showThemePicker.value = false;
}
function toggleUploadTip(): void {
showUploadTip.value = !showUploadTip.value;
}
function toggleSourceDescription(column: number): void {
if (mapping.sourceDescriptionColumns.includes(column)) {
mapping.sourceDescriptionColumns = mapping.sourceDescriptionColumns.filter((item) => item !== column);
return;
}
mapping.sourceDescriptionColumns = [...mapping.sourceDescriptionColumns, column].sort((a, b) => a - b);
}
function toggleTargetDescription(column: number): void {
if (mapping.targetDescriptionColumns.includes(column)) {
mapping.targetDescriptionColumns = mapping.targetDescriptionColumns.filter((item) => item !== column);
return;
}
mapping.targetDescriptionColumns = [...mapping.targetDescriptionColumns, column].sort((a, b) => a - b);
}
function openFileDialog(): void {
fileInputRef.value?.click();
}
function setDefaultMappingByColumns(columnSize: number): void {
const safeSize = Math.max(columnSize, 1);
const safeColumn = (index: number): number => Math.min(index, safeSize - 1);
mapping.sourceDataColumn = safeColumn(1);
mapping.sourceDescriptionColumns = [safeColumn(0), safeColumn(1)].filter(
(item, index, list) => list.indexOf(item) === index
);
mapping.targetDescriptionColumns = [safeColumn(2), safeColumn(3)].filter(
(item, index, list) => list.indexOf(item) === index
);
}
/**
* 统一处理上传文件,支持点击上传和拖拽上传两种入口。
*/
async function loadDataFile(file: File): Promise<void> {
parseError.value = '';
try {
const parsed = await parseDataFile(file);
rawTable.value = parsed;
setDefaultMappingByColumns(parsed.headers.length);
uploadMessage.value = `已加载: ${file.name}${parsed.rows.length} 行)`;
} catch (error) {
parseError.value = error instanceof Error ? error.message : '文件解析失败';
}
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
await loadDataFile(file);
input.value = '';
}
async function onDropFile(event: DragEvent): Promise<void> {
const file = event.dataTransfer?.files?.[0];
if (!file) {
return;
}
await loadDataFile(file);
}
function formatFileTimestamp(): string {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
now.getHours()
)}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
function downloadByDataUrl(dataUrl: string, filename: string): void {
const anchor = document.createElement('a');
anchor.href = dataUrl;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
function exportSvg(): void {
if (!chartInstance) {
return;
}
const svgEl = chartRef.value?.querySelector('svg');
if (svgEl) {
const serialized = new XMLSerializer().serializeToString(svgEl);
const blob = new Blob([serialized], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
downloadByDataUrl(url, `sankey_${formatFileTimestamp()}.svg`);
URL.revokeObjectURL(url);
return;
}
const dataUrl = chartInstance.getDataURL({
type: 'svg',
backgroundColor: '#ffffff',
pixelRatio: 2
});
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
}
function exportPng(): void {
if (!chartInstance) {
return;
}
const dataUrl = chartInstance.getDataURL({
type: 'png',
backgroundColor: '#ffffff',
pixelRatio: 2
});
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.png`);
}
onMounted(() => {
const container = chartRef.value;
if (!container) {
return;
}
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
chartInstance.setOption(chartOption.value);
window.addEventListener('resize', syncChartSize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', syncChartSize);
chartInstance?.dispose();
chartInstance = null;
});
</script>

3
src/core/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './types';
export * from './parser';
export * from './sankey';

96
src/core/parser.ts Normal file
View File

@@ -0,0 +1,96 @@
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import type { RawTable } from './types';
/**
* 将任意单元格值转换为字符串,统一处理 null/undefined 场景。
*/
function normalizeCell(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
/**
* 将二维数组标准化为 RawTable。
* 约定第一行为表头,后续为数据行。
*/
function toRawTable(rows: unknown[][]): RawTable {
if (rows.length === 0) {
return { headers: [], rows: [] };
}
const firstRow = rows[0] ?? [];
const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), firstRow.length);
const headers = Array.from({ length: maxColumns }, (_, index) => {
const header = normalizeCell(firstRow[index]);
return header || `${index + 1}`;
});
const dataRows = rows.slice(1).map((row) => {
return Array.from({ length: maxColumns }, (_, index) => normalizeCell(row[index]));
});
return {
headers,
rows: dataRows
};
}
/**
* 解析 CSV 文本为统一表结构。
*/
export function parseCsvText(csvText: string): RawTable {
const parsed = Papa.parse<string[]>(csvText, {
skipEmptyLines: false
});
if (parsed.errors.length > 0) {
const firstError = parsed.errors[0];
throw new Error(`CSV 解析失败: ${firstError.message}`);
}
const rows = parsed.data.map((row: string[]) => row ?? []);
return toRawTable(rows);
}
/**
* 解析 xlsx 的二进制数据。
*/
export function parseXlsxBuffer(buffer: ArrayBuffer): RawTable {
const workbook = XLSX.read(buffer, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
throw new Error('Excel 文件中没有工作表');
}
const sheet = workbook.Sheets[firstSheetName];
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
raw: false,
defval: ''
});
return toRawTable(rows);
}
/**
* 根据文件后缀自动判断并解析文件。
*/
export async function parseDataFile(file: File): Promise<RawTable> {
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith('.csv')) {
const text = await file.text();
return parseCsvText(text);
}
if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
const buffer = await file.arrayBuffer();
return parseXlsxBuffer(buffer);
}
throw new Error('仅支持 .csv / .xlsx / .xls 文件');
}

168
src/core/sankey.ts Normal file
View File

@@ -0,0 +1,168 @@
import type {
DirectionMode,
MappingConfig,
RawTable,
SankeyBuildResult,
SankeyLink,
SankeyNode
} from './types';
/**
* 统一清洗字符串,避免因为前后空格导致节点重复。
*/
function normalizeText(value: string): string {
return value.trim();
}
/**
* 将字符串解析为数字,支持千分位(例如 12,000
*/
function parseNumericValue(text: string): number | null {
const normalized = text.replace(/,/g, '').trim();
if (!normalized) {
return null;
}
const parsed = Number(normalized);
if (Number.isNaN(parsed)) {
return null;
}
return parsed;
}
/**
* 按照配置生成 source 名称。
* 若未选择描述列,则回退为数据列文本。
*/
function buildSourceName(row: string[], config: MappingConfig): string {
const sourceDataValue = config.sourceDataColumn === null ? '' : row[config.sourceDataColumn] ?? '';
if (config.sourceDescriptionColumns.length === 0) {
return normalizeText(sourceDataValue);
}
const parts = config.sourceDescriptionColumns
.map((column) => normalizeText(row[column] ?? ''))
.filter((item) => item.length > 0);
return parts.join(config.delimiter);
}
/**
* 生成 target 名称,并实现“合并单元格向下补全”的语义。
*/
function buildTargetName(
row: string[],
config: MappingConfig,
lastNonEmptyTargetValueByColumn: Map<number, string>
): string {
const parts = config.targetDescriptionColumns
.map((column) => {
const raw = normalizeText(row[column] ?? '');
if (raw.length > 0) {
lastNonEmptyTargetValueByColumn.set(column, raw);
return raw;
}
return lastNonEmptyTargetValueByColumn.get(column) ?? '';
})
.filter((item) => item.length > 0);
return parts.join(config.delimiter);
}
/**
* 将映射配置应用到表格数据,输出桑基图节点和连线。
*/
export function buildSankeyData(table: RawTable, config: MappingConfig): SankeyBuildResult {
if (config.sourceDataColumn === null) {
throw new Error('必须选择源数据列');
}
if (config.targetDescriptionColumns.length === 0) {
throw new Error('必须至少选择一个目标描述列');
}
const linkValueMap = new Map<string, number>();
const warnings: string[] = [];
let droppedRows = 0;
const lastNonEmptyTargetValueByColumn = new Map<number, string>();
table.rows.forEach((row, rowIndex) => {
const excelRow = rowIndex + 2;
const sourceRaw = normalizeText(row[config.sourceDataColumn as number] ?? '');
const sourceValue = parseNumericValue(sourceRaw);
if (sourceValue === null) {
warnings.push(`${excelRow} 行: 源数据不是有效数字,已跳过`);
droppedRows += 1;
return;
}
const sourceName = buildSourceName(row, config);
if (!sourceName) {
warnings.push(`${excelRow} 行: 源描述为空,已跳过`);
droppedRows += 1;
return;
}
const targetName = buildTargetName(row, config, lastNonEmptyTargetValueByColumn);
if (!targetName) {
warnings.push(`${excelRow} 行: 目标描述为空,已跳过`);
droppedRows += 1;
return;
}
const key = `${sourceName}@@${targetName}`;
const prev = linkValueMap.get(key) ?? 0;
linkValueMap.set(key, prev + sourceValue);
});
const links: SankeyLink[] = [];
const sourceSet = new Set<string>();
const targetSet = new Set<string>();
linkValueMap.forEach((value, key) => {
const [source, target] = key.split('@@');
if (!source || !target) {
return;
}
sourceSet.add(source);
targetSet.add(target);
links.push({ source, target, value });
});
const nodes: SankeyNode[] = [
...Array.from(sourceSet).map((name) => ({ name, kind: 'source' as const })),
...Array.from(targetSet)
.filter((name) => !sourceSet.has(name))
.map((name) => ({ name, kind: 'target' as const }))
];
return {
nodes,
links,
meta: {
droppedRows,
warnings
}
};
}
/**
* 用于方向切换:仅交换连线方向,不改动原始聚合结果。
*/
export function applyDirection(links: SankeyLink[], direction: DirectionMode): SankeyLink[] {
if (direction === 'source-to-target') {
return links;
}
return links.map((link) => ({
source: link.target,
target: link.source,
value: link.value
}));
}

49
src/core/types.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 统一后的表格结构。
* headers 表示首行列名rows 表示去掉首行后每一行的字符串值。
*/
export interface RawTable {
headers: string[];
rows: string[][];
}
/**
* 用户在界面上配置的列映射规则。
*/
export interface MappingConfig {
sourceDataColumn: number | null;
sourceDescriptionColumns: number[];
targetDescriptionColumns: number[];
delimiter: string;
}
/**
* 渲染桑基图所需的节点。
*/
export interface SankeyNode {
name: string;
kind: 'source' | 'target';
}
/**
* 渲染桑基图所需的边。
*/
export interface SankeyLink {
source: string;
target: string;
value: number;
}
/**
* 聚合后的业务输出,包括告警信息。
*/
export interface SankeyBuildResult {
nodes: SankeyNode[];
links: SankeyLink[];
meta: {
droppedRows: number;
warnings: string[];
};
}
export type DirectionMode = 'source-to-target' | 'target-to-source';

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#app');

450
src/styles.css Normal file
View File

@@ -0,0 +1,450 @@
:root {
--primary-7: #8552a1;
--primary-6: #9b6bc2;
--fill-1: #f7f8fa;
--fill-3: #e5e6eb;
--fill-4: #c9cdd4;
--text-1: #ffffff;
--text-3: #86909c;
--text-4: #4e5969;
--danger-3: #fbaca3;
--font-pingfang: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
background: #f3f4f6;
color: #1d2129;
font-family: var(--font-pingfang);
}
.page {
position: relative;
width: 100%;
min-height: 100vh;
padding: 16px 16px 10px;
}
.top-bar {
position: relative;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 12px;
padding: 0 8px;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.logo {
width: 64px;
height: 64px;
border-radius: 16px;
}
.title-logo {
width: 174px;
height: auto;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
justify-content: flex-end;
}
.tool-item {
display: flex;
align-items: center;
gap: 4px;
}
.tool-label {
color: #1d2129;
font-size: 14px;
white-space: nowrap;
}
.icon-btn {
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.icon-btn.tiny {
width: 14px;
height: 14px;
}
.upload-area {
min-width: 280px;
max-width: 420px;
flex: 1;
min-height: 38px;
border-radius: 6px;
display: flex;
align-items: center;
padding: 4px 10px;
background: var(--fill-1);
cursor: pointer;
position: relative;
}
.hidden-input {
display: none;
}
.upload-text {
color: var(--text-4);
font-size: 12px;
}
.upload-tip {
position: absolute;
left: 0;
top: 44px;
z-index: 5;
width: 100%;
border: 1px solid var(--primary-7);
background: #fff;
border-radius: 8px;
padding: 8px;
color: var(--text-4);
font-size: 12px;
}
.upload-tip a {
color: var(--primary-7);
text-decoration: none;
font-weight: 500;
}
.export-box {
display: flex;
gap: 6px;
align-items: center;
background: #fff;
border: 1px solid var(--fill-3);
border-radius: 8px;
padding: 2px 8px;
}
.export-main {
width: 18px;
height: 34px;
}
.export-item {
width: 40px;
height: 40px;
}
.theme-popover {
position: absolute;
top: 80px;
right: 290px;
width: 280px;
border: 1px solid var(--primary-7);
border-radius: 24px 24px 0 0;
background: #fff;
z-index: 10;
padding: 8px;
}
.theme-header {
text-align: center;
color: var(--primary-6);
font-size: 14px;
margin-bottom: 8px;
}
.theme-list {
max-height: 180px;
overflow: auto;
}
.theme-row {
width: 100%;
border: 0;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
cursor: pointer;
}
.theme-row img {
width: 18px;
height: 18px;
}
.palette {
flex: 1;
display: grid;
grid-template-columns: repeat(9, 1fr);
height: 20px;
}
.palette-cell {
display: block;
}
.content {
display: grid;
grid-template-columns: 360px 1fr;
gap: 8px;
min-height: calc(100vh - 140px);
}
.left-pane {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel {
border: 1px solid #f7dede;
border-radius: 16px;
background: #fff;
}
.block-panel {
padding: 8px;
}
.block-panel h2,
.preview-head h2 {
margin: 0;
font-size: 24px;
font-weight: 400;
}
.field-block {
margin-top: 12px;
}
.field-title-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.field-title-wrap h3 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.expand-icon {
width: 16px;
height: 16px;
}
.column-row {
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--fill-4);
height: 32px;
padding-bottom: 6px;
}
.column-icon {
width: 18px;
height: 18px;
}
.column-label {
flex: 1;
color: var(--text-3);
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-btn {
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
}
.select-btn img {
width: 20px;
height: 20px;
}
.preview-panel {
padding: 8px;
display: flex;
flex-direction: column;
}
.preview-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 6px;
}
.preview-controls {
display: flex;
align-items: center;
gap: 8px;
}
.preview-controls label {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-4);
font-size: 12px;
}
.preview-controls select,
.preview-controls input {
accent-color: var(--primary-7);
}
.example-line {
margin: 4px 0 8px;
color: var(--text-4);
font-size: 12px;
}
.chart-area {
background: var(--fill-1);
border-radius: 8px;
min-height: 480px;
flex: 1;
}
.warning-area {
margin-top: 8px;
font-size: 12px;
color: var(--text-4);
}
.warning-area ul {
margin: 6px 0 0;
padding-left: 18px;
}
.error-text {
color: #cb272d;
font-size: 12px;
margin-bottom: 6px;
}
.footer {
margin-top: 6px;
color: var(--text-3);
font-size: 16px;
}
@media (max-width: 1024px) {
.top-bar {
flex-direction: column;
align-items: stretch;
}
.toolbar {
justify-content: flex-start;
flex-wrap: wrap;
}
.theme-popover {
right: 12px;
top: 120px;
}
.content {
grid-template-columns: 1fr;
}
.left-pane {
flex-direction: row;
flex-wrap: wrap;
}
.block-panel {
flex: 1;
min-width: 280px;
}
.chart-area {
min-height: 360px;
}
.footer {
font-size: 14px;
}
}
@media (max-width: 640px) {
.page {
padding: 8px;
}
.logo {
width: 48px;
height: 48px;
border-radius: 12px;
}
.title-logo {
width: 160px;
}
.tool-label {
font-size: 12px;
}
.upload-area {
min-width: 220px;
}
.block-panel h2,
.preview-head h2 {
font-size: 20px;
}
.field-title-wrap h3 {
font-size: 16px;
}
}

6
src/types/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}