feat: 地区选择器改走接口、订单市价重算与资金流水、同步日志内容复用
region-address-picker 接 region-picker API;订单支持按进价重算 market_price、追踪页资金流水;MES/ERP/中台同步日志共用内容查看组件;地区管理支持 pid 下钻与拼音检索。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/desktop",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)",
|
||||
"author": "萧康云医",
|
||||
|
||||
64
apps/web-antd/src/api/region-picker.ts
Normal file
64
apps/web-antd/src/api/region-picker.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'region-picker/';
|
||||
|
||||
/** 子节点项(children 接口) */
|
||||
export interface RegionPickerChild {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
pid: number;
|
||||
level: number;
|
||||
pinyin_first: string;
|
||||
pinyin_full: string;
|
||||
deleted_at: number;
|
||||
has_children: boolean;
|
||||
}
|
||||
|
||||
/** 搜索命中项 */
|
||||
export interface RegionPickerSearchHit extends RegionPickerChild {
|
||||
path: { id: number; name: string; level: number; pid: number }[];
|
||||
path_label: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** 按省+叶解析 */
|
||||
export interface RegionPickerResolvedPair {
|
||||
province_id: number;
|
||||
city_id: number;
|
||||
province?: RegionPickerChild | null;
|
||||
city?: { id: number; name: string; level: number; pid: number } | null;
|
||||
district?: { id: number; name: string; level: number; pid: number } | null;
|
||||
path_label: string;
|
||||
}
|
||||
|
||||
/** 懒加载子级 xk-api GET /region-picker/children */
|
||||
export async function getRegionChildren(pid = 0) {
|
||||
return requestClient.get<RegionPickerChild[]>(`${prefix}children`, {
|
||||
params: { pid },
|
||||
});
|
||||
}
|
||||
|
||||
/** 合一搜索 xk-api GET /region-picker/search */
|
||||
export async function searchRegions(keyword: string, limit = 30) {
|
||||
return requestClient.get<RegionPickerSearchHit[]>(`${prefix}search`, {
|
||||
params: { keyword, limit },
|
||||
});
|
||||
}
|
||||
|
||||
/** 按省+叶回显 xk-api GET /region-picker/resolve */
|
||||
export async function resolveRegionPair(provinceId: number, cityId: number) {
|
||||
return requestClient.get<RegionPickerResolvedPair>(`${prefix}resolve`, {
|
||||
params: { province_id: provinceId, city_id: cityId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量 ID 解析 */
|
||||
export async function resolveRegionIds(ids: number[]) {
|
||||
return requestClient.get<{ items: RegionPickerSearchHit[] }>(
|
||||
`${prefix}resolve`,
|
||||
{
|
||||
params: { ids: ids.join(',') },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 省市区选择器:直连 region-picker API(懒加载 + 地名/首拼/全拼搜索)
|
||||
* 对外仍输出 [provinceId, leafId],兼容门店/管理员 address 字段
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Input, Popover } from 'ant-design-vue';
|
||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||
import { Input, Popover, Spin } from 'ant-design-vue';
|
||||
|
||||
import type {
|
||||
AddressListRow,
|
||||
@@ -18,13 +22,13 @@ import {
|
||||
buildAddressOutput,
|
||||
buildBreadcrumbFromContext,
|
||||
canCompleteAfterSelect,
|
||||
ensureResolvedAddress,
|
||||
fetchLevelOptions,
|
||||
formatAddressDisplay,
|
||||
getInitialPickerLevel,
|
||||
getLevelSearchPlaceholder,
|
||||
getNextLevel,
|
||||
isMunicipalityProvince,
|
||||
levelOptionsToRows,
|
||||
resolveAddressValue,
|
||||
rowHasNextLevel,
|
||||
searchAddressHits,
|
||||
searchHitsToRows,
|
||||
@@ -50,12 +54,19 @@ const mValue = useVModel(props, 'value', emits, {
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const currentLevel = ref<RegionLevel>('province');
|
||||
const activeIndex = ref(0);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
const triggerWrapRef = ref<HTMLElement | null>(null);
|
||||
const listRows = ref<AddressListRow[]>([]);
|
||||
const crumbLabels = ref<{
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
}>({});
|
||||
|
||||
const ctx = ref<SelectionContext>({
|
||||
provinceId: undefined,
|
||||
@@ -65,38 +76,68 @@ const ctx = ref<SelectionContext>({
|
||||
|
||||
const displayText = computed(() => formatAddressDisplay(mValue.value));
|
||||
|
||||
const breadcrumbItems = computed(() => buildBreadcrumbFromContext(ctx.value));
|
||||
const breadcrumbItems = computed(() =>
|
||||
buildBreadcrumbFromContext(ctx.value, crumbLabels.value),
|
||||
);
|
||||
|
||||
const isSearching = computed(() => !!searchKeyword.value.trim());
|
||||
|
||||
const listRows = computed((): AddressListRow[] => {
|
||||
if (isSearching.value) {
|
||||
return searchHitsToRows(
|
||||
searchAddressHits(searchKeyword.value, ctx.value),
|
||||
);
|
||||
}
|
||||
return levelOptionsToRows(currentLevel.value, ctx.value);
|
||||
});
|
||||
|
||||
const searchPlaceholder = computed(() =>
|
||||
getLevelSearchPlaceholder(currentLevel.value),
|
||||
);
|
||||
|
||||
function syncContextFromValue(val?: AddressValue | null) {
|
||||
const resolved = resolveAddressValue(val ?? undefined);
|
||||
async function reloadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (isSearching.value) {
|
||||
const hits = await searchAddressHits(searchKeyword.value);
|
||||
listRows.value = searchHitsToRows(hits);
|
||||
} else {
|
||||
const options = await fetchLevelOptions(currentLevel.value, ctx.value);
|
||||
listRows.value = levelOptionsToRows(currentLevel.value, options);
|
||||
// 用当前层选项补面包屑中文名
|
||||
if (currentLevel.value === 'province') {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
resetActiveIndex();
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedReload = useDebounceFn(() => {
|
||||
void reloadList();
|
||||
}, 250);
|
||||
|
||||
async function syncContextFromValue(val?: AddressValue | null) {
|
||||
const resolved = await ensureResolvedAddress(val ?? undefined);
|
||||
ctx.value = {
|
||||
provinceId: resolved.provinceId,
|
||||
cityId: resolved.cityId,
|
||||
districtId: resolved.districtId,
|
||||
};
|
||||
crumbLabels.value = {
|
||||
province: resolved.province?.label,
|
||||
city: resolved.city?.label,
|
||||
district: resolved.district?.label,
|
||||
};
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resetPickerToValue(val?: AddressValue | null) {
|
||||
syncContextFromValue(val);
|
||||
const resolved = resolveAddressValue(val ?? undefined);
|
||||
async function resetPickerToValue(val?: AddressValue | null) {
|
||||
const resolved = await syncContextFromValue(val);
|
||||
currentLevel.value = getInitialPickerLevel(resolved);
|
||||
// 有完整值时停在末级的「同级列表」便于改选:有区则列区,有市无区则列市
|
||||
if (resolved.districtId && resolved.cityId) {
|
||||
currentLevel.value = 'district';
|
||||
} else if (resolved.cityId && resolved.provinceId) {
|
||||
currentLevel.value = resolved.district ? 'district' : 'city';
|
||||
} else {
|
||||
currentLevel.value = 'province';
|
||||
}
|
||||
searchKeyword.value = '';
|
||||
activeIndex.value = 0;
|
||||
await reloadList();
|
||||
}
|
||||
|
||||
function resetActiveIndex() {
|
||||
@@ -106,6 +147,7 @@ function resetActiveIndex() {
|
||||
function closePanel(output?: AddressValue) {
|
||||
if (output !== undefined) {
|
||||
mValue.value = output;
|
||||
void ensureResolvedAddress(output);
|
||||
}
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
@@ -128,17 +170,27 @@ function applySelectionAtLevel(level: RegionLevel, item: AddressNode) {
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
crumbLabels.value = { province: item.label };
|
||||
} else if (level === 'city') {
|
||||
ctx.value = {
|
||||
...ctx.value,
|
||||
cityId: item.value,
|
||||
districtId: undefined,
|
||||
};
|
||||
crumbLabels.value = {
|
||||
...crumbLabels.value,
|
||||
city: item.label,
|
||||
district: undefined,
|
||||
};
|
||||
} else {
|
||||
ctx.value = {
|
||||
...ctx.value,
|
||||
districtId: item.value,
|
||||
};
|
||||
crumbLabels.value = {
|
||||
...crumbLabels.value,
|
||||
district: item.label,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +203,7 @@ function searchHitFromRow(row: AddressListRow): AddressSearchHit {
|
||||
};
|
||||
}
|
||||
|
||||
function advanceAfterRowSelect(row: AddressListRow) {
|
||||
async function advanceAfterRowSelect(row: AddressListRow) {
|
||||
searchKeyword.value = '';
|
||||
|
||||
if (row.complete) {
|
||||
@@ -159,53 +211,45 @@ function advanceAfterRowSelect(row: AddressListRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = getNextLevel(row.level, ctx.value);
|
||||
const next = getNextLevel(row.level, ctx.value, row.node.hasChildren);
|
||||
if (next) {
|
||||
currentLevel.value = next;
|
||||
await reloadList();
|
||||
return;
|
||||
}
|
||||
|
||||
completeSelection();
|
||||
}
|
||||
|
||||
function onConfirmRow(row: AddressListRow) {
|
||||
async function onConfirmRow(row: AddressListRow) {
|
||||
if (isSearching.value) {
|
||||
const hit = searchHitFromRow(row);
|
||||
if (
|
||||
hit.level === 'province' &&
|
||||
(ctx.value.cityId || ctx.value.districtId)
|
||||
) {
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
currentLevel.value = isMunicipalityProvince(hit.node.value)
|
||||
? 'district'
|
||||
: 'city';
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
return;
|
||||
}
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
advanceAfterRowSelect(row);
|
||||
// 搜索结果带 pathLabel,尽量从 path 还原;无 path 时用 applySearchHit 兜底
|
||||
const fullHits = await searchAddressHits(searchKeyword.value);
|
||||
const matched =
|
||||
fullHits.find(
|
||||
(h) => h.level === row.level && h.node.value === row.node.value,
|
||||
) ?? hit;
|
||||
ctx.value = applySearchHit(matched, ctx.value);
|
||||
// 从 pathLabel 拆面包屑文案
|
||||
const parts = (matched.pathLabel || '').split('/').filter(Boolean);
|
||||
crumbLabels.value = {
|
||||
province: parts[0],
|
||||
city: parts[1],
|
||||
district: parts[2],
|
||||
};
|
||||
await advanceAfterRowSelect({
|
||||
...row,
|
||||
complete: matched.complete,
|
||||
node: { ...row.node, hasChildren: !matched.complete },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectItem(row.node, row.level);
|
||||
await onSelectItem(row.node, row.level);
|
||||
}
|
||||
|
||||
function onSelectItem(item: AddressNode, level = currentLevel.value) {
|
||||
if (level === 'province' && (ctx.value.cityId || ctx.value.districtId)) {
|
||||
ctx.value = {
|
||||
provinceId: item.value,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
searchKeyword.value = '';
|
||||
currentLevel.value = isMunicipalityProvince(item.value)
|
||||
? 'district'
|
||||
: 'city';
|
||||
resetActiveIndex();
|
||||
return;
|
||||
}
|
||||
|
||||
async function onSelectItem(item: AddressNode, level = currentLevel.value) {
|
||||
applySelectionAtLevel(level, item);
|
||||
const row: AddressListRow = {
|
||||
level,
|
||||
@@ -213,7 +257,7 @@ function onSelectItem(item: AddressNode, level = currentLevel.value) {
|
||||
label: item.label,
|
||||
complete: canCompleteAfterSelect(level, ctx.value, item),
|
||||
};
|
||||
advanceAfterRowSelect(row);
|
||||
await advanceAfterRowSelect(row);
|
||||
}
|
||||
|
||||
function confirmActiveItem() {
|
||||
@@ -221,61 +265,45 @@ function confirmActiveItem() {
|
||||
if (activeIndex.value < 0 || activeIndex.value >= rows.length) {
|
||||
return;
|
||||
}
|
||||
onConfirmRow(rows[activeIndex.value]!);
|
||||
void onConfirmRow(rows[activeIndex.value]!);
|
||||
}
|
||||
|
||||
function drillActiveRow() {
|
||||
async function drillActiveRow() {
|
||||
const rows = listRows.value;
|
||||
const row = rows[activeIndex.value];
|
||||
if (!row || !rowHasNextLevel(row, ctx.value)) {
|
||||
if (!row || !rowHasNextLevel(row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSearching.value) {
|
||||
const hit = searchHitFromRow(row);
|
||||
if (
|
||||
hit.level === 'province' &&
|
||||
(ctx.value.cityId || ctx.value.districtId)
|
||||
) {
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
} else {
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
}
|
||||
searchKeyword.value = '';
|
||||
} else {
|
||||
applySelectionAtLevel(row.level, row.node);
|
||||
}
|
||||
|
||||
const next = getNextLevel(row.level, ctx.value);
|
||||
applySelectionAtLevel(row.level, row.node);
|
||||
searchKeyword.value = '';
|
||||
const next = getNextLevel(row.level, ctx.value, row.node.hasChildren);
|
||||
if (next) {
|
||||
currentLevel.value = next;
|
||||
resetActiveIndex();
|
||||
await reloadList();
|
||||
}
|
||||
}
|
||||
|
||||
function goBackOneLevel() {
|
||||
async function goBackOneLevel() {
|
||||
if (currentLevel.value === 'province') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentLevel.value === 'district') {
|
||||
if (isMunicipalityProvince(ctx.value.provinceId)) {
|
||||
ctx.value = { provinceId: ctx.value.provinceId };
|
||||
currentLevel.value = 'province';
|
||||
} else {
|
||||
ctx.value = {
|
||||
provinceId: ctx.value.provinceId,
|
||||
cityId: ctx.value.cityId,
|
||||
};
|
||||
currentLevel.value = 'city';
|
||||
}
|
||||
ctx.value = {
|
||||
provinceId: ctx.value.provinceId,
|
||||
cityId: ctx.value.cityId,
|
||||
};
|
||||
crumbLabels.value = {
|
||||
province: crumbLabels.value.province,
|
||||
city: crumbLabels.value.city,
|
||||
};
|
||||
currentLevel.value = ctx.value.cityId ? 'city' : 'province';
|
||||
} else if (currentLevel.value === 'city') {
|
||||
ctx.value = {};
|
||||
ctx.value = { provinceId: ctx.value.provinceId };
|
||||
crumbLabels.value = { province: crumbLabels.value.province };
|
||||
currentLevel.value = 'province';
|
||||
}
|
||||
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
await reloadList();
|
||||
}
|
||||
|
||||
function scrollActiveIntoView() {
|
||||
@@ -347,27 +375,18 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = listRows.value;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
e.preventDefault();
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = 0;
|
||||
} else {
|
||||
activeIndex.value = (activeIndex.value + 1) % rows.length;
|
||||
}
|
||||
activeIndex.value =
|
||||
activeIndex.value < 0 ? 0 : (activeIndex.value + 1) % rows.length;
|
||||
scrollActiveIntoView();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
e.preventDefault();
|
||||
activeIndex.value =
|
||||
activeIndex.value <= 0 ? rows.length - 1 : activeIndex.value - 1;
|
||||
@@ -375,24 +394,20 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
e.preventDefault();
|
||||
confirmActiveItem();
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault();
|
||||
goBackOneLevel();
|
||||
void goBackOneLevel();
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (rows.length === 0 || activeIndex.value < 0) {
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0 || activeIndex.value < 0) return;
|
||||
e.preventDefault();
|
||||
drillActiveRow();
|
||||
void drillActiveRow();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -400,38 +415,46 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function onBreadcrumbClick(item: BreadcrumbItem) {
|
||||
async function onBreadcrumbClick(item: BreadcrumbItem) {
|
||||
ctx.value = truncateContextToBreadcrumb(ctx.value, item);
|
||||
if (item.level === 'province') {
|
||||
currentLevel.value = isMunicipalityProvince(item.id) ? 'district' : 'city';
|
||||
currentLevel.value = 'city';
|
||||
crumbLabels.value = { province: item.label };
|
||||
} else if (item.level === 'city') {
|
||||
currentLevel.value = 'district';
|
||||
crumbLabels.value = {
|
||||
province: crumbLabels.value.province,
|
||||
city: item.label,
|
||||
};
|
||||
} else {
|
||||
currentLevel.value = 'district';
|
||||
}
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
await reloadList();
|
||||
}
|
||||
|
||||
function goProvinceLevel() {
|
||||
async function goProvinceLevel() {
|
||||
ctx.value = {};
|
||||
crumbLabels.value = {};
|
||||
currentLevel.value = 'province';
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
await reloadList();
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
ctx.value = {};
|
||||
crumbLabels.value = {};
|
||||
mValue.value = undefined;
|
||||
currentLevel.value = 'province';
|
||||
searchKeyword.value = '';
|
||||
listRows.value = [];
|
||||
resetActiveIndex();
|
||||
}
|
||||
|
||||
function onOpenChange(v: boolean) {
|
||||
async function onOpenChange(v: boolean) {
|
||||
open.value = v;
|
||||
if (v) {
|
||||
resetPickerToValue(mValue.value);
|
||||
await resetPickerToValue(mValue.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,9 +464,9 @@ watch(open, (v) => {
|
||||
}
|
||||
}, { flush: 'post' });
|
||||
|
||||
watch(listRows, () => {
|
||||
watch(searchKeyword, () => {
|
||||
if (open.value) {
|
||||
resetActiveIndex();
|
||||
debouncedReload();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -451,7 +474,8 @@ watch(
|
||||
() => props.value,
|
||||
(val) => {
|
||||
if (!open.value) {
|
||||
syncContextFromValue(val ?? undefined);
|
||||
void syncContextFromValue(val ?? undefined);
|
||||
void ensureResolvedAddress(val ?? undefined);
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
@@ -501,24 +525,26 @@ watch(
|
||||
class="region-address-search"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<ul ref="listRef" class="region-address-list">
|
||||
<li
|
||||
v-for="(row, index) in listRows"
|
||||
:key="`${row.level}-${row.node.value}`"
|
||||
:class="[
|
||||
'region-address-list-item',
|
||||
{ 'region-address-list-item-active': index === activeIndex },
|
||||
]"
|
||||
:data-index="index"
|
||||
@click="onConfirmRow(row)"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
{{ row.label }}
|
||||
</li>
|
||||
<li v-if="listRows.length === 0" class="region-address-list-empty">
|
||||
暂无数据
|
||||
</li>
|
||||
</ul>
|
||||
<Spin :spinning="loading">
|
||||
<ul ref="listRef" class="region-address-list">
|
||||
<li
|
||||
v-for="(row, index) in listRows"
|
||||
:key="`${row.level}-${row.node.value}`"
|
||||
:class="[
|
||||
'region-address-list-item',
|
||||
{ 'region-address-list-item-active': index === activeIndex },
|
||||
]"
|
||||
:data-index="index"
|
||||
@click="onConfirmRow(row)"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
{{ row.label }}
|
||||
</li>
|
||||
<li v-if="listRows.length === 0" class="region-address-list-empty">
|
||||
暂无数据
|
||||
</li>
|
||||
</ul>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
@@ -527,11 +553,11 @@ watch(
|
||||
@click="!disabled && (open = true)"
|
||||
>
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="displayText"
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
class="region-address-trigger"
|
||||
placeholder="请选择省市区"
|
||||
readonly
|
||||
>
|
||||
<template v-if="displayText && !disabled" #suffix>
|
||||
<span
|
||||
@@ -548,162 +574,92 @@ watch(
|
||||
.region-address-trigger-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.region-address-trigger {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.region-address-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.region-address-clear:hover {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.region-address-panel {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.region-address-repick-province {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.region-address-repick-province:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-sep {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item {
|
||||
padding: 0;
|
||||
}
|
||||
.region-address-repick-province:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.region-address-breadcrumb-sep {
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0 2px;
|
||||
}
|
||||
.region-address-breadcrumb-item {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #1677ff;
|
||||
background: transparent;
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.region-address-search {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.region-address-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.region-address-list-item {
|
||||
padding: 8px 12px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.region-address-list-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.region-address-list-item-active {
|
||||
background: #e6f4ff;
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.region-address-list-item-active:hover {
|
||||
background: #e6f4ff;
|
||||
background: hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.region-address-list-empty {
|
||||
padding: 16px 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
padding: 24px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 暗色模式适配(对齐 drug-search-select / menu-search-select) */
|
||||
.dark {
|
||||
.region-address-clear {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.region-address-clear:hover {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.region-address-repick-province {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.region-address-repick-province:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-sep {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.region-address-list-item {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.region-address-list-item:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.region-address-list-item-active,
|
||||
.region-address-list-item-active:hover {
|
||||
background: #1e3a5f;
|
||||
}
|
||||
|
||||
.region-address-list-empty {
|
||||
color: #9ca3af;
|
||||
}
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { addressOption } from '#/util/address';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
getRegionChildren,
|
||||
resolveRegionPair,
|
||||
searchRegions,
|
||||
type RegionPickerChild,
|
||||
type RegionPickerSearchHit,
|
||||
} from '#/api/region-picker';
|
||||
|
||||
export interface AddressNode {
|
||||
value: number;
|
||||
label: string;
|
||||
pvalue?: number;
|
||||
}
|
||||
|
||||
export interface ProvinceIndex {
|
||||
province: AddressNode;
|
||||
children: AddressNode[];
|
||||
isMunicipality: boolean;
|
||||
hasChildren?: boolean;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedAddress {
|
||||
@@ -19,6 +23,7 @@ export interface ResolvedAddress {
|
||||
province?: AddressNode;
|
||||
city?: AddressNode;
|
||||
district?: AddressNode;
|
||||
pathLabel?: string;
|
||||
}
|
||||
|
||||
export type AddressValue = [number, number];
|
||||
@@ -37,15 +42,14 @@ export interface SelectionContext {
|
||||
districtId?: number;
|
||||
}
|
||||
|
||||
/** 跨级搜索结果 */
|
||||
export interface AddressSearchHit {
|
||||
level: RegionLevel;
|
||||
node: AddressNode;
|
||||
pathLabel: string;
|
||||
complete: boolean;
|
||||
path?: { id: number; name: string; level: number }[];
|
||||
}
|
||||
|
||||
/** 列表面板统一行 */
|
||||
export interface AddressListRow {
|
||||
level: RegionLevel;
|
||||
node: AddressNode;
|
||||
@@ -53,186 +57,39 @@ export interface AddressListRow {
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
const MUNICIPALITY_LABELS = new Set([
|
||||
'北京市',
|
||||
'天津市',
|
||||
'上海市',
|
||||
'重庆市',
|
||||
]);
|
||||
/** 回显文案缓存变更计数,供 computed 依赖 */
|
||||
export const addressDisplayTick = ref(0);
|
||||
|
||||
let provinceIndexCache: ProvinceIndex[] | null = null;
|
||||
const displayCache = new Map<string, string>();
|
||||
const pairCache = new Map<string, ResolvedAddress>();
|
||||
|
||||
function isPrefectureCityLabel(label: string): boolean {
|
||||
return label.endsWith('市') && !label.endsWith('自治区');
|
||||
function cacheKey(provinceId: number, leafId: number) {
|
||||
return `${provinceId}_${leafId}`;
|
||||
}
|
||||
|
||||
function isDistrictLikeLabel(label: string): boolean {
|
||||
return /(?:区|县|旗)$/.test(label);
|
||||
function levelFromNumber(level: number): RegionLevel {
|
||||
if (level <= 1) return 'province';
|
||||
if (level === 2) return 'city';
|
||||
return 'district';
|
||||
}
|
||||
|
||||
function detectMunicipality(province: AddressNode, children: AddressNode[]): boolean {
|
||||
if (MUNICIPALITY_LABELS.has(province.label)) {
|
||||
return true;
|
||||
}
|
||||
if (!province.label.endsWith('市') || children.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const districtCount = children.filter((c) => isDistrictLikeLabel(c.label)).length;
|
||||
return districtCount / children.length >= 0.8;
|
||||
function toNode(row: {
|
||||
id: number;
|
||||
name: string;
|
||||
pid?: number;
|
||||
level?: number;
|
||||
has_children?: boolean;
|
||||
}): AddressNode {
|
||||
return {
|
||||
value: row.id,
|
||||
label: row.name,
|
||||
pvalue: row.pid,
|
||||
level: row.level,
|
||||
hasChildren: !!row.has_children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProvinceIndexList(): ProvinceIndex[] {
|
||||
return (addressOption as AddressNode[]).map((province) => {
|
||||
const children = (province as AddressNode & { children?: AddressNode[] })
|
||||
.children ?? [];
|
||||
return {
|
||||
province: {
|
||||
value: province.value,
|
||||
label: province.label,
|
||||
pvalue: province.pvalue,
|
||||
},
|
||||
children: children.map((c) => ({
|
||||
value: c.value,
|
||||
label: c.label,
|
||||
pvalue: c.pvalue,
|
||||
})),
|
||||
isMunicipality: detectMunicipality(province, children),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getProvinceIndexList(): ProvinceIndex[] {
|
||||
if (!provinceIndexCache) {
|
||||
provinceIndexCache = buildProvinceIndexList();
|
||||
}
|
||||
return provinceIndexCache;
|
||||
}
|
||||
|
||||
function findProvinceIndex(provinceId?: number): ProvinceIndex | undefined {
|
||||
if (!provinceId) {
|
||||
return undefined;
|
||||
}
|
||||
return getProvinceIndexList().find((p) => p.province.value === provinceId);
|
||||
}
|
||||
|
||||
function findChild(
|
||||
provinceIndex: ProvinceIndex,
|
||||
childId?: number,
|
||||
): AddressNode | undefined {
|
||||
if (!childId) {
|
||||
return undefined;
|
||||
}
|
||||
return provinceIndex.children.find((c) => c.value === childId);
|
||||
}
|
||||
|
||||
function getCityNamePrefix(label: string): string {
|
||||
return label.replace(/市$/, '');
|
||||
}
|
||||
|
||||
/** 省级列表 */
|
||||
export function getProvinces(): AddressNode[] {
|
||||
return getProvinceIndexList().map((p) => p.province);
|
||||
}
|
||||
|
||||
/** 市级列表(直辖市返回空) */
|
||||
export function getCities(provinceId?: number): AddressNode[] {
|
||||
const index = findProvinceIndex(provinceId);
|
||||
if (!index || index.isMunicipality) {
|
||||
return [];
|
||||
}
|
||||
return index.children;
|
||||
}
|
||||
|
||||
/** 区级列表 */
|
||||
export function getDistricts(
|
||||
provinceId?: number,
|
||||
cityId?: number,
|
||||
): AddressNode[] {
|
||||
const index = findProvinceIndex(provinceId);
|
||||
if (!index) {
|
||||
return [];
|
||||
}
|
||||
if (index.isMunicipality) {
|
||||
return index.children;
|
||||
}
|
||||
if (!cityId) {
|
||||
return [];
|
||||
}
|
||||
const city = findChild(index, cityId);
|
||||
if (!city || !isPrefectureCityLabel(city.label)) {
|
||||
return [];
|
||||
}
|
||||
const prefix = getCityNamePrefix(city.label);
|
||||
return index.children.filter(
|
||||
(c) =>
|
||||
c.value !== cityId &&
|
||||
!isPrefectureCityLabel(c.label) &&
|
||||
c.label.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
|
||||
/** 当前层级候选列表 */
|
||||
export function getLevelOptions(
|
||||
level: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
): AddressNode[] {
|
||||
if (level === 'province') {
|
||||
return getProvinces();
|
||||
}
|
||||
if (level === 'city') {
|
||||
return getCities(ctx.provinceId);
|
||||
}
|
||||
return getDistricts(ctx.provinceId, ctx.cityId);
|
||||
}
|
||||
|
||||
/** 选择某级后的下一层级(无则 undefined) */
|
||||
export function getNextLevel(
|
||||
currentLevel: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
): RegionLevel | undefined {
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
if (!index) {
|
||||
return undefined;
|
||||
}
|
||||
if (currentLevel === 'province') {
|
||||
return index.isMunicipality ? 'district' : 'city';
|
||||
}
|
||||
if (currentLevel === 'city') {
|
||||
if (!ctx.cityId) {
|
||||
return undefined;
|
||||
}
|
||||
const districts = getDistricts(ctx.provinceId, ctx.cityId);
|
||||
return districts.length > 0 ? 'district' : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 当前层级选中后是否可直接完成(叶节点) */
|
||||
export function canCompleteAfterSelect(
|
||||
level: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
item: AddressNode,
|
||||
): boolean {
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
if (!index) {
|
||||
return false;
|
||||
}
|
||||
if (level === 'district') {
|
||||
return true;
|
||||
}
|
||||
if (level === 'city') {
|
||||
if (index.isMunicipality) {
|
||||
return false;
|
||||
}
|
||||
if (!isPrefectureCityLabel(item.label)) {
|
||||
return true;
|
||||
}
|
||||
return getDistricts(ctx.provinceId, item.value).length === 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 组装对外 address 值 */
|
||||
/** 组装对外 address 值 [provinceId, leafId] */
|
||||
export function buildAddressOutput(
|
||||
provinceId?: number,
|
||||
cityId?: number,
|
||||
@@ -248,7 +105,10 @@ export function buildAddressOutput(
|
||||
return [provinceId, leafId];
|
||||
}
|
||||
|
||||
/** 由 address 反查省/市/区 */
|
||||
/**
|
||||
* 同步解析:只读本地缓存(由 ensureResolvedAddress 填充)
|
||||
* 为什么:表格 formatter 是同步的,不能直接 await
|
||||
*/
|
||||
export function resolveAddressValue(
|
||||
value?: AddressValue | null,
|
||||
): ResolvedAddress {
|
||||
@@ -256,323 +116,191 @@ export function resolveAddressValue(
|
||||
return {};
|
||||
}
|
||||
const [provinceId, leafId] = value;
|
||||
const index = findProvinceIndex(provinceId);
|
||||
if (!index) {
|
||||
return { provinceId };
|
||||
}
|
||||
const leaf = findChild(index, leafId);
|
||||
if (!leaf) {
|
||||
return { provinceId, province: index.province };
|
||||
}
|
||||
|
||||
if (index.isMunicipality) {
|
||||
return {
|
||||
provinceId,
|
||||
districtId: leafId,
|
||||
province: index.province,
|
||||
district: leaf,
|
||||
};
|
||||
}
|
||||
|
||||
const isLeafDistrict =
|
||||
!isPrefectureCityLabel(leaf.label) && isDistrictLikeLabel(leaf.label);
|
||||
|
||||
if (isPrefectureCityLabel(leaf.label)) {
|
||||
return {
|
||||
return (
|
||||
pairCache.get(cacheKey(provinceId, leafId)) ?? {
|
||||
provinceId,
|
||||
cityId: leafId,
|
||||
province: index.province,
|
||||
city: leaf,
|
||||
};
|
||||
}
|
||||
|
||||
if (isLeafDistrict) {
|
||||
for (const city of index.children.filter((c) =>
|
||||
isPrefectureCityLabel(c.label),
|
||||
)) {
|
||||
const prefix = getCityNamePrefix(city.label);
|
||||
if (leaf.label.startsWith(prefix)) {
|
||||
return {
|
||||
provinceId,
|
||||
cityId: city.value,
|
||||
districtId: leafId,
|
||||
province: index.province,
|
||||
city,
|
||||
district: leaf,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
provinceId,
|
||||
cityId: leafId,
|
||||
province: index.province,
|
||||
city: leaf,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provinceId,
|
||||
cityId: leafId,
|
||||
province: index.province,
|
||||
city: leaf,
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
/** 面包屑展示项 */
|
||||
export function formatBreadcrumbItems(
|
||||
resolved: ResolvedAddress,
|
||||
): BreadcrumbItem[] {
|
||||
/**
|
||||
* 异步解析并写入缓存(含软删节点,后端 resolve 不过滤 deleted_at)
|
||||
*/
|
||||
export async function ensureResolvedAddress(
|
||||
value?: AddressValue | null,
|
||||
): Promise<ResolvedAddress> {
|
||||
if (!value || value.length < 2) {
|
||||
return {};
|
||||
}
|
||||
const [provinceId, leafId] = value;
|
||||
const key = cacheKey(provinceId, leafId);
|
||||
if (pairCache.has(key)) {
|
||||
return pairCache.get(key)!;
|
||||
}
|
||||
try {
|
||||
const res = await resolveRegionPair(provinceId, leafId);
|
||||
const resolved: ResolvedAddress = {
|
||||
provinceId,
|
||||
cityId: res.city?.id ?? (res.district ? undefined : leafId),
|
||||
districtId: res.district?.id,
|
||||
province: res.province
|
||||
? toNode(res.province as RegionPickerChild)
|
||||
: undefined,
|
||||
city: res.city
|
||||
? {
|
||||
value: res.city.id,
|
||||
label: res.city.name,
|
||||
pvalue: res.city.pid,
|
||||
level: res.city.level,
|
||||
}
|
||||
: undefined,
|
||||
district: res.district
|
||||
? {
|
||||
value: res.district.id,
|
||||
label: res.district.name,
|
||||
pvalue: res.district.pid,
|
||||
level: res.district.level,
|
||||
}
|
||||
: undefined,
|
||||
pathLabel: res.path_label || '',
|
||||
};
|
||||
// 两段存法下 city_id 就是叶子:若只有 district,把 leaf 记在 districtId
|
||||
if (res.district && !res.city) {
|
||||
resolved.districtId = leafId;
|
||||
} else if (res.district) {
|
||||
resolved.cityId = res.city?.id;
|
||||
resolved.districtId = res.district.id;
|
||||
} else {
|
||||
resolved.cityId = leafId;
|
||||
}
|
||||
pairCache.set(key, resolved);
|
||||
if (resolved.pathLabel) {
|
||||
displayCache.set(key, resolved.pathLabel);
|
||||
}
|
||||
addressDisplayTick.value += 1;
|
||||
return resolved;
|
||||
} catch {
|
||||
return { provinceId, cityId: leafId };
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步展示:优先缓存文案;未命中时触发异步加载并先返回空串 */
|
||||
export function formatAddressDisplay(value?: AddressValue | null): string {
|
||||
// 依赖 tick,缓存更新后 computed 会重算
|
||||
void addressDisplayTick.value;
|
||||
if (!value || value.length < 2) {
|
||||
return '';
|
||||
}
|
||||
const [provinceId, leafId] = value;
|
||||
const key = cacheKey(provinceId, leafId);
|
||||
if (displayCache.has(key)) {
|
||||
return displayCache.get(key)!;
|
||||
}
|
||||
void ensureResolvedAddress(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function formatBreadcrumbItems(resolved: ResolvedAddress): BreadcrumbItem[] {
|
||||
const items: BreadcrumbItem[] = [];
|
||||
if (resolved.provinceId && resolved.province) {
|
||||
if (resolved.province) {
|
||||
items.push({
|
||||
level: 'province',
|
||||
id: resolved.provinceId,
|
||||
id: resolved.province.value,
|
||||
label: resolved.province.label,
|
||||
});
|
||||
}
|
||||
const index = findProvinceIndex(resolved.provinceId);
|
||||
if (index?.isMunicipality) {
|
||||
if (resolved.districtId && resolved.district) {
|
||||
items.push({
|
||||
level: 'district',
|
||||
id: resolved.districtId,
|
||||
label: resolved.district.label,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (resolved.cityId && resolved.city) {
|
||||
if (resolved.city) {
|
||||
items.push({
|
||||
level: 'city',
|
||||
id: resolved.cityId,
|
||||
id: resolved.city.value,
|
||||
label: resolved.city.label,
|
||||
});
|
||||
}
|
||||
if (resolved.districtId && resolved.district) {
|
||||
if (resolved.district) {
|
||||
items.push({
|
||||
level: 'district',
|
||||
id: resolved.districtId,
|
||||
id: resolved.district.value,
|
||||
label: resolved.district.label,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 触发器展示文案 */
|
||||
export function formatAddressDisplay(value?: AddressValue | null): string {
|
||||
const items = formatBreadcrumbItems(resolveAddressValue(value));
|
||||
return items.map((i) => i.label).join(' / ');
|
||||
}
|
||||
|
||||
/** 当前层级搜索占位 */
|
||||
export function getLevelSearchPlaceholder(level: RegionLevel): string {
|
||||
const map: Record<RegionLevel, string> = {
|
||||
province: '搜索省',
|
||||
city: '搜索市/县',
|
||||
district: '搜索区',
|
||||
};
|
||||
return map[level];
|
||||
}
|
||||
|
||||
/** 模糊筛选 */
|
||||
export function filterAddressOptions(
|
||||
options: AddressNode[],
|
||||
keyword: string,
|
||||
): AddressNode[] {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return options;
|
||||
}
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/** 从已选上下文生成面包屑(面板内) */
|
||||
export function buildBreadcrumbFromContext(
|
||||
ctx: SelectionContext,
|
||||
labels?: { province?: string; city?: string; district?: string },
|
||||
): BreadcrumbItem[] {
|
||||
return formatBreadcrumbItems({
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: ctx.districtId,
|
||||
province: findProvinceIndex(ctx.provinceId)?.province,
|
||||
city: findChild(findProvinceIndex(ctx.provinceId)!, ctx.cityId),
|
||||
district: findChild(findProvinceIndex(ctx.provinceId)!, ctx.districtId),
|
||||
});
|
||||
const items: BreadcrumbItem[] = [];
|
||||
if (ctx.provinceId) {
|
||||
items.push({
|
||||
level: 'province',
|
||||
id: ctx.provinceId,
|
||||
label: labels?.province || String(ctx.provinceId),
|
||||
});
|
||||
}
|
||||
if (ctx.cityId) {
|
||||
items.push({
|
||||
level: 'city',
|
||||
id: ctx.cityId,
|
||||
label: labels?.city || String(ctx.cityId),
|
||||
});
|
||||
}
|
||||
if (ctx.districtId) {
|
||||
items.push({
|
||||
level: 'district',
|
||||
id: ctx.districtId,
|
||||
label: labels?.district || String(ctx.districtId),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 根据面板内选中生成 ResolvedAddress */
|
||||
export function contextToResolved(ctx: SelectionContext): ResolvedAddress {
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: ctx.districtId,
|
||||
province: index?.province,
|
||||
city: index ? findChild(index, ctx.cityId) : undefined,
|
||||
district: index ? findChild(index, ctx.districtId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 是否直辖市 */
|
||||
export function isMunicipalityProvince(provinceId?: number): boolean {
|
||||
return !!findProvinceIndex(provinceId)?.isMunicipality;
|
||||
}
|
||||
|
||||
/** 打开面板时的初始层级(有回填则进入待选级,否则省) */
|
||||
export function getInitialPickerLevel(
|
||||
resolved: ResolvedAddress,
|
||||
): RegionLevel {
|
||||
const index = findProvinceIndex(resolved.provinceId);
|
||||
if (!resolved.provinceId || !index) {
|
||||
return 'province';
|
||||
}
|
||||
if (index.isMunicipality) {
|
||||
return 'district';
|
||||
}
|
||||
if (!resolved.cityId) {
|
||||
return 'city';
|
||||
}
|
||||
const districts = getDistricts(resolved.provinceId, resolved.cityId);
|
||||
if (!resolved.districtId && districts.length > 0) {
|
||||
return 'district';
|
||||
}
|
||||
return 'city';
|
||||
}
|
||||
|
||||
/** 当前级候选转列表行 */
|
||||
export function levelOptionsToRows(
|
||||
/** 拉取某一层选项 */
|
||||
export async function fetchLevelOptions(
|
||||
level: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
): Promise<AddressNode[]> {
|
||||
let pid = 0;
|
||||
if (level === 'city') {
|
||||
pid = ctx.provinceId ?? 0;
|
||||
} else if (level === 'district') {
|
||||
pid = ctx.cityId ?? 0;
|
||||
}
|
||||
const rows = await getRegionChildren(pid);
|
||||
return rows.map((r) => toNode(r));
|
||||
}
|
||||
|
||||
export function levelOptionsToRows(
|
||||
level: RegionLevel,
|
||||
options: AddressNode[],
|
||||
): AddressListRow[] {
|
||||
return getLevelOptions(level, ctx).map((node) => ({
|
||||
return options.map((node) => ({
|
||||
level,
|
||||
node,
|
||||
label: node.label,
|
||||
complete: canCompleteAfterSelect(
|
||||
level,
|
||||
level === 'city'
|
||||
? { provinceId: ctx.provinceId, cityId: node.value }
|
||||
: level === 'district'
|
||||
? {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: node.value,
|
||||
}
|
||||
: ctx,
|
||||
node,
|
||||
),
|
||||
// 无子节点则可完成;有子节点继续下钻
|
||||
complete: !node.hasChildren,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 跨级模糊搜索(含第三级区/县) */
|
||||
export function searchAddressHits(
|
||||
export async function searchAddressHits(
|
||||
keyword: string,
|
||||
ctx: SelectionContext,
|
||||
): AddressSearchHit[] {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) {
|
||||
): Promise<AddressSearchHit[]> {
|
||||
const kw = keyword.trim();
|
||||
if (!kw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hits: AddressSearchHit[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const push = (hit: AddressSearchHit) => {
|
||||
const key = `${hit.level}-${hit.node.value}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
hits.push(hit);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ctx.provinceId) {
|
||||
for (const p of getProvinces()) {
|
||||
if (p.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'province',
|
||||
node: p,
|
||||
pathLabel: p.label,
|
||||
complete: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
if (!index) {
|
||||
return hits;
|
||||
}
|
||||
|
||||
if (index.isMunicipality) {
|
||||
for (const d of index.children) {
|
||||
if (d.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'district',
|
||||
node: d,
|
||||
pathLabel: `${index.province.label} / ${d.label}`,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const cityNode = ctx.cityId ? findChild(index, ctx.cityId) : undefined;
|
||||
|
||||
if (!ctx.cityId) {
|
||||
for (const child of index.children) {
|
||||
if (!child.label.toLowerCase().includes(q)) {
|
||||
continue;
|
||||
}
|
||||
const rowCtx = { provinceId: ctx.provinceId, cityId: child.value };
|
||||
push({
|
||||
level: 'city',
|
||||
node: child,
|
||||
pathLabel: `${index.province.label} / ${child.label}`,
|
||||
complete: canCompleteAfterSelect('city', rowCtx, child),
|
||||
});
|
||||
}
|
||||
for (const child of index.children.filter((c) => isPrefectureCityLabel(c.label))) {
|
||||
for (const d of getDistricts(ctx.provinceId, child.value)) {
|
||||
if (!d.label.toLowerCase().includes(q)) {
|
||||
continue;
|
||||
}
|
||||
push({
|
||||
level: 'district',
|
||||
node: d,
|
||||
pathLabel: `${index.province.label} / ${child.label} / ${d.label}`,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
if (cityNode && cityNode.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'city',
|
||||
node: cityNode,
|
||||
pathLabel: `${index.province.label} / ${cityNode.label}`,
|
||||
complete: canCompleteAfterSelect('city', ctx, cityNode),
|
||||
});
|
||||
}
|
||||
|
||||
for (const d of getDistricts(ctx.provinceId, ctx.cityId)) {
|
||||
if (d.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'district',
|
||||
node: d,
|
||||
pathLabel: `${index.province.label} / ${cityNode?.label ?? ''} / ${d.label}`,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
const hits = await searchRegions(kw, 30);
|
||||
return hits.map((h: RegionPickerSearchHit) => ({
|
||||
level: levelFromNumber(h.level),
|
||||
node: toNode(h),
|
||||
pathLabel: h.path_label || h.name,
|
||||
complete: !!h.complete,
|
||||
path: h.path,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 搜索命中转列表行 */
|
||||
export function searchHitsToRows(hits: AddressSearchHit[]): AddressListRow[] {
|
||||
return hits.map((hit) => ({
|
||||
level: hit.level,
|
||||
@@ -582,79 +310,89 @@ export function searchHitsToRows(hits: AddressSearchHit[]): AddressListRow[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 应用跨级搜索选中 */
|
||||
export function applySearchHit(
|
||||
hit: AddressSearchHit,
|
||||
ctx: SelectionContext,
|
||||
_ctx: SelectionContext,
|
||||
): SelectionContext {
|
||||
const next: SelectionContext = {};
|
||||
const path = hit.path ?? [];
|
||||
for (const node of path) {
|
||||
if (node.level === 1) {
|
||||
next.provinceId = node.id;
|
||||
} else if (node.level === 2) {
|
||||
next.cityId = node.id;
|
||||
} else if (node.level === 3) {
|
||||
next.districtId = node.id;
|
||||
}
|
||||
}
|
||||
// 兜底:path 不完整时用命中节点本身
|
||||
if (hit.level === 'province') {
|
||||
return {
|
||||
provinceId: hit.node.value,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
next.provinceId = hit.node.value;
|
||||
} else if (hit.level === 'city') {
|
||||
next.cityId = hit.node.value;
|
||||
if (!next.provinceId) {
|
||||
next.provinceId = hit.node.pvalue;
|
||||
}
|
||||
} else {
|
||||
next.districtId = hit.node.value;
|
||||
if (!next.cityId) {
|
||||
next.cityId = hit.node.pvalue;
|
||||
}
|
||||
}
|
||||
if (hit.level === 'city') {
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: hit.node.value,
|
||||
districtId: undefined,
|
||||
};
|
||||
}
|
||||
if (hit.level === 'district' && findProvinceIndex(ctx.provinceId)?.isMunicipality) {
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: undefined,
|
||||
districtId: hit.node.value,
|
||||
};
|
||||
}
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: hit.node.value,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 高亮项是否有下一级 */
|
||||
export function rowHasNextLevel(
|
||||
row: AddressListRow,
|
||||
ctx: SelectionContext,
|
||||
export function getNextLevel(
|
||||
level: RegionLevel,
|
||||
_ctx: SelectionContext,
|
||||
hasChildren?: boolean,
|
||||
): RegionLevel | undefined {
|
||||
if (hasChildren === false) {
|
||||
return undefined;
|
||||
}
|
||||
if (level === 'province') {
|
||||
return 'city';
|
||||
}
|
||||
if (level === 'city') {
|
||||
return 'district';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getInitialPickerLevel(resolved: ResolvedAddress): RegionLevel {
|
||||
if (resolved.districtId) {
|
||||
return 'district';
|
||||
}
|
||||
if (resolved.cityId) {
|
||||
return 'city';
|
||||
}
|
||||
return 'province';
|
||||
}
|
||||
|
||||
export function canCompleteAfterSelect(
|
||||
level: RegionLevel,
|
||||
_ctx: SelectionContext,
|
||||
item: AddressNode,
|
||||
): boolean {
|
||||
if (row.level === 'province') {
|
||||
return !!getNextLevel('province', {
|
||||
provinceId: row.node.value,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
});
|
||||
if (level === 'district') {
|
||||
return true;
|
||||
}
|
||||
if (row.level === 'city') {
|
||||
return !!getNextLevel('city', {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: row.node.value,
|
||||
districtId: undefined,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
return !item.hasChildren;
|
||||
}
|
||||
|
||||
export function rowHasNextLevel(row: AddressListRow): boolean {
|
||||
return !row.complete && !!row.node.hasChildren;
|
||||
}
|
||||
|
||||
/** 点击面包屑项后截断选中并返回新上下文 */
|
||||
export function truncateContextToBreadcrumb(
|
||||
ctx: SelectionContext,
|
||||
item: BreadcrumbItem,
|
||||
): SelectionContext {
|
||||
if (item.level === 'province') {
|
||||
return {
|
||||
provinceId: item.id,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
return { provinceId: item.id };
|
||||
}
|
||||
if (item.level === 'city') {
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: item.id,
|
||||
districtId: undefined,
|
||||
};
|
||||
return { provinceId: ctx.provinceId, cityId: item.id };
|
||||
}
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
@@ -662,3 +400,18 @@ export function truncateContextToBreadcrumb(
|
||||
districtId: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLevelSearchPlaceholder(level: RegionLevel): string {
|
||||
if (level === 'province') {
|
||||
return '搜索省(地名/首拼/全拼)';
|
||||
}
|
||||
if (level === 'city') {
|
||||
return '搜索市(地名/首拼/全拼)';
|
||||
}
|
||||
return '搜索区县(地名/首拼/全拼)';
|
||||
}
|
||||
|
||||
/** 兼容旧调用:直辖市判断改为「是否直接有区」由 hasChildren 驱动,此函数保留空实现 */
|
||||
export function isMunicipalityProvince(_provinceId?: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -45,6 +45,34 @@ export async function getOrderReconciliationDetailApi(params: {
|
||||
return requestClient.get<any>(`${prefix}reconciliation-detail`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单相关资金流水(商品单号 + 关联挂号单号;诊所仅本店)
|
||||
*/
|
||||
export async function getOrderFundWaterDetailApi(params: {
|
||||
order_id: number;
|
||||
scene: 'product' | 'register';
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
items: Array<{
|
||||
created_at: string;
|
||||
doctor_name: string;
|
||||
id: number;
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
order_type: number;
|
||||
order_type_txt: string;
|
||||
price: string;
|
||||
refund_no: string;
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
type: string;
|
||||
type_txt: string;
|
||||
}>;
|
||||
order_nos: string[];
|
||||
sum_price: string;
|
||||
}>(`${prefix}fund-water-detail`, { params });
|
||||
}
|
||||
|
||||
export async function getChinaErpSyncLogsApi(params: {
|
||||
order_id: number;
|
||||
page?: number;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中药订单 ERP/MES 同步记录抽屉:列表 + 手动同步;详情走独立弹窗(预览/JSON)
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -20,6 +23,8 @@ import {
|
||||
syncChinaOrderToErpApi,
|
||||
} from '#/views/business/order/api/order-ops';
|
||||
|
||||
import SyncLogContentModal from './sync-log-content-modal.vue';
|
||||
|
||||
defineOptions({ name: 'ChinaErpSyncLogDrawer' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -61,6 +66,11 @@ const columns = [
|
||||
{ title: '操作', key: 'action', width: 90 },
|
||||
];
|
||||
|
||||
/** 详情弹窗与抽屉同级挂载,避免嵌套层级错乱 */
|
||||
const [ContentModal, contentModalApi] = useVbenModal({
|
||||
connectedComponent: SyncLogContentModal,
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[70%]',
|
||||
placement: 'right',
|
||||
@@ -89,6 +99,9 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取当前订单的同步日志分页
|
||||
*/
|
||||
async function loadLogs() {
|
||||
if (!orderId.value) {
|
||||
return;
|
||||
@@ -115,22 +128,27 @@ function onPageChange(p: number, ps: number) {
|
||||
void loadLogs();
|
||||
}
|
||||
|
||||
function showContentDetail(record: { content: string }) {
|
||||
let text = record.content;
|
||||
try {
|
||||
const parsed = JSON.parse(record.content);
|
||||
text = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// keep raw
|
||||
}
|
||||
AntdModal.info({
|
||||
title: '同步详情',
|
||||
width: 'min(800px, 96vw)',
|
||||
content: text,
|
||||
okText: '关闭',
|
||||
/**
|
||||
* 打开同步详情弹窗(详情预览 / JSON 可切换)
|
||||
*/
|
||||
function showContentDetail(record: {
|
||||
content: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
is_success: number;
|
||||
}) {
|
||||
contentModalApi.setData({
|
||||
id: record.id,
|
||||
is_success: record.is_success,
|
||||
created_at: record.created_at,
|
||||
content: record.content,
|
||||
});
|
||||
contentModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员手动同步到 MES
|
||||
*/
|
||||
function handleSync() {
|
||||
if (!orderId.value) {
|
||||
return;
|
||||
@@ -158,6 +176,8 @@ function handleSync() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 详情弹窗与抽屉同级,避免嵌在抽屉内容里导致层级/遮罩异常 -->
|
||||
<ContentModal />
|
||||
<Drawer title="中药 ERP 同步记录">
|
||||
<Spin :spinning="loading">
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -214,7 +234,7 @@ function handleSync() {
|
||||
|
||||
<div
|
||||
v-if="!loading && logs.length === 0"
|
||||
class="py-4 text-center text-gray-500"
|
||||
class="py-4 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
暂无按订单关联的同步记录
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getOrderFundWaterDetailApi,
|
||||
getOrderLedgerDetailApi,
|
||||
getOrderReconciliationDetailApi,
|
||||
getOrderTraceApi,
|
||||
@@ -41,6 +42,7 @@ const userStore = useUserStore();
|
||||
const loading = ref(false);
|
||||
const ledgerLoading = ref(false);
|
||||
const reconciliationLoading = ref(false);
|
||||
const fundWaterLoading = ref(false);
|
||||
const activeTab = ref('timeline');
|
||||
const ledgerScope = ref<LedgerScope>('all');
|
||||
const ledgerSubTab = ref<'product' | 'register'>('product');
|
||||
@@ -52,6 +54,7 @@ const traceData = ref<Record<string, any> | null>(null);
|
||||
const ledgerProduct = ref<Record<string, any> | null>(null);
|
||||
const ledgerRegister = ref<Record<string, any> | null>(null);
|
||||
const reconciliationData = ref<Record<string, any> | null>(null);
|
||||
const fundWaterData = ref<Record<string, any> | null>(null);
|
||||
const loadedTabs = ref(new Set<string>());
|
||||
|
||||
const isPlatformAdmin = computed(
|
||||
@@ -72,6 +75,18 @@ const ledgerColumns = [
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
/** 订单资金流水表列(对齐资金流水页常用字段) */
|
||||
const fundWaterColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '诊所', dataIndex: 'store_name', key: 'store_name', width: 140 },
|
||||
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', width: 180 },
|
||||
{ title: '订单类型', dataIndex: 'order_type_txt', key: 'order_type_txt', width: 100 },
|
||||
{ title: '流水类型', dataIndex: 'type_txt', key: 'type_txt', width: 90 },
|
||||
{ title: '金额', dataIndex: 'price', key: 'price', width: 100 },
|
||||
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 },
|
||||
{ title: '记录时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
];
|
||||
|
||||
const currentLedgerData = computed(() =>
|
||||
ledgerSubTab.value === 'product' ? ledgerProduct.value : ledgerRegister.value,
|
||||
);
|
||||
@@ -140,6 +155,7 @@ function resetDrawerState() {
|
||||
ledgerProduct.value = null;
|
||||
ledgerRegister.value = null;
|
||||
reconciliationData.value = null;
|
||||
fundWaterData.value = null;
|
||||
loadedTabs.value = new Set<string>();
|
||||
activeTab.value = 'timeline';
|
||||
ledgerScope.value = isPlatformAdmin.value ? 'all' : 'store';
|
||||
@@ -275,12 +291,53 @@ async function loadReconciliationDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载本单资金流水:优先商品单 id,无商品单时用挂号单 id(与打开溯源的 scene 对齐)
|
||||
*/
|
||||
async function loadFundWaterDetail() {
|
||||
if (loadedTabs.value.has('fund_water')) {
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
order_id: number;
|
||||
scene: 'product' | 'register';
|
||||
}>();
|
||||
const productOrderId = Number(traceData.value?.links?.product_order_id || 0);
|
||||
const registerId = Number(traceData.value?.links?.register_id || 0);
|
||||
let scene: 'product' | 'register' = data?.scene || 'product';
|
||||
let orderId = Number(data?.order_id || 0);
|
||||
if (productOrderId > 0) {
|
||||
scene = 'product';
|
||||
orderId = productOrderId;
|
||||
} else if (registerId > 0) {
|
||||
scene = 'register';
|
||||
orderId = registerId;
|
||||
}
|
||||
if (orderId < 1) {
|
||||
fundWaterData.value = { items: [], order_nos: [], sum_price: '0.00' };
|
||||
loadedTabs.value.add('fund_water');
|
||||
return;
|
||||
}
|
||||
fundWaterLoading.value = true;
|
||||
try {
|
||||
fundWaterData.value = await getOrderFundWaterDetailApi({
|
||||
scene,
|
||||
order_id: orderId,
|
||||
});
|
||||
loadedTabs.value.add('fund_water');
|
||||
} finally {
|
||||
fundWaterLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMainTabChange(key: string) {
|
||||
activeTab.value = key;
|
||||
if (key === 'ledger') {
|
||||
await loadLedgerDetail();
|
||||
} else if (key === 'reconciliation') {
|
||||
await loadReconciliationDetail();
|
||||
} else if (key === 'fund_water') {
|
||||
await loadFundWaterDetail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,6 +658,33 @@ watch(isPlatformAdmin, (val) => {
|
||||
/>
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="fund_water" tab="订单流水">
|
||||
<Spin :spinning="fundWaterLoading">
|
||||
<div class="mb-2 text-sm">
|
||||
流水合计:{{ fundWaterData?.sum_price ?? '0.00' }}
|
||||
<span
|
||||
v-if="fundWaterData?.order_nos?.length"
|
||||
class="ml-2 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
单号:{{ fundWaterData.order_nos.join('、') }}
|
||||
</span>
|
||||
</div>
|
||||
<Table
|
||||
:columns="fundWaterColumns"
|
||||
:data-source="fundWaterData?.items ?? []"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<div
|
||||
v-if="!fundWaterData?.items?.length && !fundWaterLoading"
|
||||
class="py-6 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
暂无资金流水
|
||||
</div>
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 订单侧「同步详情」弹窗:展示单条 xk_sync_china_order_log 的 content
|
||||
* 与日志菜单详情共用 SyncChinaOrderContentViewer(详情预览 / JSON)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Descriptions, Tag } from 'ant-design-vue';
|
||||
|
||||
import SyncChinaOrderContentViewer from '#/views/log/components/sync-china-order-content-viewer.vue';
|
||||
|
||||
const meta = ref<{
|
||||
content?: string;
|
||||
created_at?: string;
|
||||
id?: number;
|
||||
is_success?: number;
|
||||
}>({});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
showConfirmButton: false,
|
||||
cancelText: '关闭',
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
meta.value = {};
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
content?: string;
|
||||
created_at?: string;
|
||||
id?: number;
|
||||
is_success?: number;
|
||||
}>();
|
||||
meta.value = data || {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[90%] md:w-[900px]" title="同步详情">
|
||||
<Descriptions bordered size="small" :column="2" class="mb-3">
|
||||
<Descriptions.Item label="日志 ID">
|
||||
{{ meta.id ?? '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">
|
||||
<Tag
|
||||
:color="Number(meta.is_success) === 0 ? 'success' : 'error'"
|
||||
>
|
||||
{{ Number(meta.is_success) === 0 ? '成功' : '失败' }}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间" :span="2">
|
||||
{{ meta.created_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<SyncChinaOrderContentViewer :content="meta.content" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -256,3 +256,31 @@ export async function getVerifyRecentOrderAmounts(params?: {
|
||||
since: number;
|
||||
}>(`${prefix}verify-recent-order-amounts`, { params });
|
||||
}
|
||||
|
||||
/** 预览:按明细 buy_price 汇总应得采购价(不落库) */
|
||||
export async function previewRecalculateMarketPriceApi(params: {
|
||||
order_id: number;
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
all_buy_price_zero: boolean;
|
||||
expected_market_price: string;
|
||||
hint: string;
|
||||
old_market_price: string;
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
}>(`${prefix}preview-recalculate-market-price`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认后覆盖主表 market_price(仅汇总明细现有 buy_price,不回填门店药价)
|
||||
*/
|
||||
export async function recalculateMarketPriceApi(data: { order_id: number }) {
|
||||
return requestClient.post<{
|
||||
all_buy_price_zero: boolean;
|
||||
expected_market_price: string;
|
||||
hint: string;
|
||||
old_market_price: string;
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
}>(`${prefix}recalculate-market-price`, data);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Empty, Image, message, Pagination, Spin, Switch } from 'ant-design-vue';
|
||||
import { Button, Empty, Image, message, Pagination, Spin, Switch, Tag } from 'ant-design-vue';
|
||||
|
||||
import { CardActions } from '#/components/table-action';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
@@ -491,46 +491,64 @@ onMounted(() => {
|
||||
</div>
|
||||
<!-- 弹性占位:高度富余时把 footer 推到底(同行等高卡对齐),紧凑时保底 8px 间距 -->
|
||||
<div class="po-card__spacer"></div>
|
||||
<!-- 行7 footer:左分项金额/包邮开关,右操作按钮 -->
|
||||
<!-- 行7 footer:左金额两行两列,右包邮开关在详情上方 -->
|
||||
<div class="po-card__footer">
|
||||
<div class="po-card__amount-parts">
|
||||
<span>
|
||||
药品
|
||||
<Button
|
||||
v-if="props.canPercentAdjust?.(row)"
|
||||
class="!h-auto !px-0 !py-0 !text-xs"
|
||||
type="link"
|
||||
@click="emit('percentAdjust', row)"
|
||||
>
|
||||
¥{{ row.items_price ?? '0.00' }}
|
||||
</Button>
|
||||
<template v-else>¥{{ row.items_price ?? '0.00' }}</template>
|
||||
</span>
|
||||
<span>
|
||||
挂号
|
||||
<Button
|
||||
v-if="row.register_order_no"
|
||||
class="!h-auto !px-0 !py-0 !text-xs"
|
||||
type="link"
|
||||
@click="emit('goRegister', row)"
|
||||
>
|
||||
{{ registerAmount(row) }}
|
||||
</Button>
|
||||
<template v-else>{{ registerAmount(row) }}</template>
|
||||
</span>
|
||||
<Switch
|
||||
:checked="row.is_free_shipping === 1"
|
||||
checked-children="包邮"
|
||||
un-checked-children="不包邮"
|
||||
size="small"
|
||||
@change="(checked: any) => emit('toggleFreeShipping', row, Boolean(checked))"
|
||||
<div class="po-card__amount-block">
|
||||
<div class="po-card__amount-parts">
|
||||
<span>
|
||||
药品
|
||||
<Button
|
||||
v-if="props.canPercentAdjust?.(row)"
|
||||
class="!h-auto !px-0 !py-0 !text-xs"
|
||||
type="link"
|
||||
@click="emit('percentAdjust', row)"
|
||||
>
|
||||
¥{{ row.items_price ?? '0.00' }}
|
||||
</Button>
|
||||
<template v-else>¥{{ row.items_price ?? '0.00' }}</template>
|
||||
</span>
|
||||
<span>
|
||||
挂号
|
||||
<Button
|
||||
v-if="row.register_order_no || row.register_id"
|
||||
class="!h-auto !px-0 !py-0 !text-xs"
|
||||
type="link"
|
||||
@click="emit('goRegister', row)"
|
||||
>
|
||||
{{ registerAmount(row) }}
|
||||
</Button>
|
||||
<template v-else>{{ registerAmount(row) }}</template>
|
||||
</span>
|
||||
<span>诊疗 ¥{{ row.treatement_price ?? '0.00' }}</span>
|
||||
<span>
|
||||
采购 ¥{{ row.market_price ?? '0.00' }}
|
||||
<Tag
|
||||
v-if="Number(row.market_price ?? 0) === 0"
|
||||
color="error"
|
||||
class="ml-0.5 !text-[10px] !leading-4"
|
||||
>
|
||||
异常
|
||||
</Tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="po-card__footer-actions">
|
||||
<label class="po-card__ship-label">
|
||||
<span>是否包邮</span>
|
||||
<Switch
|
||||
:checked="row.is_free_shipping === 1"
|
||||
checked-children="包邮"
|
||||
un-checked-children="不包邮"
|
||||
size="small"
|
||||
@change="(checked: any) => emit('toggleFreeShipping', row, Boolean(checked))"
|
||||
/>
|
||||
</label>
|
||||
<!-- 卡片交互与列表解耦:只露出「详情」主操作,其余收进「···」菜单,避免按钮堆叠 -->
|
||||
<CardActions
|
||||
:actions="props.buildRowActions(row).actions"
|
||||
:drop-down-actions="props.buildRowActions(row).dropDownActions"
|
||||
/>
|
||||
</div>
|
||||
<!-- 卡片交互与列表解耦:只露出「详情」主操作,其余收进「···」菜单,避免按钮堆叠 -->
|
||||
<CardActions
|
||||
:actions="props.buildRowActions(row).actions"
|
||||
:drop-down-actions="props.buildRowActions(row).dropDownActions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -877,7 +895,7 @@ onMounted(() => {
|
||||
|
||||
.po-card__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -886,15 +904,50 @@ onMounted(() => {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.po-card__amount-parts {
|
||||
/* 左侧金额块:两行两列(药品/挂号、诊疗/采购) */
|
||||
.po-card__amount-block {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.po-card__amount-parts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 4px 16px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.po-card__amount-parts > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 右侧:包邮开关在上,详情/更多在下 */
|
||||
.po-card__footer-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.po-card__ship-label {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.po-card-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -14,7 +14,13 @@ import OrderPricePercentAdjustDrawer from '#/views/business/order/components/Ord
|
||||
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
|
||||
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
|
||||
import { formatPriceDiscountLabel, normalizeQuickOptions } from '#/utils/pricePercentAdjust';
|
||||
import { expressDetailByOrderId, getOrderInfo, syncLegacyShipmentApi } from '../api';
|
||||
import {
|
||||
expressDetailByOrderId,
|
||||
getOrderInfo,
|
||||
previewRecalculateMarketPriceApi,
|
||||
recalculateMarketPriceApi,
|
||||
syncLegacyShipmentApi,
|
||||
} from '../api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
|
||||
import {
|
||||
@@ -190,6 +196,43 @@ function openPrintPrescription() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情内重算采购价:预览新旧值 → 确认后覆盖 market_price(与列表同口径)
|
||||
*/
|
||||
async function handleRecalculateMarketPrice() {
|
||||
const orderId = Number(data.value?.id || 0);
|
||||
if (orderId < 1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const preview = await previewRecalculateMarketPriceApi({ order_id: orderId });
|
||||
const oldPrice = preview?.old_market_price ?? data.value?.market_price ?? '0.00';
|
||||
const newPrice = preview?.expected_market_price ?? '0.00';
|
||||
const allBuyZero = Boolean(preview?.all_buy_price_zero);
|
||||
AntdModal.confirm({
|
||||
title: '确认重算采购价',
|
||||
content: allBuyZero
|
||||
? `当前采购价 ${oldPrice},按明细重算仍为 ${newPrice}(明细供货价均为 0,无法修复)。仍要覆盖吗?`
|
||||
: `将采购价从 ${oldPrice} 覆盖为 ${newPrice}(按明细供货价汇总)。确定继续?`,
|
||||
okText: '确认覆盖',
|
||||
cancelText: '取消',
|
||||
zIndex: ORDER_POPUP_Z_INDEX.NESTED4,
|
||||
async onOk() {
|
||||
const result = await recalculateMarketPriceApi({ order_id: orderId });
|
||||
if (result?.all_buy_price_zero) {
|
||||
message.warning(result?.hint || '明细供货价均为 0,无法修复');
|
||||
} else {
|
||||
message.success(result?.hint || '采购价已更新');
|
||||
}
|
||||
await reloadOrder();
|
||||
gridApi.value?.reload?.();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认后触发后端重新结算:作废残缺分账并按规则重建入账,避免重复加钱
|
||||
*/
|
||||
@@ -407,7 +450,15 @@ function prescriptionStatusColor() {
|
||||
{{ data.items_price }} 元
|
||||
</Button>
|
||||
<template v-else>{{ data.items_price }} 元</template>
|
||||
, 加工费:{{ data.process_price }} 元
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="加工费">
|
||||
{{ data.process_price || '0.00' }} 元
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="诊疗费">
|
||||
{{ data.treatement_price || '0.00' }} 元
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item v-if="Number(data.decoct_price) > 0" label="煎药费">
|
||||
{{ data.decoct_price || '0.00' }} 元
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item v-if="priceDiscountLabel" label="价格浮动">
|
||||
{{ priceDiscountLabel }}({{ data.price_discount }}%)
|
||||
@@ -420,11 +471,23 @@ function prescriptionStatusColor() {
|
||||
清除浮动
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="供货价">
|
||||
{{ data.market_price }} 元
|
||||
<Descriptions.Item label="采购价">
|
||||
{{ data.market_price || '0.00' }} 元
|
||||
<Tag v-if="Number(data.market_price) === 0" color="error" class="ml-1">
|
||||
采购价异常
|
||||
</Tag>
|
||||
<Button
|
||||
v-if="Number(data.market_price) === 0"
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-1"
|
||||
@click="handleRecalculateMarketPrice"
|
||||
>
|
||||
重算采购价
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运费">
|
||||
{{ data.trans_expenses }} 元
|
||||
{{ data.trans_expenses || '0.00' }} 元
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="是否免邮">
|
||||
{{ data.free_ship === 0 ? '是' : '否' }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { VbenFormProps } from '#/adapter/form';
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
getOrderInfo,
|
||||
getOrderList,
|
||||
getVerifyRecentOrderAmounts,
|
||||
previewRecalculateMarketPriceApi,
|
||||
recalculateMarketPriceApi,
|
||||
saleAmountApi,
|
||||
updateFreeShipping,
|
||||
} from '#/views/business/order/product-order/api';
|
||||
@@ -59,6 +61,8 @@ import {
|
||||
WxUserPatientDetailModal,
|
||||
WxUserPatientsModal,
|
||||
} from '#/components/wx-user-patient';
|
||||
import { getRegisterListApi } from '#/views/business/order/register/api';
|
||||
import RegisterDetailModal from '#/views/business/order/register/components/RegisterDetailModal.vue';
|
||||
|
||||
import DetailModal from './components/detail.vue';
|
||||
import OrderUserInfoCell from './components/cells/OrderUserInfoCell.vue';
|
||||
@@ -84,7 +88,6 @@ import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
|
||||
import { StatIslands, type StatIslandItem } from '#/components/stat-islands';
|
||||
import { isPlatformSuperAdmin } from '#/views/system/admin/_shared/platform-admin-role';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
// vben 登录态 roles 为 string[] 与本地 PlatformAdminUserInfo 结构不同,仅取 role_id 判断,直接断言
|
||||
@@ -95,34 +98,12 @@ const canViewInviterCommission = isPlatformSuperAdmin(
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
/** 公账出账模式:未拉到配置前不当成 order,避免按天误露出逐单确认 */
|
||||
const publicAccountBillMode = ref('');
|
||||
/** 打开挂号详情时防重复点击 */
|
||||
const openingRegisterDetail = ref(false);
|
||||
|
||||
function findRegisterOrderListPath(): null | string {
|
||||
const routes = router.getRoutes();
|
||||
const hit = routes.find(
|
||||
(r) =>
|
||||
typeof r.path === 'string' &&
|
||||
r.path.length > 0 &&
|
||||
!r.redirect &&
|
||||
/register/i.test(r.path) &&
|
||||
/order/i.test(r.path),
|
||||
);
|
||||
return hit?.path ?? null;
|
||||
}
|
||||
|
||||
function goRegisterOrder(row: Record<string, any>) {
|
||||
const orderNo = row.register_order_no;
|
||||
if (!orderNo) {
|
||||
message.warning('无关联挂号订单号');
|
||||
return;
|
||||
}
|
||||
const path = findRegisterOrderListPath();
|
||||
if (!path) {
|
||||
message.warning('未找到挂号订单菜单路由,请从左侧菜单进入挂号订单');
|
||||
return;
|
||||
}
|
||||
void router.push({ path, query: { order_no: String(orderNo) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号金额格式化(列表金额列 / 卡片 footer 共用)
|
||||
*/
|
||||
function formatRegisterAmount(row: Record<string, any>) {
|
||||
if (row.register_id == null || row.register_id === '') {
|
||||
return '—';
|
||||
@@ -134,6 +115,14 @@ function formatRegisterAmount(row: Record<string, any>) {
|
||||
return `¥${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购价是否异常(主表 market_price 为 0)
|
||||
* 用于列表摘要、卡片、行操作「重算采购价」显隐
|
||||
*/
|
||||
function isMarketPriceAbnormal(row: Record<string, any>) {
|
||||
return Number(row?.market_price ?? 0) === 0;
|
||||
}
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
@@ -491,6 +480,12 @@ const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
|
||||
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
|
||||
});
|
||||
|
||||
/** 挂号详情弹窗:商品订单点挂号金额时打开,不再跳转挂号列表 */
|
||||
const [RegisterDetailModalComp, registerDetailModalApi] = useVbenModal({
|
||||
connectedComponent: RegisterDetailModal,
|
||||
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
|
||||
});
|
||||
|
||||
function openStoreCard(storeId?: number) {
|
||||
if (!storeId) return;
|
||||
openOrderPopup(
|
||||
@@ -500,6 +495,87 @@ function openStoreCard(storeId?: number) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开关联挂号详情:按 register_id 精确拉一条(list 已带就诊人/诊所/医生关联),弹窗展示
|
||||
* 为什么不跳转:商品订单页操作上下文不能丢,弹窗叠层与挂号页详情一致
|
||||
*/
|
||||
async function openRegisterDetail(row: Record<string, any>) {
|
||||
const registerId = Number(row?.register_id || 0);
|
||||
const orderNo = row?.register_order_no ? String(row.register_order_no) : '';
|
||||
if (registerId < 1 && !orderNo) {
|
||||
message.warning('无关联挂号订单');
|
||||
return;
|
||||
}
|
||||
if (openingRegisterDetail.value) {
|
||||
return;
|
||||
}
|
||||
openingRegisterDetail.value = true;
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
};
|
||||
if (registerId > 0) {
|
||||
params.id = registerId;
|
||||
} else {
|
||||
params.order_no = orderNo;
|
||||
}
|
||||
const res = await getRegisterListApi(params);
|
||||
const items = Array.isArray(res?.items) ? res.items : [];
|
||||
const registerRow = items[0];
|
||||
if (!registerRow) {
|
||||
message.warning('未找到关联挂号订单');
|
||||
return;
|
||||
}
|
||||
openOrderPopup(
|
||||
registerDetailModalApi,
|
||||
{ row: registerRow },
|
||||
ORDER_POPUP_Z_INDEX.NESTED,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('加载挂号详情失败');
|
||||
} finally {
|
||||
openingRegisterDetail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览并确认后按明细 buy_price 重算覆盖主表采购价(market_price)
|
||||
* 不回填门店药价、不改明细、不动零售合计与分账
|
||||
*/
|
||||
async function handleRecalculateMarketPrice(row: Record<string, any>) {
|
||||
const orderId = Number(row?.id || 0);
|
||||
if (orderId < 1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const preview = await previewRecalculateMarketPriceApi({ order_id: orderId });
|
||||
const oldPrice = preview?.old_market_price ?? row.market_price ?? '0.00';
|
||||
const newPrice = preview?.expected_market_price ?? '0.00';
|
||||
const allBuyZero = Boolean(preview?.all_buy_price_zero);
|
||||
AntdModal.confirm({
|
||||
title: '确认重算采购价',
|
||||
content: allBuyZero
|
||||
? `当前采购价 ${oldPrice},按明细重算仍为 ${newPrice}(明细供货价均为 0,无法修复)。仍要覆盖吗?`
|
||||
: `将采购价从 ${oldPrice} 覆盖为 ${newPrice}(按明细供货价汇总)。确定继续?`,
|
||||
okText: '确认覆盖',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
const result = await recalculateMarketPriceApi({ order_id: orderId });
|
||||
if (result?.all_buy_price_zero) {
|
||||
message.warning(result?.hint || '明细供货价均为 0,无法修复');
|
||||
} else {
|
||||
message.success(result?.hint || '采购价已更新');
|
||||
}
|
||||
refreshCurrentView();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const [PatientsModal, PatientsModalApi] = useVbenModal({
|
||||
connectedComponent: WxUserPatientsModal,
|
||||
});
|
||||
@@ -1049,6 +1125,14 @@ function buildRowActions(row: Record<string, any>): {
|
||||
size: 'small',
|
||||
onClick: openOrderTrace.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '重算采购价',
|
||||
type: 'link',
|
||||
icon: 'mdi:calculator-variant',
|
||||
size: 'small',
|
||||
ifShow: isMarketPriceAbnormal(row),
|
||||
onClick: handleRecalculateMarketPrice.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '处方',
|
||||
type: 'link',
|
||||
@@ -1178,6 +1262,10 @@ function buildRowActions(row: Record<string, any>): {
|
||||
<RefundModal />
|
||||
<DoctorCardModals />
|
||||
<StoreCardModalComp />
|
||||
<RegisterDetailModalComp
|
||||
@open-prescription="openPrescriptionDetail"
|
||||
@open-store="openStoreCard"
|
||||
/>
|
||||
<PatientsModal />
|
||||
<PatientDetailModal />
|
||||
<ExportModal />
|
||||
@@ -1343,10 +1431,11 @@ function buildRowActions(row: Record<string, any>): {
|
||||
<div>
|
||||
挂号:
|
||||
<Button
|
||||
v-if="row.register_order_no"
|
||||
v-if="row.register_order_no || row.register_id"
|
||||
class="!h-auto !px-0 !py-0"
|
||||
type="link"
|
||||
@click="goRegisterOrder(row)"
|
||||
:loading="openingRegisterDetail"
|
||||
@click="openRegisterDetail(row)"
|
||||
>
|
||||
{{ formatRegisterAmount(row) }}
|
||||
</Button>
|
||||
@@ -1528,12 +1617,29 @@ function buildRowActions(row: Record<string, any>): {
|
||||
</div>
|
||||
<div v-if="!row.prescription_type"></div>
|
||||
<div
|
||||
class="order-summary mt-6 flex justify-center space-x-4 rounded-lg bg-gray-100 p-4 dark:bg-gray-800"
|
||||
class="order-summary mt-6 flex flex-wrap items-center justify-center gap-4 rounded-lg bg-gray-100 p-4 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm">采购价:{{ row.market_price || '0.00' }}</span>
|
||||
<span class="text-sm">
|
||||
采购价:{{ row.market_price || '0.00' }}
|
||||
<Tag v-if="isMarketPriceAbnormal(row)" color="error" class="ml-1">
|
||||
采购价异常
|
||||
</Tag>
|
||||
<Button
|
||||
v-if="isMarketPriceAbnormal(row)"
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-1"
|
||||
@click="handleRecalculateMarketPrice(row)"
|
||||
>
|
||||
重算
|
||||
</Button>
|
||||
</span>
|
||||
<span class="text-sm">快递费:{{ row.trans_expenses || '0.00' }}</span>
|
||||
<span class="text-sm">诊疗费:{{ row.treatement_price || '0.00' }}</span>
|
||||
<span class="text-sm">加工费:{{ row.process_price || '0.00' }}</span>
|
||||
<span v-if="Number(row.decoct_price) > 0" class="text-sm">
|
||||
煎药费:{{ row.decoct_price || '0.00' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
@@ -1554,7 +1660,7 @@ function buildRowActions(row: Record<string, any>): {
|
||||
@open-warehouse="openChangeWarehouse"
|
||||
@toggle-free-shipping="toggleFreeShipping"
|
||||
@percent-adjust="openOrderPercentAdjust"
|
||||
@go-register="goRegisterOrder"
|
||||
@go-register="openRegisterDetail"
|
||||
@open-china-erp-sync-log="openChinaErpSyncLog"
|
||||
@accrue-salesperson="handleAccrueSalesperson"
|
||||
@reverse-salesperson="handleReverseSalesperson"
|
||||
|
||||
@@ -38,8 +38,8 @@ import {
|
||||
} from '#/views/doctor/doctor-reception/config/health-history';
|
||||
import RegionAddressPicker from '#/components/form/components/region-address-picker.vue';
|
||||
import {
|
||||
ensureResolvedAddress,
|
||||
formatAddressDisplay,
|
||||
resolveAddressValue,
|
||||
type AddressValue,
|
||||
} from '#/util/address-index';
|
||||
|
||||
@@ -237,11 +237,12 @@ function setHistoryTags(historyField: keyof ReturnType<typeof buildEmptyForm>, t
|
||||
* 组装提交体:不展示的月经/婚育强制清零;history 仅在 status=有 时带出
|
||||
* 收货地址:有详细地址才带省市区字段,后端据此建 yii_address
|
||||
*/
|
||||
function buildSubmitPayload() {
|
||||
async function buildSubmitPayload() {
|
||||
const f = form.value;
|
||||
const menstrualOk = showMenstrualHistory.value;
|
||||
const maritalOk = showMaritalHistory.value;
|
||||
const resolved = resolveAddressValue(f.ship_region);
|
||||
// 提交前按 ID 解析名称(含软删回显),避免本地树删除后取不到省市区文案
|
||||
const resolved = await ensureResolvedAddress(f.ship_region);
|
||||
const detail = String(f.ship_detail_address || '').trim();
|
||||
const shipPayload =
|
||||
detail && (resolved.province || resolved.city || resolved.district)
|
||||
@@ -249,7 +250,8 @@ function buildSubmitPayload() {
|
||||
ship_province: resolved.province?.label || '',
|
||||
ship_city: resolved.city?.label || '',
|
||||
ship_area: resolved.district?.label || '',
|
||||
ship_region: formatAddressDisplay(f.ship_region),
|
||||
ship_region:
|
||||
resolved.pathLabel || formatAddressDisplay(f.ship_region),
|
||||
ship_detail_address: detail,
|
||||
ship_name: String(f.ship_name || f.name || '').trim(),
|
||||
ship_mobile: String(f.ship_mobile || f.mobile || '').trim(),
|
||||
@@ -322,7 +324,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
submitting.value = true;
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const res = await createUserPatientApi(buildSubmitPayload());
|
||||
const res = await createUserPatientApi(await buildSubmitPayload());
|
||||
message.success('创建成功,已自动挂号');
|
||||
// 先回调再关窗:父级按 is_pay 决定选中列表或弹认领码
|
||||
onSuccessCb.value?.(res as CreateUserPatientResult);
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中药 ERP / MES 同步报文查看器
|
||||
* 用途:订单同步抽屉、同步中药 ERP 日志、第三方 MES OpenAPI 日志共用
|
||||
* content 形态:
|
||||
* 1) 订单成功 { client, params } —— params=请求,client=返回
|
||||
* 2) 订单失败 { message, file, line }
|
||||
* 3) OpenAPI { source, app_key, request, mes } —— request=请求,mes=返回
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const TabPane = Tabs.TabPane;
|
||||
|
||||
const props = defineProps<{
|
||||
/** 日志 content:JSON 字符串或已解析对象 */
|
||||
content?: string | Record<string, any> | null;
|
||||
}>();
|
||||
|
||||
/** Tab 记忆:详情预览 / JSON */
|
||||
const CONTENT_TAB_STORAGE_KEY = 'sync_china_order_content_tab';
|
||||
|
||||
function readContentTab(): 'preview' | 'json' {
|
||||
const v = localStorage.getItem(CONTENT_TAB_STORAGE_KEY);
|
||||
return v === 'json' ? 'json' : 'preview';
|
||||
}
|
||||
|
||||
const contentTab = ref<'preview' | 'json'>(readContentTab());
|
||||
|
||||
/**
|
||||
* 切换内容区 Tab,并写入 localStorage,下次打开仍用上次选择
|
||||
*/
|
||||
function onContentTabChange(key: string | number) {
|
||||
const next = String(key) === 'json' ? 'json' : 'preview';
|
||||
contentTab.value = next;
|
||||
localStorage.setItem(CONTENT_TAB_STORAGE_KEY, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 content 为对象;非法 JSON 时退回空对象,原文留给 JSON Tab 展示
|
||||
*/
|
||||
const parsed = computed<Record<string, any>>(() => {
|
||||
const raw = props.content;
|
||||
if (raw == null || raw === '') return {};
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) return raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
/**
|
||||
* 发起请求的传参:订单同步用 params,OpenAPI 用 request
|
||||
*/
|
||||
const requestPayload = computed<Record<string, any> | null>(() => {
|
||||
const p = parsed.value;
|
||||
const fromParams = p.params;
|
||||
if (fromParams && typeof fromParams === 'object' && !Array.isArray(fromParams)) {
|
||||
return fromParams;
|
||||
}
|
||||
const fromRequest = p.request;
|
||||
if (
|
||||
fromRequest &&
|
||||
typeof fromRequest === 'object' &&
|
||||
!Array.isArray(fromRequest)
|
||||
) {
|
||||
return fromRequest;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const hasRequest = computed(() => requestPayload.value != null);
|
||||
|
||||
/**
|
||||
* 返回体归一化:订单成功写在 client;OpenAPI 写在 mes
|
||||
* 统一成 { success, channel, code, message, body/raw }
|
||||
*/
|
||||
const responseInfo = computed<{
|
||||
body: unknown;
|
||||
channel: string;
|
||||
code: string;
|
||||
message: string;
|
||||
success: boolean | null;
|
||||
} | null>(() => {
|
||||
const p = parsed.value;
|
||||
// 订单同步成功:client 为 MesFailoverClient 返回数组
|
||||
if (p.client != null && typeof p.client === 'object' && !Array.isArray(p.client)) {
|
||||
const c = p.client as Record<string, any>;
|
||||
return {
|
||||
success: c.success == null ? null : Boolean(c.success),
|
||||
channel: String(c.channel ?? ''),
|
||||
code: String(c.code ?? ''),
|
||||
message: String(c.message ?? ''),
|
||||
body: c.raw ?? c.body ?? null,
|
||||
};
|
||||
}
|
||||
// OpenAPI:mes 块
|
||||
if (p.mes != null && typeof p.mes === 'object' && !Array.isArray(p.mes)) {
|
||||
const m = p.mes as Record<string, any>;
|
||||
return {
|
||||
success: m.success == null ? null : Boolean(m.success),
|
||||
channel: String(m.channel ?? ''),
|
||||
code: String(m.code ?? ''),
|
||||
message: String(m.message ?? ''),
|
||||
body: m.body ?? m.raw ?? null,
|
||||
};
|
||||
}
|
||||
// 订单失败:仅有异常信息,当作返回预览
|
||||
if (p.message) {
|
||||
return {
|
||||
success: false,
|
||||
channel: '',
|
||||
code: '',
|
||||
message: String(p.message),
|
||||
body: {
|
||||
file: p.file ?? '',
|
||||
line: p.line ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
// client 为字符串的历史兼容
|
||||
if (typeof p.client === 'string' && p.client !== '') {
|
||||
return {
|
||||
success: null,
|
||||
channel: '',
|
||||
code: '',
|
||||
message: p.client,
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const hasResponse = computed(() => responseInfo.value != null);
|
||||
|
||||
/** 药味明细(来自请求传参) */
|
||||
const drugRows = computed(() => {
|
||||
const list = requestPayload.value?.prescriptionDetailList;
|
||||
return Array.isArray(list) ? list : [];
|
||||
});
|
||||
|
||||
const drugColumns = [
|
||||
{ title: '序号', dataIndex: 'orderNumber', key: 'orderNumber', width: 60 },
|
||||
{ title: '药名', dataIndex: 'drugName', key: 'drugName', ellipsis: true },
|
||||
{ title: '编码', dataIndex: 'drugCode', key: 'drugCode', width: 110 },
|
||||
{ title: '单位', dataIndex: 'drugUnit', key: 'drugUnit', width: 60 },
|
||||
{ title: '数量', dataIndex: 'perQuantity', key: 'perQuantity', width: 70 },
|
||||
{
|
||||
title: '煎法',
|
||||
dataIndex: 'boilMethodName',
|
||||
key: 'boilMethodName',
|
||||
width: 90,
|
||||
ellipsis: true,
|
||||
},
|
||||
{ title: '单价', dataIndex: 'marketPrice', key: 'marketPrice', width: 80 },
|
||||
];
|
||||
|
||||
/** 顶栏摘要(来源 / AppKey 等,不含请求体与返回体细节) */
|
||||
const hasSummary = computed(() => {
|
||||
const p = parsed.value;
|
||||
return Boolean(
|
||||
p.source || p.app_key || p.app_id || p.hospital_prescription_code,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 对象/值格式化为可读 JSON;空则(无)
|
||||
*/
|
||||
function prettyJson(v: unknown): string {
|
||||
if (v == null || v === '') return '(无)';
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(v), null, 2);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
const fullJsonText = computed(() => prettyJson(props.content ?? parsed.value));
|
||||
const requestJsonText = computed(() =>
|
||||
hasRequest.value ? prettyJson(requestPayload.value) : '(无请求传参)',
|
||||
);
|
||||
const responseJsonText = computed(() => {
|
||||
if (!hasResponse.value || !responseInfo.value) return '(无返回)';
|
||||
// 返回预览 JSON:摘要字段 + body,方便对照 MES 回包
|
||||
return prettyJson({
|
||||
success: responseInfo.value.success,
|
||||
channel: responseInfo.value.channel,
|
||||
code: responseInfo.value.code,
|
||||
message: responseInfo.value.message,
|
||||
body: responseInfo.value.body,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 复制指定文本
|
||||
*/
|
||||
async function copyText(text: string, tip = '已复制') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success(tip);
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择文本');
|
||||
}
|
||||
}
|
||||
|
||||
function display(v: unknown): string {
|
||||
if (v == null || v === '') return '—';
|
||||
return String(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-content-viewer">
|
||||
<Tabs
|
||||
:active-key="contentTab"
|
||||
size="small"
|
||||
@change="onContentTabChange"
|
||||
>
|
||||
<TabPane key="preview" tab="详情预览">
|
||||
<div class="preview-body">
|
||||
<!-- 摘要:OpenAPI 来源等 -->
|
||||
<Descriptions
|
||||
v-if="hasSummary"
|
||||
bordered
|
||||
size="small"
|
||||
:column="2"
|
||||
class="mb-3"
|
||||
>
|
||||
<Descriptions.Item v-if="parsed.source" label="来源">
|
||||
{{ display(parsed.source) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item v-if="parsed.app_key" label="App Key">
|
||||
{{ display(parsed.app_key) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item v-if="parsed.app_id" label="App ID">
|
||||
{{ display(parsed.app_id) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="parsed.hospital_prescription_code"
|
||||
label="医院处方号"
|
||||
>
|
||||
{{ display(parsed.hospital_prescription_code) }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<!-- 请求传参预览 -->
|
||||
<div class="section-block">
|
||||
<div class="section-title">请求传参预览</div>
|
||||
<template v-if="hasRequest && requestPayload">
|
||||
<Descriptions bordered size="small" :column="2" class="mb-3">
|
||||
<Descriptions.Item label="处方号">
|
||||
{{ display(requestPayload.hospitalPrescriptionCode) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="医院编码">
|
||||
{{ display(requestPayload.hospitalCode) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="患者">
|
||||
{{ display(requestPayload.patientName) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电话">
|
||||
{{ display(requestPayload.phone) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别/年龄">
|
||||
{{ display(requestPayload.sex) }} /
|
||||
{{ display(requestPayload.age) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="医生">
|
||||
{{ display(requestPayload.doctorName) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="剂数/包数">
|
||||
{{ display(requestPayload.prescriptionNumber) }} /
|
||||
{{ display(requestPayload.packingNumber) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="加工方式">
|
||||
boilType={{ display(requestPayload.boilType) }},用法={{
|
||||
display(requestPayload.prescriptionUseMethod)
|
||||
}},isBoil={{ display(requestPayload.isBoil) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="诊断" :span="2">
|
||||
{{ display(requestPayload.clinicalDiagnosis) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="医嘱" :span="2">
|
||||
{{ display(requestPayload.doctorAdvice) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收件/配送" :span="2">
|
||||
{{ display(requestPayload.receiverName) }}
|
||||
{{ display(requestPayload.receiverPhone) }};
|
||||
{{ display(requestPayload.province)
|
||||
}}{{ display(requestPayload.city)
|
||||
}}{{ display(requestPayload.district) }}
|
||||
{{ display(requestPayload.deliverAddress) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注 remark" :span="2">
|
||||
<span class="remark-text">{{
|
||||
display(requestPayload.remark)
|
||||
}}</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div class="sub-title">药味明细({{ drugRows.length }})</div>
|
||||
<Table
|
||||
v-if="drugRows.length > 0"
|
||||
size="small"
|
||||
class="mb-3"
|
||||
:columns="drugColumns"
|
||||
:data-source="drugRows"
|
||||
:pagination="false"
|
||||
:row-key="
|
||||
(_: any, i: number) =>
|
||||
String(_?.orderNumber ?? _) + '-' + i
|
||||
"
|
||||
:scroll="{ x: 640 }"
|
||||
/>
|
||||
<div v-else class="empty-hint mb-3">无药味明细</div>
|
||||
<div class="sub-title">请求传参 JSON</div>
|
||||
<pre class="json-pre json-pre-sm">{{ requestJsonText }}</pre>
|
||||
</template>
|
||||
<div v-else class="empty-hint">
|
||||
无请求传参(常见于同步失败、尚未组出 MES 载荷)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返回预览 -->
|
||||
<div class="section-block">
|
||||
<div class="section-title">返回预览</div>
|
||||
<template v-if="hasResponse && responseInfo">
|
||||
<Descriptions bordered size="small" :column="2" class="mb-3">
|
||||
<Descriptions.Item label="是否成功">
|
||||
<Tag
|
||||
v-if="responseInfo.success === true"
|
||||
color="success"
|
||||
>
|
||||
成功
|
||||
</Tag>
|
||||
<Tag
|
||||
v-else-if="responseInfo.success === false"
|
||||
color="error"
|
||||
>
|
||||
失败
|
||||
</Tag>
|
||||
<span v-else>—</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="通道">
|
||||
{{ display(responseInfo.channel) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="业务码">
|
||||
{{ display(responseInfo.code) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="说明" :span="2">
|
||||
<span
|
||||
class="break-all"
|
||||
:class="{
|
||||
'text-destructive': responseInfo.success === false,
|
||||
}"
|
||||
>
|
||||
{{ display(responseInfo.message) }}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div class="sub-title">返回 body / raw</div>
|
||||
<pre class="json-pre json-pre-sm">{{
|
||||
prettyJson(responseInfo.body)
|
||||
}}</pre>
|
||||
</template>
|
||||
<div v-else class="empty-hint">无返回信息</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasSummary && !hasRequest && !hasResponse"
|
||||
class="empty-hint"
|
||||
>
|
||||
无法解析为结构化内容,请切换到「JSON」查看原始报文
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="json" tab="JSON">
|
||||
<div class="json-panels">
|
||||
<div class="json-panel">
|
||||
<div class="json-panel-head">
|
||||
<span class="json-title">完整 content</span>
|
||||
<Button size="small" @click="copyText(fullJsonText, '已复制完整 JSON')">
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<pre class="json-pre">{{ fullJsonText }}</pre>
|
||||
</div>
|
||||
<div class="json-panel">
|
||||
<div class="json-panel-head">
|
||||
<span class="json-title">请求传参</span>
|
||||
<Button
|
||||
size="small"
|
||||
@click="copyText(requestJsonText, '已复制请求传参')"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<pre class="json-pre">{{ requestJsonText }}</pre>
|
||||
</div>
|
||||
<div class="json-panel">
|
||||
<div class="json-panel-head">
|
||||
<span class="json-title">返回</span>
|
||||
<Button
|
||||
size="small"
|
||||
@click="copyText(responseJsonText, '已复制返回')"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<pre class="json-pre">{{ responseJsonText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-content-viewer {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
max-height: min(64vh, 720px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 8%);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.remark-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 12px 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.json-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.json-panels {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.json-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.json-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.json-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.json-pre {
|
||||
max-height: min(52vh, 560px);
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.json-pre-sm {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.text-destructive {
|
||||
color: hsl(var(--destructive, var(--warning)));
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 第三方 MES OpenAPI 传方日志详情
|
||||
* 展示 AppKey、处方号、MES 回包与完整 content JSON
|
||||
* 顶栏展示 AppKey / MES 回包摘要;完整 content 用共享 viewer(详情预览 / JSON)
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Descriptions, Tag } from 'ant-design-vue';
|
||||
|
||||
import SyncChinaOrderContentViewer from '#/views/log/components/sync-china-order-content-viewer.vue';
|
||||
|
||||
import { getMesOpenApiLogDetail } from '../api';
|
||||
|
||||
const data = ref<Record<string, any>>({});
|
||||
const contentText = computed(() => {
|
||||
const c = data.value.content;
|
||||
if (c == null) return '(无)';
|
||||
try {
|
||||
return typeof c === 'string' ? c : JSON.stringify(c, null, 2);
|
||||
} catch {
|
||||
return String(c);
|
||||
}
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
showConfirmButton: false,
|
||||
@@ -54,26 +47,28 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[90%] md:w-[800px]" title="第三方 MES 传方详情">
|
||||
<Descriptions bordered :column="1" size="small">
|
||||
<Modal class="w-[90%] md:w-[900px]" title="第三方 MES 传方详情">
|
||||
<Descriptions bordered :column="2" size="small" class="mb-3">
|
||||
<Descriptions.Item label="ID">{{ data.id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="App Key">{{ data.app_key }}</Descriptions.Item>
|
||||
<Descriptions.Item label="App ID">{{ data.app_id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="医院处方号">
|
||||
{{ data.hospital_prescription_code }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订单ID">{{ data.order_id || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单ID">
|
||||
{{ data.order_id || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">
|
||||
<Tag :color="Number(data.is_success) === 0 ? 'success' : 'error'">
|
||||
{{ data.is_success_txt }}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="MES码">{{ data.mes_code || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="MES说明">{{ data.mes_message || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{{ data.created_at }}</Descriptions.Item>
|
||||
<Descriptions.Item label="完整内容">
|
||||
<pre class="max-h-80 overflow-auto whitespace-pre-wrap break-all text-xs">{{ contentText }}</pre>
|
||||
<Descriptions.Item label="MES说明" :span="2">
|
||||
{{ data.mes_message || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<SyncChinaOrderContentViewer :content="data.content" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
/**
|
||||
* 同步中药 ERP 日志详情:顶栏元信息 + content 详情预览/JSON
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Descriptions, Tag } from 'ant-design-vue';
|
||||
|
||||
import SyncChinaOrderContentViewer from '#/views/log/components/sync-china-order-content-viewer.vue';
|
||||
|
||||
import { getSyncChinaOrderLogDetail } from '../api';
|
||||
|
||||
const data = ref<Record<string, any>>({});
|
||||
const contentText = computed(() => {
|
||||
const c = data.value.content;
|
||||
if (c == null) return '(无)';
|
||||
try {
|
||||
return typeof c === 'string' ? c : JSON.stringify(c, null, 2);
|
||||
} catch {
|
||||
return String(c);
|
||||
}
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
showConfirmButton: false,
|
||||
@@ -50,8 +46,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[90%] md:w-[720px]" title="同步中药 ERP 日志详情">
|
||||
<Descriptions bordered :column="1" size="small">
|
||||
<Modal class="w-[90%] md:w-[900px]" title="同步中药 ERP 日志详情">
|
||||
<Descriptions bordered :column="2" size="small" class="mb-3">
|
||||
<Descriptions.Item label="ID">{{ data.id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单ID">{{ data.order_id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">
|
||||
@@ -60,9 +56,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{{ data.created_at }}</Descriptions.Item>
|
||||
<Descriptions.Item label="内容">
|
||||
<pre class="max-h-80 overflow-auto whitespace-pre-wrap break-all text-xs">{{ contentText }}</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<SyncChinaOrderContentViewer :content="data.content" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -14,14 +14,23 @@ const onSuccessRef = ref<(() => void) | null>(null);
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
function buildSubmitPayload(values: Record<string, any>) {
|
||||
/**
|
||||
* 组装提交字段:市/区暂不提交快递费(字段保留,后续级联启用再开)
|
||||
* 新建子级仍写 0,避免库字段空值;编辑子级不改 express_fee
|
||||
*/
|
||||
function buildSubmitPayload(values: Record<string, any>, update: boolean) {
|
||||
const formLevel = Number(values.formLevel);
|
||||
return {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: values.id,
|
||||
name: values.name,
|
||||
pid: formLevel === 1 ? 0 : values.pid,
|
||||
express_fee: values.express_fee ?? 0,
|
||||
};
|
||||
if (formLevel === 1) {
|
||||
payload.express_fee = values.express_fee ?? 0;
|
||||
} else if (!update) {
|
||||
payload.express_fee = 0;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validateFormValues(values: Record<string, any>) {
|
||||
@@ -34,13 +43,16 @@ function validateFormValues(values: Record<string, any>) {
|
||||
message.error('请选择父级地区');
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
values.express_fee === null ||
|
||||
values.express_fee === undefined ||
|
||||
values.express_fee === ''
|
||||
) {
|
||||
message.error('请输入快递费');
|
||||
return false;
|
||||
// 仅省级要求填快递费;市/区入口已隐藏
|
||||
if (formLevel === 1) {
|
||||
if (
|
||||
values.express_fee === null ||
|
||||
values.express_fee === undefined ||
|
||||
values.express_fee === ''
|
||||
) {
|
||||
message.error('请输入快递费');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -58,7 +70,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (!validateFormValues(values)) {
|
||||
return;
|
||||
}
|
||||
const payload = buildSubmitPayload(values);
|
||||
const payload = buildSubmitPayload(values, isUpdate.value);
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateRegion : createRegion;
|
||||
submitApi(payload)
|
||||
@@ -79,10 +91,11 @@ const [Modal, modalApi] = useVbenModal({
|
||||
isUpdate.value = update;
|
||||
onSuccessRef.value = onSuccess ?? null;
|
||||
const levelLabels: Record<number, string> = {
|
||||
1: '地区',
|
||||
2: '子地区',
|
||||
1: '省级地区',
|
||||
2: '市级地区',
|
||||
3: '区县',
|
||||
};
|
||||
modalTitle.value = `${update ? '编辑' : '新增'}${levelLabels[formLevel] ?? '快递费'}`;
|
||||
modalTitle.value = `${update ? '编辑' : '新增'}${levelLabels[formLevel] ?? '地区'}`;
|
||||
formApi.resetForm();
|
||||
if (values) {
|
||||
formApi.setValues({ ...values, formLevel });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Descriptions, message } from 'ant-design-vue';
|
||||
|
||||
import { updateRegion } from '#/views/system/region/api';
|
||||
@@ -6,6 +8,7 @@ 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';
|
||||
import { getRegionLevelLabel } from '../constants';
|
||||
|
||||
const props = defineProps<{
|
||||
region: RegionRow | null | undefined;
|
||||
@@ -15,6 +18,16 @@ const emit = defineEmits<{
|
||||
updated: [patch: Partial<RegionRow>];
|
||||
}>();
|
||||
|
||||
/** 仅省级可改运费;市/区暂关闭设置入口(字段保留) */
|
||||
const canEditExpressFee = computed(() => {
|
||||
const level = Number(props.region?.level ?? 1);
|
||||
return level === 1 || !props.region?.level;
|
||||
});
|
||||
|
||||
const levelLabel = computed(() =>
|
||||
getRegionLevelLabel(props.region?.level ?? 1),
|
||||
);
|
||||
|
||||
function formatFee(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '点击设置';
|
||||
@@ -47,7 +60,7 @@ async function saveField(payload: Record<string, unknown>) {
|
||||
size="small"
|
||||
class="region-info-descriptions"
|
||||
>
|
||||
<Descriptions.Item label="级别">地区</Descriptions.Item>
|
||||
<Descriptions.Item label="级别">{{ levelLabel }}</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">
|
||||
<InlineFieldEditor
|
||||
:value="region.name"
|
||||
@@ -61,6 +74,7 @@ async function saveField(payload: Record<string, unknown>) {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="快递费" :span="2">
|
||||
<InlineFieldEditor
|
||||
v-if="canEditExpressFee"
|
||||
type="number"
|
||||
:value="region.express_fee"
|
||||
label="快递费"
|
||||
@@ -68,6 +82,9 @@ async function saveField(payload: Record<string, unknown>) {
|
||||
:display-formatter="formatFee"
|
||||
@save="(val) => saveField({ express_fee: val })"
|
||||
/>
|
||||
<span v-else class="text-muted-foreground">
|
||||
跟随省级(市/区运费暂未启用)
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
@@ -1,93 +1,99 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { getRegionList } from '#/views/system/region/api';
|
||||
|
||||
import type { RegionRow } from '../constants';
|
||||
import { matchRegionKeyword } 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 };
|
||||
function mapRow(item: Record<string, any>): RegionRow {
|
||||
return {
|
||||
id: Number(item.id),
|
||||
name: String(item.name ?? ''),
|
||||
pid: Number(item.pid ?? 0),
|
||||
code: item.code,
|
||||
level: item.level != null ? Number(item.level) : undefined,
|
||||
express_fee: item.express_fee,
|
||||
pinyin_first: item.pinyin_first,
|
||||
pinyin_full: item.pinyin_full,
|
||||
deleted_at: item.deleted_at != null ? Number(item.deleted_at) : 0,
|
||||
created_at: item.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 地区管理树:左侧省 → 右侧市 → 点击市下钻区县
|
||||
* 子级按 pid 懒加载,避免一次拉全表
|
||||
*/
|
||||
export function useRegionTree() {
|
||||
const saved = readRegionSelection();
|
||||
const loading = ref(false);
|
||||
const keyword = ref('');
|
||||
const childrenLoading = ref(false);
|
||||
// 左侧搜索记忆:刷新后恢复
|
||||
const keyword = ref(saved.level1Keyword ?? '');
|
||||
const level1List = ref<RegionRow[]>([]);
|
||||
const level2Map = ref<Record<number, RegionRow[]>>({});
|
||||
/** 当前右侧展示的子级(市或区县) */
|
||||
const currentChildren = ref<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 selectedLevel2Id = ref<number | null>(saved.level2Id);
|
||||
/** 下钻后缓存的市节点,供面包屑展示 */
|
||||
const selectedLevel2 = ref<RegionRow | null>(null);
|
||||
|
||||
const selectedLevel1 = computed(() =>
|
||||
level1List.value.find((item) => item.id === selectedLevel1Id.value),
|
||||
);
|
||||
|
||||
/** 当前右侧父节点:下钻中是市,否则是省 */
|
||||
const activeParent = computed(() => {
|
||||
if (selectedLevel2Id.value && selectedLevel2.value) {
|
||||
return selectedLevel2.value;
|
||||
}
|
||||
return selectedLevel1.value ?? null;
|
||||
});
|
||||
|
||||
const filteredLevel1 = computed(() => {
|
||||
const kw = keyword.value.trim();
|
||||
if (!kw) {
|
||||
return level1List.value;
|
||||
}
|
||||
return level1List.value.filter((item) => matchRegionKeyword(item, kw));
|
||||
});
|
||||
|
||||
function persistSelection() {
|
||||
writeRegionSelection({
|
||||
level1Id: selectedLevel1Id.value,
|
||||
level2Id: selectedLevel2Id.value,
|
||||
level1Keyword: keyword.value,
|
||||
childKeywordByParent: readRegionSelection().childKeywordByParent ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
// 左侧关键字变化时写入记忆
|
||||
watch(keyword, () => {
|
||||
persistSelection();
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取某父级下的子节点
|
||||
*/
|
||||
async function loadChildren(pid: number) {
|
||||
childrenLoading.value = true;
|
||||
try {
|
||||
const res = await getRegionList({ pid, page: 1, pageSize: 500 });
|
||||
const items = (res?.items ?? []).map(mapRow);
|
||||
currentChildren.value = items;
|
||||
return items;
|
||||
} finally {
|
||||
childrenLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始加载省级,并恢复上次选中的省/市
|
||||
*/
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -95,25 +101,68 @@ export function useRegionTree() {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
const items = res?.items ?? [];
|
||||
const parsed = parseRegionListItems(items);
|
||||
level1List.value = parsed.level1;
|
||||
level2Map.value = parsed.level2Map;
|
||||
level1List.value = (res?.items ?? []).map(mapRow);
|
||||
|
||||
const restored = restoreSelection(
|
||||
level1List.value,
|
||||
selectedLevel1Id.value,
|
||||
);
|
||||
selectedLevel1Id.value = restored.level1Id;
|
||||
let level1Id = selectedLevel1Id.value;
|
||||
if (!level1Id || !level1List.value.some((item) => item.id === level1Id)) {
|
||||
level1Id = level1List.value[0]?.id ?? null;
|
||||
}
|
||||
selectedLevel1Id.value = level1Id;
|
||||
|
||||
if (!level1Id) {
|
||||
selectedLevel2Id.value = null;
|
||||
selectedLevel2.value = null;
|
||||
currentChildren.value = [];
|
||||
persistSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// 若有记忆的市 ID:先拉省级子级找市,再下钻区县
|
||||
const cities = await loadChildren(level1Id);
|
||||
const level2Id = selectedLevel2Id.value;
|
||||
if (level2Id) {
|
||||
const city = cities.find((c) => c.id === level2Id);
|
||||
if (city) {
|
||||
selectedLevel2.value = city;
|
||||
selectedLevel2Id.value = city.id;
|
||||
await loadChildren(city.id);
|
||||
} else {
|
||||
selectedLevel2Id.value = null;
|
||||
selectedLevel2.value = null;
|
||||
}
|
||||
} else {
|
||||
selectedLevel2.value = null;
|
||||
}
|
||||
persistSelection();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectLevel1(id: number) {
|
||||
/** 选中省:右侧展示市级,退出区县下钻 */
|
||||
async function selectLevel1(id: number) {
|
||||
selectedLevel1Id.value = id;
|
||||
selectedLevel2Id.value = null;
|
||||
selectedLevel2.value = null;
|
||||
persistSelection();
|
||||
await loadChildren(id);
|
||||
}
|
||||
|
||||
/** 点击市行:下钻到区县 */
|
||||
async function drillToCity(city: RegionRow) {
|
||||
selectedLevel2Id.value = city.id;
|
||||
selectedLevel2.value = city;
|
||||
persistSelection();
|
||||
await loadChildren(city.id);
|
||||
}
|
||||
|
||||
/** 面包屑点回省:回到市级列表 */
|
||||
async function backToProvince() {
|
||||
if (!selectedLevel1Id.value) return;
|
||||
selectedLevel2Id.value = null;
|
||||
selectedLevel2.value = null;
|
||||
persistSelection();
|
||||
await loadChildren(selectedLevel1Id.value);
|
||||
}
|
||||
|
||||
function patchRegion(id: number, patch: Partial<RegionRow>) {
|
||||
@@ -122,29 +171,31 @@ export function useRegionTree() {
|
||||
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;
|
||||
}
|
||||
if (selectedLevel2.value?.id === id) {
|
||||
Object.assign(selectedLevel2.value, patch);
|
||||
}
|
||||
const found = currentChildren.value.find((item) => item.id === id);
|
||||
if (found) {
|
||||
Object.assign(found, patch);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeParent,
|
||||
backToProvince,
|
||||
childrenLoading,
|
||||
currentChildren,
|
||||
drillToCity,
|
||||
filteredLevel1,
|
||||
keyword,
|
||||
level1List,
|
||||
level2Map,
|
||||
loadData,
|
||||
loading,
|
||||
patchRegion,
|
||||
selectLevel1,
|
||||
selectedLevel1,
|
||||
selectedLevel1Id,
|
||||
selectedLevel2,
|
||||
selectedLevel2Id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { SUB_LIST_GRID_HEIGHT } from '#/views/system/shared/sub-list-grid';
|
||||
|
||||
import { getRegionLevelLabel } from '../constants';
|
||||
|
||||
export const childGridOptions: VxeGridProps = {
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
@@ -15,7 +17,9 @@ export const childGridOptions: VxeGridProps = {
|
||||
field: 'level',
|
||||
title: '级别',
|
||||
width: 80,
|
||||
formatter: () => '子地区',
|
||||
// 按行 level 映射:2市 / 3区县(不再写死「子地区」)
|
||||
formatter: ({ cellValue, row }) =>
|
||||
getRegionLevelLabel(cellValue ?? row?.level),
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
@@ -31,7 +35,8 @@ export const childGridOptions: VxeGridProps = {
|
||||
{
|
||||
field: 'express_fee',
|
||||
title: '快递费',
|
||||
width: 140,
|
||||
width: 160,
|
||||
// 市/区暂不可设置,列仍保留便于以后启用
|
||||
slots: { default: 'express_fee' },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -88,6 +88,13 @@ export const modalFormProps: VbenFormProps = {
|
||||
},
|
||||
fieldName: 'express_fee',
|
||||
label: '快递费',
|
||||
// 运费计算暂只读省级;市/区字段保留,入口先关掉
|
||||
dependencies: {
|
||||
show(values) {
|
||||
return Number(values.formLevel) === 1;
|
||||
},
|
||||
triggerFields: ['formLevel'],
|
||||
},
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
|
||||
@@ -5,5 +5,35 @@ export interface RegionRow {
|
||||
code?: string;
|
||||
level?: number;
|
||||
express_fee?: number | string;
|
||||
pinyin_first?: string;
|
||||
pinyin_full?: string;
|
||||
deleted_at?: number;
|
||||
created_at?: number | string;
|
||||
}
|
||||
|
||||
/** 层级文案:1省 2市 3区县 */
|
||||
export function getRegionLevelLabel(level?: number | string | null) {
|
||||
const n = Number(level);
|
||||
if (n === 1) return '省/地区';
|
||||
if (n === 2) return '市';
|
||||
if (n === 3) return '区县';
|
||||
return '地区';
|
||||
}
|
||||
|
||||
/** 右侧「新增子级」标题 */
|
||||
export function getRegionChildListTitle(parentLevel?: number | string | null) {
|
||||
const n = Number(parentLevel);
|
||||
if (n === 1) return '市级列表';
|
||||
if (n === 2) return '区县列表';
|
||||
return '子地区列表';
|
||||
}
|
||||
|
||||
/** 地名 / 首拼 / 全拼 合一匹配(本地过滤用) */
|
||||
export function matchRegionKeyword(row: RegionRow, keyword: string): boolean {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
if (!kw) return true;
|
||||
const name = String(row.name ?? '').toLowerCase();
|
||||
const first = String(row.pinyin_first ?? '').toLowerCase();
|
||||
const full = String(row.pinyin_full ?? '').toLowerCase();
|
||||
return name.includes(kw) || first.includes(kw) || full.includes(kw);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, watch } from 'vue';
|
||||
/**
|
||||
* 快递费/地区管理:左侧省 → 右侧市 → 点击市下钻区县
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Empty, message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { Button, Empty, Input, Spin, message } from 'ant-design-vue';
|
||||
import {
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import type { ContextMenuItem } from '#/components/context-menu';
|
||||
@@ -20,10 +27,21 @@ 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 { getRegionChildListTitle, matchRegionKeyword } from './constants';
|
||||
import { childGridOptions } from './config/child-table';
|
||||
import {
|
||||
readChildKeyword,
|
||||
writeChildKeyword,
|
||||
} from './utils/regionSelectionStorage';
|
||||
|
||||
defineOptions({ name: 'SystemRegion' });
|
||||
|
||||
const {
|
||||
activeParent,
|
||||
backToProvince,
|
||||
childrenLoading,
|
||||
currentChildren,
|
||||
drillToCity,
|
||||
filteredLevel1,
|
||||
keyword,
|
||||
level1List,
|
||||
@@ -33,8 +51,47 @@ const {
|
||||
selectLevel1,
|
||||
selectedLevel1,
|
||||
selectedLevel1Id,
|
||||
selectedLevel2,
|
||||
selectedLevel2Id,
|
||||
} = useRegionTree();
|
||||
|
||||
/**
|
||||
* 右侧子级搜索:按当前省/市记忆关键字,切换回来仍保留
|
||||
*/
|
||||
const childKeyword = ref(
|
||||
readChildKeyword(selectedLevel1Id.value, selectedLevel2Id.value),
|
||||
);
|
||||
|
||||
/** 是否处于「市→区县」下钻态 */
|
||||
const isDrilledToCity = computed(() => !!selectedLevel2Id.value);
|
||||
|
||||
const childListTitle = computed(() =>
|
||||
getRegionChildListTitle(activeParent.value?.level ?? 1),
|
||||
);
|
||||
|
||||
/** 右侧过滤后的市/区县列表 */
|
||||
const filteredChildren = computed(() => {
|
||||
const kw = childKeyword.value.trim();
|
||||
if (!kw) {
|
||||
return currentChildren.value;
|
||||
}
|
||||
return currentChildren.value.filter((item) => matchRegionKeyword(item, kw));
|
||||
});
|
||||
|
||||
const childListSubtitle = computed(() => {
|
||||
const total = currentChildren.value.length;
|
||||
const shown = filteredChildren.value.length;
|
||||
if (childKeyword.value.trim() && shown !== total) {
|
||||
return `显示 ${shown} / 共 ${total} 条`;
|
||||
}
|
||||
return `共 ${total} 条`;
|
||||
});
|
||||
|
||||
/** 新增子级的目标层级:省下加市(2),市下加区(3) */
|
||||
const createChildLevel = computed(() =>
|
||||
isDrilledToCity.value ? 3 : 2,
|
||||
);
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
@@ -51,29 +108,40 @@ const [ChildGrid, childGridApi] = useVbenVxeGrid({
|
||||
});
|
||||
|
||||
function syncChildGrid() {
|
||||
childGridApi.setGridOptions({ data: currentChildren.value });
|
||||
childGridApi.setGridOptions({ data: filteredChildren.value });
|
||||
}
|
||||
|
||||
/** 从记忆恢复当前父节点下的搜索词 */
|
||||
function restoreChildKeyword() {
|
||||
childKeyword.value = readChildKeyword(
|
||||
selectedLevel1Id.value,
|
||||
selectedLevel2Id.value,
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
restoreChildKeyword();
|
||||
await loadData();
|
||||
syncChildGrid();
|
||||
});
|
||||
|
||||
watch(currentChildren, syncChildGrid, { deep: true });
|
||||
watch(selectedLevel1Id, syncChildGrid);
|
||||
watch(filteredChildren, syncChildGrid, { deep: true });
|
||||
watch([selectedLevel1Id, selectedLevel2Id], () => {
|
||||
restoreChildKeyword();
|
||||
syncChildGrid();
|
||||
});
|
||||
watch(childKeyword, (val) => {
|
||||
writeChildKeyword(
|
||||
selectedLevel1Id.value,
|
||||
selectedLevel2Id.value,
|
||||
val,
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -86,10 +154,10 @@ function openCreateModal(options: {
|
||||
if (!isUpdate) {
|
||||
if (formLevel === 1) {
|
||||
formValues = { pid: 0, formLevel: 1, express_fee: 0 };
|
||||
} else if (formLevel === 2 && parentRow) {
|
||||
} else if (parentRow) {
|
||||
formValues = {
|
||||
pid: parentRow.id,
|
||||
formLevel: 2,
|
||||
formLevel,
|
||||
express_fee: 0,
|
||||
};
|
||||
}
|
||||
@@ -113,18 +181,78 @@ function openMoveModal(row: RegionRow) {
|
||||
moveModalApi.open();
|
||||
}
|
||||
|
||||
/** 解析行所属表单层级,供编辑弹窗用 */
|
||||
function resolveRowFormLevel(row: RegionRow) {
|
||||
if (row.level != null && Number(row.level) > 0) {
|
||||
return Number(row.level);
|
||||
}
|
||||
return isDrilledToCity.value ? 3 : 2;
|
||||
}
|
||||
|
||||
/** 右键打开编辑弹窗(改名称/快递费) */
|
||||
function openEditModal(row: RegionRow) {
|
||||
const formLevel = resolveRowFormLevel(row);
|
||||
openCreateModal({
|
||||
formLevel,
|
||||
isUpdate: true,
|
||||
values: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
pid: row.pid,
|
||||
express_fee: row.express_fee ?? 0,
|
||||
formLevel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildLevel1Menus(row: RegionRow): ContextMenuItem[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit-name',
|
||||
label: '编辑名称',
|
||||
handler: () =>
|
||||
openCreateModal({
|
||||
formLevel: 1,
|
||||
isUpdate: true,
|
||||
values: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
pid: 0,
|
||||
express_fee: row.express_fee ?? 0,
|
||||
formLevel: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'add-child',
|
||||
label: '新增子地区',
|
||||
label: '新增市级',
|
||||
handler: () => openCreateModal({ formLevel: 2, parentRow: row }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildChildMenus(row: RegionRow): ContextMenuItem[] {
|
||||
return [
|
||||
const menus: ContextMenuItem[] = [
|
||||
{
|
||||
key: 'edit-name',
|
||||
label: '编辑名称',
|
||||
handler: () => openEditModal(row),
|
||||
},
|
||||
];
|
||||
// 市级可新增区县;区县为末级不再加子级
|
||||
if (Number(row.level) === 2 || (!row.level && !isDrilledToCity.value)) {
|
||||
menus.push({
|
||||
key: 'add-child',
|
||||
label: '新增区县',
|
||||
handler: () => openCreateModal({ formLevel: 3, parentRow: row }),
|
||||
});
|
||||
menus.push({
|
||||
key: 'drill',
|
||||
label: '查看区县',
|
||||
handler: () => onDrillCity(row),
|
||||
});
|
||||
}
|
||||
menus.push(
|
||||
{
|
||||
key: 'move-pid',
|
||||
label: '移动父级',
|
||||
@@ -139,7 +267,8 @@ function buildChildMenus(row: RegionRow): ContextMenuItem[] {
|
||||
await refresh();
|
||||
},
|
||||
},
|
||||
];
|
||||
);
|
||||
return menus;
|
||||
}
|
||||
|
||||
function onLevel1ContextMenu(e: MouseEvent, row: TreeSidebarRow) {
|
||||
@@ -159,40 +288,53 @@ async function saveLevel1Name(row: TreeSidebarRow, name: string) {
|
||||
|
||||
async function saveChildField(
|
||||
row: RegionRow,
|
||||
field: 'express_fee' | 'name',
|
||||
field: '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 },
|
||||
);
|
||||
await updateRegion({ id: row.id, name: value });
|
||||
patchRegion(row.id, { name: String(value) });
|
||||
message.success('保存成功');
|
||||
}
|
||||
|
||||
function onSelectLevel1(id: number) {
|
||||
selectLevel1(id);
|
||||
async function onSelectLevel1(id: number) {
|
||||
await selectLevel1(id);
|
||||
syncChildGrid();
|
||||
}
|
||||
|
||||
async function onDrillCity(row: RegionRow) {
|
||||
await drillToCity(row);
|
||||
syncChildGrid();
|
||||
}
|
||||
|
||||
async function onBreadcrumbProvince() {
|
||||
await backToProvince();
|
||||
syncChildGrid();
|
||||
}
|
||||
|
||||
function openCreateChild() {
|
||||
if (!selectedLevel1.value) {
|
||||
const parent = activeParent.value;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
openCreateModal({ formLevel: 2, parentRow: selectedLevel1.value });
|
||||
openCreateModal({
|
||||
formLevel: createChildLevel.value,
|
||||
parentRow: parent,
|
||||
});
|
||||
}
|
||||
|
||||
/** 市一级名称可点下钻;区县保持行内编辑 */
|
||||
function canDrillRow(row: RegionRow) {
|
||||
if (isDrilledToCity.value) {
|
||||
return false;
|
||||
}
|
||||
return Number(row.level) === 2 || !row.level;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page
|
||||
auto-content-height
|
||||
description="右键地区或子地区行可新增/移动;点击虚线下划线字段可编辑"
|
||||
description="左选省、右看市;点击市名称下钻区县。右键可编辑名称/新增/移动;区县页可点返回"
|
||||
title="快递费管理"
|
||||
>
|
||||
<FormModal />
|
||||
@@ -205,7 +347,7 @@ function openCreateChild() {
|
||||
:filtered-level1="filteredLevel1"
|
||||
:keyword="keyword"
|
||||
:loading="loading"
|
||||
search-placeholder="搜索地区名称"
|
||||
search-placeholder="地名/首拼/全拼"
|
||||
:selected-level1-id="selectedLevel1Id"
|
||||
title="地区"
|
||||
:total-count="level1List.length"
|
||||
@@ -221,22 +363,63 @@ function openCreateChild() {
|
||||
>
|
||||
<template v-if="selectedLevel1">
|
||||
<div class="space-y-4">
|
||||
<!-- nowrap:面包屑与搜索始终同一行,窄宽时左侧省略 -->
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3"
|
||||
class="flex flex-nowrap 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 class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<Button
|
||||
v-if="isDrilledToCity"
|
||||
class="shrink-0"
|
||||
size="small"
|
||||
@click="onBreadcrumbProvince"
|
||||
>
|
||||
<LeftOutlined />
|
||||
返回
|
||||
</Button>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-1 overflow-hidden text-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="truncate font-medium text-primary hover:underline"
|
||||
:class="{
|
||||
'cursor-default text-foreground no-underline hover:no-underline':
|
||||
!isDrilledToCity,
|
||||
}"
|
||||
@click="isDrilledToCity && onBreadcrumbProvince()"
|
||||
>
|
||||
{{ selectedLevel1.name }}
|
||||
</button>
|
||||
<template v-if="selectedLevel2">
|
||||
<span class="shrink-0 text-muted-foreground">/</span>
|
||||
<span class="truncate font-medium text-foreground">{{
|
||||
selectedLevel2.name
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 外层定宽:affix Input 默认 width:100%,直接挂 w-44 容易被撑满整行 -->
|
||||
<div class="w-44 shrink-0">
|
||||
<Input
|
||||
v-model:value="childKeyword"
|
||||
allow-clear
|
||||
class="!w-full"
|
||||
placeholder="地名/首拼/全拼"
|
||||
size="small"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined class="text-muted-foreground" />
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegionInfoBar
|
||||
:region="selectedLevel1"
|
||||
:region="activeParent || selectedLevel1"
|
||||
@updated="
|
||||
(patch) => patchRegion(selectedLevel1!.id, patch)
|
||||
(patch) =>
|
||||
patchRegion((activeParent || selectedLevel1)!.id, patch)
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -244,9 +427,9 @@ function openCreateChild() {
|
||||
class="rounded-lg border border-border dark:border-gray-700"
|
||||
>
|
||||
<ConfigSectionHeader
|
||||
class="mb-3 px-4 pt-3"
|
||||
:subtitle="`共 ${currentChildren.length} 条`"
|
||||
title="子地区列表"
|
||||
class="mb-0 px-4 py-3"
|
||||
:subtitle="childListSubtitle"
|
||||
:title="childListTitle"
|
||||
>
|
||||
<template #actions>
|
||||
<Button size="small" type="primary" @click="openCreateChild">
|
||||
@@ -256,41 +439,63 @@ function openCreateChild() {
|
||||
</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>
|
||||
<Spin :spinning="childrenLoading">
|
||||
<div
|
||||
v-if="filteredChildren.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)">
|
||||
<!-- 市级:点击名称下钻;区县:行内改名 -->
|
||||
<button
|
||||
v-if="canDrillRow(row)"
|
||||
type="button"
|
||||
class="text-left text-primary hover:underline"
|
||||
@click.stop="onDrillCity(row)"
|
||||
>
|
||||
{{ row.name }}
|
||||
</button>
|
||||
<InlineFieldEditor
|
||||
v-else
|
||||
:value="row.name"
|
||||
@save="(val) => saveChildField(row, 'name', val)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<template #express_fee="{ row }">
|
||||
<!-- 市/区运费字段保留,设置入口暂关:算运费目前只读省级 -->
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
@contextmenu="onChildContextMenu($event, row)"
|
||||
>
|
||||
跟随省级
|
||||
</span>
|
||||
</template>
|
||||
</ChildGrid>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center px-4 py-8"
|
||||
>
|
||||
<Empty
|
||||
:description="
|
||||
currentChildren.length
|
||||
? '无匹配结果'
|
||||
: `暂无${childListTitle}`
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-if="!currentChildren.length"
|
||||
class="mt-4"
|
||||
type="primary"
|
||||
@click="openCreateChild"
|
||||
>
|
||||
<PlusOutlined />
|
||||
新增子地区
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
const STORAGE_KEY = 'region-management-selection';
|
||||
|
||||
/** 地区管理页选中态 + 右侧子级搜索记忆 */
|
||||
export interface RegionSelectionState {
|
||||
level1Id: number | null;
|
||||
level2Id: number | null;
|
||||
/**
|
||||
* 右侧搜索关键字,按「省:市」记忆
|
||||
* key 形如 `33:0`(省级市列表)或 `33:100000`(某市下区县)
|
||||
*/
|
||||
childKeywordByParent?: Record<string, string>;
|
||||
/** 左侧省列表搜索关键字 */
|
||||
level1Keyword?: string;
|
||||
}
|
||||
|
||||
/** 拼右侧搜索记忆 key */
|
||||
export function childKeywordStorageKey(
|
||||
level1Id: number | null,
|
||||
level2Id: number | null,
|
||||
) {
|
||||
return `${level1Id ?? 0}:${level2Id ?? 0}`;
|
||||
}
|
||||
|
||||
export function readRegionSelection(): RegionSelectionState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { level1Id: null };
|
||||
return { level1Id: null, level2Id: null };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<RegionSelectionState & { childId?: number }>;
|
||||
const parsed = JSON.parse(raw) as Partial<RegionSelectionState>;
|
||||
return {
|
||||
level1Id:
|
||||
parsed.level1Id != null && Number.isFinite(Number(parsed.level1Id))
|
||||
? Number(parsed.level1Id)
|
||||
: null,
|
||||
level2Id:
|
||||
parsed.level2Id != null && Number.isFinite(Number(parsed.level2Id))
|
||||
? Number(parsed.level2Id)
|
||||
: null,
|
||||
childKeywordByParent:
|
||||
parsed.childKeywordByParent &&
|
||||
typeof parsed.childKeywordByParent === 'object'
|
||||
? parsed.childKeywordByParent
|
||||
: {},
|
||||
level1Keyword:
|
||||
typeof parsed.level1Keyword === 'string' ? parsed.level1Keyword : '',
|
||||
};
|
||||
} catch {
|
||||
return { level1Id: null };
|
||||
return { level1Id: null, level2Id: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +55,40 @@ export function writeRegionSelection(state: RegionSelectionState) {
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
level1Id: state.level1Id,
|
||||
level2Id: state.level2Id,
|
||||
childKeywordByParent: state.childKeywordByParent ?? {},
|
||||
level1Keyword: state.level1Keyword ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取某父节点下记住的右侧搜索词 */
|
||||
export function readChildKeyword(
|
||||
level1Id: number | null,
|
||||
level2Id: number | null,
|
||||
) {
|
||||
const state = readRegionSelection();
|
||||
const key = childKeywordStorageKey(level1Id, level2Id);
|
||||
return state.childKeywordByParent?.[key] ?? '';
|
||||
}
|
||||
|
||||
/** 写入某父节点下的右侧搜索词(合并进现有 selection) */
|
||||
export function writeChildKeyword(
|
||||
level1Id: number | null,
|
||||
level2Id: number | null,
|
||||
keyword: string,
|
||||
) {
|
||||
const state = readRegionSelection();
|
||||
const key = childKeywordStorageKey(level1Id, level2Id);
|
||||
const map = { ...(state.childKeywordByParent ?? {}) };
|
||||
const trimmed = keyword.trim();
|
||||
if (trimmed) {
|
||||
map[key] = keyword;
|
||||
} else {
|
||||
delete map[key];
|
||||
}
|
||||
writeRegionSelection({
|
||||
...state,
|
||||
childKeywordByParent: map,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,10 +38,11 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- h-full + min-h-0:高度跟父级 flex 列对齐,列表在剩余区域内滚动,避免撑破左侧容器 -->
|
||||
<aside
|
||||
class="flex w-[240px] shrink-0 flex-col rounded-lg border border-border bg-card p-3"
|
||||
class="flex h-full min-h-0 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">
|
||||
<div class="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-foreground">{{ title }}</span>
|
||||
<Badge
|
||||
:count="totalCount"
|
||||
@@ -51,7 +52,12 @@ const emit = defineEmits<{
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button block class="mb-3" type="primary" @click="emit('addLevel1')">
|
||||
<Button
|
||||
block
|
||||
class="mb-3 shrink-0"
|
||||
type="primary"
|
||||
@click="emit('addLevel1')"
|
||||
>
|
||||
<PlusOutlined />
|
||||
{{ addLabel }}
|
||||
</Button>
|
||||
@@ -59,7 +65,7 @@ const emit = defineEmits<{
|
||||
<Input
|
||||
:value="keyword"
|
||||
allow-clear
|
||||
class="mb-3"
|
||||
class="mb-3 shrink-0"
|
||||
:placeholder="searchPlaceholder"
|
||||
@update:value="(val) => emit('update:keyword', val ?? '')"
|
||||
>
|
||||
@@ -68,33 +74,37 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<div
|
||||
v-if="filteredLevel1.length"
|
||||
class="flex max-h-[calc(100vh-300px)] flex-col gap-1 overflow-auto"
|
||||
>
|
||||
<!-- 列表占满侧栏剩余高度,内部滚动;不再用 100vh 估算 -->
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<Spin :spinning="loading">
|
||||
<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)"
|
||||
v-if="filteredLevel1.length"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<Empty v-else :description="emptyText" />
|
||||
</Spin>
|
||||
<Empty v-else :description="emptyText" />
|
||||
</Spin>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user