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

This commit is contained in:
李琦
2026-06-25 16:09:07 +08:00
parent 8cfef12a56
commit 69862cd982
51 changed files with 10673 additions and 22 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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));
}