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