1. 加工费模块重构
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
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
2. 快递费模块重构 3. 订单导出功能优化 4. 商品订单的折扣功能 5. 修复了一些已知BUG
This commit is contained in:
@@ -133,10 +133,13 @@ const openBankCardReport = async (row: any, update = false) => {
|
||||
} catch {
|
||||
report = null;
|
||||
}
|
||||
const reportStatus = Number(report?.report_status ?? 0);
|
||||
const useUpdate =
|
||||
(update || [3, 4].includes(reportStatus)) && Boolean(report?.id);
|
||||
bankCardReportModalApi.setData({
|
||||
storeId: row.id,
|
||||
storeName: row.name,
|
||||
mode: update && report?.id ? 'update' : 'report',
|
||||
mode: useUpdate ? 'update' : 'report',
|
||||
report,
|
||||
gridApi,
|
||||
});
|
||||
|
||||
@@ -1,186 +1,182 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Button, Input, InputNumber, Popover, Tag } from 'ant-design-vue';
|
||||
|
||||
type TagOption = { label: string; value: number | string };
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
displayFormatter?: (value: unknown) => string;
|
||||
label?: string;
|
||||
options?: TagOption[];
|
||||
placeholder?: string;
|
||||
type?: 'number' | 'tags' | 'text';
|
||||
value?: null | number | string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
label: '',
|
||||
options: () => [],
|
||||
placeholder: '请输入',
|
||||
type: 'text',
|
||||
value: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [value: number | string];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const draftText = ref('');
|
||||
const draftNumber = ref<null | number>(null);
|
||||
const saving = ref(false);
|
||||
|
||||
watch(open, (visible) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
if (props.type === 'number') {
|
||||
const raw = props.value;
|
||||
draftNumber.value =
|
||||
raw !== null && raw !== undefined && raw !== '' ? Number(raw) : null;
|
||||
return;
|
||||
}
|
||||
draftText.value = String(props.value ?? '');
|
||||
});
|
||||
|
||||
function displayText() {
|
||||
if (props.displayFormatter) {
|
||||
return props.displayFormatter(props.value);
|
||||
}
|
||||
if (props.value === null || props.value === undefined || props.value === '') {
|
||||
return '-';
|
||||
}
|
||||
if (props.type === 'tags') {
|
||||
const matched = props.options.find(
|
||||
(item) => String(item.value) === String(props.value),
|
||||
);
|
||||
return matched?.label ?? String(props.value);
|
||||
}
|
||||
return String(props.value);
|
||||
}
|
||||
|
||||
async function confirmSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (props.type === 'number') {
|
||||
if (draftNumber.value === null || draftNumber.value === undefined) {
|
||||
return;
|
||||
}
|
||||
emit('save', draftNumber.value);
|
||||
} else {
|
||||
const text = draftText.value.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
emit('save', text);
|
||||
}
|
||||
open.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectTag(value: number | string) {
|
||||
emit('save', value);
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open" trigger="click" placement="bottomLeft">
|
||||
<template #content>
|
||||
<div class="inline-editor-popover">
|
||||
<div v-if="label" class="inline-editor-popover__label">{{ label }}</div>
|
||||
<template v-if="type === 'tags'">
|
||||
<div class="inline-editor-popover__tags">
|
||||
<Tag
|
||||
v-for="opt in options"
|
||||
:key="String(opt.value)"
|
||||
class="cursor-pointer"
|
||||
:color="String(value) === String(opt.value) ? 'processing' : 'default'"
|
||||
@click="selectTag(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="type === 'number'">
|
||||
<InputNumber
|
||||
v-model:value="draftNumber"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<div class="inline-editor-popover__actions">
|
||||
<Button size="small" @click="open = false">取消</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="confirmSave"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Input
|
||||
v-model:value="draftText"
|
||||
:placeholder="placeholder"
|
||||
@press-enter="confirmSave"
|
||||
/>
|
||||
<div class="inline-editor-popover__actions">
|
||||
<Button size="small" @click="open = false">取消</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="confirmSave"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<Button
|
||||
class="h-auto p-0 text-left"
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
type="link"
|
||||
@click.stop
|
||||
>
|
||||
{{ displayText() }}
|
||||
</Button>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inline-editor-popover {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.inline-editor-popover__label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.inline-editor-popover__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.inline-editor-popover__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
|
||||
|
||||
import { Button, Input, InputNumber, Popover, Tag } from 'ant-design-vue';
|
||||
|
||||
|
||||
|
||||
type TagOption = { label: string; value: number | string };
|
||||
|
||||
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
defineProps<{
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
displayFormatter?: (value: unknown) => string;
|
||||
|
||||
label?: string;
|
||||
|
||||
options?: TagOption[];
|
||||
|
||||
placeholder?: string;
|
||||
|
||||
type?: 'number' | 'tags' | 'text';
|
||||
|
||||
value?: null | number | string;
|
||||
|
||||
}>(),
|
||||
|
||||
{
|
||||
|
||||
disabled: false,
|
||||
|
||||
label: '',
|
||||
|
||||
options: () => [],
|
||||
|
||||
placeholder: '请输入',
|
||||
|
||||
type: 'text',
|
||||
|
||||
value: '',
|
||||
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
save: [value: number | string];
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const draftText = ref('');
|
||||
|
||||
const draftNumber = ref<null | number>(null);
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
|
||||
|
||||
watch(open, (visible) => {
|
||||
|
||||
if (!visible) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (props.type === 'number') {
|
||||
|
||||
const raw = props.value;
|
||||
|
||||
draftNumber.value =
|
||||
|
||||
raw !== null && raw !== undefined && raw !== '' ? Number(raw) : null;
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
draftText.value = String(props.value ?? '');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
function displayText() {
|
||||
|
||||
if (props.displayFormatter) {
|
||||
|
||||
return props.displayFormatter(props.value);
|
||||
|
||||
}
|
||||
|
||||
if (props.value === null || props.value === undefined || props.value === '') {
|
||||
|
||||
return '点击设置';
|
||||
|
||||
}
|
||||
|
||||
if (props.type === 'tags') {
|
||||
|
||||
const matched = props.options.find(
|
||||
|
||||
(item) => String(item.value) === String(props.value),
|
||||
|
||||
);
|
||||
|
||||
return matched?.label ?? String(props.value);
|
||||
|
||||
}
|
||||
|
||||
return String(props.value);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function isEmptyValue() {
|
||||
|
||||
return props.value === null || props.value === undefined || props.value === '';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function confirmSave() {
|
||||
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
|
||||
if (props.type === 'number') {
|
||||
|
||||
if (draftNumber.value === null || draftNumber.value === undefined) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
emit('save', draftNumber.value);
|
||||
|
||||
} else {
|
||||
|
||||
const text = draftText.value.trim();
|
||||
|
||||
if (!text) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
emit('save', text);
|
||||
|
||||
}
|
||||
|
||||
open.value = false;
|
||||
|
||||
} finally {
|
||||
|
||||
saving.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function selectTag(value: number | string) {
|
||||
|
||||
|
||||
@@ -1,118 +1,220 @@
|
||||
<script lang="ts" setup>
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { Descriptions, Empty, message } from 'ant-design-vue';
|
||||
|
||||
|
||||
|
||||
import { updateProcess } from '#/views/system/process/api';
|
||||
|
||||
|
||||
|
||||
import {
|
||||
|
||||
CALC_METHOD_MAP,
|
||||
|
||||
CALC_METHOD_OPTIONS,
|
||||
|
||||
UNIT_OPTIONS,
|
||||
|
||||
} from '../constants';
|
||||
|
||||
import type { ProcessRuleRow } from '../constants';
|
||||
|
||||
import InlineFieldEditor from './inline-field-editor.vue';
|
||||
|
||||
import ConfigSectionHeader from '#/views/system/shared/config-section-header.vue';
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
method: ProcessRuleRow | null | undefined;
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
updated: [patch: Partial<ProcessRuleRow>];
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
async function saveField(payload: Record<string, unknown>) {
|
||||
|
||||
if (!props.method) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
await updateProcess({
|
||||
|
||||
id: props.method.id,
|
||||
|
||||
node_type: 'rule',
|
||||
|
||||
...payload,
|
||||
|
||||
});
|
||||
|
||||
emit('updated', payload as Partial<ProcessRuleRow>);
|
||||
|
||||
message.success('保存成功');
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="method" class="method-info-bar">
|
||||
<div class="method-info-bar__item">
|
||||
<span class="method-info-bar__label">级别</span>
|
||||
<span>煎法</span>
|
||||
|
||||
<section class="rounded-lg border border-border bg-muted/30 dark:border-gray-700">
|
||||
|
||||
<ConfigSectionHeader class="mb-0 px-4 pt-3" title="煎法配置" />
|
||||
|
||||
|
||||
|
||||
<div v-if="method" class="px-4 pb-3 pt-1">
|
||||
|
||||
<Descriptions
|
||||
|
||||
bordered
|
||||
|
||||
:column="3"
|
||||
|
||||
size="small"
|
||||
|
||||
class="process-method-descriptions"
|
||||
|
||||
>
|
||||
|
||||
<Descriptions.Item label="级别">煎法</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="名称">
|
||||
|
||||
<InlineFieldEditor
|
||||
|
||||
:value="method.name"
|
||||
|
||||
label="名称"
|
||||
|
||||
placeholder="请输入名称"
|
||||
|
||||
@save="(val) => saveField({ name: val })"
|
||||
|
||||
/>
|
||||
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="计算方式">
|
||||
|
||||
<InlineFieldEditor
|
||||
|
||||
type="tags"
|
||||
|
||||
:value="method.calc_method"
|
||||
|
||||
label="计算方式"
|
||||
|
||||
:options="[...CALC_METHOD_OPTIONS]"
|
||||
|
||||
:display-formatter="
|
||||
|
||||
(val) => CALC_METHOD_MAP[Number(val)] || String(val ?? '点击设置')
|
||||
|
||||
"
|
||||
|
||||
@save="(val) => saveField({ calc_method: Number(val) })"
|
||||
|
||||
/>
|
||||
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="计算价格">
|
||||
|
||||
<InlineFieldEditor
|
||||
|
||||
type="number"
|
||||
|
||||
:value="method.price"
|
||||
|
||||
label="计算价格"
|
||||
|
||||
placeholder="请输入价格"
|
||||
|
||||
@save="(val) => saveField({ price: val })"
|
||||
|
||||
/>
|
||||
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="计算单位" :span="2">
|
||||
|
||||
<InlineFieldEditor
|
||||
|
||||
type="tags"
|
||||
|
||||
:value="method.unit"
|
||||
|
||||
label="计算单位"
|
||||
|
||||
:options="UNIT_OPTIONS.map((u) => ({ label: u, value: u }))"
|
||||
|
||||
@save="(val) => saveField({ unit: val })"
|
||||
|
||||
/>
|
||||
|
||||
</Descriptions.Item>
|
||||
|
||||
</Descriptions>
|
||||
|
||||
</div>
|
||||
<div class="method-info-bar__item">
|
||||
<span class="method-info-bar__label">名称</span>
|
||||
<InlineFieldEditor
|
||||
:value="method.name"
|
||||
label="名称"
|
||||
placeholder="请输入名称"
|
||||
@save="(val) => saveField({ name: val })"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div v-else class="flex justify-center px-4 py-6">
|
||||
|
||||
<Empty description="请选择上方煎法 Tab" />
|
||||
|
||||
</div>
|
||||
<div class="method-info-bar__item">
|
||||
<span class="method-info-bar__label">计算方式</span>
|
||||
<InlineFieldEditor
|
||||
type="tags"
|
||||
:value="method.calc_method"
|
||||
label="计算方式"
|
||||
:options="[...CALC_METHOD_OPTIONS]"
|
||||
:display-formatter="
|
||||
(val) => CALC_METHOD_MAP[Number(val)] || String(val ?? '-')
|
||||
"
|
||||
@save="(val) => saveField({ calc_method: Number(val) })"
|
||||
/>
|
||||
</div>
|
||||
<div class="method-info-bar__item">
|
||||
<span class="method-info-bar__label">计算价格</span>
|
||||
<InlineFieldEditor
|
||||
type="number"
|
||||
:value="method.price"
|
||||
label="计算价格"
|
||||
placeholder="请输入价格"
|
||||
@save="(val) => saveField({ price: val })"
|
||||
/>
|
||||
</div>
|
||||
<div class="method-info-bar__item">
|
||||
<span class="method-info-bar__label">计算单位</span>
|
||||
<InlineFieldEditor
|
||||
type="tags"
|
||||
:value="method.unit"
|
||||
label="计算单位"
|
||||
:options="UNIT_OPTIONS.map((u) => ({ label: u, value: u }))"
|
||||
@save="(val) => saveField({ unit: val })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="method-info-bar method-info-bar--empty">
|
||||
请选择或新增煎法
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
.method-info-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
|
||||
.process-method-descriptions :deep(.ant-descriptions-view) {
|
||||
|
||||
border-color: hsl(var(--border));
|
||||
|
||||
}
|
||||
|
||||
.method-info-bar--empty {
|
||||
color: #9ca3af;
|
||||
|
||||
|
||||
.process-method-descriptions :deep(.ant-descriptions-item-label) {
|
||||
|
||||
color: hsl(var(--muted-foreground));
|
||||
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
|
||||
}
|
||||
|
||||
.method-info-bar__item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 140px;
|
||||
|
||||
|
||||
.process-method-descriptions :deep(.ant-descriptions-item-content) {
|
||||
|
||||
color: hsl(var(--foreground));
|
||||
|
||||
background: hsl(var(--card));
|
||||
|
||||
}
|
||||
|
||||
.method-info-bar__label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { computed, ref } from 'vue';
|
||||
import { getProcessList } from '#/views/system/process/api';
|
||||
|
||||
import type { ProcessNoteRow, ProcessRuleRow } from '../constants';
|
||||
import {
|
||||
readProcessSelection,
|
||||
writeProcessSelection,
|
||||
} from '../utils/processSelectionStorage';
|
||||
|
||||
export function parseProcessListItems(items: Record<string, any>[]) {
|
||||
const level1: ProcessRuleRow[] = [];
|
||||
@@ -47,14 +51,35 @@ export function parseProcessListItems(items: Record<string, any>[]) {
|
||||
return { level1, level2Map, notesMap };
|
||||
}
|
||||
|
||||
function restoreSelection(
|
||||
level1: ProcessRuleRow[],
|
||||
level2Map: Record<number, ProcessRuleRow[]>,
|
||||
savedLevel1Id: number | null,
|
||||
savedMethodId: number | null,
|
||||
) {
|
||||
let level1Id = savedLevel1Id;
|
||||
if (!level1Id || !level1.some((item) => item.id === level1Id)) {
|
||||
level1Id = level1[0]?.id ?? null;
|
||||
}
|
||||
|
||||
const methods = level1Id ? (level2Map[level1Id] ?? []) : [];
|
||||
let methodId = savedMethodId;
|
||||
if (!methodId || !methods.some((item) => item.id === methodId)) {
|
||||
methodId = methods[0]?.id ?? null;
|
||||
}
|
||||
|
||||
return { level1Id, methodId };
|
||||
}
|
||||
|
||||
export function useProcessTree() {
|
||||
const saved = readProcessSelection();
|
||||
const loading = ref(false);
|
||||
const keyword = ref('');
|
||||
const level1List = ref<ProcessRuleRow[]>([]);
|
||||
const level2Map = ref<Record<number, ProcessRuleRow[]>>({});
|
||||
const notesMap = ref<Record<number, ProcessNoteRow[]>>({});
|
||||
const selectedLevel1Id = ref<number | null>(null);
|
||||
const selectedMethodId = ref<number | null>(null);
|
||||
const selectedLevel1Id = ref<number | null>(saved.level1Id);
|
||||
const selectedMethodId = ref<number | null>(saved.methodId);
|
||||
|
||||
const filteredLevel1 = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase();
|
||||
@@ -103,6 +128,13 @@ export function useProcessTree() {
|
||||
level1List.value.find((item) => item.id === selectedLevel1Id.value),
|
||||
);
|
||||
|
||||
function persistSelection() {
|
||||
writeProcessSelection({
|
||||
level1Id: selectedLevel1Id.value,
|
||||
methodId: selectedMethodId.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -116,22 +148,15 @@ export function useProcessTree() {
|
||||
level2Map.value = parsed.level2Map;
|
||||
notesMap.value = parsed.notesMap;
|
||||
|
||||
if (
|
||||
!selectedLevel1Id.value ||
|
||||
!level1List.value.some((item) => item.id === selectedLevel1Id.value)
|
||||
) {
|
||||
selectedLevel1Id.value = level1List.value[0]?.id ?? null;
|
||||
}
|
||||
|
||||
const methods = selectedLevel1Id.value
|
||||
? (level2Map.value[selectedLevel1Id.value] ?? [])
|
||||
: [];
|
||||
if (
|
||||
!selectedMethodId.value ||
|
||||
!methods.some((item) => item.id === selectedMethodId.value)
|
||||
) {
|
||||
selectedMethodId.value = methods[0]?.id ?? null;
|
||||
}
|
||||
const restored = restoreSelection(
|
||||
level1List.value,
|
||||
level2Map.value,
|
||||
selectedLevel1Id.value,
|
||||
selectedMethodId.value,
|
||||
);
|
||||
selectedLevel1Id.value = restored.level1Id;
|
||||
selectedMethodId.value = restored.methodId;
|
||||
persistSelection();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -141,10 +166,12 @@ export function useProcessTree() {
|
||||
selectedLevel1Id.value = id;
|
||||
const methods = level2Map.value[id] ?? [];
|
||||
selectedMethodId.value = methods[0]?.id ?? null;
|
||||
persistSelection();
|
||||
}
|
||||
|
||||
function selectMethod(id: number) {
|
||||
selectedMethodId.value = id;
|
||||
persistSelection();
|
||||
}
|
||||
|
||||
function patchRule(id: number, patch: Partial<ProcessRuleRow>) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { SUB_LIST_GRID_HEIGHT } from '#/views/system/shared/sub-list-grid';
|
||||
|
||||
export const noteGridOptions: VxeGridProps = {
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
@@ -33,7 +35,7 @@ export const noteGridOptions: VxeGridProps = {
|
||||
minWidth: 160,
|
||||
},
|
||||
],
|
||||
height: 'auto',
|
||||
height: SUB_LIST_GRID_HEIGHT,
|
||||
border: true,
|
||||
showOverflow: true,
|
||||
pagerConfig: {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
Spin,
|
||||
Tabs,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { Button, Empty, Tabs, message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
@@ -22,6 +15,9 @@ import InlineFieldEditor from './components/inline-field-editor.vue';
|
||||
import MethodInfoBar from './components/method-info-bar.vue';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import MovePidModal from './components/move-pid-modal.vue';
|
||||
import ConfigSectionHeader from '#/views/system/shared/config-section-header.vue';
|
||||
import ConfigTreeSidebar from '#/views/system/shared/config-tree-sidebar.vue';
|
||||
import type { TreeSidebarRow } from '#/views/system/shared/types';
|
||||
import { useProcessTree } from './composables/useProcessTree';
|
||||
import type { ProcessNoteRow, ProcessRuleRow } from './constants';
|
||||
import { noteGridOptions } from './config/note-table';
|
||||
@@ -45,6 +41,10 @@ const {
|
||||
selectedMethodId,
|
||||
} = useProcessTree();
|
||||
|
||||
const breadcrumbMethodName = computed(
|
||||
() => selectedMethod.value?.name ?? '未选择煎法',
|
||||
);
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
@@ -195,8 +195,8 @@ function buildNoteMenus(row: ProcessNoteRow): ContextMenuItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
function onLevel1ContextMenu(e: MouseEvent, row: ProcessRuleRow) {
|
||||
showContextMenu(e, buildLevel1Menus(row), row);
|
||||
function onLevel1ContextMenu(e: MouseEvent, row: TreeSidebarRow) {
|
||||
showContextMenu(e, buildLevel1Menus(row as ProcessRuleRow), row);
|
||||
}
|
||||
|
||||
function onMethodTabContextMenu(e: MouseEvent, row: ProcessRuleRow) {
|
||||
@@ -207,9 +207,10 @@ function onNoteContextMenu(e: MouseEvent, row: ProcessNoteRow) {
|
||||
showContextMenu(e, buildNoteMenus(row), row);
|
||||
}
|
||||
|
||||
async function saveLevel1Name(row: ProcessRuleRow, name: string) {
|
||||
await updateProcess({ id: row.id, node_type: 'rule', name });
|
||||
patchRule(row.id, { name });
|
||||
async function saveLevel1Name(row: TreeSidebarRow, name: string) {
|
||||
const rule = row as ProcessRuleRow;
|
||||
await updateProcess({ id: rule.id, node_type: 'rule', name });
|
||||
patchRule(rule.id, { name });
|
||||
message.success('保存成功');
|
||||
}
|
||||
|
||||
@@ -243,114 +244,198 @@ function onSelectLevel1(id: number) {
|
||||
selectLevel1(id);
|
||||
syncNoteGrid();
|
||||
}
|
||||
|
||||
function openCreateMethod() {
|
||||
if (!selectedLevel1.value) {
|
||||
return;
|
||||
}
|
||||
openCreateModal({ formLevel: 2, parentRow: selectedLevel1.value });
|
||||
}
|
||||
|
||||
function openCreateNote() {
|
||||
if (!selectedMethod.value) {
|
||||
return;
|
||||
}
|
||||
openCreateModal({ formLevel: 3, parentRow: selectedMethod.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="加工费管理">
|
||||
<Page
|
||||
auto-content-height
|
||||
description="右键制剂、煎法 Tab 或备注行可新增/移动;点击虚线下划线字段可编辑"
|
||||
title="加工费管理"
|
||||
>
|
||||
<FormModal />
|
||||
<MoveModal />
|
||||
|
||||
<div class="process-layout">
|
||||
<aside class="process-sidebar">
|
||||
<div class="process-sidebar__toolbar">
|
||||
<Button type="primary" @click="openCreateModal({ formLevel: 1 })">
|
||||
<PlusOutlined />
|
||||
新增制剂
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
class="process-sidebar__search"
|
||||
placeholder="搜索名称"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="filteredLevel1.length" class="process-sidebar__list">
|
||||
<div
|
||||
v-for="item in filteredLevel1"
|
||||
:key="item.id"
|
||||
class="process-sidebar__item"
|
||||
:class="{ active: selectedLevel1Id === item.id }"
|
||||
@click="onSelectLevel1(item.id)"
|
||||
@contextmenu="onLevel1ContextMenu($event, item)"
|
||||
>
|
||||
<InlineFieldEditor
|
||||
:value="item.name"
|
||||
@click.stop
|
||||
@save="(val) => saveLevel1Name(item, String(val))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else description="暂无制剂" />
|
||||
</Spin>
|
||||
</aside>
|
||||
<div class="flex h-full min-h-0 gap-4">
|
||||
<ConfigTreeSidebar
|
||||
add-label="新增制剂"
|
||||
empty-text="暂无制剂"
|
||||
:filtered-level1="filteredLevel1"
|
||||
:keyword="keyword"
|
||||
:loading="loading"
|
||||
search-placeholder="搜索制剂名称"
|
||||
:selected-level1-id="selectedLevel1Id"
|
||||
title="制剂"
|
||||
:total-count="level1List.length"
|
||||
@add-level1="openCreateModal({ formLevel: 1 })"
|
||||
@contextmenu="onLevel1ContextMenu"
|
||||
@save-name="saveLevel1Name"
|
||||
@select="onSelectLevel1"
|
||||
@update:keyword="(val) => (keyword = val)"
|
||||
/>
|
||||
|
||||
<main class="process-main">
|
||||
<main
|
||||
class="min-w-0 flex-1 rounded-lg border border-border bg-card px-4 py-3 dark:border-gray-700"
|
||||
>
|
||||
<template v-if="selectedLevel1">
|
||||
<div class="process-main__header">
|
||||
<span class="process-main__title">{{ selectedLevel1.name }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
@click="openCreateModal({ formLevel: 2, parentRow: selectedLevel1 })"
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3"
|
||||
>
|
||||
新增煎法
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
v-if="currentMethods.length"
|
||||
:active-key="String(selectedMethodId ?? '')"
|
||||
type="card"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<Tabs.TabPane
|
||||
v-for="method in currentMethods"
|
||||
:key="String(method.id)"
|
||||
>
|
||||
<template #tab>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-medium text-foreground">{{
|
||||
selectedLevel1.name
|
||||
}}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span
|
||||
@contextmenu.prevent="onMethodTabContextMenu($event, method)"
|
||||
>
|
||||
{{ method.name }}
|
||||
</span>
|
||||
</template>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<Empty
|
||||
v-else
|
||||
description="暂无煎法,请右键制剂或点击新增"
|
||||
/>
|
||||
|
||||
<MethodInfoBar
|
||||
:method="selectedMethod"
|
||||
@updated="(patch) => selectedMethod && patchRule(selectedMethod.id, patch)"
|
||||
/>
|
||||
|
||||
<NoteGrid @contextmenu.prevent>
|
||||
<template #name="{ row }">
|
||||
<span @contextmenu="onNoteContextMenu($event, row)">
|
||||
<InlineFieldEditor
|
||||
:value="row.name"
|
||||
@save="(val) => saveNoteField(row, 'name', val)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<template #volume="{ row }">
|
||||
<span @contextmenu="onNoteContextMenu($event, row)">
|
||||
<InlineFieldEditor
|
||||
type="number"
|
||||
:value="row.volume"
|
||||
:display-formatter="
|
||||
(val) =>
|
||||
val !== null && val !== undefined && val !== ''
|
||||
? `${val}ml`
|
||||
: '设置'
|
||||
:class="
|
||||
selectedMethod
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
@save="(val) => saveNoteField(row, 'volume', val)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</NoteGrid>
|
||||
>
|
||||
{{ breadcrumbMethodName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button size="small" @click="openCreateMethod">
|
||||
<PlusOutlined />
|
||||
新增煎法
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!selectedMethod"
|
||||
@click="openCreateNote"
|
||||
>
|
||||
<PlusOutlined />
|
||||
新增备注
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<Tabs
|
||||
v-if="currentMethods.length"
|
||||
:active-key="String(selectedMethodId ?? '')"
|
||||
class="process-method-tabs select-none"
|
||||
type="line"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<Tabs.TabPane
|
||||
v-for="method in currentMethods"
|
||||
:key="String(method.id)"
|
||||
>
|
||||
<template #tab>
|
||||
<span
|
||||
class="cursor-pointer"
|
||||
@contextmenu.prevent="
|
||||
onMethodTabContextMenu($event, method)
|
||||
"
|
||||
>
|
||||
{{ method.name }}
|
||||
</span>
|
||||
</template>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-8 dark:border-gray-700"
|
||||
>
|
||||
<Empty description="暂无煎法" />
|
||||
<Button class="mt-4" type="primary" @click="openCreateMethod">
|
||||
<PlusOutlined />
|
||||
新增煎法
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MethodInfoBar
|
||||
:method="selectedMethod"
|
||||
@updated="
|
||||
(patch) => selectedMethod && patchRule(selectedMethod.id, patch)
|
||||
"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="rounded-lg border border-border dark:border-gray-700"
|
||||
>
|
||||
<ConfigSectionHeader
|
||||
class="mb-3 px-4 pt-3"
|
||||
:subtitle="selectedMethod ? `共 ${currentNotes.length} 条` : undefined"
|
||||
title="备注列表"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!selectedMethod"
|
||||
@click="openCreateNote"
|
||||
>
|
||||
<PlusOutlined />
|
||||
新增备注
|
||||
</Button>
|
||||
</template>
|
||||
</ConfigSectionHeader>
|
||||
|
||||
<div
|
||||
v-if="selectedMethod && currentNotes.length"
|
||||
class="overflow-hidden rounded-b-lg border-t border-border dark:border-gray-700"
|
||||
>
|
||||
<NoteGrid @contextmenu.prevent>
|
||||
<template #name="{ row }">
|
||||
<span @contextmenu="onNoteContextMenu($event, row)">
|
||||
<InlineFieldEditor
|
||||
:value="row.name"
|
||||
@save="(val) => saveNoteField(row, 'name', val)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<template #volume="{ row }">
|
||||
<span @contextmenu="onNoteContextMenu($event, row)">
|
||||
<InlineFieldEditor
|
||||
type="number"
|
||||
:value="row.volume"
|
||||
:display-formatter="
|
||||
(val) =>
|
||||
val !== null && val !== undefined && val !== ''
|
||||
? `${val}ml`
|
||||
: '点击设置'
|
||||
"
|
||||
@save="(val) => saveNoteField(row, 'volume', val)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</NoteGrid>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="selectedMethod"
|
||||
class="flex flex-col items-center justify-center px-4 py-8"
|
||||
>
|
||||
<Empty description="暂无备注" />
|
||||
<Button class="mt-4" type="primary" @click="openCreateNote">
|
||||
<PlusOutlined />
|
||||
新增备注
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="px-4 pb-4">
|
||||
<Empty description="请先选择煎法" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<Empty v-else description="请选择左侧制剂" />
|
||||
</main>
|
||||
@@ -359,69 +444,11 @@ function onSelectLevel1(id: number) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.process-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
.process-method-tabs :deep(.ant-tabs-nav) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.process-sidebar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.process-sidebar__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.process-sidebar__search {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.process-sidebar__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.process-sidebar__item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.process-sidebar__item:hover,
|
||||
.process-sidebar__item.active {
|
||||
background: #f0fbf8;
|
||||
}
|
||||
|
||||
.process-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.process-main__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.process-main__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
.process-method-tabs :deep(.ant-tabs-tab) {
|
||||
transition: color 0.2s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const STORAGE_KEY = 'process-management-selection';
|
||||
|
||||
export interface ProcessSelectionState {
|
||||
level1Id: number | null;
|
||||
methodId: number | null;
|
||||
}
|
||||
|
||||
export function readProcessSelection(): ProcessSelectionState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { level1Id: null, methodId: null };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<ProcessSelectionState>;
|
||||
return {
|
||||
level1Id:
|
||||
parsed.level1Id != null && Number.isFinite(Number(parsed.level1Id))
|
||||
? Number(parsed.level1Id)
|
||||
: null,
|
||||
methodId:
|
||||
parsed.methodId != null && Number.isFinite(Number(parsed.methodId))
|
||||
? Number(parsed.methodId)
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return { level1Id: null, methodId: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProcessSelection(state: ProcessSelectionState) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
level1Id: state.level1Id,
|
||||
methodId: state.methodId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -9,13 +9,44 @@ import { useVbenForm } from '#/adapter/form';
|
||||
import { createRegion, updateRegion } from '#/views/system/region/api';
|
||||
import { modalFormProps } from '#/views/system/region/config/form';
|
||||
|
||||
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const onSuccessRef = ref<(() => void) | null>(null);
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
function buildSubmitPayload(values: Record<string, any>) {
|
||||
const formLevel = Number(values.formLevel);
|
||||
return {
|
||||
id: values.id,
|
||||
name: values.name,
|
||||
pid: formLevel === 1 ? 0 : values.pid,
|
||||
express_fee: values.express_fee ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function validateFormValues(values: Record<string, any>) {
|
||||
const formLevel = Number(values.formLevel);
|
||||
if (!String(values.name ?? '').trim()) {
|
||||
message.error('请输入名称');
|
||||
return false;
|
||||
}
|
||||
if (formLevel === 2 && !values.pid) {
|
||||
message.error('请选择父级地区');
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
values.express_fee === null ||
|
||||
values.express_fee === undefined ||
|
||||
values.express_fee === ''
|
||||
) {
|
||||
message.error('请输入快递费');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const modalTitle = ref('新增快递费');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -23,38 +54,47 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateRegion : createRegion;
|
||||
submitApi(values)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
await formApi.validateAndSubmitForm();
|
||||
const values = await formApi.getValues();
|
||||
if (!validateFormValues(values)) {
|
||||
return;
|
||||
}
|
||||
const payload = buildSubmitPayload(values);
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateRegion : createRegion;
|
||||
submitApi(payload)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
onSuccessRef.value?.();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
const { values, update, formLevel, onSuccess } = modalApi.getData<
|
||||
Record<string, any>
|
||||
>();
|
||||
isUpdate.value = update;
|
||||
onSuccessRef.value = onSuccess ?? null;
|
||||
const levelLabels: Record<number, string> = {
|
||||
1: '地区',
|
||||
2: '子地区',
|
||||
};
|
||||
modalTitle.value = `${update ? '编辑' : '新增'}${levelLabels[formLevel] ?? '快递费'}`;
|
||||
formApi.resetForm();
|
||||
if (values) {
|
||||
isUpdate.value = update;
|
||||
formApi.setValues(values);
|
||||
formApi.setValues({ ...values, formLevel });
|
||||
}
|
||||
} else {
|
||||
onSuccessRef.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}快递费`" class="w-[30%]">
|
||||
<Modal :title="modalTitle" class="w-[32%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Form, FormItem, Select, message } from 'ant-design-vue';
|
||||
|
||||
import { updateRegion } from '#/views/system/region/api';
|
||||
|
||||
import type { RegionRow } from '../constants';
|
||||
|
||||
const moveTarget = ref<RegionRow | null>(null);
|
||||
const targetId = ref<number | undefined>(undefined);
|
||||
const level1List = ref<RegionRow[]>([]);
|
||||
const onSuccess = ref<(() => void) | null>(null);
|
||||
|
||||
const options = computed(() =>
|
||||
level1List.value
|
||||
.filter((item) => item.id !== moveTarget.value?.pid)
|
||||
.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (!moveTarget.value || !targetId.value) {
|
||||
message.error('请选择目标地区');
|
||||
return;
|
||||
}
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
await updateRegion({
|
||||
id: moveTarget.value.id,
|
||||
pid: targetId.value,
|
||||
});
|
||||
message.success('移动成功');
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<{
|
||||
level1List: RegionRow[];
|
||||
onSuccess?: () => void;
|
||||
target: RegionRow;
|
||||
}>();
|
||||
moveTarget.value = data?.target ?? null;
|
||||
level1List.value = data?.level1List ?? [];
|
||||
onSuccess.value = data?.onSuccess ?? null;
|
||||
targetId.value = moveTarget.value?.pid || undefined;
|
||||
} else {
|
||||
moveTarget.value = null;
|
||||
targetId.value = undefined;
|
||||
onSuccess.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="移动子地区到地区" class="w-[400px]">
|
||||
<Form layout="vertical">
|
||||
<FormItem label="目标地区" required>
|
||||
<Select
|
||||
v-model:value="targetId"
|
||||
:options="options"
|
||||
placeholder="请选择"
|
||||
show-search
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option?.label?.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { Descriptions, message } from 'ant-design-vue';
|
||||
|
||||
import { updateRegion } from '#/views/system/region/api';
|
||||
import ConfigSectionHeader from '#/views/system/shared/config-section-header.vue';
|
||||
import InlineFieldEditor from '#/views/system/process/components/inline-field-editor.vue';
|
||||
|
||||
import type { RegionRow } from '../constants';
|
||||
|
||||
const props = defineProps<{
|
||||
region: RegionRow | null | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: [patch: Partial<RegionRow>];
|
||||
}>();
|
||||
|
||||
function formatFee(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '点击设置';
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? `¥${num.toFixed(2)}` : String(value);
|
||||
}
|
||||
|
||||
async function saveField(payload: Record<string, unknown>) {
|
||||
if (!props.region) {
|
||||
return;
|
||||
}
|
||||
await updateRegion({
|
||||
id: props.region.id,
|
||||
...payload,
|
||||
});
|
||||
emit('updated', payload as Partial<RegionRow>);
|
||||
message.success('保存成功');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-lg border border-border bg-muted/30 dark:border-gray-700">
|
||||
<ConfigSectionHeader class="mb-0 px-4 pt-3" title="地区信息" />
|
||||
|
||||
<div v-if="region" class="px-4 pb-3 pt-1">
|
||||
<Descriptions
|
||||
bordered
|
||||
:column="3"
|
||||
size="small"
|
||||
class="region-info-descriptions"
|
||||
>
|
||||
<Descriptions.Item label="级别">地区</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">
|
||||
<InlineFieldEditor
|
||||
:value="region.name"
|
||||
label="名称"
|
||||
placeholder="请输入名称"
|
||||
@save="(val) => saveField({ name: val })"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="地区编码">
|
||||
{{ region.code || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="快递费" :span="2">
|
||||
<InlineFieldEditor
|
||||
type="number"
|
||||
:value="region.express_fee"
|
||||
label="快递费"
|
||||
placeholder="请输入价格"
|
||||
:display-formatter="formatFee"
|
||||
@save="(val) => saveField({ express_fee: val })"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.region-info-descriptions :deep(.ant-descriptions-view) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.region-info-descriptions :deep(.ant-descriptions-item-label) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.region-info-descriptions :deep(.ant-descriptions-item-content) {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { getRegionList } from '#/views/system/region/api';
|
||||
|
||||
import type { RegionRow } from '../constants';
|
||||
import {
|
||||
readRegionSelection,
|
||||
writeRegionSelection,
|
||||
} from '../utils/regionSelectionStorage';
|
||||
|
||||
export function parseRegionListItems(items: Record<string, any>[]) {
|
||||
const level1: RegionRow[] = [];
|
||||
const level2Map: Record<number, RegionRow[]> = {};
|
||||
|
||||
for (const item of items) {
|
||||
const row: RegionRow = {
|
||||
id: Number(item.id),
|
||||
name: String(item.name ?? ''),
|
||||
pid: Number(item.pid ?? 0),
|
||||
code: item.code,
|
||||
level: item.level,
|
||||
express_fee: item.express_fee,
|
||||
created_at: item.created_at,
|
||||
};
|
||||
|
||||
if (Number(row.pid) === 0) {
|
||||
level1.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parentId = Number(row.pid);
|
||||
if (!level2Map[parentId]) {
|
||||
level2Map[parentId] = [];
|
||||
}
|
||||
level2Map[parentId].push(row);
|
||||
}
|
||||
|
||||
level1.sort((a, b) => a.id - b.id);
|
||||
Object.values(level2Map).forEach((list) => list.sort((a, b) => a.id - b.id));
|
||||
|
||||
return { level1, level2Map };
|
||||
}
|
||||
|
||||
function restoreSelection(level1: RegionRow[], savedLevel1Id: number | null) {
|
||||
let level1Id = savedLevel1Id;
|
||||
if (!level1Id || !level1.some((item) => item.id === level1Id)) {
|
||||
level1Id = level1[0]?.id ?? null;
|
||||
}
|
||||
return { level1Id };
|
||||
}
|
||||
|
||||
export function useRegionTree() {
|
||||
const saved = readRegionSelection();
|
||||
const loading = ref(false);
|
||||
const keyword = ref('');
|
||||
const level1List = ref<RegionRow[]>([]);
|
||||
const level2Map = ref<Record<number, RegionRow[]>>({});
|
||||
const selectedLevel1Id = ref<number | null>(saved.level1Id);
|
||||
|
||||
const filteredLevel1 = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase();
|
||||
if (!kw) {
|
||||
return level1List.value;
|
||||
}
|
||||
return level1List.value.filter((item) => {
|
||||
const children = level2Map.value[item.id] ?? [];
|
||||
if (item.name.toLowerCase().includes(kw)) {
|
||||
return true;
|
||||
}
|
||||
return children.some((child) => child.name.toLowerCase().includes(kw));
|
||||
});
|
||||
});
|
||||
|
||||
const currentChildren = computed(() => {
|
||||
if (!selectedLevel1Id.value) {
|
||||
return [];
|
||||
}
|
||||
return level2Map.value[selectedLevel1Id.value] ?? [];
|
||||
});
|
||||
|
||||
const selectedLevel1 = computed(() =>
|
||||
level1List.value.find((item) => item.id === selectedLevel1Id.value),
|
||||
);
|
||||
|
||||
function persistSelection() {
|
||||
writeRegionSelection({
|
||||
level1Id: selectedLevel1Id.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getRegionList({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
const items = res?.items ?? [];
|
||||
const parsed = parseRegionListItems(items);
|
||||
level1List.value = parsed.level1;
|
||||
level2Map.value = parsed.level2Map;
|
||||
|
||||
const restored = restoreSelection(
|
||||
level1List.value,
|
||||
selectedLevel1Id.value,
|
||||
);
|
||||
selectedLevel1Id.value = restored.level1Id;
|
||||
persistSelection();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectLevel1(id: number) {
|
||||
selectedLevel1Id.value = id;
|
||||
persistSelection();
|
||||
}
|
||||
|
||||
function patchRegion(id: number, patch: Partial<RegionRow>) {
|
||||
const inLevel1 = level1List.value.find((item) => item.id === id);
|
||||
if (inLevel1) {
|
||||
Object.assign(inLevel1, patch);
|
||||
return;
|
||||
}
|
||||
for (const list of Object.values(level2Map.value)) {
|
||||
const found = list.find((item) => item.id === id);
|
||||
if (found) {
|
||||
Object.assign(found, patch);
|
||||
if (patch.pid !== undefined && Number(patch.pid) !== Number(found.pid)) {
|
||||
loadData();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentChildren,
|
||||
filteredLevel1,
|
||||
keyword,
|
||||
level1List,
|
||||
level2Map,
|
||||
loadData,
|
||||
loading,
|
||||
patchRegion,
|
||||
selectLevel1,
|
||||
selectedLevel1,
|
||||
selectedLevel1Id,
|
||||
};
|
||||
}
|
||||
47
apps/web-antd/src/views/system/region/config/child-table.ts
Normal file
47
apps/web-antd/src/views/system/region/config/child-table.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { SUB_LIST_GRID_HEIGHT } from '#/views/system/shared/sub-list-grid';
|
||||
|
||||
export const childGridOptions: VxeGridProps = {
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
keyField: 'id',
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'level',
|
||||
title: '级别',
|
||||
width: 80,
|
||||
formatter: () => '子地区',
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '名称',
|
||||
minWidth: 160,
|
||||
slots: { default: 'name' },
|
||||
},
|
||||
{
|
||||
field: 'code',
|
||||
title: '地区编码',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'express_fee',
|
||||
title: '快递费',
|
||||
width: 140,
|
||||
slots: { default: 'express_fee' },
|
||||
},
|
||||
],
|
||||
height: SUB_LIST_GRID_HEIGHT,
|
||||
border: true,
|
||||
showOverflow: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
@@ -1,50 +1,93 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
import {getRegionOptionPidApi} from "#/views/system/region/api";
|
||||
|
||||
import { getRegionOptionPidApi } from '#/views/system/region/api';
|
||||
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12', // 24栅格,
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
// 所有表单项
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
// handleSubmit: onSubmit,
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
// {
|
||||
// fieldName: 'baseinfo',
|
||||
// component: 'Divider',
|
||||
// label: '基础信息',
|
||||
// formItemClass: 'col-span-12',
|
||||
// componentProps: {},
|
||||
// hideLabel: true,
|
||||
// renderComponentContent: () => {
|
||||
// return {
|
||||
// default: () => {
|
||||
// return '基础信息';
|
||||
// },
|
||||
// };
|
||||
// },
|
||||
// },
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
formItemClass: 'col-span-6',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'formLevel',
|
||||
label: '表单层级',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['formLevel'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'level_label',
|
||||
label: '级别',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
dependencies: {
|
||||
show(values) {
|
||||
return Number(values.formLevel) === 2;
|
||||
},
|
||||
trigger(_values, form) {
|
||||
form.setFieldValue('level_label', '子地区');
|
||||
},
|
||||
triggerFields: ['formLevel'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入名称',
|
||||
},
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
placeholder: '请选择父级地区',
|
||||
api: getRegionOptionPidApi,
|
||||
filterOption: (input: string, option: any) =>
|
||||
option?.label.toLowerCase().indexOf(input.toLowerCase()) >= 0,
|
||||
showSearch: true,
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
fieldName: 'pid',
|
||||
label: '父级地区',
|
||||
dependencies: {
|
||||
show(values) {
|
||||
return Number(values.formLevel) === 2;
|
||||
},
|
||||
triggerFields: ['formLevel'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入价格',
|
||||
class: 'w-full',
|
||||
min: 0,
|
||||
},
|
||||
fieldName: 'express_fee',
|
||||
label: '价格',
|
||||
label: '快递费',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
|
||||
9
apps/web-antd/src/views/system/region/constants.ts
Normal file
9
apps/web-antd/src/views/system/region/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface RegionRow {
|
||||
id: number;
|
||||
name: string;
|
||||
pid: number;
|
||||
code?: string;
|
||||
level?: number;
|
||||
express_fee?: number | string;
|
||||
created_at?: number | string;
|
||||
}
|
||||
@@ -1,140 +1,301 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message } from 'ant-design-vue';
|
||||
import { Button, Empty, message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import type { ContextMenuItem } from '#/components/context-menu';
|
||||
import { showContextMenu } from '#/components/context-menu';
|
||||
import ConfigSectionHeader from '#/views/system/shared/config-section-header.vue';
|
||||
import ConfigTreeSidebar from '#/views/system/shared/config-tree-sidebar.vue';
|
||||
import type { TreeSidebarRow } from '#/views/system/shared/types';
|
||||
import InlineFieldEditor from '#/views/system/process/components/inline-field-editor.vue';
|
||||
|
||||
import { deleteRegion } from './api';
|
||||
import { deleteRegion, updateRegion } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import MovePidModal from './components/move-pid-modal.vue';
|
||||
import RegionInfoBar from './components/region-info-bar.vue';
|
||||
import { useRegionTree } from './composables/useRegionTree';
|
||||
import type { RegionRow } from './constants';
|
||||
import { childGridOptions } from './config/child-table';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
const {
|
||||
currentChildren,
|
||||
filteredLevel1,
|
||||
keyword,
|
||||
level1List,
|
||||
loadData,
|
||||
loading,
|
||||
patchRegion,
|
||||
selectLevel1,
|
||||
selectedLevel1,
|
||||
selectedLevel1Id,
|
||||
} = useRegionTree();
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
const [MoveModal, moveModalApi] = useVbenModal({
|
||||
connectedComponent: MovePidModal,
|
||||
});
|
||||
|
||||
const [ChildGrid, childGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
...childGridOptions,
|
||||
data: [],
|
||||
},
|
||||
});
|
||||
|
||||
function syncChildGrid() {
|
||||
childGridApi.setGridOptions({ data: currentChildren.value });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
syncChildGrid();
|
||||
});
|
||||
|
||||
watch(currentChildren, syncChildGrid, { deep: true });
|
||||
watch(selectedLevel1Id, syncChildGrid);
|
||||
|
||||
function refresh() {
|
||||
return loadData().then(syncChildGrid);
|
||||
}
|
||||
|
||||
function formatFee(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '点击设置';
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? `¥${num.toFixed(2)}` : String(value);
|
||||
}
|
||||
|
||||
function openCreateModal(options: {
|
||||
formLevel: number;
|
||||
isUpdate?: boolean;
|
||||
parentRow?: RegionRow;
|
||||
values?: Record<string, any>;
|
||||
}) {
|
||||
const { formLevel, isUpdate = false, parentRow, values = {} } = options;
|
||||
let formValues: Record<string, any> = { ...values, formLevel };
|
||||
|
||||
if (!isUpdate) {
|
||||
if (formLevel === 1) {
|
||||
formValues = { pid: 0, formLevel: 1, express_fee: 0 };
|
||||
} else if (formLevel === 2 && parentRow) {
|
||||
formValues = {
|
||||
pid: parentRow.id,
|
||||
formLevel: 2,
|
||||
express_fee: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: data,
|
||||
values: formValues,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
formLevel,
|
||||
onSuccess: refresh,
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
}
|
||||
|
||||
const deleteApi = (row: any) => {
|
||||
let ids = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||
}
|
||||
deleteRegion({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.query();
|
||||
function openMoveModal(row: RegionRow) {
|
||||
moveModalApi.setData({
|
||||
target: row,
|
||||
level1List: level1List.value,
|
||||
onSuccess: refresh,
|
||||
});
|
||||
};
|
||||
const expandAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(true);
|
||||
};
|
||||
moveModalApi.open();
|
||||
}
|
||||
|
||||
const collapseAll = () => {
|
||||
gridApi.grid?.setAllTreeExpand(false);
|
||||
};
|
||||
function buildLevel1Menus(row: RegionRow): ContextMenuItem[] {
|
||||
return [
|
||||
{
|
||||
key: 'add-child',
|
||||
label: '新增子地区',
|
||||
handler: () => openCreateModal({ formLevel: 2, parentRow: row }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildChildMenus(row: RegionRow): ContextMenuItem[] {
|
||||
return [
|
||||
{
|
||||
key: 'move-pid',
|
||||
label: '移动父级',
|
||||
handler: () => openMoveModal(row),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
handler: async () => {
|
||||
await deleteRegion({ ids: [row.id] });
|
||||
message.success('删除成功');
|
||||
await refresh();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function onLevel1ContextMenu(e: MouseEvent, row: TreeSidebarRow) {
|
||||
showContextMenu(e, buildLevel1Menus(row as RegionRow), row);
|
||||
}
|
||||
|
||||
function onChildContextMenu(e: MouseEvent, row: RegionRow) {
|
||||
showContextMenu(e, buildChildMenus(row), row);
|
||||
}
|
||||
|
||||
async function saveLevel1Name(row: TreeSidebarRow, name: string) {
|
||||
const region = row as RegionRow;
|
||||
await updateRegion({ id: region.id, name });
|
||||
patchRegion(region.id, { name });
|
||||
message.success('保存成功');
|
||||
}
|
||||
|
||||
async function saveChildField(
|
||||
row: RegionRow,
|
||||
field: 'express_fee' | 'name',
|
||||
value: number | string,
|
||||
) {
|
||||
const payload: Record<string, unknown> = { id: row.id };
|
||||
if (field === 'name') {
|
||||
payload.name = value;
|
||||
} else {
|
||||
payload.express_fee = value;
|
||||
}
|
||||
await updateRegion(payload);
|
||||
patchRegion(
|
||||
row.id,
|
||||
field === 'name' ? { name: String(value) } : { express_fee: value },
|
||||
);
|
||||
message.success('保存成功');
|
||||
}
|
||||
|
||||
function onSelectLevel1(id: number) {
|
||||
selectLevel1(id);
|
||||
syncChildGrid();
|
||||
}
|
||||
|
||||
function openCreateChild() {
|
||||
if (!selectedLevel1.value) {
|
||||
return;
|
||||
}
|
||||
openCreateModal({ formLevel: 2, parentRow: selectedLevel1.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="快递费管理">
|
||||
<Page
|
||||
auto-content-height
|
||||
description="右键地区或子地区行可新增/移动;点击虚线下划线字段可编辑"
|
||||
title="快递费管理"
|
||||
>
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '展开全部',
|
||||
type: 'primary',
|
||||
// auth: ['超级菜单', 'sys:user:save'],
|
||||
onClick: expandAll.bind(null),
|
||||
},
|
||||
{
|
||||
label: '收起全部',
|
||||
type: 'primary',
|
||||
// auth: ['超级菜单', 'sys:user:save'],
|
||||
onClick: collapseAll.bind(null),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
]"
|
||||
>
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
<Icon icon="ant-design:down-outlined" />
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #logo="{ row }">
|
||||
<Image :src="row.logo" height="30" width="30" />
|
||||
</template>
|
||||
<template #open_business_license="{ row }">
|
||||
<Image :src="row.open_business_license" height="30" width="30" />
|
||||
</template>
|
||||
<template #business_license="{ row }">
|
||||
<Image :src="row.business_license" height="30" width="30" />
|
||||
</template>
|
||||
<template #product_registration_certificate="{ row }">
|
||||
<Image
|
||||
:src="row.product_registration_certificate"
|
||||
height="30"
|
||||
width="30"
|
||||
/>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
// auth: ['region', 'sys:role:detail'],
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
<MoveModal />
|
||||
|
||||
<div class="flex h-full min-h-0 gap-4">
|
||||
<ConfigTreeSidebar
|
||||
add-label="新增地区"
|
||||
empty-text="暂无地区"
|
||||
:filtered-level1="filteredLevel1"
|
||||
:keyword="keyword"
|
||||
:loading="loading"
|
||||
search-placeholder="搜索地区名称"
|
||||
:selected-level1-id="selectedLevel1Id"
|
||||
title="地区"
|
||||
:total-count="level1List.length"
|
||||
@add-level1="openCreateModal({ formLevel: 1 })"
|
||||
@contextmenu="onLevel1ContextMenu"
|
||||
@save-name="saveLevel1Name"
|
||||
@select="onSelectLevel1"
|
||||
@update:keyword="(val) => (keyword = val)"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="min-w-0 flex-1 rounded-lg border border-border bg-card px-4 py-3 dark:border-gray-700"
|
||||
>
|
||||
<template v-if="selectedLevel1">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3"
|
||||
>
|
||||
<span class="text-sm font-medium text-foreground">{{
|
||||
selectedLevel1.name
|
||||
}}</span>
|
||||
<Button size="small" @click="openCreateChild">
|
||||
<PlusOutlined />
|
||||
新增子地区
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RegionInfoBar
|
||||
:region="selectedLevel1"
|
||||
@updated="
|
||||
(patch) => patchRegion(selectedLevel1!.id, patch)
|
||||
"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="rounded-lg border border-border dark:border-gray-700"
|
||||
>
|
||||
<ConfigSectionHeader
|
||||
class="mb-3 px-4 pt-3"
|
||||
:subtitle="`共 ${currentChildren.length} 条`"
|
||||
title="子地区列表"
|
||||
>
|
||||
<template #actions>
|
||||
<Button size="small" type="primary" @click="openCreateChild">
|
||||
<PlusOutlined />
|
||||
新增子地区
|
||||
</Button>
|
||||
</template>
|
||||
</ConfigSectionHeader>
|
||||
|
||||
<div
|
||||
v-if="currentChildren.length"
|
||||
class="overflow-hidden rounded-b-lg border-t border-border dark:border-gray-700"
|
||||
>
|
||||
<ChildGrid @contextmenu.prevent>
|
||||
<template #name="{ row }">
|
||||
<span @contextmenu="onChildContextMenu($event, row)">
|
||||
<InlineFieldEditor
|
||||
:value="row.name"
|
||||
@save="(val) => saveChildField(row, 'name', val)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<template #express_fee="{ row }">
|
||||
<span @contextmenu="onChildContextMenu($event, row)">
|
||||
<InlineFieldEditor
|
||||
type="number"
|
||||
:value="row.express_fee"
|
||||
:display-formatter="formatFee"
|
||||
@save="(val) => saveChildField(row, 'express_fee', val)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</ChildGrid>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center px-4 py-8"
|
||||
>
|
||||
<Empty description="暂无子地区" />
|
||||
<Button class="mt-4" type="primary" @click="openCreateChild">
|
||||
<PlusOutlined />
|
||||
新增子地区
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<Empty v-else description="请选择左侧地区" />
|
||||
</main>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const STORAGE_KEY = 'region-management-selection';
|
||||
|
||||
export interface RegionSelectionState {
|
||||
level1Id: number | null;
|
||||
}
|
||||
|
||||
export function readRegionSelection(): RegionSelectionState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { level1Id: null };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<RegionSelectionState & { childId?: number }>;
|
||||
return {
|
||||
level1Id:
|
||||
parsed.level1Id != null && Number.isFinite(Number(parsed.level1Id))
|
||||
? Number(parsed.level1Id)
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return { level1Id: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeRegionSelection(state: RegionSelectionState) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
level1Id: state.level1Id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
subtitle?: string;
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-foreground">{{ title }}</div>
|
||||
<div v-if="subtitle" class="mt-0.5 text-xs text-muted-foreground">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
100
apps/web-antd/src/views/system/shared/config-tree-sidebar.vue
Normal file
100
apps/web-antd/src/views/system/shared/config-tree-sidebar.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts" setup>
|
||||
import { Badge, Button, Empty, Input, Spin } from 'ant-design-vue';
|
||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import InlineFieldEditor from '#/views/system/process/components/inline-field-editor.vue';
|
||||
|
||||
import type { TreeSidebarRow } from './types';
|
||||
|
||||
export type { TreeSidebarRow };
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
addLabel: string;
|
||||
emptyText: string;
|
||||
filteredLevel1: TreeSidebarRow[];
|
||||
keyword: string;
|
||||
loading: boolean;
|
||||
searchPlaceholder: string;
|
||||
selectedLevel1Id: number | null;
|
||||
title: string;
|
||||
totalCount: number;
|
||||
}>(),
|
||||
{
|
||||
addLabel: '新增',
|
||||
emptyText: '暂无数据',
|
||||
searchPlaceholder: '搜索名称',
|
||||
title: '分类',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
addLevel1: [];
|
||||
contextmenu: [event: MouseEvent, row: TreeSidebarRow];
|
||||
saveName: [row: TreeSidebarRow, name: string];
|
||||
select: [id: number];
|
||||
'update:keyword': [value: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="flex w-[240px] shrink-0 flex-col rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-foreground">{{ title }}</span>
|
||||
<Badge
|
||||
:count="totalCount"
|
||||
:number-style="{ backgroundColor: 'hsl(var(--primary))' }"
|
||||
:overflow-count="999"
|
||||
show-zero
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button block class="mb-3" type="primary" @click="emit('addLevel1')">
|
||||
<PlusOutlined />
|
||||
{{ addLabel }}
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
:value="keyword"
|
||||
allow-clear
|
||||
class="mb-3"
|
||||
:placeholder="searchPlaceholder"
|
||||
@update:value="(val) => emit('update:keyword', val ?? '')"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined class="text-muted-foreground" />
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<div
|
||||
v-if="filteredLevel1.length"
|
||||
class="flex max-h-[calc(100vh-300px)] flex-col gap-1 overflow-auto"
|
||||
>
|
||||
<div
|
||||
v-for="item in filteredLevel1"
|
||||
:key="item.id"
|
||||
class="relative cursor-pointer rounded-md py-2.5 pl-3 pr-2 transition-colors duration-200 hover:bg-muted/50"
|
||||
:class="{
|
||||
'bg-primary/10 ring-1 ring-primary/20': selectedLevel1Id === item.id,
|
||||
}"
|
||||
@click="emit('select', item.id)"
|
||||
@contextmenu="emit('contextmenu', $event, item)"
|
||||
>
|
||||
<span
|
||||
v-if="selectedLevel1Id === item.id"
|
||||
class="absolute bottom-2 left-0 top-2 w-[3px] rounded-full bg-primary"
|
||||
/>
|
||||
<InlineFieldEditor
|
||||
:value="item.name"
|
||||
@click.stop
|
||||
@save="(val) => emit('saveName', item, String(val))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else :description="emptyText" />
|
||||
</Spin>
|
||||
</aside>
|
||||
</template>
|
||||
1
apps/web-antd/src/views/system/shared/sub-list-grid.ts
Normal file
1
apps/web-antd/src/views/system/shared/sub-list-grid.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SUB_LIST_GRID_HEIGHT = 400;
|
||||
4
apps/web-antd/src/views/system/shared/types.ts
Normal file
4
apps/web-antd/src/views/system/shared/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface TreeSidebarRow {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
@@ -42,6 +42,17 @@ const showImportAlert = computed(
|
||||
() => mode.value === 'report' && importReportId.value > 0,
|
||||
);
|
||||
|
||||
const isRejectedResubmit = computed(() => {
|
||||
const status = Number(reportInfo.value?.report_status ?? 0);
|
||||
return Boolean(reportInfo.value?.id) && [3, 4].includes(status);
|
||||
});
|
||||
|
||||
const showPendingSyncHint = computed(
|
||||
() =>
|
||||
Boolean(reportInfo.value?.id) &&
|
||||
Number(reportInfo.value?.report_status) === 1,
|
||||
);
|
||||
|
||||
const [Form, formApi] = useVbenForm(bankCardReportFormProps);
|
||||
|
||||
async function loadSelectableCards() {
|
||||
@@ -95,7 +106,18 @@ const [Modal, modalApi] = useVbenModal({
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
if (mode.value === 'report') {
|
||||
const shouldUpdate =
|
||||
mode.value === 'update' || isRejectedResubmit.value;
|
||||
if (shouldUpdate) {
|
||||
await updateStoreBankCard({
|
||||
...values,
|
||||
id: values.id,
|
||||
use_type: USE_TYPE_TRANSFER,
|
||||
});
|
||||
message.success(
|
||||
isRejectedResubmit.value ? '银行卡报备已重新提交' : '银行卡修改已提交',
|
||||
);
|
||||
} else {
|
||||
await reportStoreBankCard({
|
||||
...values,
|
||||
store_id: values.store_id,
|
||||
@@ -105,13 +127,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
message.success(
|
||||
importReportId.value > 0 ? '银行卡报备已导入' : '银行卡报备已提交',
|
||||
);
|
||||
} else {
|
||||
await updateStoreBankCard({
|
||||
...values,
|
||||
id: values.id,
|
||||
use_type: USE_TYPE_TRANSFER,
|
||||
});
|
||||
message.success('银行卡修改已提交');
|
||||
}
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
@@ -134,6 +149,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
reportInfo.value = data?.report ?? null;
|
||||
importReportId.value = 0;
|
||||
|
||||
const rejectedResubmit = [3, 4].includes(
|
||||
Number(reportInfo.value?.report_status ?? 0),
|
||||
);
|
||||
|
||||
formApi.resetForm();
|
||||
formApi.setValues({
|
||||
store_id: storeId.value,
|
||||
@@ -141,10 +160,16 @@ const [Modal, modalApi] = useVbenModal({
|
||||
use_type: USE_TYPE_TRANSFER,
|
||||
use_type_display: '转账卡(useType=2)',
|
||||
account_type: reportInfo.value?.account_type ?? '2',
|
||||
report_status: reportInfo.value?.report_status ?? 0,
|
||||
...(reportInfo.value ?? {}),
|
||||
id: reportInfo.value?.id,
|
||||
attachment: rejectedResubmit ? '' : (reportInfo.value?.attachment ?? ''),
|
||||
});
|
||||
loadSelectableCards();
|
||||
|
||||
if (reportInfo.value?.id && Number(reportInfo.value?.report_status) === 1) {
|
||||
refreshStatus({ silent: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -153,7 +178,7 @@ const modalTitle = computed(() => {
|
||||
return storeName.value ? `${prefix} - ${storeName.value}` : prefix;
|
||||
});
|
||||
|
||||
async function refreshStatus() {
|
||||
async function refreshStatus(options: { silent?: boolean } = {}) {
|
||||
if (!reportInfo.value?.id) {
|
||||
return;
|
||||
}
|
||||
@@ -161,14 +186,19 @@ async function refreshStatus() {
|
||||
try {
|
||||
const res = await queryStoreBankCardStatus({ id: reportInfo.value.id });
|
||||
reportInfo.value = res;
|
||||
const rejectedResubmit = [3, 4].includes(Number(res.report_status ?? 0));
|
||||
formApi.setValues({
|
||||
...res,
|
||||
store_id: res.store_id,
|
||||
use_type: USE_TYPE_TRANSFER,
|
||||
use_type_display: '转账卡(useType=2)',
|
||||
report_status: res.report_status ?? 0,
|
||||
only_remark: res.can_full_update === false ? 1 : 0,
|
||||
attachment: rejectedResubmit ? '' : (res.attachment ?? ''),
|
||||
});
|
||||
message.success('状态已刷新');
|
||||
if (!options.silent) {
|
||||
message.success('状态已刷新');
|
||||
}
|
||||
gridApi.value?.reload();
|
||||
} finally {
|
||||
modalApi.setState({ loading: false });
|
||||
@@ -192,6 +222,20 @@ async function refreshStatus() {
|
||||
show-icon
|
||||
type="warning"
|
||||
/>
|
||||
<Alert
|
||||
v-if="isRejectedResubmit"
|
||||
message="审核未通过,请修改信息后重新上传合同附件并提交"
|
||||
class="mb-4"
|
||||
show-icon
|
||||
type="error"
|
||||
/>
|
||||
<Alert
|
||||
v-if="showPendingSyncHint"
|
||||
message="当前为待审核状态,如审核结果已久未更新,请先点击下方「刷新报备状态」"
|
||||
class="mb-4"
|
||||
show-icon
|
||||
type="warning"
|
||||
/>
|
||||
<Alert
|
||||
v-if="showImportAlert"
|
||||
message="已从历史报备导入,不会重复提交易票联,请上传本门店合同附件"
|
||||
|
||||
@@ -33,6 +33,12 @@ export const bankCardReportFormProps: VbenFormProps = {
|
||||
label: '仅备注',
|
||||
dependencies: { show: false, triggerFields: ['only_remark'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'report_status',
|
||||
label: '报备状态',
|
||||
dependencies: { show: false, triggerFields: ['report_status'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'use_type',
|
||||
@@ -136,8 +142,16 @@ export const bankCardReportFormProps: VbenFormProps = {
|
||||
label: '合同附件',
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
show: (values) => !values.only_remark && !values.id,
|
||||
triggerFields: ['only_remark', 'id'],
|
||||
show: (values) => {
|
||||
if (values.only_remark) {
|
||||
return false;
|
||||
}
|
||||
if (!values.id) {
|
||||
return true;
|
||||
}
|
||||
return [3, 4].includes(Number(values.report_status ?? 0));
|
||||
},
|
||||
triggerFields: ['only_remark', 'id', 'report_status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -121,10 +121,13 @@ const openBankCardReport = async (row: any, update = false) => {
|
||||
} catch {
|
||||
report = null;
|
||||
}
|
||||
const reportStatus = Number(report?.report_status ?? 0);
|
||||
const useUpdate =
|
||||
(update || [3, 4].includes(reportStatus)) && Boolean(report?.id);
|
||||
bankCardReportModalApi.setData({
|
||||
storeId: row.id,
|
||||
storeName: row.name,
|
||||
mode: update && report?.id ? 'update' : 'report',
|
||||
mode: useUpdate ? 'update' : 'report',
|
||||
report,
|
||||
gridApi,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user