1. Excel交互组件封装
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
This commit is contained in:
81
apps/web-antd/src/api/spreadsheet-formula.ts
Normal file
81
apps/web-antd/src/api/spreadsheet-formula.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'spreadsheet-formula/';
|
||||
|
||||
export interface SpreadsheetFormulaRefSlot {
|
||||
slot: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SpreadsheetFormulaItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
expression_template: string;
|
||||
ref_slots: SpreadsheetFormulaRefSlot[];
|
||||
sort: number;
|
||||
status: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export async function listSpreadsheetFormula() {
|
||||
return requestClient.get<SpreadsheetFormulaItem[]>(`${prefix}list`);
|
||||
}
|
||||
|
||||
export async function createSpreadsheetFormula(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
expression_template: string;
|
||||
ref_slots: SpreadsheetFormulaRefSlot[];
|
||||
sort?: number;
|
||||
status?: boolean;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetFormulaItem>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateSpreadsheetFormula(data: {
|
||||
id: number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
expression_template?: string;
|
||||
ref_slots?: SpreadsheetFormulaRefSlot[];
|
||||
sort?: number;
|
||||
status?: boolean;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetFormulaItem>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteSpreadsheetFormula(id: number) {
|
||||
return requestClient.post<boolean>(`${prefix}delete`, { id });
|
||||
}
|
||||
|
||||
/** Replace REF0/REF1 with Excel-style column letters + row 1 */
|
||||
export function buildFormulaFromTemplate(
|
||||
template: string,
|
||||
refColumns: number[],
|
||||
): string {
|
||||
let result = template;
|
||||
refColumns.forEach((colIndex, i) => {
|
||||
const letters = colIndexToLetters(colIndex);
|
||||
const re = new RegExp(`REF${i}`, 'g');
|
||||
result = result.replace(re, `${letters}1`);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function colIndexToLetters(index: number): string {
|
||||
let n = index + 1;
|
||||
let result = '';
|
||||
while (n > 0) {
|
||||
n -= 1;
|
||||
result = String.fromCharCode(65 + (n % 26)) + result;
|
||||
n = Math.floor(n / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export { colIndexToLetters };
|
||||
38
apps/web-antd/src/api/spreadsheet-history.ts
Normal file
38
apps/web-antd/src/api/spreadsheet-history.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import type { SpreadsheetHistoryEntry } from '#/components/canvas-spreadsheet/types';
|
||||
|
||||
const prefix = 'spreadsheet-history/';
|
||||
|
||||
export async function saveSpreadsheetHistoryBatch(
|
||||
entries: SpreadsheetHistoryEntry[],
|
||||
) {
|
||||
return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, {
|
||||
entries: entries.map((e) => ({
|
||||
client_entry_id: e.id,
|
||||
table_name: e.tableName,
|
||||
sheet_key: e.sheetKey,
|
||||
row_key: String(e.rowKey),
|
||||
field_id: e.fieldId,
|
||||
action: e.action,
|
||||
before_snapshot: e.beforeSnapshot,
|
||||
after_snapshot: e.afterSnapshot,
|
||||
created_at: e.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSpreadsheetHistory(params: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
return requestClient.get<{ items: any[]; total: number }>(`${prefix}list`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function rollbackSpreadsheetHistory(id: number) {
|
||||
return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id });
|
||||
}
|
||||
55
apps/web-antd/src/api/spreadsheet-table-config.ts
Normal file
55
apps/web-antd/src/api/spreadsheet-table-config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import type { SpreadsheetConfigJson } from '#/views/business/warehouse-drug-management/admin/config/spreadsheetDefaults';
|
||||
|
||||
const prefix = 'spreadsheet-table-config/';
|
||||
|
||||
export interface SpreadsheetTableConfigItem {
|
||||
id: number;
|
||||
admin_id: number;
|
||||
table_name: string;
|
||||
sheet_key: string;
|
||||
name: string;
|
||||
is_default: number;
|
||||
config: SpreadsheetConfigJson;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export async function listSpreadsheetTableConfig(params: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
}) {
|
||||
return requestClient.get<SpreadsheetTableConfigItem[]>(`${prefix}list`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSpreadsheetTableConfigDetail(id: number) {
|
||||
return requestClient.get<SpreadsheetTableConfigItem>(`${prefix}detail`, {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSpreadsheetTableConfig(data: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
name: string;
|
||||
is_default?: boolean;
|
||||
config: SpreadsheetConfigJson;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetTableConfigItem>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateSpreadsheetTableConfig(data: {
|
||||
id: number;
|
||||
name?: string;
|
||||
is_default?: boolean;
|
||||
config?: SpreadsheetConfigJson;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetTableConfigItem>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteSpreadsheetTableConfig(id: number) {
|
||||
return requestClient.post<boolean>(`${prefix}delete`, { id });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,841 @@
|
||||
import type { SpreadsheetTheme } from './theme';
|
||||
import { getSpreadsheetTheme, resolveHeaderColor } from './theme';
|
||||
import type {
|
||||
CellRange,
|
||||
CellType,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetTextAlign,
|
||||
} from '../types';
|
||||
import {
|
||||
COLUMN_LETTER_HEIGHT,
|
||||
DEFAULT_COL_WIDTH,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
HEADER_HEIGHT,
|
||||
HEADER_TITLE_HEIGHT,
|
||||
ROW_NUMBER_WIDTH,
|
||||
SCROLL_BUFFER_ROWS,
|
||||
} from '../types';
|
||||
import { getRowTop } from '../composables/useAutoMeasure';
|
||||
import { formatCellWithSuffix } from '../composables/cellDisplay';
|
||||
import { resolveColumnTextAlign } from '../composables/columnTextAlign';
|
||||
import { colIndexToLetters } from '../formula/evaluator';
|
||||
import { normalizeRange } from '../commands/FillRangeCommand';
|
||||
import {
|
||||
computeFixedWidth,
|
||||
getDataColX,
|
||||
getFixedColIndices,
|
||||
isFixedCol,
|
||||
} from './columnLayout';
|
||||
|
||||
export {
|
||||
computeFixedWidth,
|
||||
getDataColX,
|
||||
getFixedColIndices,
|
||||
} from './columnLayout';
|
||||
|
||||
export interface RenderOptions {
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
columns: SpreadsheetColumnConfig[];
|
||||
rows: Record<string, unknown>[];
|
||||
colWidths: number[];
|
||||
rowHeight: number;
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
displayRowCount: number;
|
||||
colCount: number;
|
||||
displayColCount: number;
|
||||
defaultColWidth: number;
|
||||
defaultTextAlign: SpreadsheetTextAlign;
|
||||
isDark: boolean;
|
||||
fixedCols: number[];
|
||||
fixedWidth: number;
|
||||
selection: CellRange | null;
|
||||
activeCell: { row: number; col: number } | null;
|
||||
dirtyKeys: Set<string>;
|
||||
filterValues: Record<number, string>;
|
||||
filteredRowIndices: number[];
|
||||
showFillHandle: boolean;
|
||||
theme: SpreadsheetTheme;
|
||||
resolveDisplayValue?: (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => unknown;
|
||||
resolveCellStyle?: (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => { bgColor?: string; textColor?: string } | undefined;
|
||||
referenceCell?: { row: number; col: number } | null;
|
||||
}
|
||||
|
||||
function colByIndex(columns: SpreadsheetColumnConfig[], col: number) {
|
||||
return columns.find((c) => c.col === col);
|
||||
}
|
||||
|
||||
function isInSelection(
|
||||
row: number,
|
||||
col: number,
|
||||
selection: CellRange | null,
|
||||
): boolean {
|
||||
if (!selection) return false;
|
||||
const r0 = Math.min(selection.startRow, selection.endRow);
|
||||
const r1 = Math.max(selection.startRow, selection.endRow);
|
||||
const c0 = Math.min(selection.startCol, selection.endCol);
|
||||
const c1 = Math.max(selection.startCol, selection.endCol);
|
||||
return row >= r0 && row <= r1 && col >= c0 && col <= c1;
|
||||
}
|
||||
|
||||
function formatCellDisplay(value: unknown, cellType: CellType): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (cellType === 'number') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? String(n) : String(value);
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function drawAlignedText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
align: SpreadsheetTextAlign,
|
||||
) {
|
||||
ctx.textBaseline = 'middle';
|
||||
if (align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, x + w / 2, y);
|
||||
} else if (align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(text, x + w - 8, y);
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(text, x + 8, y, w - 16);
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
function drawCellContent(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
value: unknown,
|
||||
cellType: CellType,
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
theme: SpreadsheetTheme,
|
||||
textColor: string | undefined,
|
||||
defaultTextAlign: SpreadsheetTextAlign,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, w, h);
|
||||
ctx.clip();
|
||||
|
||||
const align = resolveColumnTextAlign(colCfg, defaultTextAlign);
|
||||
|
||||
if (cellType === 'image' && value) {
|
||||
ctx.fillStyle = theme.imagePlaceholder;
|
||||
ctx.fillRect(x + 4, y + 4, h - 8, h - 8);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '11px sans-serif';
|
||||
drawAlignedText(ctx, 'IMG', x, y + h / 2, w, align);
|
||||
} else {
|
||||
ctx.fillStyle = textColor ?? theme.text;
|
||||
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const text = colCfg
|
||||
? formatCellWithSuffix(value, colCfg)
|
||||
: formatCellDisplay(value, cellType);
|
||||
drawAlignedText(ctx, text, x, y + h / 2, w, align);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRowNumber(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
y: number,
|
||||
rowHeight: number,
|
||||
label: string,
|
||||
theme: SpreadsheetTheme,
|
||||
) {
|
||||
ctx.fillStyle = theme.rowNumBg;
|
||||
ctx.fillRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, ROW_NUMBER_WIDTH / 2 - 4, y + rowHeight / 2);
|
||||
}
|
||||
|
||||
function drawHeaderCorner(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
theme: SpreadsheetTheme,
|
||||
) {
|
||||
ctx.fillStyle = theme.rowNumBg;
|
||||
ctx.fillRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
'#',
|
||||
ROW_NUMBER_WIDTH / 2 - 4,
|
||||
COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT / 2,
|
||||
);
|
||||
}
|
||||
|
||||
function drawDataCell(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
cw: number;
|
||||
rowHeight: number;
|
||||
bg: string;
|
||||
theme: SpreadsheetTheme;
|
||||
value?: unknown;
|
||||
cellType?: CellType;
|
||||
colCfg?: SpreadsheetColumnConfig;
|
||||
textColor?: string;
|
||||
empty?: boolean;
|
||||
defaultTextAlign: SpreadsheetTextAlign;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
value,
|
||||
cellType,
|
||||
colCfg,
|
||||
textColor,
|
||||
empty,
|
||||
defaultTextAlign,
|
||||
} = options;
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, cw, rowHeight);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, y, cw, rowHeight);
|
||||
if (!empty && cellType && value !== undefined) {
|
||||
drawCellContent(
|
||||
ctx,
|
||||
value,
|
||||
cellType,
|
||||
colCfg,
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
theme,
|
||||
textColor,
|
||||
defaultTextAlign,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCellBg(
|
||||
options: RenderOptions,
|
||||
vi: number,
|
||||
col: number,
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
isEmpty: boolean,
|
||||
dataRow: number | undefined,
|
||||
): string {
|
||||
const { theme } = options;
|
||||
if (isEmpty || !colCfg) {
|
||||
return isInSelection(vi, col, options.selection)
|
||||
? theme.selectionBg
|
||||
: theme.cellBg;
|
||||
}
|
||||
const dirtyKey =
|
||||
dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
|
||||
const selected = isInSelection(vi, col, options.selection);
|
||||
const cellStyle = options.resolveCellStyle?.(vi, colCfg, row);
|
||||
if (options.dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
|
||||
if (cellStyle?.bgColor) return cellStyle.bgColor;
|
||||
if (selected) return theme.selectionBg;
|
||||
return theme.cellBg;
|
||||
}
|
||||
|
||||
function drawHeaderCell(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
col: number,
|
||||
cw: number,
|
||||
x: number,
|
||||
title: string,
|
||||
headerColor?: string,
|
||||
) {
|
||||
const { theme } = options;
|
||||
const letterY = 0;
|
||||
const titleY = COLUMN_LETTER_HEIGHT;
|
||||
|
||||
ctx.fillStyle = theme.headerBg;
|
||||
ctx.fillRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(colIndexToLetters(col), x + cw / 2, letterY + COLUMN_LETTER_HEIGHT / 2);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
const bg = resolveHeaderColor(
|
||||
headerColor,
|
||||
options.isDark,
|
||||
theme.headerBg,
|
||||
);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.fillStyle = theme.headerText;
|
||||
ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const colCfg = colByIndex(options.columns, col);
|
||||
const align = resolveColumnTextAlign(colCfg, options.defaultTextAlign);
|
||||
drawAlignedText(
|
||||
ctx,
|
||||
title,
|
||||
x,
|
||||
titleY + HEADER_TITLE_HEIGHT / 2,
|
||||
cw,
|
||||
align,
|
||||
);
|
||||
}
|
||||
|
||||
function drawHeaderTitleOnly(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
cw: number,
|
||||
title: string,
|
||||
headerColor: string | undefined,
|
||||
col: number,
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
defaultTextAlign: SpreadsheetTextAlign,
|
||||
isDark: boolean,
|
||||
theme: SpreadsheetTheme,
|
||||
) {
|
||||
const bg = resolveHeaderColor(headerColor, isDark, theme.headerBg);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, y, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.fillStyle = theme.headerText;
|
||||
ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const colCfg = colByIndex(columns, col);
|
||||
const align = resolveColumnTextAlign(colCfg, defaultTextAlign);
|
||||
drawAlignedText(ctx, title, x, y + HEADER_TITLE_HEIGHT / 2, cw, align);
|
||||
}
|
||||
|
||||
function resolveExportCellBg(
|
||||
theme: SpreadsheetTheme,
|
||||
viewRow: number,
|
||||
col: number,
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
dataRow: number | undefined,
|
||||
dirtyKeys: Set<string>,
|
||||
resolveCellStyle?: RenderOptions['resolveCellStyle'],
|
||||
): string {
|
||||
if (!colCfg) return theme.cellBg;
|
||||
const dirtyKey =
|
||||
dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
|
||||
const cellStyle = resolveCellStyle?.(viewRow, colCfg, row);
|
||||
if (dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
|
||||
if (cellStyle?.bgColor) return cellStyle.bgColor;
|
||||
return theme.cellBg;
|
||||
}
|
||||
|
||||
export interface SelectionImageParams {
|
||||
selection: CellRange;
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
columns: SpreadsheetColumnConfig[];
|
||||
colWidths: number[];
|
||||
rowHeight: number;
|
||||
defaultColWidth: number;
|
||||
defaultTextAlign: SpreadsheetTextAlign;
|
||||
isDark: boolean;
|
||||
filteredRowIndices: number[];
|
||||
rows: Record<string, unknown>[];
|
||||
dirtyKeys: Set<string>;
|
||||
resolveDisplayValue?: RenderOptions['resolveDisplayValue'];
|
||||
resolveCellStyle?: RenderOptions['resolveCellStyle'];
|
||||
}
|
||||
|
||||
export async function renderSelectionImage(
|
||||
params: SelectionImageParams,
|
||||
): Promise<Blob> {
|
||||
const sel = normalizeRange(params.selection);
|
||||
const { startRow: r0, endRow: r1, startCol: c0, endCol: c1 } = sel;
|
||||
const theme = getSpreadsheetTheme(params.isDark);
|
||||
|
||||
let width = 0;
|
||||
for (let col = c0; col <= c1; col += 1) {
|
||||
width += params.colWidths[col] ?? params.defaultColWidth;
|
||||
}
|
||||
const rowCount = r1 - r0 + 1;
|
||||
const height = HEADER_TITLE_HEIGHT + rowCount * params.rowHeight;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.ceil(width * dpr);
|
||||
canvas.height = Math.ceil(height * dpr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas not supported');
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = theme.cellBg;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
let headerX = 0;
|
||||
for (let col = c0; col <= c1; col += 1) {
|
||||
const cw = params.colWidths[col] ?? params.defaultColWidth;
|
||||
const header = params.headers.find((h) => h.col === col);
|
||||
const colCfg = colByIndex(params.columns, col);
|
||||
const title = header?.title ?? colCfg?.fieldName ?? '';
|
||||
drawHeaderTitleOnly(
|
||||
ctx,
|
||||
headerX,
|
||||
0,
|
||||
cw,
|
||||
title,
|
||||
header?.headerColor,
|
||||
col,
|
||||
params.columns,
|
||||
params.defaultTextAlign,
|
||||
params.isDark,
|
||||
theme,
|
||||
);
|
||||
headerX += cw;
|
||||
}
|
||||
|
||||
for (let viewRow = r0; viewRow <= r1; viewRow += 1) {
|
||||
const dataRow = params.filteredRowIndices[viewRow];
|
||||
const row =
|
||||
dataRow !== undefined ? params.rows[dataRow] : undefined;
|
||||
const y = HEADER_TITLE_HEIGHT + (viewRow - r0) * params.rowHeight;
|
||||
let cellX = 0;
|
||||
for (let col = c0; col <= c1; col += 1) {
|
||||
const cw = params.colWidths[col] ?? params.defaultColWidth;
|
||||
const colCfg = colByIndex(params.columns, col);
|
||||
const isEmpty = dataRow === undefined || !row || !colCfg;
|
||||
const bg = resolveExportCellBg(
|
||||
theme,
|
||||
viewRow,
|
||||
col,
|
||||
colCfg,
|
||||
row,
|
||||
dataRow,
|
||||
params.dirtyKeys,
|
||||
params.resolveCellStyle,
|
||||
);
|
||||
const cellStyle = !isEmpty && colCfg
|
||||
? params.resolveCellStyle?.(viewRow, colCfg, row)
|
||||
: undefined;
|
||||
const displayCellType = colCfg?.cellType === 'formula'
|
||||
? 'number'
|
||||
: colCfg?.cellType;
|
||||
drawDataCell(ctx, {
|
||||
x: cellX,
|
||||
y,
|
||||
cw,
|
||||
rowHeight: params.rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: isEmpty,
|
||||
value: !isEmpty && colCfg
|
||||
? params.resolveDisplayValue?.(viewRow, colCfg, row)
|
||||
: undefined,
|
||||
cellType: displayCellType,
|
||||
colCfg,
|
||||
textColor: cellStyle?.textColor,
|
||||
defaultTextAlign: params.defaultTextAlign,
|
||||
});
|
||||
cellX += cw;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error('Failed to create image'));
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
function drawHeaderFillerCell(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
col: number,
|
||||
cw: number,
|
||||
x: number,
|
||||
) {
|
||||
const { theme } = options;
|
||||
ctx.fillStyle = theme.headerBg;
|
||||
ctx.fillRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(colIndexToLetters(col), x + cw / 2, COLUMN_LETTER_HEIGHT / 2);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.strokeRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
|
||||
}
|
||||
|
||||
function drawScrollableHeaders(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
w: number,
|
||||
) {
|
||||
const { fixedCols, fixedWidth } = options;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(fixedWidth, 0, w - fixedWidth, HEADER_HEIGHT);
|
||||
ctx.clip();
|
||||
|
||||
for (const header of options.headers) {
|
||||
if (isFixedCol(header.col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
header.col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[header.col] ?? options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > w) continue;
|
||||
drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
|
||||
}
|
||||
|
||||
for (let col = options.colCount; col < options.displayColCount; col += 1) {
|
||||
if (isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > w) continue;
|
||||
drawHeaderFillerCell(ctx, options, col, cw, x);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawFrozenHeaders(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
w: number,
|
||||
) {
|
||||
const { fixedCols, fixedWidth, theme } = options;
|
||||
drawHeaderCorner(ctx, theme);
|
||||
|
||||
for (const header of options.headers) {
|
||||
if (!isFixedCol(header.col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
header.col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[header.col] ?? options.defaultColWidth;
|
||||
if (x + cw <= ROW_NUMBER_WIDTH) continue;
|
||||
drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
|
||||
}
|
||||
|
||||
if (fixedCols.length > 0 && fixedWidth < w) {
|
||||
ctx.fillStyle = theme.fixedShadow;
|
||||
ctx.fillRect(fixedWidth, 0, 1, HEADER_HEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHeader(canvas: HTMLCanvasElement, options: RenderOptions) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = HEADER_HEIGHT;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.fillStyle = options.theme.cellBg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
drawScrollableHeaders(ctx, options, w);
|
||||
drawFrozenHeaders(ctx, options, w);
|
||||
}
|
||||
|
||||
function drawScrollableBodyRow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
vi: number,
|
||||
y: number,
|
||||
dataRow: number | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
w: number,
|
||||
) {
|
||||
const { theme, rowHeight, fixedCols, fixedWidth } = options;
|
||||
const isEmpty = dataRow === undefined || !row;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(fixedWidth, y, w - fixedWidth, rowHeight);
|
||||
ctx.clip();
|
||||
|
||||
for (const colCfg of options.columns) {
|
||||
const col = colCfg.col;
|
||||
if (isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[col] ?? options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > options.viewportWidth) continue;
|
||||
|
||||
const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
|
||||
const cellStyle = !isEmpty
|
||||
? options.resolveCellStyle?.(vi, colCfg, row)
|
||||
: undefined;
|
||||
const displayCellType =
|
||||
colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
|
||||
|
||||
drawDataCell(ctx, {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: isEmpty,
|
||||
value: options.resolveDisplayValue
|
||||
? options.resolveDisplayValue(vi, colCfg, row)
|
||||
: row?.[colCfg.fieldId],
|
||||
cellType: displayCellType,
|
||||
colCfg,
|
||||
textColor: cellStyle?.textColor,
|
||||
defaultTextAlign: options.defaultTextAlign,
|
||||
});
|
||||
}
|
||||
|
||||
for (let col = options.colCount; col < options.displayColCount; col += 1) {
|
||||
if (isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > options.viewportWidth) continue;
|
||||
const bg = resolveCellBg(options, vi, col, undefined, undefined, true, dataRow);
|
||||
drawDataCell(ctx, {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: true,
|
||||
defaultTextAlign: options.defaultTextAlign,
|
||||
});
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawFrozenBodyRow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
vi: number,
|
||||
y: number,
|
||||
dataRow: number | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const { theme, rowHeight, fixedCols, fixedWidth } = options;
|
||||
const isEmpty = dataRow === undefined || !row;
|
||||
|
||||
drawRowNumber(ctx, y, rowHeight, String(vi + 1), theme);
|
||||
|
||||
for (const colCfg of options.columns) {
|
||||
const col = colCfg.col;
|
||||
if (!isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[col] ?? options.defaultColWidth;
|
||||
|
||||
const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
|
||||
const cellStyle = !isEmpty
|
||||
? options.resolveCellStyle?.(vi, colCfg, row)
|
||||
: undefined;
|
||||
const displayCellType =
|
||||
colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
|
||||
|
||||
drawDataCell(ctx, {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: isEmpty,
|
||||
value: options.resolveDisplayValue
|
||||
? options.resolveDisplayValue(vi, colCfg, row)
|
||||
: row?.[colCfg.fieldId],
|
||||
cellType: displayCellType,
|
||||
colCfg,
|
||||
textColor: cellStyle?.textColor,
|
||||
defaultTextAlign: options.defaultTextAlign,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const { theme, rowHeight, scrollTop, fixedWidth, fixedCols } = options;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const visibleCount = Math.ceil(h / rowHeight) + SCROLL_BUFFER_ROWS;
|
||||
const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2);
|
||||
const endRow = Math.min(options.displayRowCount - 1, startRow + visibleCount);
|
||||
|
||||
for (let vi = startRow; vi <= endRow; vi += 1) {
|
||||
const dataRow = options.filteredRowIndices[vi];
|
||||
const row =
|
||||
dataRow !== undefined ? options.rows[dataRow] : undefined;
|
||||
const y = getRowTop(vi, rowHeight) - scrollTop;
|
||||
drawScrollableBodyRow(ctx, options, vi, y, dataRow, row, w);
|
||||
}
|
||||
|
||||
for (let vi = startRow; vi <= endRow; vi += 1) {
|
||||
const dataRow = options.filteredRowIndices[vi];
|
||||
const row =
|
||||
dataRow !== undefined ? options.rows[dataRow] : undefined;
|
||||
const y = getRowTop(vi, rowHeight) - scrollTop;
|
||||
drawFrozenBodyRow(ctx, options, vi, y, dataRow, row);
|
||||
}
|
||||
|
||||
if (fixedCols.length > 0 && fixedWidth < w) {
|
||||
const bodyH = Math.min(h, options.displayRowCount * rowHeight - scrollTop);
|
||||
ctx.fillStyle = theme.fixedShadow;
|
||||
ctx.fillRect(fixedWidth, 0, 1, Math.max(0, bodyH));
|
||||
}
|
||||
|
||||
if (options.selection && options.activeCell) {
|
||||
const { activeCell } = options;
|
||||
const x = getDataColX(
|
||||
activeCell.col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const y = getRowTop(activeCell.row, rowHeight) - scrollTop;
|
||||
const cw = options.colWidths[activeCell.col] ?? options.defaultColWidth;
|
||||
|
||||
ctx.strokeStyle = theme.selectionBorder;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);
|
||||
|
||||
if (options.showFillHandle) {
|
||||
const fh = 6;
|
||||
ctx.fillStyle = theme.fillHandle;
|
||||
ctx.fillRect(x + cw - fh - 2, y + rowHeight - fh - 2, fh, fh);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.referenceCell) {
|
||||
const { row, col } = options.referenceCell;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const y = getRowTop(row, rowHeight) - scrollTop;
|
||||
const cw = options.colWidths[col] ?? options.defaultColWidth;
|
||||
|
||||
ctx.strokeStyle = theme.selectionBorder;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
export function getVisibleRowIndices(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
filterValues: Record<number, string>,
|
||||
): number[] {
|
||||
const activeFilters = Object.entries(filterValues).filter(([, v]) => v.trim());
|
||||
if (!activeFilters.length) {
|
||||
return rows.map((_, i) => i);
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) =>
|
||||
activeFilters.every(([colStr, keyword]) => {
|
||||
const col = Number(colStr);
|
||||
const colCfg = colByIndex(columns, col);
|
||||
if (!colCfg) return true;
|
||||
const val = formatCellDisplay(row[colCfg.fieldId], colCfg.cellType);
|
||||
return val.toLowerCase().includes(keyword.trim().toLowerCase());
|
||||
}),
|
||||
)
|
||||
.map(({ index }) => index);
|
||||
}
|
||||
|
||||
export function getContentSize(
|
||||
colWidths: number[],
|
||||
displayRowCount: number,
|
||||
rowHeight: number,
|
||||
displayContentWidth?: number,
|
||||
): { width: number; height: number } {
|
||||
const dataWidth = ROW_NUMBER_WIDTH + colWidths.reduce((s, w) => s + w, 0);
|
||||
const width = displayContentWidth ?? dataWidth;
|
||||
return { width, height: displayRowCount * rowHeight };
|
||||
}
|
||||
|
||||
export { ROW_NUMBER_WIDTH, HEADER_HEIGHT, DEFAULT_ROW_HEIGHT };
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { SpreadsheetHeaderConfig } from '../types';
|
||||
import { DEFAULT_COL_WIDTH, ROW_NUMBER_WIDTH } from '../types';
|
||||
|
||||
export function getFixedColOffset(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
fixedCols: number[],
|
||||
): number {
|
||||
let w = 0;
|
||||
for (const c of fixedCols) {
|
||||
if (c >= col) break;
|
||||
w += colWidths[c] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
export function getFixedDataWidth(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
fixedCols: number[],
|
||||
): number {
|
||||
return getFixedColOffset(col, colWidths, fixedCols);
|
||||
}
|
||||
|
||||
export function isFixedCol(col: number, fixedCols: number[]) {
|
||||
return fixedCols.includes(col);
|
||||
}
|
||||
|
||||
export function getDataColX(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
scrollLeft: number,
|
||||
fixedCols: number[],
|
||||
fixedWidth: number,
|
||||
): number {
|
||||
if (isFixedCol(col, fixedCols)) {
|
||||
return ROW_NUMBER_WIDTH + getFixedColOffset(col, colWidths, fixedCols);
|
||||
}
|
||||
const scrollColOffset =
|
||||
getColLeft(col, colWidths) - getFixedDataWidth(col, colWidths, fixedCols);
|
||||
return fixedWidth + scrollColOffset - scrollLeft;
|
||||
}
|
||||
|
||||
export function getColLeft(colIndex: number, colWidths: number[]): number {
|
||||
let left = 0;
|
||||
for (let i = 0; i < colIndex; i += 1) {
|
||||
left += colWidths[i] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
export function computeFixedWidth(
|
||||
colWidths: number[],
|
||||
fixedCols: number[],
|
||||
): number {
|
||||
let w = ROW_NUMBER_WIDTH;
|
||||
for (const col of fixedCols) {
|
||||
w += colWidths[col] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Keep only contiguous frozen columns starting from col 0 (Excel freeze semantics). */
|
||||
export function normalizeFixedCols(fixedCols: number[]): number[] {
|
||||
if (fixedCols.length === 0) return [];
|
||||
const sorted = [...fixedCols].sort((a, b) => a - b);
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < sorted.length; i += 1) {
|
||||
if (sorted[i] === i) result.push(i);
|
||||
else break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getFixedColIndices(
|
||||
headers: SpreadsheetHeaderConfig[],
|
||||
): number[] {
|
||||
const raw = headers
|
||||
.filter((h) => h.fixed)
|
||||
.map((h) => h.col)
|
||||
.sort((a, b) => a - b);
|
||||
return normalizeFixedCols(raw);
|
||||
}
|
||||
137
apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts
Normal file
137
apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export interface SpreadsheetTheme {
|
||||
headerBg: string;
|
||||
headerText: string;
|
||||
cellBg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
selectionBg: string;
|
||||
selectionBorder: string;
|
||||
dirtyBg: string;
|
||||
rowNumBg: string;
|
||||
fillHandle: string;
|
||||
fixedShadow: string;
|
||||
imagePlaceholder: string;
|
||||
}
|
||||
|
||||
export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme {
|
||||
if (isDark) {
|
||||
return {
|
||||
headerBg: '#1f1f1f',
|
||||
headerText: 'rgba(255, 255, 255, 0.85)',
|
||||
cellBg: '#141414',
|
||||
border: '#424242',
|
||||
text: 'rgba(255, 255, 255, 0.85)',
|
||||
textMuted: 'rgba(255, 255, 255, 0.45)',
|
||||
selectionBg: 'rgba(22, 93, 255, 0.25)',
|
||||
selectionBorder: '#1668dc',
|
||||
dirtyBg: 'rgba(255, 125, 0, 0.2)',
|
||||
rowNumBg: '#1a1a1a',
|
||||
fillHandle: '#1668dc',
|
||||
fixedShadow: 'rgba(0, 0, 0, 0.45)',
|
||||
imagePlaceholder: '#303030',
|
||||
};
|
||||
}
|
||||
return {
|
||||
headerBg: '#f5f7fa',
|
||||
headerText: '#1d2129',
|
||||
cellBg: '#ffffff',
|
||||
border: '#e5e6eb',
|
||||
text: '#1d2129',
|
||||
textMuted: '#86909c',
|
||||
selectionBg: 'rgba(22, 93, 255, 0.08)',
|
||||
selectionBorder: '#165dff',
|
||||
dirtyBg: 'rgba(255, 125, 0, 0.12)',
|
||||
rowNumBg: '#fafafa',
|
||||
fillHandle: '#165dff',
|
||||
fixedShadow: 'rgba(0, 0, 0, 0.12)',
|
||||
imagePlaceholder: '#f2f3f5',
|
||||
};
|
||||
}
|
||||
|
||||
export const HEADER_COLOR_PRESETS = [
|
||||
'#f0f5ff',
|
||||
'#fff7e6',
|
||||
'#f6ffed',
|
||||
'#fff1f0',
|
||||
'#f9f0ff',
|
||||
'#e6fffb',
|
||||
];
|
||||
|
||||
function parseHexColor(hex: string): { r: number; g: number; b: number } | null {
|
||||
const normalized = hex.trim().replace('#', '');
|
||||
if (normalized.length === 3) {
|
||||
return {
|
||||
r: Number.parseInt(normalized[0]! + normalized[0], 16),
|
||||
g: Number.parseInt(normalized[1]! + normalized[1], 16),
|
||||
b: Number.parseInt(normalized[2]! + normalized[2], 16),
|
||||
};
|
||||
}
|
||||
if (normalized.length === 6) {
|
||||
return {
|
||||
r: Number.parseInt(normalized.slice(0, 2), 16),
|
||||
g: Number.parseInt(normalized.slice(2, 4), 16),
|
||||
b: Number.parseInt(normalized.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number) {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||
else h = ((rn - gn) / d + 4) / 6;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255);
|
||||
const hex = v.toString(16).padStart(2, '0');
|
||||
return `#${hex}${hex}${hex}`;
|
||||
}
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
|
||||
const g = Math.round(hue2rgb(p, q, h) * 255);
|
||||
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function resolveHeaderColor(
|
||||
color: string | undefined,
|
||||
isDark: boolean,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!color) return fallback;
|
||||
if (!isDark) return color;
|
||||
|
||||
const rgb = parseHexColor(color);
|
||||
if (!rgb) return fallback;
|
||||
|
||||
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
const darkL = Math.min(0.28, Math.max(0.16, l * 0.35 + 0.12));
|
||||
const darkS = Math.min(0.55, s * 0.75 + 0.08);
|
||||
return hslToHex(h, darkS, darkL);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CellRange, SpreadsheetCommand } from '../types';
|
||||
|
||||
export function createFillRangeCommand(
|
||||
persistBatch: (
|
||||
changes: Array<{ row: number; col: number; value: unknown }>,
|
||||
) => void | Promise<void>,
|
||||
before: Array<{ row: number; col: number; value: unknown }>,
|
||||
after: Array<{ row: number; col: number; value: unknown }>,
|
||||
): SpreadsheetCommand {
|
||||
return {
|
||||
label: 'fillRange',
|
||||
undo: () => persistBatch(before),
|
||||
redo: () => persistBatch(after),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRange(range: CellRange): CellRange {
|
||||
return {
|
||||
startRow: Math.min(range.startRow, range.endRow),
|
||||
startCol: Math.min(range.startCol, range.endCol),
|
||||
endRow: Math.max(range.startRow, range.endRow),
|
||||
endCol: Math.max(range.startCol, range.endCol),
|
||||
};
|
||||
}
|
||||
|
||||
export function iterateRange(
|
||||
range: CellRange,
|
||||
fn: (row: number, col: number) => void,
|
||||
) {
|
||||
const r = normalizeRange(range);
|
||||
for (let row = r.startRow; row <= r.endRow; row += 1) {
|
||||
for (let col = r.startCol; col <= r.endCol; col += 1) {
|
||||
fn(row, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
export function createPasteRangeCommand(
|
||||
persistBatch: (
|
||||
changes: Array<{ row: number; col: number; value: unknown }>,
|
||||
) => void | Promise<void>,
|
||||
before: Array<{ row: number; col: number; value: unknown }>,
|
||||
after: Array<{ row: number; col: number; value: unknown }>,
|
||||
): SpreadsheetCommand {
|
||||
return {
|
||||
label: 'pasteRange',
|
||||
undo: () => persistBatch(before),
|
||||
redo: () => persistBatch(after),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
|
||||
|
||||
export function createSetCellValueCommand(
|
||||
|
||||
undo: () => void | Promise<void>,
|
||||
|
||||
redo: () => void | Promise<void>,
|
||||
|
||||
): SpreadsheetCommand {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
ConditionalFormatRule,
|
||||
SpreadsheetColumnConfig,
|
||||
} from '../types';
|
||||
import { formatCellDisplay } from './useAutoMeasure';
|
||||
|
||||
export function shouldShowSuffix(displayText: string): boolean {
|
||||
if (!displayText) return false;
|
||||
if (displayText === '#ERROR' || displayText === '-') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatCellWithSuffix(
|
||||
value: unknown,
|
||||
colCfg?: SpreadsheetColumnConfig,
|
||||
): string {
|
||||
const cellType =
|
||||
colCfg?.cellType === 'formula' ? 'number' : (colCfg?.cellType ?? 'text');
|
||||
const base = formatCellDisplay(value, cellType);
|
||||
if (!shouldShowSuffix(base) || !colCfg?.suffix) return base;
|
||||
return base + colCfg.suffix;
|
||||
}
|
||||
|
||||
function resolveRuleStyle(
|
||||
rule: ConditionalFormatRule,
|
||||
): { bgColor?: string; textColor?: string } {
|
||||
const mode = rule.colorMode ?? 'background';
|
||||
if (mode === 'text') {
|
||||
return rule.textColor ? { textColor: rule.textColor } : {};
|
||||
}
|
||||
if (mode === 'both') {
|
||||
return {
|
||||
bgColor: rule.bgColor,
|
||||
textColor: rule.textColor,
|
||||
};
|
||||
}
|
||||
return rule.bgColor ? { bgColor: rule.bgColor } : {};
|
||||
}
|
||||
|
||||
export function resolveConditionalStyle(
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
numericValue: number | null,
|
||||
): { bgColor?: string; textColor?: string } | undefined {
|
||||
const rules = colCfg.conditionalFormat;
|
||||
if (rules == null || numericValue == null || Number.isNaN(numericValue)) {
|
||||
return undefined;
|
||||
}
|
||||
if (rules.gt && numericValue > rules.gt.threshold) {
|
||||
return resolveRuleStyle(rules.gt);
|
||||
}
|
||||
if (rules.lt && numericValue < rules.lt.threshold) {
|
||||
return resolveRuleStyle(rules.lt);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetTextAlign,
|
||||
} from '../types';
|
||||
|
||||
export function resolveColumnTextAlign(
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
defaultAlign: SpreadsheetTextAlign = 'left',
|
||||
): SpreadsheetTextAlign {
|
||||
const a = colCfg?.textAlign;
|
||||
if (!a || a === 'inherit') return defaultAlign;
|
||||
return a;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { formatCellWithSuffix } from './cellDisplay';
|
||||
import type {
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetColumnFilter,
|
||||
SpreadsheetViewState,
|
||||
} from '../types';
|
||||
|
||||
type ResolveDisplayValue = (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => unknown;
|
||||
|
||||
function formatDisplayText(value: unknown, colCfg: SpreadsheetColumnConfig): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const cellType = colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
|
||||
if (colCfg.suffix) {
|
||||
return formatCellWithSuffix(value, colCfg);
|
||||
}
|
||||
if (cellType === 'number') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? String(n) : String(value);
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function getCellDisplayText(
|
||||
row: Record<string, unknown>,
|
||||
dataRowIndex: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): string {
|
||||
const raw = resolveDisplayValue(dataRowIndex, colCfg, row);
|
||||
return formatDisplayText(raw, colCfg);
|
||||
}
|
||||
|
||||
export function collectColumnUniqueValues(
|
||||
rows: Record<string, unknown>[],
|
||||
col: number,
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): string[] {
|
||||
const colCfg = columns.find((c) => c.col === col);
|
||||
if (!colCfg) return [];
|
||||
const set = new Set<string>();
|
||||
rows.forEach((row, dataRowIndex) => {
|
||||
set.add(getCellDisplayText(row, dataRowIndex, colCfg, resolveDisplayValue));
|
||||
});
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function isColumnFilterActive(filter?: SpreadsheetColumnFilter): boolean {
|
||||
return filter?.checkedValues !== undefined;
|
||||
}
|
||||
|
||||
export function isColumnFilterMatching(
|
||||
displayText: string,
|
||||
filter?: SpreadsheetColumnFilter,
|
||||
allValues?: string[],
|
||||
): boolean {
|
||||
if (!filter || filter.checkedValues === undefined) return true;
|
||||
if (filter.checkedValues.length === 0) return false;
|
||||
if (allValues && filter.checkedValues.length >= allValues.length) return true;
|
||||
return filter.checkedValues.includes(displayText);
|
||||
}
|
||||
|
||||
function compareRows(
|
||||
a: number,
|
||||
b: number,
|
||||
sortCol: number,
|
||||
rows: Record<string, unknown>[],
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): number {
|
||||
const colCfg = columns.find((c) => c.col === sortCol);
|
||||
if (!colCfg) return 0;
|
||||
const rowA = rows[a];
|
||||
const rowB = rows[b];
|
||||
if (!rowA || !rowB) return 0;
|
||||
|
||||
const textA = getCellDisplayText(rowA, a, colCfg, resolveDisplayValue);
|
||||
const textB = getCellDisplayText(rowB, b, colCfg, resolveDisplayValue);
|
||||
|
||||
if (colCfg.cellType === 'number' || colCfg.cellType === 'formula') {
|
||||
const nA = Number(textA);
|
||||
const nB = Number(textB);
|
||||
const aValid = Number.isFinite(nA);
|
||||
const bValid = Number.isFinite(nB);
|
||||
if (!aValid && !bValid) return 0;
|
||||
if (!aValid) return 1;
|
||||
if (!bValid) return -1;
|
||||
return nA - nB;
|
||||
}
|
||||
return textA.localeCompare(textB, 'zh-CN');
|
||||
}
|
||||
|
||||
export function applyViewState(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
viewState: SpreadsheetViewState,
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): number[] {
|
||||
const activeFilterCols = Object.entries(viewState.filters).filter(([, f]) =>
|
||||
isColumnFilterActive(f),
|
||||
);
|
||||
|
||||
let indices = rows.map((_, i) => i);
|
||||
|
||||
if (activeFilterCols.length > 0) {
|
||||
const uniqueValuesMap = new Map<number, string[]>();
|
||||
for (const [colStr] of activeFilterCols) {
|
||||
const col = Number(colStr);
|
||||
if (!uniqueValuesMap.has(col)) {
|
||||
uniqueValuesMap.set(
|
||||
col,
|
||||
collectColumnUniqueValues(
|
||||
rows,
|
||||
col,
|
||||
columns,
|
||||
resolveDisplayValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
indices = indices.filter((dataRowIndex) => {
|
||||
const row = rows[dataRowIndex];
|
||||
if (!row) return false;
|
||||
return activeFilterCols.every(([colStr, filter]) => {
|
||||
const col = Number(colStr);
|
||||
const colCfg = columns.find((c) => c.col === col);
|
||||
if (!colCfg) return true;
|
||||
const allValues = uniqueValuesMap.get(col);
|
||||
const text = getCellDisplayText(
|
||||
row,
|
||||
dataRowIndex,
|
||||
colCfg,
|
||||
resolveDisplayValue,
|
||||
);
|
||||
return isColumnFilterMatching(text, filter, allValues);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (viewState.sort) {
|
||||
const { col, direction } = viewState.sort;
|
||||
indices = [...indices].sort((a, b) => {
|
||||
const cmp = compareRows(a, b, col, rows, columns, resolveDisplayValue);
|
||||
return direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { SpreadsheetColumnConfig } from '../types';
|
||||
import {
|
||||
DEFAULT_COL_WIDTH,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
MIN_COL_WIDTH,
|
||||
ROW_NUMBER_WIDTH,
|
||||
} from '../types';
|
||||
import { getDataColX } from '../canvas/columnLayout';
|
||||
|
||||
const CELL_PADDING = 16;
|
||||
const SAMPLE_ROWS = 50;
|
||||
|
||||
function measureTextWidth(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
): number {
|
||||
return ctx.measureText(text).width + CELL_PADDING;
|
||||
}
|
||||
|
||||
function formatCellDisplay(value: unknown, cellType: import('../types').CellType): string { if (value === null || value === undefined) return '';
|
||||
if (cellType === 'number') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? String(n) : String(value);
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function measureColumnWidths(
|
||||
canvas: HTMLCanvasElement,
|
||||
headers: Array<{ title: string; col: number }>,
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
rows: Record<string, unknown>[],
|
||||
defaultWidth: number,
|
||||
resolveDisplayValue?: (
|
||||
dataRowIndex: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row: Record<string, unknown>,
|
||||
) => unknown,
|
||||
formatWithSuffix?: (
|
||||
value: unknown,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
) => string,
|
||||
): number[] { const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return headers.map(() => defaultWidth);
|
||||
|
||||
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
|
||||
const maxCol = Math.max(...headers.map((h) => h.col), 0);
|
||||
const widths = new Array<number>(maxCol + 1).fill(defaultWidth);
|
||||
|
||||
for (const header of headers) {
|
||||
widths[header.col] = Math.max(
|
||||
widths[header.col] ?? defaultWidth,
|
||||
measureTextWidth(ctx, header.title),
|
||||
MIN_COL_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
const sample = rows.slice(0, SAMPLE_ROWS);
|
||||
for (const colCfg of columns) {
|
||||
let maxW = widths[colCfg.col] ?? defaultWidth;
|
||||
sample.forEach((row, dataRowIdx) => {
|
||||
const raw = resolveDisplayValue
|
||||
? resolveDisplayValue(dataRowIdx, colCfg, row)
|
||||
: row[colCfg.fieldId];
|
||||
const text = formatWithSuffix
|
||||
? formatWithSuffix(raw, colCfg)
|
||||
: formatCellDisplay(raw, colCfg.cellType === 'formula' ? 'number' : colCfg.cellType);
|
||||
maxW = Math.max(maxW, measureTextWidth(ctx, text));
|
||||
});
|
||||
widths[colCfg.col] = Math.max(maxW, MIN_COL_WIDTH);
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
export function getRowHeight(
|
||||
cellType: import('../types').CellType, configured: number | 'auto' | undefined,
|
||||
): number {
|
||||
if (typeof configured === 'number') return configured;
|
||||
switch (cellType) {
|
||||
case 'image':
|
||||
case 'video':
|
||||
return 48;
|
||||
default:
|
||||
return DEFAULT_ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalWidth(colWidths: number[]): number {
|
||||
return colWidths.reduce((sum, w) => sum + w, 0);
|
||||
}
|
||||
|
||||
export function getRowTop(rowIndex: number, rowHeight: number): number {
|
||||
return rowIndex * rowHeight;
|
||||
}
|
||||
|
||||
export function getColLeft(colIndex: number, colWidths: number[]): number {
|
||||
let left = 0;
|
||||
for (let i = 0; i < colIndex; i += 1) {
|
||||
left += colWidths[i] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function getDataColScreenX(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
scrollLeft: number,
|
||||
fixedCols: number[],
|
||||
fixedWidth: number,
|
||||
): number {
|
||||
return getDataColX(col, colWidths, scrollLeft, fixedCols, fixedWidth);
|
||||
}
|
||||
|
||||
export function hitTestCell(
|
||||
x: number,
|
||||
y: number,
|
||||
scrollTop: number,
|
||||
scrollLeft: number,
|
||||
colWidths: number[],
|
||||
rowHeight: number,
|
||||
rowCount: number,
|
||||
colCount: number,
|
||||
fixedCols: number[] = [],
|
||||
fixedWidth = ROW_NUMBER_WIDTH,
|
||||
): { row: number; col: number } | null {
|
||||
const bodyY = y + scrollTop;
|
||||
if (bodyY < 0) return null;
|
||||
|
||||
const row = Math.floor(bodyY / rowHeight);
|
||||
if (row < 0 || row >= rowCount) return null;
|
||||
|
||||
for (let col = 0; col < colCount; col += 1) {
|
||||
const cellX = getDataColScreenX(
|
||||
col,
|
||||
colWidths,
|
||||
scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const w = colWidths[col] ?? DEFAULT_COL_WIDTH;
|
||||
if (x >= cellX && x < cellX + w) {
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hitTestHeaderCol(
|
||||
x: number,
|
||||
scrollLeft: number,
|
||||
colWidths: number[],
|
||||
colCount: number,
|
||||
fixedCols: number[] = [],
|
||||
fixedWidth = ROW_NUMBER_WIDTH,
|
||||
): number | null {
|
||||
for (let col = 0; col < colCount; col += 1) {
|
||||
const cellX = getDataColScreenX(
|
||||
col,
|
||||
colWidths,
|
||||
scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const w = colWidths[col] ?? DEFAULT_COL_WIDTH;
|
||||
if (x >= cellX && x < cellX + w) return col;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { getDataColScreenX };
|
||||
|
||||
export { formatCellDisplay, CELL_PADDING };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { ClipboardMatrix } from '../types';
|
||||
|
||||
export function useClipboard() {
|
||||
const internal = ref<ClipboardMatrix | null>(null);
|
||||
|
||||
function setMatrix(matrix: ClipboardMatrix) {
|
||||
internal.value = matrix;
|
||||
}
|
||||
|
||||
function getMatrix() {
|
||||
return internal.value;
|
||||
}
|
||||
|
||||
function hasContent() {
|
||||
return !!internal.value?.rows?.length;
|
||||
}
|
||||
|
||||
function matrixToTsv(matrix: ClipboardMatrix): string {
|
||||
return matrix.rows.map((row) => row.map(String).join('\t')).join('\n');
|
||||
}
|
||||
|
||||
function tsvToMatrix(text: string): unknown[][] {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line, i, arr) => line.length > 0 || i < arr.length - 1)
|
||||
.map((line) => line.split('\t'));
|
||||
}
|
||||
|
||||
async function writeSystemClipboard(matrix: ClipboardMatrix) {
|
||||
const text = matrixToTsv(matrix);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function readSystemClipboard(): Promise<string | null> {
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
internal,
|
||||
setMatrix,
|
||||
getMatrix,
|
||||
hasContent,
|
||||
matrixToTsv,
|
||||
tsvToMatrix,
|
||||
writeSystemClipboard,
|
||||
readSystemClipboard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import type { SpreadsheetHistoryEntry } from '../types';
|
||||
import {
|
||||
appendLocalHistory,
|
||||
listUnsyncedHistory,
|
||||
markHistorySynced,
|
||||
} from '../history/localHistoryStore';
|
||||
import { saveHistoryBatch } from '../history/remoteHistoryApi';
|
||||
|
||||
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function uuid() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
export function useEditHistory(
|
||||
tableName: string,
|
||||
sheetKey?: string,
|
||||
enabled = true,
|
||||
) {
|
||||
const userStore = useUserStore();
|
||||
|
||||
async function recordChange(
|
||||
payload: Omit<
|
||||
SpreadsheetHistoryEntry,
|
||||
'id' | 'tableName' | 'sheetKey' | 'userId' | 'userName' | 'synced' | 'createdAt'
|
||||
>,
|
||||
) {
|
||||
if (!enabled) return;
|
||||
|
||||
const entry: SpreadsheetHistoryEntry = {
|
||||
id: uuid(),
|
||||
tableName,
|
||||
sheetKey,
|
||||
userId: Number(userStore.userInfo?.userId ?? 0),
|
||||
userName: String(
|
||||
userStore.userInfo?.nick_name ??
|
||||
userStore.userInfo?.username ??
|
||||
'未知用户',
|
||||
),
|
||||
createdAt: new Date().toISOString(),
|
||||
synced: false,
|
||||
...payload,
|
||||
};
|
||||
|
||||
await appendLocalHistory(entry);
|
||||
scheduleSync();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(async () => {
|
||||
try {
|
||||
const pending = await listUnsyncedHistory();
|
||||
if (!pending.length) return;
|
||||
const res = await saveHistoryBatch(pending);
|
||||
await markHistorySynced(res.saved_ids ?? pending.map((p) => p.id));
|
||||
} catch {
|
||||
// retry on next change
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return { recordChange, scheduleSync };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useFormulaReferenceMode() {
|
||||
const active = ref(false);
|
||||
const pickedCell = ref<{ row: number; col: number } | null>(null);
|
||||
const caret = ref(0);
|
||||
|
||||
function setCaret(pos: number) {
|
||||
caret.value = Math.max(0, pos);
|
||||
}
|
||||
|
||||
function enterIfNeeded(draft: string, focused: boolean, editable: boolean) {
|
||||
if (!focused || !editable) {
|
||||
active.value = false;
|
||||
pickedCell.value = null;
|
||||
return;
|
||||
}
|
||||
const on = draft.trimStart().startsWith('=');
|
||||
active.value = on;
|
||||
if (!on) pickedCell.value = null;
|
||||
}
|
||||
|
||||
function exit() {
|
||||
active.value = false;
|
||||
pickedCell.value = null;
|
||||
}
|
||||
|
||||
function insertAtCaret(draft: string, text: string): string {
|
||||
const pos = caret.value;
|
||||
const next = draft.slice(0, pos) + text + draft.slice(pos);
|
||||
caret.value = pos + text.length;
|
||||
return next;
|
||||
}
|
||||
|
||||
function pickCell(row: number, col: number) {
|
||||
pickedCell.value = { row, col };
|
||||
}
|
||||
|
||||
return {
|
||||
active,
|
||||
pickedCell,
|
||||
caret,
|
||||
setCaret,
|
||||
enterIfNeeded,
|
||||
exit,
|
||||
insertAtCaret,
|
||||
pickCell,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { CellRange } from '../types';
|
||||
import { normalizeRange } from '../commands/FillRangeCommand';
|
||||
|
||||
export function useSelection() {
|
||||
const selection = ref<CellRange | null>(null);
|
||||
const anchor = ref<{ row: number; col: number } | null>(null);
|
||||
const activeCell = ref<{ row: number; col: number } | null>(null);
|
||||
const isFillDragging = ref(false);
|
||||
|
||||
function selectCell(row: number, col: number) {
|
||||
activeCell.value = { row, col };
|
||||
anchor.value = { row, col };
|
||||
selection.value = {
|
||||
startRow: row,
|
||||
startCol: col,
|
||||
endRow: row,
|
||||
endCol: col,
|
||||
};
|
||||
}
|
||||
|
||||
function extendSelection(row: number, col: number) {
|
||||
if (!anchor.value) {
|
||||
selectCell(row, col);
|
||||
return;
|
||||
}
|
||||
selection.value = {
|
||||
startRow: anchor.value.row,
|
||||
startCol: anchor.value.col,
|
||||
endRow: row,
|
||||
endCol: col,
|
||||
};
|
||||
activeCell.value = { row, col };
|
||||
}
|
||||
|
||||
function selectRange(range: CellRange) {
|
||||
selection.value = normalizeRange(range);
|
||||
activeCell.value = {
|
||||
row: selection.value.startRow,
|
||||
col: selection.value.startCol,
|
||||
};
|
||||
anchor.value = { ...activeCell.value };
|
||||
}
|
||||
|
||||
function getNormalizedSelection(): CellRange | null {
|
||||
return selection.value ? normalizeRange(selection.value) : null;
|
||||
}
|
||||
|
||||
function containsCell(row: number, col: number): boolean {
|
||||
const sel = getNormalizedSelection();
|
||||
if (!sel) return false;
|
||||
const r0 = Math.min(sel.startRow, sel.endRow);
|
||||
const r1 = Math.max(sel.startRow, sel.endRow);
|
||||
const c0 = Math.min(sel.startCol, sel.endCol);
|
||||
const c1 = Math.max(sel.startCol, sel.endCol);
|
||||
return row >= r0 && row <= r1 && col >= c0 && col <= c1;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selection.value = null;
|
||||
anchor.value = null;
|
||||
activeCell.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
selection,
|
||||
anchor,
|
||||
activeCell,
|
||||
isFillDragging,
|
||||
selectCell,
|
||||
extendSelection,
|
||||
selectRange,
|
||||
getNormalizedSelection,
|
||||
containsCell,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { ContextMenuItem } from '#/components/context-menu';
|
||||
import { menuDivider, showContextMenu } from '#/components/context-menu';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import type {
|
||||
CellEditContext,
|
||||
HeaderContextMenuContext,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetEngineExpose,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetTableConfig,
|
||||
} from '../types';
|
||||
|
||||
export interface HeaderLayoutActions {
|
||||
toggleFixed: (col: number) => void;
|
||||
setHeaderColor: (col: number, color?: string) => void;
|
||||
isFixed: (col: number) => boolean;
|
||||
getHeaderColor: (col: number) => string | undefined;
|
||||
}
|
||||
|
||||
export interface SpreadsheetAdminMenuActions {
|
||||
onOpenConfig: () => void;
|
||||
onAddColumn: () => void;
|
||||
onSetColumn: (col: number) => void;
|
||||
}
|
||||
|
||||
export interface SpreadsheetMenuActions {
|
||||
col: number;
|
||||
layoutActions?: HeaderLayoutActions;
|
||||
onOpenHeaderColorPicker: (col: number) => void;
|
||||
admin?: SpreadsheetAdminMenuActions;
|
||||
}
|
||||
|
||||
function resolveCustomItems<T>(
|
||||
config:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: T) => ContextMenuItem[])
|
||||
| undefined,
|
||||
ctx: T,
|
||||
): ContextMenuItem[] {
|
||||
if (!config) return [];
|
||||
return typeof config === 'function' ? config(ctx) : config;
|
||||
}
|
||||
|
||||
function buildCellBuiltinItems(
|
||||
ctx: CellEditContext,
|
||||
engine: SpreadsheetEngineExpose,
|
||||
onStartEdit: (row: number, col: number) => void,
|
||||
): ContextMenuItem[] {
|
||||
const canEditCell = engine.canEdit(ctx.rowIndex, ctx.colIndex);
|
||||
const sel = ctx.selection;
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
iconName: 'ant-design:edit-outlined',
|
||||
disabled: !canEditCell,
|
||||
handler: () => onStartEdit(ctx.rowIndex, ctx.colIndex),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
label: '复制',
|
||||
iconName: 'ant-design:copy-outlined',
|
||||
disabled: !sel,
|
||||
handler: () => {
|
||||
if (sel) {
|
||||
engine.copy(sel);
|
||||
message.success('复制成功');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'copy-image',
|
||||
label: '复制为图片',
|
||||
iconName: 'ant-design:picture-outlined',
|
||||
disabled: !sel,
|
||||
handler: () => {
|
||||
if (!sel) return;
|
||||
void engine
|
||||
.copyAsImage(sel)
|
||||
.then(() => {
|
||||
message.success('已复制为图片');
|
||||
})
|
||||
.catch(() => {
|
||||
message.warning('复制图片失败,请检查浏览器剪贴板权限');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'paste',
|
||||
label: '粘贴',
|
||||
iconName: 'ant-design:snippets-outlined',
|
||||
disabled: !engine.canPaste(),
|
||||
handler: () => {
|
||||
void engine.paste();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'undo',
|
||||
label: '撤回',
|
||||
iconName: 'ant-design:undo-outlined',
|
||||
disabled: !engine.canUndo(),
|
||||
handler: () => {
|
||||
void engine.undo();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildFixedItem(
|
||||
col: number,
|
||||
layoutActions?: HeaderLayoutActions,
|
||||
): ContextMenuItem | null {
|
||||
if (!layoutActions) return null;
|
||||
const fixed = layoutActions.isFixed(col);
|
||||
return {
|
||||
key: 'toggle-fixed',
|
||||
label: fixed ? '取消固定' : '固定此列',
|
||||
iconName: 'ant-design:pushpin-outlined',
|
||||
handler: () => layoutActions.toggleFixed(col),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHeaderColorItem(
|
||||
col: number,
|
||||
onOpen: (col: number) => void,
|
||||
): ContextMenuItem {
|
||||
return {
|
||||
key: 'header-color',
|
||||
label: '表头颜色',
|
||||
iconName: 'ant-design:bg-colors-outlined',
|
||||
handler: () => onOpen(col),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdminFlatItemsForCol(
|
||||
col: number,
|
||||
admin: SpreadsheetAdminMenuActions,
|
||||
): ContextMenuItem[] {
|
||||
return [
|
||||
{
|
||||
key: 'admin-config',
|
||||
label: 'Excel 配置',
|
||||
iconName: 'mdi:table-cog',
|
||||
handler: () => admin.onOpenConfig(),
|
||||
},
|
||||
{
|
||||
key: 'admin-add-col',
|
||||
label: '新增一列',
|
||||
iconName: 'ant-design:plus-outlined',
|
||||
handler: () => admin.onAddColumn(),
|
||||
},
|
||||
{
|
||||
key: 'admin-set-col',
|
||||
label: '设置列',
|
||||
iconName: 'ant-design:setting-outlined',
|
||||
handler: () => admin.onSetColumn(col),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildColumnSubmenu(menuActions: SpreadsheetMenuActions): ContextMenuItem {
|
||||
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
|
||||
const children: ContextMenuItem[] = [];
|
||||
|
||||
if (admin) {
|
||||
children.push({
|
||||
key: 'admin-add-col',
|
||||
label: '新增一列',
|
||||
iconName: 'ant-design:plus-outlined',
|
||||
handler: () => admin.onAddColumn(),
|
||||
});
|
||||
children.push({
|
||||
key: 'admin-set-col',
|
||||
label: '设置列',
|
||||
iconName: 'ant-design:setting-outlined',
|
||||
handler: () => admin.onSetColumn(col),
|
||||
});
|
||||
}
|
||||
|
||||
const fixedItem = buildFixedItem(col, layoutActions);
|
||||
if (fixedItem) children.push(fixedItem);
|
||||
|
||||
children.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
|
||||
|
||||
return {
|
||||
key: 'column-submenu',
|
||||
label: '列',
|
||||
iconName: 'ant-design:table-outlined',
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHeaderMenuItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
|
||||
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
const fixedItem = buildFixedItem(col, layoutActions);
|
||||
if (fixedItem) items.push(fixedItem);
|
||||
|
||||
items.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
|
||||
|
||||
if (admin) {
|
||||
items.push(...buildAdminFlatItemsForCol(col, admin));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildCellAdminItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
|
||||
if (!menuActions.admin) return [];
|
||||
return [
|
||||
{
|
||||
key: 'admin-config',
|
||||
label: 'Excel 配置',
|
||||
iconName: 'mdi:table-cog',
|
||||
handler: () => menuActions.admin!.onOpenConfig(),
|
||||
},
|
||||
buildColumnSubmenu(menuActions),
|
||||
];
|
||||
}
|
||||
|
||||
function mergeMenus(
|
||||
builtin: ContextMenuItem[],
|
||||
custom: ContextMenuItem[],
|
||||
): ContextMenuItem[] {
|
||||
if (!custom.length) return builtin;
|
||||
return [...builtin, menuDivider('__divider__'), ...custom];
|
||||
}
|
||||
|
||||
function appendAdminBlock(
|
||||
items: ContextMenuItem[],
|
||||
adminItems: ContextMenuItem[],
|
||||
): ContextMenuItem[] {
|
||||
if (!adminItems.length) return items;
|
||||
return [...items, menuDivider('__admin_divider__'), ...adminItems];
|
||||
}
|
||||
|
||||
export function openCellContextMenu(
|
||||
e: MouseEvent,
|
||||
ctx: CellEditContext,
|
||||
column: SpreadsheetColumnConfig | undefined,
|
||||
engine: SpreadsheetEngineExpose,
|
||||
onStartEdit: (row: number, col: number) => void,
|
||||
menuActions?: SpreadsheetMenuActions,
|
||||
) {
|
||||
const builtin = buildCellBuiltinItems(ctx, engine, onStartEdit);
|
||||
const custom = resolveCustomItems(column?.contextMenu, ctx);
|
||||
const adminItems = menuActions ? buildCellAdminItems(menuActions) : [];
|
||||
showContextMenu(
|
||||
e,
|
||||
appendAdminBlock(mergeMenus(builtin, custom), adminItems),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
export function openHeaderContextMenu(
|
||||
e: MouseEvent,
|
||||
ctx: HeaderContextMenuContext,
|
||||
header: SpreadsheetHeaderConfig | undefined,
|
||||
tableConfig: SpreadsheetTableConfig,
|
||||
menuActions: SpreadsheetMenuActions,
|
||||
) {
|
||||
const builtin = buildHeaderMenuItems(menuActions);
|
||||
let custom = resolveCustomItems(header?.contextMenu, ctx);
|
||||
if (!custom.length) {
|
||||
custom = resolveCustomItems(tableConfig.headerContextMenu, ctx);
|
||||
}
|
||||
showContextMenu(e, mergeMenus(builtin, custom), ctx);
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
import { computed, ref, shallowRef, watch, type Ref } from 'vue';
|
||||
|
||||
import { createFillRangeCommand, iterateRange, normalizeRange } from '../commands/FillRangeCommand';
|
||||
import { createPasteRangeCommand } from '../commands/PasteRangeCommand';
|
||||
import { createSetCellValueCommand } from '../commands/SetCellValueCommand';
|
||||
import {
|
||||
computeFixedWidth,
|
||||
getContentSize,
|
||||
getFixedColIndices,
|
||||
renderSelectionImage,
|
||||
} from '../canvas/SpreadsheetRenderer';
|
||||
import { applyViewState, isColumnFilterActive } from './filterSort';
|
||||
import {
|
||||
getRowHeight,
|
||||
hitTestCell,
|
||||
hitTestHeaderCol,
|
||||
measureColumnWidths,
|
||||
} from '../composables/useAutoMeasure';
|
||||
import { useClipboard } from '../composables/useClipboard';
|
||||
import { useEditHistory } from '../composables/useEditHistory';
|
||||
import { useSelection } from '../composables/useSelection';
|
||||
import { useUndoRedo } from '../composables/useUndoRedo';
|
||||
import type {
|
||||
CellEditContext,
|
||||
CellRange,
|
||||
SpreadsheetBatchCellChange,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetEngineExpose,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetHistoryEntry,
|
||||
SpreadsheetTableConfig,
|
||||
SpreadsheetViewState,
|
||||
SortDirection,
|
||||
} from '../types';
|
||||
import {
|
||||
createDefaultViewState,
|
||||
DEFAULT_COL_WIDTH,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
HEADER_HEIGHT,
|
||||
ROW_NUMBER_WIDTH,
|
||||
} from '../types';
|
||||
import {
|
||||
evaluateFormula,
|
||||
formatFormulaResult,
|
||||
type CellValueReader,
|
||||
type FormulaResult,
|
||||
} from '../formula/evaluator';
|
||||
import {
|
||||
formatCellWithSuffix,
|
||||
resolveConditionalStyle,
|
||||
} from '../composables/cellDisplay';
|
||||
|
||||
export function useSpreadsheetEngine(options: {
|
||||
tableConfig: Ref<SpreadsheetTableConfig>;
|
||||
mergedHeaders: Ref<SpreadsheetHeaderConfig[]>;
|
||||
columns: Ref<SpreadsheetColumnConfig[]>;
|
||||
data: Ref<Record<string, unknown>[]>;
|
||||
rowKeyField: Ref<string>;
|
||||
layoutColWidths: Ref<Record<number, number>>;
|
||||
scrollViewportHeight: Ref<number>;
|
||||
isDark: Ref<boolean>;
|
||||
onChange: (data: Record<string, unknown>[]) => void;
|
||||
}) {
|
||||
const sortedColumns = computed(() =>
|
||||
[...options.columns.value].sort((a, b) => a.col - b.col),
|
||||
);
|
||||
|
||||
const colWidths = ref<number[]>([]);
|
||||
const rowHeight = ref(DEFAULT_ROW_HEIGHT);
|
||||
const scrollTop = ref(0);
|
||||
const scrollLeft = ref(0);
|
||||
const viewportWidth = ref(800);
|
||||
const viewportHeight = ref(600);
|
||||
const filterValues = ref<Record<number, string>>({});
|
||||
const viewState = ref<SpreadsheetViewState>(createDefaultViewState());
|
||||
const initialSnapshot = shallowRef<Map<string, unknown>>(new Map());
|
||||
const dirtyKeys = ref<Set<string>>(new Set());
|
||||
const editing = ref<{ row: number; col: number } | null>(null);
|
||||
const measureCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const selectionApi = useSelection();
|
||||
const undoRedo = useUndoRedo();
|
||||
const clipboard = useClipboard();
|
||||
const history = useEditHistory(
|
||||
options.tableConfig.value.tableName,
|
||||
options.tableConfig.value.sheetKey,
|
||||
options.tableConfig.value.enableHistory !== false,
|
||||
);
|
||||
|
||||
const filteredRowIndices = computed(() =>
|
||||
applyViewState(
|
||||
options.data.value,
|
||||
sortedColumns.value,
|
||||
viewState.value,
|
||||
(dataRowIndex, colCfg, row) =>
|
||||
resolveDisplayValueForDataRow(dataRowIndex, colCfg, row),
|
||||
),
|
||||
);
|
||||
|
||||
function resolveDisplayValueForDataRow(
|
||||
dataRowIndex: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (colCfg.cellType === 'formula' && colCfg.formula) {
|
||||
const result = evaluateFormula(
|
||||
colCfg.formula,
|
||||
dataRowIndex,
|
||||
(viewRow, col) => {
|
||||
const cfg = colConfig(col);
|
||||
if (!cfg || cfg.cellType === 'formula') return 0;
|
||||
const dr = options.data.value[viewRow];
|
||||
return dr ? getCellValue(viewRow, cfg.fieldId) : 0;
|
||||
},
|
||||
);
|
||||
return formatFormulaResult(result);
|
||||
}
|
||||
const rowData = row ?? options.data.value[dataRowIndex];
|
||||
if (!rowData) return undefined;
|
||||
return rowData[colCfg.fieldId];
|
||||
}
|
||||
|
||||
const fixedCols = computed(() =>
|
||||
getFixedColIndices(options.mergedHeaders.value),
|
||||
);
|
||||
|
||||
const fixedWidth = computed(() =>
|
||||
computeFixedWidth(colWidths.value, fixedCols.value),
|
||||
);
|
||||
|
||||
const displayRowCount = computed(() => {
|
||||
const minRows = Math.max(
|
||||
1,
|
||||
Math.ceil(options.scrollViewportHeight.value / rowHeight.value),
|
||||
);
|
||||
return Math.max(filteredRowIndices.value.length, minRows);
|
||||
});
|
||||
|
||||
const colCount = computed(
|
||||
() => Math.max(...sortedColumns.value.map((c) => c.col), 0) + 1,
|
||||
);
|
||||
|
||||
const defaultColWidth = computed(
|
||||
() => options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH,
|
||||
);
|
||||
|
||||
const dataContentWidth = computed(
|
||||
() => ROW_NUMBER_WIDTH + colWidths.value.reduce((s, w) => s + w, 0),
|
||||
);
|
||||
|
||||
const displayContentWidth = computed(() =>
|
||||
Math.max(dataContentWidth.value, viewportWidth.value),
|
||||
);
|
||||
|
||||
const fillerColCount = computed(() => {
|
||||
const remaining = displayContentWidth.value - dataContentWidth.value;
|
||||
if (remaining <= 0) return 0;
|
||||
return Math.ceil(remaining / defaultColWidth.value);
|
||||
});
|
||||
|
||||
const displayColCount = computed(
|
||||
() => colCount.value + fillerColCount.value,
|
||||
);
|
||||
|
||||
const paddedColWidths = computed(() => {
|
||||
const defaultW = defaultColWidth.value;
|
||||
const result: number[] = [];
|
||||
for (let c = 0; c < displayColCount.value; c += 1) {
|
||||
result.push(colWidths.value[c] ?? defaultW);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function colConfig(col: number) {
|
||||
return sortedColumns.value.find((c) => c.col === col);
|
||||
}
|
||||
|
||||
function dataRowFromViewRow(viewRow: number) {
|
||||
return filteredRowIndices.value[viewRow] ?? viewRow;
|
||||
}
|
||||
|
||||
function viewRowFromDataRow(dataRow: number) {
|
||||
return filteredRowIndices.value.indexOf(dataRow);
|
||||
}
|
||||
|
||||
function isEditable(colCfg?: SpreadsheetColumnConfig) {
|
||||
if (!colCfg) return false;
|
||||
if (colCfg.cellType === 'readonly' || colCfg.cellType === 'formula') {
|
||||
return false;
|
||||
}
|
||||
return colCfg.editable !== false;
|
||||
}
|
||||
|
||||
const readCellForFormula: CellValueReader = (viewRow, col) => {
|
||||
const cfg = colConfig(col);
|
||||
if (!cfg || cfg.cellType === 'formula') return 0;
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
return getCellValue(dataRow, cfg.fieldId);
|
||||
};
|
||||
|
||||
function resolveNumericValue(
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
): number | null {
|
||||
if (colCfg.cellType === 'formula' && colCfg.formula) {
|
||||
const result: FormulaResult = evaluateFormula(
|
||||
colCfg.formula,
|
||||
viewRow,
|
||||
readCellForFormula,
|
||||
);
|
||||
if (result === 'DIV_ZERO' || result == null) return null;
|
||||
return result;
|
||||
}
|
||||
if (colCfg.cellType === 'number' && row) {
|
||||
const n = Number(row[colCfg.fieldId]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCellStyle(
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) {
|
||||
return resolveConditionalStyle(
|
||||
colCfg,
|
||||
resolveNumericValue(viewRow, colCfg, row),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDisplayValue(
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (colCfg.cellType === 'formula' && colCfg.formula) {
|
||||
const result = evaluateFormula(
|
||||
colCfg.formula,
|
||||
viewRow,
|
||||
readCellForFormula,
|
||||
);
|
||||
return formatFormulaResult(result);
|
||||
}
|
||||
if (!row) return undefined;
|
||||
return row[colCfg.fieldId];
|
||||
}
|
||||
|
||||
function getCellValue(dataRow: number, fieldId: string) {
|
||||
return options.data.value[dataRow]?.[fieldId];
|
||||
}
|
||||
|
||||
function buildCellContext(viewRow: number, col: number): CellEditContext | null {
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg) return null;
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
const rowData = options.data.value[dataRow];
|
||||
if (!rowData) return null;
|
||||
return {
|
||||
fieldId: colCfg.fieldId,
|
||||
fieldValue: rowData[colCfg.fieldId],
|
||||
rowIndex: viewRow,
|
||||
colIndex: col,
|
||||
rowKey: rowData[options.rowKeyField.value] as string | number,
|
||||
rowData,
|
||||
editParams: colCfg.editParams ?? {},
|
||||
selection: selectionApi.getNormalizedSelection() ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotKey(dataRow: number, fieldId: string) {
|
||||
const row = options.data.value[dataRow];
|
||||
const rowKey = row?.[options.rowKeyField.value];
|
||||
return `${rowKey}:${fieldId}`;
|
||||
}
|
||||
|
||||
function markDirty(dataRow: number, fieldId: string, value: unknown) {
|
||||
const key = snapshotKey(dataRow, fieldId);
|
||||
const initial = initialSnapshot.value.get(key);
|
||||
const next = new Set(dirtyKeys.value);
|
||||
if (initial !== undefined && initial !== value) {
|
||||
next.add(`${dataRow}:${fieldId}`);
|
||||
} else {
|
||||
next.delete(`${dataRow}:${fieldId}`);
|
||||
}
|
||||
dirtyKeys.value = next;
|
||||
}
|
||||
|
||||
function applyLocalValue(dataRow: number, fieldId: string, value: unknown) {
|
||||
const rows = options.data.value.map((r, i) =>
|
||||
i === dataRow ? { ...r, [fieldId]: value } : r,
|
||||
);
|
||||
options.onChange(rows);
|
||||
markDirty(dataRow, fieldId, value);
|
||||
}
|
||||
|
||||
async function persistChanges(
|
||||
items: Array<{ viewRow: number; col: number; value: unknown }>,
|
||||
opts: {
|
||||
recordHistory?: boolean;
|
||||
action?: SpreadsheetHistoryEntry['action'];
|
||||
} = {},
|
||||
) {
|
||||
const batchChanges: SpreadsheetBatchCellChange[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const colCfg = colConfig(item.col);
|
||||
if (!colCfg || !isEditable(colCfg)) continue;
|
||||
|
||||
const dataRow = dataRowFromViewRow(item.viewRow);
|
||||
const ctx = buildCellContext(item.viewRow, item.col);
|
||||
if (!ctx) continue;
|
||||
|
||||
const before = getCellValue(dataRow, colCfg.fieldId);
|
||||
applyLocalValue(dataRow, colCfg.fieldId, item.value);
|
||||
batchChanges.push({
|
||||
ctx: { ...ctx, fieldValue: item.value },
|
||||
before,
|
||||
after: item.value,
|
||||
col: item.col,
|
||||
viewRow: item.viewRow,
|
||||
});
|
||||
}
|
||||
|
||||
if (batchChanges.length === 0) return;
|
||||
|
||||
const onBatchEdit = options.tableConfig.value.onBatchEdit;
|
||||
if (onBatchEdit) {
|
||||
await onBatchEdit(batchChanges);
|
||||
} else {
|
||||
for (const ch of batchChanges) {
|
||||
const cfg = colConfig(ch.col);
|
||||
if (cfg?.onEdit) {
|
||||
await cfg.onEdit({ ...ch.ctx, fieldValue: ch.after });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
opts.recordHistory !== false &&
|
||||
options.tableConfig.value.enableHistory !== false
|
||||
) {
|
||||
for (const ch of batchChanges) {
|
||||
await history.recordChange({
|
||||
action: opts.action ?? 'edit',
|
||||
rowKey: ch.ctx.rowKey,
|
||||
fieldId: ch.ctx.fieldId,
|
||||
beforeSnapshot: ch.before,
|
||||
afterSnapshot: ch.after,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toPersistItems(
|
||||
batch: Array<{ row: number; col: number; value: unknown }>,
|
||||
) {
|
||||
return batch.map((ch) => ({
|
||||
viewRow: ch.row,
|
||||
col: ch.col,
|
||||
value: ch.value,
|
||||
}));
|
||||
}
|
||||
|
||||
async function commitCellChange(
|
||||
viewRow: number,
|
||||
col: number,
|
||||
newValue: unknown,
|
||||
recordHistory = true,
|
||||
action: SpreadsheetHistoryEntry['action'] = 'edit',
|
||||
) {
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg || !isEditable(colCfg)) return;
|
||||
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
const before = getCellValue(dataRow, colCfg.fieldId);
|
||||
if (before === newValue) return;
|
||||
|
||||
await persistChanges([{ viewRow, col, value: newValue }], {
|
||||
recordHistory,
|
||||
action,
|
||||
});
|
||||
|
||||
undoRedo.push(
|
||||
createSetCellValueCommand(
|
||||
() =>
|
||||
persistChanges([{ viewRow, col, value: before }], {
|
||||
recordHistory: false,
|
||||
}),
|
||||
() =>
|
||||
persistChanges([{ viewRow, col, value: newValue }], {
|
||||
recordHistory: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function setCellValue(
|
||||
viewRow: number,
|
||||
col: number,
|
||||
value: unknown,
|
||||
recordHistory = true,
|
||||
action: SpreadsheetHistoryEntry['action'] = 'edit',
|
||||
) {
|
||||
await commitCellChange(viewRow, col, value, recordHistory, action);
|
||||
}
|
||||
|
||||
async function fillRange(source: CellRange, target: CellRange) {
|
||||
const src = normalizeRange(source);
|
||||
const tgt = normalizeRange(target);
|
||||
const before: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
const after: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
|
||||
const srcRows = src.endRow - src.startRow + 1;
|
||||
const srcCols = src.endCol - src.startCol + 1;
|
||||
|
||||
iterateRange(tgt, (row, col) => {
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg || !isEditable(colCfg)) return;
|
||||
const relRow = (row - tgt.startRow) % srcRows;
|
||||
const relCol = (col - tgt.startCol) % srcCols;
|
||||
const srcRow = src.startRow + relRow;
|
||||
const srcCol = src.startCol + relCol;
|
||||
const srcColCfg = colConfig(srcCol);
|
||||
if (!srcColCfg) return;
|
||||
|
||||
const dataRow = dataRowFromViewRow(row);
|
||||
const beforeVal = getCellValue(dataRow, colCfg.fieldId);
|
||||
|
||||
const afterVal = getCellValue(
|
||||
dataRowFromViewRow(srcRow),
|
||||
srcColCfg.fieldId,
|
||||
);
|
||||
|
||||
before.push({ row, col, value: beforeVal });
|
||||
after.push({ row, col, value: afterVal });
|
||||
});
|
||||
|
||||
await persistChanges(toPersistItems(after), {
|
||||
recordHistory: true,
|
||||
action: 'fill',
|
||||
});
|
||||
|
||||
undoRedo.push(
|
||||
createFillRangeCommand(
|
||||
(batch) => persistChanges(toPersistItems(batch), { recordHistory: false }),
|
||||
before,
|
||||
after,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function copy(selectionOverride?: CellRange) {
|
||||
const sel = selectionOverride ?? selectionApi.getNormalizedSelection();
|
||||
if (!sel) return;
|
||||
const rows: unknown[][] = [];
|
||||
iterateRange(sel, (viewRow, col) => {
|
||||
const relRow = viewRow - sel.startRow;
|
||||
if (!rows[relRow]) rows[relRow] = [];
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg) {
|
||||
rows[relRow]![col - sel.startCol] = '';
|
||||
return;
|
||||
}
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
const row = options.data.value[dataRow];
|
||||
const raw = resolveDisplayValue(viewRow, colCfg, row);
|
||||
rows[relRow]![col - sel.startCol] = formatCellWithSuffix(raw, colCfg);
|
||||
});
|
||||
const matrix = {
|
||||
rows,
|
||||
startRow: sel.startRow,
|
||||
startCol: sel.startCol,
|
||||
};
|
||||
clipboard.setMatrix(matrix);
|
||||
void clipboard.writeSystemClipboard(matrix);
|
||||
}
|
||||
|
||||
async function copyAsImage(selectionOverride?: CellRange) {
|
||||
const sel = selectionOverride ?? selectionApi.getNormalizedSelection();
|
||||
if (!sel) return;
|
||||
const blob = await renderSelectionImage({
|
||||
selection: sel,
|
||||
headers: options.mergedHeaders.value,
|
||||
columns: sortedColumns.value,
|
||||
colWidths: paddedColWidths.value,
|
||||
rowHeight: rowHeight.value,
|
||||
defaultColWidth: defaultColWidth.value,
|
||||
defaultTextAlign: options.tableConfig.value.defaultTextAlign ?? 'left',
|
||||
isDark: options.isDark.value,
|
||||
filteredRowIndices: filteredRowIndices.value,
|
||||
rows: options.data.value,
|
||||
dirtyKeys: dirtyKeys.value,
|
||||
resolveDisplayValue,
|
||||
resolveCellStyle,
|
||||
});
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function paste() {
|
||||
let matrix = clipboard.getMatrix();
|
||||
if (!matrix) {
|
||||
const text = await clipboard.readSystemClipboard();
|
||||
if (!text) return;
|
||||
matrix = {
|
||||
rows: clipboard.tsvToMatrix(text),
|
||||
startRow: 0,
|
||||
startCol: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const start = selectionApi.activeCell.value ?? {
|
||||
row: 0,
|
||||
col: sortedColumns.value[0]?.col ?? 0,
|
||||
};
|
||||
|
||||
const before: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
const after: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
|
||||
matrix.rows.forEach((rowVals, rOff) => {
|
||||
rowVals.forEach((val, cOff) => {
|
||||
const viewRow = start.row + rOff;
|
||||
const col = start.col + cOff;
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg || !isEditable(colCfg)) return;
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
if (dataRow >= options.data.value.length) return;
|
||||
before.push({
|
||||
row: viewRow,
|
||||
col,
|
||||
value: getCellValue(dataRow, colCfg.fieldId),
|
||||
});
|
||||
after.push({ row: viewRow, col, value: val });
|
||||
});
|
||||
});
|
||||
|
||||
const applyBatch = (
|
||||
changes: Array<{ row: number; col: number; value: unknown }>,
|
||||
) => persistChanges(toPersistItems(changes), { recordHistory: false });
|
||||
|
||||
await persistChanges(toPersistItems(after), {
|
||||
recordHistory: true,
|
||||
action: 'paste',
|
||||
});
|
||||
|
||||
undoRedo.push(createPasteRangeCommand(applyBatch, before, after));
|
||||
}
|
||||
|
||||
function remeasure() {
|
||||
if (!measureCanvas.value) return;
|
||||
const defaultW =
|
||||
options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH;
|
||||
const measured = measureColumnWidths(
|
||||
measureCanvas.value,
|
||||
options.mergedHeaders.value,
|
||||
sortedColumns.value,
|
||||
options.data.value,
|
||||
defaultW,
|
||||
(dataRowIdx, colCfg, row) =>
|
||||
resolveDisplayValue(dataRowIdx, colCfg, row),
|
||||
formatCellWithSuffix,
|
||||
);
|
||||
const overrides = options.layoutColWidths.value;
|
||||
colWidths.value = measured.map((w, i) => overrides[i] ?? w);
|
||||
const firstType = sortedColumns.value[0]?.cellType ?? 'text';
|
||||
rowHeight.value = getRowHeight(
|
||||
firstType,
|
||||
options.tableConfig.value.rowHeight,
|
||||
);
|
||||
}
|
||||
|
||||
function initSnapshot() {
|
||||
const map = new Map<string, unknown>();
|
||||
options.data.value.forEach((row, dataRow) => {
|
||||
sortedColumns.value.forEach((col) => {
|
||||
map.set(snapshotKey(dataRow, col.fieldId), row[col.fieldId]);
|
||||
});
|
||||
});
|
||||
initialSnapshot.value = map;
|
||||
dirtyKeys.value = new Set();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => options.data.value,
|
||||
() => {
|
||||
if (initialSnapshot.value.size === 0) initSnapshot();
|
||||
remeasure();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => options.mergedHeaders.value,
|
||||
() => remeasure(),
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => options.layoutColWidths.value,
|
||||
() => remeasure(),
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const contentSize = computed(() =>
|
||||
getContentSize(
|
||||
colWidths.value,
|
||||
displayRowCount.value,
|
||||
rowHeight.value,
|
||||
displayContentWidth.value,
|
||||
),
|
||||
);
|
||||
|
||||
function setColumnFilter(col: number, checkedValues: string[] | undefined) {
|
||||
const filters = { ...viewState.value.filters };
|
||||
if (checkedValues === undefined) {
|
||||
delete filters[col];
|
||||
} else {
|
||||
filters[col] = { checkedValues };
|
||||
}
|
||||
viewState.value = { ...viewState.value, filters };
|
||||
}
|
||||
|
||||
function clearColumnFilter(col: number) {
|
||||
const next = { ...viewState.value.filters };
|
||||
delete next[col];
|
||||
viewState.value = { ...viewState.value, filters: next };
|
||||
}
|
||||
|
||||
function setSort(col: number, direction: SortDirection) {
|
||||
viewState.value = { ...viewState.value, sort: { col, direction } };
|
||||
}
|
||||
|
||||
function clearSort() {
|
||||
viewState.value = { ...viewState.value, sort: null };
|
||||
}
|
||||
|
||||
const engine: SpreadsheetEngineExpose = {
|
||||
copy,
|
||||
copyAsImage,
|
||||
paste,
|
||||
undo: undoRedo.undo,
|
||||
redo: undoRedo.redo,
|
||||
canUndo: undoRedo.canUndo,
|
||||
canRedo: undoRedo.canRedo,
|
||||
canCopy: () => !!selectionApi.getNormalizedSelection(),
|
||||
canCopyAsImage: () => !!selectionApi.getNormalizedSelection(),
|
||||
canPaste: () => clipboard.hasContent() || true,
|
||||
canEdit: (_row, col) => {
|
||||
const cfg = colConfig(col);
|
||||
return !!cfg && isEditable(cfg);
|
||||
},
|
||||
getSelection: selectionApi.getNormalizedSelection,
|
||||
getCellEditContext: (row, col) => buildCellContext(row, col),
|
||||
getHeaderContext: (col) => {
|
||||
const header =
|
||||
options.mergedHeaders.value.find((h) => h.col === col) ?? {
|
||||
title: '',
|
||||
col,
|
||||
};
|
||||
return {
|
||||
colIndex: col,
|
||||
fieldId: header.fieldId ?? colConfig(col)?.fieldId,
|
||||
headerTitle: header.title,
|
||||
filterValue: isColumnFilterActive(viewState.value.filters[col])
|
||||
? 'active'
|
||||
: '',
|
||||
};
|
||||
},
|
||||
setCellValue,
|
||||
fillRange,
|
||||
getColumnConfig: colConfig,
|
||||
getFilterValue: (col) =>
|
||||
isColumnFilterActive(viewState.value.filters[col]) ? 'active' : '',
|
||||
};
|
||||
|
||||
return {
|
||||
engine,
|
||||
sortedColumns,
|
||||
colWidths,
|
||||
rowHeight,
|
||||
scrollTop,
|
||||
scrollLeft,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
filterValues,
|
||||
viewState,
|
||||
setColumnFilter,
|
||||
clearColumnFilter,
|
||||
setSort,
|
||||
clearSort,
|
||||
dirtyKeys,
|
||||
editing,
|
||||
measureCanvas,
|
||||
filteredRowIndices,
|
||||
contentSize,
|
||||
displayRowCount,
|
||||
displayColCount,
|
||||
displayContentWidth,
|
||||
defaultColWidth,
|
||||
paddedColWidths,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
colCount,
|
||||
selectionApi,
|
||||
undoRedo,
|
||||
dataRowFromViewRow,
|
||||
viewRowFromDataRow,
|
||||
colConfig,
|
||||
isEditable,
|
||||
resolveDisplayValue,
|
||||
resolveCellStyle,
|
||||
buildCellContext,
|
||||
commitCellChange,
|
||||
remeasure,
|
||||
initSnapshot,
|
||||
hitTestCell: (x: number, y: number) =>
|
||||
hitTestCell(
|
||||
x,
|
||||
y,
|
||||
scrollTop.value,
|
||||
scrollLeft.value,
|
||||
paddedColWidths.value,
|
||||
rowHeight.value,
|
||||
displayRowCount.value,
|
||||
colCount.value,
|
||||
fixedCols.value,
|
||||
fixedWidth.value,
|
||||
),
|
||||
hitTestHeaderCol: (x: number) =>
|
||||
hitTestHeaderCol(
|
||||
x,
|
||||
scrollLeft.value,
|
||||
paddedColWidths.value,
|
||||
colCount.value,
|
||||
fixedCols.value,
|
||||
fixedWidth.value,
|
||||
),
|
||||
HEADER_HEIGHT,
|
||||
ROW_NUMBER_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
export type SpreadsheetEngine = ReturnType<typeof useSpreadsheetEngine>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
const MAX_STACK = 100;
|
||||
|
||||
export function useUndoRedo() {
|
||||
const undoStack = ref<SpreadsheetCommand[]>([]);
|
||||
const redoStack = ref<SpreadsheetCommand[]>([]);
|
||||
|
||||
function push(cmd: SpreadsheetCommand) {
|
||||
undoStack.value.push(cmd);
|
||||
if (undoStack.value.length > MAX_STACK) {
|
||||
undoStack.value.shift();
|
||||
}
|
||||
redoStack.value = [];
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
const cmd = undoStack.value.pop();
|
||||
if (!cmd) return;
|
||||
await cmd.undo();
|
||||
redoStack.value.push(cmd);
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
const cmd = redoStack.value.pop();
|
||||
if (!cmd) return;
|
||||
await cmd.redo();
|
||||
undoStack.value.push(cmd);
|
||||
}
|
||||
|
||||
function canUndo() {
|
||||
return undoStack.value.length > 0;
|
||||
}
|
||||
|
||||
function canRedo() {
|
||||
return redoStack.value.length > 0;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
undoStack.value = [];
|
||||
redoStack.value = [];
|
||||
}
|
||||
|
||||
return { push, undo, redo, canUndo, canRedo, clear };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Input, InputNumber } from 'ant-design-vue';
|
||||
|
||||
import { getDataColScreenX } from '../composables/useAutoMeasure';
|
||||
|
||||
import type { SpreadsheetColumnConfig } from '../types';
|
||||
|
||||
const BLUR_GUARD_MS = 150;
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
viewRow: number;
|
||||
col: number;
|
||||
colWidths: number[];
|
||||
rowHeight: number;
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
fixedCols: number[];
|
||||
fixedWidth: number;
|
||||
isDark?: boolean;
|
||||
column?: SpreadsheetColumnConfig;
|
||||
value: unknown;
|
||||
draftSeed?: string | number | null;
|
||||
selectAllOnFocus?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
commit: [value: unknown];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const inputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
|
||||
const draft = ref<string | number>('');
|
||||
const ignoreBlurUntil = ref(0);
|
||||
const suppressBlurCommit = ref(false);
|
||||
const isCommitting = ref(false);
|
||||
|
||||
const style = computed(() => {
|
||||
const left = getDataColScreenX(
|
||||
props.col,
|
||||
props.colWidths,
|
||||
props.scrollLeft,
|
||||
props.fixedCols,
|
||||
props.fixedWidth,
|
||||
);
|
||||
const top = props.viewRow * props.rowHeight - props.scrollTop;
|
||||
const width = props.colWidths[props.col] ?? 100;
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${props.rowHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function getNativeInput(): HTMLInputElement | null {
|
||||
const el = inputRef.value as
|
||||
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
|
||||
| null;
|
||||
if (!el) return null;
|
||||
if (el.$el) {
|
||||
return el.$el.querySelector('input') as HTMLInputElement | null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusInput(options?: { selectAll?: boolean }) {
|
||||
const el = inputRef.value as
|
||||
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
|
||||
| null;
|
||||
const native = getNativeInput();
|
||||
|
||||
if (native) {
|
||||
native.focus({ preventScroll: true });
|
||||
if (options?.selectAll) {
|
||||
native.select();
|
||||
} else {
|
||||
const len = String(native.value ?? '').length;
|
||||
native.setSelectionRange(len, len);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el) return;
|
||||
if (typeof el.focus === 'function') {
|
||||
el.focus();
|
||||
if (options?.selectAll) {
|
||||
el.select?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blurInput() {
|
||||
getNativeInput()?.blur();
|
||||
}
|
||||
|
||||
function getDraftValue(): unknown {
|
||||
return props.column?.cellType === 'number'
|
||||
? Number(draft.value)
|
||||
: draft.value;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (v, _old, onCleanup) => {
|
||||
let cancelled = false;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
if (!v) {
|
||||
suppressBlurCommit.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ignoreBlurUntil.value = Date.now() + BLUR_GUARD_MS;
|
||||
|
||||
if (props.draftSeed != null) {
|
||||
draft.value =
|
||||
props.column?.cellType === 'number'
|
||||
? Number(props.draftSeed)
|
||||
: String(props.draftSeed);
|
||||
} else {
|
||||
draft.value =
|
||||
props.column?.cellType === 'number'
|
||||
? Number(props.value ?? 0)
|
||||
: String(props.value ?? '');
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
if (cancelled || !props.visible) return;
|
||||
focusInput({ selectAll: props.selectAllOnFocus !== false });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function commitFromEnter() {
|
||||
if (isCommitting.value) return;
|
||||
isCommitting.value = true;
|
||||
suppressBlurCommit.value = true;
|
||||
submit();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitFromEnter();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressBlurCommit.value = true;
|
||||
blurInput();
|
||||
emit('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit('commit', getDraftValue());
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (Date.now() < ignoreBlurUntil.value) return;
|
||||
if (suppressBlurCommit.value) return;
|
||||
submit();
|
||||
}
|
||||
|
||||
function stopPointer(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getDraftValue,
|
||||
blurInput,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cell-editor-overlay"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
:style="style"
|
||||
@mousedown.stop="stopPointer"
|
||||
@click.stop="stopPointer"
|
||||
>
|
||||
<InputNumber
|
||||
v-if="column?.cellType === 'number'"
|
||||
ref="inputRef"
|
||||
v-model:value="draft as number"
|
||||
class="cell-editor-input"
|
||||
size="small"
|
||||
:controls="false"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model:value="draft as string"
|
||||
class="cell-editor-input"
|
||||
size="small"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cell-editor-overlay {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #165dff;
|
||||
background: #fff;
|
||||
|
||||
&.is-dark {
|
||||
border-color: #1668dc;
|
||||
background: #141414;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-editor-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(.ant-input-number),
|
||||
:deep(.ant-input) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
export type CellValueReader = (viewRow: number, col: number) => unknown;
|
||||
|
||||
export type FormulaResult = number | 'DIV_ZERO' | null;
|
||||
|
||||
class DivZeroError extends Error {
|
||||
constructor() {
|
||||
super('Division by zero');
|
||||
this.name = 'DivZeroError';
|
||||
}
|
||||
}
|
||||
|
||||
export function colLettersToIndex(letters: string): number {
|
||||
let n = 0;
|
||||
for (const ch of letters.toUpperCase()) {
|
||||
n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
export function colIndexToLetters(index: number): string {
|
||||
let n = index + 1;
|
||||
let result = '';
|
||||
while (n > 0) {
|
||||
n -= 1;
|
||||
result = String.fromCharCode(65 + (n % 26)) + result;
|
||||
n = Math.floor(n / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (value == null || value === '') return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : Number.NaN;
|
||||
}
|
||||
|
||||
function tokenize(expr: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
while (i < expr.length) {
|
||||
const ch = expr[i]!;
|
||||
if (ch === ' ' || ch === '\t') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ('+-*/()'.includes(ch)) {
|
||||
tokens.push(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9.]/.test(ch)) {
|
||||
let num = ch;
|
||||
i += 1;
|
||||
while (i < expr.length && /[0-9.]/.test(expr[i]!)) {
|
||||
num += expr[i];
|
||||
i += 1;
|
||||
}
|
||||
tokens.push(num);
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z]/.test(ch)) {
|
||||
let ref = ch;
|
||||
i += 1;
|
||||
while (i < expr.length && /[A-Za-z0-9]/.test(expr[i]!)) {
|
||||
ref += expr[i];
|
||||
i += 1;
|
||||
}
|
||||
tokens.push(ref);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unexpected character: ${ch}`);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseFactor(
|
||||
tokens: string[],
|
||||
pos: { i: number },
|
||||
readCell: CellValueReader,
|
||||
viewRow: number,
|
||||
): number {
|
||||
const token = tokens[pos.i];
|
||||
if (token === undefined) throw new Error('Unexpected end');
|
||||
|
||||
if (token === '(') {
|
||||
pos.i += 1;
|
||||
const value = parseExpression(tokens, pos, readCell, viewRow);
|
||||
if (tokens[pos.i] !== ')') throw new Error('Missing )');
|
||||
pos.i += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
if (token === '+') {
|
||||
pos.i += 1;
|
||||
return parseFactor(tokens, pos, readCell, viewRow);
|
||||
}
|
||||
|
||||
if (token === '-') {
|
||||
pos.i += 1;
|
||||
return -parseFactor(tokens, pos, readCell, viewRow);
|
||||
}
|
||||
|
||||
if (/^[0-9]+(\.[0-9]+)?$/.test(token)) {
|
||||
pos.i += 1;
|
||||
return Number(token);
|
||||
}
|
||||
|
||||
const match = /^([A-Za-z]+)(\d+)$/.exec(token);
|
||||
if (match) {
|
||||
pos.i += 1;
|
||||
const col = colLettersToIndex(match[1]!);
|
||||
return toNumber(readCell(viewRow, col));
|
||||
}
|
||||
|
||||
throw new Error(`Invalid token: ${token}`);
|
||||
}
|
||||
|
||||
function parseTerm(
|
||||
tokens: string[],
|
||||
pos: { i: number },
|
||||
readCell: CellValueReader,
|
||||
viewRow: number,
|
||||
): number {
|
||||
let value = parseFactor(tokens, pos, readCell, viewRow);
|
||||
while (pos.i < tokens.length) {
|
||||
const op = tokens[pos.i];
|
||||
if (op === '*' || op === '/') {
|
||||
pos.i += 1;
|
||||
const rhs = parseFactor(tokens, pos, readCell, viewRow);
|
||||
if (op === '/') {
|
||||
if (rhs === 0) throw new DivZeroError();
|
||||
value /= rhs;
|
||||
} else {
|
||||
value *= rhs;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseExpression(
|
||||
tokens: string[],
|
||||
pos: { i: number },
|
||||
readCell: CellValueReader,
|
||||
viewRow: number,
|
||||
): number {
|
||||
let value = parseTerm(tokens, pos, readCell, viewRow);
|
||||
while (pos.i < tokens.length) {
|
||||
const op = tokens[pos.i];
|
||||
if (op === '+' || op === '-') {
|
||||
pos.i += 1;
|
||||
const rhs = parseTerm(tokens, pos, readCell, viewRow);
|
||||
value = op === '+' ? value + rhs : value - rhs;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function evaluateFormula(
|
||||
formula: string,
|
||||
viewRow: number,
|
||||
readCell: CellValueReader,
|
||||
): FormulaResult {
|
||||
if (!formula?.trim()) return null;
|
||||
let expr = formula.trim();
|
||||
if (expr.startsWith('=')) expr = expr.slice(1);
|
||||
if (!expr) return null;
|
||||
|
||||
try {
|
||||
const tokens = tokenize(expr);
|
||||
const pos = { i: 0 };
|
||||
const result = parseExpression(tokens, pos, readCell, viewRow);
|
||||
if (pos.i < tokens.length) throw new Error('Trailing tokens');
|
||||
if (!Number.isFinite(result)) return null;
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof DivZeroError) return 'DIV_ZERO';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFormulaResult(value: FormulaResult): string {
|
||||
if (value === 'DIV_ZERO') return '-';
|
||||
if (value == null || Number.isNaN(value)) return '#ERROR';
|
||||
if (Number.isInteger(value)) return String(value);
|
||||
return String(Math.round(value * 100) / 100);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
SubMenu,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
import type { SortDirection } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
col: number;
|
||||
title: string;
|
||||
left: number;
|
||||
width: number;
|
||||
top: number;
|
||||
active: boolean;
|
||||
sortDirection?: SortDirection | null;
|
||||
uniqueValues: string[];
|
||||
checkedValues?: string[];
|
||||
disabled?: boolean;
|
||||
isDark?: boolean;
|
||||
loadingValues?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [direction: SortDirection];
|
||||
clearSort: [];
|
||||
clearFilter: [];
|
||||
open: [];
|
||||
'update:checkedValues': [values: string[] | undefined];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const search = ref('');
|
||||
|
||||
const effectiveChecked = computed(() => {
|
||||
if (props.checkedValues === undefined) {
|
||||
return new Set(props.uniqueValues);
|
||||
}
|
||||
return new Set(props.checkedValues);
|
||||
});
|
||||
|
||||
const filteredValues = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const list = props.uniqueValues;
|
||||
if (!q) return list.slice(0, 200);
|
||||
return list.filter((v) => v.toLowerCase().includes(q)).slice(0, 200);
|
||||
});
|
||||
|
||||
const allChecked = computed(
|
||||
() =>
|
||||
props.uniqueValues.length > 0 &&
|
||||
props.uniqueValues.every((v) => effectiveChecked.value.has(v)),
|
||||
);
|
||||
|
||||
const indeterminate = computed(() => {
|
||||
if (allChecked.value) return false;
|
||||
return props.uniqueValues.some((v) => effectiveChecked.value.has(v));
|
||||
});
|
||||
|
||||
function toggleValue(value: string, checked: boolean) {
|
||||
const next = new Set(effectiveChecked.value);
|
||||
if (checked) next.add(value);
|
||||
else next.delete(value);
|
||||
emit('update:checkedValues', [...next]);
|
||||
}
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
if (checked) {
|
||||
emit('update:checkedValues', undefined);
|
||||
} else {
|
||||
emit('update:checkedValues', []);
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) {
|
||||
emit('open');
|
||||
} else {
|
||||
search.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="header-column-filter"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
:style="{ left: `${left}px`, top: `${top}px`, width: `${width}px` }"
|
||||
>
|
||||
<Dropdown
|
||||
v-model:open="open"
|
||||
:trigger="['click']"
|
||||
placement="bottomRight"
|
||||
:disabled="disabled"
|
||||
:overlay-class-name="isDark ? 'sheet-header-filter-dropdown is-dark' : 'sheet-header-filter-dropdown'"
|
||||
>
|
||||
<Button
|
||||
class="filter-btn"
|
||||
:class="{
|
||||
'is-active': active,
|
||||
'is-sorted': !!sortDirection,
|
||||
}"
|
||||
size="small"
|
||||
type="text"
|
||||
@click.stop
|
||||
>
|
||||
<MIcon
|
||||
:icon="
|
||||
sortDirection === 'asc'
|
||||
? 'ant-design:sort-ascending-outlined'
|
||||
: sortDirection === 'desc'
|
||||
? 'ant-design:sort-descending-outlined'
|
||||
: 'ant-design:filter-outlined'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<template #overlay>
|
||||
<div class="filter-panel" :class="{ 'is-dark': isDark }" @click.stop>
|
||||
<Menu mode="vertical">
|
||||
<SubMenu key="sort" title="排序">
|
||||
<MenuItem @click="emit('sort', 'asc')">升序</MenuItem>
|
||||
<MenuItem @click="emit('sort', 'desc')">降序</MenuItem>
|
||||
<MenuItem @click="emit('clearSort')">清除排序</MenuItem>
|
||||
</SubMenu>
|
||||
<MenuItem @click="emit('clearFilter')">清除筛选</MenuItem>
|
||||
</Menu>
|
||||
<Divider style="margin: 8px 0" />
|
||||
<Input
|
||||
v-model:value="search"
|
||||
allow-clear
|
||||
placeholder="搜索"
|
||||
size="small"
|
||||
/>
|
||||
<div v-if="loadingValues" class="value-loading">加载中...</div>
|
||||
<div v-else class="value-list">
|
||||
<Checkbox
|
||||
:checked="allChecked"
|
||||
:indeterminate="indeterminate"
|
||||
@change="(e) => toggleAll(!!e.target.checked)"
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
v-for="val in filteredValues"
|
||||
:key="val || '__empty__'"
|
||||
:checked="effectiveChecked.has(val)"
|
||||
@change="(e) => toggleValue(val, !!e.target.checked)"
|
||||
>
|
||||
{{ val || '(空白)' }}
|
||||
</Checkbox>
|
||||
<div
|
||||
v-if="uniqueValues.length > 200 && !search.trim()"
|
||||
class="value-hint"
|
||||
>
|
||||
值过多,请搜索缩小范围
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-column-filter {
|
||||
position: absolute;
|
||||
height: 36px;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
pointer-events: auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #86909c;
|
||||
|
||||
&.is-active,
|
||||
&.is-sorted {
|
||||
color: #1668dc;
|
||||
background: rgba(22, 104, 220, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.header-column-filter.is-dark .filter-btn {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&.is-active,
|
||||
&.is-sorted {
|
||||
color: #3c89e8;
|
||||
background: rgba(22, 104, 220, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
width: 240px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
&.is-dark {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #424242;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
|
||||
|
||||
:deep(.ant-menu) {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item),
|
||||
:deep(.ant-menu-submenu-title) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover {
|
||||
background: #303030 !important;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-divider) {
|
||||
border-color: #424242;
|
||||
}
|
||||
|
||||
:deep(.ant-input) {
|
||||
background: #141414;
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-input-clear-icon) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.value-hint,
|
||||
.value-loading {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.value-hint,
|
||||
.value-loading {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.sheet-header-filter-dropdown.is-dark {
|
||||
.ant-dropdown-menu {
|
||||
background: #1f1f1f;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { getDataColScreenX } from '../composables/useAutoMeasure';
|
||||
import {
|
||||
collectColumnUniqueValues,
|
||||
isColumnFilterActive,
|
||||
} from '../composables/filterSort';
|
||||
import type {
|
||||
SortDirection,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetViewState,
|
||||
} from '../types';
|
||||
import { COLUMN_LETTER_HEIGHT, HEADER_TITLE_HEIGHT } from '../types';
|
||||
|
||||
import HeaderColumnFilter from './HeaderColumnFilter.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
columns: SpreadsheetColumnConfig[];
|
||||
rows: Record<string, unknown>[];
|
||||
colWidths: number[];
|
||||
scrollLeft: number;
|
||||
fixedCols: number[];
|
||||
fixedWidth: number;
|
||||
viewportWidth: number;
|
||||
viewState: SpreadsheetViewState;
|
||||
enableFilter?: boolean;
|
||||
isDark?: boolean;
|
||||
resolveDisplayValue: (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => unknown;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [col: number, direction: SortDirection];
|
||||
clearSort: [];
|
||||
clearFilter: [col: number];
|
||||
'update:columnFilter': [col: number, checkedValues: string[] | undefined];
|
||||
}>();
|
||||
|
||||
const uniqueCache = ref<Record<number, string[]>>({});
|
||||
const loadingCols = ref<Set<number>>(new Set());
|
||||
const rowsVersion = ref(0);
|
||||
|
||||
const filterableHeaders = computed(() =>
|
||||
props.headers.filter((h) => h.filterable !== false),
|
||||
);
|
||||
|
||||
function invalidateCache() {
|
||||
uniqueCache.value = {};
|
||||
rowsVersion.value += 1;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.rows.length,
|
||||
invalidateCache,
|
||||
);
|
||||
|
||||
function ensureUniqueValues(col: number) {
|
||||
if (uniqueCache.value[col]?.length !== undefined) return;
|
||||
loadingCols.value = new Set([...loadingCols.value, col]);
|
||||
uniqueCache.value[col] = collectColumnUniqueValues(
|
||||
props.rows,
|
||||
col,
|
||||
props.columns,
|
||||
props.resolveDisplayValue,
|
||||
);
|
||||
const next = new Set(loadingCols.value);
|
||||
next.delete(col);
|
||||
loadingCols.value = next;
|
||||
}
|
||||
|
||||
function onFilterOpen(col: number) {
|
||||
ensureUniqueValues(col);
|
||||
}
|
||||
|
||||
function getUniqueValues(col: number): string[] {
|
||||
return uniqueCache.value[col] ?? [];
|
||||
}
|
||||
|
||||
function isLoadingCol(col: number): boolean {
|
||||
return loadingCols.value.has(col);
|
||||
}
|
||||
|
||||
function headerLeft(col: number): number {
|
||||
return getDataColScreenX(
|
||||
col,
|
||||
props.colWidths,
|
||||
props.scrollLeft,
|
||||
props.fixedCols,
|
||||
props.fixedWidth,
|
||||
);
|
||||
}
|
||||
|
||||
function isFilterActive(col: number): boolean {
|
||||
return isColumnFilterActive(props.viewState.filters[col]);
|
||||
}
|
||||
|
||||
function sortDirection(col: number): SortDirection | null {
|
||||
if (props.viewState.sort?.col === col) {
|
||||
return props.viewState.sort.direction;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="enableFilter !== false"
|
||||
class="header-filter-layer"
|
||||
:style="{ width: `${viewportWidth}px`, height: `${COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT}px` }"
|
||||
>
|
||||
<HeaderColumnFilter
|
||||
v-for="header in filterableHeaders"
|
||||
:key="header.col"
|
||||
:active="isFilterActive(header.col)"
|
||||
:checked-values="viewState.filters[header.col]?.checkedValues"
|
||||
:col="header.col"
|
||||
:is-dark="isDark"
|
||||
:left="headerLeft(header.col)"
|
||||
:loading-values="isLoadingCol(header.col)"
|
||||
:sort-direction="sortDirection(header.col)"
|
||||
:title="header.title"
|
||||
:top="COLUMN_LETTER_HEIGHT"
|
||||
:unique-values="getUniqueValues(header.col)"
|
||||
:width="colWidths[header.col] ?? 100"
|
||||
@clear-filter="emit('clearFilter', header.col)"
|
||||
@clear-sort="emit('clearSort')"
|
||||
@open="onFilterOpen(header.col)"
|
||||
@sort="(dir) => emit('sort', header.col, dir)"
|
||||
@update:checked-values="
|
||||
(v) => emit('update:columnFilter', header.col, v)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-filter-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import ColorPicker from '#/components/form/components/color-picker.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
col: number;
|
||||
color: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:color': [value: string];
|
||||
apply: [col: number, color: string | undefined];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const draftColor = ref(props.color);
|
||||
|
||||
watch(
|
||||
() => props.color,
|
||||
(v) => {
|
||||
draftColor.value = v;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
draftColor.value = props.color;
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
} else {
|
||||
removeListeners();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function removeListeners() {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const target = e.target as Node | null;
|
||||
const panel = panelRef.value;
|
||||
if (panel && target && !panel.contains(target)) {
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function onColorUpdate(value: string) {
|
||||
draftColor.value = value;
|
||||
emit('update:color', value);
|
||||
emit('apply', props.col, value || undefined);
|
||||
}
|
||||
|
||||
onUnmounted(removeListeners);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panelRef"
|
||||
class="header-color-picker-popover"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div class="popover-title">表头颜色</div>
|
||||
<ColorPicker
|
||||
:value="draftColor"
|
||||
placeholder="选择颜色"
|
||||
@update:value="onColorUpdate"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-color-picker-popover {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.is-dark .header-color-picker-popover {
|
||||
background: #1f1f1f;
|
||||
border-color: #424242;
|
||||
|
||||
.popover-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SpreadsheetLayoutState } from '../types';
|
||||
|
||||
function storageKey(tableName: string, sheetKey?: string) {
|
||||
return `xk_spreadsheet_layout_${tableName}_${sheetKey ?? 'default'}`;
|
||||
}
|
||||
|
||||
export function readSpreadsheetLayout(
|
||||
tableName: string,
|
||||
sheetKey?: string,
|
||||
): SpreadsheetLayoutState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(tableName, sheetKey));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as SpreadsheetLayoutState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSpreadsheetLayout(
|
||||
tableName: string,
|
||||
sheetKey: string | undefined,
|
||||
state: SpreadsheetLayoutState,
|
||||
) {
|
||||
localStorage.setItem(storageKey(tableName, sheetKey), JSON.stringify(state));
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function debouncedWriteSpreadsheetLayout(
|
||||
tableName: string,
|
||||
sheetKey: string | undefined,
|
||||
state: SpreadsheetLayoutState,
|
||||
delayMs = 400,
|
||||
) {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
writeSpreadsheetLayout(tableName, sheetKey, state);
|
||||
saveTimer = null;
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { SpreadsheetHistoryEntry } from '../types';
|
||||
|
||||
const DB_NAME = 'xk_spreadsheet_history';
|
||||
const STORE_NAME = 'entries';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
store.createIndex('tableName', 'tableName', { unique: false });
|
||||
store.createIndex('tableSheet', ['tableName', 'sheetKey'], {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
store.createIndex('synced', 'synced', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendLocalHistory(entry: SpreadsheetHistoryEntry) {
|
||||
const db = await openDb();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put(entry);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listLocalHistory(
|
||||
tableName: string,
|
||||
sheetKey?: string,
|
||||
limit = 100,
|
||||
): Promise<SpreadsheetHistoryEntry[]> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const index = store.index('tableName');
|
||||
const req = index.getAll(tableName);
|
||||
req.onsuccess = () => {
|
||||
let items = (req.result as SpreadsheetHistoryEntry[]) ?? [];
|
||||
if (sheetKey) {
|
||||
items = items.filter((i) => i.sheetKey === sheetKey);
|
||||
}
|
||||
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
resolve(items.slice(0, limit));
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUnsyncedHistory(): Promise<SpreadsheetHistoryEntry[]> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const req = tx.objectStore(STORE_NAME).getAll();
|
||||
req.onsuccess = () => {
|
||||
const items = ((req.result as SpreadsheetHistoryEntry[]) ?? []).filter(
|
||||
(i) => !i.synced,
|
||||
);
|
||||
resolve(items);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function markHistorySynced(ids: string[]) {
|
||||
if (!ids.length) return;
|
||||
const db = await openDb();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const id of ids) {
|
||||
const getReq = store.get(id);
|
||||
getReq.onsuccess = () => {
|
||||
const entry = getReq.result as SpreadsheetHistoryEntry | undefined;
|
||||
if (entry) {
|
||||
entry.synced = true;
|
||||
store.put(entry);
|
||||
}
|
||||
};
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import type { SpreadsheetHistoryEntry } from '../types';
|
||||
|
||||
const prefix = 'spreadsheet-history/';
|
||||
|
||||
export interface RemoteHistoryItem {
|
||||
id: number;
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
admin_user_id: number;
|
||||
admin_user_name: string;
|
||||
row_key: string;
|
||||
field_id: string;
|
||||
action: string;
|
||||
before_snapshot: unknown;
|
||||
after_snapshot: unknown;
|
||||
client_entry_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function saveHistoryBatch(
|
||||
entries: SpreadsheetHistoryEntry[],
|
||||
) {
|
||||
return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, {
|
||||
entries: entries.map((e) => ({
|
||||
client_entry_id: e.id,
|
||||
table_name: e.tableName,
|
||||
sheet_key: e.sheetKey,
|
||||
row_key: String(e.rowKey),
|
||||
field_id: e.fieldId,
|
||||
action: e.action,
|
||||
before_snapshot: e.beforeSnapshot,
|
||||
after_snapshot: e.afterSnapshot,
|
||||
created_at: e.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRemoteHistory(params: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
items: RemoteHistoryItem[];
|
||||
total: number;
|
||||
}>(`${prefix}list`, { params });
|
||||
}
|
||||
|
||||
export async function rollbackRemoteHistory(id: number) {
|
||||
return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id });
|
||||
}
|
||||
4
apps/web-antd/src/components/canvas-spreadsheet/index.ts
Normal file
4
apps/web-antd/src/components/canvas-spreadsheet/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as CanvasSpreadsheet } from './CanvasSpreadsheet.vue';
|
||||
export * from './types';
|
||||
export { resolveColumnTextAlign } from './composables/columnTextAlign';
|
||||
export { colIndexToLetters, colLettersToIndex } from './formula/evaluator';
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Input, Popover } from 'ant-design-vue';
|
||||
|
||||
import type { SpreadsheetFormulaItem } from '#/api/spreadsheet-formula';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
cellAddress: string;
|
||||
sourceValue: string;
|
||||
editable: boolean;
|
||||
formulas: SpreadsheetFormulaItem[];
|
||||
isDark?: boolean;
|
||||
referenceMode?: boolean;
|
||||
caretPosition?: number;
|
||||
}>(),
|
||||
{
|
||||
isDark: false,
|
||||
referenceMode: false,
|
||||
caretPosition: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
commit: [value: string];
|
||||
'draft-change': [value: string];
|
||||
'caret-change': [position: number];
|
||||
}>();
|
||||
|
||||
const draft = ref('');
|
||||
const fxOpen = ref(false);
|
||||
const focused = ref(false);
|
||||
const inputRef = ref<{ input: HTMLInputElement } | null>(null);
|
||||
|
||||
function syncDraftFromProps() {
|
||||
if (focused.value) return;
|
||||
draft.value = props.sourceValue;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.cellAddress,
|
||||
() => syncDraftFromProps(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.sourceValue,
|
||||
() => syncDraftFromProps(),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.caretPosition,
|
||||
async (pos) => {
|
||||
if (!focused.value || pos == null) return;
|
||||
await nextTick();
|
||||
const el = inputRef.value?.input;
|
||||
if (el) {
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function syncCaret() {
|
||||
const el = inputRef.value?.input;
|
||||
if (!el) return;
|
||||
emit('caret-change', el.selectionStart ?? draft.value.length);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
focused.value = true;
|
||||
syncCaret();
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
focused.value = false;
|
||||
if (!props.editable || props.referenceMode) return;
|
||||
emit('commit', draft.value);
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
focused.value = false;
|
||||
if (!props.editable) return;
|
||||
emit('commit', draft.value);
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
emit('draft-change', draft.value);
|
||||
syncCaret();
|
||||
}
|
||||
|
||||
function insertFormula(item: SpreadsheetFormulaItem) {
|
||||
draft.value = item.expression_template.replace(/REF(\d+)/g, (_, n) => {
|
||||
return `${String.fromCharCode(65 + Number(n))}1`;
|
||||
});
|
||||
emit('draft-change', draft.value);
|
||||
fxOpen.value = false;
|
||||
}
|
||||
|
||||
function insertAtCaret(text: string) {
|
||||
const pos = props.caretPosition ?? draft.value.length;
|
||||
draft.value = draft.value.slice(0, pos) + text + draft.value.slice(pos);
|
||||
const nextCaret = pos + text.length;
|
||||
emit('draft-change', draft.value);
|
||||
emit('caret-change', nextCaret);
|
||||
void nextTick(() => {
|
||||
const el = inputRef.value?.input;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(nextCaret, nextCaret);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
inputRef.value?.input?.focus();
|
||||
}
|
||||
|
||||
const formulaList = computed(() => props.formulas);
|
||||
|
||||
defineExpose({
|
||||
insertAtCaret,
|
||||
focusInput,
|
||||
getDraft: () => draft.value,
|
||||
setDraft: (v: string) => {
|
||||
draft.value = v;
|
||||
emit('draft-change', v);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="spreadsheet-formula-bar"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
>
|
||||
<div class="cell-address">{{ cellAddress || ' ' }}</div>
|
||||
<Popover
|
||||
v-model:open="fxOpen"
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
:overlay-class-name="
|
||||
isDark ? 'sheet-fx-popover is-dark' : 'sheet-fx-popover'
|
||||
"
|
||||
>
|
||||
<template #content>
|
||||
<div class="fx-list" :class="{ 'is-dark': isDark }">
|
||||
<div
|
||||
v-for="item in formulaList"
|
||||
:key="item.id"
|
||||
class="fx-item"
|
||||
@click="insertFormula(item)"
|
||||
>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.expression_template }}</span>
|
||||
<small v-if="item.description">{{ item.description }}</small>
|
||||
</div>
|
||||
<div v-if="!formulaList.length" class="fx-empty">暂无公式</div>
|
||||
</div>
|
||||
</template>
|
||||
<Button class="fx-btn" size="small" type="text">fx</Button>
|
||||
</Popover>
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model:value="draft"
|
||||
class="formula-input"
|
||||
:readonly="!editable"
|
||||
@blur="onBlur"
|
||||
@click="syncCaret"
|
||||
@focus="onFocus"
|
||||
@input="onInput"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@keyup="syncCaret"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spreadsheet-formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid #e5e6eb;
|
||||
background: #fafafa;
|
||||
|
||||
&.is-dark {
|
||||
background: #1f1f1f;
|
||||
border-top-color: #424242;
|
||||
|
||||
.cell-address {
|
||||
background: #141414;
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.fx-btn {
|
||||
color: #3c89e8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cell-address {
|
||||
min-width: 48px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fx-btn {
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
color: #1668dc;
|
||||
}
|
||||
|
||||
.formula-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fx-list {
|
||||
width: 320px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fx-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
|
||||
.fx-list.is-dark {
|
||||
.fx-item {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
small {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.fx-empty {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.fx-empty {
|
||||
padding: 12px;
|
||||
color: #86909c;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.sheet-fx-popover.is-dark {
|
||||
.ant-popover-inner {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #424242;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
createSpreadsheetFormula,
|
||||
deleteSpreadsheetFormula,
|
||||
listSpreadsheetFormula,
|
||||
type SpreadsheetFormulaItem,
|
||||
updateSpreadsheetFormula,
|
||||
} from '#/api/spreadsheet-formula';
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: [];
|
||||
}>();
|
||||
|
||||
const items = ref<SpreadsheetFormulaItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const formCode = ref('');
|
||||
const formName = ref('');
|
||||
const formDesc = ref('');
|
||||
const formExpression = ref('');
|
||||
const formRefSlots = ref('[{"slot":"REF0","label":"参数1"}]');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '全局公式库',
|
||||
class: 'w-[720px]',
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) void loadList();
|
||||
},
|
||||
});
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
items.value = await listSpreadsheetFormula();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
formCode.value = '';
|
||||
formName.value = '';
|
||||
formDesc.value = '';
|
||||
formExpression.value = '';
|
||||
formRefSlots.value = '[{"slot":"REF0","label":"参数1"}]';
|
||||
}
|
||||
|
||||
function startEdit(item: SpreadsheetFormulaItem) {
|
||||
editingId.value = item.id;
|
||||
formCode.value = item.code;
|
||||
formName.value = item.name;
|
||||
formDesc.value = item.description;
|
||||
formExpression.value = item.expression_template;
|
||||
formRefSlots.value = JSON.stringify(item.ref_slots, null, 2);
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
let refSlots: { slot: string; label: string }[];
|
||||
try {
|
||||
refSlots = JSON.parse(formRefSlots.value);
|
||||
if (!Array.isArray(refSlots)) throw new Error('invalid');
|
||||
} catch {
|
||||
message.error('ref_slots 必须是 JSON 数组');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateSpreadsheetFormula({
|
||||
id: editingId.value,
|
||||
code: formCode.value,
|
||||
name: formName.value,
|
||||
description: formDesc.value,
|
||||
expression_template: formExpression.value,
|
||||
ref_slots: refSlots,
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await createSpreadsheetFormula({
|
||||
code: formCode.value,
|
||||
name: formName.value,
|
||||
description: formDesc.value,
|
||||
expression_template: formExpression.value,
|
||||
ref_slots: refSlots,
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
resetForm();
|
||||
await loadList();
|
||||
emit('updated');
|
||||
} catch (err) {
|
||||
message.error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(id: number) {
|
||||
await deleteSpreadsheetFormula(id);
|
||||
message.success('已删除');
|
||||
await loadList();
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '表达式', dataIndex: 'expression_template' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
},
|
||||
]);
|
||||
|
||||
defineExpose({
|
||||
open: () => modalApi.open(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<div class="formula-library">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button size="small" type="link" @click="startEdit(record)">
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除?" @confirm="removeItem(record.id)">
|
||||
<Button danger size="small" type="link">删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div class="formula-form">
|
||||
<Space direction="vertical" style="width: 100%">
|
||||
<Input v-model:value="formCode" placeholder="编码 code" />
|
||||
<Input v-model:value="formName" placeholder="显示名称" />
|
||||
<Input v-model:value="formDesc" placeholder="说明" />
|
||||
<Input
|
||||
v-model:value="formExpression"
|
||||
placeholder="表达式模板,如 =(REF0-REF1)/REF0"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-model:value="formRefSlots"
|
||||
:rows="4"
|
||||
placeholder='ref_slots JSON,如 [{"slot":"REF0","label":"被减数"}]'
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" @click="saveForm">
|
||||
{{ editingId ? '保存' : '新建' }}
|
||||
</Button>
|
||||
<Button @click="resetForm">清空</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.formula-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formula-form {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5e6eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Empty,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
import { listLocalHistory } from '../history/localHistoryStore';
|
||||
import { listRemoteHistory } from '../history/remoteHistoryApi';
|
||||
import type { SpreadsheetHistoryEntry, SpreadsheetTableConfig } from '../types';
|
||||
|
||||
import SpreadsheetFormulaLibraryModal from './SpreadsheetFormulaLibraryModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
tableConfig: SpreadsheetTableConfig;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isDark?: boolean;
|
||||
refreshing?: boolean;
|
||||
showConfigButton?: boolean;
|
||||
showFormulaLibrary?: boolean;
|
||||
workbookName?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
undo: [];
|
||||
redo: [];
|
||||
refresh: [];
|
||||
openConfig: [];
|
||||
rollback: [entry: SpreadsheetHistoryEntry];
|
||||
formulaLibraryUpdated: [];
|
||||
}>();
|
||||
|
||||
const historyOpen = ref(false);
|
||||
const historyItems = ref<SpreadsheetHistoryEntry[]>([]);
|
||||
const loadingHistory = ref(false);
|
||||
const formulaLibraryRef = ref<InstanceType<
|
||||
typeof SpreadsheetFormulaLibraryModal
|
||||
> | null>(null);
|
||||
let historyLoadToken = 0;
|
||||
|
||||
function invalidateHistoryLoad() {
|
||||
historyLoadToken += 1;
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
historyOpen.value = true;
|
||||
const token = ++historyLoadToken;
|
||||
loadingHistory.value = true;
|
||||
try {
|
||||
const local = await listLocalHistory(
|
||||
props.tableConfig.tableName,
|
||||
props.tableConfig.sheetKey,
|
||||
);
|
||||
if (token !== historyLoadToken) return;
|
||||
try {
|
||||
const remote = await listRemoteHistory({
|
||||
table_name: props.tableConfig.tableName,
|
||||
sheet_key: props.tableConfig.sheetKey,
|
||||
pageSize: 50,
|
||||
});
|
||||
if (token !== historyLoadToken) return;
|
||||
const merged = [...local];
|
||||
for (const r of remote.items ?? []) {
|
||||
if (!merged.some((m) => m.id === r.client_entry_id)) {
|
||||
merged.push({
|
||||
id: r.client_entry_id ?? String(r.id),
|
||||
tableName: r.table_name,
|
||||
sheetKey: r.sheet_key,
|
||||
userId: r.admin_user_id,
|
||||
userName: r.admin_user_name,
|
||||
action: r.action as SpreadsheetHistoryEntry['action'],
|
||||
rowKey: r.row_key,
|
||||
fieldId: r.field_id,
|
||||
beforeSnapshot: r.before_snapshot,
|
||||
afterSnapshot: r.after_snapshot,
|
||||
createdAt: r.created_at,
|
||||
synced: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
historyItems.value = merged.slice(0, 100);
|
||||
} catch {
|
||||
if (token !== historyLoadToken) return;
|
||||
historyItems.value = local;
|
||||
}
|
||||
} finally {
|
||||
if (token === historyLoadToken) {
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(entry: SpreadsheetHistoryEntry) {
|
||||
emit('rollback', entry);
|
||||
historyOpen.value = false;
|
||||
}
|
||||
|
||||
function openFormulaLibrary() {
|
||||
formulaLibraryRef.value?.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spreadsheet-toolbar" :class="{ 'is-dark': isDark }">
|
||||
<div class="toolbar-row toolbar-main">
|
||||
<Tag v-if="workbookName" class="workbook-tag" color="processing">
|
||||
{{ workbookName }}
|
||||
</Tag>
|
||||
<span v-else class="workbook-tag plain">{{ tableConfig.tableName }}</span>
|
||||
|
||||
<span class="toolbar-divider" />
|
||||
|
||||
<Space :size="4">
|
||||
<Tooltip title="撤回 (Ctrl+Z)">
|
||||
<Button size="small" :disabled="!canUndo" @click="emit('undo')">
|
||||
<MIcon icon="ant-design:undo-outlined" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="重做 (Ctrl+Y)">
|
||||
<Button size="small" :disabled="!canRedo" @click="emit('redo')">
|
||||
<MIcon icon="ant-design:redo-outlined" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
size="small"
|
||||
:disabled="refreshing"
|
||||
:loading="refreshing"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<MIcon icon="ant-design:reload-outlined" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-if="tableConfig.enableHistory !== false"
|
||||
size="small"
|
||||
@click="openHistory"
|
||||
>
|
||||
<MIcon icon="ant-design:history-outlined" />
|
||||
历史
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<span class="toolbar-divider" />
|
||||
|
||||
<Space :size="4">
|
||||
<Button
|
||||
v-if="showConfigButton"
|
||||
size="small"
|
||||
@click="emit('openConfig')"
|
||||
>
|
||||
<MIcon icon="mdi:table-cog" />
|
||||
Excel 配置
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showFormulaLibrary"
|
||||
size="small"
|
||||
@click="openFormulaLibrary"
|
||||
>
|
||||
<MIcon icon="mdi:function-variant" />
|
||||
公式库
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<slot name="formula-bar" />
|
||||
|
||||
<SpreadsheetFormulaLibraryModal
|
||||
ref="formulaLibraryRef"
|
||||
@updated="emit('formulaLibraryUpdated')"
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
v-model:open="historyOpen"
|
||||
:title="`${workbookName || tableConfig.tableName} - 编辑历史`"
|
||||
width="480"
|
||||
@after-open-change="(open: boolean) => { if (!open) invalidateHistoryLoad(); }"
|
||||
>
|
||||
<div v-if="loadingHistory" class="history-loading">加载中...</div>
|
||||
<Empty v-else-if="!historyItems.length" description="暂无历史记录" />
|
||||
<div v-else class="history-list">
|
||||
<div v-for="item in historyItems" :key="item.id" class="history-item">
|
||||
<div class="history-meta">
|
||||
<strong>{{ item.userName }}</strong>
|
||||
<span>{{ item.createdAt }}</span>
|
||||
<Tag size="small">{{ item.action }}</Tag>
|
||||
</div>
|
||||
<div class="history-diff">
|
||||
行 {{ item.rowKey }} · {{ item.fieldId }}
|
||||
<br />
|
||||
{{ item.beforeSnapshot }} → {{ item.afterSnapshot }}
|
||||
</div>
|
||||
<Button size="small" type="link" @click="handleRollback(item)">
|
||||
回滚到此版本
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spreadsheet-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.toolbar-main {
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.workbook-tag {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
|
||||
&.plain {
|
||||
font-size: 13px;
|
||||
color: #1d2129;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #e5e6eb;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.history-diff {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.spreadsheet-toolbar.is-dark {
|
||||
background: #141414;
|
||||
|
||||
.toolbar-main {
|
||||
border-bottom-color: #424242;
|
||||
}
|
||||
|
||||
.workbook-tag.plain {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
background: #424242;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
border-color: #424242;
|
||||
}
|
||||
|
||||
.history-meta,
|
||||
.history-loading {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
206
apps/web-antd/src/components/canvas-spreadsheet/types.ts
Normal file
206
apps/web-antd/src/components/canvas-spreadsheet/types.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { ContextMenuItem } from '#/components/context-menu';
|
||||
|
||||
export type CellType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'file'
|
||||
| 'readonly'
|
||||
| 'formula';
|
||||
|
||||
export interface CellRange {
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
endRow: number;
|
||||
endCol: number;
|
||||
}
|
||||
|
||||
export interface CellEditContext {
|
||||
fieldId: string;
|
||||
fieldValue: unknown;
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
rowKey: string | number;
|
||||
rowData: Record<string, unknown>;
|
||||
editParams: Record<string, unknown>;
|
||||
selection?: CellRange;
|
||||
}
|
||||
|
||||
export interface HeaderContextMenuContext {
|
||||
colIndex: number;
|
||||
fieldId?: string;
|
||||
headerTitle: string;
|
||||
filterValue?: string;
|
||||
}
|
||||
|
||||
export type ConditionalColorMode = 'background' | 'text' | 'both';
|
||||
|
||||
export type SpreadsheetTextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
/** undefined | 'inherit' 均表示跟随全局 defaultTextAlign */
|
||||
export type ColumnTextAlign = SpreadsheetTextAlign | 'inherit';
|
||||
|
||||
export interface ConditionalFormatRule {
|
||||
threshold: number;
|
||||
bgColor?: string;
|
||||
textColor?: string;
|
||||
colorMode?: ConditionalColorMode;
|
||||
}
|
||||
|
||||
export interface ColumnConditionalFormat {
|
||||
gt?: ConditionalFormatRule;
|
||||
lt?: ConditionalFormatRule;
|
||||
}
|
||||
|
||||
export interface SpreadsheetColumnConfig {
|
||||
fieldId: string;
|
||||
fieldName: string;
|
||||
cellType: CellType;
|
||||
col: number;
|
||||
editable?: boolean;
|
||||
formula?: string;
|
||||
suffix?: string;
|
||||
textAlign?: ColumnTextAlign;
|
||||
conditionalFormat?: ColumnConditionalFormat;
|
||||
onEdit?: (ctx: CellEditContext) => void | Promise<void>;
|
||||
onDoubleClick?: (ctx: CellEditContext) => void;
|
||||
editParams?: Record<string, unknown>;
|
||||
contextMenu?:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: CellEditContext) => ContextMenuItem[]);
|
||||
}
|
||||
|
||||
export interface SpreadsheetHeaderConfig {
|
||||
title: string;
|
||||
col: number;
|
||||
fieldId?: string;
|
||||
filterable?: boolean;
|
||||
width?: number | 'auto';
|
||||
headerColor?: string;
|
||||
fixed?: boolean;
|
||||
contextMenu?:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
|
||||
}
|
||||
|
||||
export interface SpreadsheetLayoutState {
|
||||
colWidths?: Record<number, number>;
|
||||
headers?: Array<{
|
||||
col: number;
|
||||
headerColor?: string;
|
||||
fixed?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SpreadsheetBatchCellChange {
|
||||
ctx: CellEditContext;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
col: number;
|
||||
viewRow: number;
|
||||
}
|
||||
|
||||
export interface SpreadsheetTableConfig {
|
||||
tableName: string;
|
||||
sheetKey?: string;
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
enableFilter?: boolean;
|
||||
enableHistory?: boolean;
|
||||
rowHeight?: number | 'auto';
|
||||
defaultColWidth?: number;
|
||||
defaultTextAlign?: SpreadsheetTextAlign;
|
||||
onBatchEdit?: (changes: SpreadsheetBatchCellChange[]) => Promise<void>;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
headerContextMenu?:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
|
||||
}
|
||||
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SpreadsheetColumnSort {
|
||||
col: number;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/** checkedValues === undefined means all selected (no filter) */
|
||||
export interface SpreadsheetColumnFilter {
|
||||
checkedValues?: string[];
|
||||
}
|
||||
|
||||
export interface SpreadsheetViewState {
|
||||
filters: Record<number, SpreadsheetColumnFilter>;
|
||||
sort: SpreadsheetColumnSort | null;
|
||||
}
|
||||
|
||||
export function createDefaultViewState(): SpreadsheetViewState {
|
||||
return { filters: {}, sort: null };
|
||||
}
|
||||
|
||||
export interface SpreadsheetHistoryEntry {
|
||||
id: string;
|
||||
tableName: string;
|
||||
sheetKey?: string;
|
||||
userId: number;
|
||||
userName: string;
|
||||
action: 'edit' | 'fill' | 'paste' | 'rollback';
|
||||
rowKey: string | number;
|
||||
fieldId: string;
|
||||
beforeSnapshot: unknown;
|
||||
afterSnapshot: unknown;
|
||||
createdAt: string;
|
||||
synced: boolean;
|
||||
}
|
||||
|
||||
export interface SpreadsheetCommand {
|
||||
label: string;
|
||||
undo: () => void | Promise<void>;
|
||||
redo: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClipboardMatrix {
|
||||
rows: unknown[][];
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
}
|
||||
|
||||
export interface SpreadsheetEngineExpose {
|
||||
copy: (selectionOverride?: CellRange) => void;
|
||||
copyAsImage: (selectionOverride?: CellRange) => Promise<void>;
|
||||
paste: () => Promise<void>;
|
||||
undo: () => Promise<void>;
|
||||
redo: () => Promise<void>;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
canCopy: () => boolean;
|
||||
canCopyAsImage: () => boolean;
|
||||
canPaste: () => boolean;
|
||||
canEdit: (row: number, col: number) => boolean;
|
||||
getSelection: () => CellRange | null;
|
||||
getCellEditContext: (row: number, col: number) => CellEditContext | null;
|
||||
getHeaderContext: (col: number) => HeaderContextMenuContext;
|
||||
setCellValue: (
|
||||
row: number,
|
||||
col: number,
|
||||
value: unknown,
|
||||
recordHistory?: boolean,
|
||||
action?: SpreadsheetHistoryEntry['action'],
|
||||
) => Promise<void>;
|
||||
fillRange: (
|
||||
source: CellRange,
|
||||
target: CellRange,
|
||||
) => Promise<void>;
|
||||
getColumnConfig: (col: number) => SpreadsheetColumnConfig | undefined;
|
||||
getFilterValue: (col: number) => string;
|
||||
}
|
||||
|
||||
export const COLUMN_LETTER_HEIGHT = 22;
|
||||
export const HEADER_TITLE_HEIGHT = 36;
|
||||
export const HEADER_HEIGHT = COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT;
|
||||
export const ROW_NUMBER_WIDTH = 48;
|
||||
export const DEFAULT_ROW_HEIGHT = 32;
|
||||
export const DEFAULT_COL_WIDTH = 100;
|
||||
export const MIN_COL_WIDTH = 60;
|
||||
export const SCROLL_BUFFER_ROWS = 8;
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import type { ContextMenuItem } from './types';
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
import {
|
||||
contextMenuState,
|
||||
getSubmenuPosition,
|
||||
hideContextMenu,
|
||||
MENU_ITEM_HEIGHT,
|
||||
MENU_WIDTH,
|
||||
showSubmenu,
|
||||
submenuState,
|
||||
@@ -14,6 +15,10 @@ import {
|
||||
defineOptions({ name: 'GlobalContextMenu' });
|
||||
|
||||
function onItemEnter(item: ContextMenuItem, e: MouseEvent) {
|
||||
if (item.type === 'divider') {
|
||||
showSubmenu('', [], 0, 0);
|
||||
return;
|
||||
}
|
||||
if (!item.children?.length || item.disabled) {
|
||||
showSubmenu('', [], 0, 0);
|
||||
return;
|
||||
@@ -29,16 +34,22 @@ function handleClick(item: ContextMenuItem) {
|
||||
if (item.disabled || item.children?.length) {
|
||||
return;
|
||||
}
|
||||
item.handler?.(contextMenuState.payload);
|
||||
const payload = contextMenuState.payload;
|
||||
hideContextMenu();
|
||||
void nextTick(() => {
|
||||
item.handler?.(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmenuClick(item: ContextMenuItem) {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
item.handler?.(contextMenuState.payload);
|
||||
const payload = contextMenuState.payload;
|
||||
hideContextMenu();
|
||||
void nextTick(() => {
|
||||
item.handler?.(payload);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,21 +61,23 @@ function handleSubmenuClick(item: ContextMenuItem) {
|
||||
:style="{ left: `${contextMenuState.x}px`, top: `${contextMenuState.y}px`, width: `${MENU_WIDTH}px` }"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div
|
||||
v-for="item in contextMenuState.items"
|
||||
:key="item.key"
|
||||
class="context-menu-item"
|
||||
:class="{ disabled: item.disabled, 'has-children': !!item.children?.length }"
|
||||
@click.stop="handleClick(item)"
|
||||
@mouseenter="onItemEnter(item, $event)"
|
||||
>
|
||||
<span class="item-icon">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<MIcon v-else-if="item.iconName" :icon="item.iconName" size="14" />
|
||||
</span>
|
||||
<span class="item-label">{{ item.label }}</span>
|
||||
<span v-if="item.children?.length" class="item-arrow">›</span>
|
||||
</div>
|
||||
<template v-for="item in contextMenuState.items" :key="item.key">
|
||||
<div v-if="item.type === 'divider'" class="context-menu-divider" />
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
:class="{ disabled: item.disabled, 'has-children': !!item.children?.length }"
|
||||
@click.stop="handleClick(item)"
|
||||
@mouseenter="onItemEnter(item, $event)"
|
||||
>
|
||||
<span class="item-icon">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<MIcon v-else-if="item.iconName" :icon="item.iconName" size="14" />
|
||||
</span>
|
||||
<span class="item-label">{{ item.label }}</span>
|
||||
<span v-if="item.children?.length" class="item-arrow">›</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -148,6 +161,12 @@ function handleSubmenuClick(item: ContextMenuItem) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 8px;
|
||||
background: #e5e6eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.global-context-menu {
|
||||
background: #1f2937;
|
||||
@@ -162,5 +181,9 @@ function handleSubmenuClick(item: ContextMenuItem) {
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { ContextMenuItem } from './types';
|
||||
|
||||
export type { ContextMenuItem } from './types';
|
||||
export { showContextMenu, hideContextMenu } from './use-context-menu';
|
||||
export { default as GlobalContextMenu } from './global-context-menu.vue';
|
||||
|
||||
export function menuDivider(key = `divider-${Date.now()}`): ContextMenuItem {
|
||||
return { key, type: 'divider' };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { Component } from 'vue';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
type?: 'item' | 'divider';
|
||||
icon?: Component;
|
||||
iconName?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ContextMenuItem, ContextMenuState } from './types';
|
||||
|
||||
const MENU_WIDTH = 180;
|
||||
const MENU_ITEM_HEIGHT = 36;
|
||||
const MENU_DIVIDER_HEIGHT = 9;
|
||||
const SUBMENU_GAP = 4;
|
||||
|
||||
let listenersBound = false;
|
||||
@@ -86,7 +87,12 @@ export function hideContextMenu() {
|
||||
}
|
||||
|
||||
export function estimateMenuHeight(items: ContextMenuItem[]): number {
|
||||
return items.length * MENU_ITEM_HEIGHT + 8;
|
||||
let height = 8;
|
||||
for (const item of items) {
|
||||
height +=
|
||||
item.type === 'divider' ? MENU_DIVIDER_HEIGHT : MENU_ITEM_HEIGHT;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
export function getSubmenuPosition(
|
||||
|
||||
140
apps/web-antd/src/components/form/components/color-picker.vue
Normal file
140
apps/web-antd/src/components/form/components/color-picker.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '选择颜色',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:value']);
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
// 预设颜色列表(来自岗位颜色配置)
|
||||
const presetColors = [
|
||||
{ color: '#3b82f6', name: '蓝色' },
|
||||
{ color: '#8b5cf6', name: '紫色' },
|
||||
{ color: '#10b981', name: '绿色' },
|
||||
{ color: '#f59e0b', name: '橙色' },
|
||||
{ color: '#ec4899', name: '粉色' },
|
||||
{ color: '#06b6d4', name: '青色' },
|
||||
{ color: '#ef4444', name: '红色' },
|
||||
{ color: '#6b7280', name: '灰色' },
|
||||
];
|
||||
|
||||
const handleClear = (e: Event) => {
|
||||
e.preventDefault();
|
||||
mValue.value = '';
|
||||
};
|
||||
|
||||
const selectPresetColor = (color: string) => {
|
||||
mValue.value = color;
|
||||
};
|
||||
|
||||
const isSelected = (color: string) => {
|
||||
return mValue.value?.toLowerCase() === color.toLowerCase();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="color-picker-container rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<!-- 主选择器 -->
|
||||
<div class="color-picker-main flex items-center gap-2.5">
|
||||
<label class="color-picker-label relative inline-block cursor-pointer">
|
||||
<input v-model="mValue" type="color" class="color-input" />
|
||||
<span
|
||||
class="color-preview flex h-10 w-10 items-center justify-center rounded-lg border-2 border-gray-300 shadow-sm transition-all hover:scale-105 hover:border-blue-500 dark:border-gray-500 dark:hover:border-blue-400"
|
||||
:style="{ backgroundColor: mValue || 'var(--color-placeholder-bg)' }"
|
||||
>
|
||||
<span
|
||||
v-if="!mValue"
|
||||
class="text-base font-bold text-gray-400 dark:text-gray-500"
|
||||
>?</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="color-value flex-1 rounded border border-gray-200 bg-white px-2.5 py-1.5 font-mono text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{{ mValue || placeholder }}
|
||||
</span>
|
||||
<a
|
||||
v-if="mValue"
|
||||
class="cursor-pointer rounded px-2 py-1 text-xs text-red-500 transition-all hover:bg-red-50 hover:no-underline dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
@click="handleClear"
|
||||
>
|
||||
清除
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 预设颜色 -->
|
||||
<div class="color-presets mt-3">
|
||||
<span class="presets-label mb-2 block text-xs text-gray-500 dark:text-gray-400">
|
||||
推荐颜色
|
||||
</span>
|
||||
<div class="presets-grid flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="preset in presetColors"
|
||||
:key="preset.color"
|
||||
class="preset-item flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-2 border-transparent shadow-sm transition-all hover:scale-115 hover:shadow-md"
|
||||
:class="{
|
||||
'ring-2 ring-gray-800 ring-offset-2 dark:ring-gray-200 dark:ring-offset-gray-800':
|
||||
isSelected(preset.color),
|
||||
}"
|
||||
:style="{ backgroundColor: preset.color }"
|
||||
:title="preset.name"
|
||||
@click="selectPresetColor(preset.color)"
|
||||
>
|
||||
<span
|
||||
v-if="isSelected(preset.color)"
|
||||
class="check-icon text-sm font-bold text-white drop-shadow"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.color-picker-container {
|
||||
max-width: 320px;
|
||||
|
||||
--color-placeholder-bg: #e5e7eb;
|
||||
|
||||
.dark & {
|
||||
--color-placeholder-bg: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-label {
|
||||
.color-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SpreadsheetConfigJson } from '../config/spreadsheetDefaults';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Checkbox, Input, message, Space } from 'ant-design-vue';
|
||||
|
||||
import SpreadsheetFormulaField from './SpreadsheetFormulaField.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
config: SpreadsheetConfigJson;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [config: SpreadsheetConfigJson];
|
||||
}>();
|
||||
|
||||
const title = ref('');
|
||||
const fieldId = ref('');
|
||||
const formula = ref('');
|
||||
const filterable = ref(true);
|
||||
const headerColor = ref<string | undefined>();
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '新增一列',
|
||||
class: 'w-[560px]',
|
||||
onConfirm: handleSave,
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
title.value = '';
|
||||
fieldId.value = '';
|
||||
formula.value = '';
|
||||
filterable.value = true;
|
||||
headerColor.value = undefined;
|
||||
}
|
||||
|
||||
function nextColIndex() {
|
||||
const cols = [
|
||||
...props.config.headers.map((h) => h.col),
|
||||
...props.config.columns.map((c) => c.col),
|
||||
];
|
||||
return cols.length > 0 ? Math.max(...cols) + 1 : 0;
|
||||
}
|
||||
|
||||
function buildConfig(): SpreadsheetConfigJson {
|
||||
const col = nextColIndex();
|
||||
const fid =
|
||||
fieldId.value.trim() ||
|
||||
`col_${col}_${Date.now().toString(36).slice(-4)}`;
|
||||
const fname = title.value.trim();
|
||||
|
||||
return {
|
||||
...props.config,
|
||||
headers: [
|
||||
...props.config.headers,
|
||||
{
|
||||
title: fname,
|
||||
col,
|
||||
fieldId: fid,
|
||||
filterable: filterable.value,
|
||||
headerColor: headerColor.value,
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
...props.config.columns,
|
||||
{
|
||||
fieldId: fid,
|
||||
fieldName: fname,
|
||||
cellType: 'formula',
|
||||
col,
|
||||
editable: false,
|
||||
formula: formula.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.value.trim()) {
|
||||
message.error('请输入列名');
|
||||
return false;
|
||||
}
|
||||
if (!formula.value.trim()) {
|
||||
message.error('请选择公式或手写表达式');
|
||||
return false;
|
||||
}
|
||||
emit('save', buildConfig());
|
||||
resetForm();
|
||||
return true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: () => {
|
||||
resetForm();
|
||||
modalApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<Space direction="vertical" style="width: 100%">
|
||||
<div>
|
||||
<div class="field-label">列名</div>
|
||||
<Input v-model:value="title" placeholder="如:毛利率" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">字段 ID(可选)</div>
|
||||
<Input v-model:value="fieldId" placeholder="自动生成" />
|
||||
</div>
|
||||
|
||||
<SpreadsheetFormulaField
|
||||
v-model="formula"
|
||||
:headers="config.headers"
|
||||
:show-preview="true"
|
||||
/>
|
||||
|
||||
<Checkbox v-model:checked="filterable">可筛选</Checkbox>
|
||||
</Space>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,824 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SpreadsheetConfigJson } from '../config/spreadsheetDefaults';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import ColorPicker from '#/components/form/components/color-picker.vue';
|
||||
import type {
|
||||
CellType,
|
||||
ColumnConditionalFormat,
|
||||
ConditionalColorMode,
|
||||
} from '#/components/canvas-spreadsheet';
|
||||
|
||||
import SpreadsheetFormulaField from './SpreadsheetFormulaField.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
config: SpreadsheetConfigJson;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:config': [value: SpreadsheetConfigJson];
|
||||
}>();
|
||||
|
||||
const formSubTab = ref('global');
|
||||
|
||||
const cellTypeOptions = [
|
||||
{ label: '只读', value: 'readonly' },
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: '数字', value: 'number' },
|
||||
{ label: '公式', value: 'formula' },
|
||||
];
|
||||
|
||||
const textAlignOptions = [
|
||||
{ label: '靠左', value: 'left' },
|
||||
{ label: '居中', value: 'center' },
|
||||
{ label: '靠右', value: 'right' },
|
||||
];
|
||||
|
||||
const columnTextAlignOptions = [
|
||||
{ label: '跟随全局', value: 'inherit' },
|
||||
...textAlignOptions,
|
||||
];
|
||||
|
||||
const rowHeightMode = computed({
|
||||
get() {
|
||||
return props.config.rowHeight === 'auto' ? 'auto' : 'fixed';
|
||||
},
|
||||
set(mode: 'auto' | 'fixed') {
|
||||
patchConfig({
|
||||
rowHeight: mode === 'auto' ? 'auto' : 32,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rowHeightFixed = computed({
|
||||
get() {
|
||||
return typeof props.config.rowHeight === 'number'
|
||||
? props.config.rowHeight
|
||||
: 32;
|
||||
},
|
||||
set(v: number) {
|
||||
patchConfig({ rowHeight: v || 32 });
|
||||
},
|
||||
});
|
||||
|
||||
function patchConfig(partial: Partial<SpreadsheetConfigJson>) {
|
||||
emit('update:config', { ...props.config, ...partial });
|
||||
}
|
||||
|
||||
function patchHeaders(headers: SpreadsheetConfigJson['headers']) {
|
||||
patchConfig({ headers: [...headers] });
|
||||
}
|
||||
|
||||
function patchColumns(columns: SpreadsheetConfigJson['columns']) {
|
||||
patchConfig({ columns: [...columns] });
|
||||
}
|
||||
|
||||
function nextColIndex() {
|
||||
const cols = [
|
||||
...props.config.headers.map((h) => h.col),
|
||||
...props.config.columns.map((c) => c.col),
|
||||
];
|
||||
return cols.length > 0 ? Math.max(...cols) + 1 : 0;
|
||||
}
|
||||
|
||||
function addHeader() {
|
||||
const col = nextColIndex();
|
||||
patchHeaders([
|
||||
...props.config.headers,
|
||||
{
|
||||
title: `列${col + 1}`,
|
||||
col,
|
||||
fieldId: `field_${col}`,
|
||||
filterable: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeHeader(index: number) {
|
||||
const header = props.config.headers[index];
|
||||
if (!header) return;
|
||||
patchHeaders(props.config.headers.filter((_, i) => i !== index));
|
||||
patchColumns(
|
||||
props.config.columns.filter((c) => c.col !== header.col),
|
||||
);
|
||||
}
|
||||
|
||||
function updateHeader(
|
||||
index: number,
|
||||
key: keyof SpreadsheetConfigJson['headers'][number],
|
||||
value: unknown,
|
||||
) {
|
||||
const headers = props.config.headers.map((h, i) =>
|
||||
i === index ? { ...h, [key]: value } : h,
|
||||
);
|
||||
patchHeaders(headers);
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
const col = nextColIndex();
|
||||
const fieldId = `field_${col}`;
|
||||
const fieldName = `列${col + 1}`;
|
||||
const headers = [...props.config.headers];
|
||||
if (!headers.some((h) => h.col === col)) {
|
||||
headers.push({
|
||||
title: fieldName,
|
||||
col,
|
||||
fieldId,
|
||||
filterable: true,
|
||||
});
|
||||
}
|
||||
patchConfig({
|
||||
headers,
|
||||
columns: [
|
||||
...props.config.columns,
|
||||
{
|
||||
col,
|
||||
fieldId,
|
||||
fieldName,
|
||||
cellType: 'readonly' as CellType,
|
||||
editable: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function removeColumn(index: number) {
|
||||
const column = props.config.columns[index];
|
||||
if (!column) return;
|
||||
patchColumns(props.config.columns.filter((_, i) => i !== index));
|
||||
patchHeaders(props.config.headers.filter((h) => h.col !== column.col));
|
||||
}
|
||||
|
||||
function updateColumn(
|
||||
index: number,
|
||||
key: keyof SpreadsheetConfigJson['columns'][number],
|
||||
value: unknown,
|
||||
) {
|
||||
const columns = props.config.columns.map((c, i) => {
|
||||
if (i !== index) return c;
|
||||
const next = { ...c, [key]: value };
|
||||
if (key === 'cellType') {
|
||||
const cellType = value as CellType;
|
||||
if (cellType === 'formula' || cellType === 'readonly') {
|
||||
next.editable = false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
let headers = props.config.headers;
|
||||
const column = columns[index];
|
||||
if (column && key === 'fieldName' && value) {
|
||||
const headerIdx = headers.findIndex((h) => h.col === column.col);
|
||||
if (headerIdx >= 0) {
|
||||
headers = headers.map((h, hi) =>
|
||||
hi === headerIdx ? { ...h, title: String(value) } : h,
|
||||
);
|
||||
} else {
|
||||
headers = [
|
||||
...headers,
|
||||
{
|
||||
title: String(value),
|
||||
col: column.col,
|
||||
fieldId: column.fieldId,
|
||||
filterable: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (column && key === 'cellType' && value === 'formula') {
|
||||
if (!headers.some((h) => h.col === column.col)) {
|
||||
headers = [
|
||||
...headers,
|
||||
{
|
||||
title: column.fieldName || column.fieldId,
|
||||
col: column.col,
|
||||
fieldId: column.fieldId,
|
||||
filterable: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
patchConfig({ headers, columns });
|
||||
}
|
||||
|
||||
function updateColumnConditional(
|
||||
index: number,
|
||||
ruleKey: 'gt' | 'lt',
|
||||
field: 'threshold' | 'bgColor' | 'textColor' | 'colorMode',
|
||||
value: unknown,
|
||||
) {
|
||||
const columns = props.config.columns.map((c, i) => {
|
||||
if (i !== index) return c;
|
||||
const cf: ColumnConditionalFormat = { ...(c.conditionalFormat ?? {}) };
|
||||
const prev = cf[ruleKey] ?? { threshold: 0, bgColor: '' };
|
||||
cf[ruleKey] = {
|
||||
...prev,
|
||||
[field]: value,
|
||||
};
|
||||
return { ...c, conditionalFormat: cf };
|
||||
});
|
||||
patchColumns(columns);
|
||||
}
|
||||
|
||||
const colorModeOptions = [
|
||||
{ label: '背景', value: 'background' },
|
||||
{ label: '文字', value: 'text' },
|
||||
{ label: '两者', value: 'both' },
|
||||
];
|
||||
|
||||
function conditionalColorMode(
|
||||
record: { conditionalFormat?: ColumnConditionalFormat },
|
||||
ruleKey: 'gt' | 'lt',
|
||||
): ConditionalColorMode {
|
||||
return record.conditionalFormat?.[ruleKey]?.colorMode ?? 'background';
|
||||
}
|
||||
|
||||
function showConditionalFields(record: { cellType?: string }) {
|
||||
return record.cellType === 'formula' || record.cellType === 'number';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spreadsheet-config-form" :class="{ disabled: disabled }">
|
||||
<Tabs v-model:active-key="formSubTab">
|
||||
<Tabs.TabPane key="global" tab="全局设置">
|
||||
<Space wrap :size="12" class="global-settings">
|
||||
<span class="field-label">列宽</span>
|
||||
<InputNumber
|
||||
:disabled="disabled"
|
||||
:min="60"
|
||||
:value="config.defaultColWidth ?? 120"
|
||||
@update:value="(v) => patchConfig({ defaultColWidth: Number(v) || 120 })"
|
||||
/>
|
||||
<span class="field-label">文字对齐</span>
|
||||
<Select
|
||||
:disabled="disabled"
|
||||
:options="textAlignOptions"
|
||||
:value="config.defaultTextAlign ?? 'left'"
|
||||
style="width: 88px"
|
||||
@update:value="(v) => patchConfig({ defaultTextAlign: v })"
|
||||
/>
|
||||
<span class="field-label">行高</span>
|
||||
<Select
|
||||
:disabled="disabled"
|
||||
:options="[
|
||||
{ label: '自动', value: 'auto' },
|
||||
{ label: '固定', value: 'fixed' },
|
||||
]"
|
||||
:value="rowHeightMode"
|
||||
style="width: 88px"
|
||||
@update:value="(v) => (rowHeightMode = v as 'auto' | 'fixed')"
|
||||
/>
|
||||
<InputNumber
|
||||
v-if="rowHeightMode === 'fixed'"
|
||||
:disabled="disabled"
|
||||
:min="20"
|
||||
:value="rowHeightFixed"
|
||||
@update:value="(v) => (rowHeightFixed = Number(v) || 32)"
|
||||
/>
|
||||
<span class="field-label">筛选</span>
|
||||
<Switch
|
||||
:checked="config.enableFilter !== false"
|
||||
:disabled="disabled"
|
||||
@update:checked="(v) => patchConfig({ enableFilter: v })"
|
||||
/>
|
||||
<span class="field-label">历史</span>
|
||||
<Switch
|
||||
:checked="config.enableHistory !== false"
|
||||
:disabled="disabled"
|
||||
@update:checked="(v) => patchConfig({ enableHistory: v })"
|
||||
/>
|
||||
</Space>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="headers" tab="表头配置">
|
||||
<div class="table-toolbar">
|
||||
<Button :disabled="disabled" size="small" @click="addHeader">
|
||||
新增表头
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
:columns="[
|
||||
{ title: '列序号', dataIndex: 'col', width: 72 },
|
||||
{ title: '标题', dataIndex: 'title', width: 120 },
|
||||
{ title: '字段标识', dataIndex: 'fieldId', width: 120 },
|
||||
{ title: '可筛选', dataIndex: 'filterable', width: 72 },
|
||||
{ title: '固定列', dataIndex: 'fixed', width: 72 },
|
||||
{ title: '表头颜色', dataIndex: 'headerColor', width: 100 },
|
||||
{ title: '操作', dataIndex: 'action', width: 64 },
|
||||
]"
|
||||
:data-source="config.headers.map((h, index) => ({ ...h, index, key: index }))"
|
||||
:pagination="false"
|
||||
bordered
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'col'">
|
||||
<InputNumber
|
||||
:disabled="disabled"
|
||||
:min="0"
|
||||
:value="record.col"
|
||||
size="small"
|
||||
@update:value="(v) => updateHeader(record.index, 'col', Number(v))"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'title'">
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="record.title"
|
||||
size="small"
|
||||
@update:value="(v) => updateHeader(record.index, 'title', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'fieldId'">
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="record.fieldId"
|
||||
size="small"
|
||||
@update:value="(v) => updateHeader(record.index, 'fieldId', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'filterable'">
|
||||
<Checkbox
|
||||
:checked="record.filterable !== false"
|
||||
:disabled="disabled"
|
||||
@update:checked="(v) => updateHeader(record.index, 'filterable', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'fixed'">
|
||||
<Checkbox
|
||||
:checked="!!record.fixed"
|
||||
:disabled="disabled"
|
||||
@update:checked="(v) => updateHeader(record.index, 'fixed', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'headerColor'">
|
||||
<Popover trigger="click" :disabled="disabled">
|
||||
<template #content>
|
||||
<ColorPicker
|
||||
:value="record.headerColor ?? ''"
|
||||
@update:value="
|
||||
(v) => updateHeader(record.index, 'headerColor', v || undefined)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
class="color-swatch-btn"
|
||||
:disabled="disabled"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="color-swatch"
|
||||
:style="{ backgroundColor: record.headerColor || '#e5e7eb' }"
|
||||
/>
|
||||
<span class="color-swatch-label">
|
||||
{{ record.headerColor || '选色' }}
|
||||
</span>
|
||||
</button>
|
||||
</Popover>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<Button
|
||||
danger
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="removeHeader(record.index)"
|
||||
>
|
||||
删
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="columns" tab="列配置">
|
||||
<div class="table-toolbar">
|
||||
<Button :disabled="disabled" size="small" @click="addColumn">
|
||||
新增列
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
:columns="[
|
||||
{ title: '列序号', dataIndex: 'col', width: 64 },
|
||||
{ title: '字段标识', dataIndex: 'fieldId', width: 100 },
|
||||
{ title: '字段名称', dataIndex: 'fieldName', width: 96 },
|
||||
{ title: '单元格类型', dataIndex: 'cellType', width: 100 },
|
||||
{ title: '可编辑', dataIndex: 'editable', width: 64 },
|
||||
{ title: '公式', dataIndex: 'formula', width: 130 },
|
||||
{ title: '显示后缀', dataIndex: 'suffix', width: 80 },
|
||||
{ title: '对齐', dataIndex: 'textAlign', width: 100 },
|
||||
{ title: '大于阈值', dataIndex: 'gtThreshold', width: 88 },
|
||||
{ title: '大于颜色', dataIndex: 'gtColor', width: 140 },
|
||||
{ title: '小于阈值', dataIndex: 'ltThreshold', width: 88 },
|
||||
{ title: '小于颜色', dataIndex: 'ltColor', width: 140 },
|
||||
{ title: '操作', dataIndex: 'action', width: 56 },
|
||||
]"
|
||||
:data-source="config.columns.map((c, index) => ({ ...c, index, key: index }))"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 1200 }"
|
||||
bordered
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'col'">
|
||||
<InputNumber
|
||||
:disabled="disabled"
|
||||
:min="0"
|
||||
:value="record.col"
|
||||
size="small"
|
||||
@update:value="(v) => updateColumn(record.index, 'col', Number(v))"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'fieldId'">
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="record.fieldId"
|
||||
size="small"
|
||||
@update:value="(v) => updateColumn(record.index, 'fieldId', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'fieldName'">
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="record.fieldName"
|
||||
size="small"
|
||||
@update:value="(v) => updateColumn(record.index, 'fieldName', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'cellType'">
|
||||
<Select
|
||||
:disabled="disabled"
|
||||
:options="cellTypeOptions"
|
||||
:value="record.cellType"
|
||||
size="small"
|
||||
style="width: 92px"
|
||||
@update:value="(v) => updateColumn(record.index, 'cellType', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'editable'">
|
||||
<Checkbox
|
||||
:checked="record.editable !== false"
|
||||
:disabled="
|
||||
disabled ||
|
||||
record.cellType === 'formula' ||
|
||||
record.cellType === 'readonly'
|
||||
"
|
||||
@update:checked="(v) => updateColumn(record.index, 'editable', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'formula'">
|
||||
<SpreadsheetFormulaField
|
||||
v-if="record.cellType === 'formula'"
|
||||
compact
|
||||
:disabled="disabled"
|
||||
:headers="config.headers"
|
||||
:model-value="record.formula ?? ''"
|
||||
@update:model-value="(v) => updateColumn(record.index, 'formula', v)"
|
||||
/>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'suffix'">
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="record.suffix ?? ''"
|
||||
placeholder="%"
|
||||
size="small"
|
||||
@update:value="(v) => updateColumn(record.index, 'suffix', v || undefined)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'textAlign'">
|
||||
<Select
|
||||
:disabled="disabled"
|
||||
:options="columnTextAlignOptions"
|
||||
:value="record.textAlign ?? 'inherit'"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
@update:value="
|
||||
(v) =>
|
||||
updateColumn(
|
||||
record.index,
|
||||
'textAlign',
|
||||
v === 'inherit' ? undefined : v,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'gtThreshold'">
|
||||
<InputNumber
|
||||
v-if="showConditionalFields(record)"
|
||||
:disabled="disabled"
|
||||
:step="0.01"
|
||||
:value="record.conditionalFormat?.gt?.threshold"
|
||||
size="small"
|
||||
style="width: 72px"
|
||||
@update:value="
|
||||
(v) => updateColumnConditional(record.index, 'gt', 'threshold', Number(v))
|
||||
"
|
||||
/>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'gtColor'">
|
||||
<div v-if="showConditionalFields(record)" class="color-rule-cell">
|
||||
<Select
|
||||
:disabled="disabled"
|
||||
:options="colorModeOptions"
|
||||
size="small"
|
||||
style="width: 72px; margin-bottom: 4px"
|
||||
:value="conditionalColorMode(record, 'gt')"
|
||||
@update:value="
|
||||
(v) =>
|
||||
updateColumnConditional(
|
||||
record.index,
|
||||
'gt',
|
||||
'colorMode',
|
||||
v,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Popover
|
||||
v-if="
|
||||
conditionalColorMode(record, 'gt') !== 'text'
|
||||
"
|
||||
trigger="click"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #content>
|
||||
<ColorPicker
|
||||
:value="record.conditionalFormat?.gt?.bgColor ?? ''"
|
||||
@update:value="
|
||||
(v) =>
|
||||
updateColumnConditional(record.index, 'gt', 'bgColor', v)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
class="color-swatch-btn"
|
||||
:disabled="disabled"
|
||||
title="背景色"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="color-swatch"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
record.conditionalFormat?.gt?.bgColor || '#e5e7eb',
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</Popover>
|
||||
<Popover
|
||||
v-if="
|
||||
['text', 'both'].includes(
|
||||
conditionalColorMode(record, 'gt'),
|
||||
)
|
||||
"
|
||||
trigger="click"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #content>
|
||||
<ColorPicker
|
||||
:value="record.conditionalFormat?.gt?.textColor ?? ''"
|
||||
@update:value="
|
||||
(v) =>
|
||||
updateColumnConditional(
|
||||
record.index,
|
||||
'gt',
|
||||
'textColor',
|
||||
v,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
class="color-swatch-btn"
|
||||
:disabled="disabled"
|
||||
title="文字色"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="color-swatch color-swatch-text"
|
||||
:style="{
|
||||
color:
|
||||
record.conditionalFormat?.gt?.textColor || '#111',
|
||||
}"
|
||||
>
|
||||
A
|
||||
</span>
|
||||
</button>
|
||||
</Popover>
|
||||
</div>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'ltThreshold'">
|
||||
<InputNumber
|
||||
v-if="showConditionalFields(record)"
|
||||
:disabled="disabled"
|
||||
:step="0.01"
|
||||
:value="record.conditionalFormat?.lt?.threshold"
|
||||
size="small"
|
||||
style="width: 72px"
|
||||
@update:value="
|
||||
(v) => updateColumnConditional(record.index, 'lt', 'threshold', Number(v))
|
||||
"
|
||||
/>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'ltColor'">
|
||||
<div v-if="showConditionalFields(record)" class="color-rule-cell">
|
||||
<Select
|
||||
:disabled="disabled"
|
||||
:options="colorModeOptions"
|
||||
size="small"
|
||||
style="width: 72px; margin-bottom: 4px"
|
||||
:value="conditionalColorMode(record, 'lt')"
|
||||
@update:value="
|
||||
(v) =>
|
||||
updateColumnConditional(
|
||||
record.index,
|
||||
'lt',
|
||||
'colorMode',
|
||||
v,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Popover
|
||||
v-if="
|
||||
conditionalColorMode(record, 'lt') !== 'text'
|
||||
"
|
||||
trigger="click"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #content>
|
||||
<ColorPicker
|
||||
:value="record.conditionalFormat?.lt?.bgColor ?? ''"
|
||||
@update:value="
|
||||
(v) =>
|
||||
updateColumnConditional(record.index, 'lt', 'bgColor', v)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
class="color-swatch-btn"
|
||||
:disabled="disabled"
|
||||
title="背景色"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="color-swatch"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
record.conditionalFormat?.lt?.bgColor || '#e5e7eb',
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</Popover>
|
||||
<Popover
|
||||
v-if="
|
||||
['text', 'both'].includes(
|
||||
conditionalColorMode(record, 'lt'),
|
||||
)
|
||||
"
|
||||
trigger="click"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #content>
|
||||
<ColorPicker
|
||||
:value="record.conditionalFormat?.lt?.textColor ?? ''"
|
||||
@update:value="
|
||||
(v) =>
|
||||
updateColumnConditional(
|
||||
record.index,
|
||||
'lt',
|
||||
'textColor',
|
||||
v,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
class="color-swatch-btn"
|
||||
:disabled="disabled"
|
||||
title="文字色"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="color-swatch color-swatch-text"
|
||||
:style="{
|
||||
color:
|
||||
record.conditionalFormat?.lt?.textColor || '#111',
|
||||
}"
|
||||
>
|
||||
A
|
||||
</span>
|
||||
</button>
|
||||
</Popover>
|
||||
</div>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<Button
|
||||
danger
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="removeColumn(record.index)"
|
||||
>
|
||||
删
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spreadsheet-config-form {
|
||||
&.disabled {
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.global-settings {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.color-swatch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.color-swatch-label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
max-width: 72px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.color-rule-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.color-swatch-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,387 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SpreadsheetHeaderConfig } from '#/components/canvas-spreadsheet';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
message,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
buildFormulaFromTemplate,
|
||||
listSpreadsheetFormula,
|
||||
type SpreadsheetFormulaItem,
|
||||
} from '#/api/spreadsheet-formula';
|
||||
import { colIndexToLetters } from '#/components/canvas-spreadsheet';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
isDark?: boolean;
|
||||
showPreview?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
compact: false,
|
||||
isDark: false,
|
||||
showPreview: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const pickerOpen = ref(false);
|
||||
const useCustomFormula = ref(false);
|
||||
const customFormula = ref('');
|
||||
const selectedFormulaId = ref<number | null>(null);
|
||||
const refColumns = ref<number[]>([]);
|
||||
const refPopoverOpen = ref(false);
|
||||
const formulas = ref<SpreadsheetFormulaItem[]>([]);
|
||||
|
||||
const formulaOptions = computed(() =>
|
||||
formulas.value.map((f) => ({ label: f.name, value: f.id })),
|
||||
);
|
||||
|
||||
const selectedFormula = computed(() =>
|
||||
formulas.value.find((f) => f.id === selectedFormulaId.value),
|
||||
);
|
||||
|
||||
const columnChoices = computed(() =>
|
||||
props.headers.map((h) => ({
|
||||
col: h.col,
|
||||
label: `${colIndexToLetters(h.col)} · ${h.title}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const previewFormula = computed(() => {
|
||||
if (useCustomFormula.value) return customFormula.value;
|
||||
const tpl = selectedFormula.value?.expression_template ?? '';
|
||||
if (!tpl) return props.modelValue;
|
||||
if (refColumns.value.length === 0) return tpl;
|
||||
return buildFormulaFromTemplate(tpl, refColumns.value);
|
||||
});
|
||||
|
||||
function syncFromModelValue() {
|
||||
if (props.modelValue?.trim()) {
|
||||
useCustomFormula.value = true;
|
||||
customFormula.value = props.modelValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFormulas() {
|
||||
try {
|
||||
formulas.value = await listSpreadsheetFormula();
|
||||
} catch {
|
||||
formulas.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRefCol(col: number, checked: boolean) {
|
||||
const slots = selectedFormula.value?.ref_slots?.length ?? 2;
|
||||
if (checked) {
|
||||
if (refColumns.value.length >= slots) {
|
||||
message.warning(`最多选择 ${slots} 列`);
|
||||
return;
|
||||
}
|
||||
refColumns.value = [...refColumns.value, col];
|
||||
} else {
|
||||
refColumns.value = refColumns.value.filter((c) => c !== col);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFormula() {
|
||||
const next = previewFormula.value.trim();
|
||||
if (!next) {
|
||||
message.warning('请选择公式或输入表达式');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', next);
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
|
||||
function onCustomInput(v: string) {
|
||||
customFormula.value = v;
|
||||
emit('update:modelValue', v);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFormulas();
|
||||
syncFromModelValue();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (useCustomFormula.value && v !== customFormula.value) {
|
||||
customFormula.value = v;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(pickerOpen, (open) => {
|
||||
if (open) {
|
||||
void loadFormulas();
|
||||
if (props.modelValue?.trim()) {
|
||||
useCustomFormula.value = true;
|
||||
customFormula.value = props.modelValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(previewFormula, (v) => {
|
||||
if (!props.compact && !useCustomFormula.value && v) {
|
||||
emit('update:modelValue', v);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spreadsheet-formula-field" :class="{ compact, 'is-dark': isDark }">
|
||||
<template v-if="compact">
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="modelValue"
|
||||
placeholder="=(D1-E1)"
|
||||
size="small"
|
||||
@update:value="onCustomInput"
|
||||
/>
|
||||
<Popover
|
||||
v-model:open="pickerOpen"
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
:overlay-class-name="
|
||||
isDark
|
||||
? 'sheet-formula-field-popover is-dark'
|
||||
: 'sheet-formula-field-popover'
|
||||
"
|
||||
>
|
||||
<template #content>
|
||||
<div class="picker-panel" :class="{ 'is-dark': isDark }">
|
||||
<Space direction="vertical" style="width: 280px">
|
||||
<Checkbox
|
||||
v-model:checked="useCustomFormula"
|
||||
:disabled="disabled"
|
||||
>
|
||||
手写公式
|
||||
</Checkbox>
|
||||
<template v-if="useCustomFormula">
|
||||
<Input
|
||||
v-model:value="customFormula"
|
||||
:disabled="disabled"
|
||||
placeholder="=(E1-D1)/D1"
|
||||
@update:value="onCustomInput"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Select
|
||||
v-model:value="selectedFormulaId"
|
||||
:disabled="disabled"
|
||||
:options="formulaOptions"
|
||||
allow-clear
|
||||
placeholder="选择公式模板"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div v-if="selectedFormula">
|
||||
<div class="field-label">引用列(按顺序)</div>
|
||||
<Popover v-model:open="refPopoverOpen" trigger="click">
|
||||
<template #content>
|
||||
<div class="ref-picker">
|
||||
<div
|
||||
v-for="item in columnChoices"
|
||||
:key="item.col"
|
||||
class="ref-item"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="refColumns.includes(item.col)"
|
||||
@change="
|
||||
(e) => toggleRefCol(item.col, !!e.target.checked)
|
||||
"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Input
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
placeholder="点击选择引用列"
|
||||
size="small"
|
||||
:value="
|
||||
refColumns.map((c) => colIndexToLetters(c)).join(', ')
|
||||
"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="showPreview">
|
||||
<div class="field-label">预览</div>
|
||||
<Tag color="blue">{{ previewFormula || '—' }}</Tag>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="applyFormula"
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</template>
|
||||
<Button
|
||||
class="fx-btn"
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
type="text"
|
||||
@click.stop
|
||||
>
|
||||
fx
|
||||
</Button>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Checkbox v-model:checked="useCustomFormula" :disabled="disabled">
|
||||
手写公式
|
||||
</Checkbox>
|
||||
<template v-if="useCustomFormula">
|
||||
<Input
|
||||
v-model:value="customFormula"
|
||||
:disabled="disabled"
|
||||
placeholder="=(E1-D1)/D1"
|
||||
@update:value="onCustomInput"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Select
|
||||
v-model:value="selectedFormulaId"
|
||||
:disabled="disabled"
|
||||
:options="formulaOptions"
|
||||
allow-clear
|
||||
placeholder="选择公式模板"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div v-if="selectedFormula">
|
||||
<div class="field-label">引用列(按顺序)</div>
|
||||
<Popover v-model:open="refPopoverOpen" trigger="click">
|
||||
<template #content>
|
||||
<div class="ref-picker">
|
||||
<div
|
||||
v-for="item in columnChoices"
|
||||
:key="item.col"
|
||||
class="ref-item"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="refColumns.includes(item.col)"
|
||||
@change="
|
||||
(e) => toggleRefCol(item.col, !!e.target.checked)
|
||||
"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Input
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
placeholder="点击选择引用列"
|
||||
:value="refColumns.map((c) => colIndexToLetters(c)).join(', ')"
|
||||
/>
|
||||
</Popover>
|
||||
<div v-if="selectedFormula.ref_slots?.length" class="ref-hint">
|
||||
需要:
|
||||
{{ selectedFormula.ref_slots.map((s) => s.label).join('、') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="showPreview">
|
||||
<div class="field-label">公式预览</div>
|
||||
<Tag color="blue">{{ previewFormula || '—' }}</Tag>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spreadsheet-formula-field {
|
||||
&.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
:deep(.ant-input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fx-btn {
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
color: #1668dc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ref-picker {
|
||||
width: 260px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ref-item {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ref-hint {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
padding: 4px;
|
||||
|
||||
&.is-dark {
|
||||
.field-label,
|
||||
.ref-hint {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-dark .fx-btn {
|
||||
color: #3c89e8;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.sheet-formula-field-popover.is-dark {
|
||||
.ant-popover-inner {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #424242;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SpreadsheetConfigJson } from '../config/spreadsheetDefaults';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
Input,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import ColorPicker from '#/components/form/components/color-picker.vue';
|
||||
import type { CellType, ColumnTextAlign } from '#/components/canvas-spreadsheet';
|
||||
|
||||
import SpreadsheetFormulaField from './SpreadsheetFormulaField.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
config: SpreadsheetConfigJson;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [config: SpreadsheetConfigJson];
|
||||
}>();
|
||||
|
||||
const colIndex = ref(0);
|
||||
const title = ref('');
|
||||
const fieldId = ref('');
|
||||
const cellType = ref<CellType>('readonly');
|
||||
const formula = ref('');
|
||||
const suffix = ref('');
|
||||
const filterable = ref(true);
|
||||
const fixed = ref(false);
|
||||
const headerColor = ref<string | undefined>();
|
||||
const editable = ref(false);
|
||||
const textAlign = ref<ColumnTextAlign>('inherit');
|
||||
|
||||
const cellTypeOptions = [
|
||||
{ label: '只读', value: 'readonly' },
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: '数字', value: 'number' },
|
||||
{ label: '公式', value: 'formula' },
|
||||
];
|
||||
|
||||
const columnTextAlignOptions = [
|
||||
{ label: '跟随全局', value: 'inherit' },
|
||||
{ label: '靠左', value: 'left' },
|
||||
{ label: '居中', value: 'center' },
|
||||
{ label: '靠右', value: 'right' },
|
||||
];
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '设置列',
|
||||
class: 'w-[520px]',
|
||||
onConfirm: handleSave,
|
||||
});
|
||||
|
||||
function loadCol(col: number) {
|
||||
colIndex.value = col;
|
||||
const colCfg = props.config.columns.find((c) => c.col === col);
|
||||
const hdr = props.config.headers.find((h) => h.col === col);
|
||||
title.value = hdr?.title ?? colCfg?.fieldName ?? '';
|
||||
fieldId.value = colCfg?.fieldId ?? hdr?.fieldId ?? '';
|
||||
cellType.value = colCfg?.cellType ?? 'readonly';
|
||||
formula.value = colCfg?.formula ?? '';
|
||||
suffix.value = colCfg?.suffix ?? '';
|
||||
filterable.value = hdr?.filterable !== false;
|
||||
fixed.value = hdr?.fixed ?? false;
|
||||
headerColor.value = hdr?.headerColor;
|
||||
editable.value = colCfg?.editable ?? false;
|
||||
textAlign.value = colCfg?.textAlign ?? 'inherit';
|
||||
}
|
||||
|
||||
function buildConfig(): SpreadsheetConfigJson {
|
||||
const headers = props.config.headers.map((h) =>
|
||||
h.col === colIndex.value
|
||||
? {
|
||||
...h,
|
||||
title: title.value,
|
||||
fieldId: fieldId.value,
|
||||
filterable: filterable.value,
|
||||
fixed: fixed.value,
|
||||
headerColor: headerColor.value,
|
||||
}
|
||||
: h,
|
||||
);
|
||||
const columns = props.config.columns.map((c) =>
|
||||
c.col === colIndex.value
|
||||
? {
|
||||
...c,
|
||||
fieldId: fieldId.value,
|
||||
fieldName: title.value,
|
||||
cellType: cellType.value,
|
||||
formula: cellType.value === 'formula' ? formula.value : undefined,
|
||||
suffix: suffix.value || undefined,
|
||||
editable: editable.value,
|
||||
textAlign:
|
||||
textAlign.value === 'inherit' ? undefined : textAlign.value,
|
||||
}
|
||||
: c,
|
||||
);
|
||||
return { ...props.config, headers, columns };
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.value.trim()) {
|
||||
message.error('请输入列标题');
|
||||
return false;
|
||||
}
|
||||
emit('save', buildConfig());
|
||||
return true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: (col: number) => {
|
||||
loadCol(col);
|
||||
modalApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<Space direction="vertical" style="width: 100%">
|
||||
<div>
|
||||
<div class="field-label">列标题</div>
|
||||
<Input v-model:value="title" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">字段 ID</div>
|
||||
<Input v-model:value="fieldId" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">单元格类型</div>
|
||||
<Select
|
||||
v-model:value="cellType"
|
||||
:options="cellTypeOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cellType === 'formula'">
|
||||
<div class="field-label">公式</div>
|
||||
<SpreadsheetFormulaField
|
||||
v-model="formula"
|
||||
:headers="config.headers"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">后缀(展示用)</div>
|
||||
<Input v-model:value="suffix" placeholder="如 %" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">对齐</div>
|
||||
<Select
|
||||
v-model:value="textAlign"
|
||||
:options="columnTextAlignOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<Checkbox v-model:checked="filterable">可筛选</Checkbox>
|
||||
<Checkbox v-model:checked="fixed">固定列</Checkbox>
|
||||
<Checkbox v-model:checked="editable">可编辑</Checkbox>
|
||||
<div>
|
||||
<div class="field-label">表头颜色</div>
|
||||
<ColorPicker v-model:value="headerColor" />
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SpreadsheetConfigJson } from '../config/spreadsheetDefaults';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
List,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Tabs,
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
createSpreadsheetTableConfig,
|
||||
deleteSpreadsheetTableConfig,
|
||||
listSpreadsheetTableConfig,
|
||||
type SpreadsheetTableConfigItem,
|
||||
updateSpreadsheetTableConfig,
|
||||
} from '#/api/spreadsheet-table-config';
|
||||
|
||||
import {
|
||||
getWarehouseSpreadsheetDefaults,
|
||||
mergeSpreadsheetConfig,
|
||||
WAREHOUSE_SPREADSHEET_TABLE_NAME,
|
||||
warehouseSpreadsheetSheetKey,
|
||||
} from '../config/spreadsheetDefaults';
|
||||
import { canManageSpreadsheetConfig } from '../utils/spreadsheetAdminRole';
|
||||
|
||||
import SpreadsheetConfigFormEditor from './SpreadsheetConfigFormEditor.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
productType: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
}>();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const items = ref<SpreadsheetTableConfigItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedId = ref<number | null>(null);
|
||||
const formName = ref('默认');
|
||||
const formIsDefault = ref(false);
|
||||
const formJson = ref('');
|
||||
const formConfig = ref<SpreadsheetConfigJson>(
|
||||
getWarehouseSpreadsheetDefaults(props.productType),
|
||||
);
|
||||
const jsonError = ref('');
|
||||
const activeTab = ref<'form' | 'json'>('form');
|
||||
|
||||
let listLoadToken = 0;
|
||||
|
||||
const sheetKey = computed(() => warehouseSpreadsheetSheetKey(props.productType));
|
||||
|
||||
const isSuperAdmin = computed(() =>
|
||||
canManageSpreadsheetConfig(userStore.userInfo),
|
||||
);
|
||||
|
||||
const selectedItem = computed(() =>
|
||||
items.value.find((i) => i.id === selectedId.value),
|
||||
);
|
||||
|
||||
const isSystemConfig = computed(() => selectedItem.value?.admin_id === 0);
|
||||
|
||||
const isReadOnly = computed(() => isSystemConfig.value && !isSuperAdmin.value);
|
||||
|
||||
function formatJson(config: SpreadsheetConfigJson) {
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
function syncJsonFromConfig(config: SpreadsheetConfigJson) {
|
||||
formConfig.value = config;
|
||||
formJson.value = formatJson(config);
|
||||
jsonError.value = '';
|
||||
}
|
||||
|
||||
function parseJson(): SpreadsheetConfigJson | null {
|
||||
jsonError.value = '';
|
||||
try {
|
||||
const parsed = JSON.parse(formJson.value) as SpreadsheetConfigJson;
|
||||
if (!Array.isArray(parsed.headers) || !Array.isArray(parsed.columns)) {
|
||||
jsonError.value = 'config 需包含 headers 与 columns 数组';
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
jsonError.value = 'JSON 格式无效';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigForSave(): SpreadsheetConfigJson | null {
|
||||
if (activeTab.value === 'form') {
|
||||
return formConfig.value;
|
||||
}
|
||||
const parsed = parseJson();
|
||||
if (parsed) {
|
||||
formConfig.value = parsed;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
if (key === 'json' && activeTab.value === 'form') {
|
||||
formJson.value = formatJson(formConfig.value);
|
||||
jsonError.value = '';
|
||||
} else if (key === 'form' && activeTab.value === 'json') {
|
||||
const parsed = parseJson();
|
||||
if (!parsed) {
|
||||
message.warning('JSON 格式无效,请修正后再切换表单');
|
||||
return;
|
||||
}
|
||||
formConfig.value = parsed;
|
||||
}
|
||||
activeTab.value = key as 'form' | 'json';
|
||||
}
|
||||
|
||||
function onFormConfigUpdate(config: SpreadsheetConfigJson) {
|
||||
formConfig.value = config;
|
||||
if (activeTab.value === 'form') {
|
||||
formJson.value = formatJson(config);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const token = ++listLoadToken;
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await listSpreadsheetTableConfig({
|
||||
table_name: WAREHOUSE_SPREADSHEET_TABLE_NAME,
|
||||
sheet_key: sheetKey.value,
|
||||
});
|
||||
if (token !== listLoadToken) return;
|
||||
items.value = list;
|
||||
if (items.value.length === 0) {
|
||||
selectedId.value = null;
|
||||
resetFormFromDefaults();
|
||||
return;
|
||||
}
|
||||
const current =
|
||||
items.value.find((i) => i.id === selectedId.value) ??
|
||||
items.value.find((i) => i.is_default === 1) ??
|
||||
items.value[0];
|
||||
if (current) selectItem(current);
|
||||
} catch {
|
||||
if (token !== listLoadToken) return;
|
||||
items.value = [];
|
||||
resetFormFromDefaults();
|
||||
} finally {
|
||||
if (token === listLoadToken) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetFormFromDefaults() {
|
||||
formName.value = '默认';
|
||||
formIsDefault.value = true;
|
||||
syncJsonFromConfig(getWarehouseSpreadsheetDefaults(props.productType));
|
||||
}
|
||||
|
||||
function selectItem(item: SpreadsheetTableConfigItem) {
|
||||
selectedId.value = item.id;
|
||||
formName.value = item.name;
|
||||
formIsDefault.value = item.is_default === 1;
|
||||
syncJsonFromConfig(item.config);
|
||||
activeTab.value = 'form';
|
||||
}
|
||||
|
||||
function handleNew() {
|
||||
selectedId.value = null;
|
||||
resetFormFromDefaults();
|
||||
formName.value = `方案${items.value.length + 1}`;
|
||||
formIsDefault.value = false;
|
||||
activeTab.value = 'form';
|
||||
}
|
||||
|
||||
function handleFormat() {
|
||||
const parsed = parseJson();
|
||||
if (parsed) {
|
||||
formJson.value = formatJson(parsed);
|
||||
formConfig.value = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<boolean> {
|
||||
if (isReadOnly.value) {
|
||||
message.warning('系统默认配置不可编辑');
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = getConfigForSave();
|
||||
if (!config) return false;
|
||||
|
||||
const payload = {
|
||||
table_name: WAREHOUSE_SPREADSHEET_TABLE_NAME,
|
||||
sheet_key: sheetKey.value,
|
||||
name: formName.value.trim() || '默认',
|
||||
is_default: formIsDefault.value,
|
||||
config: mergeSpreadsheetConfig(
|
||||
config,
|
||||
getWarehouseSpreadsheetDefaults(props.productType),
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedId.value) {
|
||||
await updateSpreadsheetTableConfig({
|
||||
id: selectedId.value,
|
||||
name: payload.name,
|
||||
is_default: payload.is_default,
|
||||
config: payload.config,
|
||||
});
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createSpreadsheetTableConfig(payload);
|
||||
message.success('创建成功');
|
||||
}
|
||||
await loadList();
|
||||
emit('saved');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
message.error(err?.message ?? '保存失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteSpreadsheetTableConfig(id);
|
||||
message.success('删除成功');
|
||||
if (selectedId.value === id) selectedId.value = null;
|
||||
await loadList();
|
||||
emit('saved');
|
||||
} catch (err: any) {
|
||||
message.error(err?.message ?? '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreen: true,
|
||||
fullscreenButton: true,
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
showConfirmButton: true,
|
||||
onConfirm: async () => {
|
||||
return handleSave();
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
modalApi.setState({ fullscreen: true });
|
||||
void loadList();
|
||||
} else {
|
||||
listLoadToken += 1;
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open: () => modalApi.open(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="Excel 表格配置">
|
||||
<div class="config-modal-body">
|
||||
<div class="config-list-pane">
|
||||
<Space class="config-list-actions">
|
||||
<Button size="small" type="primary" @click="handleNew">新建</Button>
|
||||
<Button size="small" :loading="loading" @click="loadList">刷新</Button>
|
||||
</Space>
|
||||
<List
|
||||
:data-source="items"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item
|
||||
class="config-list-item"
|
||||
:class="{ active: item.id === selectedId }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<div class="config-list-item-main">
|
||||
<span>{{ item.name }}</span>
|
||||
<span v-if="item.admin_id === 0" class="system-tag">系统</span>
|
||||
<span v-if="item.is_default === 1" class="default-tag">默认</span>
|
||||
</div>
|
||||
<Popconfirm
|
||||
v-if="item.admin_id !== 0"
|
||||
title="确定删除该配置?"
|
||||
@confirm="handleDelete(item.id)"
|
||||
>
|
||||
<Button danger size="small" type="link" @click.stop>删除</Button>
|
||||
</Popconfirm>
|
||||
</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
</div>
|
||||
|
||||
<div class="config-editor-pane">
|
||||
<Space direction="vertical" class="config-editor-form" :size="12">
|
||||
<Input
|
||||
v-model:value="formName"
|
||||
:disabled="isReadOnly"
|
||||
addon-before="名称"
|
||||
/>
|
||||
<Checkbox v-model:checked="formIsDefault" :disabled="isReadOnly">
|
||||
设为默认
|
||||
</Checkbox>
|
||||
|
||||
<Alert
|
||||
v-if="isReadOnly"
|
||||
message="系统默认配置仅管理员(role 1/2)可编辑"
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
<Alert
|
||||
v-else-if="isSystemConfig && isSuperAdmin"
|
||||
message="正在编辑系统默认配置,保存后对所有用户生效"
|
||||
show-icon
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
<Tabs :active-key="activeTab" @change="onTabChange">
|
||||
<Tabs.TabPane key="form" tab="表单编辑">
|
||||
<SpreadsheetConfigFormEditor
|
||||
:config="formConfig"
|
||||
:disabled="isReadOnly"
|
||||
@update:config="onFormConfigUpdate"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="json" tab="JSON 高级">
|
||||
<div class="json-toolbar">
|
||||
<span>config JSON</span>
|
||||
<Button :disabled="isReadOnly" size="small" @click="handleFormat">
|
||||
格式化
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model:value="formJson"
|
||||
:auto-size="{ minRows: 14, maxRows: 22 }"
|
||||
:disabled="isReadOnly"
|
||||
class="json-textarea"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<div v-if="jsonError" class="json-error">{{ jsonError }}</div>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.config-modal-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.config-list-pane {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-list-actions {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-list-item {
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.config-list-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.default-tag {
|
||||
font-size: 12px;
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
.system-tag {
|
||||
font-size: 12px;
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.config-editor-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-editor-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.json-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.json-textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.json-error {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import { FloatButton, Tooltip } from 'ant-design-vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
const viewMode = defineModel<'excel' | 'list'>({ default: 'list' });
|
||||
|
||||
function toggle() {
|
||||
viewMode.value = viewMode.value === 'list' ? 'excel' : 'list';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip
|
||||
:title="viewMode === 'list' ? '切换到 Excel 视图' : '切换到列表视图'"
|
||||
placement="left"
|
||||
>
|
||||
<FloatButton
|
||||
:style="{ right: '24px', bottom: '84px' }"
|
||||
shape="circle"
|
||||
:type="viewMode === 'excel' ? 'primary' : 'default'"
|
||||
@click="toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<MIcon
|
||||
:icon="
|
||||
viewMode === 'list'
|
||||
? 'mdi:table-large'
|
||||
: 'ant-design:unordered-list-outlined'
|
||||
"
|
||||
size="20"
|
||||
/>
|
||||
</template>
|
||||
</FloatButton>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,536 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type {
|
||||
|
||||
SpreadsheetBatchCellChange,
|
||||
|
||||
SpreadsheetColumnConfig,
|
||||
|
||||
SpreadsheetTableConfig,
|
||||
|
||||
} from '#/components/canvas-spreadsheet';
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
|
||||
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
|
||||
|
||||
import {
|
||||
|
||||
listSpreadsheetTableConfig,
|
||||
|
||||
updateSpreadsheetTableConfig,
|
||||
|
||||
} from '#/api/spreadsheet-table-config';
|
||||
|
||||
import { CanvasSpreadsheet } from '#/components/canvas-spreadsheet';
|
||||
|
||||
|
||||
|
||||
import {
|
||||
|
||||
applyBatchPriceApi,
|
||||
|
||||
getAllForBatchPriceApi,
|
||||
|
||||
type WarehouseBatchPriceItem,
|
||||
|
||||
} from '../api';
|
||||
|
||||
import type { SpreadsheetConfigJson } from '../config/spreadsheetDefaults';
|
||||
|
||||
import {
|
||||
|
||||
getWarehouseSpreadsheetDefaults,
|
||||
|
||||
mergeSpreadsheetConfig,
|
||||
|
||||
WAREHOUSE_SPREADSHEET_TABLE_NAME,
|
||||
|
||||
warehouseSpreadsheetSheetKey,
|
||||
|
||||
} from '../config/spreadsheetDefaults';
|
||||
|
||||
import { normalizePricePair, parsePriceValue } from '../utils/priceCompare';
|
||||
|
||||
import { canManageSpreadsheetConfig } from '../utils/spreadsheetAdminRole';
|
||||
|
||||
|
||||
|
||||
import SpreadsheetAddColumnModal from './SpreadsheetAddColumnModal.vue';
|
||||
|
||||
import SpreadsheetSetColumnModal from './SpreadsheetSetColumnModal.vue';
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
productType: number;
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
openConfig: [];
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const configLoading = ref(false);
|
||||
|
||||
|
||||
|
||||
const rows = ref<WarehouseBatchPriceItem[]>([]);
|
||||
|
||||
const sheetData = ref<Record<string, unknown>[]>([]);
|
||||
|
||||
|
||||
|
||||
const mergedConfig = ref(getWarehouseSpreadsheetDefaults(props.productType));
|
||||
|
||||
const activeConfigId = ref<number | null>(null);
|
||||
|
||||
const workbookName = ref('默认');
|
||||
|
||||
|
||||
|
||||
const addColumnModalRef = ref<InstanceType<
|
||||
|
||||
typeof SpreadsheetAddColumnModal
|
||||
|
||||
> | null>(null);
|
||||
|
||||
const setColumnModalRef = ref<InstanceType<
|
||||
|
||||
typeof SpreadsheetSetColumnModal
|
||||
|
||||
> | null>(null);
|
||||
|
||||
|
||||
|
||||
const showConfigButton = computed(() =>
|
||||
|
||||
canManageSpreadsheetConfig(userStore.userInfo),
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
async function loadSpreadsheetConfig() {
|
||||
|
||||
configLoading.value = true;
|
||||
|
||||
try {
|
||||
|
||||
const defaults = getWarehouseSpreadsheetDefaults(props.productType);
|
||||
|
||||
const list = await listSpreadsheetTableConfig({
|
||||
|
||||
table_name: WAREHOUSE_SPREADSHEET_TABLE_NAME,
|
||||
|
||||
sheet_key: warehouseSpreadsheetSheetKey(props.productType),
|
||||
|
||||
});
|
||||
|
||||
const picked =
|
||||
|
||||
list.find((i) => i.is_default === 1) ?? list[0] ?? null;
|
||||
|
||||
activeConfigId.value = picked?.id ?? null;
|
||||
|
||||
workbookName.value = picked?.name ?? '默认';
|
||||
|
||||
mergedConfig.value = mergeSpreadsheetConfig(picked?.config, defaults);
|
||||
|
||||
} catch {
|
||||
|
||||
activeConfigId.value = null;
|
||||
|
||||
workbookName.value = '默认';
|
||||
|
||||
mergedConfig.value = getWarehouseSpreadsheetDefaults(props.productType);
|
||||
|
||||
} finally {
|
||||
|
||||
configLoading.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function persistConfig(config: SpreadsheetConfigJson) {
|
||||
|
||||
if (!activeConfigId.value) {
|
||||
|
||||
message.error('未找到可写入的配置,请先在 Excel 配置中创建');
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
await updateSpreadsheetTableConfig({
|
||||
|
||||
id: activeConfigId.value,
|
||||
|
||||
config,
|
||||
|
||||
});
|
||||
|
||||
mergedConfig.value = config;
|
||||
|
||||
message.success('配置已保存');
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function handleConfigSave(config: SpreadsheetConfigJson) {
|
||||
|
||||
await persistConfig(config);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function handleFormulaUpdate(payload: { col: number; formula: string }) {
|
||||
|
||||
const columns = mergedConfig.value.columns.map((c) =>
|
||||
|
||||
c.col === payload.col ? { ...c, formula: payload.formula } : c,
|
||||
|
||||
);
|
||||
|
||||
await persistConfig({ ...mergedConfig.value, columns });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function onAdminAddColumn() {
|
||||
|
||||
addColumnModalRef.value?.open();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function onAdminSetColumn(col: number) {
|
||||
|
||||
setColumnModalRef.value?.open(col);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function handleBatchPriceEdit(changes: SpreadsheetBatchCellChange[]) {
|
||||
|
||||
if (changes.length === 0) return;
|
||||
|
||||
|
||||
|
||||
const rowMap = new Map<number, Record<string, unknown>>();
|
||||
|
||||
|
||||
|
||||
for (const ch of changes) {
|
||||
|
||||
const id = Number(ch.ctx.rowKey);
|
||||
|
||||
const row = sheetData.value.find((r) => String(r.id) === String(id));
|
||||
|
||||
if (!row) continue;
|
||||
|
||||
|
||||
|
||||
if (!rowMap.has(id)) {
|
||||
|
||||
rowMap.set(id, { ...row });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
rowMap.get(id)![ch.ctx.fieldId] = parsePriceValue(ch.after);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const updates = [...rowMap.entries()].map(([id, row]) => {
|
||||
|
||||
const pair = normalizePricePair({
|
||||
|
||||
market_price: parsePriceValue(row.market_price),
|
||||
|
||||
price: parsePriceValue(row.price),
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
return {
|
||||
|
||||
id,
|
||||
|
||||
market_price: pair.market_price,
|
||||
|
||||
price: pair.price,
|
||||
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await applyBatchPriceApi({
|
||||
|
||||
type: props.productType,
|
||||
|
||||
updates,
|
||||
|
||||
notify: false,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
for (const [id, row] of rowMap) {
|
||||
|
||||
const sheetRow = sheetData.value.find((r) => Number(r.id) === id);
|
||||
|
||||
if (!sheetRow) continue;
|
||||
|
||||
sheetRow.market_price = parsePriceValue(row.market_price);
|
||||
|
||||
sheetRow.price = parsePriceValue(row.price);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
message.success('保存成功');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const tableConfig = computed<SpreadsheetTableConfig>(() => ({
|
||||
|
||||
tableName: WAREHOUSE_SPREADSHEET_TABLE_NAME,
|
||||
|
||||
sheetKey: warehouseSpreadsheetSheetKey(props.productType),
|
||||
|
||||
enableFilter: mergedConfig.value.enableFilter ?? true,
|
||||
|
||||
enableHistory: mergedConfig.value.enableHistory ?? true,
|
||||
|
||||
rowHeight: mergedConfig.value.rowHeight ?? 'auto',
|
||||
|
||||
defaultColWidth: mergedConfig.value.defaultColWidth ?? 120,
|
||||
|
||||
defaultTextAlign: mergedConfig.value.defaultTextAlign ?? 'left',
|
||||
|
||||
onBatchEdit: handleBatchPriceEdit,
|
||||
|
||||
onRefresh: refreshData,
|
||||
|
||||
headers: mergedConfig.value.headers,
|
||||
|
||||
}));
|
||||
|
||||
|
||||
|
||||
const columns = computed<SpreadsheetColumnConfig[]>(
|
||||
|
||||
() => mergedConfig.value.columns,
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
async function refreshData() {
|
||||
|
||||
const res = await getAllForBatchPriceApi({ type: props.productType });
|
||||
|
||||
rows.value = res.items ?? [];
|
||||
|
||||
sheetData.value = rows.value.map((item) => ({
|
||||
|
||||
id: item.id,
|
||||
|
||||
drug_id: item.drug_id,
|
||||
|
||||
drug_name: item.drug_name,
|
||||
|
||||
pinyin_simple: item.pinyin_simple,
|
||||
|
||||
drug_number: item.drug_number,
|
||||
|
||||
market_price: parsePriceValue(item.market_price),
|
||||
|
||||
price: parsePriceValue(item.price),
|
||||
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function loadData() {
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
|
||||
await Promise.all([loadSpreadsheetConfig(), refreshData()]);
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function onSheetChange(payload: { data: Record<string, unknown>[] }) {
|
||||
|
||||
sheetData.value = payload.data;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function reloadConfig() {
|
||||
|
||||
await loadSpreadsheetConfig();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
defineExpose({ reloadConfig });
|
||||
|
||||
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="warehouse-drug-excel-view">
|
||||
|
||||
<CanvasSpreadsheet
|
||||
|
||||
:key="`${productType}-${columns.length}-${tableConfig.headers.length}-${activeConfigId}`"
|
||||
|
||||
:columns="columns"
|
||||
|
||||
:data="sheetData"
|
||||
|
||||
:loading="loading || configLoading"
|
||||
|
||||
:show-config-button="showConfigButton"
|
||||
|
||||
:table-config="tableConfig"
|
||||
|
||||
:workbook-name="workbookName"
|
||||
|
||||
class="warehouse-excel-canvas"
|
||||
|
||||
row-key-field="id"
|
||||
|
||||
@admin-add-column="onAdminAddColumn"
|
||||
|
||||
@admin-set-column="onAdminSetColumn"
|
||||
|
||||
@change="onSheetChange"
|
||||
|
||||
@open-config="emit('openConfig')"
|
||||
|
||||
@update-column-formula="handleFormulaUpdate"
|
||||
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<SpreadsheetAddColumnModal
|
||||
|
||||
ref="addColumnModalRef"
|
||||
|
||||
:config="mergedConfig"
|
||||
|
||||
@save="handleConfigSave"
|
||||
|
||||
/>
|
||||
|
||||
<SpreadsheetSetColumnModal
|
||||
|
||||
ref="setColumnModalRef"
|
||||
|
||||
:config="mergedConfig"
|
||||
|
||||
@save="handleConfigSave"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.warehouse-drug-excel-view {
|
||||
|
||||
flex: 1;
|
||||
|
||||
min-height: 0;
|
||||
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.warehouse-excel-canvas {
|
||||
|
||||
flex: 1;
|
||||
|
||||
min-height: 0;
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetTextAlign,
|
||||
} from '#/components/canvas-spreadsheet';
|
||||
|
||||
export const WAREHOUSE_SPREADSHEET_TABLE_NAME = '中药仓库-总仓-价格';
|
||||
|
||||
export function warehouseSpreadsheetSheetKey(productType: number) {
|
||||
return `type_${productType}`;
|
||||
}
|
||||
|
||||
export interface SpreadsheetConfigJson {
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
columns: SpreadsheetColumnConfig[];
|
||||
defaultColWidth?: number;
|
||||
defaultTextAlign?: SpreadsheetTextAlign;
|
||||
enableFilter?: boolean;
|
||||
enableHistory?: boolean;
|
||||
rowHeight?: number | 'auto';
|
||||
}
|
||||
|
||||
export function getWarehouseSpreadsheetDefaults(
|
||||
productType: number,
|
||||
): SpreadsheetConfigJson {
|
||||
return {
|
||||
defaultColWidth: 120,
|
||||
defaultTextAlign: 'left',
|
||||
enableFilter: true,
|
||||
enableHistory: true,
|
||||
rowHeight: 'auto',
|
||||
headers: [
|
||||
{
|
||||
title: '药品名称',
|
||||
col: 0,
|
||||
fieldId: 'drug_name',
|
||||
filterable: true,
|
||||
fixed: true,
|
||||
headerColor: '#f0f5ff',
|
||||
},
|
||||
{ title: '拼音', col: 1, fieldId: 'pinyin_simple', filterable: true },
|
||||
{ title: '货号', col: 2, fieldId: 'drug_number', filterable: true },
|
||||
{
|
||||
title: '供货价',
|
||||
col: 3,
|
||||
fieldId: 'market_price',
|
||||
filterable: true,
|
||||
headerColor: '#fff7e6',
|
||||
},
|
||||
{ title: '建议售价', col: 4, fieldId: 'price', filterable: true },
|
||||
{
|
||||
title: '价差',
|
||||
col: 5,
|
||||
fieldId: 'price_diff',
|
||||
filterable: false,
|
||||
headerColor: '#f6ffed',
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
fieldId: 'drug_name',
|
||||
fieldName: '药品名称',
|
||||
cellType: 'readonly',
|
||||
col: 0,
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
fieldId: 'pinyin_simple',
|
||||
fieldName: '拼音',
|
||||
cellType: 'readonly',
|
||||
col: 1,
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
fieldId: 'drug_number',
|
||||
fieldName: '货号',
|
||||
cellType: 'readonly',
|
||||
col: 2,
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
fieldId: 'market_price',
|
||||
fieldName: '供货价',
|
||||
cellType: 'number',
|
||||
col: 3,
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldId: 'price',
|
||||
fieldName: '建议售价',
|
||||
cellType: 'number',
|
||||
col: 4,
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
fieldId: 'price_diff',
|
||||
fieldName: '价差',
|
||||
cellType: 'formula',
|
||||
col: 5,
|
||||
editable: false,
|
||||
formula: '=(E1-D1)',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeSpreadsheetConfig(
|
||||
remote: Partial<SpreadsheetConfigJson> | null | undefined,
|
||||
defaults: SpreadsheetConfigJson,
|
||||
): SpreadsheetConfigJson {
|
||||
if (!remote) return { ...defaults };
|
||||
return {
|
||||
defaultColWidth: remote.defaultColWidth ?? defaults.defaultColWidth,
|
||||
defaultTextAlign: remote.defaultTextAlign ?? defaults.defaultTextAlign,
|
||||
enableFilter: remote.enableFilter ?? defaults.enableFilter,
|
||||
enableHistory: remote.enableHistory ?? defaults.enableHistory,
|
||||
rowHeight: remote.rowHeight ?? defaults.rowHeight,
|
||||
headers:
|
||||
Array.isArray(remote.headers) && remote.headers.length > 0
|
||||
? remote.headers
|
||||
: defaults.headers,
|
||||
columns:
|
||||
Array.isArray(remote.columns) && remote.columns.length > 0
|
||||
? remote.columns
|
||||
: defaults.columns,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
||||
|
||||
@@ -19,15 +20,38 @@ import {
|
||||
} from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import SpreadsheetTableConfigModal from './components/SpreadsheetTableConfigModal.vue';
|
||||
import TcmPriceBatchModal from './components/TcmPriceBatchModal.vue';
|
||||
import ViewModeFloatButton from './components/ViewModeFloatButton.vue';
|
||||
import WarehouseDrugExcelView from './components/WarehouseDrugExcelView.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { createGridOptions } from './config/table';
|
||||
import {
|
||||
readWarehouseViewMode,
|
||||
writeWarehouseViewMode,
|
||||
} from './utils/warehouseViewModeStorage';
|
||||
import { canManageSpreadsheetConfig } from './utils/spreadsheetAdminRole';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const { productType, typeLabel } = useWarehouseDrugTypeRoute(
|
||||
'/warehouse-drug-management/type/1',
|
||||
);
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const viewMode = ref<'excel' | 'list'>('list');
|
||||
|
||||
onMounted(() => {
|
||||
if (productType.value === 1) {
|
||||
viewMode.value = readWarehouseViewMode(productType.value) ?? 'list';
|
||||
}
|
||||
});
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (productType.value === 1) {
|
||||
writeWarehouseViewMode(productType.value, mode);
|
||||
}
|
||||
});
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -62,6 +86,14 @@ const [TcmPriceBatchModalComp, TcmPriceBatchModalApi] = useVbenModal({
|
||||
connectedComponent: TcmPriceBatchModal,
|
||||
});
|
||||
|
||||
const spreadsheetConfigModalRef = ref<InstanceType<
|
||||
typeof SpreadsheetTableConfigModal
|
||||
> | null>(null);
|
||||
|
||||
const excelViewRef = ref<InstanceType<typeof WarehouseDrugExcelView> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const TCM_PLACEHOLDER_IMAGE =
|
||||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
||||
|
||||
@@ -130,6 +162,14 @@ const updateStatus = (id: number) => {
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
|
||||
function openSpreadsheetConfigModal() {
|
||||
spreadsheetConfigModalRef.value?.open();
|
||||
}
|
||||
|
||||
function onSpreadsheetConfigSaved() {
|
||||
excelViewRef.value?.reloadConfig();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -137,7 +177,27 @@ const updateStatus = (id: number) => {
|
||||
<ExcelUploadModal />
|
||||
<TcmPriceBatchModalComp />
|
||||
<FormModal />
|
||||
<Grid v-if="productType">
|
||||
<SpreadsheetTableConfigModal
|
||||
v-if="productType === 1"
|
||||
ref="spreadsheetConfigModalRef"
|
||||
:product-type="productType"
|
||||
@saved="onSpreadsheetConfigSaved"
|
||||
/>
|
||||
<ViewModeFloatButton
|
||||
v-if="productType === 1"
|
||||
v-model="viewMode"
|
||||
/>
|
||||
<div
|
||||
v-if="productType === 1 && viewMode === 'excel'"
|
||||
class="warehouse-excel-shell"
|
||||
>
|
||||
<WarehouseDrugExcelView
|
||||
ref="excelViewRef"
|
||||
:product-type="productType"
|
||||
@open-config="openSpreadsheetConfigModal"
|
||||
/>
|
||||
</div>
|
||||
<Grid v-if="productType && viewMode === 'list'">
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -169,6 +229,15 @@ const updateStatus = (id: number) => {
|
||||
ifShow: () => productType === 1,
|
||||
onClick: openTcmPriceBatchModal,
|
||||
},
|
||||
{
|
||||
label: 'Excel 配置',
|
||||
type: 'default',
|
||||
icon: 'mdi:table-cog',
|
||||
ifShow: () =>
|
||||
productType === 1 &&
|
||||
canManageSpreadsheetConfig(userStore.userInfo),
|
||||
onClick: openSpreadsheetConfigModal,
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
type: 'primary',
|
||||
@@ -275,3 +344,14 @@ const updateStatus = (id: number) => {
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.warehouse-excel-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 180px);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type SpreadsheetAdminUserInfo = {
|
||||
role_id?: number;
|
||||
roles?: { id?: number };
|
||||
} | null | undefined;
|
||||
|
||||
export function canManageSpreadsheetConfig(
|
||||
userInfo: SpreadsheetAdminUserInfo,
|
||||
): boolean {
|
||||
const roleId = Number(userInfo?.role_id ?? userInfo?.roles?.id);
|
||||
return roleId === 1 || roleId === 2;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type WarehouseViewMode = 'excel' | 'list';
|
||||
|
||||
const STORAGE_KEY = 'warehouse-drug-admin-view-mode';
|
||||
|
||||
type ViewModeMap = Record<string, WarehouseViewMode>;
|
||||
|
||||
function readMap(): ViewModeMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as ViewModeMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readWarehouseViewMode(productType: number): WarehouseViewMode | null {
|
||||
const map = readMap();
|
||||
const mode = map[String(productType)];
|
||||
return mode === 'excel' || mode === 'list' ? mode : null;
|
||||
}
|
||||
|
||||
export function writeWarehouseViewMode(
|
||||
productType: number,
|
||||
mode: WarehouseViewMode,
|
||||
) {
|
||||
const map = readMap();
|
||||
map[String(productType)] = mode;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||
}
|
||||
Reference in New Issue
Block a user