feat: 地区选择器改走接口、订单市价重算与资金流水、同步日志内容复用

region-address-picker 接 region-picker API;订单支持按进价重算 market_price、追踪页资金流水;MES/ERP/中台同步日志共用内容查看组件;地区管理支持 pid 下钻与拼音检索。
This commit is contained in:
李琦
2026-10-09 10:02:33 +08:00
parent 87c480f391
commit 8df1ffb80a
26 changed files with 2256 additions and 1109 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@vben/desktop",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)",
"author": "萧康云医",

View 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(',') },
},
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ? '是' : '否' }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' },
},
],

View File

@@ -88,6 +88,13 @@ export const modalFormProps: VbenFormProps = {
},
fieldName: 'express_fee',
label: '快递费',
// 运费计算暂只读省级;市/区字段保留,入口先关掉
dependencies: {
show(values) {
return Number(values.formLevel) === 1;
},
triggerFields: ['formLevel'],
},
},
],
showDefaultActions: false,

View File

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

View File

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

View File

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

View File

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